perf(api): lista filmow serializowana wsadowo (7 zapytan/film -> 8 na strone), /sources z agregatem w pamieci na 5 min

- GET /movies 371 -> 82 ms, z has_playback 477 -> 209 ms; GET /sources 604 -> 8 ms
- VACUUM ANALYZE tabel filmow po tygodniu przemialu (11k martwych wierszy w movies)
This commit is contained in:
goon-foss 2026-09-08 10:04:01 +02:00
parent 44842478ae
commit 385aeb3ddb
2 changed files with 167 additions and 110 deletions

View file

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import uuid import uuid
from collections import defaultdict
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
@ -132,8 +133,8 @@ def list_movies(
base = base.limit(per_page).offset((page - 1) * per_page) base = base.limit(per_page).offset((page - 1) * per_page)
movies = session.execute(base).scalars().all() movies = list(session.execute(base).scalars().all())
items = [_movie_to_out(session, m, device_id=device_id) for m in movies] items = _movies_to_out(session, movies, device_id=device_id)
return MovieListOut(items=items, total=total, page=page, per_page=per_page) return MovieListOut(items=items, total=total, page=page, per_page=per_page)
@ -225,63 +226,108 @@ def get_movie(
def _movie_to_out(session: Session, movie: Movie, *, device_id: str = LEGACY_DEVICE) -> MovieOut: def _movie_to_out(session: Session, movie: Movie, *, device_id: str = LEGACY_DEVICE) -> MovieOut:
studio_out: StudioOut | None = None return _movies_to_out(session, [movie], device_id=device_id)[0]
if movie.studio_id:
studio = session.get(Studio, movie.studio_id)
if studio is not None:
studio_out = StudioOut.model_validate(studio)
performer_rows = session.execute(
select(Performer, MoviePerformer.as_alias) def _movies_to_out(
.join(MoviePerformer, MoviePerformer.performer_id == Performer.id) session: Session, movies: list[Movie], *, device_id: str = LEGACY_DEVICE
.where(MoviePerformer.movie_id == movie.id) ) -> list[MovieOut]:
"""Serializacja wsadowa: po jednym zapytaniu na relacje dla calej strony listy.
Wczesniej kazdy film robil 7 zapytan (studio, obsada, tagi, rozdzialy, refy,
zrodla, ulubione + postep), czyli ~210 na strone 30 filmow i 350 ms na
GET /movies (Sentry: N+1 Query list_movies, pomiar 2026-09-08). Logika per film
jest ta sama co w starym `_movie_to_out`, tylko dane przychodza z map.
"""
if not movies:
return []
ids = [m.id for m in movies]
studio_ids = {m.studio_id for m in movies if m.studio_id}
studios: dict = {}
if studio_ids:
studios = {
st.id: st for st in session.execute(select(Studio).where(Studio.id.in_(studio_ids))).scalars()
}
perf_by: dict = defaultdict(list)
for mid, perf, alias in session.execute(
select(MoviePerformer.movie_id, Performer, MoviePerformer.as_alias)
.join(Performer, MoviePerformer.performer_id == Performer.id)
.where(MoviePerformer.movie_id.in_(ids))
.order_by(MoviePerformer.position.asc().nulls_last()) .order_by(MoviePerformer.position.asc().nulls_last())
).all() ).all():
performers = [ perf_by[mid].append((perf, alias))
PerformerOut(
id=p.id,
canonical_name=p.canonical_name,
slug=p.slug,
gender=p.gender.value if p.gender else None,
as_alias=alias,
)
for p, alias in performer_rows
]
tag_rows = session.execute( tags_by: dict = defaultdict(list)
select(Tag).join(MovieTag, MovieTag.tag_id == Tag.id) for mid, tag in session.execute(
.where(MovieTag.movie_id == movie.id) select(MovieTag.movie_id, Tag)
.join(Tag, MovieTag.tag_id == Tag.id)
.where(MovieTag.movie_id.in_(ids))
.order_by(Tag.name.asc()) .order_by(Tag.name.asc())
).scalars().all() ).all():
tags = [TagOut.model_validate(t) for t in tag_rows] tags_by[mid].append(tag)
chapter_rows = session.execute( chapters_by: dict = defaultdict(list)
select(MovieChapter).where(MovieChapter.movie_id == movie.id) for ch in session.execute(
.order_by(MovieChapter.chapter_index.asc()) select(MovieChapter).where(MovieChapter.movie_id.in_(ids)).order_by(MovieChapter.chapter_index.asc())
).scalars().all() ).scalars():
chapters = [MovieChapterOut.model_validate(c) for c in chapter_rows] chapters_by[ch.movie_id].append(ch)
ref_rows = session.execute( refs_by: dict = defaultdict(list)
for ref, name in session.execute(
select(MovieExternalRef, Source.name) select(MovieExternalRef, Source.name)
.join(Source, Source.id == MovieExternalRef.source_id) .join(Source, Source.id == MovieExternalRef.source_id)
.where(MovieExternalRef.movie_id == movie.id) .where(MovieExternalRef.movie_id.in_(ids))
).all() ).all():
external_refs = [ refs_by[ref.movie_id].append((ref, name))
ExternalRefOut(
source=name,
external_id=ref.external_id,
url=ref.url,
last_seen=ref.last_seen,
)
for ref, name in ref_rows
]
pb_rows = session.execute( pb_by: dict = defaultdict(list)
for pb in session.execute(
select(MoviePlaybackSource) select(MoviePlaybackSource)
.where(MoviePlaybackSource.movie_id == movie.id) .where(MoviePlaybackSource.movie_id.in_(ids))
.where(MoviePlaybackSource.dead_at.is_(None)) .where(MoviePlaybackSource.dead_at.is_(None))
.order_by(MoviePlaybackSource.created_at.desc()) .order_by(MoviePlaybackSource.created_at.desc())
).scalars().all() ).scalars():
pb_by[pb.movie_id].append(pb)
favs = set(
session.execute(
select(FavoriteMovie.movie_id)
.where(FavoriteMovie.device_id == device_id)
.where(FavoriteMovie.movie_id.in_(ids))
).scalars()
)
progress_by = {
pr.movie_id: pr
for pr in session.execute(
select(MoviePlayProgress)
.where(MoviePlayProgress.device_id == device_id)
.where(MoviePlayProgress.movie_id.in_(ids))
).scalars()
}
out: list[MovieOut] = []
for movie in movies:
studio = studios.get(movie.studio_id) if movie.studio_id else None
studio_out = StudioOut.model_validate(studio) if studio is not None else None
performers = [
PerformerOut(
id=perf.id,
canonical_name=perf.canonical_name,
slug=perf.slug,
gender=perf.gender.value if perf.gender else None,
as_alias=alias,
)
for perf, alias in perf_by.get(movie.id, [])
]
tags = [TagOut.model_validate(t) for t in tags_by.get(movie.id, [])]
chapters = [MovieChapterOut.model_validate(c) for c in chapters_by.get(movie.id, [])]
external_refs = [
ExternalRefOut(source=name, external_id=ref.external_id, url=ref.url, last_seen=ref.last_seen)
for ref, name in refs_by.get(movie.id, [])
]
pb_rows = list(pb_by.get(movie.id, []))
pb_rows = [p for p in pb_rows if p.origin not in _MOVIE_DROP_ORIGINS] pb_rows = [p for p in pb_rows if p.origin not in _MOVIE_DROP_ORIGINS]
# Bug-report 2026-05-16: raw landing origins (`mangoporn`/`pandamovies`/ # Bug-report 2026-05-16: raw landing origins (`mangoporn`/`pandamovies`/
# `streamporn` BEZ `:host`) otwierały WebView z reklamami pełnoekranowymi # `streamporn` BEZ `:host`) otwierały WebView z reklamami pełnoekranowymi
@ -309,10 +355,9 @@ def _movie_to_out(session: Session, movie: Movie, *, device_id: str = LEGACY_DEV
pb_rows = deduped pb_rows = deduped
playback_sources = [PlaybackSourceOut.model_validate(p) for p in pb_rows] playback_sources = [PlaybackSourceOut.model_validate(p) for p in pb_rows]
is_fav = session.get(FavoriteMovie, (device_id, movie.id)) is not None progress = progress_by.get(movie.id)
progress = session.get(MoviePlayProgress, (device_id, movie.id)) out.append(
MovieOut(
return MovieOut(
id=movie.id, id=movie.id,
title=movie.title, title=movie.title,
slug=movie.slug, slug=movie.slug,
@ -332,8 +377,10 @@ def _movie_to_out(session: Session, movie: Movie, *, device_id: str = LEGACY_DEV
external_refs=external_refs, external_refs=external_refs,
playback_sources=playback_sources, playback_sources=playback_sources,
created_at=movie.created_at, created_at=movie.created_at,
is_favorite=is_fav, is_favorite=movie.id in favs,
last_played_at=progress.last_played_at if progress else None, last_played_at=progress.last_played_at if progress else None,
finished=progress.finished if progress else False, finished=progress.finished if progress else False,
position_sec=progress.position_sec if progress else 0, position_sec=progress.position_sec if progress else 0,
) )
)
return out

View file

@ -16,6 +16,7 @@ from __future__ import annotations
import logging import logging
import os import os
import re import re
import time
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Annotated from typing import Annotated
@ -85,6 +86,8 @@ class SourceOut(BaseModel):
_ICON_DIR = Path(__file__).resolve().parent.parent / "static" / "site-icons" _ICON_DIR = Path(__file__).resolve().parent.parent / "static" / "site-icons"
_SOURCES_TTL = 300.0
_SOURCES_CACHE: dict = {"at": 0.0, "rows": None}
def _icon_url(sitetag: str) -> str | None: def _icon_url(sitetag: str) -> str | None:
@ -139,7 +142,12 @@ def list_sources(
Filter: `origin LIKE 'tube:%'` (drop canonical:* TPDB trailery to inna semantyka). Filter: `origin LIKE 'tube:%'` (drop canonical:* TPDB trailery to inna semantyka).
""" """
rows = session.execute( # Agregat idzie po 4,1 mln zywych zrodel (600 ms na kazde wejscie w Sites, Sentry:
# Slow DB Query list_sources). Liczby zmieniaja sie o promile na godzine, wiec
# wynik zyje w pamieci procesu przez _SOURCES_TTL sekund.
now = time.monotonic()
if _SOURCES_CACHE["rows"] is None or now - _SOURCES_CACHE["at"] > _SOURCES_TTL:
_SOURCES_CACHE["rows"] = session.execute(
select( select(
PlaybackSource.origin, PlaybackSource.origin,
func.count(PlaybackSource.id).label("scene_count"), func.count(PlaybackSource.id).label("scene_count"),
@ -149,6 +157,8 @@ def list_sources(
.where(PlaybackSource.origin.like("tube:%")) .where(PlaybackSource.origin.like("tube:%"))
.group_by(PlaybackSource.origin) .group_by(PlaybackSource.origin)
).all() ).all()
_SOURCES_CACHE["at"] = now
rows = _SOURCES_CACHE["rows"]
# Oceny z source_stats (policzone offline przez run_source_stats). Origin → row. # Oceny z source_stats (policzone offline przez run_source_stats). Origin → row.
stats = {s.origin: s for s in session.execute(select(SourceStats)).scalars().all()} stats = {s.origin: s for s in session.execute(select(SourceStats)).scalars().all()}