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>
This commit is contained in:
goon-foss 2026-10-06 16:34:52 +02:00
parent 2feef620a1
commit 6540d4a009
6 changed files with 215 additions and 37 deletions

View file

@ -0,0 +1,31 @@
"""hidden_sites: strony ukryte przez uzytkownika (per urzadzenie).
Zgloszenie 3910b19b: brak przycisku ukrywania stron. Lustro favorite_sites (0034).
Revision ID: 0054_hidden_sites
Revises: 0053_playback_events_via
Create Date: 2026-10-06
"""
from __future__ import annotations
from alembic import op
revision = "0054_hidden_sites"
down_revision: str | None = "0053_playback_events_via"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("""
CREATE TABLE IF NOT EXISTS hidden_sites (
device_id varchar(64) NOT NULL,
origin varchar(64) NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (device_id, origin)
)
""")
def downgrade() -> None:
op.execute("DROP TABLE IF EXISTS hidden_sites")

View file

@ -9,18 +9,23 @@ Endpointy:
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 select
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 (
@ -28,7 +33,9 @@ from app.models.blacklist import (
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
@ -49,6 +56,8 @@ 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)
@ -74,13 +83,54 @@ def list_blacklist(
.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"
@ -105,6 +155,7 @@ def add_blacklist(
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}
@ -121,3 +172,4 @@ def remove_blacklist(
return # idempotent
session.delete(row)
session.commit()
invalidate_blacklist_cache(device_id)

View file

@ -56,6 +56,10 @@ class ImportOut(BaseModel):
_COPY_TABLES: list[tuple[str, str]] = _TABLES + [
("saved_searches", "query"),
("clip_blacklist", "value"),
# Przypiete i ukryte strony (bez FK, kluczem jest napis origin). favorite_sites
# brakowalo tu od poczatku, wiec przypiecia nie przechodzily kodem synchronizacji.
("favorite_sites", "origin"),
("hidden_sites", "origin"),
]

View file

@ -187,31 +187,47 @@ def _jav_source_exists():
# 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.
# Cache per device_id (blacklisty są teraz device-scoped, bug 2026-06-08).
_BLACKLIST_EMPTY_CACHE: dict[str, tuple[float, bool]] = {}
# Trzymamy, KTORE listy sa niepuste, nie tylko "czy cokolwiek": ukryte strony (2026-10-06)
# nie powinny doklejac trzech pustych NOT EXISTS po performerach/studiach/tagach.
# Dodanie i usuniecie wpisu czysci cache (invalidate_blacklist_cache), wczesniej pierwszy
# wpis dzialal dopiero po 5 minutach.
_BLACKLIST_EMPTY_CACHE: dict[str, tuple[float, frozenset[str]]] = {}
_BLACKLIST_EMPTY_TTL = 300.0
def _blacklists_empty(session: Session, device_id: str) -> bool:
"""True gdy WSZYSTKIE 3 blacklisty TEGO device puste → pomiń NOT EXISTS klauzule."""
def invalidate_blacklist_cache(device_id: str) -> None:
_BLACKLIST_EMPTY_CACHE.pop(device_id, None)
def _blacklist_kinds(session: Session, device_id: str) -> frozenset[str]:
"""Niepuste listy TEGO urzadzenia: podzbior {'performer', 'studio', 'tag', 'site'}."""
import time as _time
from app.models.blacklist import (
BlacklistedPerformer,
BlacklistedStudio,
BlacklistedTag,
)
from app.models.hidden_site import HiddenSite
now = _time.monotonic()
cached = _BLACKLIST_EMPTY_CACHE.get(device_id)
if cached and (now - cached[0]) < _BLACKLIST_EMPTY_TTL:
return cached[1]
has_any = session.execute(
row = session.execute(
select(
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))
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)),
exists(select(1).select_from(HiddenSite).where(HiddenSite.device_id == device_id)),
)
).scalar_one()
_BLACKLIST_EMPTY_CACHE[device_id] = (now, not has_any)
return not has_any
).one()
kinds = frozenset(k for k, present in zip(("performer", "studio", "tag", "site"), row) if present)
_BLACKLIST_EMPTY_CACHE[device_id] = (now, kinds)
return kinds
def _blacklists_empty(session: Session, device_id: str) -> bool:
"""True gdy WSZYSTKIE listy TEGO device puste → pomiń NOT EXISTS klauzule."""
return not _blacklist_kinds(session, device_id)
# Szukanie po tytule (GOON-73/75, pomiary 2026-09-22). Sa dwa dobre plany i planer nie
@ -417,9 +433,17 @@ def live_playback_exists():
)
def hidden_origins_subquery(device_id: str):
"""SELECT origin z ukrytych stron urzadzenia (do IN / NOT IN)."""
from app.models.hidden_site import HiddenSite
return select(HiddenSite.origin).where(HiddenSite.device_id == device_id)
def blacklist_clauses(session: Session, device_id: str) -> list:
"""NOT-EXISTS klauzule blacklist device (performer/studio/tag). [] gdy wszystkie puste."""
if _blacklists_empty(session, device_id):
"""Klauzule blacklist device (performer/studio/tag) i ukrytych stron. [] gdy wszystko puste."""
kinds = _blacklist_kinds(session, device_id)
if not kinds:
return []
from app.models.blacklist import (
BlacklistedPerformer,
@ -427,31 +451,63 @@ def blacklist_clauses(session: Session, device_id: str) -> list:
BlacklistedTag,
)
return [
~exists(
select(1)
.select_from(ScenePerformer)
.join(
BlacklistedPerformer,
(BlacklistedPerformer.performer_id == ScenePerformer.performer_id)
& (BlacklistedPerformer.device_id == device_id),
out: list = []
if "performer" in kinds:
out.append(
~exists(
select(1)
.select_from(ScenePerformer)
.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)
),
~Scene.studio_id.in_(
select(BlacklistedStudio.studio_id).where(BlacklistedStudio.device_id == device_id)
),
~exists(
select(1)
.select_from(SceneTag)
.join(
BlacklistedTag,
(BlacklistedTag.tag_id == SceneTag.tag_id)
& (BlacklistedTag.device_id == device_id),
)
if "studio" in kinds:
out.append(
~Scene.studio_id.in_(
select(BlacklistedStudio.studio_id).where(BlacklistedStudio.device_id == device_id)
)
.where(SceneTag.scene_id == Scene.id)
),
]
)
if "tag" in kinds:
out.append(
~exists(
select(1)
.select_from(SceneTag)
.join(
BlacklistedTag,
(BlacklistedTag.tag_id == SceneTag.tag_id)
& (BlacklistedTag.device_id == device_id),
)
.where(SceneTag.scene_id == Scene.id)
)
)
if "site" in kinds:
# Ukryta strona chowa scene tylko wtedy, gdy wszystkie jej zywe zrodla sa z ukrytych
# stron: ta sama scena z innego serwisu dalej jest do obejrzenia. Sceny bez zywego
# zrodla przepuszczamy, o nich decyduje filtr has_playback.
hidden = hidden_origins_subquery(device_id)
out.append(
~(
exists(
select(1).where(
PlaybackSource.scene_id == Scene.id,
PlaybackSource.dead_at.is_(None),
PlaybackSource.origin.in_(hidden),
)
)
& ~exists(
select(1).where(
PlaybackSource.scene_id == Scene.id,
PlaybackSource.dead_at.is_(None),
PlaybackSource.origin.not_in(hidden),
)
)
)
)
return out
def _studio_sorted_select(base, inner_limit: int, studio_ids=None):
@ -1147,7 +1203,15 @@ def get_scene(
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, device_id=device_id)
out = _build_scene_out(session, scene, device_id=device_id)
# Zrodla z ukrytych stron chowamy, o ile zostaje jakies inne. Scena tylko z ukrytych
# stron (np. z historii albo ulubionych) zostaje grywalna, zamiast pustej sekcji Watch.
if "site" in _blacklist_kinds(session, device_id):
hidden = set(session.execute(hidden_origins_subquery(device_id)).scalars())
visible = [p for p in out.playback_sources if p.origin not in hidden]
if visible and len(visible) < len(out.playback_sources):
out.playback_sources = visible
return out
@router.get("/{scene_id}/similar", response_model=SceneListOut)

View file

@ -8,6 +8,7 @@ from app.models.favorite_movie import FavoriteMovie
from app.models.favorite_performer import FavoritePerformer
from app.models.favorite_scene import FavoriteScene
from app.models.favorite_site import FavoriteSite
from app.models.hidden_site import HiddenSite
from app.models.favorite_studio import FavoriteStudio
from app.models.ingest_run import IngestRun
from app.models.merge_candidate import MergeCandidate
@ -58,6 +59,7 @@ __all__ = [
"FavoritePerformer",
"FavoriteScene",
"FavoriteSite",
"HiddenSite",
"FavoriteStudio",
"IngestRun",
"MergeCandidate",

25
app/models/hidden_site.py Normal file
View file

@ -0,0 +1,25 @@
"""Ukryte strony (origins) per urzadzenie. Prosba ze zgloszenia 3910b19b (2026-10-05):
"nie ma przycisku ukrywania stron". Lustro FavoriteSite.
Scena z listy znika tylko wtedy, gdy WSZYSTKIE jej zywe zrodla sa z ukrytych stron
(app/api/scenes.py blacklist_clauses); w szczegolach sceny przyciski ukrytych stron
znikaja, o ile zostaje inne zrodlo. Przechodzi kodem synchronizacji (app/api/me.py).
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base
class HiddenSite(Base):
__tablename__ = "hidden_sites"
device_id: Mapped[str] = mapped_column(String(64), primary_key=True)
origin: Mapped[str] = mapped_column(String(64), primary_key=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)