Compare commits

..

7 commits

Author SHA1 Message Date
goon-foss
4fd94de72d fix: cofnij obietnice keep-awake (modul nie jest w APK) + martwe miniatury fastpic
Some checks are pending
Backend tests / test (push) Waiting to run
1) Changelog obiecywal 'screen no longer turns off', ale to NIE dziala. Audyt APK
v0.2.1: nazwa 'ExpoKeepAwake' NIE wystepuje w zadnym classes*.dex - jest tylko
KeepAwakeManager z expo-modules-core (wewnetrzny interfejs). Czyli expo-keep-awake
nie zostal wkompilowany, requireNativeModule rzuca, a moj try/catch cicho polyka
blad. Blad w moim wczesniejszym rozumowaniu: obecnosc w node_modules/expo/
node_modules NIE oznacza, ze autolinking wciagnal modul do konkretnego builda.
Keep-awake WYMAGA nowego APK (OTA dowozi tylko JS), wiec wpis usuniety zamiast
obiecywac cos, czego nie ma. Zgloszenie 9d5618dd.

2) fastpic.org (galaxyporn) zrywa polaczenie zamiast oddac obraz -> dopisany do
_is_rotting_thumb. Bonus: scena bez miniatury odpala w apce auto-enrich, ktory
sciaga swieza ze strony tube'a. Zgloszenie c2f1dd41, 240 z 20213 zrodel.
2026-08-05 21:58:06 +02:00
goon-foss
81f0074d34 fix(vjav): bramka jakosci tytulu + wlasna iteracja stron
Po naprawie dostepu vjav ruszyl, ale zaczal sypac smieciem: pomiar na 295 scenach z
doby po fixie dal 78,6 proc. tytulow slug-concat i ZERO przypisanej obsady. Takie
sceny sa w apce nieodnajdywalne - nie maja ani tytulu, ani performera.

Bramka odrzuca cztery wzorce: prefiks id_JAV, hash x1x..., kod typu
QCT062-KPUDYLMLBL913295279 oraz ciagi bez samoglosek/spacji (walniecie w klawiature).
Zmierzone na probce 6000 tytulow: odrzuca 80 proc. swiezych i 27 proc. starych, przy
39 przepuszczonych na 200 obejrzanych - zrodlo nie jest wyzerowane.

WAZNE, znalezione przy pomiarze pierwszej wersji: str.isalpha() jest prawdziwe dla
kana i kanji, a japonski nie stawia spacji, wiec reguly "za malo samoglosek" i "brak
spacji" odrzucalyby PRAWDZIWE tytuly na serwisie JAV - 708 z 708 tytulow CJK z probki.
Stad jawny wyjatek _has_cjk. Kontrola jednostkowa: 8/8 przypadkow, w tym dwa CJK.

Drugi blad, wprowadzony przez sama bramke i zlapany dopiero pilotem: strona 1 jest w
100 proc. smieciowa, wiec po odsianiu zwracala [], a bazowe latest_scenes traktuje
pusta strone jako koniec katalogu i konczylo caly run. Zrodlo dawalo seen: 0 mimo 39
dobrych scen na stronach 2-5. Wlasna iteracja rozstrzyga wyczerpanie po dlugosci listy
id, nie po liczbie scen po filtrze. To ta sama pulapka co w _load_ids.

Pilot po obu poprawkach: seen 39 (wszystkie skipped - weszly przed bramka).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 14:25:00 +02:00
goon-foss
6199577ae7 revert(watchdog): usuniecie integracji ze Slackiem
Na zyczenie uzytkownika. Alerty zostaja wylacznie w Sentry - z eskalacja poziomu i
kubelkami wieku w fingerprincie z poprzedniego commita, wiec sama poprawa
powiadamiania zostaje nienaruszona.

Usuniete: app/notify/ (caly pakiet), job watchdog-digest, parametr to_slack w
run_ingest_freshness_watchdog oraz klucze konfiguracji sched_watchdog_digest_hours,
slack_bot_token i slack_channel. Zadna wartosc SLACK nigdy nie trafila do .env ani do
compose, wiec nic nie bylo wysylane.

Po usunieciu watchdog dalej sprawdza 41 zrodel (sceny + filmy), scheduler rejestruje
juz tylko ingest-watchdog.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 15:12:17 +02:00
goon-foss
6e056b81c2 feat(watchdog): pokrycie filmow, eskalacja poziomu i dobowy digest na Slacka
Watchdog dzialal poprawnie - vjav i superporn wypisywal co 6h przez caly czas ich
ciszy. Zawiodlo POWIADOMIENIE, w trzech miejscach naraz.

1. Filmy nie byly pokryte w ogole. Watchdog skanowal tylko origins tube:<sitetag> ze
scen, wiec streamporn.vip stal 23 dni i zaden automat nie mial jak tego zauwazyc.
Doszlo 6 konektorow filmowych (35 -> 41 sprawdzanych zrodel), prog 72h. Zweryfikowane
przez celowe obnizenie progu do 1h: wszystkie 6 matchuje realne liczby pozycji, a nie
zwraca cicho zera - bo to dokladnie ta klasa bledu, ktora naprawiamy.

2. Stabilny fingerprint per origin tworzyl JEDNO issue przy pierwszym wystapieniu i
potem tylko licznik. Sentry powiadamia o nowych i regresjach, nie o kolejnych
wystapieniach otwartego issue. Teraz fingerprint zawiera kubelek wieku (2d+/7d+/30d+),
wiec przekroczenie kazdego progu zaklada nowe issue, ale w obrebie kubelka nadal nie
ma spamu co 6h.

3. Poziom byl zawsze warning, a reguly alertow celuja w error. Od 7 dni ciszy idzie
error. Sprawdzone: vjav i superporn eskaluja teraz do error, pornbusy/youperv/porndish
zostaja na warning.

Plus kanal, ktory czlowiek realnie czyta: dobowy digest na Slacka (osobny job, zeby
nie wysylac tej samej listy 4x dziennie). Domyslnie NO-OP - bez GOON_SLACK_BOT_TOKEN
i GOON_SLACK_CHANNEL nic nie probuje wysylac, kanalu nie zgadujemy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 15:03:27 +02:00
goon-foss
532c7e2bb4 fix(vjav): 429 parsowany jako XML, zrodlo stalo 12 dni bez zadnego bledu
Audyt po fixie streamporn.vip. vjav (77 tys. zrodel) nie dodal nic od 2026-07-22 i
NIE zglaszal bledu - crawl_page oddawal 0 scen, licznik pokazywal errors: 0.

