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 import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.device import get_device_id
from app.auth import require_api_key from app.auth import require_api_key
from app.db import get_session from app.db import get_session
from app.models.blacklist import ( from app.models.blacklist import (
@ -53,20 +54,24 @@ class BlacklistOut(BaseModel):
@router.get("", response_model=BlacklistOut) @router.get("", response_model=BlacklistOut)
def list_blacklist( def list_blacklist(
session: Annotated[Session, Depends(get_session)], session: Annotated[Session, Depends(get_session)],
device_id: Annotated[str, Depends(get_device_id)],
) -> BlacklistOut: ) -> BlacklistOut:
perfs = session.execute( perfs = session.execute(
select(BlacklistedPerformer.performer_id, Performer.canonical_name, Performer.slug) select(BlacklistedPerformer.performer_id, Performer.canonical_name, Performer.slug)
.join(Performer, Performer.id == BlacklistedPerformer.performer_id) .join(Performer, Performer.id == BlacklistedPerformer.performer_id)
.where(BlacklistedPerformer.device_id == device_id)
.order_by(Performer.canonical_name) .order_by(Performer.canonical_name)
).all() ).all()
studios = session.execute( studios = session.execute(
select(BlacklistedStudio.studio_id, Studio.name, Studio.slug) select(BlacklistedStudio.studio_id, Studio.name, Studio.slug)
.join(Studio, Studio.id == BlacklistedStudio.studio_id) .join(Studio, Studio.id == BlacklistedStudio.studio_id)
.where(BlacklistedStudio.device_id == device_id)
.order_by(Studio.name) .order_by(Studio.name)
).all() ).all()
tags = session.execute( tags = session.execute(
select(BlacklistedTag.tag_id, Tag.name, Tag.slug) select(BlacklistedTag.tag_id, Tag.name, Tag.slug)
.join(Tag, Tag.id == BlacklistedTag.tag_id) .join(Tag, Tag.id == BlacklistedTag.tag_id)
.where(BlacklistedTag.device_id == device_id)
.order_by(Tag.name) .order_by(Tag.name)
).all() ).all()
return BlacklistOut( return BlacklistOut(
@ -91,13 +96,14 @@ def add_blacklist(
kind: Kind, kind: Kind,
entity_id: uuid.UUID, entity_id: uuid.UUID,
session: Annotated[Session, Depends(get_session)], session: Annotated[Session, Depends(get_session)],
device_id: Annotated[str, Depends(get_device_id)],
) -> dict: ) -> dict:
bl_model, parent_model, fk = _kind_to_entity(kind) bl_model, parent_model, fk = _kind_to_entity(kind)
if session.get(parent_model, entity_id) is None: if session.get(parent_model, entity_id) is None:
raise HTTPException(status_code=404, detail=f"{kind} not found") 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} 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() session.commit()
return {"kind": kind, "id": str(entity_id), "created": True} return {"kind": kind, "id": str(entity_id), "created": True}
@ -107,9 +113,10 @@ def remove_blacklist(
kind: Kind, kind: Kind,
entity_id: uuid.UUID, entity_id: uuid.UUID,
session: Annotated[Session, Depends(get_session)], session: Annotated[Session, Depends(get_session)],
device_id: Annotated[str, Depends(get_device_id)],
) -> None: ) -> None:
bl_model, _, _ = _kind_to_entity(kind) 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: if row is None:
return # idempotent return # idempotent
session.delete(row) 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 import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.device import get_device_id
from app.auth import require_api_key from app.auth import require_api_key
from app.db import get_session from app.db import get_session
from app.models.favorite_movie import FavoriteMovie from app.models.favorite_movie import FavoriteMovie
@ -65,10 +66,12 @@ class FavoriteListOut(BaseModel):
@router.get("", response_model=FavoriteListOut) @router.get("", response_model=FavoriteListOut)
def list_favorites( def list_favorites(
session: Annotated[Session, Depends(get_session)], session: Annotated[Session, Depends(get_session)],
device_id: Annotated[str, Depends(get_device_id)],
) -> FavoriteListOut: ) -> FavoriteListOut:
rows = session.execute( rows = session.execute(
select(FavoritePerformer, Performer) select(FavoritePerformer, Performer)
.join(Performer, Performer.id == FavoritePerformer.performer_id) .join(Performer, Performer.id == FavoritePerformer.performer_id)
.where(FavoritePerformer.device_id == device_id)
.order_by(Performer.canonical_name) .order_by(Performer.canonical_name)
).all() ).all()
if not rows: if not rows:
@ -148,14 +151,15 @@ class FavoriteAddOut(BaseModel):
def add_favorite( def add_favorite(
performer_id: uuid.UUID, performer_id: uuid.UUID,
session: Annotated[Session, Depends(get_session)], session: Annotated[Session, Depends(get_session)],
device_id: Annotated[str, Depends(get_device_id)],
) -> FavoriteAddOut: ) -> FavoriteAddOut:
perf = session.get(Performer, performer_id) perf = session.get(Performer, performer_id)
if perf is None: if perf is None:
raise HTTPException(status_code=404, detail="performer not found") 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: if existing is not None:
return FavoriteAddOut(performer_id=performer_id, created=False) 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() session.commit()
return FavoriteAddOut(performer_id=performer_id, created=True) return FavoriteAddOut(performer_id=performer_id, created=True)
@ -164,8 +168,9 @@ def add_favorite(
def remove_favorite( def remove_favorite(
performer_id: uuid.UUID, performer_id: uuid.UUID,
session: Annotated[Session, Depends(get_session)], session: Annotated[Session, Depends(get_session)],
device_id: Annotated[str, Depends(get_device_id)],
) -> None: ) -> None:
fav = session.get(FavoritePerformer, performer_id) fav = session.get(FavoritePerformer, (device_id, performer_id))
if fav is None: if fav is None:
# idempotent — brak ulubionego = nie ma nic do usunięcia, success # idempotent — brak ulubionego = nie ma nic do usunięcia, success
return return
@ -182,8 +187,9 @@ class SeenOut(BaseModel):
def mark_seen( def mark_seen(
performer_id: uuid.UUID, performer_id: uuid.UUID,
session: Annotated[Session, Depends(get_session)], session: Annotated[Session, Depends(get_session)],
device_id: Annotated[str, Depends(get_device_id)],
) -> SeenOut: ) -> SeenOut:
fav = session.get(FavoritePerformer, performer_id) fav = session.get(FavoritePerformer, (device_id, performer_id))
if fav is None: if fav is None:
raise HTTPException(status_code=404, detail="not in favorites") raise HTTPException(status_code=404, detail="not in favorites")
fav.last_seen_at = datetime.now(UTC) fav.last_seen_at = datetime.now(UTC)
@ -213,10 +219,12 @@ class FavoriteStudioListOut(BaseModel):
@router.get("/studios", response_model=FavoriteStudioListOut) @router.get("/studios", response_model=FavoriteStudioListOut)
def list_favorite_studios( def list_favorite_studios(
session: Annotated[Session, Depends(get_session)], session: Annotated[Session, Depends(get_session)],
device_id: Annotated[str, Depends(get_device_id)],
) -> FavoriteStudioListOut: ) -> FavoriteStudioListOut:
rows = session.execute( rows = session.execute(
select(FavoriteStudio, Studio) select(FavoriteStudio, Studio)
.join(Studio, Studio.id == FavoriteStudio.studio_id) .join(Studio, Studio.id == FavoriteStudio.studio_id)
.where(FavoriteStudio.device_id == device_id)
.order_by(Studio.name) .order_by(Studio.name)
).all() ).all()
if not rows: if not rows:
@ -282,14 +290,15 @@ class FavoriteStudioAddOut(BaseModel):
def add_favorite_studio( def add_favorite_studio(
studio_id: uuid.UUID, studio_id: uuid.UUID,
session: Annotated[Session, Depends(get_session)], session: Annotated[Session, Depends(get_session)],
device_id: Annotated[str, Depends(get_device_id)],
) -> FavoriteStudioAddOut: ) -> FavoriteStudioAddOut:
st = session.get(Studio, studio_id) st = session.get(Studio, studio_id)
if st is None: if st is None:
raise HTTPException(status_code=404, detail="studio not found") 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: if existing is not None:
return FavoriteStudioAddOut(studio_id=studio_id, created=False) 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() session.commit()
return FavoriteStudioAddOut(studio_id=studio_id, created=True) return FavoriteStudioAddOut(studio_id=studio_id, created=True)
@ -298,8 +307,9 @@ def add_favorite_studio(
def remove_favorite_studio( def remove_favorite_studio(
studio_id: uuid.UUID, studio_id: uuid.UUID,
session: Annotated[Session, Depends(get_session)], session: Annotated[Session, Depends(get_session)],
device_id: Annotated[str, Depends(get_device_id)],
) -> None: ) -> None:
fav = session.get(FavoriteStudio, studio_id) fav = session.get(FavoriteStudio, (device_id, studio_id))
if fav is None: if fav is None:
return return
session.delete(fav) session.delete(fav)
@ -315,8 +325,9 @@ class SeenStudioOut(BaseModel):
def mark_studio_seen( def mark_studio_seen(
studio_id: uuid.UUID, studio_id: uuid.UUID,
session: Annotated[Session, Depends(get_session)], session: Annotated[Session, Depends(get_session)],
device_id: Annotated[str, Depends(get_device_id)],
) -> SeenStudioOut: ) -> SeenStudioOut:
fav = session.get(FavoriteStudio, studio_id) fav = session.get(FavoriteStudio, (device_id, studio_id))
if fav is None: if fav is None:
raise HTTPException(status_code=404, detail="not in favorites") raise HTTPException(status_code=404, detail="not in favorites")
fav.last_seen_at = datetime.now(UTC) fav.last_seen_at = datetime.now(UTC)
@ -350,11 +361,13 @@ class FavoriteMovieListOut(BaseModel):
@router.get("/movies", response_model=FavoriteMovieListOut) @router.get("/movies", response_model=FavoriteMovieListOut)
def list_favorite_movies( def list_favorite_movies(
session: Annotated[Session, Depends(get_session)], session: Annotated[Session, Depends(get_session)],
device_id: Annotated[str, Depends(get_device_id)],
) -> FavoriteMovieListOut: ) -> FavoriteMovieListOut:
rows = session.execute( rows = session.execute(
select(FavoriteMovie, Movie, Studio) select(FavoriteMovie, Movie, Studio)
.join(Movie, Movie.id == FavoriteMovie.movie_id) .join(Movie, Movie.id == FavoriteMovie.movie_id)
.outerjoin(Studio, Studio.id == Movie.studio_id) .outerjoin(Studio, Studio.id == Movie.studio_id)
.where(FavoriteMovie.device_id == device_id)
.order_by(Movie.title) .order_by(Movie.title)
).all() ).all()
items = [ items = [
@ -386,14 +399,15 @@ class FavoriteMovieAddOut(BaseModel):
def add_favorite_movie( def add_favorite_movie(
movie_id: uuid.UUID, movie_id: uuid.UUID,
session: Annotated[Session, Depends(get_session)], session: Annotated[Session, Depends(get_session)],
device_id: Annotated[str, Depends(get_device_id)],
) -> FavoriteMovieAddOut: ) -> FavoriteMovieAddOut:
movie = session.get(Movie, movie_id) movie = session.get(Movie, movie_id)
if movie is None: if movie is None:
raise HTTPException(status_code=404, detail="movie not found") 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: if existing is not None:
return FavoriteMovieAddOut(movie_id=movie_id, created=False) 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() session.commit()
return FavoriteMovieAddOut(movie_id=movie_id, created=True) return FavoriteMovieAddOut(movie_id=movie_id, created=True)
@ -402,8 +416,9 @@ def add_favorite_movie(
def remove_favorite_movie( def remove_favorite_movie(
movie_id: uuid.UUID, movie_id: uuid.UUID,
session: Annotated[Session, Depends(get_session)], session: Annotated[Session, Depends(get_session)],
device_id: Annotated[str, Depends(get_device_id)],
) -> None: ) -> None:
fav = session.get(FavoriteMovie, movie_id) fav = session.get(FavoriteMovie, (device_id, movie_id))
if fav is None: if fav is None:
return return
session.delete(fav) session.delete(fav)
@ -419,8 +434,9 @@ class SeenMovieOut(BaseModel):
def mark_movie_seen( def mark_movie_seen(
movie_id: uuid.UUID, movie_id: uuid.UUID,
session: Annotated[Session, Depends(get_session)], session: Annotated[Session, Depends(get_session)],
device_id: Annotated[str, Depends(get_device_id)],
) -> SeenMovieOut: ) -> SeenMovieOut:
fav = session.get(FavoriteMovie, movie_id) fav = session.get(FavoriteMovie, (device_id, movie_id))
if fav is None: if fav is None:
raise HTTPException(status_code=404, detail="not in favorites") raise HTTPException(status_code=404, detail="not in favorites")
fav.last_seen_at = datetime.now(UTC) 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, MoviePerformer,
MovieTag, MovieTag,
) )
from app.api.device import LEGACY_DEVICE, get_device_id
from app.models.favorite_movie import FavoriteMovie from app.models.favorite_movie import FavoriteMovie
from app.models.movie_playback_source import MoviePlaybackSource from app.models.movie_playback_source import MoviePlaybackSource
from app.models.performer import Performer from app.models.performer import Performer
@ -49,6 +50,7 @@ def _split_csv(raw: str | None) -> list[str]:
@router.get("", response_model=MovieListOut) @router.get("", response_model=MovieListOut)
def list_movies( def list_movies(
session: Annotated[Session, Depends(get_session)], session: Annotated[Session, Depends(get_session)],
device_id: Annotated[str, Depends(get_device_id)],
q: str | None = Query(default=None, description="Title search (trgm)"), q: str | None = Query(default=None, description="Title search (trgm)"),
studio_slugs: str | None = Query(default=None, description="Comma-separated studio slugs (OR)"), 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)"), 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) base = base.limit(per_page).offset((page - 1) * per_page)
movies = session.execute(base).scalars().all() 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) 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( def get_movie(
movie_id: uuid.UUID, movie_id: uuid.UUID,
session: Annotated[Session, Depends(get_session)], session: Annotated[Session, Depends(get_session)],
device_id: Annotated[str, Depends(get_device_id)],
) -> MovieOut: ) -> MovieOut:
movie = session.get(Movie, movie_id) movie = session.get(Movie, movie_id)
if movie is None: if movie is None:
raise HTTPException(status_code=404, detail="movie not found") 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 studio_out: StudioOut | None = None
if movie.studio_id: if movie.studio_id:
studio = session.get(Studio, 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)) pb_rows = sorted(pb_rows, key=lambda p: _movie_origin_priority(p.origin))
playback_sources = [PlaybackSourceOut.model_validate(p) for p in pb_rows] playback_sources = [PlaybackSourceOut.model_validate(p) for p in pb_rows]
is_fav = session.get(FavoriteMovie, movie.id) is not None is_fav = session.get(FavoriteMovie, (device_id, movie.id)) is not None
progress = session.get(MoviePlayProgress, movie.id) progress = session.get(MoviePlayProgress, (device_id, movie.id))
return MovieOut( return MovieOut(
id=movie.id, id=movie.id,

View file

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

View file

@ -24,6 +24,7 @@ from app.api.schemas import (
TagOut, TagOut,
) )
from app.db import get_session 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.favorite_scene import FavoriteScene
from app.models.performer import Performer from app.models.performer import Performer
from app.models.play_progress import ScenePlayProgress 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). # 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 # 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. # 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 _BLACKLIST_EMPTY_TTL = 300.0
def _blacklists_empty(session: Session) -> bool: def _blacklists_empty(session: Session, device_id: str) -> bool:
"""True gdy WSZYSTKIE 3 blacklisty puste → można pominąć NOT EXISTS klauzule.""" """True gdy WSZYSTKIE 3 blacklisty TEGO device puste → pomiń NOT EXISTS klauzule."""
import time as _time import time as _time
from app.models.blacklist import ( from app.models.blacklist import (
BlacklistedPerformer, BlacklistedPerformer,
@ -86,18 +88,17 @@ def _blacklists_empty(session: Session) -> bool:
BlacklistedTag, BlacklistedTag,
) )
now = _time.monotonic() now = _time.monotonic()
if _BLACKLIST_EMPTY_CACHE["checked"] and (now - _BLACKLIST_EMPTY_CACHE["ts"]) < _BLACKLIST_EMPTY_TTL: cached = _BLACKLIST_EMPTY_CACHE.get(device_id)
return _BLACKLIST_EMPTY_CACHE["val"] if cached and (now - cached[0]) < _BLACKLIST_EMPTY_TTL:
return cached[1]
has_any = session.execute( has_any = session.execute(
select( select(
exists(select(1).select_from(BlacklistedPerformer)) exists(select(1).select_from(BlacklistedPerformer).where(BlacklistedPerformer.device_id == device_id))
| exists(select(1).select_from(BlacklistedStudio)) | exists(select(1).select_from(BlacklistedStudio).where(BlacklistedStudio.device_id == device_id))
| exists(select(1).select_from(BlacklistedTag)) | exists(select(1).select_from(BlacklistedTag).where(BlacklistedTag.device_id == device_id))
) )
).scalar_one() ).scalar_one()
_BLACKLIST_EMPTY_CACHE["ts"] = now _BLACKLIST_EMPTY_CACHE[device_id] = (now, not has_any)
_BLACKLIST_EMPTY_CACHE["val"] = not has_any
_BLACKLIST_EMPTY_CACHE["checked"] = True
return not has_any return not has_any
@ -110,6 +111,7 @@ def _split_csv(raw: str | None) -> list[str]:
@router.get("", response_model=SceneListOut) @router.get("", response_model=SceneListOut)
def list_scenes( def list_scenes(
session: Annotated[Session, Depends(get_session)], 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)"), 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_slug: str | None = Query(default=None, description="DEPRECATED — użyj studio_slugs"),
studio_slugs: str | None = Query( studio_slugs: str | None = Query(
@ -259,7 +261,7 @@ def list_scenes(
# performera, jest na blacklisted studio, lub ma JAKIKOLWIEK blacklisted tag → out. # performera, jest na blacklisted studio, lub ma JAKIKOLWIEK blacklisted tag → out.
# Pomijamy gdy wszystkie 3 blacklisty puste (typowy stan single-user) — te NOT EXISTS # 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. # 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 ( from app.models.blacklist import (
BlacklistedPerformer, BlacklistedPerformer,
BlacklistedStudio, BlacklistedStudio,
@ -269,18 +271,28 @@ def list_scenes(
~exists( ~exists(
select(1) select(1)
.select_from(ScenePerformer) .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) .where(ScenePerformer.scene_id == Scene.id)
) )
) )
base = base.where( 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( base = base.where(
~exists( ~exists(
select(1) select(1)
.select_from(SceneTag) .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) .where(SceneTag.scene_id == Scene.id)
) )
) )
@ -406,7 +418,7 @@ def list_scenes(
total = (page - 1) * per_page + len(rows) total = (page - 1) * per_page + len(rows)
total_capped = has_more 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( return SceneListOut(
items=items, items=items,
@ -422,11 +434,12 @@ def list_scenes(
def get_scene( def get_scene(
scene_id: uuid.UUID, scene_id: uuid.UUID,
session: Annotated[Session, Depends(get_session)], session: Annotated[Session, Depends(get_session)],
device_id: Annotated[str, Depends(get_device_id)],
) -> SceneOut: ) -> SceneOut:
scene = session.get(Scene, scene_id) scene = session.get(Scene, scene_id)
if scene is None: if scene is None:
raise HTTPException(status_code=404, detail="scene not found") 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: def _needs_proxy(url: str) -> bool:
@ -452,7 +465,7 @@ def _wrap_image_proxy(url: str, referer: str) -> str:
def _build_scenes_out_batch( 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]: ) -> list[SceneOut]:
"""Batch-fetch wszystkich relacji dla N scen w 7 zapytaniach (zamiast 7×N). """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) out.animated_thumbnail_url = _wrap_image_proxy(out.animated_thumbnail_url, p.page_url)
pb_by_scene[p.scene_id].append(out) pb_by_scene[p.scene_id].append(out)
# 6) Progress # 6) Progress (device-scoped)
progress_by_scene: dict = {} progress_by_scene: dict = {}
for prog in session.execute( 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(): ).scalars():
progress_by_scene[prog.scene_id] = prog progress_by_scene[prog.scene_id] = prog
# 7) Favorites # 7) Favorites (device-scoped)
fav_scene_ids: set = set( fav_scene_ids: set = set(
session.execute( session.execute(
select(FavoriteScene.scene_id).where( select(FavoriteScene.scene_id).where(
FavoriteScene.scene_id.in_(scene_ids) FavoriteScene.scene_id.in_(scene_ids),
FavoriteScene.device_id == device_id,
) )
).scalars() ).scalars()
) )
@ -636,7 +653,7 @@ def _build_scenes_out_batch(
return out 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 studio_out: StudioOut | None = None
if scene.studio_id is not None: if scene.studio_id is not None:
st = session.get(Studio, scene.studio_id) 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 "")) playback_out.sort(key=lambda o: (_resolve_rank(o.origin), o.origin or ""))
progress = session.get(ScenePlayProgress, scene.id) progress = session.get(ScenePlayProgress, (device_id, scene.id))
is_fav = session.get(FavoriteScene, scene.id) is not None is_fav = session.get(FavoriteScene, (device_id, scene.id)) is not None
return SceneOut( return SceneOut(
id=scene.id, id=scene.id,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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