"""cdn.loadvid.com — HLS hoster oddający manifest przez POST (nie URL). Protokół (reverse-engineer 2026-07-27, potwierdzony live): 1. GET /videos/play/ → HTML z `` + inline `window.LoadVidConfig = { videoHash, videoToken, ... }` 2. POST /videos/resolve-token {token, hash} + nagłówek `X-CSRF-TOKEN` → **treść manifestu m3u8** (200, `application/vnd.apple.mpegurl`, ~116 KB, ~870 segmentów), a NIE URL do manifestu. Dlatego to nie jest zwykły extractor "URL → URL": nie da się oddać linku do manifestu, bo taki link nie istnieje (GET na resolve-token → 405, zgadywane `/index.m3u8` → 404). Manifest musi wyprodukować backend. Rozwiązanie: `extract()` zwraca **embed URL** oznaczony `manifest_producer=loadvid`. `/proxy/hls//play.m3u8` rozpoznaje ten marker i zamiast GET-a woła `fetch_manifest()`, po czym serwuje manifest telefonowi. Segmenty w manifeście są ABSOLUTNE i w pełni przenośne (zweryfikowane: 206 `bytes 0-1023` BEZ jakichkolwiek nagłówków, bez Referera, bez tokena — serwowane jako `image/png`, w środku TS), więc telefon ciągnie je bezpośrednio z CDN. Przez VPS idzie tylko manifest. """ from __future__ import annotations import logging import re from urllib.parse import urlparse from app.extractors._fetch import _DEFAULT_IMPERSONATE, _DEFAULT_UA, _HAS_CURL_CFFI from app.extractors._models import HosterDead, StreamSource log = logging.getLogger(__name__) _HOST_RE = re.compile(r"^(?:[a-z0-9]+\.)?loadvid\.com$", re.IGNORECASE) _CSRF_RE = re.compile(r' bool: try: return bool(_HOST_RE.match(urlparse(url).hostname or "")) except Exception: return False def fetch_manifest(embed_url: str, *, timeout: float = 30.0) -> str | None: """Embed URL → treść manifestu m3u8 (albo None). CSRF-token i cookie sesji muszą pochodzić z TEGO SAMEGO requestu co POST, dlatego jedna sesja curl_cffi na całą operację.""" if not _HAS_CURL_CFFI: log.info("loadvid: curl_cffi unavailable") return None from curl_cffi import requests as cf parsed = urlparse(embed_url) origin = f"{parsed.scheme}://{parsed.hostname}" session = cf.Session(impersonate=_DEFAULT_IMPERSONATE) try: r = session.get( embed_url, headers={"User-Agent": _DEFAULT_UA, "Accept": "text/html,application/xhtml+xml"}, timeout=timeout, ) except Exception as e: log.info("loadvid: embed fetch failed %s: %s", embed_url, e) return None if r.status_code in (404, 410): raise HosterDead(f"loadvid {embed_url}: HTTP {r.status_code}") if r.status_code != 200 or not r.text: log.info("loadvid: embed status=%s for %s", r.status_code, embed_url) return None csrf = _CSRF_RE.search(r.text) cfg = _CONFIG_RE.search(r.text) if not csrf or not cfg: log.info("loadvid: no csrf/LoadVidConfig on %s", embed_url) return None tok = _TOKEN_RE.search(cfg.group(1)) hsh = _HASH_RE.search(cfg.group(1)) if not tok or not hsh: log.info("loadvid: no videoToken/videoHash on %s", embed_url) return None try: pr = session.post( origin + _RESOLVE_PATH, headers={ "User-Agent": _DEFAULT_UA, "X-CSRF-TOKEN": csrf.group(1), "X-Requested-With": "XMLHttpRequest", "Accept": "application/vnd.apple.mpegurl", "Referer": embed_url, }, json={"token": tok.group(1), "hash": hsh.group(1)}, timeout=timeout, ) except Exception as e: log.info("loadvid: resolve-token failed %s: %s", embed_url, e) return None if pr.status_code != 200 or "#EXTM3U" not in pr.text: log.info("loadvid: resolve-token status=%s len=%d", pr.status_code, len(pr.text or "")) return None return pr.text def extract(page_url: str, *, timeout: float = 30.0) -> list[StreamSource] | None: """Zwraca embed URL z markerem producenta — manifest powstaje dopiero w `/proxy/hls` (patrz docstring modułu). Weryfikujemy tu, że manifest realnie da się wyprodukować, żeby nie oddawać martwego źródła.""" if not matches(page_url): return None manifest = fetch_manifest(page_url, timeout=timeout) if not manifest: return None return [ StreamSource( link=page_url, type="m3u8", raw={ # Segmenty absolutne i portable → telefon ciągnie je direct z CDN, # przez VPS leci tylko manifest. "mobile_direct_ok": True, "manifest_producer": "loadvid", }, ) ]