goon/app/api/favorites.py
jtrzupek 7895148c0f
Some checks are pending
Backend tests / test (push) Waiting to run
fix(review): player recovery deadlocks, quick-play re-fire, backfill gaps, em-dashes
Addresses the ultra-review findings on this branch:

Player (PlayerScreen.tsx): the new recoveryPending mirrored the fallback-chain guards
by hand and could deadlock into a permanent "Reconnecting" spinner with no way to Mark
broken — for gone (410) sources on IP-bound tubes (re-resolve bails before setting
reResolveDone) and for any post-load error on those tubes (re-resolve is initial-load
only). Derive one reResolveApplicable flag (IP-bound AND initial-load AND not-gone) and
use it for both the chain gate and the spinner, so gone/post-load errors fall through to
proxy/WebView or the terminal error card. Seek-recovery now falls through to the chain
when player.replace() throws instead of returning.

Quick-play (SceneDetail): the autoplay route param persisted and autoPlay={i===0} re-fired
when the source list reordered (e.g. after Mark broken drops the dead source), bouncing the
user into the player. Consume it once via onAutoPlayConsumed -> nav.setParams({autoplay:false}).

Backfill semantics: performer-driven direct-scraper "backward fill" now tags scenes
backfill=True (search-by-name pulls the whole old catalog); merge coalesces backfill
(keep AND drop) so a fresh scene merged into a dead dup keeps NEW; deep-crawl only tags
backfill on a tube's FIRST sweep (swept_once) so re-sweep catalog growth stays genuine;
pilot script tags backfill.

Perf/migration: migration 0026 is now idempotent (IF NOT EXISTS; prod got the column via
manual ALTER) and adds ix_scene_performers_performer_id (favorites count filtered
performer_id with no index); index also created on prod.

Cleanup: deleted dead FavoriteSceneRow (unused import in two screens, stale isNew without
the backfill guard); removed em-dashes from all lines this branch added (user CLAUDE.md
rule), including the user-facing changelog / Settings / player-overlay strings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 09:20:40 +02:00

542 lines
19 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 uuid
from datetime import UTC, datetime
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy import 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.playback_source import PlaybackSource
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
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)
perf_ids = [perf.id for _, perf in rows]
last_seen_by_perf = {fav.performer_id: fav.last_seen_at for fav, _ in rows}
# 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_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:
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"),
Scene.backfill.label("backfill"),
rn,
)
.join(Scene, Scene.id == ScenePerformer.scene_id)
.where(ScenePerformer.performer_id.in_(perf_ids))
.where(*clauses)
.subquery()
)
for gid, created_at, backfill in session.execute(
select(inner.c.gid, inner.c.created_at, inner.c.backfill).where(
inner.c.rn <= _FAVORITES_PAGE_CAP
)
):
ls = last_seen_by_perf.get(gid)
# backfill (masowy import katalogu) NIE liczy się jako nowość, patrz Scene.backfill.
if not backfill and 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
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)
studio_ids = [st.id for _, st in rows]
last_seen_by_studio = {fav.studio_id: fav.last_seen_at for fav, _ in rows}
# 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:
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"),
Scene.backfill.label("backfill"),
rn,
)
.where(Scene.studio_id.in_(studio_ids))
.where(*clauses)
.subquery()
)
for gid, created_at, backfill in session.execute(
select(inner.c.gid, inner.c.created_at, inner.c.backfill).where(
inner.c.rn <= _FAVORITES_PAGE_CAP
)
):
ls = last_seen_by_studio.get(gid)
# backfill (masowy import katalogu) NIE liczy się jako nowość, patrz Scene.backfill.
if not backfill and 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
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)