fix(movies): przestan podsuwac hostery z rodziny seekplayer, ktore nie maja plikow

Zgloszenia z 2026-08-24: "Easyvidplayer nie chce odpalic" (ccbc75da), "Embedseek tez
nie rusza" (d59fc396) i sasiednie. To NIE byl nasz blad parsowania.

Co ustalone: `/api/v1/video` odpowiada 200 i poprawnie sie odszyfrowuje, tylko nie ma
w odpowiedzi pola `source`. W ich wlasnym bundlu warunek jest doslownie taki sam i
konczy sie komunikatem "Video is not ready yet". Potwierdzone w przegladarce z lacza
domowego (plakat obok oddal 502), z VPS i przez brytyjskie proxy, wiec to nie blokada
po IP ani po kraju. Protokol sie nie zmienil, hoster po prostu nie ma juz plikow.

Skala na 250 losowych zrodlach: 3 zywe, 88 razy HTTP 404, 79 razy brak `source`.
Czyli okolo 2% jeszcze gra.

Dwie zmiany:
* `seekplayer_engine.extract` rzuca `HosterDead` zamiast zwracac None. Dopiero wtedy
  `resolve_movie_playback` oznacza zrodlo martwe. Zwracanie None dawalo WebView z tym
  samym komunikatem, w kolko, i wlasnie to zglaszali uzytkownicy. Katalog czysci sie
  teraz sam, w miare jak ludzie po nim chodza.
* De-ranking calej rodziny w `_movie_origin_priority`, zeby na wierzch wychodzily
  hostery, ktore graja. Nie usuwamy ich z listy, bo te 2% bywa jedynym zrodlem.

Bezpieczne: filmow zaleznych WYLACZNIE od tej rodziny jest w bazie jeden, pozostale
26 223 maja alternatywe.

Dolozony `scripts/seekplayer_reap.py` do sprzatania calosci na zadanie. NIE odpalam go
w tle: przy limitach hostera (32% odpowiedzi to 429) pelny przemial to kilkadziesiat
godzin ciaglego pukania, co grozi zablokowaniem naszego IP dla prawdziwych resolve.

Zweryfikowane na filmie ze zgloszenia: piec zrodel seekplayer oznaczonych martwymi,
zostaly mixdrop (206 video/mp4) i voe (200 m3u8), oba sprawdzone spoza serwerowni.
This commit is contained in:
goon-foss 2026-08-24 13:39:40 +02:00
parent 3c8bcada84
commit 5a48175a85
3 changed files with 248 additions and 0 deletions

View file

