"""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://../#`, hash fragment to video ID. SPA shell `Loading...` body load'uje `/assets/index-.js` bundle. 2. JS fetcha `/api/v1/video?id=&w=&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///ty//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 urlencode, 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") # Mapowanie z ICH bundla (`index-*.js`, funkcja skladajaca liste zrodel): # {Cloudflare: cf, Tiktok: hlsVideoTiktok, Google: hlsVideoGoogle, "In-House": source} # Kolejnosc prob i przekształcenia adresu przychodza w `streamingConfig`, ktore serwis # ustawia per plik. Do 2026-08-26 czytalismy WYLACZNIE `source`, wiec sceny majace # wylacznie `hlsVideoTiktok` (a takie sa) traktowalismy jak martwe, mimo ze graja. _STREAM_FIELDS = { "Cloudflare": "cf", "Tiktok": "hlsVideoTiktok", "Google": "hlsVideoGoogle", "In-House": "source", } # Ile kandydatow realnie sprawdzamy. Sprawdzenie to osobne pobranie, a to sciezka # resolve, na ktora czeka uzytkownik; przy tylu proba i tak trafiamy w to, co dziala. _MAX_CANDIDATES = 3 _VERIFY_TIMEOUT = 8.0 def _build_candidate(host: str, raw_value: str | None, cfg: dict, key: str) -> str | None: """Sklada adres dla jednej sciezki CDN wg regul z `streamingConfig.adjust`. Odwzorowanie ich funkcji: pomijamy wylaczone, zamieniamy `/hls/` na `/hlsmod//` (tak kieruja ruch na CDN TikToka) i doklejamy `params`. """ adjust = (cfg.get("adjust") or {}).get(key) or {} if adjust.get("disabled") or not raw_value: return None url = raw_value if raw_value.startswith("http") else host + raw_value domain = adjust.get("domain") if domain and "/hls/" in url: url = url.replace("/hls/", "/hlsmod/" + domain + "/") params = adjust.get("params") if isinstance(params, dict) and params: url += ("&" if "?" in url else "?") + urlencode(params) return url def _responds(url: str, referer: str) -> bool: """Czy pod tym adresem NAPRAWDE cos jest. Konieczne, bo serwis podaje adresy do plikow, ktorych juz nie ma: ten sam wezel CDN serwuje jeden plik poprawnie, a na inny oddaje 502 (sprawdzone 2026-08-26, wezly 94.131.217.172/175/177 rotuja i wszystkie zwracaja 502 dla usunietego pliku). Bez tej weryfikacji oddawalismy martwy adres, aplikacja pokazywala czarny ekran, a ratunek z vikingfile nigdy sie nie uruchamial, bo lista zrodel nie byla pusta. """ try: r = browser_get( url, headers={"User-Agent": _DEFAULT_UA, "Referer": referer}, timeout=_VERIFY_TIMEOUT, follow_redirects=True, ) except Exception as e: log.debug("seekplayer-engine: weryfikacja %s padla (%s)", url, e) return False if r.status_code != 200: return False head = (r.text or "")[:200] # Manifest HLS albo lista adresow (wariant `cf` oddaje plik tekstowy z linkami). return "#EXTM3U" in head or head.strip().startswith("http") 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 `#` 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']}") # Adresy strumienia siedza w kilku polach naraz, a serwis sam mowi w # `streamingConfig`, ktorej sciezki uzyc i w jakiej kolejnosci. Do 2026-08-26 # czytalismy tylko `source`, przez co sceny majace wylacznie `hlsVideoTiktok` # (zweryfikowane: oddaje manifest 1080p, dziala i z serwerowni, i z lacza domowego) # traktowalismy jak martwe. # # `source` to m3u8 na wezle CDN pod GOLYM IP. Token ma ~2,8 h TTL i NIE jest zwiazany # z IP (sprawdzone 2026-08-14), a wezly rotuja, wiec padniecie odtwarzania leczy # ponowny resolve (`BACKEND_RERESOLVE_ORIGINS` w PlayerScreen), nie WebView. # # `proxy_no_verify` NIE JEST przez nic czytane; flaga jest informacyjna i zapisuje # realne ryzyko: gdyby ktorys wezel zaczal serwowac cert niepasujacy do IP, trzeba # bedzie dopisac galaz `verify=False` w `stream_proxy.py`. cfg = {} raw_cfg = data.get("streamingConfig") if isinstance(raw_cfg, str) and raw_cfg.strip(): try: cfg = json.loads(raw_cfg) except Exception: cfg = {} order = cfg.get("order") or ["In-House"] # Budujemy kandydatow w KOLEJNOSCI, ktora serwis sam podaje, i zwracamy pierwszego, # ktory naprawde odpowiada. Weryfikacja jest tu kluczowa: adres do usunietego pliku # wyglada normalnie, a oddaje 502, i bez sprawdzenia oddawalismy go dalej jako dobry. referer = f"{host}/" sprawdzone = 0 for key in order: if sprawdzone >= _MAX_CANDIDATES: break candidate = _build_candidate(host, data.get(_STREAM_FIELDS.get(key, "")), cfg, key) if not candidate: continue sprawdzone += 1 if not _responds(candidate, referer): log.info("seekplayer-engine: sciezka %s nie odpowiada dla %s", key, page_url) continue return [ StreamSource( link=candidate, quality=None, type="m3u8", referer=referer, raw={"proxy_no_verify": True, "engine": "seekplayer", "sciezka": key}, ) ] # Zadna sciezka nie odpowiada. To NIE jest usterka parsowania ani chwilowy problem # sieci: w ich wlasnym bundlu brak zrodla konczy sie komunikatem "Video is not ready # yet", a przy adresie do usunietego pliku CDN oddaje 502 ze WSZYSTKICH wezlow # (sprawdzone 2026-08-26; ten sam wezel serwuje inny plik poprawnie). # # Rzucamy `HosterDead`, bo tylko wtedy `resolve_movie_playback` oznaczy zrodlo martwe. # Dla scen galaxyporn ten wyjatek jest lapany i uruchamia ratunek z vikingfile. raise HosterDead(f"seekplayer-engine {page_url}: zadna sciezka CDN nie odpowiada")