goon/app/api/blacklist.py
jtrzupek c8baa11604 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>
2026-06-10 08:58:01 +02:00

123 lines
4.2 KiB
Python

"""Blacklists — globalnie ukryte performerki/studia/tagi.
Sceny które MAJĄ blacklisted entity wypadają z każdego /scenes (pełna lista, search,
performer scenes, tag scenes). Auto-apply w `app/api/scenes.py`.
Endpointy:
GET /blacklist — wszystkie 3 listy w jednym response
POST /blacklist/{kind}/{entity_id} — dodaj (idempotent)
DELETE /blacklist/{kind}/{entity_id} — usuń
`kind` ∈ {performer, studio, tag}.
"""
from __future__ import annotations
import uuid
from typing import Annotated, Literal
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.api.device import get_device_id
from app.auth import require_api_key
from app.db import get_session
from app.models.blacklist import (
BlacklistedPerformer,
BlacklistedStudio,
BlacklistedTag,
)
from app.models.performer import Performer
from app.models.studio import Studio
from app.models.tag import Tag
router = APIRouter(
prefix="/blacklist", tags=["blacklist"], dependencies=[Depends(require_api_key)]
)
Kind = Literal["performer", "studio", "tag"]
class BlacklistEntry(BaseModel):
id: uuid.UUID
name: str # canonical_name (performer) / name (studio/tag)
slug: str | None = None
class BlacklistOut(BaseModel):
performers: list[BlacklistEntry]
studios: list[BlacklistEntry]
tags: list[BlacklistEntry]
@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(
performers=[BlacklistEntry(id=r[0], name=r[1], slug=r[2]) for r in perfs],
studios=[BlacklistEntry(id=r[0], name=r[1], slug=r[2]) for r in studios],
tags=[BlacklistEntry(id=r[0], name=r[1], slug=r[2]) for r in tags],
)
def _kind_to_entity(kind: Kind):
if kind == "performer":
return BlacklistedPerformer, Performer, "performer_id"
if kind == "studio":
return BlacklistedStudio, Studio, "studio_id"
if kind == "tag":
return BlacklistedTag, Tag, "tag_id"
raise HTTPException(status_code=400, detail="kind must be performer|studio|tag")
@router.post("/{kind}/{entity_id}", status_code=status.HTTP_200_OK)
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, (device_id, entity_id)) is not None:
return {"kind": kind, "id": str(entity_id), "created": False}
session.add(bl_model(**{"device_id": device_id, fk: entity_id}))
session.commit()
return {"kind": kind, "id": str(entity_id), "created": True}
@router.delete("/{kind}/{entity_id}", status_code=status.HTTP_204_NO_CONTENT)
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, (device_id, entity_id))
if row is None:
return # idempotent
session.delete(row)
session.commit()