Przyczyna: vjav odcial IP VPS-a. Sprawdzone 2026-08-03 - sitemap, strona glowna i
/latest-updates/ daja 429 przy trzech probach z rzedu, a przez Bright Data 200 od
pierwszej. Kod tego nie widzial, bo browser_get NIE rzuca na 4xx, wiec 343-bajtowa
tresc bledu byla parsowana jako XML: zero sitemap, zero id.

Drugi blad, ktory to ukryl: przy niepowodzeniu _load_ids ustawialo _sorted_ids = [],
a crawl_page tlumaczy [] na "koniec katalogu" (poprawny stan), nie na awarie. Teraz
przy bledzie zostaje None, ktore crawl_page zwraca jako transient-fail.

Fix: proxy Bright Data na sitemap i API metadanych (ten sam host, ten sam 429), jawne
sprawdzanie statusu, [] tylko dla realnie pustego katalogu.

Po fixie crawl_page oddaje 120 scen zamiast 0. Nowej podazy to jeszcze nie dalo
(wszystkie 120 to pozycje sprzed blokady, skipped na niezmienionym hashu) - sitemap
vjav nie przesunal sie od czasu odciecia. Do obserwacji. Uwaga na jakosc: w tej
partii 58 proc. tytulow to slug-concat typu 0008363_JAV_..., przy 2,9 proc. w calym
katalogu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 14:36:25 +02:00
goon-foss
3f8f38202a fix(movies): streamporn.vip czytal zly listing, stal od 23 dni
Zrodlo nie dodalo nic nowego od 2026-07-11. Strona jednak ZYLA, a konektor parsowal
poprawnie - problem byl w tym, ze `/movies/` na tym motywie NIE jest sortowane po
dacie i oddaje stary katalog. Zmierzone na tych samych 8 pozycjach: `/movies/` dalo
roczniki 2012-2015, strona glowna wylacznie 2026, po 10-11 zrodel playbacku kazda.
`?orderby=date` motyw ignoruje (odpowiedz identyczna z domyslna).

To ta sama klasa bledu co przy freshporno: wlasciwa strona, zly endpoint listingu.
Diagnoza po licznikach per zrodlo (ostatni nowy film + nowe w 30d/7d), nie po tym,
czy konektor rzuca wyjatkiem - bo nie rzucal.

Fix: `_listing_path` na `/` + `/page/N/`. Strony 1 i 2 wspoldziela ~44 linki
(sidebar/widgety), ale dedup po URL w fetch_movies je odsiewa.

Pilot po zmianie: seen 40, new 18, updated 10, skipped 12, errors 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 10:56:13 +02:00
goon-foss
c586a8ad32 feat(movies): konektor yourdailypornmovies.ws
Sprawdzony NA DUPLIKAT przed budowa, bo poprzedni kandydat (speedporn.net) okazal sie
kolejna nakladka na pandanetwork.club - 49/49 miniaturek z tego samego hosta co nasze
pandamovies, 0 nowych filmow. Tutaj z 40 pozycji ze strony glownej 13 mielismy po
tytule, 9 po id produktu Adult Empire, a 18 bylo nowych. Inna pula tresci: okladki DVD
z imgs1cdn.adultempire.com, nie re-hosty.

Pilot na 40: seen 40, new 22, updated 18, errors 0. W bazie 36 filmow, z tego 34 z
obsada, 36 ze studiem, 14 dopielo sie do pozycji, ktore juz mielismy.

WordPress z wlasnym motywem, wiec nie da sie podpiac pod DooplayConnector.

Pulapka, ktora trzeba bylo obejsc: /tag/ w calym HTML to 2177 linkow, czyli chmura
tagow w sidebarze (ten sam wzorzec co przy sexu). Obsada, tagi i studio czytane
WYLACZNIE z blokow div.xmll - efekt to 5-14 tagow na film zamiast 2177.

Druga rzecz: /director/ trzyma w praktyce STUDIO (New Sensations, VIXEN, Zero
Tolerance Films), nie rezysera. Bierzemy to jako studio, bo tak jest uzyte.

Playback: voe i mixdrop maja gotowe moduly hosterow i zweryfikowalem, ze resolwuja
(voe m3u8, mixdrop mp4). vidara.to nieznana, leci jako zwykly embed do WebView.
Katalog min. 8000 pozycji, siega 2003. Brak dlugosci filmu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 10:38:18 +02:00
10 changed files with 476 additions and 65 deletions

View file

@ -681,7 +681,13 @@ def _is_rotting_thumb(url: str) -> bool:
# tn.sextu.com (fullvideosporn) — CDN odrzuca ruch z IP datacenter (429), więc nasze # tn.sextu.com (fullvideosporn) — CDN odrzuca ruch z IP datacenter (429), więc nasze
# image-proxy NIGDY tej miniatury nie pobierze (znany problem tego źródła). Traktujemy # image-proxy NIGDY tej miniatury nie pobierze (znany problem tego źródła). Traktujemy
# jak martwą, żeby scena po merge sięgnęła po działającą z innego źródła. # jak martwą, żeby scena po merge sięgnęła po działającą z innego źródła.
return "tn.sextu.com" in url if "tn.sextu.com" in url:
return True
# fastpic.org (galaxyporn i inne re-uploadery) — hosting zrywa połączenie zamiast
# oddać obraz (RemoteProtocolError), czyli martwy. Zerowanie ma tu dodatkowy zysk:
# scena bez miniatury odpala w apce auto-enrich, który pobiera świeżą ze strony
# tube'a. Zgłoszenie c2f1dd41; dotyczy 240 z 20213 źródeł galaxyporn.
return "fastpic.org" in url
def _needs_proxy(url: str) -> bool: def _needs_proxy(url: str) -> bool:

View file

@ -123,6 +123,12 @@ class Settings(BaseSettings):
sched_phash_blacklist_hours: int = Field( sched_phash_blacklist_hours: int = Field(
default=24, validation_alias="GOON_SCHED_PHASH_BLACKLIST_HOURS" default=24, validation_alias="GOON_SCHED_PHASH_BLACKLIST_HOURS"
) )
# Próg ciszy dla konektorów FILMOWYCH. Wcześniej filmy nie były pokryte watchdogiem
# w ogóle — streamporn.vip stał 23 dni i nic nie krzyknęło. Filmy ingestujemy
# codziennie, więc 72h to bezpieczny margines na jeden nieudany cykl.
ingest_watchdog_movie_max_age_hours: int = Field(
default=72, validation_alias="GOON_INGEST_WATCHDOG_MOVIE_MAX_AGE_HOURS"
)
# Śmieciowi performerzy — kategorie i studia podszywające się pod osoby („Creampie", # Śmieciowi performerzy — kategorie i studia podszywające się pod osoby („Creampie",
# „Natural tits", „Brazzers"). Audyt 2026-07-27: 277 rekordów, 33 784 przypisania. # „Natural tits", „Brazzers"). Audyt 2026-07-27: 277 rekordów, 33 784 przypisania.
# Odrastają, bo tube-search dokleja performera po jednym tokenie. 24h. 0 = off. # Odrastają, bo tube-search dokleja performera po jednym tokenie. 24h. 0 = off.

