Zgloszenie a405b9d6 "znowu galaxyporn nie dziala". Przyczyna byla inna niz sie wydawalo:
nasz resolver dzialal, tylko oddawal adres do pliku, ktorego juz nie ma.
Co ustalone przez reverse engineering ich bundla i pomiary:
1. Adres strumienia siedzi w KILKU polach, a serwis sam podaje w `streamingConfig`,
ktorej sciezki uzyc i w jakiej kolejnosci:
{Cloudflare: cf, Tiktok: hlsVideoTiktok, Google: hlsVideoGoogle, "In-House": source}.
Czytalismy WYLACZNIE `source`, wiec sceny majace tylko `hlsVideoTiktok` traktowalismy
jak martwe. A one graja: zweryfikowany manifest 1080p, 200 i z serwerowni, i z lacza
domowego. Doszlo skladanie adresu wg ich regul (`/hls/` -> `/hlsmod/<domain>/` + params).
2. WAZNIEJSZE: adres do usunietego pliku wyglada normalnie, a CDN oddaje na niego 502.
Sprawdzone: wezly 94.131.217.172/175/177 rotuja i WSZYSTKIE daja 502 dla jednego pliku,
podczas gdy TEN SAM wezel .172 serwuje inny plik poprawnie. Czyli CDN zyje, brakuje
konkretnych plikow. Oddawalismy taki adres jako dobry, aplikacja pokazywala czarny
ekran, a ratunek z vikingfile nigdy sie nie uruchamial, bo lista zrodel nie byla pusta.
Teraz kazdy kandydat jest sprawdzany (`_responds`) i zwracamy tylko ten, ktory
naprawde odpowiada manifestem.
3. galaxyporn: gdy player mowi wprost, ze pliku nie ma, a na stronie NIE MA tez linku do
vikingfile, przepuszczamy `HosterDead` dalej zamiast zwracac None. Wczesniej takie
sceny dawaly 503 "sprobuj ponownie" i wracaly w kolko.
Weryfikacja, ze `_responds` nie odrzuca dzialajacych: scena `#puwcko` -> http=200,
responds=True, prawdziwy #EXTM3U (przyjeta); `#bgitoa` -> 502 na wszystkich wezlach
(odrzucona). Na probce 20 scen galaxyporn 85% trafia teraz do vikingfile zamiast dostawac
martwy adres. Scena ze zgloszenia zostala oznaczona martwa przez prawdziwy endpoint
resolve (410 zamiast czarnego ekranu).
Czego NIE zrobilem: vikingfile wymaga tokenu z WIDOCZNEGO checkboxa Cloudflare
(`POST <adres strony>` z `cf-turnstile-response=<token>`). Obchodzenia tego nie buduje.
259 lines
10 KiB
Python
259 lines
10 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 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/<domain>/` (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 `#<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']}")
|
||
|
||
# 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")
|