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:
parent
072f2608b3
commit
aa05ce2647
2 changed files with 77 additions and 0 deletions
|
|
@ -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
|
# 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.
|
# (nie IP-bound), mobile ExoPlayer może pobrać manifest direct bez VPS proxy.
|
||||||
is_manifest_type = type_lower in {"m3u8", "hls", "mpd"}
|
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):
|
if use_impersonate or force_proxy or (is_manifest_type and not mobile_direct_ok):
|
||||||
direct_for_mobile = proxied
|
direct_for_mobile = proxied
|
||||||
|
elif hls_needs_passthrough:
|
||||||
|
direct_for_mobile = f"/proxy/hls/{token}/play.m3u8"
|
||||||
else:
|
else:
|
||||||
direct_for_mobile = raw_url
|
direct_for_mobile = raw_url
|
||||||
return StreamLink(
|
return StreamLink(
|
||||||
|
|
|
||||||
|
|
@ -277,6 +277,26 @@ def _rewrite_m3u8(content: str, base_url: str, referer: str | None) -> str:
|
||||||
return "\n".join(out) + "\n"
|
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")
|
@router.get("/sign")
|
||||||
def sign_url(
|
def sign_url(
|
||||||
_api: Annotated[None, Depends(require_api_key)],
|
_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
|
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}")
|
@router.get("/{token}/{_basename:path}")
|
||||||
async def proxy_stream(
|
async def proxy_stream(
|
||||||
token: str,
|
token: str,
|
||||||
|
|
@ -606,6 +662,16 @@ async def proxy_stream(
|
||||||
async for chunk in upstream.aiter_raw():
|
async for chunk in upstream.aiter_raw():
|
||||||
bytes_out += len(chunk)
|
bytes_out += len(chunk)
|
||||||
yield 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:
|
finally:
|
||||||
await upstream.aclose()
|
await upstream.aclose()
|
||||||
await client.aclose()
|
await client.aclose()
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue