Root cause zgloszenia 9d272101 ("5 duplikatow, czemu nie zmergowane?"):
phash mialo tylko 37,8% grywalnych scen. Licza go WYLACZNIE niektore
konektory w momencie ingestu, wiec najwieksze zrodla maja miniaturke dla
~100% scen, a odcisk dla 1-2% (xvideos 1,8%, xnxx 1,4%, eporner 1,0%).
Material jest zapisany, brakowalo tylko obliczenia.
W zglaszanym przypadku 7 wierszy o identycznej dlugosci 2324 s, a dwa
kafelki z IDENTYCZNA okladka nie mialy phasha wcale, wiec jedyny sygnal,
ktory by je sklail, nigdy nie powstal.
Zakres CELOWANY, nie caly katalog: sceny w klastrze (performer, dokladna
dlugosc) o licznosci >1, bez phasha, ale z zapisana miniaturka = 238 704
pozycje (wobec ~1,4 mln scen bez phasha). Reszta nie ma sie z czym skleic.
Kolejka jako TABELA, nie zapytanie w locie: wyznaczenie kandydatow to
ciezki agregat po calym katalogu, ktorego nie chcemy powtarzac przy kazdej
paczce, a kolejka przezywa restart workera i widac z niej postep.
Weryfikacja end-to-end na zglaszanej scenie: ce81a110 dostal phash
85c1bac2f8c60ecf, czyli DOKLADNIE ten sam, ktory mial juz 8373bbb5 (dwa
z kafelkow uzytkownika) -> istniejacy phash_exact je teraz sklei.
Nie zapisujemy wartosci z phash_blacklist (zaslepki dzielone przez setki
scen). Throttling swiadomy: 800 co 15 min, ~3 dni na nadrobienie.
Dwie pulapki SQL zapisane w kodzie: `referer` ma 255 znakow wiec origin
wycinamy w SQL (pelny page_url wywalal planowanie), a rzutowanie musi isc
przez CAST() bo text() SQLAlchemy nie podstawia parametru za ktorym stoi
`::`.
197 lines
7.5 KiB
Python
197 lines
7.5 KiB
Python
"""Dolicza phash scenom, które mają zapisaną miniaturkę, ale nie mają odcisku.
|
|
|
|
Kontekst, liczby i powód istnienia kolejki: `app/models/phash_backfill_queue.py`.
|
|
|
|
Dwa kroki, świadomie rozdzielone:
|
|
|
|
- `plan_phash_backfill()` — wyznacza kandydatów (ciężki agregat po katalogu, ~minuty)
|
|
i wrzuca ich do kolejki. Idempotentny, `ON CONFLICT DO NOTHING`, więc można go
|
|
puszczać ponownie, gdy dojdą nowe sceny.
|
|
- `run_phash_backfill()` — bierze paczkę z kolejki, pobiera miniaturki, liczy phash.
|
|
To jedyna część, która chodzi cyklicznie.
|
|
|
|
**Throttling jest tu istotny, nie kosmetyczny.** To 242 tysiące pobrań z cudzych CDN-ów.
|
|
Idziemy wąskim strumieniem (`_CONCURRENCY`) z przerwą między paczkami, żeby nie wyglądać
|
|
jak atak i nie ściągnąć bana na IP VPS-a, z którego lecą też scrapery. Domyślny budżet
|
|
paczki jest mały celowo: lepiej, żeby to trwało dniami w tle, niż żeby zablokowało worker.
|
|
|
|
**Nie zapisujemy wartości z `phash_blacklist`.** To zaślepki i czarne klatki dzielone
|
|
przez setki niepowiązanych scen (rekordzistka: 892 sceny o 892 różnych tytułach).
|
|
Wpisanie ich tutaj wprowadzałoby dokładnie ten szum, który tamta tabela wycina.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import time
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from urllib.parse import urlsplit
|
|
|
|
from sqlalchemy import text
|
|
|
|
from app.connectors.direct_scrapers._browse_base import compute_thumbnail_phash
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
#: Ile miniaturek naraz. Nisko celowo, patrz throttling w docstringu.
|
|
_CONCURRENCY = 4
|
|
#: Domyślny rozmiar paczki na jedno uruchomienie.
|
|
_DEFAULT_BATCH = 500
|
|
#: Po tylu nieudanych próbach odpuszczamy scenę (martwy CDN, 404, format nie do odczytu).
|
|
_MAX_ATTEMPTS = 3
|
|
|
|
#: Kandydaci: scena w klastrze (performer, DOKŁADNA długość) o liczności > 1, czyli
|
|
#: taka, która w ogóle ma z czym się skleić, bez phasha, ale z zapisaną miniaturką.
|
|
_PLAN_SQL = text(
|
|
"""
|
|
INSERT INTO phash_backfill_queue (scene_id, thumbnail_url, referer)
|
|
SELECT DISTINCT ON (s.id)
|
|
s.id,
|
|
ps.thumbnail_url,
|
|
-- SAM origin, nie pełny `page_url`: kolumna ma 255 znaków, a URL-e scen
|
|
-- bywają dłuższe (pierwsza wersja wywalała się na tym przy planowaniu).
|
|
-- CDN i tak sprawdza domenę odsyłającą, nie ścieżkę.
|
|
substring(ps.page_url from '^https?://[^/]+') || '/'
|
|
FROM scenes s
|
|
JOIN scene_performers sp ON sp.scene_id = s.id
|
|
JOIN (
|
|
SELECT sp2.performer_id, s2.duration_sec
|
|
FROM scenes s2
|
|
JOIN scene_performers sp2 ON sp2.scene_id = s2.id
|
|
WHERE s2.duration_sec >= 60
|
|
AND EXISTS (
|
|
SELECT 1 FROM playback_sources p2
|
|
WHERE p2.scene_id = s2.id AND p2.dead_at IS NULL
|
|
)
|
|
GROUP BY 1, 2
|
|
HAVING count(*) > 1
|
|
) k ON k.performer_id = sp.performer_id AND k.duration_sec = s.duration_sec
|
|
JOIN playback_sources ps ON ps.scene_id = s.id
|
|
AND ps.dead_at IS NULL
|
|
AND ps.thumbnail_url IS NOT NULL
|
|
WHERE NOT EXISTS (
|
|
SELECT 1 FROM scene_fingerprints f
|
|
WHERE f.scene_id = s.id AND f.kind = 'phash'
|
|
)
|
|
ON CONFLICT (scene_id) DO NOTHING
|
|
"""
|
|
)
|
|
|
|
_TAKE_SQL = text(
|
|
"""
|
|
SELECT scene_id, thumbnail_url, referer
|
|
FROM phash_backfill_queue
|
|
WHERE done_at IS NULL AND attempts < :max_attempts
|
|
ORDER BY attempts, scene_id
|
|
LIMIT :limit
|
|
"""
|
|
)
|
|
|
|
#: Wstawiamy odcisk tylko, gdy scena wciąż go nie ma (mogła dostać w międzyczasie
|
|
#: z ingestu) i gdy wartość nie jest zablacklistowana.
|
|
_INSERT_SQL = text(
|
|
"""
|
|
INSERT INTO scene_fingerprints (scene_id, kind, value)
|
|
-- `kind` to enum `fingerprint_kind`, surowy string wymaga rzutowania. `:value`
|
|
-- też: występuje i w SELECT, i w porównaniu z blacklistą, a bez rzutowania Postgres
|
|
-- wnioskuje dla niego dwa różne typy (text vs varchar) i odmawia wykonania.
|
|
-- Rzutujemy przez CAST(), nie `::`, bo `text()` SQLAlchemy nie podstawia parametru,
|
|
-- za którym stoi `::` (leci wtedy surowe `:value` i Postgres wywala składnię).
|
|
SELECT CAST(:scene_id AS uuid), 'phash'::fingerprint_kind, CAST(:value AS varchar)
|
|
WHERE NOT EXISTS (
|
|
SELECT 1 FROM scene_fingerprints f
|
|
WHERE f.scene_id = CAST(:scene_id AS uuid) AND f.kind = 'phash'
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM phash_blacklist b WHERE b.value = CAST(:value AS varchar)
|
|
)
|
|
ON CONFLICT DO NOTHING
|
|
"""
|
|
)
|
|
|
|
|
|
def _referer_origin(page_url: str | None) -> str | None:
|
|
"""Sam origin. Kolejka trzyma już origin (wycięty w `_PLAN_SQL`), więc to zabezpieczenie
|
|
na wiersze wstawione inaczej; dla gotowego originu zwraca go bez zmian."""
|
|
if not page_url:
|
|
return None
|
|
try:
|
|
parts = urlsplit(page_url)
|
|
if parts.scheme and parts.netloc:
|
|
return f"{parts.scheme}://{parts.netloc}/"
|
|
except ValueError:
|
|
pass
|
|
return None
|
|
|
|
|
|
def plan_phash_backfill() -> int:
|
|
"""Dopisz kandydatów do kolejki. Zwraca rozmiar kolejki po planowaniu."""
|
|
from app.db import session_scope
|
|
|
|
with session_scope() as session:
|
|
session.execute(_PLAN_SQL)
|
|
total = session.execute(
|
|
text("SELECT count(*) FROM phash_backfill_queue WHERE done_at IS NULL")
|
|
).scalar_one()
|
|
log.info("phash-backfill: w kolejce %d scen", total)
|
|
return int(total)
|
|
|
|
|
|
def run_phash_backfill(*, batch: int = _DEFAULT_BATCH, pause_sec: float = 0.0) -> dict[str, int]:
|
|
"""Przetwórz jedną paczkę. Zwraca liczniki: wzięte/policzone/zapisane/nieudane."""
|
|
from app.db import session_scope
|
|
|
|
with session_scope() as session:
|
|
rows = session.execute(
|
|
_TAKE_SQL, {"limit": batch, "max_attempts": _MAX_ATTEMPTS}
|
|
).all()
|
|
|
|
if not rows:
|
|
return {"taken": 0, "hashed": 0, "stored": 0, "failed": 0}
|
|
|
|
def _work(row) -> tuple:
|
|
scene_id, thumb, page_url = row
|
|
value = compute_thumbnail_phash(thumb, referer=_referer_origin(page_url))
|
|
return scene_id, value
|
|
|
|
results: list[tuple] = []
|
|
with ThreadPoolExecutor(max_workers=_CONCURRENCY) as pool:
|
|
for res in pool.map(_work, rows):
|
|
results.append(res)
|
|
|
|
hashed = stored = failed = 0
|
|
with session_scope() as session:
|
|
for scene_id, value in results:
|
|
if value:
|
|
hashed += 1
|
|
stored += int(
|
|
session.execute(
|
|
_INSERT_SQL, {"scene_id": scene_id, "value": value}
|
|
).rowcount
|
|
or 0
|
|
)
|
|
session.execute(
|
|
text(
|
|
"UPDATE phash_backfill_queue SET done_at = now() WHERE scene_id = :sid"
|
|
),
|
|
{"sid": scene_id},
|
|
)
|
|
else:
|
|
failed += 1
|
|
# Wyczerpane próby → domknij wiersz, żeby nie wracał w kółko.
|
|
session.execute(
|
|
text(
|
|
"""
|
|
UPDATE phash_backfill_queue
|
|
SET attempts = attempts + 1,
|
|
done_at = CASE WHEN attempts + 1 >= :max THEN now() ELSE NULL END
|
|
WHERE scene_id = :sid
|
|
"""
|
|
),
|
|
{"sid": scene_id, "max": _MAX_ATTEMPTS},
|
|
)
|
|
|
|
out = {"taken": len(rows), "hashed": hashed, "stored": stored, "failed": failed}
|
|
log.info("phash-backfill: %s", out)
|
|
if pause_sec:
|
|
time.sleep(pause_sec)
|
|
return out
|