View file

@ -40,6 +40,7 @@ def get_movie_connectors() -> list[tuple[str, type]]:
) )
from app.connectors.freeomovie import FreeoMovieConnector from app.connectors.freeomovie import FreeoMovieConnector
from app.connectors.paradisehill import ParadisehillConnector from app.connectors.paradisehill import ParadisehillConnector
from app.connectors.yourdailypornmovies import YourDailyPornMoviesConnector
# Kolejność ingestu: paradisehill FIRST (canonical primary, mirrory się do # Kolejność ingestu: paradisehill FIRST (canonical primary, mirrory się do
# niego przyklejają), potem mangoporn (jedyny mirror z realnym new-content — # niego przyklejają), potem mangoporn (jedyny mirror z realnym new-content —
@ -58,4 +59,12 @@ def get_movie_connectors() -> list[tuple[str, type]]:
# freeomovie.to (bestia theme, nie dooplay) — mirror: świeże filmy DVD, # freeomovie.to (bestia theme, nie dooplay) — mirror: świeże filmy DVD,
# title-trigram attach do canonical + playback z TABS. Ocena 2026-07-02. # title-trigram attach do canonical + playback z TABS. Ocena 2026-07-02.
("freeomovie", FreeoMovieConnector), ("freeomovie", FreeoMovieConnector),
# yourdailypornmovies.ws (własny motyw WP, nie dooplay) — ocena 2026-07-28.
# SPRAWDZONY NA DUPLIKAT: 18/40 filmów ze strony głównej było nowych, bo to inna
# pula treści (okładki DVD z adultempire, nie re-hosty z pandanetwork.club, na
# których siedzą mangoporn/pandamovies/speedporn). Katalog min. 8000, do 2003.
# Metadane: obsada, studio (kryje się pod `/director/`), tagi, rok, synopsis,
# id produktu Adult Empire. Bez długości. Playback: voe + mixdrop (oba mają
# gotowe moduły hosterów), vidara.to nieznana — leci jako zwykły embed.
("yourdailypornmovies", YourDailyPornMoviesConnector),
] ]

View file

