feat: automatyczne sprzatanie martwych zrodel sxyprn i eporner

Oba serwisy kasuja filmy po cichu: strona odpowiada 200 z "Post Not Found" albo "Video has
been deleted". Probka 2026-09-19: 57% zywych w bazie zrodel sxyprn w wieku 10-40 dni i 22%
eporner bylo juz usunietych; w apce to puste kafelki i "the tube removed" po kliknieciu.

- eporner: ekstraktor rozpoznaje usunieta strone i rzuca HosterDead, wiec resolve oznacza
  zrodlo zamiast oddawac "blad chwilowy" (82 takie wejscia w 48 h, nic nie oznaczal).
- proxy miniatur: "Post Not Found" na sxyprn oznacza zrodlo od razu, 404 na miniaturze
  eporner uruchamia w tle sprawdzenie strony (28/30 takich miniatur to usuniete filmy).
  Indeks hash po thumbnail_url tylko dla eporner (0044), zeby znalezc zrodlo po obrazku.
- job dead-reap co 6 h: po 1500 zywych zrodel na serwis, polowa z ostatnich 30 dni.
  Oznacza tylko przy pewnym sygnale; siec i timeouty zostawia.

Sprawdzone na produkcji: 10/10 miniatur eporner i 23/23 postow sxyprn oznaczone, 17 zywych
postow nietknietych.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
goon-foss 2026-09-19 22:29:57 +02:00
parent 0edc85c05f
commit 41ad7f1818
8 changed files with 338 additions and 1 deletions

View file

@ -0,0 +1,37 @@
"""Indeks do szukania zrodla eporner po adresie miniatury (app/dead_sources.py).
Proxy miniatur dostaje od CDN-u eporner 404 i zna tylko adres obrazka; zeby sprawdzic i
oznaczyc film, trzeba znalezc jego zrodlo. Miniatura ma inny identyfikator niz strona
filmu, wiec bez indeksu to przeszukanie 4 mln wierszy przy kazdym pustym kafelku.
Hash i czesciowy (tylko eporner): wyszukujemy wylacznie po rownosci, a eporner to ~350 tys.
wierszy, wiec indeks ma kilkanascie MB.
CONCURRENTLY w autocommit_block, jak w 0035, 0038, 0039, 0041 i 0043.
Revision ID: 0044_playback_eporner_thumb_idx
Revises: 0043_scene_tags_tag_sort_idx
Create Date: 2026-09-19
"""
from __future__ import annotations
from alembic import op
revision = "0044_playback_eporner_thumb_idx"
down_revision: str | None = "0043_scene_tags_tag_sort_idx"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.get_context().autocommit_block():
op.execute(
"CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_playback_sources_eporner_thumb "
"ON playback_sources USING hash (thumbnail_url) "
"WHERE origin = 'tube:epornercom'"
)
def downgrade() -> None:
with op.get_context().autocommit_block():
op.execute("DROP INDEX CONCURRENTLY IF EXISTS ix_playback_sources_eporner_thumb")

View file

@ -433,6 +433,12 @@ async def proxy_image(
# GOON-5 (Cloudflare 523) i GOON-D, bezsensowny noise w Sentry, lepiej # GOON-5 (Cloudflare 523) i GOON-D, bezsensowny noise w Sentry, lepiej
# info log + 502 pass-through bez exception. # info log + 502 pass-through bez exception.
log.info("img proxy upstream %d for %s", r.status_code, target) log.info("img proxy upstream %d for %s", r.status_code, target)
if r.status_code == 404 and ".eporner.com/" in target:
# 404 na miniaturze eporner prawie zawsze znaczy usuniety film; strona
# jest sprawdzana w tle przed oznaczeniem (app/dead_sources.py).
from app import dead_sources
dead_sources.eporner_thumb_404(target)
return Response( return Response(
content=b"", content=b"",
status_code=502 if r.status_code >= 500 else r.status_code, status_code=502 if r.status_code >= 500 else r.status_code,
@ -548,6 +554,11 @@ async def sxyprn_thumb(post_id: str) -> Response:
return Response(content=b"", status_code=502, media_type="image/jpeg") return Response(content=b"", status_code=502, media_type="image/jpeg")
html = r.text html = r.text
if "Post Not Found" in html: if "Post Not Found" in html:
# Post usuniety, czyli strumien tez (ta sama strona). Oznaczamy zrodlo w tle,
# zeby scena zniknela z list, zamiast czekac na klikniecie (2026-09-19).
from app import dead_sources
dead_sources.sxyprn_post_not_found(pid)
return Response(content=b"", status_code=404, media_type="image/jpeg") return Response(content=b"", status_code=404, media_type="image/jpeg")
m = _OG_IMG_RE.search(html) or _OG_IMG_RE2.search(html) or _VID_POSTER_RE.search(html) m = _OG_IMG_RE.search(html) or _OG_IMG_RE2.search(html) or _VID_POSTER_RE.search(html)
if not m: if not m:

View file

@ -122,6 +122,10 @@ class Settings(BaseSettings):
sched_studio_title_dedup_hours: int = Field( sched_studio_title_dedup_hours: int = Field(
default=12, validation_alias="GOON_SCHED_STUDIO_TITLE_DEDUP_HOURS" default=12, validation_alias="GOON_SCHED_STUDIO_TITLE_DEDUP_HOURS"
) )
# Sprzatanie martwych zrodel sxyprn i eporner (app/scheduler/dead_reap.py): co N godzin
# porcja zywych zrodel na serwis, polowa z ostatnich 30 dni. 0 = off.
sched_dead_reap_hours: int = Field(default=6, validation_alias="GOON_SCHED_DEAD_REAP_HOURS")
dead_reap_per_origin: int = Field(default=1500, validation_alias="GOON_DEAD_REAP_PER_ORIGIN")
# Blacklista zdegenerowanych phashy, wartości dzielonych przez ≥10 scen (zaślepka, # Blacklista zdegenerowanych phashy, wartości dzielonych przez ≥10 scen (zaślepka,
# czarna klatka, intro studia). Audyt 2026-07-27: 73 takie wartości przy 4375 # czarna klatka, intro studia). Audyt 2026-07-27: 73 takie wartości przy 4375
# scenach, rekordzistka przy 892 scenach o 892 różnych tytułach. Tube może zacząć # scenach, rekordzistka przy 892 scenach o 892 różnych tytułach. Tube może zacząć

116
app/dead_sources.py Normal file
View file

@ -0,0 +1,116 @@
"""Oznaczanie martwych zrodel na podstawie sygnalow z proxy miniatur (2026-09-19).
Pusty kafelek to zwykle martwy film: user widzi scene bez miniatury, klika i dostaje
"the tube removed this video". Dotad zrodlo oznaczal dopiero resolve po kliknieciu, a sceny
bez klikniec wisialy w listach tygodniami. Proxy miniatur i tak pobiera to, co mowi o smierci
filmu, wiec tu zapisujemy wniosek:
- sxyprn: `/proxy/sxyprn-thumb/<post>` pobiera strone posta; "Post Not Found" to pewny
sygnal (ta sama strona daje miniature i strumien), oznaczamy od razu.
- eporner: miniatura z 404 w 28 na 30 przypadkow nalezala do usunietego filmu, w 2 strona
miala 404 (probka 2026-09-19). Mimo to sprawdzamy strone filmu przed oznaczeniem, bo 404
na miniaturze bywa tez chwilowe.
Wszystko w tle, na malej puli watkow: lista 50 scen potrafi dac kilkadziesiat 404 naraz,
a proxy miniatur nie moze na to czekac. Kazdy klucz obslugujemy raz na godzine.
"""
from __future__ import annotations
import logging
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from sqlalchemy import text
from app.db import session_scope
log = logging.getLogger(__name__)
_POOL = ThreadPoolExecutor(max_workers=2, thread_name_prefix="dead-mark")
_SEEN: dict[str, float] = {}
_SEEN_LOCK = threading.Lock()
_SEEN_TTL = 3600.0
_EPORNER_ORIGIN = "tube:epornercom"
_SXYPRN_ORIGIN = "tube:sxyprncom"
def _first_time(key: str) -> bool:
now = time.time()
with _SEEN_LOCK:
if _SEEN.get(key, 0.0) > now:
return False
_SEEN[key] = now + _SEEN_TTL
if len(_SEEN) > 50_000:
for k in [k for k, v in _SEEN.items() if v <= now]:
_SEEN.pop(k, None)
return True
def _mark(where_sql: str, params: dict, reason: str) -> int:
with session_scope() as s:
n = s.execute(
text(
"UPDATE playback_sources SET dead_at = now(), dead_reason = :reason "
f"WHERE dead_at IS NULL AND {where_sql}"
),
{**params, "reason": reason[:512]},
).rowcount
return n or 0
def sxyprn_post_not_found(post_id: str) -> None:
"""Strona posta sxyprn mowi "Post Not Found". Nie blokuje wolajacego."""
if not _first_time(f"sx:{post_id}"):
return
_POOL.submit(_mark_sxyprn, post_id)
def _mark_sxyprn(post_id: str) -> None:
try:
n = _mark(
"origin = :o AND page_url = :p",
{"o": _SXYPRN_ORIGIN, "p": f"https://sxyprn.com/post/{post_id}.html"},
"sxyprn: Post Not Found (proxy miniatury)",
)
if n:
log.info("dead-mark sxyprn %s: %d zrodel", post_id, n)
except Exception as e: # pragma: no cover - obronnie, to tylko sprzatanie
log.warning("dead-mark sxyprn %s padl: %s", post_id, e)
def eporner_thumb_404(thumb_url: str) -> None:
"""Miniatura eporner oddala 404. Sprawdza strone filmu w tle i oznacza, jesli usuniety."""
if not _first_time(f"ep:{thumb_url}"):
return
_POOL.submit(_verify_eporner, thumb_url)
def _verify_eporner(thumb_url: str) -> None:
from app.extractors._fetch import fetch_tube_html
from app.extractors._models import TubePageError
from app.extractors.tubes.eporner import is_deleted_page
try:
with session_scope() as s:
rows = s.execute(
text(
"SELECT page_url FROM playback_sources "
"WHERE origin = :o AND thumbnail_url = :t AND dead_at IS NULL"
),
{"o": _EPORNER_ORIGIN, "t": thumb_url},
).all()
for (page,) in rows:
reason = None
try:
if is_deleted_page(fetch_tube_html(page, timeout=30)):
reason = "eporner: video has been deleted (proxy miniatury)"
except TubePageError as e:
if e.status_code in (404, 410):
reason = f"eporner: strona {e.status_code} (proxy miniatury)"
if reason:
n = _mark("origin = :o AND page_url = :p", {"o": _EPORNER_ORIGIN, "p": page}, reason)
if n:
log.info("dead-mark eporner %s: %s", page, reason)
except Exception as e: # pragma: no cover - obronnie, to tylko sprzatanie
log.warning("dead-mark eporner %s padl: %s", thumb_url, e)

View file

@ -19,7 +19,7 @@ import logging
import re import re
from app.extractors._fetch import _DEFAULT_UA, browser_get, fetch_tube_html from app.extractors._fetch import _DEFAULT_UA, browser_get, fetch_tube_html
from app.extractors._models import StreamSource from app.extractors._models import HosterDead, StreamSource
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -46,8 +46,19 @@ def _hash_to_b36(hex_hash: str) -> str:
return "".join(parts) return "".join(parts)
def is_deleted_page(html: str) -> bool:
"""Strona usunietego filmu odpowiada 200 z tytulem, ale bez playera i z tekstem
"Video has been deleted" (np. "File has been removed due to copyright owner request").
Zywa strona tego tekstu nie ma (sprawdzone 2026-09-19)."""
return "Video has been deleted" in html and not _VID_RE.search(html)
def extract(page_url: str, *, timeout: float = 60.0) -> list[StreamSource] | None: def extract(page_url: str, *, timeout: float = 60.0) -> list[StreamSource] | None:
html = fetch_tube_html(page_url, timeout=timeout) html = fetch_tube_html(page_url, timeout=timeout)
if is_deleted_page(html):
# Bez tego resolve zwracal "blad chwilowy" (503) i zrodlo nigdy nie bylo oznaczane
# jako martwe: 82 takie wejscia w 48 h, te same martwe sceny wisialy w feedzie.
raise HosterDead(f"eporner {page_url}: video has been deleted")
m_vid = _VID_RE.search(html) m_vid = _VID_RE.search(html)
m_hash = _HASH_RE.search(html) m_hash = _HASH_RE.search(html)
if not m_vid or not m_hash: if not m_vid or not m_hash:

126
app/scheduler/dead_reap.py Normal file
View file

@ -0,0 +1,126 @@
"""Cykliczne sprzatanie martwych zrodel sxyprn i eporner (2026-09-19).
Oba serwisy kasuja filmy szybko i po cichu: strona dalej odpowiada 200, tylko z
komunikatem ("Post Not Found" na sxyprn, "Video has been deleted" na eporner). Probka
2026-09-19: 57% zywych w bazie zrodel sxyprn w wieku 10-40 dni i 22% eporner bylo juz
usunietych. W apce to puste kafelki i "the tube removed this video" po kliknieciu.
Dwie drogi, ktore sie uzupelniaja:
- app/dead_sources.py oznacza to, co ktos wlasnie przewija (sygnal z proxy miniatur),
- ten job co kilka godzin sprawdza porcje reszty: polowe z ostatnich 30 dni, bo tam
kasowanie jest najczestsze, polowe losowo ze starszych. Bez kursora i bez stanu: kazdy
przebieg bierze tylko zywe zrodla, wiec oznaczone same wypadaja z puli.
Oznaczamy tylko przy pewnym sygnale (komunikat o usunieciu albo 404 strony). Siec, timeout,
rate-limit i strona bez playera to "nie wiadomo" i zrodlo zostaje. Wolno i grzecznie
(3 watki, odstep przed kazdym pobraniem), bo sxyprn to nasze najczesciej ogladane zrodlo i
ban IP bylby gorszy niz martwe kafelki. Pelny przebieg po zaleglosciach:
scripts/sxyprn_reap_dead.py.
"""
from __future__ import annotations
import collections
import logging
import time
from concurrent.futures import ThreadPoolExecutor
from sqlalchemy import text
from app.db import session_scope
log = logging.getLogger(__name__)
_CONCURRENCY = 3
_SPACING_SEC = 0.5
_TIMEOUT = 20.0
_ALIVE, _DEAD, _UNKNOWN = "alive", "dead", "unknown"
def _probe_sxyprn(page_url: str) -> str:
from app.extractors._models import HosterDead
from app.extractors.tubes import sxyprn
try:
res = sxyprn.extract(page_url, timeout=_TIMEOUT)
except HosterDead:
return _DEAD
except Exception:
return _UNKNOWN
return _ALIVE if res and getattr(res[0], "link", None) else _UNKNOWN
def _probe_eporner(page_url: str) -> str:
from app.extractors._fetch import fetch_tube_html
from app.extractors._models import TubePageError
from app.extractors.tubes.eporner import is_deleted_page
try:
html = fetch_tube_html(page_url, timeout=_TIMEOUT)
except TubePageError as e:
return _DEAD if e.status_code in (404, 410) else _UNKNOWN
except Exception:
return _UNKNOWN
if is_deleted_page(html):
return _DEAD
return _ALIVE if "EP.video.player.vid" in html else _UNKNOWN
_PROBES = {
"tube:sxyprncom": ("sxyprn-reap: Post Not Found", _probe_sxyprn),
"tube:epornercom": ("eporner-reap: video has been deleted", _probe_eporner),
}
def _pick(origin: str, per_run: int) -> list[tuple[str, str]]:
half = per_run // 2
with session_scope() as s:
recent = s.execute(
text(
"SELECT id::text, page_url FROM playback_sources "
"WHERE origin = :o AND dead_at IS NULL AND page_url IS NOT NULL "
"AND created_at > now() - interval '30 days' ORDER BY random() LIMIT :n"
),
{"o": origin, "n": half},
).all()
older = s.execute(
text(
"SELECT id::text, page_url FROM playback_sources "
"WHERE origin = :o AND dead_at IS NULL AND page_url IS NOT NULL "
"AND created_at <= now() - interval '30 days' ORDER BY random() LIMIT :n"
),
{"o": origin, "n": per_run - len(recent)},
).all()
return [(r[0], r[1]) for r in recent + older]
def run_dead_reap(*, per_origin: int = 2000) -> dict[str, dict[str, int]]:
"""Sprawdza po `per_origin` zywych zrodel sxyprn i eporner, oznacza usuniete."""
out: dict[str, dict[str, int]] = {}
for origin, (reason, probe) in _PROBES.items():
t0 = time.time()
rows = _pick(origin, per_origin)
cnt: collections.Counter = collections.Counter()
dead_ids: list[str] = []
def work(row: tuple[str, str], _probe=probe) -> tuple[str, str]:
time.sleep(_SPACING_SEC)
return row[0], _probe(row[1])
with ThreadPoolExecutor(max_workers=_CONCURRENCY) as pool:
for sid, verdict in pool.map(work, rows):
cnt[verdict] += 1
if verdict == _DEAD:
dead_ids.append(sid)
marked = 0
for i in range(0, len(dead_ids), 500):
with session_scope() as s:
marked += s.execute(
text(
"UPDATE playback_sources SET dead_at = now(), dead_reason = :r "
"WHERE id = ANY(CAST(:ids AS uuid[])) AND dead_at IS NULL"
),
{"r": reason, "ids": dead_ids[i : i + 500]},
).rowcount
out[origin] = {"checked": len(rows), **cnt, "marked": marked}
log.info("dead-reap %s: %s w %.0f s", origin, out[origin], time.time() - t0)
return out

View file

@ -416,6 +416,22 @@ def _job_studio_title_dedup() -> None:
log.exception("[scheduler] studio-title dedup failed") log.exception("[scheduler] studio-title dedup failed")
def _job_dead_reap(per_origin: int) -> None:
"""Oznacz martwe zrodla sxyprn i eporner (strona mowi, ze film usuniety). Porcja na
przebieg, polowa z ostatnich 30 dni; szczegoly w app/scheduler/dead_reap.py."""
log.info("[scheduler] dead-reap starting (per_origin=%d)", per_origin)
try:
from app.scheduler.dead_reap import run_dead_reap
# 2 x 1500 sprawdzen po ~1,2 s na 3 watkach to ~20 min; limit z zapasem.
_run_with_timeout(
lambda: run_dead_reap(per_origin=per_origin), label="dead-reap", timeout_sec=3600
)
log.info("[scheduler] dead-reap done")
except Exception:
log.exception("[scheduler] dead-reap failed")
def _job_ingest_watchdog( def _job_ingest_watchdog(
max_age_hours: int, search_max_age_hours: int, movie_max_age_hours: int max_age_hours: int, search_max_age_hours: int, movie_max_age_hours: int
) -> None: ) -> None:
@ -756,6 +772,19 @@ def build_scheduler(cfg: dict[str, Any]) -> BlockingScheduler:
) )
log.info("scheduler: studio-title dedup every %dh", cfg["studio_title_dedup_hours"]) log.info("scheduler: studio-title dedup every %dh", cfg["studio_title_dedup_hours"])
if cfg.get("dead_reap_hours"):
_dr_per = cfg.get("dead_reap_per_origin") or 1500
sched.add_job(
_job_dead_reap,
IntervalTrigger(hours=cfg["dead_reap_hours"], start_date=INTERVAL_ANCHOR),
args=[_dr_per],
id="dead_reap",
replace_existing=True,
max_instances=1,
coalesce=True,
)
log.info("scheduler: dead-reap every %dh (per_origin=%d)", cfg["dead_reap_hours"], _dr_per)
if cfg.get("ingest_watchdog_hours"): if cfg.get("ingest_watchdog_hours"):
wd_max_age = cfg.get("ingest_watchdog_max_age_hours") or 48 wd_max_age = cfg.get("ingest_watchdog_max_age_hours") or 48
wd_search_max_age = cfg.get("ingest_watchdog_search_max_age_hours") or 168 wd_search_max_age = cfg.get("ingest_watchdog_search_max_age_hours") or 168

View file

@ -218,6 +218,9 @@ def run_forever() -> int:
"title_dedup_hours": getattr(settings, "sched_title_dedup_hours", 12) or None, "title_dedup_hours": getattr(settings, "sched_title_dedup_hours", 12) or None,
# Studio + rdzen tytulu, kopie z tubow o roznym formacie tytulu (report bb1a357d). # Studio + rdzen tytulu, kopie z tubow o roznym formacie tytulu (report bb1a357d).
"studio_title_dedup_hours": getattr(settings, "sched_studio_title_dedup_hours", 12) or None, "studio_title_dedup_hours": getattr(settings, "sched_studio_title_dedup_hours", 12) or None,
# Martwe zrodla sxyprn/eporner (strona mowi "usuniety"), porcja co 6 h.
"dead_reap_hours": getattr(settings, "sched_dead_reap_hours", 6) or None,
"dead_reap_per_origin": getattr(settings, "dead_reap_per_origin", 1500),
# Blacklista zdegenerowanych phashy, zaślepki dzielone przez setki scen (audyt # Blacklista zdegenerowanych phashy, zaślepki dzielone przez setki scen (audyt
# 2026-07-27). Bez tego zaślepka jest zawsze najbliższym trafieniem w Path 3. # 2026-07-27). Bez tego zaślepka jest zawsze najbliższym trafieniem w Path 3.
"phash_blacklist_hours": getattr(settings, "sched_phash_blacklist_hours", 24) or None, "phash_blacklist_hours": getattr(settings, "sched_phash_blacklist_hours", 24) or None,