goon/app/scheduler/duration_backfill.py
goon-foss 544de9c5ba feat(ingest): backfill brakujacej dlugosci scen
1 755 421 scen nie ma dlugosci ani na scenie, ani na zrodle, wiec wypadaja z
feedu: w SQL "duration_sec >= 60" nie jest prawda dla NULL, przez co scena o
nieznanej dlugosci znika tak samo jak dziesieciosekundowy smiec. Filtr zostaje,
bo odcina smieci, uzupelniamy dane.

Swiadomie waskie originy. Uniwersalny regex trafia w smieci: porntrex ma w HTML
"duration: 5" reklamy, mypornerleak dlugosc ciastka 720, a xhamster i porntrex
wypisuja dlugosci filmow z paska obok (naiwne dopasowanie dalo 198s zamiast 240s).
Zla dlugosc jest gorsza niz brak, bo dedup opiera sie na niej najmocniej.

- xvideos, xnxx: VideoObject LD-JSON, 5/5 na probce
- xhamster: videoEntity z kontrola zgodnosci id z URL-a
- porntrex pominiety: strona szczegolow nie podaje wlasnej dlugosci

Kolejka jak przy phashu (plan raz, mielenie paczkami, przezywa restart):
845 899 scen, 500 stron co 5 min, czyli ok. 6 dni. Plan powtarza sie co 24h,
zeby swieze sceny bez dlugosci tez trafialy do kolejki.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 12:10:22 +02:00

148 lines
5.6 KiB
Python

"""Dolicza brakującą długość scenom ze stron tubów, paczkami.
Po co i dlaczego tylko wybrane originy: `app/models/duration_backfill_queue.py`.
W skrócie: bez długości scena wypada z feedu, bo `duration_sec >= 60` nie jest prawdą
dla NULL, a zła długość jest gorsza niż brak, bo dedup opiera się na niej najmocniej.
Stąd wyłącznie wzorce punktowe z `ORIGIN_DURATION_RESOLVERS`.
"""
from __future__ import annotations
import logging
from concurrent.futures import ThreadPoolExecutor
from sqlalchemy import text
from app.db import session_scope
from app.extractors.duration_extract import (
ORIGIN_DURATION_RESOLVERS,
extract_duration_for_origin,
)
log = logging.getLogger(__name__)
# Po tylu nieudanych próbach odkładamy wiersz. Strony bywają skasowane i nie ma sensu
# wracać do nich w kółko, blokując kolejkę.
_MAX_ATTEMPTS = 3
_DEFAULT_BATCH = 500
# Sześć wątków na paczkę, a paczki są rozłożone w czasie przez scheduler. Pobrania
# rozkładają się na trzy różne serwisy, więc żaden nie dostaje serii pod rząd.
_CONCURRENCY = 6
_FETCH_TIMEOUT = 20.0
def plan_duration_backfill(limit: int | None = None) -> int:
"""Wrzuca do kolejki sceny bez długości, mające źródło na obsługiwanym originie.
Idempotentne: `ON CONFLICT DO NOTHING`, więc można wołać wielokrotnie, także po
dopisaniu nowego originu do `ORIGIN_DURATION_RESOLVERS`.
"""
origins = sorted(ORIGIN_DURATION_RESOLVERS)
if not origins:
return 0
sql = """
INSERT INTO duration_backfill_queue (scene_id, page_url, origin)
SELECT DISTINCT ON (s.id) s.id, ps.page_url, ps.origin
FROM scenes s
JOIN playback_sources ps ON ps.scene_id = s.id
WHERE s.duration_sec IS NULL
AND ps.origin = ANY(:origins)
AND ps.page_url IS NOT NULL
AND ps.dead_at IS NULL
ORDER BY s.id, ps.origin
ON CONFLICT (scene_id) DO NOTHING
"""
if limit is not None:
# LIMIT musi objąć SELECT, nie INSERT, stąd podzapytanie przy ograniczonym planie.
sql = f"""
INSERT INTO duration_backfill_queue (scene_id, page_url, origin)
SELECT * FROM (
SELECT DISTINCT ON (s.id) s.id, ps.page_url, ps.origin
FROM scenes s
JOIN playback_sources ps ON ps.scene_id = s.id
WHERE s.duration_sec IS NULL
AND ps.origin = ANY(:origins)
AND ps.page_url IS NOT NULL
AND ps.dead_at IS NULL
ORDER BY s.id, ps.origin
) q LIMIT {int(limit)}
ON CONFLICT (scene_id) DO NOTHING
"""
with session_scope() as s:
n = s.execute(text(sql), {"origins": origins}).rowcount
log.info("duration-backfill: zaplanowano %d scen (originy: %s)", n, ",".join(origins))
return n
def _fetch_one(row: tuple) -> tuple:
"""(scene_id, duration|None). Sieć i parsowanie, bez dotykania bazy."""
scene_id, page_url, origin = row
from app.extractors._fetch import browser_get
try:
r = browser_get(page_url, timeout=_FETCH_TIMEOUT, follow_redirects=True)
if r.status_code >= 400:
return (scene_id, None)
return (scene_id, extract_duration_for_origin(origin, r.text, page_url))
except Exception as e: # pojedyncza strona nie może wywrócić paczki
log.debug("duration-backfill: %s padlo (%s)", page_url, e)
return (scene_id, None)
def run_duration_backfill(*, batch: int = _DEFAULT_BATCH) -> dict[str, int]:
"""Bierze paczkę z kolejki, dolicza długość, zapisuje. Pusta kolejka = no-op."""
with session_scope() as s:
rows = s.execute(
text(
"""
SELECT scene_id, page_url, origin
FROM duration_backfill_queue
WHERE done_at IS NULL AND attempts < :max_attempts
ORDER BY attempts, created_at
LIMIT :batch
"""
),
{"max_attempts": _MAX_ATTEMPTS, "batch": batch},
).fetchall()
if not rows:
return {"taken": 0, "filled": 0, "missed": 0}
with ThreadPoolExecutor(max_workers=_CONCURRENCY) as pool:
results = list(pool.map(_fetch_one, [tuple(r) for r in rows]))
filled = [(sid, dur) for sid, dur in results if dur]
missed = [sid for sid, dur in results if not dur]
with session_scope() as s:
for scene_id, dur in filled:
# Warunek na NULL, bo między planem a zapisem scena mogła zostać scalona
# albo wzbogacona z kanonu, a ten jest wiarygodniejszy niż tube.
s.execute(
text(
"UPDATE scenes SET duration_sec = :d "
" WHERE id = :id AND duration_sec IS NULL"
),
{"d": dur, "id": scene_id},
)
s.execute(
text(
"UPDATE duration_backfill_queue SET done_at = now() WHERE scene_id = :id"
),
{"id": scene_id},
)
if missed:
# Wyczerpane próby zamykamy, żeby kolejka nie kręciła się w kółko na martwych.
s.execute(
text(
"""
UPDATE duration_backfill_queue
SET attempts = attempts + 1,
done_at = CASE WHEN attempts + 1 >= :max_attempts
THEN now() ELSE NULL END
WHERE scene_id = ANY(:ids)
"""
),
{"ids": missed, "max_attempts": _MAX_ATTEMPTS},
)
return {"taken": len(rows), "filled": len(filled), "missed": len(missed)}