diff --git a/app/api/scenes.py b/app/api/scenes.py index 265a0f1..7bf1ad9 100644 --- a/app/api/scenes.py +++ b/app/api/scenes.py @@ -404,7 +404,7 @@ def list_scenes( total = (page - 1) * per_page + len(rows) total_capped = has_more - items = _build_scenes_out_batch(session, list(rows)) + items = _build_scenes_out_batch(session, list(rows), light=True) return SceneListOut( items=items, @@ -447,12 +447,19 @@ def _wrap_image_proxy(url: str, referer: str) -> str: return f"/proxy/img/{token}/img.{ext}" -def _build_scenes_out_batch(session: Session, scenes: list[Scene]) -> list[SceneOut]: +def _build_scenes_out_batch( + session: Session, scenes: list[Scene], *, light: bool = False +) -> list[SceneOut]: """Batch-fetch wszystkich relacji dla N scen w 7 zapytaniach (zamiast 7×N). Eliminuje N+1 z `_build_scene_out` w listach scen — `/scenes?per_page=24` szło z ~9.6s do <500ms. Pojedyncza scena (`/scenes/{id}`) nadal używa `_build_scene_out` bo overhead na batch nie ma sensu dla N=1. + + `light=True` (listy/grid): pomija `tags` i `external_refs` (kafelek SceneTile ich + NIE używa, a SceneDetail re-fetchuje pełną scenę osobno) i ślimaczy `playback_sources` + do 1 wpisu z samą miniaturką (kafelek czyta tylko thumbnail_url/animated_thumbnail_url). + Mniej DB + mniej payloadu + szybszy parse na kliencie (perf 2026-06-07). """ from collections import defaultdict if not scenes: @@ -488,50 +495,95 @@ def _build_scenes_out_batch(session: Session, scenes: list[Scene]) -> list[Scene ) ) - # 3) Tags - tag_rows = session.execute( - select(SceneTag.scene_id, Tag) - .join(Tag, Tag.id == SceneTag.tag_id) - .where(SceneTag.scene_id.in_(scene_ids)) - ).all() + # 3) Tags + 4) External refs — kafelek listy ich nie używa; w light mode pomijamy + # (SceneDetail re-fetchuje pełną scenę przez /scenes/{id}). tags_by_scene: dict = defaultdict(list) - for sid, t in tag_rows: - tags_by_scene[sid].append(TagOut.model_validate(t)) - - # 4) External refs + sources - ref_rows = session.execute( - select(SceneExternalRef, Source) - .join(Source, Source.id == SceneExternalRef.source_id) - .where(SceneExternalRef.scene_id.in_(scene_ids)) - ).all() refs_by_scene: dict = defaultdict(list) - for ref, src in ref_rows: - refs_by_scene[ref.scene_id].append( - ExternalRefOut( - source=src.name, - external_id=ref.external_id, - url=ref.url, - last_seen=ref.last_seen, - ) - ) + if not light: + tag_rows = session.execute( + select(SceneTag.scene_id, Tag) + .join(Tag, Tag.id == SceneTag.tag_id) + .where(SceneTag.scene_id.in_(scene_ids)) + ).all() + for sid, t in tag_rows: + tags_by_scene[sid].append(TagOut.model_validate(t)) - # 5) Playback sources - pb_rows = session.execute( - select(PlaybackSource) - .where( - PlaybackSource.scene_id.in_(scene_ids), - PlaybackSource.dead_at.is_(None), - ) - .order_by(PlaybackSource.origin.asc()) - ).scalars().all() + ref_rows = session.execute( + select(SceneExternalRef, Source) + .join(Source, Source.id == SceneExternalRef.source_id) + .where(SceneExternalRef.scene_id.in_(scene_ids)) + ).all() + for ref, src in ref_rows: + refs_by_scene[ref.scene_id].append( + ExternalRefOut( + source=src.name, + external_id=ref.external_id, + url=ref.url, + last_seen=ref.last_seen, + ) + ) + + # 5) Playback sources. Light mode: tylko miniaturka (jedna na scenę) — kafelek + # czyta wyłącznie playback_sources[].thumbnail_url / animated_thumbnail_url. pb_by_scene: dict = defaultdict(list) - for p in pb_rows: - out = PlaybackSourceOut.model_validate(p) - if out.thumbnail_url and _needs_proxy(out.thumbnail_url): - out.thumbnail_url = _wrap_image_proxy(out.thumbnail_url, p.page_url) - if out.animated_thumbnail_url and _needs_proxy(out.animated_thumbnail_url): - out.animated_thumbnail_url = _wrap_image_proxy(out.animated_thumbnail_url, p.page_url) - pb_by_scene[p.scene_id].append(out) + if light: + pb_light = session.execute( + select( + PlaybackSource.scene_id, + PlaybackSource.thumbnail_url, + PlaybackSource.animated_thumbnail_url, + PlaybackSource.page_url, + ) + .where( + PlaybackSource.scene_id.in_(scene_ids), + PlaybackSource.dead_at.is_(None), + ) + .order_by(PlaybackSource.origin.asc()) + ).all() + # Pierwsza miniaturka + pierwszy animated per scena (1 slim wpis). + thumb_by_scene: dict = {} + anim_by_scene: dict = {} + for sid, thumb, anim, page_url in pb_light: + if sid not in thumb_by_scene and thumb: + thumb_by_scene[sid] = (thumb, page_url) + if sid not in anim_by_scene and anim: + anim_by_scene[sid] = (anim, page_url) + for sid in scene_ids: + t = thumb_by_scene.get(sid) + a = anim_by_scene.get(sid) + if not t and not a: + continue + t_url = t[0] if t else None + a_url = a[0] if a else None + ref = (t or a)[1] + if t_url and _needs_proxy(t_url): + t_url = _wrap_image_proxy(t_url, ref) + if a_url and _needs_proxy(a_url): + a_url = _wrap_image_proxy(a_url, ref) + # id/origin/page_url wymagane przez schemat ale nieużywane przez kafelek + # (SceneDetail re-fetchuje pełne źródła) — dummy sentinel. + pb_by_scene[sid].append( + PlaybackSourceOut( + id=uuid.UUID(int=0), origin="", page_url="", + thumbnail_url=t_url, animated_thumbnail_url=a_url, + ) + ) + else: + pb_rows = session.execute( + select(PlaybackSource) + .where( + PlaybackSource.scene_id.in_(scene_ids), + PlaybackSource.dead_at.is_(None), + ) + .order_by(PlaybackSource.origin.asc()) + ).scalars().all() + for p in pb_rows: + out = PlaybackSourceOut.model_validate(p) + if out.thumbnail_url and _needs_proxy(out.thumbnail_url): + out.thumbnail_url = _wrap_image_proxy(out.thumbnail_url, p.page_url) + if out.animated_thumbnail_url and _needs_proxy(out.animated_thumbnail_url): + out.animated_thumbnail_url = _wrap_image_proxy(out.animated_thumbnail_url, p.page_url) + pb_by_scene[p.scene_id].append(out) # 6) Progress progress_by_scene: dict = {}