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:
parent
2feef620a1
commit
6540d4a009
6 changed files with 215 additions and 37 deletions
31
alembic/versions/20261006_0054_hidden_sites.py
Normal file
31
alembic/versions/20261006_0054_hidden_sites.py
Normal 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")
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,7 +451,9 @@ def blacklist_clauses(session: Session, device_id: str) -> list:
|
|||
BlacklistedTag,
|
||||
)
|
||||
|
||||
return [
|
||||
out: list = []
|
||||
if "performer" in kinds:
|
||||
out.append(
|
||||
~exists(
|
||||
select(1)
|
||||
.select_from(ScenePerformer)
|
||||
|
|
@ -437,10 +463,16 @@ def blacklist_clauses(session: Session, device_id: str) -> list:
|
|||
& (BlacklistedPerformer.device_id == device_id),
|
||||
)
|
||||
.where(ScenePerformer.scene_id == Scene.id)
|
||||
),
|
||||
)
|
||||
)
|
||||
if "studio" in kinds:
|
||||
out.append(
|
||||
~Scene.studio_id.in_(
|
||||
select(BlacklistedStudio.studio_id).where(BlacklistedStudio.device_id == device_id)
|
||||
),
|
||||
)
|
||||
)
|
||||
if "tag" in kinds:
|
||||
out.append(
|
||||
~exists(
|
||||
select(1)
|
||||
.select_from(SceneTag)
|
||||
|
|
@ -450,8 +482,32 @@ def blacklist_clauses(session: Session, device_id: str) -> list:
|
|||
& (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)
|
||||
|
|
|
|||
|
|
@ -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
25
app/models/hidden_site.py
Normal 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
|
||||
)
|
||||
Loading…
Add table
Reference in a new issue