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:
parent
44842478ae
commit
385aeb3ddb
2 changed files with 167 additions and 110 deletions
|
|
@ -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,115 +226,161 @@ 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_rows = [p for p in pb_rows if p.origin not in _MOVIE_DROP_ORIGINS]
|
pb_by[pb.movie_id].append(pb)
|
||||||
# Bug-report 2026-05-16: raw landing origins (`mangoporn`/`pandamovies`/
|
|
||||||
# `streamporn` BEZ `:host`) otwierały WebView z reklamami pełnoekranowymi
|
|
||||||
# i myliły usera. Ukrywamy raw landing GDY ten sam movie ma co najmniej
|
|
||||||
# jeden sub-host entry (origin zawiera `:`). Jeśli movie nie ma sub-hosters
|
|
||||||
# (bo theme HTML się zmienił lub regex nie złapał), zostawiamy landing jako
|
|
||||||
# last-resort.
|
|
||||||
has_subhost = any(":" in p.origin for p in pb_rows)
|
|
||||||
if has_subhost:
|
|
||||||
pb_rows = [p for p in pb_rows if p.origin not in _MOVIE_LANDING_HIDE]
|
|
||||||
pb_rows = sorted(pb_rows, key=lambda p: _movie_origin_priority(p.origin))
|
|
||||||
# Dedup po realnym celu: 3 mirrory dooplay (mangoporn/pandamovies/streamporn)
|
|
||||||
# zapisują TEN SAM embed hostera jako osobne wiersze (np. luluvid/e/X z każdego
|
|
||||||
# mirrora) → film miał ~100 linków, z czego ~połowa to ten sam film (bug-report
|
|
||||||
# 41ca1fa4). Zostawiamy pierwszy = najwyższy priorytet (sort wyżej).
|
|
||||||
seen_targets: set[str] = set()
|
|
||||||
deduped: list[MoviePlaybackSource] = []
|
|
||||||
for p in pb_rows:
|
|
||||||
target = (p.embed_url or p.stream_url or p.page_url or "").strip()
|
|
||||||
if target and target in seen_targets:
|
|
||||||
continue
|
|
||||||
if target:
|
|
||||||
seen_targets.add(target)
|
|
||||||
deduped.append(p)
|
|
||||||
pb_rows = deduped
|
|
||||||
playback_sources = [PlaybackSourceOut.model_validate(p) for p in pb_rows]
|
|
||||||
|
|
||||||
is_fav = session.get(FavoriteMovie, (device_id, movie.id)) is not None
|
favs = set(
|
||||||
progress = session.get(MoviePlayProgress, (device_id, movie.id))
|
session.execute(
|
||||||
|
select(FavoriteMovie.movie_id)
|
||||||
return MovieOut(
|
.where(FavoriteMovie.device_id == device_id)
|
||||||
id=movie.id,
|
.where(FavoriteMovie.movie_id.in_(ids))
|
||||||
title=movie.title,
|
).scalars()
|
||||||
slug=movie.slug,
|
|
||||||
release_year=movie.release_year,
|
|
||||||
release_date=movie.release_date,
|
|
||||||
duration_sec=movie.duration_sec,
|
|
||||||
description=movie.description,
|
|
||||||
director=movie.director,
|
|
||||||
country=movie.country,
|
|
||||||
rating=movie.rating,
|
|
||||||
poster_url=movie.poster_url,
|
|
||||||
backdrop_url=movie.backdrop_url,
|
|
||||||
studio=studio_out,
|
|
||||||
performers=performers,
|
|
||||||
tags=tags,
|
|
||||||
chapters=chapters,
|
|
||||||
external_refs=external_refs,
|
|
||||||
playback_sources=playback_sources,
|
|
||||||
created_at=movie.created_at,
|
|
||||||
is_favorite=is_fav,
|
|
||||||
last_played_at=progress.last_played_at if progress else None,
|
|
||||||
finished=progress.finished if progress else False,
|
|
||||||
position_sec=progress.position_sec if progress else 0,
|
|
||||||
)
|
)
|
||||||
|
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]
|
||||||
|
# Bug-report 2026-05-16: raw landing origins (`mangoporn`/`pandamovies`/
|
||||||
|
# `streamporn` BEZ `:host`) otwierały WebView z reklamami pełnoekranowymi
|
||||||
|
# i myliły usera. Ukrywamy raw landing GDY ten sam movie ma co najmniej
|
||||||
|
# jeden sub-host entry (origin zawiera `:`). Jeśli movie nie ma sub-hosters
|
||||||
|
# (bo theme HTML się zmienił lub regex nie złapał), zostawiamy landing jako
|
||||||
|
# last-resort.
|
||||||
|
has_subhost = any(":" in p.origin for p in pb_rows)
|
||||||
|
if has_subhost:
|
||||||
|
pb_rows = [p for p in pb_rows if p.origin not in _MOVIE_LANDING_HIDE]
|
||||||
|
pb_rows = sorted(pb_rows, key=lambda p: _movie_origin_priority(p.origin))
|
||||||
|
# Dedup po realnym celu: 3 mirrory dooplay (mangoporn/pandamovies/streamporn)
|
||||||
|
# zapisują TEN SAM embed hostera jako osobne wiersze (np. luluvid/e/X z każdego
|
||||||
|
# mirrora) → film miał ~100 linków, z czego ~połowa to ten sam film (bug-report
|
||||||
|
# 41ca1fa4). Zostawiamy pierwszy = najwyższy priorytet (sort wyżej).
|
||||||
|
seen_targets: set[str] = set()
|
||||||
|
deduped: list[MoviePlaybackSource] = []
|
||||||
|
for p in pb_rows:
|
||||||
|
target = (p.embed_url or p.stream_url or p.page_url or "").strip()
|
||||||
|
if target and target in seen_targets:
|
||||||
|
continue
|
||||||
|
if target:
|
||||||
|
seen_targets.add(target)
|
||||||
|
deduped.append(p)
|
||||||
|
pb_rows = deduped
|
||||||
|
playback_sources = [PlaybackSourceOut.model_validate(p) for p in pb_rows]
|
||||||
|
|
||||||
|
progress = progress_by.get(movie.id)
|
||||||
|
out.append(
|
||||||
|
MovieOut(
|
||||||
|
id=movie.id,
|
||||||
|
title=movie.title,
|
||||||
|
slug=movie.slug,
|
||||||
|
release_year=movie.release_year,
|
||||||
|
release_date=movie.release_date,
|
||||||
|
duration_sec=movie.duration_sec,
|
||||||
|
description=movie.description,
|
||||||
|
director=movie.director,
|
||||||
|
country=movie.country,
|
||||||
|
rating=movie.rating,
|
||||||
|
poster_url=movie.poster_url,
|
||||||
|
backdrop_url=movie.backdrop_url,
|
||||||
|
studio=studio_out,
|
||||||
|
performers=performers,
|
||||||
|
tags=tags,
|
||||||
|
chapters=chapters,
|
||||||
|
external_refs=external_refs,
|
||||||
|
playback_sources=playback_sources,
|
||||||
|
created_at=movie.created_at,
|
||||||
|
is_favorite=movie.id in favs,
|
||||||
|
last_played_at=progress.last_played_at if progress else None,
|
||||||
|
finished=progress.finished if progress else False,
|
||||||
|
position_sec=progress.position_sec if progress else 0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
|
||||||
|
|
@ -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,16 +142,23 @@ 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:
|
||||||
select(
|
# Slow DB Query list_sources). Liczby zmieniaja sie o promile na godzine, wiec
|
||||||
PlaybackSource.origin,
|
# wynik zyje w pamieci procesu przez _SOURCES_TTL sekund.
|
||||||
func.count(PlaybackSource.id).label("scene_count"),
|
now = time.monotonic()
|
||||||
func.max(PlaybackSource.last_seen_at).label("last_scraped_at"),
|
if _SOURCES_CACHE["rows"] is None or now - _SOURCES_CACHE["at"] > _SOURCES_TTL:
|
||||||
)
|
_SOURCES_CACHE["rows"] = session.execute(
|
||||||
.where(PlaybackSource.dead_at.is_(None))
|
select(
|
||||||
.where(PlaybackSource.origin.like("tube:%"))
|
PlaybackSource.origin,
|
||||||
.group_by(PlaybackSource.origin)
|
func.count(PlaybackSource.id).label("scene_count"),
|
||||||
).all()
|
func.max(PlaybackSource.last_seen_at).label("last_scraped_at"),
|
||||||
|
)
|
||||||
|
.where(PlaybackSource.dead_at.is_(None))
|
||||||
|
.where(PlaybackSource.origin.like("tube:%"))
|
||||||
|
.group_by(PlaybackSource.origin)
|
||||||
|
).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()}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue