goon/app/scheduler/duration_backfill.py
goon-foss 09f389100a
Some checks are pending
Backend tests / test (push) Waiting to run
feat(watchdog): jakosc zrodel, progi adaptacyjne, dlugosci perverzija i mypornerleak, poprawki parserow
- browse_runs (migracja 0056): jeden wiersz per scraper i przebieg browse-latest
- quality_watchdog: listing pusty albo bez nowych, skok lub zanik obsady, sceny bez
  dlugosci i miniatur, spadek udanych odtworzen per origin; fingerprint per zrodlo i rodzaj
- ingest_watchdog: prog swiezosci z mediany odstepow zrodla (3x, max 14 d) zamiast stalej
- duration backfill: resolvery perverzija (JSON-LD) i mypornerleak (itemprop), paczka
  po rowno z originow, 404/410 zamyka wiersz od razu
- parsery: galaxyporn data z JSON-LD, hentaimama dlugosc, javflix aktorki, fullmovies
  i hdporngg data z uploadDate
2026-10-14 12:13:13 +02:00

209 lines
7.9 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, gone). Sieć i parsowanie, bez dotykania bazy.
`gone` = strona odpowiedziała 404/410, więc kolejne próby nic nie dadzą i wiersz
można zamknąć od razu zamiast odwiedzać martwy adres trzy razy. Błąd sieci, 5xx
albo 403 (limit, geo) zostają zwykłą nieudaną próbą.
"""
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 in (404, 410):
return (scene_id, None, True)
if r.status_code >= 400:
return (scene_id, None, False)
return (scene_id, extract_duration_for_origin(origin, r.text, page_url), False)
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, False)
def _take_batch(session, *, batch: int) -> list[tuple]:
"""Paczka po rowno z kazdego originu, ktory ma jeszcze cos w kolejce.
Kolejnosc dopisania (`created_at`) jako jedyny klucz oznaczalaby, ze origin dopisany
pozniej (perverzija, mypornerleak, 2026-10-14) czeka, az 900 tys. wierszy xhamster,
xvideos i xnxx sie wyczerpie. Zamiast tego limit dzielimy miedzy originy z otwartymi
wierszami (najmniejsze kolejki dostaja swoj udzial w calosci, reszta dzieli resztę),
a wewnatrz originu zostaje stary porzadek: najpierw nieprobowane, potem najstarsze.
"""
remaining = session.execute(
text(
"""
SELECT origin, count(*) AS n
FROM duration_backfill_queue
WHERE done_at IS NULL AND attempts < :max_attempts
GROUP BY origin
"""
),
{"max_attempts": _MAX_ATTEMPTS},
).fetchall()
pending = sorted(((int(n), o) for o, n in remaining if n), key=lambda t: t[0])
rows: list[tuple] = []
left = batch
for i, (n, origin) in enumerate(pending):
quota = min(n, max(1, left // (len(pending) - i)))
if quota <= 0:
break
rows += [
tuple(r)
for r in session.execute(
text(
"""
SELECT scene_id, page_url, origin
FROM duration_backfill_queue
WHERE origin = :origin AND done_at IS NULL AND attempts < :max_attempts
ORDER BY attempts, created_at
LIMIT :quota
"""
),
{"origin": origin, "max_attempts": _MAX_ATTEMPTS, "quota": quota},
).fetchall()
]
left -= quota
if left <= 0:
break
return rows
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 = _take_batch(s, batch=batch)
if not rows:
return {"taken": 0, "filled": 0, "missed": 0, "gone": 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, _gone in results if dur]
gone = [sid for sid, dur, is_gone in results if not dur and is_gone]
missed = [sid for sid, dur, is_gone in results if not dur and not is_gone]
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 gone:
s.execute(
text(
"""
UPDATE duration_backfill_queue
SET attempts = :max_attempts, done_at = now()
WHERE scene_id = ANY(:ids)
"""
),
{"ids": gone, "max_attempts": _MAX_ATTEMPTS},
)
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),
"gone": len(gone),
}