diff --git a/alembic/versions/20260914_0035_scenes_duration_idx.py b/alembic/versions/20260914_0035_scenes_duration_idx.py new file mode 100644 index 0000000..28e3bb3 --- /dev/null +++ b/alembic/versions/20260914_0035_scenes_duration_idx.py @@ -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") diff --git a/app/api/scenes.py b/app/api/scenes.py index 784fe8e..54f7a4a 100644 --- a/app/api/scenes.py +++ b/app/api/scenes.py @@ -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()