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

+103
View File
@@ -0,0 +1,103 @@
"""test_animekai_live.py — LIVE smoke tests for the animekai provider.
Skipped by default so the suite never touches animekai.be (politeness:
the provider must not be spammed by CI or local test runs). Enable with:
ANI_LIVE=1 .venv/bin/pytest tests/test_animekai_live.py -q -s
Every call rides the provider's _cached TTL cache, so one run is a
handful of requests, not a crawl.
"""
import base64
import os
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
live = pytest.mark.skipif(
os.environ.get("ANI_LIVE") != "1",
reason="live upstream test: run with ANI_LIVE=1")
@live
def test_search_shape():
from server.providers import animekai as a
r = a.search("one piece")
assert r and all(set(x) >= {"id", "slug_id", "name"} for x in r), r[:2]
print("\nsearch:", [(x["name"], x["slug_id"]) for x in r[:3]])
@live
def test_details_episodes_languages():
from server.providers import animekai as a
r = a.search("one piece")
top = next(x for x in r if x["name"].lower().startswith("one piece"))
d = a.details(top["slug_id"])
print("\ndetails:", {k: (str(v)[:60] if v else v) for k, v in d.items()
if k != "seasons"})
assert d["name"] and d["id"], d
eps = a.episodes(d["id"])
assert eps and eps[0]["number"] == 1, eps[:2]
print("episodes:", len(eps), "first:", eps[0])
langs = a.languages(eps[0]["id"])
print("langs ep1:", langs)
assert langs, "ep1 should have at least sub"
@live
def test_master_resolves_and_stream_proxy_serves_range(monkeypatch):
"""Full path: provider resolves a stream, and the in-process /stream
proxy serves it with Range (206 + chunk). server.app import is safe
here: the CDN probe is idle-gated (no upstream fetch until a play)."""
# stream_meta's allowlist walks the ANI_SOURCE chain; point it at
# animekai (as run.sh does) so the resolved host is permitted
monkeypatch.setenv("ANI_SOURCE", "animekai")
from server.providers import animekai as a
# Sparks of Tomorrow's early eps were observed on 1anime servers
# (independent CDN); the browse sort is most_viewed, so pick the title
# by name, not position, then scan its first episodes for a resolver.
r = a.search("sparks of tomorrow")
top = next((x for x in r if "sparks" in x["name"].lower()), r[0])
d = a.details(top["slug_id"])
eps = a.episodes(d["id"])
got = None
for e in eps[:5]:
for lang in ("jpn", "eng"):
try:
got = a.master_m3u8(e["id"], lang)
except Exception as ex:
print(f"\nep{e['number']} {lang}: {ex}")
if got:
break
if got:
break
assert got, "no server resolved for ep1-3 (both backends dead?)"
print("\nresolved:", got)
# host must be allowlisted (that IS the /stream permit)
from urllib.parse import urlparse
meta = a.stream_meta(got)
assert meta is not None, f"provider did not register host for {got}"
# in-process /stream Range check (direct handler call, no server spawn)
import asyncio
import types
import server.app as app_mod
token = base64.urlsafe_b64encode(got.encode()).decode()
rng = None if got.endswith(".m3u8") else "bytes=0-1023"
req = types.SimpleNamespace(headers={"range": rng} if rng else {})
resp = app_mod.stream(token, request=req)
async def _drain(it):
return b"".join([c async for c in it])
body = (asyncio.run(_drain(resp.body_iterator)) if getattr(resp, "body_iterator", None) is not None
else resp.body)
if got.endswith(".m3u8"):
assert resp.status_code == 200 and b"#EXTM3U" in body
else:
assert resp.status_code == 206 and len(body) == 1024, resp.status_code
print("proxy:", resp.status_code, len(body), "b")