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:
parent
0edc85c05f
commit
41ad7f1818
8 changed files with 338 additions and 1 deletions
37
alembic/versions/20260919_0044_playback_eporner_thumb_idx.py
Normal file
37
alembic/versions/20260919_0044_playback_eporner_thumb_idx.py
Normal 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")
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
116
app/dead_sources.py
Normal 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)
|
||||||
|
|
@ -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
126
app/scheduler/dead_reap.py
Normal 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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue