goon/app/models/duration_backfill_queue.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

67 lines
3.4 KiB
Python

"""Kolejka scen, którym trzeba doliczyć długość ze strony tuba.
Filtr `min_duration_sec=60` odcina śmieciowe klipy i tak ma zostać, ale w SQL-u
`duration_sec >= 60` NIE jest prawdą dla NULL, więc scena o NIEZNANEJ długości znika
z feedu tak samo jak dziesięciosekundowy śmieć. Audyt 2026-08-20: **1 755 421 scen**
nie ma długości ani na scenie, ani na źródle, czyli scraper nigdy jej nie zebrał.
Największe zaległości to porntrex (676 tys.), xhamster (361 tys.), xvideos (259 tys.)
i xnxx (226 tys.).
**Dlaczego nie jeden uniwersalny regex po stronie szczegółów.** Bo trafia w śmieci.
Na próbce: porntrex ma w HTML `duration: '5'` (długość reklamy), mypornerleak
`disclaimer_cookie_duration":"720"` (ciastko), a xhamster i porntrex wypisują długości
WSZYSTKICH filmów powiązanych z paska obok. Naiwne dopasowanie przy jednym xhamsterze
dało 198 s zamiast prawdziwych 240 s. Zła długość jest gorsza niż jej brak, bo dedup
traktuje długość jako najmocniejszy pojedynczy sygnał i skleiłby obce sceny.
Dlatego backfill obsługuje tylko originy z POTWIERDZONYM, punktowym wzorcem
(`app/extractors/duration_extract.py`), a nie wszystko, co się nawinie:
* xvideos, xnxx: `VideoObject` LD-JSON z ISO 8601, sprawdzone 5/5
* xhamster: `"videoEntity":{"id":<id z URL>,…,"duration":<sekundy>}`, z kontrolą
zgodności id, bo bez niej łapie film z paska obok
porntrex świadomie NIE jest tu obsługiwany: jego strona szczegółów w ogóle nie podaje
własnej długości (`video_duration` w flashvars nie występuje), a jedyne `durations`
w HTML należą do filmów powiązanych. Dla niego drogą jest listing, bo kafelek długość
pokazuje i `porntrex_browse.py` już ją stamtąd czyta.
Kolejka jest tabelą z tego samego powodu co przy phashu: wyznaczenie kandydatów to
ciężki przegląd całego katalogu, a mielenie ma przeżyć restart workera i pokazywać
postęp. Patrz [[phash_backfill_queue]] dla bliźniaczego wzorca.
"""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base
class DurationBackfillQueue(Base):
__tablename__ = "duration_backfill_queue"
scene_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("scenes.id", ondelete="CASCADE"),
primary_key=True,
)
#: Strona tuba, z której czytamy długość. Zapisana przy planowaniu, żeby mielenie
#: nie musiało co paczkę wracać do `playback_sources`.
page_url: Mapped[str] = mapped_column(String(1024), nullable=False)
#: Decyduje, KTÓRY punktowy wzorzec zostanie użyty. Bez tego wracamy do zgadywania.
origin: Mapped[str] = mapped_column(String(64), nullable=False)
#: Rośnie przy każdej nieudanej próbie; po wyczerpaniu limitu wiersz jest odkładany,
#: żeby skasowana strona nie blokowała kolejki w nieskończoność.
attempts: Mapped[int] = mapped_column(Integer, nullable=False, server_default="0")
#: Ustawione po sukcesie ALBO po wyczerpaniu prób (wtedy długość została pusta).
done_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)