Public instance has no accounts, so all user state was GLOBAL in DB — new users saw/overwrote each other's (and Jan's) favorites, watched badges and blacklists (bug 2026-06-10). Add device_id (VARCHAR 64) to 9 state tables with composite PK (device_id, entity_id); app sends X-Device-Id header (get_device_id dep). All favorites/scene-favorites/blacklist/watch + scene&movie list/detail (is_favorite, watched, blacklist-hide) now filter by device. Existing rows backfilled to 'legacy-shared'; POST /me/adopt-legacy reassigns them to the caller once. Old clients (no header) map to legacy-shared so they keep working until OTA updates. Migration 0022: add col, backfill, composite PK. Verified on prod: 967 progress rows preserved, device isolation holds (new device sees none of legacy state). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
444 lines
15 KiB
Python
444 lines
15 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
|
|
from app.models.studio import Studio
|
|
|
|
router = APIRouter(
|
|
prefix="/favorites", tags=["favorites"], dependencies=[Depends(require_api_key)]
|
|
)
|
|
|
|
|
|
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}
|
|
|
|
# Batch: new_count per performer — sceny z created_at > last_seen_at favorite'a.
|
|
# Każda performerka ma INNY last_seen_at, więc warunek per-row. Trick: GREATEST jest
|
|
# nieważny — robimy CASE per row z mapowaniem perf_id → last_seen przez VALUES list.
|
|
# Prościej: jeden join + WHERE z OR po wszystkich (perf_id=X AND created_at>ts_X) —
|
|
# ale to N OR-ów. Najczystsze rozwiązanie: zapytaj per-row ale wszystkie naraz w
|
|
# SQL używając IN tuple lub sub-query. Tu korzystamy z faktu że N=14 typowo, więc
|
|
# robimy unionall albo prosty (perf_id, last_seen_at) JOIN.
|
|
new_counts: dict = {}
|
|
if perf_ids:
|
|
# Liczymy TYLKO sceny z żywym playback_source (has_live_playback). Powód:
|
|
# TPDB/StashDB sync wstawia metadata-only stubs (52 scen Danielle Renae jednego
|
|
# dnia z 0 playback) — bumpują created_at, badge `+N`, ale w PerformerScenes
|
|
# mobile filtruje `has_playback=true` → 0 widocznych. Result: user widzi +48
|
|
# ale w profilu nic nowego. Filter aligns count z faktycznie oglądalnym
|
|
# contentem ("new znalezisko" = scena którą da się odtworzyć).
|
|
from sqlalchemy import and_, exists
|
|
live_playback = exists().where(
|
|
and_(
|
|
PlaybackSource.scene_id == Scene.id,
|
|
PlaybackSource.dead_at.is_(None),
|
|
)
|
|
)
|
|
per_scene_rows = session.execute(
|
|
select(ScenePerformer.performer_id, Scene.created_at)
|
|
.join(Scene, Scene.id == ScenePerformer.scene_id)
|
|
.where(ScenePerformer.performer_id.in_(perf_ids))
|
|
.where(live_playback)
|
|
).all()
|
|
for pid, created_at in per_scene_rows:
|
|
if created_at is None:
|
|
continue
|
|
if created_at > last_seen_by_perf.get(pid):
|
|
new_counts[pid] = new_counts.get(pid, 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_counts: dict = {}
|
|
if studio_ids:
|
|
# has_live_playback filter — patrz `list_favorites` (performers) wyżej.
|
|
from sqlalchemy import and_, exists
|
|
live_playback = exists().where(
|
|
and_(
|
|
PlaybackSource.scene_id == Scene.id,
|
|
PlaybackSource.dead_at.is_(None),
|
|
)
|
|
)
|
|
per_scene_rows = session.execute(
|
|
select(Scene.studio_id, Scene.created_at)
|
|
.where(Scene.studio_id.in_(studio_ids))
|
|
.where(live_playback)
|
|
).all()
|
|
for sid, created_at in per_scene_rows:
|
|
if created_at is None:
|
|
continue
|
|
if created_at > last_seen_by_studio.get(sid):
|
|
new_counts[sid] = new_counts.get(sid, 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)
|