"""Favorites — ulubione performerki + studia + liczenie nowych scen. Single-user (brak users), więc API zwraca/operuje na global zbiorze. Multi-user można dodać dorzuceniem `user_id` query/header bez breaking change. Endpointy (performers — `/favorites/...` zostawione żeby nie łamać starego mobile): GET /favorites — lista ulubionych performerek POST /favorites/{performer_id} — dodaj (idempotent) DELETE /favorites/{performer_id} — usuń POST /favorites/{performer_id}/seen — mark-as-seen (zeruje badge) Endpointy (studios): GET /favorites/studios — lista ulubionych studiów POST /favorites/studios/{studio_id} — dodaj DELETE /favorites/studios/{studio_id} — usuń POST /favorites/studios/{studio_id}/seen — mark-as-seen "Nowa scena" = scena której Scene.created_at > favorite.last_seen_at: - dla performerki: ScenePerformer.performer_id = X - dla studio: Scene.studio_id = X """ from __future__ import annotations import uuid from datetime import UTC, datetime from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel from sqlalchemy import exists, select from sqlalchemy.orm import Session from app.api.device import get_device_id from app.auth import require_api_key from app.db import get_session from app.models.favorite_movie import FavoriteMovie from app.models.favorite_performer import FavoritePerformer from app.models.favorite_studio import FavoriteStudio from app.models.movie import Movie from app.models.performer import Performer from app.models.scene import Scene, ScenePerformer 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 _new_counts(session: Session, device_id: str, *, kind: str) -> dict: """Policz per-favorite "+N nowych" = sceny created_at > last_seen_at, ale TYLKO wśród tych które user zobaczy na liście: te same filtry widoczności co list_scenes (żywy playback + blacklist + stub) i to samo okno (top-_FAVORITES_PAGE_CAP pod sortem release_date desc), z pominięciem backfillu. Liczy w SQL (count(*) FILTER), zwraca {group_id: n} — bez streamowania N*200 wierszy do Pythona. kind="performer": grupuje po ScenePerformer.performer_id, join favorite_performers. kind="studio": grupuje po Scene.studio_id, join favorite_studios. Studia potrzebują odsiewu stub (mogą nie mieć performera); performerki nie (mają). """ from sqlalchemy import and_, func from app.api.scenes import blacklist_clauses, live_playback_exists, stub_exclusion_clause clauses = [live_playback_exists(), *blacklist_clauses(session, device_id)] if kind == "performer": gid = ScenePerformer.performer_id last_seen = FavoritePerformer.last_seen_at base = ( select( gid.label("gid"), Scene.created_at.label("created_at"), Scene.backfill.label("backfill"), last_seen.label("last_seen"), ) .select_from(FavoritePerformer) .join(ScenePerformer, ScenePerformer.performer_id == FavoritePerformer.performer_id) .join(Scene, Scene.id == ScenePerformer.scene_id) .where(FavoritePerformer.device_id == device_id) ) partition = ScenePerformer.performer_id else: clauses.append(stub_exclusion_clause()) gid = Scene.studio_id last_seen = FavoriteStudio.last_seen_at base = ( select( gid.label("gid"), Scene.created_at.label("created_at"), Scene.backfill.label("backfill"), last_seen.label("last_seen"), ) .select_from(FavoriteStudio) .join(Scene, Scene.studio_id == FavoriteStudio.studio_id) .where(FavoriteStudio.device_id == device_id) ) partition = Scene.studio_id rn = func.row_number().over( partition_by=partition, order_by=(Scene.release_date.desc().nullslast(), Scene.created_at.desc()), ).label("rn") inner = base.add_columns(rn).where(*clauses).subquery() rows = session.execute( select( inner.c.gid, func.count() .filter( and_( inner.c.rn <= _FAVORITES_PAGE_CAP, inner.c.backfill.is_(False), inner.c.created_at > inner.c.last_seen, ) ) .label("n"), ).group_by(inner.c.gid) ).all() return {gid_val: int(n) for gid_val, n in rows} def _new_movie_counts(session: Session, device_id: str, *, kind: str) -> dict: """To samo co `_new_counts`, ale dla FILMÓW (user-request 2026-07-28: "+N" ma się pojawiać też gdy ulubiony performer/studio dostanie nowy film, nie tylko scenę). Filtr widoczności jak w `list_movies`: film musi mieć żywy playback. Filmów jest o rzędy wielkości mniej niż scen (brak tube-spamu), więc nie potrzeba okna top-N ani odsiewu stub/backfill — te pojęcia dotyczą scen. """ from sqlalchemy import func from app.models.movie import Movie, MoviePerformer from app.models.movie_playback_source import MoviePlaybackSource live_movie = exists( select(1).where( MoviePlaybackSource.movie_id == Movie.id, MoviePlaybackSource.dead_at.is_(None), ) ) if kind == "performer": base = ( select( MoviePerformer.performer_id.label("gid"), func.count() .filter(Movie.created_at > FavoritePerformer.last_seen_at) .label("n"), ) .select_from(FavoritePerformer) .join(MoviePerformer, MoviePerformer.performer_id == FavoritePerformer.performer_id) .join(Movie, Movie.id == MoviePerformer.movie_id) .where(FavoritePerformer.device_id == device_id, live_movie) .group_by(MoviePerformer.performer_id) ) else: base = ( select( Movie.studio_id.label("gid"), func.count().filter(Movie.created_at > FavoriteStudio.last_seen_at).label("n"), ) .select_from(FavoriteStudio) .join(Movie, Movie.studio_id == FavoriteStudio.studio_id) .where(FavoriteStudio.device_id == device_id, live_movie) .group_by(Movie.studio_id) ) return {gid: int(n) for gid, n in session.execute(base).all() if n} def _merged_new_counts(session: Session, device_id: str, *, kind: str) -> dict: """Sceny + filmy w jednym liczniku "+N" (badge nie rozróżnia typu treści).""" counts = _new_counts(session, device_id, kind=kind) for gid, n in _new_movie_counts(session, device_id, kind=kind).items(): counts[gid] = counts.get(gid, 0) + n return counts class FavoriteOut(BaseModel): performer_id: uuid.UUID canonical_name: str slug: str | None scene_count: int new_count: int # sceny od last_seen_at last_seen_at: datetime created_at: datetime class FavoriteListOut(BaseModel): items: list[FavoriteOut] total: int new_total: int # suma new_count po wszystkich — dla badge w toolbar @router.get("", response_model=FavoriteListOut) def list_favorites( session: Annotated[Session, Depends(get_session)], device_id: Annotated[str, Depends(get_device_id)], ) -> FavoriteListOut: rows = session.execute( select(FavoritePerformer, Performer) .join(Performer, Performer.id == FavoritePerformer.performer_id) .where(FavoritePerformer.device_id == device_id) .order_by(Performer.canonical_name) ).all() if not rows: return FavoriteListOut(items=[], total=0, new_total=0) # scene_count: czytamy zdenormalizowany Performer.scene_count (refresh w tle przez # _job_refresh_taxonomy_counts) — ta sama definicja co przed (sceny z żywym # playback). Wcześniej grouped count z EXISTS playback per-request. Migracja 0019. scene_counts: dict = {perf.id: perf.scene_count for _, perf in rows} new_counts = _merged_new_counts(session, device_id, kind="performer") items: list[FavoriteOut] = [] new_total = 0 for fav, perf in rows: nc = new_counts.get(perf.id, 0) new_total += nc items.append( FavoriteOut( performer_id=perf.id, canonical_name=perf.canonical_name, slug=perf.slug, scene_count=scene_counts.get(perf.id, 0), new_count=nc, last_seen_at=fav.last_seen_at, created_at=fav.created_at, ) ) return FavoriteListOut(items=items, total=len(items), new_total=new_total) class FavoriteAddOut(BaseModel): performer_id: uuid.UUID created: bool @router.post( "/{performer_id}", response_model=FavoriteAddOut, status_code=status.HTTP_200_OK, ) def add_favorite( performer_id: uuid.UUID, session: Annotated[Session, Depends(get_session)], device_id: Annotated[str, Depends(get_device_id)], ) -> FavoriteAddOut: perf = session.get(Performer, performer_id) if perf is None: raise HTTPException(status_code=404, detail="performer not found") existing = session.get(FavoritePerformer, (device_id, performer_id)) if existing is not None: return FavoriteAddOut(performer_id=performer_id, created=False) session.add(FavoritePerformer(device_id=device_id, performer_id=performer_id)) session.commit() return FavoriteAddOut(performer_id=performer_id, created=True) @router.delete("/{performer_id}", status_code=status.HTTP_204_NO_CONTENT) def remove_favorite( performer_id: uuid.UUID, session: Annotated[Session, Depends(get_session)], device_id: Annotated[str, Depends(get_device_id)], ) -> None: fav = session.get(FavoritePerformer, (device_id, performer_id)) if fav is None: # idempotent — brak ulubionego = nie ma nic do usunięcia, success return session.delete(fav) session.commit() class SeenOut(BaseModel): performer_id: uuid.UUID last_seen_at: datetime @router.post("/{performer_id}/seen", response_model=SeenOut) def mark_seen( performer_id: uuid.UUID, session: Annotated[Session, Depends(get_session)], device_id: Annotated[str, Depends(get_device_id)], ) -> SeenOut: fav = session.get(FavoritePerformer, (device_id, performer_id)) if fav is None: raise HTTPException(status_code=404, detail="not in favorites") fav.last_seen_at = datetime.now(UTC) session.commit() return SeenOut(performer_id=performer_id, last_seen_at=fav.last_seen_at) # ---------- Studios ---------- class FavoriteStudioOut(BaseModel): studio_id: uuid.UUID name: str slug: str network: str | None = None scene_count: int new_count: int last_seen_at: datetime created_at: datetime class FavoriteStudioListOut(BaseModel): items: list[FavoriteStudioOut] total: int new_total: int @router.get("/studios", response_model=FavoriteStudioListOut) def list_favorite_studios( session: Annotated[Session, Depends(get_session)], device_id: Annotated[str, Depends(get_device_id)], ) -> FavoriteStudioListOut: rows = session.execute( select(FavoriteStudio, Studio) .join(Studio, Studio.id == FavoriteStudio.studio_id) .where(FavoriteStudio.device_id == device_id) .order_by(Studio.name) ).all() if not rows: return FavoriteStudioListOut(items=[], total=0, new_total=0) # scene_count: zdenormalizowany Studio.scene_count (refresh w tle, migracja 0019). scene_counts: dict = {st.id: st.scene_count for _, st in rows} new_counts = _merged_new_counts(session, device_id, kind="studio") items: list[FavoriteStudioOut] = [] new_total = 0 for fav, st in rows: nc = new_counts.get(st.id, 0) new_total += nc items.append( FavoriteStudioOut( studio_id=st.id, name=st.name, slug=st.slug, network=st.network, scene_count=scene_counts.get(st.id, 0), new_count=nc, last_seen_at=fav.last_seen_at, created_at=fav.created_at, ) ) return FavoriteStudioListOut(items=items, total=len(items), new_total=new_total) class FavoriteStudioAddOut(BaseModel): studio_id: uuid.UUID created: bool @router.post( "/studios/{studio_id}", response_model=FavoriteStudioAddOut, status_code=status.HTTP_200_OK, ) def add_favorite_studio( studio_id: uuid.UUID, session: Annotated[Session, Depends(get_session)], device_id: Annotated[str, Depends(get_device_id)], ) -> FavoriteStudioAddOut: st = session.get(Studio, studio_id) if st is None: raise HTTPException(status_code=404, detail="studio not found") existing = session.get(FavoriteStudio, (device_id, studio_id)) if existing is not None: return FavoriteStudioAddOut(studio_id=studio_id, created=False) session.add(FavoriteStudio(device_id=device_id, studio_id=studio_id)) session.commit() return FavoriteStudioAddOut(studio_id=studio_id, created=True) @router.delete("/studios/{studio_id}", status_code=status.HTTP_204_NO_CONTENT) def remove_favorite_studio( studio_id: uuid.UUID, session: Annotated[Session, Depends(get_session)], device_id: Annotated[str, Depends(get_device_id)], ) -> None: fav = session.get(FavoriteStudio, (device_id, studio_id)) if fav is None: return session.delete(fav) session.commit() class SeenStudioOut(BaseModel): studio_id: uuid.UUID last_seen_at: datetime @router.post("/studios/{studio_id}/seen", response_model=SeenStudioOut) def mark_studio_seen( studio_id: uuid.UUID, session: Annotated[Session, Depends(get_session)], device_id: Annotated[str, Depends(get_device_id)], ) -> SeenStudioOut: fav = session.get(FavoriteStudio, (device_id, studio_id)) if fav is None: raise HTTPException(status_code=404, detail="not in favorites") fav.last_seen_at = datetime.now(UTC) session.commit() return SeenStudioOut(studio_id=studio_id, last_seen_at=fav.last_seen_at) # ── Favorite movies ──────────────────────────────────────────────────────── # Movies nie mają child scenes per-favorite (jak performerki/studia), więc # `last_seen_at` nie jest tu używany do NEW count — tylko jako tracking ostatniego # wglądu przez usera. Mobile używa NEW badge w liście /movies przez OSOBNY # globalny last_seen z AsyncStorage (client-side, brak backendowego state). class FavoriteMovieOut(BaseModel): movie_id: uuid.UUID title: str slug: str | None poster_url: str | None release_year: int | None studio_name: str | None last_seen_at: datetime created_at: datetime class FavoriteMovieListOut(BaseModel): items: list[FavoriteMovieOut] total: int @router.get("/movies", response_model=FavoriteMovieListOut) def list_favorite_movies( session: Annotated[Session, Depends(get_session)], device_id: Annotated[str, Depends(get_device_id)], ) -> FavoriteMovieListOut: rows = session.execute( select(FavoriteMovie, Movie, Studio) .join(Movie, Movie.id == FavoriteMovie.movie_id) .outerjoin(Studio, Studio.id == Movie.studio_id) .where(FavoriteMovie.device_id == device_id) .order_by(Movie.title) ).all() items = [ FavoriteMovieOut( movie_id=movie.id, title=movie.title, slug=movie.slug, poster_url=movie.poster_url, release_year=movie.release_year, studio_name=studio.name if studio else None, last_seen_at=fav.last_seen_at, created_at=fav.created_at, ) for fav, movie, studio in rows ] return FavoriteMovieListOut(items=items, total=len(items)) class FavoriteMovieAddOut(BaseModel): movie_id: uuid.UUID created: bool @router.post( "/movies/{movie_id}", response_model=FavoriteMovieAddOut, status_code=status.HTTP_200_OK, ) def add_favorite_movie( movie_id: uuid.UUID, session: Annotated[Session, Depends(get_session)], device_id: Annotated[str, Depends(get_device_id)], ) -> FavoriteMovieAddOut: movie = session.get(Movie, movie_id) if movie is None: raise HTTPException(status_code=404, detail="movie not found") existing = session.get(FavoriteMovie, (device_id, movie_id)) if existing is not None: return FavoriteMovieAddOut(movie_id=movie_id, created=False) session.add(FavoriteMovie(device_id=device_id, movie_id=movie_id)) session.commit() return FavoriteMovieAddOut(movie_id=movie_id, created=True) @router.delete("/movies/{movie_id}", status_code=status.HTTP_204_NO_CONTENT) def remove_favorite_movie( movie_id: uuid.UUID, session: Annotated[Session, Depends(get_session)], device_id: Annotated[str, Depends(get_device_id)], ) -> None: fav = session.get(FavoriteMovie, (device_id, movie_id)) if fav is None: return session.delete(fav) session.commit() class SeenMovieOut(BaseModel): movie_id: uuid.UUID last_seen_at: datetime @router.post("/movies/{movie_id}/seen", response_model=SeenMovieOut) def mark_movie_seen( movie_id: uuid.UUID, session: Annotated[Session, Depends(get_session)], device_id: Annotated[str, Depends(get_device_id)], ) -> SeenMovieOut: fav = session.get(FavoriteMovie, (device_id, movie_id)) if fav is None: raise HTTPException(status_code=404, detail="not in favorites") fav.last_seen_at = datetime.now(UTC) session.commit() return SeenMovieOut(movie_id=movie_id, last_seen_at=fav.last_seen_at)