goon/app/scheduler/phash_blacklist.py
goon-foss 3de8489ffa 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>
2026-07-27 12:47:20 +02:00

65 lines
2.6 KiB
Python

"""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}