@ -163,7 +163,25 @@ _MOVIE_DROP_ORIGINS = frozenset({
_MOVIE_LANDING_HIDE = frozenset({"mangoporn", "pandamovies", "streamporn"}) _MOVIE_LANDING_HIDE = frozenset({"mangoporn", "pandamovies", "streamporn"})
# Rodzina seekplayer (embed `#<hash>`, wspólny silnik) na sam koniec listy.
# Pomiar 2026-08-24 na 250 losowych źródłach: 3 miały jeszcze strumień, 88 oddało
# HTTP 404, a 79 odpowiedź bez pola `source`, po której ICH WŁASNY player pisze
# „Video is not ready yet" (sprawdzone w przeglądarce z łącza domowego, z VPS i przez
# brytyjskie proxy, więc to nie blokada po IP). Zgłoszenia `d59fc396`, `ccbc75da`,
# `7bfc2e8d` i sąsiednie z tego dnia to dokładnie te trafienia.
#
# Nie wyrzucamy ich z listy (te ~2% działa i bywa jedynym źródłem), ale nie mogą stać
# przed hosterami, które grają. Filmów zależnych WYŁĄCZNIE od tej rodziny jest w bazie
# jeden, więc de-ranking nikomu nie zabiera dostępu.
_MOVIE_DEPRIORITIZED_HOSTERS = frozenset({
"easyvidplayer", "embedseek", "seekplayer", "upns", "player4me", "rpmplay",
"4meplayer", "seekplays", "ezplayer", "seeks",
})
def _movie_origin_priority(origin: str) -> int: def _movie_origin_priority(origin: str) -> int:
if origin.rpartition(":")[2] in _MOVIE_DEPRIORITIZED_HOSTERS:
return 900 # za wszystkim, co nieklasyfikowane
try: try:
return _MOVIE_PREFERRED_ORIGINS.index(origin) return _MOVIE_PREFERRED_ORIGINS.index(origin)
except ValueError: except ValueError:

View file

@ -167,6 +167,19 @@ def extract(page_url: str, *, timeout: float = 30.0) -> list[StreamSource] | Non
# #
# Padnięcie pojedynczego noda leczy ponowny resolve: kolejne wywołania wracają z # Padnięcie pojedynczego noda leczy ponowny resolve: kolejne wywołania wracają z
# różnymi adresami CDN (`BACKEND_RERESOLVE_ORIGINS` w PlayerScreen, commit 47547fe). # różnymi adresami CDN (`BACKEND_RERESOLVE_ORIGINS` w PlayerScreen, commit 47547fe).
if not source:
# Brak `source` to NIE jest usterka parsowania ani chwilowy problem sieci.
# W ich własnym bundlu (`index-*.js`, funkcja startowa playera) warunek brzmi
# dosłownie: gdy w odpowiedzi nie ma `source`, player pokazuje „Video is not
# ready yet" i kończy. Potwierdzone 2026-08-24 w przeglądarce z łącza domowego
# na `vip.easyvidplayer.com/#96kzl`: dokładnie ten komunikat, a plakat obok
# oddał 502. To samo z VPS i przez brytyjskie proxy, więc nie chodzi o IP.
#
# Rzucamy `HosterDead`, bo tylko wtedy `resolve_movie_playback` oznaczy źródło
# martwe i przestanie je podsuwać. Zwracanie None dawało WebView z tym samym
# komunikatem, w kółko, i tak wyglądały zgłoszenia z 2026-08-24.
raise HosterDead(f"seekplayer-engine {page_url}: brak pola source")
sources: list[StreamSource] = [] sources: list[StreamSource] = []
if source: if source:
sources.append( sources.append(

217
scripts/seekplayer_reap.py Normal file
View file

@ -0,0 +1,217 @@
"""Oznacza jako martwe źródła z rodziny seekplayer, których hoster już nie serwuje.
Dlaczego: zgłoszenia `d59fc396`, `ccbc75da` i sąsiednie z 2026-08-24 („Easyvidplayer
nie chce odpalić", „Embedseek też nie rusza"). Sprawdzone: to NIE jest nasz błąd
parsowania. Endpoint `/api/v1/video` odpowiada 200 i poprawnie się odszyfrowuje, tylko
w odpowiedzi nie ma pola `source`. Ich własny player w tej sytuacji pisze wprost
„Video is not ready yet", co potwierdziłem w przeglądarce z łącza domowego, i to samo
widać z VPS oraz z brytyjskiego proxy, więc nie chodzi o blokadę po IP ani po kraju.
Skala (próbka 45 losowych źródeł, 2026-08-24): 26 razy HTTP 404, 18 razy odpowiedź bez
strumienia, 1 raz działający strumień. Czyli około 2% katalogu na tym silniku jeszcze
gra, reszty hoster już nie ma. Sam protokół jest bez zmian, dlatego NIE kasujemy tych
źródeł hurtem, tylko pytamy o każde z osobna i zostawiamy te, które odpowiadają.
Bezpieczeństwo: filmów zależnych WYŁĄCZNIE od tej rodziny jest w bazie dokładnie
jeden, pozostałe 26 223 mają inne źródło. Sprzątanie nie chowa więc katalogu, tylko
przestaje podsuwać wybór, który i tak kończy się czarnym ekranem.
Oszczędność: domeny bliźniacze (`p.` i `vip.easyvidplayer.com`, `my.` i
`vip.player4me`) trzymają ten sam identyfikator wideo, więc pytamy raz na parę
(marka, hash) i werdykt stosujemy do wszystkich źródeł z tym kluczem.
Wznawianie za darmo: bierzemy tylko żywe źródła, więc kolejne odpalenie samo pomija
to, co już oznaczone. Żadnego pliku stanu.
Użycie (w kontenerze workera):
python3 /srv/scripts/seekplayer_reap.py --limit 300 --dry-run
python3 /srv/scripts/seekplayer_reap.py
"""
from __future__ import annotations
import argparse
import collections
import json
import logging
import re
import sys
import time
from concurrent.futures import ThreadPoolExecutor
from urllib.parse import urlparse
from sqlalchemy import text
from app.db import session_scope
from app.extractors._fetch import _DEFAULT_UA, browser_get
from app.extractors.hosters.seekplayer_engine import _HOST_RE, _decrypt
log = logging.getLogger("seekplayer-reap")
# Ta sama rodzina co `seekplayer_engine._HOST_RE`, w formie nadającej się do SQL-a.
_FAMILY_SQL = (
"easyvidplayer|embedseek|seekplayer|upns|player4me|rpmplay"
"|4meplayer|seekplays|ezplayer|seeks"
)
# Pola, w których silnik podaje adres strumienia. Nazwy wzięte z ich własnego bundla
# (`{Cloudflare: cf, Tiktok: hlsVideoTiktok, Google: hlsVideoGoogle, "In-House": source}`),
# a warunek „nie ma żadnego z nich" to dokładnie ten sam warunek, po którym ich player
# pokazuje „Video is not ready yet".
_STREAM_FIELDS = ("source", "cf", "hlsVideoGoogle", "hlsVideoTiktok", "cfNative", "p2pStream")
_CONCURRENCY = 3
# Odstęp przed każdym pobraniem. Bez niego przy pięciu wątkach 38% odpowiedzi to było
# HTTP 429, a limit nie jest dowodem na nic, więc takie źródło trzeba zostawić i wrócić
# do niego później. Wolniej, ale za to prawie każda próba coś rozstrzyga.
_SPACING_SEC = 0.4
_TIMEOUT = 20.0
_ALIVE, _DEAD, _UNKNOWN = "alive", "dead", "unknown"
def _brand(hostname: str) -> str:
"""`vip.easyvidplayer.com` → `easyvidplayer`. Klucz wspólny dla domen bliźniaczych."""
m = re.search(_FAMILY_SQL, hostname)
return m.group(0) if m else hostname
def _probe(item: tuple) -> tuple:
"""((brand, hash), verdict, detail). Sieć i parsowanie, bez dotykania bazy."""
key, url = item
time.sleep(_SPACING_SEC)
p = urlparse(url)
host = f"{p.scheme}://{p.hostname}"
api = f"{host}/api/v1/video?id={p.fragment}&w=1920&h=1080&r="
headers = {"User-Agent": _DEFAULT_UA, "Accept": "*/*", "Referer": host + "/"}
for attempt in (1, 2):
try:
r = browser_get(api, headers=headers, timeout=_TIMEOUT)
except Exception as e:
return (key, _UNKNOWN, f"{type(e).__name__}")
if r.status_code in (404, 410):
return (key, _DEAD, f"HTTP {r.status_code}")
if r.status_code == 429:
# Rate limit nie jest dowodem na cokolwiek, więc jedna przerwa i koniec.
if attempt == 1:
time.sleep(5)
continue
return (key, _UNKNOWN, "HTTP 429")
if r.status_code != 200 or not r.text:
return (key, _UNKNOWN, f"HTTP {r.status_code}")
try:
data = json.loads(_decrypt(r.text))
except Exception as e:
return (key, _UNKNOWN, f"dekod {type(e).__name__}")
if data.get("error"):
return (key, _DEAD, str(data["error"])[:80])
if any(data.get(k) for k in _STREAM_FIELDS):
return (key, _ALIVE, "")
return (key, _DEAD, "brak pola source (Video is not ready yet)")
return (key, _UNKNOWN, "429")
def _load_work() -> tuple[dict, dict, dict]:
"""{(brand,hash): url}, {(brand,hash): [movie_src_id]}, {(brand,hash): [scene_src_id]}"""
probes: dict = {}
movie_ids: dict = collections.defaultdict(list)
scene_ids: dict = collections.defaultdict(list)
with session_scope() as s:
for table, bucket in (
("movie_playback_sources", movie_ids),
("playback_sources", scene_ids),
):
rows = s.execute(
text(
f"""
SELECT id, coalesce(embed_url, page_url) AS u
FROM {table}
WHERE dead_at IS NULL
AND coalesce(embed_url, page_url) ~ :fam
"""
),
{"fam": _FAMILY_SQL},
).fetchall()
for sid, u in rows:
p = urlparse(u)
# Regex w SQL to tylko tani przesiew i potrafi trafić w zwykły adres
# tuba, gdy w slugu tytułu siedzi np. "seeks". Autorytetem jest
# `_HOST_RE` z samego ekstraktora, ono patrzy wyłącznie na hosta.
if not p.hostname or not p.fragment or not _HOST_RE.match(p.hostname):
continue
key = (_brand(p.hostname), p.fragment.strip())
probes.setdefault(key, u)
bucket[key].append(sid)
return probes, movie_ids, scene_ids
def _mark_dead(table: str, ids: list, reason: str) -> int:
if not ids:
return 0
with session_scope() as s:
return s.execute(
text(
f"UPDATE {table} SET dead_at = now(), dead_reason = :r "
" WHERE id = ANY(:ids) AND dead_at IS NULL"
),
{"r": f"seekplayer-reap: {reason}"[:512], "ids": ids},
).rowcount
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser()
p.add_argument("--limit", type=int, default=0, help="ile kluczy sprawdzić (0 = wszystkie)")
p.add_argument("--dry-run", action="store_true", help="nie zapisuj, tylko policz")
a = p.parse_args(argv)
logging.basicConfig(
level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", stream=sys.stdout
)
for noisy in ("httpx", "httpcore", "urllib3"):
logging.getLogger(noisy).setLevel(logging.WARNING)
probes, movie_ids, scene_ids = _load_work()
keys = list(probes)
if a.limit:
keys = keys[: a.limit]
log.info(
"do sprawdzenia %d kluczy (marka+hash), zrodel: filmy %d, sceny %d",
len(keys),
sum(len(v) for v in movie_ids.values()),
sum(len(v) for v in scene_ids.values()),
)
cnt: collections.Counter = collections.Counter()
powody: collections.Counter = collections.Counter()
nieznane: collections.Counter = collections.Counter()
ubite_f = ubite_s = 0
t0 = time.time()
with ThreadPoolExecutor(max_workers=_CONCURRENCY) as pool:
for i, (key, verdict, detail) in enumerate(
pool.map(_probe, [(k, probes[k]) for k in keys]), 1
):
cnt[verdict] += 1
if verdict == _UNKNOWN:
nieznane[detail[:40]] += 1
if verdict == _DEAD:
powody[detail[:40]] += 1
if not a.dry_run:
ubite_f += _mark_dead("movie_playback_sources", movie_ids.get(key, []), detail)
ubite_s += _mark_dead("playback_sources", scene_ids.get(key, []), detail)
if i % 250 == 0:
log.info(
"[%d/%d] zywe=%d martwe=%d nieznane=%d, ubite zrodla: filmy %d sceny %d (%.0f min)",
i, len(keys), cnt[_ALIVE], cnt[_DEAD], cnt[_UNKNOWN],
ubite_f, ubite_s, (time.time() - t0) / 60,
)
log.info(
"KONIEC: zywe=%d martwe=%d nieznane=%d | ubite zrodla: filmy %d sceny %d | %.0f min",
cnt[_ALIVE], cnt[_DEAD], cnt[_UNKNOWN], ubite_f, ubite_s, (time.time() - t0) / 60,
)
log.info("powody martwych: %s", dict(powody.most_common(6)))
log.info("powody nieznanych: %s", dict(nieznane.most_common(6)))
return 0
if __name__ == "__main__":
raise SystemExit(main())