diff --git a/server/app.py b/server/app.py index a0140cd..73c54e6 100644 --- a/server/app.py +++ b/server/app.py @@ -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") diff --git a/server/upstream.py b/server/upstream.py index 0443f42..7ce5a34 100644 --- a/server/upstream.py +++ b/server/upstream.py @@ -64,6 +64,25 @@ def _get(url, **kw): time.sleep(0.3) +def get_stream(url, timeout=12): + """Segments: open the upstream response WITHOUT reading the body, so the + proxy can forward bytes as the CDN emits them. (A buffered fetch made the + browser's time-to-first-byte = the CDN's whole upload time, which tripped + hls.js's fragLoadTimeOut on slow CDN moments.) + ponytail: skips the 'Just a moment' body check — challenge pages arrive + as 403/503 (handled below), and reading the body would defeat streaming.""" + for attempt in range(2): + r = _session().get(url, **{**H, "timeout": timeout, "stream": True}) + if r.status_code in (403, 429, 503) or r.status_code >= 500: + r.close() + if attempt: + raise RuntimeError(f"upstream {r.status_code}: {url}") + time.sleep(0.3) + continue + r.raise_for_status() + return r + + def _search(q): """Step 1: browse page -> [{id, slug_id, name, poster}]""" html = _get(f"{BASE}/browse", params={"q": q}).text