diff --git a/app/api/favorites.py b/app/api/favorites.py index 19a2275..cd7ab72 100644 --- a/app/api/favorites.py +++ b/app/api/favorites.py @@ -39,13 +39,98 @@ from app.models.favorite_studio import FavoriteStudio from app.models.movie import Movie from app.models.performer import Performer from app.models.playback_source import PlaybackSource -from app.models.scene import Scene, ScenePerformer +from app.models.scene import Scene, ScenePerformer, SceneTag from app.models.studio import Studio router = APIRouter( prefix="/favorites", tags=["favorites"], dependencies=[Depends(require_api_key)] ) +# Licznik "+N nowych" MUSI liczyć to samo, co user zobaczy po wejściu na listę scen +# performerki/studia — inaczej pokazuje +6 a lista ma 0 (report: „+6 a nic nowego"). +# Rozjazdy które to powodowały: (1) licznik nie nakładał blacklist device (gay-filter/ +# ukryte tagi) którą lista nakłada, (2) nie ograniczał do pierwszej strony listy +# (per_page=200, sort release_date desc) — nowa scena o starej dacie wydania wpadała +# poza top-200 albo w ogóle poza to co widać, (3) dla studiów nie odsiewał stub-scen. +# Poniższe replikuje filtry i okno listy (app/api/scenes.py list_scenes, domyślne paramy +# mobile: has_playback=true, include_stubs=false). Trzymane ręcznie w zgodzie z tamtym. +_FAVORITES_PAGE_CAP = 200 # == per_page w PerformerScenesScreen/StudioScenesScreen + + +def _visible_scene_clauses(session: Session, device_id: str, *, apply_stub: bool) -> list: + """Klauzule WHERE = filtry widoczności listy scen: żywy playback_source + + blacklisty device + (opcjonalnie) odsianie stub-scen. Aplikowane na zapytanie + z Scene w FROM.""" + from sqlalchemy import exists + + clauses = [ + exists( + select(1).where( + PlaybackSource.scene_id == Scene.id, + PlaybackSource.dead_at.is_(None), + ) + ) + ] + from app.api.scenes import _blacklists_empty + + if not _blacklists_empty(session, device_id): + from app.models.blacklist import ( + BlacklistedPerformer, + BlacklistedStudio, + BlacklistedTag, + ) + + clauses.append( + ~exists( + select(1) + .select_from(ScenePerformer) + .join( + BlacklistedPerformer, + (BlacklistedPerformer.performer_id == ScenePerformer.performer_id) + & (BlacklistedPerformer.device_id == device_id), + ) + .where(ScenePerformer.scene_id == Scene.id) + ) + ) + clauses.append( + ~Scene.studio_id.in_( + select(BlacklistedStudio.studio_id).where( + BlacklistedStudio.device_id == device_id + ) + ) + ) + clauses.append( + ~exists( + select(1) + .select_from(SceneTag) + .join( + BlacklistedTag, + (BlacklistedTag.tag_id == SceneTag.tag_id) + & (BlacklistedTag.device_id == device_id), + ) + .where(SceneTag.scene_id == Scene.id) + ) + ) + if apply_stub: + # Stub = tube-only scena bez release_date AND bez canonical (TPDB/StashDB) AND + # bez performera. Dla widoku performerki nigdy nie zachodzi (ma performera), więc + # apply_stub=False tam; dla studiów tak. Lustro scenes.py:348-367. + from app.models.scene import SceneExternalRef + from app.models.source import Source, SourceKind + + canonical_exists = exists( + select(1) + .select_from(SceneExternalRef) + .join(Source, Source.id == SceneExternalRef.source_id) + .where(SceneExternalRef.scene_id == Scene.id) + .where(Source.kind.in_([SourceKind.tpdb, SourceKind.stashdb])) + ) + has_performer = exists(select(1).where(ScenePerformer.scene_id == Scene.id)) + clauses.append( + Scene.release_date.is_not(None) | canonical_exists | has_performer + ) + return clauses + class FavoriteOut(BaseModel): performer_id: uuid.UUID @@ -85,39 +170,36 @@ def list_favorites( # playback). Wcześniej grouped count z EXISTS playback per-request. Migracja 0019. scene_counts: dict = {perf.id: perf.scene_count for _, perf in rows} - # Batch: new_count per performer — sceny z created_at > last_seen_at favorite'a. - # Każda performerka ma INNY last_seen_at, więc warunek per-row. Trick: GREATEST jest - # nieważny — robimy CASE per row z mapowaniem perf_id → last_seen przez VALUES list. - # Prościej: jeden join + WHERE z OR po wszystkich (perf_id=X AND created_at>ts_X) — - # ale to N OR-ów. Najczystsze rozwiązanie: zapytaj per-row ale wszystkie naraz w - # SQL używając IN tuple lub sub-query. Tu korzystamy z faktu że N=14 typowo, więc - # robimy unionall albo prosty (perf_id, last_seen_at) JOIN. + # new_count per performer = sceny created_at > last_seen_at, ale liczone TYLKO wśród + # tych, które user faktycznie zobaczy na liście: te same filtry (żywy playback + + # blacklist) i to samo okno (top-_FAVORITES_PAGE_CAP pod release_date desc) co + # PerformerScenes. apply_stub=False — widok performerki i tak ma performera (nie-stub). new_counts: dict = {} if perf_ids: - # Liczymy TYLKO sceny z żywym playback_source (has_live_playback). Powód: - # TPDB/StashDB sync wstawia metadata-only stubs (52 scen Danielle Renae jednego - # dnia z 0 playback) — bumpują created_at, badge `+N`, ale w PerformerScenes - # mobile filtruje `has_playback=true` → 0 widocznych. Result: user widzi +48 - # ale w profilu nic nowego. Filter aligns count z faktycznie oglądalnym - # contentem ("new znalezisko" = scena którą da się odtworzyć). - from sqlalchemy import and_, exists - live_playback = exists().where( - and_( - PlaybackSource.scene_id == Scene.id, - PlaybackSource.dead_at.is_(None), + from sqlalchemy import func + + clauses = _visible_scene_clauses(session, device_id, apply_stub=False) + rn = func.row_number().over( + partition_by=ScenePerformer.performer_id, + order_by=(Scene.release_date.desc().nullslast(), Scene.created_at.desc()), + ).label("rn") + inner = ( + select( + ScenePerformer.performer_id.label("gid"), + Scene.created_at.label("created_at"), + rn, ) - ) - per_scene_rows = session.execute( - select(ScenePerformer.performer_id, Scene.created_at) .join(Scene, Scene.id == ScenePerformer.scene_id) .where(ScenePerformer.performer_id.in_(perf_ids)) - .where(live_playback) - ).all() - for pid, created_at in per_scene_rows: - if created_at is None: - continue - if created_at > last_seen_by_perf.get(pid): - new_counts[pid] = new_counts.get(pid, 0) + 1 + .where(*clauses) + .subquery() + ) + for gid, created_at in session.execute( + select(inner.c.gid, inner.c.created_at).where(inner.c.rn <= _FAVORITES_PAGE_CAP) + ): + ls = last_seen_by_perf.get(gid) + if created_at is not None and ls is not None and created_at > ls: + new_counts[gid] = new_counts.get(gid, 0) + 1 items: list[FavoriteOut] = [] new_total = 0 @@ -236,26 +318,34 @@ def list_favorite_studios( # scene_count: zdenormalizowany Studio.scene_count (refresh w tle, migracja 0019). scene_counts: dict = {st.id: st.scene_count for _, st in rows} + # new_count per studio — jak dla performerów (patrz list_favorites): te same filtry + # + okno co lista StudioScenes. apply_stub=True: studio-scena bez performera/release/ + # canonical to stub który lista odsiewa, więc licznik też musi. new_counts: dict = {} if studio_ids: - # has_live_playback filter — patrz `list_favorites` (performers) wyżej. - from sqlalchemy import and_, exists - live_playback = exists().where( - and_( - PlaybackSource.scene_id == Scene.id, - PlaybackSource.dead_at.is_(None), + from sqlalchemy import func + + clauses = _visible_scene_clauses(session, device_id, apply_stub=True) + rn = func.row_number().over( + partition_by=Scene.studio_id, + order_by=(Scene.release_date.desc().nullslast(), Scene.created_at.desc()), + ).label("rn") + inner = ( + select( + Scene.studio_id.label("gid"), + Scene.created_at.label("created_at"), + rn, ) - ) - per_scene_rows = session.execute( - select(Scene.studio_id, Scene.created_at) .where(Scene.studio_id.in_(studio_ids)) - .where(live_playback) - ).all() - for sid, created_at in per_scene_rows: - if created_at is None: - continue - if created_at > last_seen_by_studio.get(sid): - new_counts[sid] = new_counts.get(sid, 0) + 1 + .where(*clauses) + .subquery() + ) + for gid, created_at in session.execute( + select(inner.c.gid, inner.c.created_at).where(inner.c.rn <= _FAVORITES_PAGE_CAP) + ): + ls = last_seen_by_studio.get(gid) + if created_at is not None and ls is not None and created_at > ls: + new_counts[gid] = new_counts.get(gid, 0) + 1 items: list[FavoriteStudioOut] = [] new_total = 0