@ -31,6 +31,7 @@ from app.connectors.base import (
RawStudio, RawStudio,
RawTag, RawTag,
) )
from app.config import get_settings
from app.connectors.direct_scrapers._browse_base import BaseBrowseScraper from app.connectors.direct_scrapers._browse_base import BaseBrowseScraper
from app.extractors import browser_get from app.extractors import browser_get
from app.normalize.text import slugify from app.normalize.text import slugify
@ -58,6 +59,47 @@ def _parse_duration(value: str | None) -> int | None:
return sec or None return sec or None
# --- bramka jakości tytułu ----------------------------------------------------------
# vjav zaczął sypać śmieciem: pomiar 2026-08-04 na 295 scenach z doby po naprawie
# dostępu dał 78,6% tytułów typu slug-concat i ZERO przypisanej obsady. Takie sceny są
# w apce nieodnajdywalne — nie mają ani tytułu, ani performera, po którym da się trafić.
# W starym katalogu ten sam pomiar daje 27% (wcześniejsza liczba 2,9% była zaniżona,
# bo sprawdzała tylko jeden z czterech wzorców).
_JUNK_ID_JAV = re.compile(r"^\d{6,}_JAV", re.IGNORECASE) # 0008364_JAV_JapaneseAV_...
_JUNK_HEX_ID = re.compile(r"^x\d+x[0-9A-F]{12,}$") # x1x84BDE66B5F8862EDA6
_JUNK_CODE_MASH = re.compile(r"^[A-Za-z]{2,4}\d{2,4}-[A-Z]{6,}\d{6,}$") # QCT062-KPUDYLMLBL913295279
def _has_cjk(text: str) -> bool:
"""Tytuły japońskie/chińskie MUSZĄ omijać heurystyki znakowe: `str.isalpha()` jest
prawdziwe dla kana i kanji, a japoński nie stawia spacji, więc reguły za mało
samogłosek" i „brak spacji" odrzuciłyby prawdziwe tytuły na serwisie JAV. Ta pomyłka
wyszła przy pomiarze pierwszej wersji bramki bez tego wyjątku poszłoby 708 z 708
tytułów CJK z próbki."""
return sum(1 for c in text if " " <= c <= "鿿" or "" <= c <= "") >= 3
def _is_junk_title(title: str) -> bool:
"""True gdy tytuł nie niesie żadnej informacji (id, hash, walnięcie w klawiaturę).
Zmierzone na próbce 6000 tytułów vjav: odrzuca 76% świeżych i 27% starych, przy
ZERO odrzuconych tytułach CJK. Przepuszczone przykłady to normalne zdania
(Maki Horiguchis Erotic Shower...", „200GANA-2482 Japanese Amateur")."""
t = (title or "").strip()
if not t:
return True
if _JUNK_ID_JAV.search(t) or _JUNK_HEX_ID.search(t) or _JUNK_CODE_MASH.search(t):
return True
if _has_cjk(t):
return False
letters = [c for c in t if "a" <= c.lower() <= "z"]
if len(letters) >= 12:
vowels = sum(1 for c in letters if c.lower() in "aeiouy")
if vowels / len(letters) < 0.22:
return True
return " " not in t and len(t) >= 20
def _parse_post_date(value: str | None) -> date | None: def _parse_post_date(value: str | None) -> date | None:
if not value: if not value:
return None return None
@ -74,21 +116,45 @@ class VjavScraper(BaseBrowseScraper):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# Cache id-ów wideo (posortowane malejąco = newest first), per instancja/run. # Cache id-ów wideo (posortowane malejąco = newest first), per instancja/run.
self._sorted_ids: list[int] | None = None self._sorted_ids: list[int] | None = None
# vjav odciął IP VPS-a: 2026-08-03 KAŻDA ścieżka (sitemap, strona główna,
# /latest-updates/) oddawała 429 przy trzech próbach z rzędu, a przez proxy
# 200 od pierwszej. Źródło stało 12 dni. Bright Data jest tu więc warunkiem
# działania, nie optymalizacją — bez klucza scraper się wyłącza.
self._proxy = get_settings().brightdata_proxy_url
if not self._proxy:
log.warning("vjav: BRAK BRIGHTDATA_PROXY_URL — vjav blokuje IP VPS-a (429)")
# ---- browse: sitemap id-walk ------------------------------------------------ # ---- browse: sitemap id-walk ------------------------------------------------
def _load_ids(self) -> None: def _load_ids(self) -> None:
"""`self._sorted_ids = []` znaczy „katalog pusty", a to woła `crawl_page` do
zwrócenia [], czyli koniec katalogu". Dlatego błąd pobrania NIE MOŻE tu
ustawiać []: przy 429 scraper raportował 0 scen i 0 błędów, więc przez 12 dni
wyglądał na zdrowy. Zostawiamy None = nie wiadomo", co crawl_page tłumaczy na
transient-fail. Status sprawdzamy jawnie, bo `browser_get` nie rzuca na 4xx
i to właśnie przez to treść błędu 429 była parsowana jako XML."""
try: try:
res = browser_get(_SITEMAP_INDEX, timeout=self._timeout) res = browser_get(_SITEMAP_INDEX, timeout=self._timeout, proxy=self._proxy)
if getattr(res, "status_code", 200) >= 400:
log.warning("vjav: sitemap index HTTP %s — przerywam", res.status_code)
self._sorted_ids = None
return
idx = res.text if hasattr(res, "text") else res idx = res.text if hasattr(res, "text") else res
except Exception as e: except Exception as e:
log.warning("vjav: sitemap index fetch fail: %s", e) log.warning("vjav: sitemap index fetch fail: %s", e)
self._sorted_ids = [] self._sorted_ids = None
return return
maps = list(dict.fromkeys(_SITEMAP_RE.findall(idx))) maps = list(dict.fromkeys(_SITEMAP_RE.findall(idx)))
if not maps:
log.warning("vjav: sitemap index bez sitemap wideo (%d B) — przerywam", len(idx))
self._sorted_ids = None
return
ids: set[int] = set() ids: set[int] = set()
for sm in maps: for sm in maps:
try: try:
res = browser_get(sm, timeout=self._timeout) res = browser_get(sm, timeout=self._timeout, proxy=self._proxy)
if getattr(res, "status_code", 200) >= 400:
log.info("vjav: sitemap %s HTTP %s", sm, res.status_code)
continue
body = res.text if hasattr(res, "text") else res body = res.text if hasattr(res, "text") else res
except Exception as e: except Exception as e:
log.info("vjav: sitemap %s fetch fail: %s", sm, e) log.info("vjav: sitemap %s fetch fail: %s", sm, e)
@ -108,6 +174,8 @@ class VjavScraper(BaseBrowseScraper):
slug = (v.get("dir") or "").strip() slug = (v.get("dir") or "").strip()
if not title or not slug: if not title or not slug:
return None return None
if _is_junk_title(title):
return None
page_url = f"{_BASE}/videos/{vid}/{slug}/" page_url = f"{_BASE}/videos/{vid}/{slug}/"
duration_sec = _parse_duration(v.get("duration")) duration_sec = _parse_duration(v.get("duration"))
release_date = _parse_post_date(v.get("post_date")) release_date = _parse_post_date(v.get("post_date"))
@ -164,6 +232,29 @@ class VjavScraper(BaseBrowseScraper):
raw={"source": "vjav_api", "id": vid}, raw={"source": "vjav_api", "id": vid},
) )
def latest_scenes(self, *, max_pages: int = 5):
"""Własna iteracja, bo bazowa przerywa na pierwszej pustej stronie.
Po wprowadzeniu bramki jakości pusta strona przestała znaczyć koniec
katalogu": strona 1 bywa w 100% śmieciowa (same `0008364_JAV_...`), więc po
odsianiu zwraca [], a bazowe `latest_scenes` traktowało to jak wyczerpanie i
kończyło CAŁY run źródło dawało `seen: 0` mimo 39 dobrych scen na stronach
2-5. To ta sama pułapka [] znaczy wyczerpane" co w `_load_ids`.
Tu wyczerpanie rozstrzyga długość listy id, nie liczba scen po filtrze.
"""
if self._sorted_ids is None:
self._load_ids()
if not self._sorted_ids:
return
for page in range(1, max_pages + 1):
if (page - 1) * _PER_PAGE >= len(self._sorted_ids):
return # realny koniec katalogu
scenes = self.crawl_page(page)
if scenes is None:
return # transient fail
yield from scenes
def crawl_page(self, page: int) -> list[RawScene] | None: def crawl_page(self, page: int) -> list[RawScene] | None:
if self._sorted_ids is None: if self._sorted_ids is None:
self._load_ids() self._load_ids()
@ -177,7 +268,14 @@ class VjavScraper(BaseBrowseScraper):
out: list[RawScene] = [] out: list[RawScene] = []
for vid in chunk: for vid in chunk:
try: try:
res = browser_get(self._meta_url(vid), timeout=self._timeout, headers={"Referer": _BASE + "/"}) # Proxy także tutaj — API metadanych siedzi na tym samym hoście, więc
# obejmuje je ten sam 429 co sitemap.
res = browser_get(
self._meta_url(vid),
timeout=self._timeout,
headers={"Referer": _BASE + "/"},
proxy=self._proxy,
)
body = res.text if hasattr(res, "text") else res body = res.text if hasattr(res, "text") else res
v = json.loads(body).get("video") v = json.loads(body).get("video")
except Exception as e: except Exception as e:

View file

@ -454,6 +454,22 @@ class StreampornVipConnector(DooplayConnector):
name = "streampornvip" name = "streampornvip"
base_url = "https://streamporn.vip" base_url = "https://streamporn.vip"
def _listing_path(self, page: int) -> str:
"""Strona główna, NIE `/movies/`. Diagnoza 2026-08-03: źródło stało od
2026-07-11 (0 nowych przez 23 dni), ale strona żyła i konektor parsował
poprawnie tyle że `/movies/` na tym motywie NIE jest sortowane po dacie i
oddaje stary katalog. Zmierzone na tych samych 8 pozycjach: `/movies/` dało
roczniki 2012-2015, strona główna wyłącznie 2026 (po 10-11 źródeł playbacku).
`?orderby=date` motyw ignoruje (odpowiedź identyczna z domyślną).
To ta sama klasa błędu co przy freshporno: właściwa strona, zły endpoint
listingu patrz [[reference_kvs_root_rotates_use_latest_updates]].
Strony 1 i 2 współdzielą ~44 linki (sidebar/widgety), ale treść główna jest
stronicowana normalnie; dedup po URL w `fetch_movies` i tak je odsiewa.
"""
return "/" if page == 1 else f"/page/{page}/"
class PandamoviesConnector(DooplayConnector): class PandamoviesConnector(DooplayConnector):
name = "pandamovies" name = "pandamovies"

