goon/app/api/blacklist.py
goon-foss 6540d4a009 feat(blacklist): ukrywanie stron per urzadzenie
Zgloszenie 3910b19b: brak przycisku ukrywania stron. Tabela hidden_sites
(0054), POST/DELETE /blacklist/sites/{origin}, pole sites w GET /blacklist.
Scena znika z list tylko gdy wszystkie jej zywe zrodla sa z ukrytych stron
(ta sama scena z innego serwisu zostaje); w szczegolach sceny przyciski
ukrytych stron znikaja, o ile zostaje inne zrodlo.

Cache pustosci blacklist trzyma teraz, ktore listy sa niepuste, wiec ukryte
strony nie doklejaja trzech pustych NOT EXISTS, i jest czyszczony przy kazdej
zmianie (wczesniej pierwszy wpis dzialal po 5 minutach). Kod synchronizacji
kopiuje hidden_sites oraz favorite_sites, ktorych tam brakowalo.

Sandbox na produkcyjnej bazie: strona porndoe 42 z 60 scen tylko z porndoe
przed ukryciem, 0 po; feed 0,34 s, duze tagi 0,3 do 0,8 s z dwiema ukrytymi.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 16:34:52 +02:00

175 lines
6.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}.
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)