feat(scenes): sortowanie losowe (seed, okno po PK) i po dlugosci (indeks partial), zgloszenie ae6a045b
- random: md5(seed) -> uuid startu, okno 2000 id (8000 dla wertykalow) po pk_scenes, filtry na oknie; bez okna planer robil Merge Anti Join po 2,3 mln wierszy (7,6 s) - duration_desc/asc: bez NULL, do 10 h, indeks ix_scenes_duration (migracja 0035) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
d5fb3fb521
commit
be6c18b3b4
2 changed files with 87 additions and 2 deletions
35
alembic/versions/20260914_0035_scenes_duration_idx.py
Normal file
35
alembic/versions/20260914_0035_scenes_duration_idx.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
"""Indeks pod sortowanie scen po dlugosci (najdluzsze / najkrotsze).
|
||||
|
||||
Jeden indeks (duration_sec, created_at, id) WHERE duration_sec IS NOT NULL obsluguje
|
||||
oba kierunki: skan w przod daje najkrotsze, w tyl najdluzsze. Sceny bez dlugosci sa
|
||||
odfiltrowane w zapytaniu, wiec kolejnosc NULL-i nie gra roli i wystarczy jeden indeks.
|
||||
|
||||
CONCURRENTLY w autocommit_block, jak w 0027: zwykly CREATE INDEX blokowalby zapisy
|
||||
ingestu na czas budowy (4,1 mln wierszy). Nieudany CONCURRENTLY zostawia indeks
|
||||
INVALID; wtedy DROP INDEX i ponownie.
|
||||
|
||||
Revision ID: 0035_scenes_duration_idx
|
||||
Revises: 0034_favorite_sites
|
||||
Create Date: 2026-09-14
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "0035_scenes_duration_idx"
|
||||
down_revision: str | None = "0034_favorite_sites"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.get_context().autocommit_block():
|
||||
op.execute(
|
||||
"CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_scenes_duration "
|
||||
"ON scenes (duration_sec, created_at, id) WHERE duration_sec IS NOT NULL"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.get_context().autocommit_block():
|
||||
op.execute("DROP INDEX CONCURRENTLY IF EXISTS ix_scenes_duration")
|
||||
|
|
@ -3,6 +3,7 @@ from __future__ import annotations
|
|||
|
||||
import logging
|
||||
import re
|
||||
import hashlib
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
|
|
@ -40,7 +41,15 @@ log = logging.getLogger(__name__)
|
|||
router = APIRouter(prefix="/scenes", tags=["scenes"], dependencies=[Depends(require_api_key)])
|
||||
|
||||
|
||||
_VALID_SORTS = {"created_at", "release_date", "title", "studio"}
|
||||
_VALID_SORTS = {
|
||||
"created_at",
|
||||
"release_date",
|
||||
"title",
|
||||
"studio",
|
||||
"random",
|
||||
"duration_desc",
|
||||
"duration_asc",
|
||||
}
|
||||
|
||||
|
||||
def _effective_date_desc():
|
||||
|
|
@ -277,7 +286,15 @@ def list_scenes(
|
|||
"JAV to osobna sekcja w apce, nie zalewa głównego feedu (origin javflix/javguru/vjav/supjav)."
|
||||
),
|
||||
),
|
||||
sort: str = Query(default="created_at", description="created_at|release_date|title|studio"),
|
||||
sort: str = Query(
|
||||
default="created_at",
|
||||
description="created_at|release_date|title|studio|random|duration_desc|duration_asc",
|
||||
),
|
||||
seed: str | None = Query(
|
||||
default=None,
|
||||
max_length=64,
|
||||
description="Dla sort=random: ten sam seed daje ten sam porzadek na kolejnych stronach.",
|
||||
),
|
||||
page: int = Query(default=1, ge=1),
|
||||
per_page: int = Query(default=50, ge=1, le=200),
|
||||
) -> SceneListOut:
|
||||
|
|
@ -480,6 +497,39 @@ def list_scenes(
|
|||
Scene.id.desc(),
|
||||
)
|
||||
)
|
||||
elif sort in ("duration_desc", "duration_asc"):
|
||||
# Najdluzsze / najkrotsze (zgloszenie ae6a045b). Sceny bez dlugosci odpadaja,
|
||||
# dzieki czemu jeden indeks (duration_sec, created_at, id) WHERE duration_sec
|
||||
# IS NOT NULL obsluguje oba kierunki skanem w przod i w tyl (migracja 0035).
|
||||
dur = Scene.duration_sec.desc() if sort == "duration_desc" else Scene.duration_sec.asc()
|
||||
created = Scene.created_at.desc() if sort == "duration_desc" else Scene.created_at.asc()
|
||||
ident = Scene.id.desc() if sort == "duration_desc" else Scene.id.asc()
|
||||
# Gorny limit 10 h: tuby raportuja czasem absurdy (rekord 130 h), ktore
|
||||
# zasmiecalyby szczyt listy „najdluzsze".
|
||||
ordered = (
|
||||
base.where(Scene.duration_sec.isnot(None), Scene.duration_sec <= 36000)
|
||||
.order_by(dur, created, ident)
|
||||
)
|
||||
elif sort == "random":
|
||||
# Losowo bez sortowania milionow wierszy: id to losowy UUID, wiec porzadek po id
|
||||
# od losowego punktu startu JEST losowy i idzie po indeksie PK. Punkt startu
|
||||
# wynika z seeda (md5 -> uuid), wiec paginacja jest stabilna, a nowy seed
|
||||
# (pull-to-refresh w apce) daje nowe rozdanie. Za koncem zakresu lista sie konczy.
|
||||
start = uuid.UUID(hashlib.md5((seed or uuid.uuid4().hex).encode("utf-8")).hexdigest())
|
||||
# Okno kandydatow po PK z LIMIT, a filtry (EXISTS/NOT EXISTS) dopiero na nim.
|
||||
# Bez okna planer robil Merge Anti Join z indeksem playback_sources po scene_id
|
||||
# i przechodzil 2,3 mln wierszy (7,6 s), bo ORDER BY id pasowal do obu indeksow.
|
||||
# Z oknem 2000 id to nested loop po kilkudziesieciu wierszach (pomiar 2026-09-14).
|
||||
# Okno rosnie ze strona, zeby glebsze strony mialy z czego wybierac. Sekcje
|
||||
# wertykalne (JAV, Anime) to ~2% katalogu, wiec dostaja 4x szersze okno; z 2000 id
|
||||
# JAV wyciagal 27 scen i druga strona bylaby pusta. Szersze okno dla wszystkich
|
||||
# nie wchodzi w gre: z filtrem tagu 6000 id to 2,7 s (probe scene_tags per scena).
|
||||
base_window = 8000 if (vertical or jav) else 2000
|
||||
window = max(base_window, page * per_page * 4)
|
||||
cand = (
|
||||
select(Scene.id).where(Scene.id > start).order_by(Scene.id.asc()).limit(window).subquery()
|
||||
)
|
||||
ordered = base.where(Scene.id.in_(select(cand.c.id))).order_by(Scene.id.asc())
|
||||
else: # created_at
|
||||
ordered = base.order_by(
|
||||
Scene.created_at.desc(), Scene.release_date.desc().nullslast(), Scene.id.desc()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue