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:
goon-foss 2026-09-14 15:13:13 +02:00
parent d5fb3fb521
commit be6c18b3b4
2 changed files with 87 additions and 2 deletions

View 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")

View file

@ -3,6 +3,7 @@ from __future__ import annotations
import logging import logging
import re import re
import hashlib
import uuid import uuid
from typing import Annotated from typing import Annotated
@ -40,7 +41,15 @@ log = logging.getLogger(__name__)
router = APIRouter(prefix="/scenes", tags=["scenes"], dependencies=[Depends(require_api_key)]) 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(): 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)." "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), page: int = Query(default=1, ge=1),
per_page: int = Query(default=50, ge=1, le=200), per_page: int = Query(default=50, ge=1, le=200),
) -> SceneListOut: ) -> SceneListOut:
@ -480,6 +497,39 @@ def list_scenes(
Scene.id.desc(), 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 else: # created_at
ordered = base.order_by( ordered = base.order_by(
Scene.created_at.desc(), Scene.release_date.desc().nullslast(), Scene.id.desc() Scene.created_at.desc(), Scene.release_date.desc().nullslast(), Scene.id.desc()