- /favorites i /favorites/studios: liczniki w cache procesu (swiezy 5 min, potem odswiezanie w tle), mark_seen zeruje od razu; parametr counts=false dla ekranow, ktore chca tylko stan gwiazdki - licznik studiow idzie indeksem ix_scenes_studio_effective_date_desc (migracja 0038) i konczy po 200 scenach na studio: 10-25 s -> kilkanascie ms - okno licznika sortuje tak jak lista (data premiery albo dzien dodania), wczesniej rozjechane od 0033 - similar_scenes: pula z 4 najrzadszych tagow zamiast grupowania po wszystkich (do 5 s -> ponizej 0,4 s) - enrich-tags: tagi jednym zapytaniem po slugach i jeden INSERT - /scene-favorites: serializer wsadowy Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
753 lines
27 KiB
Python
753 lines
27 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:
|
|
"""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
|
|
|
|
|
|
# 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] = 0
|
|
_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] = 0
|
|
|
|
|
|
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)],
|
|
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 {}
|
|
|
|
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()
|
|
_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
|
|
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")
|
|
|
|
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),
|
|
movie_count=movie_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()
|
|
_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)
|