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
|
||||
# (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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue