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:
parent
953068f0db
commit
c8baa11604
16 changed files with 285 additions and 62 deletions
58
alembic/versions/20260608_0022_device_scoped_user_state.py
Normal file
58
alembic/versions/20260608_0022_device_scoped_user_state.py
Normal 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")
|
||||||
|
|
@ -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
29
app/api/device.py
Normal 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
|
||||||
|
|
@ -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
68
app/api/me.py
Normal 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)
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue