fix(dedup): blacklista zdegenerowanych phashy + scalenie 4647 duplikatow

Audyt duplikatow po phashu miniatur wykazal dwa rozne zjawiska, ktore wygladaly
jak jedno.

1. Zdegenerowane phashe. 73 wartosci wystepowaly przy >=10 scenach kazda, lacznie
przy 4375 scenach; rekordzistka byla dzielona przez 892 sceny o 892 roznych
tytulach i 1886 roznych performerach. To zaslepki i czarne klatki, nie odciski
scen. find_by_phash_within bierze najblizsza wartosc z calej tabeli, wiec taka
zaslepka zawsze wygrywala z prawdziwym duplikatem (dist 0). Do tej pory bronila
nas bramka dur_prox i nic sie nie skleilo, ale to zabezpieczenie drugiej linii.

Blacklista jest tabela, nie jednorazowym DELETE, bo sam DELETE nic nie daje:
zaslepka wraca przy kolejnym ingescie. Trzeba pamietac, ze wartosc jest
bezuzyteczna. Job co 24h dopisuje nowe i czysci odciski. Po czyszczeniu zero
grup >=10, najwieksza pozostala ma 9.

2. Realne duplikaty. 4647 scalonych. Przyczyna byla jedna: 97 procent par ma
perverzije po dokladnie jednej stronie, bo pisze tytuly z prefiksem studia i
performera, a reszta tubow daje goly tytul. Wbrew mojej pierwszej diagnozie NIE
trzeba tu ruszac scoringu tytulu (token_set_ratio i tak radzi sobie z prefiksem,
a sciezka phash idzie przed composite): 80 procent par to dlug sprzed 60+ dni, a
biezacy wyciek to okolo 1 dziennie. Nadmiarowe wiersze: 18452 na 9449.

merge_phash_exact_dupes.py dostal wykluczenie blacklisty. Bez tego byl grozny:
sam klaster 892 scen dawal ~397 tys. par do rozwazenia.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
goon-foss 2026-07-27 12:47:20 +02:00
parent b485511f3f
commit 3de8489ffa
9 changed files with 192 additions and 1 deletions

View file

@ -0,0 +1,39 @@
"""phash blacklist: zdegenerowane wartości phasha
Revision ID: 0025_phash_blacklist
Revises: 0024_saved_searches
Create Date: 2026-07-27
Audyt duplikatów po phashu wykazał 73 wartości występujące przy 10 scenach każda
(łącznie 4375 scen), z rekordzistką dzieloną przez 892 sceny o 892 różnych tytułach.
To zaślepki i czarne klatki, nie odciski scen. Tabela pamięta takie wartości na
stałe, żeby po wyczyszczeniu wierszy nie wracały przy kolejnych ingestach.
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0025_phash_blacklist"
down_revision: str | None = "0024_saved_searches"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"phash_blacklist",
sa.Column("value", sa.String(length=128), nullable=False),
sa.Column("scene_count", sa.Integer(), nullable=False),
sa.Column(
"detected_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.PrimaryKeyConstraint("value", name="pk_phash_blacklist"),
)
def downgrade() -> None:
op.drop_table("phash_blacklist")

View file

@ -116,6 +116,13 @@ class Settings(BaseSettings):
sched_title_dedup_hours: int = Field(
default=12, validation_alias="GOON_SCHED_TITLE_DEDUP_HOURS"
)
# Blacklista zdegenerowanych phashy — wartości dzielonych przez ≥10 scen (zaślepka,
# czarna klatka, intro studia). Audyt 2026-07-27: 73 takie wartości przy 4375
# scenach, rekordzistka przy 892 scenach o 892 różnych tytułach. Tube może zacząć
# serwować zaślepkę w dowolnym momencie, stąd cyklicznie. 24h. 0 = off.
sched_phash_blacklist_hours: int = Field(
default=24, validation_alias="GOON_SCHED_PHASH_BLACKLIST_HOURS"
)
# Ingest freshness watchdog — alert do Sentry gdy aktywny tube (origin
# tube:<sitetag>) przestał dawać nowe sceny > próg. Łapie zamrożenie
# pojedynczego origin, którego globalny monitor (jeden Source "tube-scraper") nie

View file

@ -20,6 +20,7 @@ from app.models.performer import Performer, PerformerAlias, PerformerExternalRef
from app.models.play_progress import MoviePlayProgress, ScenePlayProgress
from app.models.playback_event import PlaybackEvent
from app.models.playback_source import PlaybackSource
from app.models.phash_blacklist import PhashBlacklist
from app.models.saved_search import SavedSearch
from app.models.source_stats import SourceStats
from app.models.scene import (
@ -58,6 +59,7 @@ __all__ = [
"MoviePlayProgress",
"ScenePlayProgress",
"PlaybackSource",
"PhashBlacklist",
"SavedSearch",
"Scene",
"SceneExternalRef",

View file

@ -0,0 +1,32 @@
"""Zdegenerowane phashe — wartości, które NIE identyfikują sceny.
Audyt 2026-07-27: 73 wartości phasha występowały przy 10 scenach każda, łącznie
przy 4375 scenach. Rekordzistka miała **892 sceny o 892 różnych tytułach i 1886
różnych performerach** to placeholder, czarna klatka albo intro, nie odcisk sceny.
Dlaczego to musi być tabela, a nie zwykłe skasowanie wierszy: sam `DELETE` nic nie
załatwia, bo przy kolejnych ingestach ta sama zaślepka wraca. Trzeba PAMIĘTAĆ, że
dana wartość jest bezużyteczna, i już nigdy po niej nie matchować.
Do tej pory obroniła nas bramka `dur_prox` w `scene_resolver` (te 892 sceny nadal
istnieją osobno, więc nic się nie skleiło), ale to zabezpieczenie drugiej linii:
wystarczy, żeby dwie niepowiązane sceny miały samą zaślepkę i zbliżoną długość.
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base
class PhashBlacklist(Base):
__tablename__ = "phash_blacklist"
value: Mapped[str] = mapped_column(String(128), primary_key=True)
scene_count: Mapped[int] = mapped_column(Integer, nullable=False)
detected_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)

