Proxy: stream HLS segments through instead of buffering

fragLoadTimeOut root cause: /stream buffered the whole segment
(r.content) before sending, so the browser's time-to-first-byte = the
CDN's entire upload time. On slow CDN moments that trips hls.js's
first-byte timer even though the segment would have completed.

- upstream.get_stream(): opens the upstream response without reading
  the body (stream=True), same retry/timeout rules as _get
- segments now flow to the browser as the CDN emits them (256KB chunks);
  the 12s cap becomes a total-transfer cap; cache is populated on
  completion only (a truncated body never poisons the LRU)
- playlists keep the buffered path (body needed for URI rewriting)
- cache-hit fast path restored for both (stream() checks the LRU first)

Verified: segment byte-identical to direct CDN (sha256), sequential
keep-alive 3/3 OK, 5 concurrent fetches 200+identical, cache hit
0.148s miss -> 0.001s hit, 17/17 API tests green.

Skipped: raising hls.js retry counts — the first-byte race was the
binding constraint, not the retry budget.
This commit is contained in:
jozamudi committed 2026-08-31 01:05:00 +00:00
1 parent 27de918cb0
commit 19bc2afb32
2 files changed
+72 -22

No files matched your search

+53 -22
View File
@@ -15,7 +15,7 @@ from base64 import urlsafe_b64encode, urlsafe_b64decode
from urllib.parse import quote, urljoin, urlparse
from fastapi import Body, FastAPI, HTTPException, Query, Request
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse, Response
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse, Response, StreamingResponse
from fastapi.staticfiles import StaticFiles
from itsdangerous import BadSignature, TimestampSigner
@@ -479,33 +479,64 @@ _STREAM_MAX = 24 # segments ~1-2MB -> <= ~48MB
_STREAM_CACHE_HDRS = {"Cache-Control": "public, max-age=86400"}
@app.get("/stream/{token}")
def stream(token: str):
# ponytail: buffers whole response in memory; .ts segments are small
url = _url(token)
# ponytail: token is plain base64, so allowlist the host — otherwise any
# logged-in user could use this as a GET proxy to arbitrary URLs (SSRF).
# Verified 2026-08: master, variant and segment URLs all live on
# hls.anidb.app; if the CDN moves, this 400s loudly and the list updates.
p = urlparse(url)
if p.scheme != "https" or p.netloc != "hls.anidb.app":
raise HTTPException(400, "bad stream token")
hit = _STREAM_CACHE.get(url)
if hit:
_STREAM_CACHE.move_to_end(url)
return Response(hit[1], media_type=hit[0], headers=_STREAM_CACHE_HDRS)
def _cache_put(url, ct, body):
_STREAM_CACHE[url] = (ct, body)
while len(_STREAM_CACHE) > _STREAM_MAX:
_STREAM_CACHE.popitem(last=False)
def _seg_stream(r, url, ct):
"""Forward the CDN's bytes as they arrive; keep the LRU warm for
completed downloads (a truncated one would poison the cache)."""
buf, complete = [], False
try:
for c in r.iter_content(chunk_size=256 * 1024):
buf.append(c)
yield c
complete = True
finally:
r.close()
if complete and buf:
_cache_put(url, ct, b"".join(buf))
def _stream_playlist(url):
# buffered: the full body is needed to rewrite URIs through the proxy
# ponytail: 12s < hls.js's fragLoadingTimeOut (20s) — on a hang the proxy
# 502s first, so hls.js retries the frag (backoff) instead of freezing
# on a fatal timeout race
r = _upstream(upstream._get, url, timeout=12)
body = r.content
if url.endswith(".m3u8") or "mpegurl" in r.headers.get("content-type", ""):
if "mpegurl" in r.headers.get("content-type", ""):
body = _rewrite_m3u8(body.decode(), url).encode()
_STREAM_CACHE[url] = (r.headers.get("content-type", "application/octet-stream"), body)
while len(_STREAM_CACHE) > _STREAM_MAX:
_STREAM_CACHE.popitem(last=False)
return Response(body, media_type=r.headers.get("content-type", "application/octet-stream"),
headers=_STREAM_CACHE_HDRS)
ct = r.headers.get("content-type", "application/octet-stream")
_cache_put(url, ct, body)
return Response(body, media_type=ct, headers=_STREAM_CACHE_HDRS)
@app.get("/stream/{token}")
def stream(token: str):
# ponytail: token is plain base64, so allowlist the host — otherwise any
# logged-in user could use this as a GET proxy to arbitrary URLs (SSRF).
# Verified 2026-08: master, variant and segment URLs all live on
# hls.anidb.app; if the CDN moves, this 400s loudly and the list updates.
url = _url(token)
p = urlparse(url)
if p.scheme != "https" or p.netloc != "hls.anidb.app":
raise HTTPException(400, "bad stream token")
# cache hit (a playlist, or a segment downloaded earlier): serve at once
hit = _STREAM_CACHE.get(url)
if hit:
_STREAM_CACHE.move_to_end(url)
return Response(hit[1], media_type=hit[0], headers=_STREAM_CACHE_HDRS)
if url.endswith(".m3u8"):
return _stream_playlist(url)
# segment miss: stream through (12s = total transfer cap), so the browser's
# first byte no longer waits for the CDN's whole upload
r = _upstream(upstream.get_stream, url, timeout=12)
ct = r.headers.get("content-type", "application/octet-stream")
return StreamingResponse(_seg_stream(r, url, ct), media_type=ct,
headers=_STREAM_CACHE_HDRS)
app.mount("/", StaticFiles(directory=ROOT / "static", html=True), name="static")