Follow-up cleanup from the ultra-review (behaviour-preserving, verified equivalent): - scenes.py: extract live_playback_exists / blacklist_clauses / stub_exclusion_clause; list_scenes and favorites now share ONE definition of "visible scene" (was a hand-kept copy in favorites -> the count-vs-list drift class). Verified identical: helper vs old inline both count 1,853,327 scenes. - favorites.py: replace the two copy-pasted count blocks with one _new_counts(kind=...) that counts in SQL (count(*) FILTER over the windowed subquery) instead of streaming up to N*200 rows to Python; joins the favorite table for per-row last_seen. Deployed _new_counts verified == hand SQL (studios 364). Dropped now-unused imports. - mobile: extract lib/newScenes.ts (isNewScene / sortNewFirst); SceneTile + Performer/ StudioScenes use it (was triplicated, already drifted once in the deleted FavoriteSceneRow). - SceneDetail: fold tube:pornxpph into the phoneResolver map (was a verbatim-duplicated block). - deep_crawl: persist the cursor after every completed page (was once at run end), so a mid-page hard-kill past the soft budget can't lose progress (GOON-V hardening). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
466 lines
16 KiB
Python
466 lines
16 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.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}
|
|
|
|
|
|
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 = _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 = _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)
|