goon/app/api/favorites.py
goon-foss 92f83b93fe feat(favorites): ulubione z nowosciami na gorze, osobno nowe sceny i filmy
Zgloszenie 5a724cc8: aktorki i studia z "+N" grupowane u gory listy, a znaczek
rozbity na sceny i filmy (new_scene_count, new_movie_count). new_count zostaje
jako suma dla starszych wersji apki, ktore dostaja tez nowa kolejnosc.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 11:30:50 +02:00

771 lines
28 KiB
Python

"""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 logging
import threading
import time
import uuid
from datetime import UTC, datetime
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Path, Query, status
from pydantic import BaseModel
from sqlalchemy import exists, func, 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, session_scope
from app.models.favorite_movie import FavoriteMovie
from app.models.favorite_performer import FavoritePerformer
from app.models.favorite_site import FavoriteSite
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
log = logging.getLogger(__name__)
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` z list_scenes, czyli data premiery albo dzień dodania), z pominięciem
backfillu. Liczy w SQL (count(*) FILTER), zwraca {group_id: n}.
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, true
from app.api.scenes import (
_effective_date_desc,
blacklist_clauses,
live_playback_exists,
stub_exclusion_clause,
)
clauses = [live_playback_exists(), *blacklist_clauses(session, device_id)]
list_order = (_effective_date_desc(), Scene.created_at.desc(), Scene.id.desc())
if kind == "studio":
# Po kolei indeksem ix_scenes_studio_effective_date_desc (migracja 0038) i stop po
# 200 widocznych scenach na studio. Okno row_number() czytało wszystkie sceny
# wszystkich ulubionych studiów (324 tys. u najcięższego urządzenia, 10-25 s).
clauses.append(stub_exclusion_clause())
top = (
select(Scene.created_at.label("created_at"), Scene.backfill.label("backfill"))
.where(Scene.studio_id == FavoriteStudio.studio_id, *clauses)
.order_by(*list_order)
.limit(_FAVORITES_PAGE_CAP)
.lateral("top")
)
rows = session.execute(
select(
FavoriteStudio.studio_id,
func.count().filter(
and_(
top.c.backfill.is_(False),
top.c.created_at > FavoriteStudio.last_seen_at,
)
),
)
.select_from(FavoriteStudio)
.join(top, true())
.where(FavoriteStudio.device_id == device_id)
.group_by(FavoriteStudio.studio_id)
).all()
return {gid_val: int(n) for gid_val, n in rows}
# Performerki: sceny idą przez scene_performers, więc indeks po dacie nie pomoże i
# trzeba przeczytać całą filmografię. To jest drogie (6-15 s przy ~1000 ulubionych),
# dlatego wynik siedzi w cache, patrz _cached_new_counts.
base = (
select(
ScenePerformer.performer_id.label("gid"),
Scene.created_at.label("created_at"),
Scene.backfill.label("backfill"),
FavoritePerformer.last_seen_at.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)
)
rn = func.row_number().over(
partition_by=ScenePerformer.performer_id, order_by=list_order
).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:
"""gid -> (nowe sceny, nowe filmy). Apka od 2026-10 pokazuje je osobno (zgłoszenie
5a724cc8), starsze wersje dostają sumę w `new_count`."""
scenes = _new_counts(session, device_id, kind=kind)
movies = _new_movie_counts(session, device_id, kind=kind)
return {gid: (scenes.get(gid, 0), movies.get(gid, 0)) for gid in scenes.keys() | movies.keys()}
_NO_NEW = (0, 0)
def _favorites_order(new: dict, gid, name: str) -> tuple:
"""Najpierw ulubione z nowościami, w obu grupach alfabetycznie (zgłoszenie 5a724cc8)."""
return (sum(new.get(gid, _NO_NEW)) == 0, (name or "").lower())
# Cache liczników "+N" w pamięci procesu API (uvicorn chodzi jednym procesem). Liczenie
# dla urządzeń z setkami ulubionych trwa sekundy, a apka woła /favorites przy wejściu na
# listę scen, ulubione i każdą performerkę. Liczniki zmieniają się wolno (ingest), więc:
# świeży wynik oddajemy od razu, starszy niż _COUNTS_FRESH_SEC też od razu i liczymy
# nowy w tle, a dopiero starszy niż _COUNTS_MAX_AGE_SEC liczymy na miejscu.
# mark_seen zeruje licznik w cache, żeby badge zgasł natychmiast.
_COUNTS_FRESH_SEC = 300.0
_COUNTS_MAX_AGE_SEC = 6 * 3600.0
_counts_cache: dict[tuple[str, str], tuple[float, dict]] = {}
_counts_seen: dict[tuple[str, str], dict] = {} # gid -> kiedy oznaczono jako obejrzane
_counts_locks: dict[tuple[str, str], threading.Lock] = {}
_counts_guard = threading.Lock()
def _counts_lock(key: tuple[str, str]) -> threading.Lock:
with _counts_guard:
lock = _counts_locks.get(key)
if lock is None:
lock = _counts_locks[key] = threading.Lock()
return lock
def _store_counts(key: tuple[str, str], started: float, counts: dict) -> None:
with _counts_guard:
# mark_seen w trakcie liczenia: wynik policzono ze starym last_seen_at
for gid, at in _counts_seen.get(key, {}).items():
if at >= started:
counts[gid] = _NO_NEW
_counts_seen.pop(key, None)
_counts_cache[key] = (time.monotonic(), counts)
stale = [k for k, (at, _) in _counts_cache.items() if time.monotonic() - at > _COUNTS_MAX_AGE_SEC]
for k in stale:
_counts_cache.pop(k, None)
_counts_locks.pop(k, None)
def _compute_counts(key: tuple[str, str]) -> None:
device_id, kind = key
started = time.monotonic()
with session_scope() as session:
counts = _merged_new_counts(session, device_id, kind=kind)
_store_counts(key, started, counts)
def _refresh_in_background(key: tuple[str, str]) -> None:
lock = _counts_lock(key)
if not lock.acquire(blocking=False):
return # już ktoś liczy
def run() -> None:
try:
_compute_counts(key)
except Exception: # noqa: BLE001
log.exception("favorites: odświeżenie liczników w tle nie wyszło %s", key)
finally:
lock.release()
threading.Thread(target=run, name="fav-counts", daemon=True).start()
def _cached_new_counts(device_id: str, *, kind: str) -> dict:
key = (device_id, kind)
hit = _counts_cache.get(key)
if hit is not None:
age = time.monotonic() - hit[0]
if age < _COUNTS_MAX_AGE_SEC:
if age > _COUNTS_FRESH_SEC:
_refresh_in_background(key)
return dict(hit[1])
# Brak w cache: liczymy teraz. Lock, żeby równoległe zapytania z kilku ekranów
# nie liczyły tego samego naraz, drugie poczeka i weźmie gotowy wynik.
with _counts_lock(key):
hit = _counts_cache.get(key)
if hit is not None and time.monotonic() - hit[0] < _COUNTS_FRESH_SEC:
return dict(hit[1])
_compute_counts(key)
return dict(_counts_cache[key][1])
def _mark_counts_seen(device_id: str, kind: str, gid: uuid.UUID) -> None:
key = (device_id, kind)
with _counts_guard:
_counts_seen.setdefault(key, {})[gid] = time.monotonic()
hit = _counts_cache.get(key)
if hit is not None and hit[1].get(gid):
hit[1][gid] = _NO_NEW
class FavoriteOut(BaseModel):
performer_id: uuid.UUID
canonical_name: str
slug: str | None
scene_count: int
new_count: int # sceny + filmy od last_seen_at (dla starszych wersji apki)
new_scene_count: int = 0
new_movie_count: int = 0
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)],
counts: bool = Query(
default=True,
description="false: bez liczników +N (ekrany, które chcą tylko wiedzieć, czy zapalić gwiazdkę)",
),
) -> 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 = _cached_new_counts(device_id, kind="performer") if counts else {}
rows = sorted(rows, key=lambda r: _favorites_order(new_counts, r[1].id, r[1].canonical_name))
items: list[FavoriteOut] = []
new_total = 0
for fav, perf in rows:
ns, nm = new_counts.get(perf.id, _NO_NEW)
new_total += ns + nm
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=ns + nm,
new_scene_count=ns,
new_movie_count=nm,
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()
_mark_counts_seen(device_id, "performer", performer_id)
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
#: Filmów z żywym playbackiem. Studio może mieć ZERO scen i setki filmów
#: (Jonathan Jordan XXX 0/654, Kink Clips 0/616, EAGLE 0/523), a lista ulubionych
#: pokazywała wtedy samo „0" i wyglądała na pustą, mimo że „+N" liczyło już filmy.
movie_count: int = 0
new_count: int
new_scene_count: int = 0
new_movie_count: int = 0
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)],
counts: bool = Query(
default=True,
description="false: bez liczników +N i filmów (sam stan gwiazdki)",
),
) -> 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)
if not counts:
return FavoriteStudioListOut(
items=[
FavoriteStudioOut(
studio_id=st.id,
name=st.name,
slug=st.slug,
network=st.network,
scene_count=st.scene_count,
new_count=0,
last_seen_at=fav.last_seen_at,
created_at=fav.created_at,
)
for fav, st in rows
],
total=len(rows),
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}
# movie_count liczymy NA ŻYWO: ulubionych studiów jest kilkaset na urządzenie,
# a filmów o rzędy wielkości mniej niż scen, więc agregat jest tani i nie trzeba
# kolejnej zdenormalizowanej kolumny do utrzymania.
from app.models.movie import Movie
from app.models.movie_playback_source import MoviePlaybackSource
studio_ids = [st.id for _, st in rows]
movie_counts: dict = {
sid: n
for sid, n in session.execute(
select(Movie.studio_id, func.count())
.where(
Movie.studio_id.in_(studio_ids),
exists(
select(1).where(
MoviePlaybackSource.movie_id == Movie.id,
MoviePlaybackSource.dead_at.is_(None),
)
),
)
.group_by(Movie.studio_id)
).all()
}
new_counts = _cached_new_counts(device_id, kind="studio")
rows = sorted(rows, key=lambda r: _favorites_order(new_counts, r[1].id, r[1].name))
items: list[FavoriteStudioOut] = []
new_total = 0
for fav, st in rows:
ns, nm = new_counts.get(st.id, _NO_NEW)
new_total += ns + nm
items.append(
FavoriteStudioOut(
studio_id=st.id,
name=st.name,
slug=st.slug,
network=st.network,
scene_count=scene_counts.get(st.id, 0),
movie_count=movie_counts.get(st.id, 0),
new_count=ns + nm,
new_scene_count=ns,
new_movie_count=nm,
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()
_mark_counts_seen(device_id, "studio", studio_id)
return SeenStudioOut(studio_id=studio_id, last_seen_at=fav.last_seen_at)
# ---------- Sites (origins) ----------
# Przypiete strony w zakladce Sites (prosba z apki 2026-09-02). Bez licznikow "+N": lista
# zrodel i tak pokazuje swiezosc per strona, a badge liczy sie tylko dla performerow,
# studiow i filmow.
_ORIGIN_RE = r"^[a-z0-9][a-z0-9:._-]{1,63}$"
class FavoriteSiteListOut(BaseModel):
items: list[str] # origins, w kolejnosci dodania
total: int
class FavoriteSiteAddOut(BaseModel):
origin: str
created: bool
@router.get("/sites", response_model=FavoriteSiteListOut)
def list_favorite_sites(
session: Annotated[Session, Depends(get_session)],
device_id: Annotated[str, Depends(get_device_id)],
) -> FavoriteSiteListOut:
rows = session.execute(
select(FavoriteSite.origin)
.where(FavoriteSite.device_id == device_id)
.order_by(FavoriteSite.created_at)
).scalars().all()
return FavoriteSiteListOut(items=list(rows), total=len(rows))
@router.post("/sites/{origin}", response_model=FavoriteSiteAddOut)
def add_favorite_site(
origin: Annotated[str, Path(pattern=_ORIGIN_RE)],
session: Annotated[Session, Depends(get_session)],
device_id: Annotated[str, Depends(get_device_id)],
) -> FavoriteSiteAddOut:
from app.models.playback_source import PlaybackSource
znany = session.execute(
select(1).where(PlaybackSource.origin == origin).limit(1)
).scalar_one_or_none()
if znany is None:
raise HTTPException(status_code=404, detail="unknown origin")
if session.get(FavoriteSite, (device_id, origin)) is not None:
return FavoriteSiteAddOut(origin=origin, created=False)
session.add(FavoriteSite(device_id=device_id, origin=origin))
session.commit()
return FavoriteSiteAddOut(origin=origin, created=True)
@router.delete("/sites/{origin}", status_code=status.HTTP_204_NO_CONTENT)
def remove_favorite_site(
origin: Annotated[str, Path(pattern=_ORIGIN_RE)],
session: Annotated[Session, Depends(get_session)],
device_id: Annotated[str, Depends(get_device_id)],
) -> None:
fav = session.get(FavoriteSite, (device_id, origin))
if fav is None:
return
session.delete(fav)
session.commit()
# ── 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)