Some checks are pending
Backend tests / test (push) Waiting to run
- 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
153 lines
6.4 KiB
Python
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"
|