diff --git a/alembic/versions/20260608_0022_device_scoped_user_state.py b/alembic/versions/20260608_0022_device_scoped_user_state.py new file mode 100644 index 0000000..9de970f --- /dev/null +++ b/alembic/versions/20260608_0022_device_scoped_user_state.py @@ -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, )`. + +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") diff --git a/app/api/blacklist.py b/app/api/blacklist.py index 33774d3..4975fe8 100644 --- a/app/api/blacklist.py +++ b/app/api/blacklist.py @@ -20,6 +20,7 @@ from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.orm import Session +from app.api.device import get_device_id from app.auth import require_api_key from app.db import get_session from app.models.blacklist import ( @@ -53,20 +54,24 @@ class BlacklistOut(BaseModel): @router.get("", response_model=BlacklistOut) def list_blacklist( session: Annotated[Session, Depends(get_session)], + device_id: Annotated[str, Depends(get_device_id)], ) -> BlacklistOut: perfs = session.execute( select(BlacklistedPerformer.performer_id, Performer.canonical_name, Performer.slug) .join(Performer, Performer.id == BlacklistedPerformer.performer_id) + .where(BlacklistedPerformer.device_id == device_id) .order_by(Performer.canonical_name) ).all() studios = session.execute( select(BlacklistedStudio.studio_id, Studio.name, Studio.slug) .join(Studio, Studio.id == BlacklistedStudio.studio_id) + .where(BlacklistedStudio.device_id == device_id) .order_by(Studio.name) ).all() tags = session.execute( select(BlacklistedTag.tag_id, Tag.name, Tag.slug) .join(Tag, Tag.id == BlacklistedTag.tag_id) + .where(BlacklistedTag.device_id == device_id) .order_by(Tag.name) ).all() return BlacklistOut( @@ -91,13 +96,14 @@ def add_blacklist( kind: Kind, entity_id: uuid.UUID, session: Annotated[Session, Depends(get_session)], + device_id: Annotated[str, Depends(get_device_id)], ) -> dict: bl_model, parent_model, fk = _kind_to_entity(kind) if session.get(parent_model, entity_id) is None: raise HTTPException(status_code=404, detail=f"{kind} not found") - if session.get(bl_model, entity_id) is not None: + if session.get(bl_model, (device_id, entity_id)) is not None: return {"kind": kind, "id": str(entity_id), "created": False} - session.add(bl_model(**{fk: entity_id})) + session.add(bl_model(**{"device_id": device_id, fk: entity_id})) session.commit() return {"kind": kind, "id": str(entity_id), "created": True} @@ -107,9 +113,10 @@ def remove_blacklist( kind: Kind, entity_id: uuid.UUID, session: Annotated[Session, Depends(get_session)], + device_id: Annotated[str, Depends(get_device_id)], ) -> None: bl_model, _, _ = _kind_to_entity(kind) - row = session.get(bl_model, entity_id) + row = session.get(bl_model, (device_id, entity_id)) if row is None: return # idempotent session.delete(row) diff --git a/app/api/device.py b/app/api/device.py new file mode 100644 index 0000000..2ea605f --- /dev/null +++ b/app/api/device.py @@ -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 diff --git a/app/api/favorites.py b/app/api/favorites.py index 1d3216e..19a2275 100644 --- a/app/api/favorites.py +++ b/app/api/favorites.py @@ -30,6 +30,7 @@ from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.orm import Session +from app.api.device import get_device_id from app.auth import require_api_key from app.db import get_session from app.models.favorite_movie import FavoriteMovie @@ -65,10 +66,12 @@ class FavoriteListOut(BaseModel): @router.get("", response_model=FavoriteListOut) def list_favorites( session: Annotated[Session, Depends(get_session)], + device_id: Annotated[str, Depends(get_device_id)], ) -> FavoriteListOut: rows = session.execute( select(FavoritePerformer, Performer) .join(Performer, Performer.id == FavoritePerformer.performer_id) + .where(FavoritePerformer.device_id == device_id) .order_by(Performer.canonical_name) ).all() if not rows: @@ -148,14 +151,15 @@ class FavoriteAddOut(BaseModel): def add_favorite( performer_id: uuid.UUID, session: Annotated[Session, Depends(get_session)], + device_id: Annotated[str, Depends(get_device_id)], ) -> FavoriteAddOut: perf = session.get(Performer, performer_id) if perf is None: raise HTTPException(status_code=404, detail="performer not found") - existing = session.get(FavoritePerformer, performer_id) + existing = session.get(FavoritePerformer, (device_id, performer_id)) if existing is not None: return FavoriteAddOut(performer_id=performer_id, created=False) - session.add(FavoritePerformer(performer_id=performer_id)) + session.add(FavoritePerformer(device_id=device_id, performer_id=performer_id)) session.commit() return FavoriteAddOut(performer_id=performer_id, created=True) @@ -164,8 +168,9 @@ def add_favorite( def remove_favorite( performer_id: uuid.UUID, session: Annotated[Session, Depends(get_session)], + device_id: Annotated[str, Depends(get_device_id)], ) -> None: - fav = session.get(FavoritePerformer, performer_id) + fav = session.get(FavoritePerformer, (device_id, performer_id)) if fav is None: # idempotent — brak ulubionego = nie ma nic do usunięcia, success return @@ -182,8 +187,9 @@ class SeenOut(BaseModel): def mark_seen( performer_id: uuid.UUID, session: Annotated[Session, Depends(get_session)], + device_id: Annotated[str, Depends(get_device_id)], ) -> SeenOut: - fav = session.get(FavoritePerformer, performer_id) + fav = session.get(FavoritePerformer, (device_id, performer_id)) if fav is None: raise HTTPException(status_code=404, detail="not in favorites") fav.last_seen_at = datetime.now(UTC) @@ -213,10 +219,12 @@ class FavoriteStudioListOut(BaseModel): @router.get("/studios", response_model=FavoriteStudioListOut) def list_favorite_studios( session: Annotated[Session, Depends(get_session)], + device_id: Annotated[str, Depends(get_device_id)], ) -> FavoriteStudioListOut: rows = session.execute( select(FavoriteStudio, Studio) .join(Studio, Studio.id == FavoriteStudio.studio_id) + .where(FavoriteStudio.device_id == device_id) .order_by(Studio.name) ).all() if not rows: @@ -282,14 +290,15 @@ class FavoriteStudioAddOut(BaseModel): def add_favorite_studio( studio_id: uuid.UUID, session: Annotated[Session, Depends(get_session)], + device_id: Annotated[str, Depends(get_device_id)], ) -> FavoriteStudioAddOut: st = session.get(Studio, studio_id) if st is None: raise HTTPException(status_code=404, detail="studio not found") - existing = session.get(FavoriteStudio, studio_id) + existing = session.get(FavoriteStudio, (device_id, studio_id)) if existing is not None: return FavoriteStudioAddOut(studio_id=studio_id, created=False) - session.add(FavoriteStudio(studio_id=studio_id)) + session.add(FavoriteStudio(device_id=device_id, studio_id=studio_id)) session.commit() return FavoriteStudioAddOut(studio_id=studio_id, created=True) @@ -298,8 +307,9 @@ def add_favorite_studio( def remove_favorite_studio( studio_id: uuid.UUID, session: Annotated[Session, Depends(get_session)], + device_id: Annotated[str, Depends(get_device_id)], ) -> None: - fav = session.get(FavoriteStudio, studio_id) + fav = session.get(FavoriteStudio, (device_id, studio_id)) if fav is None: return session.delete(fav) @@ -315,8 +325,9 @@ class SeenStudioOut(BaseModel): def mark_studio_seen( studio_id: uuid.UUID, session: Annotated[Session, Depends(get_session)], + device_id: Annotated[str, Depends(get_device_id)], ) -> SeenStudioOut: - fav = session.get(FavoriteStudio, studio_id) + fav = session.get(FavoriteStudio, (device_id, studio_id)) if fav is None: raise HTTPException(status_code=404, detail="not in favorites") fav.last_seen_at = datetime.now(UTC) @@ -350,11 +361,13 @@ class FavoriteMovieListOut(BaseModel): @router.get("/movies", response_model=FavoriteMovieListOut) def list_favorite_movies( session: Annotated[Session, Depends(get_session)], + device_id: Annotated[str, Depends(get_device_id)], ) -> FavoriteMovieListOut: rows = session.execute( select(FavoriteMovie, Movie, Studio) .join(Movie, Movie.id == FavoriteMovie.movie_id) .outerjoin(Studio, Studio.id == Movie.studio_id) + .where(FavoriteMovie.device_id == device_id) .order_by(Movie.title) ).all() items = [ @@ -386,14 +399,15 @@ class FavoriteMovieAddOut(BaseModel): def add_favorite_movie( movie_id: uuid.UUID, session: Annotated[Session, Depends(get_session)], + device_id: Annotated[str, Depends(get_device_id)], ) -> FavoriteMovieAddOut: movie = session.get(Movie, movie_id) if movie is None: raise HTTPException(status_code=404, detail="movie not found") - existing = session.get(FavoriteMovie, movie_id) + existing = session.get(FavoriteMovie, (device_id, movie_id)) if existing is not None: return FavoriteMovieAddOut(movie_id=movie_id, created=False) - session.add(FavoriteMovie(movie_id=movie_id)) + session.add(FavoriteMovie(device_id=device_id, movie_id=movie_id)) session.commit() return FavoriteMovieAddOut(movie_id=movie_id, created=True) @@ -402,8 +416,9 @@ def add_favorite_movie( def remove_favorite_movie( movie_id: uuid.UUID, session: Annotated[Session, Depends(get_session)], + device_id: Annotated[str, Depends(get_device_id)], ) -> None: - fav = session.get(FavoriteMovie, movie_id) + fav = session.get(FavoriteMovie, (device_id, movie_id)) if fav is None: return session.delete(fav) @@ -419,8 +434,9 @@ class SeenMovieOut(BaseModel): def mark_movie_seen( movie_id: uuid.UUID, session: Annotated[Session, Depends(get_session)], + device_id: Annotated[str, Depends(get_device_id)], ) -> SeenMovieOut: - fav = session.get(FavoriteMovie, movie_id) + fav = session.get(FavoriteMovie, (device_id, movie_id)) if fav is None: raise HTTPException(status_code=404, detail="not in favorites") fav.last_seen_at = datetime.now(UTC) diff --git a/app/api/me.py b/app/api/me.py new file mode 100644 index 0000000..11f77ce --- /dev/null +++ b/app/api/me.py @@ -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) diff --git a/app/api/movies.py b/app/api/movies.py index b443c44..c26b3af 100644 --- a/app/api/movies.py +++ b/app/api/movies.py @@ -27,6 +27,7 @@ from app.models.movie import ( MoviePerformer, MovieTag, ) +from app.api.device import LEGACY_DEVICE, get_device_id from app.models.favorite_movie import FavoriteMovie from app.models.movie_playback_source import MoviePlaybackSource from app.models.performer import Performer @@ -49,6 +50,7 @@ def _split_csv(raw: str | None) -> list[str]: @router.get("", response_model=MovieListOut) def list_movies( session: Annotated[Session, Depends(get_session)], + device_id: Annotated[str, Depends(get_device_id)], q: str | None = Query(default=None, description="Title search (trgm)"), studio_slugs: str | None = Query(default=None, description="Comma-separated studio slugs (OR)"), tags: str | None = Query(default=None, description="Comma-separated tag slugs (AND)"), @@ -131,7 +133,7 @@ def list_movies( base = base.limit(per_page).offset((page - 1) * per_page) movies = session.execute(base).scalars().all() - items = [_movie_to_out(session, m) for m in movies] + items = [_movie_to_out(session, m, device_id=device_id) for m in movies] return MovieListOut(items=items, total=total, page=page, per_page=per_page) @@ -172,14 +174,15 @@ def _movie_origin_priority(origin: str) -> int: def get_movie( movie_id: uuid.UUID, session: Annotated[Session, Depends(get_session)], + device_id: Annotated[str, Depends(get_device_id)], ) -> MovieOut: movie = session.get(Movie, movie_id) if movie is None: raise HTTPException(status_code=404, detail="movie not found") - return _movie_to_out(session, movie) + return _movie_to_out(session, movie, device_id=device_id) -def _movie_to_out(session: Session, movie: Movie) -> MovieOut: +def _movie_to_out(session: Session, movie: Movie, *, device_id: str = LEGACY_DEVICE) -> MovieOut: studio_out: StudioOut | None = None if movie.studio_id: studio = session.get(Studio, movie.studio_id) @@ -250,8 +253,8 @@ def _movie_to_out(session: Session, movie: Movie) -> MovieOut: pb_rows = sorted(pb_rows, key=lambda p: _movie_origin_priority(p.origin)) playback_sources = [PlaybackSourceOut.model_validate(p) for p in pb_rows] - is_fav = session.get(FavoriteMovie, movie.id) is not None - progress = session.get(MoviePlayProgress, movie.id) + is_fav = session.get(FavoriteMovie, (device_id, movie.id)) is not None + progress = session.get(MoviePlayProgress, (device_id, movie.id)) return MovieOut( id=movie.id, diff --git a/app/api/scene_favorites.py b/app/api/scene_favorites.py index 9700609..72876bd 100644 --- a/app/api/scene_favorites.py +++ b/app/api/scene_favorites.py @@ -15,6 +15,7 @@ from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.orm import Session +from app.api.device import get_device_id from app.api.scenes import _build_scene_out from app.api.schemas import SceneOut from app.auth import require_api_key @@ -42,16 +43,18 @@ class SceneFavoriteToggleOut(BaseModel): @router.get("", response_model=SceneFavoriteListOut) def list_scene_favorites( session: Annotated[Session, Depends(get_session)], + device_id: Annotated[str, Depends(get_device_id)], ) -> SceneFavoriteListOut: rows = ( session.execute( select(Scene, FavoriteScene) .join(FavoriteScene, FavoriteScene.scene_id == Scene.id) + .where(FavoriteScene.device_id == device_id) .order_by(FavoriteScene.created_at.desc()) ) .all() ) - items = [_build_scene_out(session, scene) for scene, _ in rows] + items = [_build_scene_out(session, scene, device_id=device_id) for scene, _ in rows] return SceneFavoriteListOut(items=items, total=len(items)) @@ -63,13 +66,14 @@ def list_scene_favorites( def add_scene_favorite( scene_id: uuid.UUID, session: Annotated[Session, Depends(get_session)], + device_id: Annotated[str, Depends(get_device_id)], ) -> SceneFavoriteToggleOut: scene = session.get(Scene, scene_id) if scene is None: raise HTTPException(status_code=404, detail="scene not found") - existing = session.get(FavoriteScene, scene_id) + existing = session.get(FavoriteScene, (device_id, scene_id)) if existing is None: - session.add(FavoriteScene(scene_id=scene_id)) + session.add(FavoriteScene(device_id=device_id, scene_id=scene_id)) return SceneFavoriteToggleOut(scene_id=scene_id, favorited=True) @@ -77,7 +81,8 @@ def add_scene_favorite( def remove_scene_favorite( scene_id: uuid.UUID, session: Annotated[Session, Depends(get_session)], + device_id: Annotated[str, Depends(get_device_id)], ) -> None: - fav = session.get(FavoriteScene, scene_id) + fav = session.get(FavoriteScene, (device_id, scene_id)) if fav is not None: session.delete(fav) diff --git a/app/api/scenes.py b/app/api/scenes.py index 0694977..640a3b5 100644 --- a/app/api/scenes.py +++ b/app/api/scenes.py @@ -24,6 +24,7 @@ from app.api.schemas import ( TagOut, ) from app.db import get_session +from app.api.device import LEGACY_DEVICE, get_device_id from app.models.favorite_scene import FavoriteScene from app.models.performer import Performer from app.models.play_progress import ScenePlayProgress @@ -73,12 +74,13 @@ def _default_scene_count(session: Session) -> int: # puste-zawsze klauzule kosztowały ~3.4s (mega-tag „anal": 6.7s→3.3s po pominięciu). # Cache'ujemy emptiness (TTL 5 min); gdy ktoś doda blacklist-wpis, w ciągu 5 min klauzule # wracają. Patrz reference_scenes_list_perf / task #22. -_BLACKLIST_EMPTY_CACHE: dict = {"ts": 0.0, "val": False, "checked": False} +# Cache per device_id (blacklisty są teraz device-scoped — bug 2026-06-08). +_BLACKLIST_EMPTY_CACHE: dict[str, tuple[float, bool]] = {} _BLACKLIST_EMPTY_TTL = 300.0 -def _blacklists_empty(session: Session) -> bool: - """True gdy WSZYSTKIE 3 blacklisty puste → można pominąć NOT EXISTS klauzule.""" +def _blacklists_empty(session: Session, device_id: str) -> bool: + """True gdy WSZYSTKIE 3 blacklisty TEGO device puste → pomiń NOT EXISTS klauzule.""" import time as _time from app.models.blacklist import ( BlacklistedPerformer, @@ -86,18 +88,17 @@ def _blacklists_empty(session: Session) -> bool: BlacklistedTag, ) now = _time.monotonic() - if _BLACKLIST_EMPTY_CACHE["checked"] and (now - _BLACKLIST_EMPTY_CACHE["ts"]) < _BLACKLIST_EMPTY_TTL: - return _BLACKLIST_EMPTY_CACHE["val"] + cached = _BLACKLIST_EMPTY_CACHE.get(device_id) + if cached and (now - cached[0]) < _BLACKLIST_EMPTY_TTL: + return cached[1] has_any = session.execute( select( - exists(select(1).select_from(BlacklistedPerformer)) - | exists(select(1).select_from(BlacklistedStudio)) - | exists(select(1).select_from(BlacklistedTag)) + exists(select(1).select_from(BlacklistedPerformer).where(BlacklistedPerformer.device_id == device_id)) + | exists(select(1).select_from(BlacklistedStudio).where(BlacklistedStudio.device_id == device_id)) + | exists(select(1).select_from(BlacklistedTag).where(BlacklistedTag.device_id == device_id)) ) ).scalar_one() - _BLACKLIST_EMPTY_CACHE["ts"] = now - _BLACKLIST_EMPTY_CACHE["val"] = not has_any - _BLACKLIST_EMPTY_CACHE["checked"] = True + _BLACKLIST_EMPTY_CACHE[device_id] = (now, not has_any) return not has_any @@ -110,6 +111,7 @@ def _split_csv(raw: str | None) -> list[str]: @router.get("", response_model=SceneListOut) def list_scenes( session: Annotated[Session, Depends(get_session)], + device_id: Annotated[str, Depends(get_device_id)], q: str | None = Query(default=None, description="Wyszukiwanie po title_normalized (trgm)"), studio_slug: str | None = Query(default=None, description="DEPRECATED — użyj studio_slugs"), studio_slugs: str | None = Query( @@ -259,7 +261,7 @@ def list_scenes( # performera, jest na blacklisted studio, lub ma JAKIKOLWIEK blacklisted tag → out. # Pomijamy gdy wszystkie 3 blacklisty puste (typowy stan single-user) — te NOT EXISTS # ewaluują się per-row na ~176k scen przy mega-tagu i kosztowały ~3.4s za nic. - if not _blacklists_empty(session): + if not _blacklists_empty(session, device_id): from app.models.blacklist import ( BlacklistedPerformer, BlacklistedStudio, @@ -269,18 +271,28 @@ def list_scenes( ~exists( select(1) .select_from(ScenePerformer) - .join(BlacklistedPerformer, BlacklistedPerformer.performer_id == ScenePerformer.performer_id) + .join( + BlacklistedPerformer, + (BlacklistedPerformer.performer_id == ScenePerformer.performer_id) + & (BlacklistedPerformer.device_id == device_id), + ) .where(ScenePerformer.scene_id == Scene.id) ) ) base = base.where( - ~Scene.studio_id.in_(select(BlacklistedStudio.studio_id)) + ~Scene.studio_id.in_( + select(BlacklistedStudio.studio_id).where(BlacklistedStudio.device_id == device_id) + ) ) base = base.where( ~exists( select(1) .select_from(SceneTag) - .join(BlacklistedTag, BlacklistedTag.tag_id == SceneTag.tag_id) + .join( + BlacklistedTag, + (BlacklistedTag.tag_id == SceneTag.tag_id) + & (BlacklistedTag.device_id == device_id), + ) .where(SceneTag.scene_id == Scene.id) ) ) @@ -406,7 +418,7 @@ def list_scenes( total = (page - 1) * per_page + len(rows) total_capped = has_more - items = _build_scenes_out_batch(session, list(rows), light=True) + items = _build_scenes_out_batch(session, list(rows), light=True, device_id=device_id) return SceneListOut( items=items, @@ -422,11 +434,12 @@ def list_scenes( def get_scene( scene_id: uuid.UUID, session: Annotated[Session, Depends(get_session)], + device_id: Annotated[str, Depends(get_device_id)], ) -> SceneOut: scene = session.get(Scene, scene_id) if scene is None: raise HTTPException(status_code=404, detail="scene not found") - return _build_scene_out(session, scene) + return _build_scene_out(session, scene, device_id=device_id) def _needs_proxy(url: str) -> bool: @@ -452,7 +465,7 @@ def _wrap_image_proxy(url: str, referer: str) -> str: def _build_scenes_out_batch( - session: Session, scenes: list[Scene], *, light: bool = False + session: Session, scenes: list[Scene], *, light: bool = False, device_id: str = LEGACY_DEVICE ) -> list[SceneOut]: """Batch-fetch wszystkich relacji dla N scen w 7 zapytaniach (zamiast 7×N). @@ -589,18 +602,22 @@ def _build_scenes_out_batch( out.animated_thumbnail_url = _wrap_image_proxy(out.animated_thumbnail_url, p.page_url) pb_by_scene[p.scene_id].append(out) - # 6) Progress + # 6) Progress (device-scoped) progress_by_scene: dict = {} for prog in session.execute( - select(ScenePlayProgress).where(ScenePlayProgress.scene_id.in_(scene_ids)) + select(ScenePlayProgress).where( + ScenePlayProgress.scene_id.in_(scene_ids), + ScenePlayProgress.device_id == device_id, + ) ).scalars(): progress_by_scene[prog.scene_id] = prog - # 7) Favorites + # 7) Favorites (device-scoped) fav_scene_ids: set = set( session.execute( select(FavoriteScene.scene_id).where( - FavoriteScene.scene_id.in_(scene_ids) + FavoriteScene.scene_id.in_(scene_ids), + FavoriteScene.device_id == device_id, ) ).scalars() ) @@ -636,7 +653,7 @@ def _build_scenes_out_batch( return out -def _build_scene_out(session: Session, scene: Scene) -> SceneOut: +def _build_scene_out(session: Session, scene: Scene, *, device_id: str = LEGACY_DEVICE) -> SceneOut: studio_out: StudioOut | None = None if scene.studio_id is not None: st = session.get(Studio, scene.studio_id) @@ -744,8 +761,8 @@ def _build_scene_out(session: Session, scene: Scene) -> SceneOut: playback_out.sort(key=lambda o: (_resolve_rank(o.origin), o.origin or "")) - progress = session.get(ScenePlayProgress, scene.id) - is_fav = session.get(FavoriteScene, scene.id) is not None + progress = session.get(ScenePlayProgress, (device_id, scene.id)) + is_fav = session.get(FavoriteScene, (device_id, scene.id)) is not None return SceneOut( id=scene.id, diff --git a/app/api/watch.py b/app/api/watch.py index 96947c0..da193af 100644 --- a/app/api/watch.py +++ b/app/api/watch.py @@ -19,6 +19,7 @@ from pydantic import BaseModel from sqlalchemy import desc, select from sqlalchemy.orm import Session +from app.api.device import get_device_id from app.api.scenes import _build_scene_out from app.api.schemas import SceneOut from app.auth import require_api_key @@ -49,6 +50,7 @@ def upsert_progress( scene_id: uuid.UUID, body: ProgressIn, session: Annotated[Session, Depends(get_session)], + device_id: Annotated[str, Depends(get_device_id)], ) -> ProgressOut: if session.get(Scene, scene_id) is None: raise HTTPException(status_code=404, detail="scene not found") @@ -68,6 +70,7 @@ def upsert_progress( stmt = ( pg_insert(ScenePlayProgress) .values( + device_id=device_id, scene_id=scene_id, position_sec=position_sec, duration_sec=body.duration_sec, @@ -75,7 +78,7 @@ def upsert_progress( last_played_at=now, ) .on_conflict_do_update( - index_elements=["scene_id"], + index_elements=["device_id", "scene_id"], set_={ "position_sec": position_sec, # duration_sec: zachowaj istniejący gdy body nie podaje @@ -91,7 +94,7 @@ def upsert_progress( ) session.execute(stmt) session.commit() - row = session.get(ScenePlayProgress, scene_id) + row = session.get(ScenePlayProgress, (device_id, scene_id)) assert row is not None return ProgressOut( scene_id=scene_id, @@ -109,8 +112,9 @@ def upsert_progress( def remove_progress( scene_id: uuid.UUID, session: Annotated[Session, Depends(get_session)], + device_id: Annotated[str, Depends(get_device_id)], ) -> None: - row = session.get(ScenePlayProgress, scene_id) + row = session.get(ScenePlayProgress, (device_id, scene_id)) if row is None: return session.delete(row) @@ -133,6 +137,7 @@ def upsert_movie_progress( movie_id: uuid.UUID, body: ProgressIn, session: Annotated[Session, Depends(get_session)], + device_id: Annotated[str, Depends(get_device_id)], ) -> MovieProgressOut: """Mirror upsert_progress dla filmów (bug-report b207ff17 2026-05-26 — "przydałoby się oznaczenie filmów już obejrzanych").""" @@ -151,6 +156,7 @@ def upsert_movie_progress( stmt = ( pg_insert(MoviePlayProgress) .values( + device_id=device_id, movie_id=movie_id, position_sec=position_sec, duration_sec=body.duration_sec, @@ -158,7 +164,7 @@ def upsert_movie_progress( last_played_at=now, ) .on_conflict_do_update( - index_elements=["movie_id"], + index_elements=["device_id", "movie_id"], set_={ "position_sec": position_sec, "duration_sec": ( @@ -173,7 +179,7 @@ def upsert_movie_progress( ) session.execute(stmt) session.commit() - row = session.get(MoviePlayProgress, movie_id) + row = session.get(MoviePlayProgress, (device_id, movie_id)) assert row is not None return MovieProgressOut( movie_id=movie_id, @@ -191,8 +197,9 @@ def upsert_movie_progress( def remove_movie_progress( movie_id: uuid.UUID, session: Annotated[Session, Depends(get_session)], + device_id: Annotated[str, Depends(get_device_id)], ) -> None: - row = session.get(MoviePlayProgress, movie_id) + row = session.get(MoviePlayProgress, (device_id, movie_id)) if row is None: return session.delete(row) @@ -214,6 +221,7 @@ class WatchListOut(BaseModel): @router.get("/watch/recent", response_model=WatchListOut) def list_recent( session: Annotated[Session, Depends(get_session)], + device_id: Annotated[str, Depends(get_device_id)], limit: int = Query(default=10, ge=1, le=50), include_finished: bool = Query(default=False), ) -> WatchListOut: @@ -222,6 +230,7 @@ def list_recent( stmt = ( select(ScenePlayProgress, Scene) .join(Scene, Scene.id == ScenePlayProgress.scene_id) + .where(ScenePlayProgress.device_id == device_id) .order_by(desc(ScenePlayProgress.last_played_at)) .limit(limit) ) @@ -232,7 +241,7 @@ def list_recent( for prog, scene in session.execute(stmt).all(): items.append( WatchEntry( - scene=_build_scene_out(session, scene), + scene=_build_scene_out(session, scene, device_id=device_id), position_sec=prog.position_sec, duration_sec=prog.duration_sec, finished=prog.finished, diff --git a/app/main.py b/app/main.py index 5951d32..5bf7392 100644 --- a/app/main.py +++ b/app/main.py @@ -14,6 +14,7 @@ from app.api.blacklist import router as blacklist_router from app.api.bug_reports import router as bug_reports_router from app.api.expo_updates import router as expo_updates_router from app.api.favorites import router as favorites_router +from app.api.me import router as me_router from app.api.movies import router as movies_router from app.api.playback import movies_router as movies_playback_router from app.api.playback import router as playback_router @@ -80,6 +81,7 @@ app.include_router(blacklist_router) app.include_router(bug_reports_router) app.include_router(expo_updates_router) app.include_router(watch_router) +app.include_router(me_router) app.include_router(admin_router) app.include_router(admin_html_router) app.include_router(seo_router) diff --git a/app/models/blacklist.py b/app/models/blacklist.py index 6daf149..f21c62a 100644 --- a/app/models/blacklist.py +++ b/app/models/blacklist.py @@ -4,7 +4,7 @@ from __future__ import annotations import uuid from datetime import datetime -from sqlalchemy import DateTime, ForeignKey, func +from sqlalchemy import DateTime, ForeignKey, String, func from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import Mapped, mapped_column @@ -13,6 +13,7 @@ from app.models.base import Base class BlacklistedPerformer(Base): __tablename__ = "blacklisted_performers" + device_id: Mapped[str] = mapped_column(String(64), primary_key=True) performer_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("performers.id", ondelete="CASCADE"), @@ -25,6 +26,7 @@ class BlacklistedPerformer(Base): class BlacklistedStudio(Base): __tablename__ = "blacklisted_studios" + device_id: Mapped[str] = mapped_column(String(64), primary_key=True) studio_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("studios.id", ondelete="CASCADE"), @@ -37,6 +39,7 @@ class BlacklistedStudio(Base): class BlacklistedTag(Base): __tablename__ = "blacklisted_tags" + device_id: Mapped[str] = mapped_column(String(64), primary_key=True) tag_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("tags.id", ondelete="CASCADE"), diff --git a/app/models/favorite_movie.py b/app/models/favorite_movie.py index 60dec85..f0d5a19 100644 --- a/app/models/favorite_movie.py +++ b/app/models/favorite_movie.py @@ -4,7 +4,7 @@ from __future__ import annotations import uuid from datetime import datetime -from sqlalchemy import DateTime, ForeignKey, func +from sqlalchemy import DateTime, ForeignKey, String, func from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import Mapped, mapped_column @@ -14,6 +14,7 @@ from app.models.base import Base class FavoriteMovie(Base): __tablename__ = "favorite_movies" + device_id: Mapped[str] = mapped_column(String(64), primary_key=True) movie_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("movies.id", ondelete="CASCADE"), diff --git a/app/models/favorite_performer.py b/app/models/favorite_performer.py index f1f54ed..b86963a 100644 --- a/app/models/favorite_performer.py +++ b/app/models/favorite_performer.py @@ -4,7 +4,7 @@ from __future__ import annotations import uuid from datetime import datetime -from sqlalchemy import DateTime, ForeignKey, func +from sqlalchemy import DateTime, ForeignKey, String, func from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import Mapped, mapped_column @@ -14,6 +14,7 @@ from app.models.base import Base class FavoritePerformer(Base): __tablename__ = "favorite_performers" + device_id: Mapped[str] = mapped_column(String(64), primary_key=True) performer_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("performers.id", ondelete="CASCADE"), diff --git a/app/models/favorite_scene.py b/app/models/favorite_scene.py index 49e6cee..7a24955 100644 --- a/app/models/favorite_scene.py +++ b/app/models/favorite_scene.py @@ -4,7 +4,7 @@ from __future__ import annotations import uuid from datetime import datetime -from sqlalchemy import DateTime, ForeignKey, func +from sqlalchemy import DateTime, ForeignKey, String, func from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import Mapped, mapped_column @@ -14,6 +14,7 @@ from app.models.base import Base class FavoriteScene(Base): __tablename__ = "favorite_scenes" + device_id: Mapped[str] = mapped_column(String(64), primary_key=True) scene_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("scenes.id", ondelete="CASCADE"), diff --git a/app/models/favorite_studio.py b/app/models/favorite_studio.py index 07135d2..f96e870 100644 --- a/app/models/favorite_studio.py +++ b/app/models/favorite_studio.py @@ -4,7 +4,7 @@ from __future__ import annotations import uuid from datetime import datetime -from sqlalchemy import DateTime, ForeignKey, func +from sqlalchemy import DateTime, ForeignKey, String, func from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import Mapped, mapped_column @@ -14,6 +14,7 @@ from app.models.base import Base class FavoriteStudio(Base): __tablename__ = "favorite_studios" + device_id: Mapped[str] = mapped_column(String(64), primary_key=True) studio_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("studios.id", ondelete="CASCADE"), diff --git a/app/models/play_progress.py b/app/models/play_progress.py index a4083ee..acf4d61 100644 --- a/app/models/play_progress.py +++ b/app/models/play_progress.py @@ -4,7 +4,7 @@ from __future__ import annotations import uuid from datetime import datetime -from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, func +from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, func from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import Mapped, mapped_column @@ -14,6 +14,7 @@ from app.models.base import Base class ScenePlayProgress(Base): __tablename__ = "scene_play_progress" + device_id: Mapped[str] = mapped_column(String(64), primary_key=True) scene_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("scenes.id", ondelete="CASCADE"), @@ -42,6 +43,7 @@ class MoviePlayProgress(Base): __tablename__ = "movie_play_progress" + device_id: Mapped[str] = mapped_column(String(64), primary_key=True) movie_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("movies.id", ondelete="CASCADE"),