From 6540d4a009890ea08d5021c339898b2ae678028f Mon Sep 17 00:00:00 2001 From: goon-foss Date: Tue, 6 Oct 2026 16:34:52 +0200 Subject: [PATCH] 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 --- .../versions/20261006_0054_hidden_sites.py | 31 ++++ app/api/blacklist.py | 54 ++++++- app/api/me.py | 4 + app/api/scenes.py | 136 +++++++++++++----- app/models/__init__.py | 2 + app/models/hidden_site.py | 25 ++++ 6 files changed, 215 insertions(+), 37 deletions(-) create mode 100644 alembic/versions/20261006_0054_hidden_sites.py create mode 100644 app/models/hidden_site.py diff --git a/alembic/versions/20261006_0054_hidden_sites.py b/alembic/versions/20261006_0054_hidden_sites.py new file mode 100644 index 0000000..bbf181e --- /dev/null +++ b/alembic/versions/20261006_0054_hidden_sites.py @@ -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") diff --git a/app/api/blacklist.py b/app/api/blacklist.py index 0cc5d76..a1fa2f9 100644 --- a/app/api/blacklist.py +++ b/app/api/blacklist.py @@ -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) diff --git a/app/api/me.py b/app/api/me.py index 4048e92..0d47244 100644 --- a/app/api/me.py +++ b/app/api/me.py @@ -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"), ] diff --git a/app/api/scenes.py b/app/api/scenes.py index b74d88c..e8c684c 100644 --- a/app/api/scenes.py +++ b/app/api/scenes.py @@ -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) diff --git a/app/models/__init__.py b/app/models/__init__.py index 9ba6619..83a549d 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -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", diff --git a/app/models/hidden_site.py b/app/models/hidden_site.py new file mode 100644 index 0000000..2ed2b6f --- /dev/null +++ b/app/models/hidden_site.py @@ -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 + )