"""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}. Ukryte strony (2026-10-06, zgloszenie 3910b19b): origin to napis, nie UUID, wiec osobne trasy `/blacklist/sites/{origin}`, zadeklarowane PRZED `/{kind}/{entity_id}`. """ from __future__ import annotations import re import uuid from typing import Annotated, Literal from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel from sqlalchemy import exists, select from sqlalchemy.orm import Session from app.api.device import get_device_id from app.api.scenes import invalidate_blacklist_cache from app.auth import require_api_key from app.db import get_session from app.models.blacklist import ( BlacklistedPerformer, BlacklistedStudio, BlacklistedTag, ) from app.models.hidden_site import HiddenSite from app.models.performer import Performer from app.models.playback_source import PlaybackSource 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] # Ukryte strony: same originy ('tube:porndoecom'), nazwy apka bierze z /sources. sites: list[str] = [] @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() sites = session.execute( select(HiddenSite.origin).where(HiddenSite.device_id == device_id).order_by(HiddenSite.origin) ).scalars().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], sites=list(sites), ) _ORIGIN_RE = re.compile(r"^[a-z0-9][a-z0-9:._-]{1,63}$") @router.post("/sites/{origin}", status_code=status.HTTP_200_OK) def hide_site( origin: str, session: Annotated[Session, Depends(get_session)], device_id: Annotated[str, Depends(get_device_id)], ) -> dict: if not _ORIGIN_RE.match(origin): raise HTTPException(status_code=400, detail="bad origin") if session.get(HiddenSite, (device_id, origin)) is not None: return {"origin": origin, "created": False} known = session.execute( select(exists(select(1).where(PlaybackSource.origin == origin))) ).scalar_one() if not known: raise HTTPException(status_code=404, detail="site not found") session.add(HiddenSite(device_id=device_id, origin=origin)) session.commit() invalidate_blacklist_cache(device_id) return {"origin": origin, "created": True} @router.delete("/sites/{origin}", status_code=status.HTTP_204_NO_CONTENT) def unhide_site( origin: str, session: Annotated[Session, Depends(get_session)], device_id: Annotated[str, Depends(get_device_id)], ) -> None: row = session.get(HiddenSite, (device_id, origin)) if row is not None: session.delete(row) session.commit() invalidate_blacklist_cache(device_id) 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() invalidate_blacklist_cache(device_id) 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() invalidate_blacklist_cache(device_id)