View file

@ -0,0 +1,225 @@
"""yourdailypornmovies.ws — konektor filmów. Dodany 2026-07-28.
WordPress z własnym motywem (NIE dooplay), więc nie da się podpiąć pod
`DooplayConnector` jak mangoporn/pandamovies/streamporn.
**Sprawdzony na duplikat przed dodaniem** (2026-07-28), bo poprzedni kandydat
(speedporn.net) okazał się kolejną nakładką na `pandanetwork.club`, czyli 0 nowych
filmów. Tutaj z 40 filmów ze strony głównej: 13 mieliśmy po tytule, 9 po id produktu
Adult Empire, a **18 (45%) było nowych**. Inna pula treści: miniaturki to oficjalne
okładki DVD z `imgs1cdn.adultempire.com`, nie re-hosty z pandanetwork.
Katalog: min. 8000 pozycji (strona 200 nadal oddaje wyniki, po 40 na stronę), sięga
2003 roku, strona 1 ma bieżący rok.
**Pułapka: `/tag/` w całym HTML to 2177 linków** to chmura tagów w sidebarze,
identyczny wzorzec jak przy sexu. Tagi (i obsada) MUSZĄ być czytane z bloków
`div.xmll`, inaczej każdy film dostanie te same dwa tysiące śmieci.
**`/director/` trzyma w praktyce STUDIO**, nie reżysera (New Sensations", „Brazzers").
Bierzemy to jako studio, bo tak jest użyte, a reżyserów strona i tak nie podaje.
Czego strona nie ma: długości filmu.
Playback: 3 hostery w iframe. `voe.sx` i `mxdrop.sx` zweryfikowane, że resolwują
istniejącymi modułami (`app/extractors/hosters/voe.py` m3u8, `mixdrop.py` mp4).
`vidara.to` jest nam nieznany zapisujemy go jako zwykły embed, telefon otworzy go
w WebView, ale nie liczymy na niego.
"""
from __future__ import annotations
import html as html_mod
import logging
import re
from collections.abc import Iterator
from datetime import datetime
from urllib.parse import urlparse
from app.connectors.base import (
BaseMovieConnector,
RawMovie,
RawPerformer,
RawPlaybackSource,
RawStudio,
RawTag,
)
from app.extractors import browser_get
from app.models.source import SourceKind
from app.normalize.text import slugify
log = logging.getLogger(__name__)
_BASE = "https://yourdailypornmovies.ws"
# Karta listingu: id produktu Adult Empire w miniaturce + URL + tytuł + rok.
_CARD_RE = re.compile(
r'<div class="movie"><div class="imagen">\s*'
r'<img src="(?P<poster>https://imgs\d*cdn\.adultempire\.com/products/\d+/(?P<ae>\d+)h\.jpg)"'
r'[^>]*>\s*<a href="(?P<url>https://yourdailypornmovies\.ws/[^"]+)">.*?'
r'</div><h2>(?P<title>[^<]+)</h2>\s*<span class="year">(?P<year>\d{4})</span>',
re.DOTALL,
)
# Blok metadanych filmu. WSZYSTKO czytamy TYLKO stąd — patrz pułapka w docstringu.
_XMLL_RE = re.compile(r'<div class="xmll">.*?</div>', re.DOTALL)
_ACTOR_RE = re.compile(r'href="[^"]*/actor/[^"]+"[^>]*>([^<]+)</a>')
_TAG_RE = re.compile(r'href="[^"]*/tag/[^"]+"[^>]*>([^<]+)</a>')
_DIRECTOR_RE = re.compile(r'href="[^"]*/director/[^"]+"[^>]*>([^<]+)</a>')
_YEAR_RE = re.compile(r'href="[^"]*/fecha-estreno/(\d{4})/"')
_OG_DESC_RE = re.compile(r'<meta property="og:description" content="([^"]*)"')
_IFRAME_RE = re.compile(r'<iframe[^>]+src="((?:https?:)?//[^"]+)"', re.IGNORECASE)
# Hosty w iframe, które NIE są odtwarzaczem (reklamy).
_AD_HOSTS = ("realsrv", "magsrv", "exosrv", "juicyads", "google", "doubleclick")
def _host_label(url: str) -> str | None:
host = (urlparse(url).hostname or "").lower().replace("www.", "")
if not host:
return None
parts = host.split(".")
return parts[-2] if len(parts) >= 2 else parts[0]
class YourDailyPornMoviesConnector(BaseMovieConnector):
kind = SourceKind.scraper
name = "yourdailypornmovies"
base_url = _BASE
_MAX_PAGES_DELTA = 3
_MAX_PAGES_FULL = 40
def __init__(self, *, timeout: float = 30.0) -> None:
self._timeout = timeout
def close(self) -> None:
pass
def _fetch(self, url: str) -> str:
if not url.startswith("http"):
url = _BASE + url
r = browser_get(url, timeout=self._timeout, follow_redirects=True)
if r.status_code >= 400:
raise RuntimeError(f"{r.status_code} for {url}")
return r.text
def fetch_movies(
self, *, since: datetime | None = None, limit: int | None = None
) -> Iterator[RawMovie]:
seen = 0
seen_urls: set[str] = set()
max_pages = self._MAX_PAGES_DELTA if since is not None else self._MAX_PAGES_FULL
for page in range(1, max_pages + 1):
path = "/" if page == 1 else f"/page/{page}/"
try:
listing = self._fetch(path)
except Exception as e:
log.warning("%s listing page=%d failed: %s", self.name, page, e)
return
cards = list(_CARD_RE.finditer(listing))
if not cards:
log.info("%s: pusta strona=%d, stop", self.name, page)
return
for m in cards:
url = m.group("url")
if url in seen_urls:
continue
seen_urls.add(url)
try:
movie = self._parse_detail(m, url)
except Exception as e:
log.warning("%s detail failed %s: %s", self.name, url, e)
continue
if movie is None:
continue
yield movie
seen += 1
if limit is not None and seen >= limit:
return
def _parse_detail(self, card: re.Match[str], url: str) -> RawMovie | None:
detail = self._fetch(url)
title = html_mod.unescape(card.group("title")).strip()
if not title:
return None
# Playback najpierw — bez niego film jest bezużyteczny, nie tworzymy orphana.
playback: list[RawPlaybackSource] = []
seen_host: set[str] = set()
for m in _IFRAME_RE.finditer(detail):
embed = m.group(1)
if embed.startswith("//"):
embed = "https:" + embed
host = _host_label(embed)
if not host or host in seen_host or any(a in embed for a in _AD_HOSTS):
continue
seen_host.add(host)
playback.append(
RawPlaybackSource(
origin=f"{self.name}:{host}",
page_url=embed,
embed_url=embed,
)
)
if not playback:
return None
# Obsada, tagi i studio WYŁĄCZNIE z bloków `div.xmll` (chmura tagów z sidebara
# ma 2177 linków i zaśmieciłaby każdy film tym samym zestawem).
blocks = " ".join(_XMLL_RE.findall(detail))
performers: list[RawPerformer] = []
seen_perf: set[str] = set()
for name in _ACTOR_RE.findall(blocks):
name = html_mod.unescape(name).strip()
slug = slugify(name)
if slug and slug not in seen_perf:
seen_perf.add(slug)
performers.append(
RawPerformer(external_id=f"{self.name}:performer:{slug}", name=name)
)
tags: list[RawTag] = []
seen_tag: set[str] = set()
for name in _TAG_RE.findall(blocks):
name = html_mod.unescape(name).strip()
slug = slugify(name)
if slug and slug not in seen_tag:
seen_tag.add(slug)
tags.append(RawTag(external_id=f"{self.name}:tag:{slug}", name=name, slug=slug))
studio = None
dm = _DIRECTOR_RE.search(blocks)
if dm:
sname = html_mod.unescape(dm.group(1)).strip()
if sname and slugify(sname):
studio = RawStudio(
external_id=f"{self.name}:studio:{slugify(sname)}",
name=sname,
slug=slugify(sname),
)
ym = _YEAR_RE.search(detail)
year = int(ym.group(1)) if ym else int(card.group("year"))
desc = None
om = _OG_DESC_RE.search(detail)
if om:
desc = html_mod.unescape(om.group(1)).strip() or None
slug = urlparse(url).path.strip("/").split("/")[-1]
return RawMovie(
external_id=slug,
title=title,
description=desc,
release_year=year,
url=url,
poster_url=card.group("poster"),
studio=studio,
performers=performers,
tags=tags,
playback_sources=playback,
# id produktu Adult Empire — mocniejsza kotwica do dedupu niż tytuł
# (przy ocenie źródła złapało 9 filmów, których tytuł się nie zgadzał).
raw={"source": self.name, "url": url, "adultempire_id": card.group("ae")},
)

