diff --git a/app/api/playback.py b/app/api/playback.py index 45ad295..f49ba13 100644 --- a/app/api/playback.py +++ b/app/api/playback.py @@ -507,8 +507,19 @@ def _proxify_link(link: StreamLink, referer: str) -> StreamLink: # mobile_direct_ok overrides m3u8 default-to-proxy: gdy CDN ma time-bound token # (nie IP-bound), mobile ExoPlayer może pobrać manifest direct bez VPS proxy. is_manifest_type = type_lower in {"m3u8", "hls", "mpd"} + is_hls = type_lower in {"m3u8", "hls"} + # ExoPlayer (expo-video 2.0.6, brak contentType) zgaduje typ z URL: ścieżka kończąca + # się `.m3u8` → HLS (gra direct, 0 VPS), inaczej → progressive Mp4Extractor → fail + # → fallback na pełny proxy → CAŁE wideo przez VPS (pornhat `...mp4,?...`: 466 hitów + # /48h, audit 2026-06-11). Dla takich time-bound HLS dajemy manifest-passthrough pod + # `play.m3u8`: segmenty zostają absolutne na CDN → telefon ciągnie je direct, przez + # VPS leci tylko ~1KB manifestu. Manifesty z poprawnym `.m3u8` zostają w pełni direct. + raw_path = raw_url.split("?", 1)[0].lower() + hls_needs_passthrough = is_hls and mobile_direct_ok and not raw_path.endswith(".m3u8") if use_impersonate or force_proxy or (is_manifest_type and not mobile_direct_ok): direct_for_mobile = proxied + elif hls_needs_passthrough: + direct_for_mobile = f"/proxy/hls/{token}/play.m3u8" else: direct_for_mobile = raw_url return StreamLink( diff --git a/app/api/stream_proxy.py b/app/api/stream_proxy.py index 58b71c7..5bdd7da 100644 --- a/app/api/stream_proxy.py +++ b/app/api/stream_proxy.py @@ -277,6 +277,26 @@ def _rewrite_m3u8(content: str, base_url: str, referer: str | None) -> str: return "\n".join(out) + "\n" +def _absolutize_m3u8(content: str, base_url: str) -> str: + """Rewrite m3u8 tak, że child-URL-e (variant/segmenty/key) są ABSOLUTNE na CDN — + NIE przez proxy. Dla direct-HLS passthrough (`/proxy/hls/...`): telefon ciągnie je + bezpośrednio z CDN-a (phone IP, time-bound token). Relatywne → absolute (urljoin), + absolute → bez zmian. Odwrotność `_rewrite_m3u8` (który wszystko proxifikuje).""" + out: list[str] = [] + for raw_line in content.splitlines(): + line = raw_line.strip() + if not line: + out.append(raw_line) + continue + if line.startswith("#"): + def _sub(m: re.Match) -> str: + return f"{m.group(1)}{urljoin(base_url, m.group(2))}{m.group(3)}" + out.append(_M3U8_URI_RE.sub(_sub, raw_line)) + continue + out.append(urljoin(base_url, line)) + return "\n".join(out) + "\n" + + @router.get("/sign") def sign_url( _api: Annotated[None, Depends(require_api_key)], @@ -499,6 +519,42 @@ async def _curl_cffi_stream( raise HTTPException(status_code=502, detail=f"proxy error: {e}") from e +@router.get("/hls/{token}/{_basename:path}") +async def proxy_hls_manifest(token: str, _basename: str) -> Response: + """Direct-HLS manifest passthrough dla time-bound (mobile_direct_ok) m3u8 hosterów. + + Problem: expo-video 2.0.6 nie ma `contentType`, więc ExoPlayer zgaduje typ z URL. + Time-bound manifesty których ścieżka NIE kończy się `.m3u8` (pornhat: + `.../552351,_360p.mp4,.mp4,_720p.mp4,?...`) → ExoPlayer leci Mp4Extractor → + UnrecognizedInputFormat → fallback na pełny `/proxy/` → CAŁE wideo przez VPS + (privatehost 466 hitów/48h, audit 2026-06-11). + + Tu serwujemy SAM manifest pod `play.m3u8` (ExoPlayer → HlsMediaSource), a child-URL-e + (variant/segmenty) zostają ABSOLUTNE na CDN → telefon ciągnie je DIRECT (phone IP, + token time-bound działa cross-IP; Referer propaguje Media3 na child-requesty — + zweryfikowane cross-IP 2026-06-11). Przez VPS leci tylko ~1KB manifestu, nie segmenty. + Tylko dla mobile_direct_ok (gdzie segmenty z definicji działają cross-IP); IP-bound + hostery dalej idą pełnym `/proxy/`.""" + payload = parse_token(token) + target = payload["u"] + referer = payload.get("r") or None + headers = _build_headers(referer) + async with httpx.AsyncClient(follow_redirects=True, timeout=20.0) as client: + try: + r = await client.get(target, headers=headers) + except httpx.HTTPError as e: + log.info("proxy-hls fetch failed %s: %s", target, e) + raise HTTPException(status_code=502, detail="manifest fetch failed") from e + if r.status_code >= 400: + return _upstream_error_response(r.status_code, dict(r.headers), target) + rewritten = _absolutize_m3u8(r.text, base_url=str(r.url)) + return Response( + content=rewritten, + media_type="application/vnd.apple.mpegurl", + headers={"Cache-Control": "no-store"}, + ) + + @router.get("/{token}/{_basename:path}") async def proxy_stream( token: str, @@ -606,6 +662,16 @@ async def proxy_stream( async for chunk in upstream.aiter_raw(): bytes_out += len(chunk) yield chunk + except httpx.TransportError as e: + # Upstream CDN zerwał połączenie w trakcie streamu (peer closed before + # full body — RemoteProtocolError, ReadError, timeout). Klient dostaje + # uciętą odpowiedź i ponawia Range-requestem; to NIE jest bug aplikacji. + # Bez tego wyjątek leci z generatora PO zwróceniu StreamingResponse, omija + # outer try/except i ląduje w Sentry jako unhandled (GOON-Y 2026-06-11). + log.info( + "proxy upstream dropped %s after %d bytes: %s", + target, bytes_out, type(e).__name__, + ) finally: await upstream.aclose() await client.aclose()