feat(api): device-scope user state (favorites/progress/blacklists)

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>
This commit is contained in:
jtrzupek 2026-06-10 08:58:01 +02:00
parent 953068f0db
commit c8baa11604
16 changed files with 285 additions and 62 deletions

View file

@ -0,0 +1,58 @@
"""device-scope user state: favorites / play-progress / blacklists
Revision ID: 0022_device_scoped_user_state
Revises: 0021_scene_tags_tag_id_stats
Create Date: 2026-06-08
Publiczna instancja nie ma kont stan usera był GLOBALNY, nowi użytkownicy
nadpisywali ulubione/blacklisty/progress Jana (bug 2026-06-08). Dodajemy `device_id`
(VARCHAR 64) do 9 tabel stanu i przerabiamy PK na composite `(device_id, <entity>)`.
Istniejące wiersze `device_id = 'legacy-shared'` (sentinel). Apka po update wysyła
`X-Device-Id`; `/me/adopt-legacy` przepina legacy na docelowe device. Zero utraty
danych (backfill + composite PK, nie drop).
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0022_device_scoped_user_state"
down_revision: str | None = "0021_scene_tags_tag_id_stats"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
LEGACY = "legacy-shared"
# (table, entity_pk_column)
_TABLES: list[tuple[str, str]] = [
("favorite_performers", "performer_id"),
("favorite_studios", "studio_id"),
("favorite_scenes", "scene_id"),
("favorite_movies", "movie_id"),
("scene_play_progress", "scene_id"),
("movie_play_progress", "movie_id"),
("blacklisted_performers", "performer_id"),
("blacklisted_studios", "studio_id"),
("blacklisted_tags", "tag_id"),
]
def upgrade() -> None:
for table, entity in _TABLES:
# 1. dodaj nullable, 2. backfill legacy, 3. NOT NULL, 4. composite PK
op.add_column(table, sa.Column("device_id", sa.String(length=64), nullable=True))
op.execute(sa.text(f"UPDATE {table} SET device_id = :d").bindparams(d=LEGACY))
op.alter_column(table, "device_id", nullable=False)
op.drop_constraint(f"pk_{table}", table, type_="primary")
op.create_primary_key(f"pk_{table}", table, ["device_id", entity])
def downgrade() -> None:
for table, entity in _TABLES:
op.drop_constraint(f"pk_{table}", table, type_="primary")
# przywróć single-col PK (zakłada brak duplikatów entity po dropie device_id)
op.execute(sa.text(f"DELETE FROM {table} a USING {table} b "
f"WHERE a.ctid < b.ctid AND a.{entity} = b.{entity}"))
op.create_primary_key(f"pk_{table}", table, [entity])
op.drop_column(table, "device_id")

View file

@ -20,6 +20,7 @@ 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.blacklist import (
@ -53,20 +54,24 @@ class BlacklistOut(BaseModel):
@router.get("", response_model=BlacklistOut)
def list_blacklist(
session: Annotated[Session, Depends(get_session)],
device_id: Annotated[str, Depends(get_device_id)],
) -> BlacklistOut:
perfs = session.execute(
select(BlacklistedPerformer.performer_id, Performer.canonical_name, Performer.slug)
.join(Performer, Performer.id == BlacklistedPerformer.performer_id)
.where(BlacklistedPerformer.device_id == device_id)
.order_by(Performer.canonical_name)
).all()
studios = session.execute(
select(BlacklistedStudio.studio_id, Studio.name, Studio.slug)
.join(Studio, Studio.id == BlacklistedStudio.studio_id)
.where(BlacklistedStudio.device_id == device_id)
.order_by(Studio.name)
).all()
tags = session.execute(
select(BlacklistedTag.tag_id, Tag.name, Tag.slug)
.join(Tag, Tag.id == BlacklistedTag.tag_id)
.where(BlacklistedTag.device_id == device_id)
.order_by(Tag.name)
).all()
return BlacklistOut(
@ -91,13 +96,14 @@ def add_blacklist(
kind: Kind,
entity_id: uuid.UUID,
session: Annotated[Session, Depends(get_session)],
device_id: Annotated[str, Depends(get_device_id)],
) -> dict:
bl_model, parent_model, fk = _kind_to_entity(kind)
if session.get(parent_model, entity_id) is None:
raise HTTPException(status_code=404, detail=f"{kind} not found")
if session.get(bl_model, entity_id) is not None:
if session.get(bl_model, (device_id, entity_id)) is not None:
return {"kind": kind, "id": str(entity_id), "created": False}
session.add(bl_model(**{fk: entity_id}))
session.add(bl_model(**{"device_id": device_id, fk: entity_id}))
session.commit()
return {"kind": kind, "id": str(entity_id), "created": True}
@ -107,9 +113,10 @@ def remove_blacklist(
kind: Kind,
entity_id: uuid.UUID,
session: Annotated[Session, Depends(get_session)],
device_id: Annotated[str, Depends(get_device_id)],
) -> None:
bl_model, _, _ = _kind_to_entity(kind)
row = session.get(bl_model, entity_id)
row = session.get(bl_model, (device_id, entity_id))
if row is None:
return # idempotent
session.delete(row)

29
app/api/device.py Normal file
View file

@ -0,0 +1,29 @@
"""Per-device scoping stanu usera (favorites / play-progress / blacklisty).
Publiczna instancja Goon nie ma kont. Wcześniej cały stan usera był GLOBALNY w DB
nowi użytkownicy widzieli/nadpisywali ulubione, watched-badge i blacklisty Jana
(bug 2026-06-08). Apka generuje raz UUID instalacji (SecureStore) i wysyła go w
nagłówku `X-Device-Id`; backend scope'uje wszystkie tabele stanu po `device_id`.
Stare wiersze (sprzed migracji) mają `device_id = LEGACY_DEVICE`. Klient bez nagłówka
(stara wersja apki przed OTA) trafia również na LEGACY_DEVICE czyli dostaje dawną
współdzieloną pulę, dopóki nie zaktualizuje bundla. Endpoint `/me/adopt-legacy`
przepina LEGACY rows na konkretne device (Jan robi to raz po update).
"""
from __future__ import annotations
from typing import Annotated
from fastapi import Header
# Sentinel dla wierszy sprzed device-scopingu + klientów bez nagłówka.
LEGACY_DEVICE = "legacy-shared"
def get_device_id(
x_device_id: Annotated[str | None, Header(alias="X-Device-Id")] = None,
) -> str:
"""Zwraca device_id z nagłówka `X-Device-Id` (przycięty do 64 znaków).
Brak/empty LEGACY_DEVICE (kompat ze starymi klientami)."""
v = (x_device_id or "").strip()
return v[:64] if v else LEGACY_DEVICE

View file

@ -30,6 +30,7 @@ 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
@ -65,10 +66,12 @@ class FavoriteListOut(BaseModel):
@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:
@ -148,14 +151,15 @@ class FavoriteAddOut(BaseModel):
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, performer_id)
existing = session.get(FavoritePerformer, (device_id, performer_id))
if existing is not None:
return FavoriteAddOut(performer_id=performer_id, created=False)
session.add(FavoritePerformer(performer_id=performer_id))
session.add(FavoritePerformer(device_id=device_id, performer_id=performer_id))
session.commit()
return FavoriteAddOut(performer_id=performer_id, created=True)
@ -164,8 +168,9 @@ def add_favorite(
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, performer_id)
fav = session.get(FavoritePerformer, (device_id, performer_id))
if fav is None:
# idempotent — brak ulubionego = nie ma nic do usunięcia, success
return
@ -182,8 +187,9 @@ class SeenOut(BaseModel):
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, performer_id)
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)
@ -213,10 +219,12 @@ class FavoriteStudioListOut(BaseModel):
@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:
@ -282,14 +290,15 @@ class FavoriteStudioAddOut(BaseModel):
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, studio_id)
existing = session.get(FavoriteStudio, (device_id, studio_id))
if existing is not None:
return FavoriteStudioAddOut(studio_id=studio_id, created=False)
session.add(FavoriteStudio(studio_id=studio_id))
session.add(FavoriteStudio(device_id=device_id, studio_id=studio_id))
session.commit()
return FavoriteStudioAddOut(studio_id=studio_id, created=True)
@ -298,8 +307,9 @@ def add_favorite_studio(
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, studio_id)
fav = session.get(FavoriteStudio, (device_id, studio_id))
if fav is None:
return
session.delete(fav)
@ -315,8 +325,9 @@ class SeenStudioOut(BaseModel):
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, studio_id)
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)
@ -350,11 +361,13 @@ class FavoriteMovieListOut(BaseModel):
@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 = [
@ -386,14 +399,15 @@ class FavoriteMovieAddOut(BaseModel):
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, movie_id)
existing = session.get(FavoriteMovie, (device_id, movie_id))
if existing is not None:
return FavoriteMovieAddOut(movie_id=movie_id, created=False)
session.add(FavoriteMovie(movie_id=movie_id))
session.add(FavoriteMovie(device_id=device_id, movie_id=movie_id))
session.commit()
return FavoriteMovieAddOut(movie_id=movie_id, created=True)
@ -402,8 +416,9 @@ def add_favorite_movie(
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, movie_id)
fav = session.get(FavoriteMovie, (device_id, movie_id))
if fav is None:
return
session.delete(fav)
@ -419,8 +434,9 @@ class SeenMovieOut(BaseModel):
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, movie_id)
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)

68
app/api/me.py Normal file
View file

@ -0,0 +1,68 @@
"""Per-device self-service: przejęcie legacy stanu usera.
Po migracji device-scopingu (0022) stare wiersze mają `device_id='legacy-shared'`.
`POST /me/adopt-legacy` przepina WSZYSTKIE legacy wiersze (favorites/progress/blacklisty)
na device wołającego. Robi to JEDEN raz właściciel instancji po update apki kolejne
wywołania nic nie znajdą (legacy już puste). Jeśli na keep-device istnieje już wiersz
dla tej samej encji, legacy duplikat jest pomijany (ON CONFLICT DO NOTHING potem
kasujemy resztki legacy).
"""
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.api.device import LEGACY_DEVICE, get_device_id
from app.auth import require_api_key
from app.db import get_session
router = APIRouter(prefix="/me", tags=["me"], dependencies=[Depends(require_api_key)])
# (tabela, kolumna-encji) — wszystkie tabele device-scoped (migracja 0022).
_TABLES: list[tuple[str, str]] = [
("favorite_performers", "performer_id"),
("favorite_studios", "studio_id"),
("favorite_scenes", "scene_id"),
("favorite_movies", "movie_id"),
("scene_play_progress", "scene_id"),
("movie_play_progress", "movie_id"),
("blacklisted_performers", "performer_id"),
("blacklisted_studios", "studio_id"),
("blacklisted_tags", "tag_id"),
]
class AdoptLegacyOut(BaseModel):
device_id: str
moved: dict[str, int]
@router.post("/adopt-legacy", response_model=AdoptLegacyOut)
def adopt_legacy(
session: Annotated[Session, Depends(get_session)],
device_id: Annotated[str, Depends(get_device_id)],
) -> AdoptLegacyOut:
moved: dict[str, int] = {}
if device_id == LEGACY_DEVICE:
# Wołający bez X-Device-Id == legacy → nie ma czego przepinać.
return AdoptLegacyOut(device_id=device_id, moved={})
for table, entity in _TABLES:
# Przepnij legacy → device dla encji których device JESZCZE nie ma.
res = session.execute(
text(
f"UPDATE {table} SET device_id = :dev "
f"WHERE device_id = :legacy AND {entity} NOT IN "
f"(SELECT {entity} FROM {table} WHERE device_id = :dev)"
).bindparams(dev=device_id, legacy=LEGACY_DEVICE)
)
moved[table] = res.rowcount or 0
# Resztki legacy (encje które device już miał) — skasuj, żeby nie wisiały.
session.execute(
text(f"DELETE FROM {table} WHERE device_id = :legacy").bindparams(legacy=LEGACY_DEVICE)
)
session.commit()
return AdoptLegacyOut(device_id=device_id, moved=moved)

View file

@ -27,6 +27,7 @@ from app.models.movie import (
MoviePerformer,
MovieTag,
)
from app.api.device import LEGACY_DEVICE, get_device_id
from app.models.favorite_movie import FavoriteMovie
from app.models.movie_playback_source import MoviePlaybackSource
from app.models.performer import Performer
@ -49,6 +50,7 @@ def _split_csv(raw: str | None) -> list[str]:
@router.get("", response_model=MovieListOut)
def list_movies(
session: Annotated[Session, Depends(get_session)],
device_id: Annotated[str, Depends(get_device_id)],
q: str | None = Query(default=None, description="Title search (trgm)"),
studio_slugs: str | None = Query(default=None, description="Comma-separated studio slugs (OR)"),
tags: str | None = Query(default=None, description="Comma-separated tag slugs (AND)"),
@ -131,7 +133,7 @@ def list_movies(
base = base.limit(per_page).offset((page - 1) * per_page)
movies = session.execute(base).scalars().all()
items = [_movie_to_out(session, m) for m in movies]
items = [_movie_to_out(session, m, device_id=device_id) for m in movies]
return MovieListOut(items=items, total=total, page=page, per_page=per_page)
@ -172,14 +174,15 @@ def _movie_origin_priority(origin: str) -> int:
def get_movie(
movie_id: uuid.UUID,
session: Annotated[Session, Depends(get_session)],
device_id: Annotated[str, Depends(get_device_id)],
) -> MovieOut:
movie = session.get(Movie, movie_id)
if movie is None:
raise HTTPException(status_code=404, detail="movie not found")
return _movie_to_out(session, movie)
return _movie_to_out(session, movie, device_id=device_id)
def _movie_to_out(session: Session, movie: Movie) -> MovieOut:
def _movie_to_out(session: Session, movie: Movie, *, device_id: str = LEGACY_DEVICE) -> MovieOut:
studio_out: StudioOut | None = None
if movie.studio_id:
studio = session.get(Studio, movie.studio_id)
@ -250,8 +253,8 @@ def _movie_to_out(session: Session, movie: Movie) -> MovieOut:
pb_rows = sorted(pb_rows, key=lambda p: _movie_origin_priority(p.origin))
playback_sources = [PlaybackSourceOut.model_validate(p) for p in pb_rows]
is_fav = session.get(FavoriteMovie, movie.id) is not None
progress = session.get(MoviePlayProgress, movie.id)
is_fav = session.get(FavoriteMovie, (device_id, movie.id)) is not None
progress = session.get(MoviePlayProgress, (device_id, movie.id))
return MovieOut(
id=movie.id,

View file

@ -15,6 +15,7 @@ from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.api.device import get_device_id
from app.api.scenes import _build_scene_out
from app.api.schemas import SceneOut
from app.auth import require_api_key
@ -42,16 +43,18 @@ class SceneFavoriteToggleOut(BaseModel):
@router.get("", response_model=SceneFavoriteListOut)
def list_scene_favorites(
session: Annotated[Session, Depends(get_session)],
device_id: Annotated[str, Depends(get_device_id)],
) -> SceneFavoriteListOut:
rows = (
session.execute(
select(Scene, FavoriteScene)
.join(FavoriteScene, FavoriteScene.scene_id == Scene.id)
.where(FavoriteScene.device_id == device_id)
.order_by(FavoriteScene.created_at.desc())
)
.all()
)
items = [_build_scene_out(session, scene) for scene, _ in rows]
items = [_build_scene_out(session, scene, device_id=device_id) for scene, _ in rows]
return SceneFavoriteListOut(items=items, total=len(items))
@ -63,13 +66,14 @@ def list_scene_favorites(
def add_scene_favorite(
scene_id: uuid.UUID,
session: Annotated[Session, Depends(get_session)],
device_id: Annotated[str, Depends(get_device_id)],
) -> SceneFavoriteToggleOut:
scene = session.get(Scene, scene_id)
if scene is None:
raise HTTPException(status_code=404, detail="scene not found")
existing = session.get(FavoriteScene, scene_id)
existing = session.get(FavoriteScene, (device_id, scene_id))
if existing is None:
session.add(FavoriteScene(scene_id=scene_id))
session.add(FavoriteScene(device_id=device_id, scene_id=scene_id))
return SceneFavoriteToggleOut(scene_id=scene_id, favorited=True)
@ -77,7 +81,8 @@ def add_scene_favorite(
def remove_scene_favorite(
scene_id: uuid.UUID,
session: Annotated[Session, Depends(get_session)],
device_id: Annotated[str, Depends(get_device_id)],
) -> None:
fav = session.get(FavoriteScene, scene_id)
fav = session.get(FavoriteScene, (device_id, scene_id))
if fav is not None:
session.delete(fav)

View file

@ -24,6 +24,7 @@ from app.api.schemas import (
TagOut,
)
from app.db import get_session
from app.api.device import LEGACY_DEVICE, get_device_id
from app.models.favorite_scene import FavoriteScene
from app.models.performer import Performer
from app.models.play_progress import ScenePlayProgress
@ -73,12 +74,13 @@ def _default_scene_count(session: Session) -> int:
# puste-zawsze klauzule kosztowały ~3.4s (mega-tag „anal": 6.7s→3.3s po pominięciu).
# Cache'ujemy emptiness (TTL 5 min); gdy ktoś doda blacklist-wpis, w ciągu 5 min klauzule
# wracają. Patrz reference_scenes_list_perf / task #22.
_BLACKLIST_EMPTY_CACHE: dict = {"ts": 0.0, "val": False, "checked": False}
# Cache per device_id (blacklisty są teraz device-scoped — bug 2026-06-08).
_BLACKLIST_EMPTY_CACHE: dict[str, tuple[float, bool]] = {}
_BLACKLIST_EMPTY_TTL = 300.0
def _blacklists_empty(session: Session) -> bool:
"""True gdy WSZYSTKIE 3 blacklisty puste → można pominąć NOT EXISTS klauzule."""
def _blacklists_empty(session: Session, device_id: str) -> bool:
"""True gdy WSZYSTKIE 3 blacklisty TEGO device puste → pomiń NOT EXISTS klauzule."""
import time as _time
from app.models.blacklist import (
BlacklistedPerformer,
@ -86,18 +88,17 @@ def _blacklists_empty(session: Session) -> bool:
BlacklistedTag,
)
now = _time.monotonic()
if _BLACKLIST_EMPTY_CACHE["checked"] and (now - _BLACKLIST_EMPTY_CACHE["ts"]) < _BLACKLIST_EMPTY_TTL:
return _BLACKLIST_EMPTY_CACHE["val"]
cached = _BLACKLIST_EMPTY_CACHE.get(device_id)
if cached and (now - cached[0]) < _BLACKLIST_EMPTY_TTL:
return cached[1]
has_any = session.execute(
select(
exists(select(1).select_from(BlacklistedPerformer))
| exists(select(1).select_from(BlacklistedStudio))
| exists(select(1).select_from(BlacklistedTag))
exists(select(1).select_from(BlacklistedPerformer).where(BlacklistedPerformer.device_id == device_id))
| exists(select(1).select_from(BlacklistedStudio).where(BlacklistedStudio.device_id == device_id))
| exists(select(1).select_from(BlacklistedTag).where(BlacklistedTag.device_id == device_id))
)
).scalar_one()
_BLACKLIST_EMPTY_CACHE["ts"] = now
_BLACKLIST_EMPTY_CACHE["val"] = not has_any
_BLACKLIST_EMPTY_CACHE["checked"] = True
_BLACKLIST_EMPTY_CACHE[device_id] = (now, not has_any)
return not has_any
@ -110,6 +111,7 @@ def _split_csv(raw: str | None) -> list[str]:
@router.get("", response_model=SceneListOut)
def list_scenes(
session: Annotated[Session, Depends(get_session)],
device_id: Annotated[str, Depends(get_device_id)],
q: str | None = Query(default=None, description="Wyszukiwanie po title_normalized (trgm)"),
studio_slug: str | None = Query(default=None, description="DEPRECATED — użyj studio_slugs"),
studio_slugs: str | None = Query(
@ -259,7 +261,7 @@ def list_scenes(
# performera, jest na blacklisted studio, lub ma JAKIKOLWIEK blacklisted tag → out.
# Pomijamy gdy wszystkie 3 blacklisty puste (typowy stan single-user) — te NOT EXISTS
# ewaluują się per-row na ~176k scen przy mega-tagu i kosztowały ~3.4s za nic.
if not _blacklists_empty(session):
if not _blacklists_empty(session, device_id):
from app.models.blacklist import (
BlacklistedPerformer,
BlacklistedStudio,
@ -269,18 +271,28 @@ def list_scenes(
~exists(
select(1)
.select_from(ScenePerformer)
.join(BlacklistedPerformer, BlacklistedPerformer.performer_id == ScenePerformer.performer_id)
.join(
BlacklistedPerformer,
(BlacklistedPerformer.performer_id == ScenePerformer.performer_id)
& (BlacklistedPerformer.device_id == device_id),
)
.where(ScenePerformer.scene_id == Scene.id)
)
)
base = base.where(
~Scene.studio_id.in_(select(BlacklistedStudio.studio_id))
~Scene.studio_id.in_(
select(BlacklistedStudio.studio_id).where(BlacklistedStudio.device_id == device_id)
)
)
base = base.where(
~exists(
select(1)
.select_from(SceneTag)
.join(BlacklistedTag, BlacklistedTag.tag_id == SceneTag.tag_id)
.join(
BlacklistedTag,
(BlacklistedTag.tag_id == SceneTag.tag_id)
& (BlacklistedTag.device_id == device_id),
)
.where(SceneTag.scene_id == Scene.id)
)
)
@ -406,7 +418,7 @@ def list_scenes(
total = (page - 1) * per_page + len(rows)
total_capped = has_more
items = _build_scenes_out_batch(session, list(rows), light=True)
items = _build_scenes_out_batch(session, list(rows), light=True, device_id=device_id)
return SceneListOut(
items=items,
@ -422,11 +434,12 @@ def list_scenes(
def get_scene(
scene_id: uuid.UUID,
session: Annotated[Session, Depends(get_session)],
device_id: Annotated[str, Depends(get_device_id)],
) -> SceneOut:
scene = session.get(Scene, scene_id)
if scene is None:
raise HTTPException(status_code=404, detail="scene not found")
return _build_scene_out(session, scene)
return _build_scene_out(session, scene, device_id=device_id)
def _needs_proxy(url: str) -> bool:
@ -452,7 +465,7 @@ def _wrap_image_proxy(url: str, referer: str) -> str:
def _build_scenes_out_batch(
session: Session, scenes: list[Scene], *, light: bool = False
session: Session, scenes: list[Scene], *, light: bool = False, device_id: str = LEGACY_DEVICE
) -> list[SceneOut]:
"""Batch-fetch wszystkich relacji dla N scen w 7 zapytaniach (zamiast 7×N).
@ -589,18 +602,22 @@ def _build_scenes_out_batch(
out.animated_thumbnail_url = _wrap_image_proxy(out.animated_thumbnail_url, p.page_url)
pb_by_scene[p.scene_id].append(out)
# 6) Progress
# 6) Progress (device-scoped)
progress_by_scene: dict = {}
for prog in session.execute(
select(ScenePlayProgress).where(ScenePlayProgress.scene_id.in_(scene_ids))
select(ScenePlayProgress).where(
ScenePlayProgress.scene_id.in_(scene_ids),
ScenePlayProgress.device_id == device_id,
)
).scalars():
progress_by_scene[prog.scene_id] = prog
# 7) Favorites
# 7) Favorites (device-scoped)
fav_scene_ids: set = set(
session.execute(
select(FavoriteScene.scene_id).where(
FavoriteScene.scene_id.in_(scene_ids)
FavoriteScene.scene_id.in_(scene_ids),
FavoriteScene.device_id == device_id,
)
).scalars()
)
@ -636,7 +653,7 @@ def _build_scenes_out_batch(
return out
def _build_scene_out(session: Session, scene: Scene) -> SceneOut:
def _build_scene_out(session: Session, scene: Scene, *, device_id: str = LEGACY_DEVICE) -> SceneOut:
studio_out: StudioOut | None = None
if scene.studio_id is not None:
st = session.get(Studio, scene.studio_id)
@ -744,8 +761,8 @@ def _build_scene_out(session: Session, scene: Scene) -> SceneOut:
playback_out.sort(key=lambda o: (_resolve_rank(o.origin), o.origin or ""))
progress = session.get(ScenePlayProgress, scene.id)
is_fav = session.get(FavoriteScene, scene.id) is not None
progress = session.get(ScenePlayProgress, (device_id, scene.id))
is_fav = session.get(FavoriteScene, (device_id, scene.id)) is not None
return SceneOut(
id=scene.id,

View file

@ -19,6 +19,7 @@ from pydantic import BaseModel
from sqlalchemy import desc, select
from sqlalchemy.orm import Session
from app.api.device import get_device_id
from app.api.scenes import _build_scene_out
from app.api.schemas import SceneOut
from app.auth import require_api_key
@ -49,6 +50,7 @@ def upsert_progress(
scene_id: uuid.UUID,
body: ProgressIn,
session: Annotated[Session, Depends(get_session)],
device_id: Annotated[str, Depends(get_device_id)],
) -> ProgressOut:
if session.get(Scene, scene_id) is None:
raise HTTPException(status_code=404, detail="scene not found")
@ -68,6 +70,7 @@ def upsert_progress(
stmt = (
pg_insert(ScenePlayProgress)
.values(
device_id=device_id,
scene_id=scene_id,
position_sec=position_sec,
duration_sec=body.duration_sec,
@ -75,7 +78,7 @@ def upsert_progress(
last_played_at=now,
)
.on_conflict_do_update(
index_elements=["scene_id"],
index_elements=["device_id", "scene_id"],
set_={
"position_sec": position_sec,
# duration_sec: zachowaj istniejący gdy body nie podaje
@ -91,7 +94,7 @@ def upsert_progress(
)
session.execute(stmt)
session.commit()
row = session.get(ScenePlayProgress, scene_id)
row = session.get(ScenePlayProgress, (device_id, scene_id))
assert row is not None
return ProgressOut(
scene_id=scene_id,
@ -109,8 +112,9 @@ def upsert_progress(
def remove_progress(
scene_id: uuid.UUID,
session: Annotated[Session, Depends(get_session)],
device_id: Annotated[str, Depends(get_device_id)],
) -> None:
row = session.get(ScenePlayProgress, scene_id)
row = session.get(ScenePlayProgress, (device_id, scene_id))
if row is None:
return
session.delete(row)
@ -133,6 +137,7 @@ def upsert_movie_progress(
movie_id: uuid.UUID,
body: ProgressIn,
session: Annotated[Session, Depends(get_session)],
device_id: Annotated[str, Depends(get_device_id)],
) -> MovieProgressOut:
"""Mirror upsert_progress dla filmów (bug-report b207ff17 2026-05-26 —
"przydałoby się oznaczenie filmów już obejrzanych")."""
@ -151,6 +156,7 @@ def upsert_movie_progress(
stmt = (
pg_insert(MoviePlayProgress)
.values(
device_id=device_id,
movie_id=movie_id,
position_sec=position_sec,
duration_sec=body.duration_sec,
@ -158,7 +164,7 @@ def upsert_movie_progress(
last_played_at=now,
)
.on_conflict_do_update(
index_elements=["movie_id"],
index_elements=["device_id", "movie_id"],
set_={
"position_sec": position_sec,
"duration_sec": (
@ -173,7 +179,7 @@ def upsert_movie_progress(
)
session.execute(stmt)
session.commit()
row = session.get(MoviePlayProgress, movie_id)
row = session.get(MoviePlayProgress, (device_id, movie_id))
assert row is not None
return MovieProgressOut(
movie_id=movie_id,
@ -191,8 +197,9 @@ def upsert_movie_progress(
def remove_movie_progress(
movie_id: uuid.UUID,
session: Annotated[Session, Depends(get_session)],
device_id: Annotated[str, Depends(get_device_id)],
) -> None:
row = session.get(MoviePlayProgress, movie_id)
row = session.get(MoviePlayProgress, (device_id, movie_id))
if row is None:
return
session.delete(row)
@ -214,6 +221,7 @@ class WatchListOut(BaseModel):
@router.get("/watch/recent", response_model=WatchListOut)
def list_recent(
session: Annotated[Session, Depends(get_session)],
device_id: Annotated[str, Depends(get_device_id)],
limit: int = Query(default=10, ge=1, le=50),
include_finished: bool = Query(default=False),
) -> WatchListOut:
@ -222,6 +230,7 @@ def list_recent(
stmt = (
select(ScenePlayProgress, Scene)
.join(Scene, Scene.id == ScenePlayProgress.scene_id)
.where(ScenePlayProgress.device_id == device_id)
.order_by(desc(ScenePlayProgress.last_played_at))
.limit(limit)
)
@ -232,7 +241,7 @@ def list_recent(
for prog, scene in session.execute(stmt).all():
items.append(
WatchEntry(
scene=_build_scene_out(session, scene),
scene=_build_scene_out(session, scene, device_id=device_id),
position_sec=prog.position_sec,
duration_sec=prog.duration_sec,
finished=prog.finished,

View file

@ -14,6 +14,7 @@ from app.api.blacklist import router as blacklist_router
from app.api.bug_reports import router as bug_reports_router
from app.api.expo_updates import router as expo_updates_router
from app.api.favorites import router as favorites_router
from app.api.me import router as me_router
from app.api.movies import router as movies_router
from app.api.playback import movies_router as movies_playback_router
from app.api.playback import router as playback_router
@ -80,6 +81,7 @@ app.include_router(blacklist_router)
app.include_router(bug_reports_router)
app.include_router(expo_updates_router)
app.include_router(watch_router)
app.include_router(me_router)
app.include_router(admin_router)
app.include_router(admin_html_router)
app.include_router(seo_router)

View file

@ -4,7 +4,7 @@ from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, func
from sqlalchemy import DateTime, ForeignKey, String, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
@ -13,6 +13,7 @@ from app.models.base import Base
class BlacklistedPerformer(Base):
__tablename__ = "blacklisted_performers"
device_id: Mapped[str] = mapped_column(String(64), primary_key=True)
performer_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("performers.id", ondelete="CASCADE"),
@ -25,6 +26,7 @@ class BlacklistedPerformer(Base):
class BlacklistedStudio(Base):
__tablename__ = "blacklisted_studios"
device_id: Mapped[str] = mapped_column(String(64), primary_key=True)
studio_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("studios.id", ondelete="CASCADE"),
@ -37,6 +39,7 @@ class BlacklistedStudio(Base):
class BlacklistedTag(Base):
__tablename__ = "blacklisted_tags"
device_id: Mapped[str] = mapped_column(String(64), primary_key=True)
tag_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("tags.id", ondelete="CASCADE"),

View file

@ -4,7 +4,7 @@ from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, func
from sqlalchemy import DateTime, ForeignKey, String, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
@ -14,6 +14,7 @@ from app.models.base import Base
class FavoriteMovie(Base):
__tablename__ = "favorite_movies"
device_id: Mapped[str] = mapped_column(String(64), primary_key=True)
movie_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("movies.id", ondelete="CASCADE"),

View file

@ -4,7 +4,7 @@ from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, func
from sqlalchemy import DateTime, ForeignKey, String, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
@ -14,6 +14,7 @@ from app.models.base import Base
class FavoritePerformer(Base):
__tablename__ = "favorite_performers"
device_id: Mapped[str] = mapped_column(String(64), primary_key=True)
performer_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("performers.id", ondelete="CASCADE"),

View file

@ -4,7 +4,7 @@ from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, func
from sqlalchemy import DateTime, ForeignKey, String, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
@ -14,6 +14,7 @@ from app.models.base import Base
class FavoriteScene(Base):
__tablename__ = "favorite_scenes"
device_id: Mapped[str] = mapped_column(String(64), primary_key=True)
scene_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("scenes.id", ondelete="CASCADE"),

View file

@ -4,7 +4,7 @@ from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, func
from sqlalchemy import DateTime, ForeignKey, String, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
@ -14,6 +14,7 @@ from app.models.base import Base
class FavoriteStudio(Base):
__tablename__ = "favorite_studios"
device_id: Mapped[str] = mapped_column(String(64), primary_key=True)
studio_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("studios.id", ondelete="CASCADE"),

View file

@ -4,7 +4,7 @@ from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, func
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
@ -14,6 +14,7 @@ from app.models.base import Base
class ScenePlayProgress(Base):
__tablename__ = "scene_play_progress"
device_id: Mapped[str] = mapped_column(String(64), primary_key=True)
scene_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("scenes.id", ondelete="CASCADE"),
@ -42,6 +43,7 @@ class MoviePlayProgress(Base):
__tablename__ = "movie_play_progress"
device_id: Mapped[str] = mapped_column(String(64), primary_key=True)
movie_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("movies.id", ondelete="CASCADE"),