feat(playback): direct-HLS manifest passthrough + proxy stream drop handling

Time-bound HLS hosters whose manifest URL lacks a .m3u8 extension (e.g. pornhat's
"...mp4,?..." path) were mis-detected by ExoPlayer as progressive MP4 and failed,
forcing a full proxy fallback that streamed the whole video through the server. Serve
such manifests via /proxy/hls/<token>/play.m3u8 with child URLs left absolute on the
CDN, so the device fetches variant+segments directly and only the ~1KB manifest is
proxied. Routed only for mobile_direct_ok (time-bound) HLS without a .m3u8 path.

Also swallow httpx.TransportError in the stream proxy body generator: an upstream CDN
closing the connection mid-stream is benign (client just retries a range) and should
not surface as an unhandled error.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jtrzupek 2026-06-11 16:14:25 +02:00
parent 072f2608b3
commit aa05ce2647
2 changed files with 77 additions and 0 deletions

View file

@ -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(

View file

@ -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()