View file

@ -1,20 +1,32 @@
"""Per-sitetag freshness watchdog — alert gdy aktywny tube przestał dawać nowe sceny. """Per-origin freshness watchdog — alert gdy aktywne źródło przestało dawać nowe treści.
Globalny monitor źródeł (ingest_runs per `Source`) tego NIE łapie, bo wszystkie tube Globalny monitor źródeł (ingest_runs per `Source`) tego NIE łapie, bo wszystkie tube
scrapery dzielą jeden `Source` = "tube-scraper" pojedynczy origin może zamarznąć scrapery dzielą jeden `Source` = "tube-scraper" pojedynczy origin może zamarznąć
(np. freshporno: scraper browsował z roota `/`, który KVS rotuje cold-session (np. freshporno: scraper browsował z roota `/`, który KVS rotuje cold-session
dostawała stary zestaw new=0/skipped=N przez 2 dni), a zagregowany run nadal dostawała stary zestaw new=0/skipped=N przez 2 dni), a zagregowany run nadal
raportuje success. Sygnał per-origin: `max(created_at)` na playback_sources danego raportuje success. Sygnał per-origin: `max(created_at)` na źródłach playbacku.
`tube:<sitetag>`. Jak zamrożony > próg alert (report 14f3a655 2026-06-15).
Pokrywamy DWIE klasy scraperów, każda z własnym progiem: Pokrywamy TRZY klasy, każda z własnym progiem:
- **browse** (`ALL_BROWSE_SCRAPERS`) crawlowane codziennie z listingu, próg 48h. - **browse** (`ALL_BROWSE_SCRAPERS`) crawlowane codziennie z listingu, próg 48h.
- **search** (`ALL_DIRECT_SCRAPERS`) performer-driven, nierówna kadencja (~30d - **search** (`ALL_DIRECT_SCRAPERS`) performer-driven, nierówna kadencja (~30d
refresh per performer), próg wyższy (domyślnie 7d). Bez tego pokrycia kilka refresh per performer), próg wyższy (domyślnie 7d).
search-tubów (sxyland, latestpornvideo, perverzija, fpoxxx, mypornerleak, porndish) - **movies** (`_MOVIE_CONNECTORS`) dodane 2026-08-03. Wcześniej filmy NIE BYŁY
zamarzło cicho 2026-05-07/06-07/06-13 i nic nie krzyknęło do Sentry. pokryte w ogóle: streamporn.vip stał 23 dni (czytał listing nieposortowany po
Tag obecny w obu listach (xvideoscom, epornercom i browse i search) liczymy jako dacie) i żaden automat nie miał jak tego zauważyć.
browse (ostrzejszy próg).
Tag obecny w kilku listach liczymy wg najostrzejszego progu.
**Eskalacja zamiast jednego cichego issue.** Do 2026-08-03 zdarzenie szło zawsze jako
`warning` ze stabilnym fingerprintem per origin. Fingerprint jest tam po to, żeby nie
tworzyć nowego issue co 6h ale skutkiem ubocznym było JEDNO issue przy pierwszym
wystąpieniu, potem tylko rosnący licznik. Sentry powiadamia o nowych i regresjach, nie
o kolejnych wystąpieniach otwartego issue, a reguły alertów zwykle celują w `error`,
nie `warning`. Efekt: vjav stał 12 dni, watchdog go poprawnie wypisywał co cykl, i nikt
się nie dowiedział.
Teraz fingerprint zawiera KUBEŁEK WIEKU, więc przekroczenie każdego kolejnego progu
zakłada nowe issue (= nowe powiadomienie), a w obrębie kubełka dalej nie ma spamu.
Od 7 dni ciszy poziom idzie na `error`, żeby wpaść w standardowe reguły alertów.
Patrz [[reference_kvs_root_rotates_use_latest_updates]] dla klasy błędu, którą to łapie. Patrz [[reference_kvs_root_rotates_use_latest_updates]] dla klasy błędu, którą to łapie.
""" """
@ -30,58 +42,86 @@ from app.db import session_scope
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
#: Kubełki wieku ciszy (godziny, malejąco) → etykieta + poziom Sentry. Wejście w
#: kolejny kubełek zmienia fingerprint, czyli zakłada NOWE issue i wysyła alert.
_BUCKETS: tuple[tuple[int, str, str], ...] = (
(720, "30d+", "error"),
(168, "7d+", "error"),
(48, "2d+", "warning"),
(0, "swiezo", "warning"),
)
def _bucket(age_h: float) -> tuple[str, str]:
for floor, label, level in _BUCKETS:
if age_h >= floor:
return label, level
return "swiezo", "warning"
def run_ingest_freshness_watchdog( def run_ingest_freshness_watchdog(
*, *,
max_age_hours: int = 48, max_age_hours: int = 48,
search_max_age_hours: int = 168, search_max_age_hours: int = 168,
movie_max_age_hours: int = 72,
min_history: int = 100, min_history: int = 100,
to_sentry: bool = True,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Sprawdź każdy aktywny scraper: czy origin dostał nową scenę < próg dla swojej klasy. """Sprawdź każde aktywne źródło: czy dostało nową treść < próg dla swojej klasy.
Skanujemy sitetagi z `ALL_BROWSE_SCRAPERS` (próg `max_age_hours`, domyślnie 48h) oraz `min_history` odsiewa świeżo dodane źródła bez ustalonej kadencji (za mało pozycji,
z `ALL_DIRECT_SCRAPERS` (performer-driven search, próg `search_max_age_hours`, domyślnie by wiedzieć, czy cisza to anomalia).
7d nierówna kadencja, 48h dawałoby false-positivy). Tag w obu listach liczymy jako
browse (ostrzejszy próg). Nie skanujemy wszystkich origin-ów, żeby nie alarmować o
legacy/jednorazowych źródłach. `min_history` odsiewa świeżo dodane tuby bez ustalonej
kadencji (za mało scen, by wiedzieć czy cisza to anomalia).
Stale origin Sentry `capture_message` ze stabilnym fingerprintem per origin `to_sentry=False` daje sam odczyt (przydatne do ręcznego sprawdzenia bez
(wiek + próg w extra, nie w tytule inaczej każdy run = nowe issue). Zwraca zaśmiecania issue).
{checked, stale:[{origin, age_hours, total, kind, max_age_hours}]}.
""" """
from app.connectors.direct_scrapers import ( from app.connectors import get_movie_connectors # noqa: PLC0415
from app.connectors.direct_scrapers import ( # noqa: PLC0415
ALL_BROWSE_SCRAPERS, ALL_BROWSE_SCRAPERS,
ALL_DIRECT_SCRAPERS, ALL_DIRECT_SCRAPERS,
) )
browse_tags = {cls.sitetag for cls in ALL_BROWSE_SCRAPERS} browse_tags = {cls.sitetag for cls in ALL_BROWSE_SCRAPERS}
# Search-tuby dzielące tag z browse (xvideoscom, epornercom) idą pod browse-próg.
search_tags = {cls.sitetag for cls in ALL_DIRECT_SCRAPERS} - browse_tags search_tags = {cls.sitetag for cls in ALL_DIRECT_SCRAPERS} - browse_tags
# (sitetag, próg_h, klasa) — klasa tylko do logów/extra w Sentry. # (origin, tabela, próg_h, klasa)
checks: list[tuple[str, int, str]] = [ checks: list[tuple[str, str, int, str]] = (
(tag, max_age_hours, "browse") for tag in sorted(browse_tags) [(f"tube:{t}", "playback_sources", max_age_hours, "browse") for t in sorted(browse_tags)]
] + [(tag, search_max_age_hours, "search") for tag in sorted(search_tags)] + [(f"tube:{t}", "playback_sources", search_max_age_hours, "search")
for t in sorted(search_tags)]
)
try:
for name, _cls in get_movie_connectors():
checks.append((name, "movie_playback_sources", movie_max_age_hours, "movies"))
except Exception as e: # pragma: no cover - rejestr filmów niedostępny
log.warning("ingest-watchdog: nie udało się pobrać konektorów filmowych: %s", e)
now = datetime.now(UTC) now = datetime.now(UTC)
stale: list[dict[str, Any]] = [] stale: list[dict[str, Any]] = []
with session_scope() as s: with session_scope() as s:
for tag, threshold, kind in checks: for origin, table, threshold, kind in checks:
origin = f"tube:{tag}" # Filmy trzymają origin jako `<connector>:<host>`, sceny jako dokładne
row = s.execute( # `tube:<sitetag>` — stąd LIKE dla filmów, równość dla scen.
text( if table == "movie_playback_sources":
"SELECT max(created_at) AS newest, count(*) AS total " sql = (
"FROM playback_sources WHERE origin = :o" f"SELECT max(created_at) AS newest, count(*) AS total FROM {table} "
), "WHERE origin = :o OR origin LIKE :p"
{"o": origin}, )
).one() params = {"o": origin, "p": f"{origin}:%"}
else:
sql = (
f"SELECT max(created_at) AS newest, count(*) AS total FROM {table} "
"WHERE origin = :o"
)
params = {"o": origin}
row = s.execute(text(sql), params).one()
newest, total = row.newest, row.total newest, total = row.newest, row.total
if total < min_history or newest is None: if total < min_history or newest is None:
continue continue
age_h = (now - newest).total_seconds() / 3600.0 age_h = (now - newest).total_seconds() / 3600.0
if age_h >= threshold: if age_h >= threshold:
label, level = _bucket(age_h)
stale.append( stale.append(
{ {
"origin": origin, "origin": origin,
@ -89,39 +129,43 @@ def run_ingest_freshness_watchdog(
"total": total, "total": total,
"kind": kind, "kind": kind,
"max_age_hours": threshold, "max_age_hours": threshold,
"bucket": label,
"level": level,
} }
) )
if stale: if not stale:
log.info("ingest-watchdog: wszystkie %d źródła świeże", len(checks))
return {"checked": len(checks), "stale": []}
stale.sort(key=lambda x: -x["age_hours"])
log.warning( log.warning(
"ingest-watchdog: %d/%d origin(s) bez nowych scen (browse>%dh / search>%dh): %s", "ingest-watchdog: %d/%d źródeł bez nowych treści: %s",
len(stale), len(checks), max_age_hours, search_max_age_hours, len(stale), len(checks),
", ".join(f"{x['origin']}({x['age_hours']}h,{x['kind']})" for x in stale), ", ".join(f"{x['origin']}({x['age_hours']}h,{x['kind']})" for x in stale),
) )
if to_sentry:
try: try:
import sentry_sdk import sentry_sdk
for x in stale: for x in stale:
with sentry_sdk.push_scope() as scope: with sentry_sdk.push_scope() as scope:
scope.level = "warning" scope.level = x["level"]
scope.set_tag("ingest_origin", x["origin"]) scope.set_tag("ingest_origin", x["origin"])
scope.set_tag("ingest_scraper_kind", x["kind"]) scope.set_tag("ingest_scraper_kind", x["kind"])
scope.set_tag("ingest_stale_bucket", x["bucket"])
scope.set_extra("age_hours", x["age_hours"]) scope.set_extra("age_hours", x["age_hours"])
scope.set_extra("total_scenes", x["total"]) scope.set_extra("total_items", x["total"])
scope.set_extra("max_age_hours", x["max_age_hours"]) scope.set_extra("max_age_hours", x["max_age_hours"])
# Fingerprint per origin → jedno trwałe issue na zamrożony tube, # Kubełek W fingerprincie: nowe issue (= nowy alert) przy każdym
# nie fragmentowane przez zmienny wiek. # kolejnym progu, ale bez spamu co 6h w obrębie tego samego progu.
scope.fingerprint = ["ingest-stale-origin", x["origin"]] scope.fingerprint = ["ingest-stale-origin", x["origin"], x["bucket"]]
sentry_sdk.capture_message( sentry_sdk.capture_message(
f"ingest-watchdog: {x['origin']} ({x['kind']}) bez nowych scen " f"ingest-watchdog: {x['origin']} ({x['kind']}) bez nowych treści "
f"({x['age_hours']:.0f}h, próg {x['max_age_hours']}h)" f"({x['age_hours']:.0f}h, próg {x['max_age_hours']}h)"
) )
except Exception: # pragma: no cover - Sentry off / brak DSN except Exception: # pragma: no cover - Sentry off / brak DSN
log.exception("ingest-watchdog: Sentry capture failed") log.exception("ingest-watchdog: Sentry capture failed")
else:
log.info(
"ingest-watchdog: wszystkie %d origin świeże (browse<%dh / search<%dh)",
len(checks), max_age_hours, search_max_age_hours,
)
return {"checked": len(checks), "stale": stale} return {"checked": len(checks), "stale": stale}

View file

@ -324,17 +324,20 @@ def _job_title_duration_dedup() -> None:
log.exception("[scheduler] title-duration dedup failed") log.exception("[scheduler] title-duration dedup failed")
def _job_ingest_watchdog(max_age_hours: int, search_max_age_hours: int) -> None: def _job_ingest_watchdog(
"""Per-origin freshness watchdog — alert do Sentry gdy aktywny tube przestał dawać max_age_hours: int, search_max_age_hours: int, movie_max_age_hours: int
nowe sceny > próg (browse: max_age_hours, search: search_max_age_hours). Globalny ) -> None:
monitor (jeden Source 'tube-scraper') tego nie łapie; pojedynczy origin może zamarznąć """Per-origin freshness watchdog → Sentry. Obejmuje sceny (browse/search) ORAZ
przy success-runie (report 14f3a655).""" filmy (od 2026-08-03; wcześniej filmy nie były pokryte i streamporn.vip stał 23 dni
niezauważony). Globalny monitor (jeden Source 'tube-scraper') tego nie łapie;
pojedynczy origin może zamarznąć przy success-runie (report 14f3a655)."""
try: try:
from app.scheduler.ingest_watchdog import run_ingest_freshness_watchdog from app.scheduler.ingest_watchdog import run_ingest_freshness_watchdog
res = run_ingest_freshness_watchdog( res = run_ingest_freshness_watchdog(
max_age_hours=max_age_hours, max_age_hours=max_age_hours,
search_max_age_hours=search_max_age_hours, search_max_age_hours=search_max_age_hours,
movie_max_age_hours=movie_max_age_hours,
) )
if res["stale"]: if res["stale"]:
log.warning("[scheduler] ingest-watchdog: %d stale origin(s)", len(res["stale"])) log.warning("[scheduler] ingest-watchdog: %d stale origin(s)", len(res["stale"]))
@ -538,8 +541,9 @@ def build_scheduler(cfg: dict[str, Any]) -> BlockingScheduler:
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
wd_movie_max_age = cfg.get("ingest_watchdog_movie_max_age_hours") or 72
sched.add_job( sched.add_job(
lambda: _job_ingest_watchdog(wd_max_age, wd_search_max_age), lambda: _job_ingest_watchdog(wd_max_age, wd_search_max_age, wd_movie_max_age),
IntervalTrigger(hours=cfg["ingest_watchdog_hours"], start_date=INTERVAL_ANCHOR), IntervalTrigger(hours=cfg["ingest_watchdog_hours"], start_date=INTERVAL_ANCHOR),
id="ingest_watchdog", id="ingest_watchdog",
replace_existing=True, replace_existing=True,
@ -547,8 +551,8 @@ def build_scheduler(cfg: dict[str, Any]) -> BlockingScheduler:
coalesce=True, coalesce=True,
) )
log.info( log.info(
"scheduler: ingest-watchdog every %dh (browse max_age=%dh, search max_age=%dh)", "scheduler: ingest-watchdog every %dh (browse=%dh, search=%dh, movies=%dh)",
cfg["ingest_watchdog_hours"], wd_max_age, wd_search_max_age, cfg["ingest_watchdog_hours"], wd_max_age, wd_search_max_age, wd_movie_max_age,
) )
if cfg.get("hetzner_monitor_hours"): if cfg.get("hetzner_monitor_hours"):

View file

@ -233,6 +233,10 @@ def run_forever() -> int:
"ingest_watchdog_search_max_age_hours": getattr( "ingest_watchdog_search_max_age_hours": getattr(
settings, "ingest_watchdog_search_max_age_hours", 168 settings, "ingest_watchdog_search_max_age_hours", 168
), ),
# Filmy — wcześniej BEZ pokrycia watchdogiem; streamporn.vip stał 23 dni.
"ingest_watchdog_movie_max_age_hours": getattr(
settings, "ingest_watchdog_movie_max_age_hours", 72
),
# Hetzner Cloud bandwidth monitor — alert do Sentry przy progach % included. # Hetzner Cloud bandwidth monitor — alert do Sentry przy progach % included.
# No-op gdy brak HETZNER_API_TOKEN/SERVER_ID (sam job może być on). # No-op gdy brak HETZNER_API_TOKEN/SERVER_ID (sam job może być on).
"hetzner_monitor_hours": getattr(settings, "sched_hetzner_monitor_hours", 6) or None, "hetzner_monitor_hours": getattr(settings, "sched_hetzner_monitor_hours", 6) or None,

View file

@ -43,7 +43,6 @@ export const CHANGELOG: ChangelogEntry[] = [
id: '2026-07-26b', id: '2026-07-26b',
date: 'July 2026', date: 'July 2026',
items: [ items: [
'The screen no longer turns off while a video is playing. It still dims normally when you pause.',
'youporn thumbnails that had gone blank now load again.', 'youporn thumbnails that had gone blank now load again.',
], ],
}, },