Add animekai source with 1anime direct-MP4 backend (independent CDN)

animekai.be fronted two server backends per episode: megaplay.buzz
(same player/CDN family as hianime — 403s our egress IP) and
my.1anime.site (direct progressive MP4, independent CDN — verified
serving our IP at ~2.4MB/s). This is the rescue path around the
MegaPlay Cloudflare block, per the ani-cli #1945/#1918 work.

- providers/animekai.py: standard interface, per-episode backend
  dispatch (1anime first, then megaplay), same listed-but-dead ->
  raise semantics as hianime. All endpoints ride the shared TTL cache
  (no re-fetching on repeat UI hits).
- upstream.py: megaplay_master() moved out of hianime — the MegaPlay
  chain (embed -> data-id -> getSourcesNew -> enc decrypt -> CDN proof)
  is now shared by every provider fronting that player.
- app.py: /stream forwards Range for progressive files (206 +
  Content-Range passthrough, partial bodies never LRU-cached, 8MB cap
  on the parallel path so a 250MB MP4 can't pin RAM). api_stream
  reports hls=true/false from the URL — the player kind is a property
  of the URL, so future stream types slot in without app changes.
- app.js: hls===false -> native <video> (existing Range-seeking),
  hls.js path untouched.
- run.sh: ANI_SOURCE=animekai,hianime,anidb,anikoto (revert to the old
  chain when the MegaPlay block lifts).
- tests: test_animekai.py (offline: interface, enc decrypt, /stream
  Range via monkeypatched network) + test_animekai_live.py (opt-in
  ANI_LIVE=1 end-to-end, skipped by default so the suite never touches
  animekai.be).
This commit is contained in:
jozamudi committed 2026-09-29 18:35:22 +00:00
1 parent e3ef51d141
commit d12ff79d29
8 files changed
+504 -35

No files matched your search

+26 -5
View File
@@ -565,6 +565,9 @@ def api_stream(ep_id: int, lang: str = "jpn", request: Request = None):
if not master:
raise HTTPException(404, f"no {lang} stream for episode {ep_id}")
out = {"token": _b64(master)}
# the player kind is a property of the URL, not the provider: .m3u8
# -> hls.js, anything else -> native <video> (app.js branches on this)
out["hls"] = master.endswith(".m3u8")
sub = upstream.subtitles(ep_id) # external .vtt (hianime); anidb bakes subs in
if sub:
out["sub"] = _b64(sub)
@@ -615,9 +618,11 @@ def _cache_put(url, ct, body):
_STREAM_CACHE.popitem(last=False)
def _seg_stream(r, url, ct):
def _seg_stream(r, url, ct, cache=True):
"""Forward the CDN's bytes as they arrive; keep the LRU warm for
completed downloads (a truncated one would poison the cache)."""
completed downloads (a truncated one would poison the cache). cache=False
for ranged file chunks — a partial body under the full-URL key would
poison the LRU."""
import time
t0, t_first, buf, complete = time.time(), None, [], False
try:
@@ -629,7 +634,7 @@ def _seg_stream(r, url, ct):
complete = True
finally:
r.close()
if complete and buf:
if complete and buf and cache:
_cache_put(url, ct, b"".join(buf))
# observability: a slow segment is the fragLoadTimeOut culprit. Log
# only the slow ones so normal playback stays quiet. TTFB>3s or
@@ -659,7 +664,7 @@ def _stream_playlist(url, meta=None):
@app.get("/stream/{token}")
def stream(token: str):
def stream(token: str, request: Request = None):
url = _url(token)
p = urlparse(url)
# ponytail: token is plain base64, so allowlist the host — otherwise any
@@ -677,6 +682,19 @@ def stream(token: str):
return Response(hit[1], media_type=hit[0], headers=_STREAM_CACHE_HDRS)
if url.endswith(".m3u8"):
return _stream_playlist(url, meta)
# progressive file (1anime's direct MP4): the browser seeks with Range —
# forward it, stream the chunk back, forward 206/Content-Range, never
# cache (partial bodies under the full-URL key would poison the LRU)
rng = request.headers.get("range") if request else None
if rng:
r = _upstream(upstream.get_stream, url, timeout=30,
headers={**(meta or {}), "Range": rng})
ct = r.headers.get("content-type", "video/mp4")
return StreamingResponse(
_seg_stream(r, url, ct, cache=False),
status_code=r.status_code, media_type=ct,
headers={k: v for k, v in r.headers.items()
if k.lower() in ("content-range", "accept-ranges")})
# segment miss: try parallel byte-range lanes first (4 concurrent fetches
# beat per-connection CDN throttling); ANY failure falls back to the
# single streaming path, so this can never be worse than before
@@ -698,7 +716,10 @@ def stream(token: str):
name = url.rsplit("/", 1)[-1][:40]
print(f"[stream-slow] {name} parallel total={time.time()-t0:.1f}s "
f"bytes={len(body)}", flush=True)
_cache_put(url, ct, body)
# ponytail: 8MB cap — the LRU is for playlists/segments; a multi-hundred-MB
# progressive file (1anime MP4) would evict everything and pin RAM
if len(body) < 8 * 1024 * 1024:
_cache_put(url, ct, body)
return Response(body, media_type=ct, headers=_STREAM_CACHE_HDRS)