goon/app/extractors/hosters/seekplayer_engine.py
goon-foss 5a48175a85 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.
2026-08-24 13:39:40 +02:00

197 lines
8.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Common engine extractor for: embedseek, seekplayer, rpmplay, upns, player4me, easyvidplayer.
Wszyscy używają tego samego silnika (Vite-built React SPA + AES-CBC encrypted API
+ HLS-based streaming). Hostname domains different ale shared backend.
Pattern (verified 2026-05-15 z residential PL + VPS Hetzner FI):
1. Embed URL = `https://<sub>.<host>.<tld>/#<hash_id>`, hash fragment to video ID.
SPA shell `Loading...` body load'uje `/assets/index-<n>.js` bundle.
2. JS fetcha `/api/v1/video?id=<hash_id>&w=<W>&h=<H>&r=` (W,H z window.screen).
Response: hex-encoded AES-CBC(key=`kiemtienmua911ca`, iv=`1234567890oiuytr`)
ciphertext, ~5KB. PKCS7 padded.
3. Plaintext JSON zawiera:
- `source`: signed m3u8 URL na CDN edge IP (np. `185.237.107.146/v4/<sig>/<exp>/ty/<hash>/master.m3u8?v=...`)
- `cf`: Cloudflare-fronted fallback URL (.txt z listą m3u8 paths)
- `metric.ipAddress`: IP visitora (signed token IP-bound do tego IP)
- `metric.cfDomain`: CF domain dla fallback
- `title`, `poster`, `thumbnail`, ...
4. `source` URL jest signed z visitor IP. Z VPS fetch zwraca master.m3u8 z signed
token tied to VPS IP, proxy fetcha segments z tym samym tokenem, działa.
CDN port 443 z `verify=False` (self-signed IP cert).
5. Wszystkie hostery share te same wartości KEY/IV. Wewnętrzna obfuskacja JS
maskuje to lookupem `ue(773)`, `ue(686)` itp., derived bytes są zawsze
identyczne dla każdej domeny.
Hostery covered (origin counts w DB, 2026-05-15):
- embedseek (20271), seekplayer (20271), mirror sites, dzielą hash_id
- rpmplay (15317)
- upns (14287)
- player4me (41040)
- easyvidplayer (47588)
Razem ~159k playback sources.
"""
from __future__ import annotations
import json
import logging
import re
from urllib.parse import urlparse
from cryptography.hazmat.primitives import padding
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from app.extractors._fetch import _DEFAULT_UA, browser_get
from app.extractors._models import HosterDead, StreamSource
log = logging.getLogger(__name__)
_KEY = b"kiemtienmua911ca"
_IV = b"1234567890oiuytr"
# Hostname matching: base hosts × subdomains × TLD variants.
# Examples:
# my.embedseek.online, vip.seekplayer.vip, my.rpmplay.online,
# my.upns.online, vip.player4me.vip, p.easyvidplayer.com
#
# 2026-07-26: dołożone `seekplays|4meplayer|ezplayer|seeks` + TLD `pro|cloud|one`.
# Ta sama rodzina silnika (identyczny _KEY/_IV + `/api/v1/video?id=`), tylko inne
# domeny, bez tego regexu `matches()` odrzucał je zanim doszło do dekodowania.
# Zweryfikowane: galaxy.4meplayer.pro / sport.seekplays.com / news.upns.pro
# (galaxyporn) oraz av.ezplayer.me / av.seeks.cloud / 4k.upn.one (pornbusy)
# dekodują się tym samym silnikiem. Whitelist jest addytywna, istniejące hosty
# matchują się dalej tak samo.
_HOST_RE = re.compile(
r"^(?:[a-z0-9]+\.)?"
r"(?:embedseek|seekplayer|seekplays|seeks|rpmplay|upns|upn|player4me|4meplayer"
r"|ezplayer|easyvidplayer)\."
r"(?:online|vip|com|net|io|me|tv|pro|cloud|one)$",
re.IGNORECASE,
)
def matches(url: str) -> bool:
try:
host = urlparse(url).hostname or ""
except Exception:
return False
return bool(_HOST_RE.match(host))
def _decrypt(hex_str: str) -> str:
ct = bytes.fromhex(hex_str)
cipher = Cipher(algorithms.AES(_KEY), modes.CBC(_IV))
dec = cipher.decryptor()
pt = dec.update(ct) + dec.finalize()
unpadder = padding.PKCS7(128).unpadder()
return (unpadder.update(pt) + unpadder.finalize()).decode("utf-8", errors="replace")
def extract(page_url: str, *, timeout: float = 30.0) -> list[StreamSource] | None:
parsed = urlparse(page_url)
if not parsed.hostname or not _HOST_RE.match(parsed.hostname):
return None
# hash_id w `#<id>` fragmencie; gdy klient przekazał bez `#` (np. po nav.replace),
# spróbujemy też `?id=` query param.
hash_id = parsed.fragment.strip()
if not hash_id and parsed.query:
from urllib.parse import parse_qs
qs = parse_qs(parsed.query)
hash_id = (qs.get("id") or [""])[0]
if not hash_id:
log.info("seekplayer-engine: no hash_id w %s", page_url)
return None
host = f"{parsed.scheme}://{parsed.hostname}"
api_url = f"{host}/api/v1/video?id={hash_id}&w=1920&h=1080&r="
headers = {
"User-Agent": _DEFAULT_UA,
"Accept": "*/*",
"Referer": f"{host}/",
}
r = browser_get(api_url, headers=headers, timeout=timeout)
if r.status_code in (404, 410):
raise HosterDead(f"seekplayer-engine {page_url}: HTTP {r.status_code}")
if r.status_code != 200 or not r.text:
log.info("seekplayer-engine: api fail %s status=%s", api_url, r.status_code)
return None
try:
plaintext = _decrypt(r.text)
except Exception as e:
log.warning("seekplayer-engine: decrypt fail dla %s: %s", api_url, e)
return None
try:
data = json.loads(plaintext)
except Exception as e:
log.warning("seekplayer-engine: JSON parse fail dla %s: %s", api_url, e)
return None
# Hostery same-engine wracają `{"error": "..."}` gdy video nie istnieje.
if isinstance(data, dict) and data.get("error"):
raise HosterDead(f"seekplayer-engine {page_url}: {data['error']}")
source = (data.get("source") or "").strip()
# Source: m3u8 na CDN edge pod GOŁYM IP (np. `185.237.107.146/v4/<sig>/<exp>/…/master.m3u8`).
# Token ma ~2,8 h TTL i NIE jest związany z IP (sprawdzone 2026-08-14), a węzły
# rotują: dwa kolejne resolve tej samej sceny wracają z różnymi adresami. Dlatego
# padnięcie odtwarzania na tym źródle leczy się ponownym resolve, a nie WebView-em
# (`BACKEND_RERESOLVE_ORIGINS` w PlayerScreen).
#
# `proxy_no_verify` NIE JEST przez nic czytane. Wcześniejszy komentarz twierdził, że
# `stream_proxy.py` ma gałąź `verify=False` dla hostów-IP — nie ma jej, w tym pliku nie
# występuje słowo `verify` ani razu. W praktyce certyfikaty tych węzłów przechodzą
# weryfikację (fetch przez proxy oddaje 200), więc flaga jest informacyjna. Zostaje,
# bo zapisuje realne ryzyko: gdyby któryś nod zaczął serwować cert niepasujący do IP,
# trzeba będzie tę gałąź dopisać.
#
# `cf` (fallback za Cloudflare) CELOWO NIE JEST PRZEKAZYWANY DALEJ. Sprawdzone
# 2026-08-14, sześć wariantów, wszystkie 403:
#
# httpx + Referer hostera · httpx + Referer galaxyporn.net
# curl_cffi chrome120 z Refererem · curl_cffi bez Referera (oddaje stronę CF)
# znacznik czasu bieżący · znacznik z linku głównego
#
# I to samo 403 z łącza RESIDENTIAL, więc to nie jest blokada datacenter. Adres jest
# najpewniej związany z sesją tego samego wywołania `/api/v1/video`, a my sięgamy po
# niego osobno i po czasie (znacznik w nazwie pliku bywa 5 dni stary). Podpięcie go
# jako zapasowego źródła dokładałoby request, który ZAWSZE pada, na ścieżce błędu.
#
# Padnięcie pojedynczego noda leczy ponowny resolve: kolejne wywołania wracają z
# 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] = []
if source:
sources.append(
StreamSource(
link=source,
quality=None,
type="m3u8",
referer=f"{host}/",
raw={
"proxy_no_verify": True,
"engine": "seekplayer",
},
)
)
return sources or None