From 3de8489ffaa8cfeb8da7af72470f3acf6903dfe1 Mon Sep 17 00:00:00 2001 From: goon-foss Date: Mon, 27 Jul 2026 12:47:20 +0200 Subject: [PATCH] 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 --- .../versions/20260727_0025_phash_blacklist.py | 39 +++++++++++ app/config.py | 7 ++ app/models/__init__.py | 2 + app/models/phash_blacklist.py | 32 +++++++++ app/resolve/scene_match.py | 12 +++- app/scheduler/jobs.py | 27 ++++++++ app/scheduler/phash_blacklist.py | 65 +++++++++++++++++++ app/scheduler/worker.py | 3 + scripts/merge_phash_exact_dupes.py | 6 ++ 9 files changed, 192 insertions(+), 1 deletion(-) create mode 100644 alembic/versions/20260727_0025_phash_blacklist.py create mode 100644 app/models/phash_blacklist.py create mode 100644 app/scheduler/phash_blacklist.py diff --git a/alembic/versions/20260727_0025_phash_blacklist.py b/alembic/versions/20260727_0025_phash_blacklist.py new file mode 100644 index 0000000..6291deb --- /dev/null +++ b/alembic/versions/20260727_0025_phash_blacklist.py @@ -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") diff --git a/app/config.py b/app/config.py index 97d7ea1..9fae463 100644 --- a/app/config.py +++ b/app/config.py @@ -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:) przestał dawać nowe sceny > próg. Łapie zamrożenie # pojedynczego origin, którego globalny monitor (jeden Source "tube-scraper") nie diff --git a/app/models/__init__.py b/app/models/__init__.py index fa3fe80..a13f681 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -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", diff --git a/app/models/phash_blacklist.py b/app/models/phash_blacklist.py new file mode 100644 index 0000000..ca707e4 --- /dev/null +++ b/app/models/phash_blacklist.py @@ -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 tę 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 + ) diff --git a/app/resolve/scene_match.py b/app/resolve/scene_match.py index 34261b6..fc5c3cb 100644 --- a/app/resolve/scene_match.py +++ b/app/resolve/scene_match.py @@ -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}, diff --git a/app/scheduler/jobs.py b/app/scheduler/jobs.py index 8aa6a69..95198c6 100644 --- a/app/scheduler/jobs.py +++ b/app/scheduler/jobs.py @@ -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, diff --git a/app/scheduler/phash_blacklist.py b/app/scheduler/phash_blacklist.py new file mode 100644 index 0000000..e52168b --- /dev/null +++ b/app/scheduler/phash_blacklist.py @@ -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} diff --git a/app/scheduler/worker.py b/app/scheduler/worker.py index 2223a15..041c9de 100644 --- a/app/scheduler/worker.py +++ b/app/scheduler/worker.py @@ -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). diff --git a/scripts/merge_phash_exact_dupes.py b/scripts/merge_phash_exact_dupes.py index a163a44..b9b8823 100644 --- a/scripts/merge_phash_exact_dupes.py +++ b/scripts/merge_phash_exact_dupes.py @@ -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 (