"""sexu.com — HLS z `POST /api/video-info`. Dodany 2026-07-27. Player jest hydratowany po stronie klienta (`/js/vjs/hydrate-source.js`): strona sceny nie zawiera żadnego URL-a wideo, dopiero `POST /api/video-info` z `videoId=` oddaje JSON-a z `playerData`. **Ten POST wymaga Bright Data.** Z gołego IP VPS-a Cloudflare oddaje na niego challenge (GET-y na listing i detale przechodzą bez problemu — blokowany jest sam POST). Idzie tu JSON, nie wideo, więc mieści się w tym, do czego proxy służy. **Bierzemy `playerData.src` (HLS), a NIE `playerData.sources` (mp4).** Wygląda to odwrotnie do intuicji, bo mp4 są wygodniejsze, ale: sources[].src → /key=…,end=…,ip=158.46.155.106/sec=protect/…-720p-x.mp4 playerData.src → /key=…,end=…/multi=854x480:480p,1280x720:720p/media=hls4A/… mp4 mają w tokenie `ip=` requestera, czyli IP wyjściowe proxy — na telefonie dałyby 403. Token HLS `ip=` nie ma i jest przenośny: sprawdzone z VPS-a (inne IP niż proxy) master 200, wariant 200, segment 206. Telefon gra go z własnego IP. Ścieżka mastera kończy się na `.mp4`, nie `.m3u8`, więc ExoPlayer wziąłby go za progresywny plik i padł. Łapie to istniejąca logika `hls_needs_passthrough` w `playback.py` (typ `m3u8` + `mobile_direct_ok` + brak `.m3u8` w ścieżce) → manifest (~430 B) leci przez `/proxy/hls`, a segmenty prosto z CDN. """ from __future__ import annotations import json import logging import re from app.config import get_settings from app.extractors._models import StreamSource log = logging.getLogger(__name__) _BASE = "https://sexu.com" _SCENE_ID_RE = re.compile(r"/(\d+)/?$") def extract(page_url: str, *, timeout: float = 60.0) -> list[StreamSource] | None: m = _SCENE_ID_RE.search(page_url.split("?")[0]) if not m: log.info("sexu: nie wyłuskałem id z %s", page_url) return None video_id = m.group(1) proxy = get_settings().brightdata_proxy_url if not proxy: log.info("sexu: brak BRIGHTDATA_PROXY_URL — CF zablokuje POST /api/video-info") return None from curl_cffi import requests as cr try: session = cr.Session( impersonate="chrome120", proxies={"http": proxy, "https": proxy}, verify=False, ) # Strona sceny najpierw — bez jej ciasteczek CF odrzuca POST-a. session.get(page_url, timeout=timeout) res = session.post( f"{_BASE}/api/video-info", data={"videoId": video_id}, headers={ "X-Requested-With": "XMLHttpRequest", "Referer": page_url, }, timeout=timeout, ) data = json.loads(res.text) except Exception as e: log.info("sexu: video-info failed %s: %s", page_url, e) return None if not data.get("success"): log.info("sexu: video-info success=false na %s", page_url) return None src = (data.get("playerData") or {}).get("src") if not src: log.info("sexu: brak playerData.src na %s", page_url) return None if src.startswith("//"): src = "https:" + src return [ StreamSource( link=src, type="m3u8", quality="720p", # master multi=854x480,1280x720 — ExoPlayer wybierze sam referer=_BASE + "/", raw={"mobile_direct_ok": True}, ) ]