goon/app/extractors/hosters/loadvid.py
goon-foss a4486980a4 style: myslniki em/en z komentarzy i docstringow (calosc repo)
1745 wystapien w 291 plikach (backend, scripts, mobile). Dominowal jeden ksztalt,
' — ' laczace zdania, wiec poszedl przecinek; myslnik otwierajacy mysl zdjety;
zakresy 0–5 i A–Z na dywiz.

Podzial komentarz/kod robil skaner stanow, nie regex, zeby nie ruszyc myslnika
stojacego w stringu albo w wyrazeniu regularnym. Potwierdzone maszynowo: po wycieciu
komentarzy z wersji HEAD i z dysku obie sa identyczne we wszystkich 291 plikach,
czyli poza komentarzami nie zmienilo sie nic. 233 pliki .py parsuja sie bez bledu,
tsc ma te same 11 bledow w tych samych 3 plikach co przed zmiana.

Dwie rzeczy wymagaly poprawki po automacie: wyrownane listy definicji, gdzie myslnik
byl separatorem kolumny (37 miejsc, kolumna przywrocona), i trzy przeniesienia mysli
do nowej linii, gdzie zostawal wiszacy przecinek na poczatku (poprawione recznie).

Bundla nie republikuje: Hermes i tak nie niesie komentarzy, wiec dla uzytkownika
to zero zmiany.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 11:51:57 +02:00

128 lines
5 KiB
Python

"""cdn.loadvid.com, HLS hoster oddający manifest przez POST (nie URL).
Protokół (reverse-engineer 2026-07-27, potwierdzony live):
1. GET /videos/play/<hash> → HTML z `<meta name="csrf-token">` + 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/<token>/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'<meta name="csrf-token" content="([^"]+)"', re.IGNORECASE)
_CONFIG_RE = re.compile(r"LoadVidConfig\s*=\s*(\{.*?\})\s*;", re.DOTALL)
_TOKEN_RE = re.compile(r"""videoToken\s*:\s*['"]([^'"]+)""")
_HASH_RE = re.compile(r"""videoHash\s*:\s*['"]([^'"]+)""")
_RESOLVE_PATH = "/videos/resolve-token"
def matches(url: str) -> 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",
},
)
]