goon/app/extractors/hosters/xtremestream.py
goon-foss a872fc3a88
Some checks are pending
Backend tests / test (push) Waiting to run
fix(perverzija): manifest HLS z AES-128 skladany w /proxy/hls, proxy bez gzip, porndoe poza alarmem, skrypt dlugosci porntrex
- xtremestream od 12.10 szyfruje segmenty (AES-128-CBC, IV = numer segmentu, klucz spod
  tokenu zwiazanego z IP i UA): producent manifestu bierze xs1.php, przepisuje sciezki
  na _enc i dokleja EXT-X-KEY; klucz oddaje /proxy/hls/<token>/key.bin na wlasny token
- proxy: Accept-Encoding identity (skompresowany manifest bez naglowka konczyl na
  "Input does not start with the #EXTM3U header"), play.m3u8 z nazwy pliku jako manifest
- ingest_watchdog: porndoe jako zrodlo zamrozone (nowe sceny przekierowuja z kazdego IP)
- scripts/porntrex_duration_walk.py: dlugosci z listingu /latest-updates/N/, z dry-run
2026-10-14 14:57:29 +02:00

153 lines
6.4 KiB
Python

"""xtremestream (player perverzija): manifest HLS z segmentami AES-128 skladany po stronie backendu.
Od ok. 2026-10-12 player (`ftk_engine.js`, zaciemniony, z blokada devtools) nie gra juz
z `xs1.php`: segmenty pod starymi adresami `/cdn/down/<id>/<q>/<q><i>.html` oddaja 404,
a prawdziwe leza pod `/cdn/down/<id>/<q>_enc/<q><i>.html` i sa zaszyfrowane AES-128-CBC,
IV = numer segmentu (16 bajtow big-endian), dopelnienie PKCS7, czyli dokladnie tak jak
standardowe `#EXT-X-KEY:METHOD=AES-128` bez atrybutu IV (sprawdzone 2026-10-14: openssl
z kluczem i IV=0 daje poprawny MPEG-TS, ffprobe widzi h264 i aac).
Klucz (16 B) oddaje `proto_api.php?data=<id>&op=key&vt=<vt>` z naglowkiem `X-XS-P: 1`.
`vt` to token ze strony playera (`XSFTK.boot(id, vt, player)`), zwiazany z IP i UA
pobierajacego (ten sam vt z innego IP = 403 `forbidden-vt`), ale klucz jest jeden na film,
niezalezny od vt (sprawdzone z dwoch IP). Segmenty i stary manifest `xs1.php` (nadal
serwowany, z dobrymi dlugosciami i rotacja hostow cdn[i % n]) nie sa zwiazane z IP.
Dlatego telefon nic nie musi robic: backend bierze `xs1.php`, przepisuje sciezki na `_enc`,
dokleja `#EXT-X-KEY` z URI wzglednym `key.bin`, a `/proxy/hls/<token>/key.bin` oddaje klucz
pobrany na wlasny vt. Telefon ciagnie segmenty prosto z CDN (przez VPS leci ~50 kB manifestu
i 16 B klucza). Czesc katalogu (starszy storage) ma nadal zwykle segmenty: wtedy manifest
idzie bez klucza, tak jak dotad.
"""
from __future__ import annotations
import logging
import re
import time
from urllib.parse import parse_qs, quote, urlparse
import httpx
log = logging.getLogger(__name__)
_UA = (
"Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/128.0.0.0 Mobile Safari/537.36"
)
_PLAYER_RE = re.compile(
r"https?://(?P<host>[a-z0-9\-]+\.xtremestream\.[a-z]+)/player/index\.php\?data=(?P<id>[0-9a-f]{16,64})",
re.IGNORECASE,
)
_BOOT_RE = re.compile(r'XSFTK\.boot\("[0-9a-f]+",\s*"([^"]+)"')
# `https://pervl7.xspcdn01.click/cdn/down/<id>/720/7200.html` -> grupy: prefiks, jakosc, numer
_SEG_RE = re.compile(r"^(https?://[^/]+/cdn/down/[0-9a-f]+/)(\d+p?)/\2(\d+)\.html\s*$")
_DEFAULT_QUALITY = "720"
_KEY_TTL_SEC = 6 * 3600
_key_cache: dict[str, tuple[bytes, float]] = {}
def parse_player(player_url: str) -> tuple[str, str] | None:
m = _PLAYER_RE.search(player_url)
return (m.group("host"), m.group("id")) if m else None
def quality_of(player_url: str) -> str:
q = parse_qs(urlparse(player_url).query).get("q", [_DEFAULT_QUALITY])[0]
return q if re.fullmatch(r"\d{3,4}", q) else _DEFAULT_QUALITY
def _to_enc(line: str) -> str:
m = _SEG_RE.match(line)
if not m:
return line
return f"{m.group(1)}{m.group(2)}_enc/{m.group(2)}{m.group(3)}.html"
def _reachable(url: str, timeout: float) -> bool:
try:
r = httpx.get(url, headers={"User-Agent": _UA, "Range": "bytes=0-0"}, timeout=timeout)
return r.status_code in (200, 206)
except httpx.HTTPError:
return False
def fetch_key(player_url: str, *, timeout: float = 20.0) -> bytes | None:
"""16-bajtowy klucz AES filmu, z pamiecia podreczna na 6 h (klucz nie zalezy od vt)."""
parsed = parse_player(player_url)
if not parsed:
return None
host, vid = parsed
cached = _key_cache.get(vid)
if cached and time.time() - cached[1] < _KEY_TTL_SEC:
return cached[0]
index_url = f"https://{host}/player/index.php?data={vid}"
try:
page = httpx.get(
index_url,
headers={"User-Agent": _UA, "Referer": "https://tube.perverzija.com/"},
timeout=timeout,
follow_redirects=True,
)
m = _BOOT_RE.search(page.text)
if not m:
log.info("xtremestream: brak tokenu vt na %s (http %s)", index_url, page.status_code)
return None
vt = m.group(1)
# UA musi byc ten sam co przy stronie playera, vt jest pod niego liczony.
k = httpx.get(
f"https://{host}/player/proto_api.php?data={vid}&op=key&vt={quote(vt, safe='')}",
headers={"User-Agent": _UA, "X-XS-P": "1", "Referer": index_url},
timeout=timeout,
)
except httpx.HTTPError as e:
log.info("xtremestream: pobranie klucza padlo dla %s: %s", vid, e)
return None
if k.status_code != 200 or len(k.content) != 16:
log.info("xtremestream: klucz dla %s http %s, %d B", vid, k.status_code, len(k.content))
return None
_key_cache[vid] = (k.content, time.time())
return k.content
def build_manifest(player_url: str, *, key_uri: str = "key.bin", timeout: float = 20.0) -> str | None:
"""Manifest HLS do odegrania przez telefon: segmenty `_enc` z CDN i klucz pod `key_uri`
(wzgledny wobec adresu manifestu). None, gdy xs1.php nie oddaje manifestu albo zaden
wariant segmentow (zaszyfrowany ani zwykly) nie odpowiada."""
parsed = parse_player(player_url)
if not parsed:
return None
host, vid = parsed
q = quality_of(player_url)
index_url = f"https://{host}/player/index.php?data={vid}"
try:
m = httpx.get(
f"https://{host}/player/xs1.php?data={vid}&q={q}",
headers={"User-Agent": _UA, "Referer": index_url},
timeout=timeout,
)
except httpx.HTTPError as e:
log.info("xtremestream: xs1.php padlo dla %s: %s", vid, e)
return None
text = m.text
if m.status_code != 200 or not text.lstrip(" \t\r\n").startswith("#EXTM3U"):
log.info("xtremestream: xs1.php dla %s http %s, nie-manifest (%d B)", vid, m.status_code, len(m.content))
return None
lines = text.splitlines()
segments = [l for l in lines if l.startswith("http")]
if not segments:
return None
first_enc = _to_enc(segments[0])
if first_enc == segments[0] or not _reachable(first_enc, timeout):
# Starszy storage: zwykle segmenty, manifest bez zmian. Jesli i te nie odpowiadaja,
# film jest martwy po stronie hostera.
return text if _reachable(segments[0], timeout) else None
out: list[str] = []
key_done = False
for line in lines:
out.append(_to_enc(line) if line.startswith("http") else line)
if not key_done and line.startswith("#EXT-X-MEDIA-SEQUENCE"):
out.append(f'#EXT-X-KEY:METHOD=AES-128,URI="{key_uri}"')
key_done = True
if not key_done:
out.insert(1, f'#EXT-X-KEY:METHOD=AES-128,URI="{key_uri}"')
return "\n".join(out) + "\n"