Zgloszenie 7f2234eb: "samo wideo dziala, a aplikacja zglasza problem
z hostem". Ekstraktor NIE jest zepsuty:
- try_extract oddaje dzialajacy manifest w 0,4 s
- z Refererem ekstraktora (galaxy.4meplayer.pro) link daje 200; bez
niego 403, wiec naglowek jest ustawiany poprawnie
- pobranie przez nasze /proxy oddaje 200 i playliste z wariantem 1080p
- telemetria 14 dni: 1140 udanych, 25 bledow (2,1%)
Padal POJEDYNCZY nod CDN. Kolejne wywolania resolve tej samej sceny
wracaja z ROZNYMI adresami (185.237.107.133, potem 203.188.166.163), a
token ma ~2,8 h TTL i nie jest IP-bound. Czyli ponowny resolve po prostu
laduje na zywym wezle — dokladnie wzorzec, ktory dziala juz dla watchporn.
Stad `tube:galaxyporncom` w BACKEND_RERESOLVE_ORIGINS.
Po drodze skorygowany FALSZYWY komentarz w seekplayer_engine.py: twierdzil,
ze stream_proxy.py ma galaz `verify=False` dla hostow pod golym IP. Nie ma
jej — slowo `verify` nie wystepuje w tym pliku ani razu. `proxy_no_verify`
i `cf_fallback` sa ustawiane przez trzy ekstraktory i przez NIC nieczytane.
cf_fallback jest o tyle bolesny, ze to gotowy zapasowy URL, ktory uratowalby
wlasnie ten przypadek. Osobne zadanie.
Blad diagnostyczny po drodze, dla potomnosci: pierwszy test dal 404, bo
przetestowalem URL uciety wlasnym printem [:70], bez /master.m3u8.
170 lines
6.6 KiB
Python
170 lines
6.6 KiB
Python
"""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()
|
||
cf = (data.get("cf") 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).
|
||
#
|
||
# UWAGA: `proxy_no_verify` i `cf_fallback` niżej NIE SĄ 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 to flagi
|
||
# informacyjne, nie działające przełączniki.
|
||
sources: list[StreamSource] = []
|
||
if source:
|
||
sources.append(
|
||
StreamSource(
|
||
link=source,
|
||
quality=None,
|
||
type="m3u8",
|
||
referer=f"{host}/",
|
||
raw={
|
||
"proxy_no_verify": True,
|
||
"cf_fallback": cf or None,
|
||
"engine": "seekplayer",
|
||
},
|
||
)
|
||
)
|
||
return sources or None
|