View file

@ -95,13 +95,23 @@ def find_by_phash_within(
if max_hamming is None:
max_hamming = get_settings().fingerprint_hamming_max
# Zdegenerowane wartości (zaślepka / czarna klatka / intro studia) odpadają po
# obu stronach: nie szukamy PO nich i nie matchujemy DO nich. Bez tego jedna
# zaślepka dzielona przez 892 sceny jest zawsze najbliższym trafieniem (dist 0)
# i wygrywa z prawdziwym duplikatem. Tabela ma ~70 wierszy, więc koszt zerowy.
if session.execute(
text("SELECT 1 FROM phash_blacklist WHERE value = :phash"), {"phash": phash}
).first():
return None
if len(phash) == 16:
row = session.execute(
text(
"SELECT scene_id, "
"bit_count(('x'||value)::bit(64) # ('x'||:phash)::bit(64)) AS dist "
"FROM scene_fingerprints "
"FROM scene_fingerprints f "
"WHERE kind = 'phash' AND length(value) = 16 "
"AND NOT EXISTS (SELECT 1 FROM phash_blacklist b WHERE b.value = f.value) "
"ORDER BY dist ASC LIMIT 1"
),
{"phash": phash},

View file

@ -276,6 +276,22 @@ def _job_thumb_asset_dedup() -> None:
log.exception("[scheduler] thumb-asset dedup failed")
def _job_phash_blacklist() -> None:
"""Wyłapuje phashe, które przestały cokolwiek znaczyć (zaślepka / czarna klatka /
intro studia) i kasuje je z fingerprintów. Audyt 2026-07-27: 73 takie wartości przy
4375 scenach, rekordzistka dzielona przez 892 sceny o 892 różnych tytułach.
Cyklicznie, bo tube potrafi zacząć serwować zaślepkę w dowolnym momencie, a wartość
raz wpisana na blacklistę zostaje tam na stałe (inaczej odrastałaby przy ingeście)."""
log.info("[scheduler] phash blacklist starting")
try:
from app.scheduler.phash_blacklist import run_phash_blacklist
_run_with_timeout(run_phash_blacklist, label="phash-blacklist")
log.info("[scheduler] phash blacklist done")
except Exception:
log.exception("[scheduler] phash blacklist failed")
def _job_title_duration_dedup() -> None:
"""Scal missing-merge dupy (ten sam performer + identyczny tytuł + długość) których
bulk_dedup nie łapie (tube re-scrape / cross-tube np. porn00 vs xnxx, reports
@ -471,6 +487,17 @@ def build_scheduler(cfg: dict[str, Any]) -> BlockingScheduler:
)
log.info("scheduler: thumb-asset dedup every %dh", cfg["thumb_dedup_hours"])
if cfg.get("phash_blacklist_hours"):
sched.add_job(
_job_phash_blacklist,
IntervalTrigger(hours=cfg["phash_blacklist_hours"], start_date=INTERVAL_ANCHOR),
id="phash_blacklist",
replace_existing=True,
max_instances=1,
coalesce=True,
)
log.info("scheduler: phash blacklist every %dh", cfg["phash_blacklist_hours"])
if cfg.get("title_dedup_hours"):
sched.add_job(
_job_title_duration_dedup,

View file

@ -0,0 +1,65 @@
"""Wykrywanie i czyszczenie zdegenerowanych phashy.
Phash identyfikuje scenę tylko wtedy, gdy miniaturka pokazuje treść sceny. Gdy tube
serwuje zaślepkę, czarną klatkę albo wspólne intro studia, ta sama wartość ląduje
przy setkach niepowiązanych scen i przestaje cokolwiek znaczyć a `find_by_phash_within`
bierze najbliższą wartość z CAŁEJ tabeli, więc taki wpis jest czystym szumem.
Próg `min_scenes=10` wynika z rozkładu zmierzonego 2026-07-27: 11 000 wartości
występowało przy dokładnie 2 scenach (to realne duplikaty, chcemy je zachować),
1346 przy 3-9, a dopiero od 10 w górę zaczynają się grupy, w których liczba różnych
tytułów równa się liczbie scen czyli nic ich nie łączy poza obrazkiem.
Kolejność ma znaczenie: najpierw `refresh` (zapisz wartości na stałe), potem `purge`
(skasuj wiersze). Odwrotnie skasowalibyśmy dowody i blacklista wyszłaby pusta.
"""
from __future__ import annotations
import logging
from sqlalchemy import text
from sqlalchemy.orm import Session
log = logging.getLogger(__name__)
DEFAULT_MIN_SCENES = 10
_REFRESH_SQL = """
INSERT INTO phash_blacklist (value, scene_count)
SELECT value, count(DISTINCT scene_id)
FROM scene_fingerprints
WHERE kind = 'phash'
GROUP BY value
HAVING count(DISTINCT scene_id) >= :min_scenes
ON CONFLICT (value) DO UPDATE SET scene_count = EXCLUDED.scene_count
"""
_PURGE_SQL = """
DELETE FROM scene_fingerprints f
USING phash_blacklist b
WHERE f.kind = 'phash' AND f.value = b.value
"""
def refresh_phash_blacklist(session: Session, *, min_scenes: int = DEFAULT_MIN_SCENES) -> int:
"""Dopisz do blacklisty wartości dzielone przez ≥min_scenes scen. Zwraca rozmiar
blacklisty po odświeżeniu (wpisy raz dodane NIE znikają o to chodzi)."""
session.execute(text(_REFRESH_SQL), {"min_scenes": min_scenes})
return int(session.execute(text("SELECT count(*) FROM phash_blacklist")).scalar_one())
def purge_blacklisted_fingerprints(session: Session) -> int:
"""Skasuj wiersze `scene_fingerprints` o zablacklistowanej wartości. Zwraca liczbę
skasowanych. Sceny zostają nietknięte znika tylko bezużyteczny odcisk."""
return int(session.execute(text(_PURGE_SQL)).rowcount or 0)
def run_phash_blacklist(*, min_scenes: int = DEFAULT_MIN_SCENES) -> dict[str, int]:
from app.db import session_scope
with session_scope() as session:
size = refresh_phash_blacklist(session, min_scenes=min_scenes)
purged = purge_blacklisted_fingerprints(session)
session.commit()
log.info("phash blacklist: %d wartości na liście, skasowano %d odcisków", size, purged)
return {"blacklist_size": size, "purged": purged}

View file

@ -216,6 +216,9 @@ def run_forever() -> int:
"thumb_dedup_hours": getattr(settings, "sched_thumb_dedup_hours", 12) or None,
# Title+duration dedup — missing-merge tube-dupy (reports 28fe8181/32df33b1).
"title_dedup_hours": getattr(settings, "sched_title_dedup_hours", 12) or None,
# Blacklista zdegenerowanych phashy — zaślepki dzielone przez setki scen (audyt
# 2026-07-27). Bez tego zaślepka jest zawsze najbliższym trafieniem w Path 3.
"phash_blacklist_hours": getattr(settings, "sched_phash_blacklist_hours", 24) or None,
# Taxonomy scene_count refresh — denormalizacja liczników (perf fix 0019).
"taxonomy_counts_hours": getattr(settings, "sched_taxonomy_counts_hours", 3) or None,
# Ingest freshness watchdog — per-origin alert do Sentry (report 14f3a655).

View file

@ -29,6 +29,11 @@ from app.models.scene import Scene
from app.resolve.scene_merge import merge_scenes
from app.scheduler.bulk_dedup import _pick_keep_drop
# UWAGA (2026-07-27): bez wykluczenia `phash_blacklist` ten self-join jest NIEBEZPIECZNY.
# Jedna zaślepka była dzielona przez 892 sceny → sam ten klaster daje ~397 tys. par, a
# każde dwie z nich o zbliżonej długości i wspólnym performerze zostałyby scalone, mimo
# że to zupełnie różne sceny. Blacklista musi być odświeżona PRZED uruchomieniem:
# python -c "from app.scheduler.phash_blacklist import run_phash_blacklist; run_phash_blacklist()"
_SAFE_PAIRS_SQL = """
SELECT DISTINCT a.scene_id AS sa, b.scene_id AS sb
FROM scene_fingerprints a
@ -36,6 +41,7 @@ JOIN scene_fingerprints b ON a.value = b.value AND a.scene_id < b.scene_id
JOIN scenes sca ON sca.id = a.scene_id
JOIN scenes scb ON scb.id = b.scene_id
WHERE a.kind = 'phash' AND b.kind = 'phash'
AND NOT EXISTS (SELECT 1 FROM phash_blacklist bl WHERE bl.value = a.value)
AND sca.duration_sec IS NOT NULL AND scb.duration_sec IS NOT NULL
AND abs(sca.duration_sec - scb.duration_sec) <= 3
AND (