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:
parent
b485511f3f
commit
3de8489ffa
9 changed files with 192 additions and 1 deletions
39
alembic/versions/20260727_0025_phash_blacklist.py
Normal file
39
alembic/versions/20260727_0025_phash_blacklist.py
Normal 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")
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
32
app/models/phash_blacklist.py
Normal file
32
app/models/phash_blacklist.py
Normal 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 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
|
||||
)
|
||||
|
|
@ -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},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
65
app/scheduler/phash_blacklist.py
Normal file
65
app/scheduler/phash_blacklist.py
Normal 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}
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue