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:
1 parent
e3ef51d141
commit
d12ff79d29
8 files changed
+504
-35
No files matched your search
@@ -0,0 +1,133 @@
|
||||
"""test_animekai.py — animekai provider + MegaPlay decrypt + /stream Range path.
|
||||
|
||||
All offline (monkeypatched network) so the suite never touches animekai.be,
|
||||
MegaPlay, or any CDN. Live smoke tests live in test_animekai_live.py and
|
||||
are skipped unless ANI_LIVE=1.
|
||||
|
||||
Run: .venv/bin/pytest tests/test_animekai.py -q
|
||||
"""
|
||||
import base64
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
# Live 1anime-style host the /stream allowlist tests will register
|
||||
HOST = "my.1anime.site"
|
||||
VIDEO = f"https://{HOST}/stream/69f9d2aef757c305714fdc246e06d6bc"
|
||||
|
||||
|
||||
def test_animekai_provider_imports():
|
||||
import server.providers.animekai as a # noqa: F401
|
||||
for fn in ("search", "details", "episodes", "languages", "master_m3u8"):
|
||||
assert callable(getattr(a, fn)), fn
|
||||
assert callable(a.stream_meta)
|
||||
assert callable(a.subtitles)
|
||||
|
||||
|
||||
def test_animekai_stream_meta_allowlist():
|
||||
import server.providers.animekai as a
|
||||
assert a.stream_meta("https://other.example/x.m3u8") is None
|
||||
a.STREAM_HOSTS[HOST] = {"Referer": "https://" + HOST + "/"}
|
||||
assert a.stream_meta(VIDEO) == {"Referer": "https://" + HOST + "/"}
|
||||
a.STREAM_HOSTS.clear()
|
||||
|
||||
|
||||
# --- MegaPlay payload: old plaintext shape, new AES "enc" shape ------------
|
||||
# enc sample: real getSourcesNew response captured 2026-09-29 (deterministic
|
||||
# per data-id; key/IV static in the player's public JS — see upstream._MP_KEY)
|
||||
ENC = ("wdeBruh3qqn_i5wUNnyaPcXqidp1UWP84FfPHzGyKXA2hDZBfMCmZ4FLvs7_pQuH549Ept"
|
||||
"ax8UOjAJyRIZfrRhUGUKy9OBeGh2yB_-m_JLAlLnWTLzYZC3_C5I4ltveYoiaU66Do9Rg"
|
||||
"I9bCetmk_o87-sd66brXnWV1MbjhLnjw")
|
||||
|
||||
|
||||
def test_megaplay_sources_old_shape_passthrough():
|
||||
from server.upstream import megaplay_sources
|
||||
d = {"sources": {"file": "https://cdn.x/master.m3u8"}, "tracks": []}
|
||||
out = megaplay_sources(d)
|
||||
assert out["sources"]["file"] == "https://cdn.x/master.m3u8"
|
||||
|
||||
|
||||
def test_megaplay_sources_new_shape_decrypts():
|
||||
from server.upstream import megaplay_sources
|
||||
out = megaplay_sources({"enc": ENC, "tracks": [{"file": "a.vtt"}]})
|
||||
f = out["sources"]["file"]
|
||||
assert f.startswith("https://") and f.endswith("master.m3u8"), f
|
||||
assert out["tracks"] == [{"file": "a.vtt"}] # outer fields untouched
|
||||
|
||||
|
||||
def test_megaplay_sources_no_sources_unchanged():
|
||||
from server.upstream import megaplay_sources
|
||||
assert megaplay_sources({}) == {}
|
||||
|
||||
|
||||
def test_megaplay_sources_garbage_enc_raises():
|
||||
from server.upstream import megaplay_sources
|
||||
# corrupt payload must raise (provider -> fallback chain), not return None
|
||||
with pytest.raises(Exception):
|
||||
megaplay_sources({"enc": "AAAA" * 10})
|
||||
|
||||
|
||||
# --- /stream Range pass-through (progressive MP4), offline -----------------
|
||||
|
||||
class _FakeStream:
|
||||
"""Minimal stand-in for a streaming curl_cffi response (206 + chunk)."""
|
||||
status_code = 206
|
||||
headers = {"content-type": "video/mp4",
|
||||
"content-range": f"bytes 0-1023/{1024 * 1024}",
|
||||
"accept-ranges": "bytes"}
|
||||
|
||||
def iter_content(self, chunk_size=1):
|
||||
yield b"x" * 1024
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def app_mod(monkeypatch):
|
||||
monkeypatch.setenv("ANI_SOURCE", "animekai")
|
||||
import server.upstream as up
|
||||
monkeypatch.setattr(up, "get_stream",
|
||||
lambda url, timeout=12, headers=None: _FakeStream())
|
||||
import server.app as m
|
||||
return m
|
||||
|
||||
|
||||
def _drain(it):
|
||||
import asyncio
|
||||
async def _go():
|
||||
return b"".join([c async for c in it])
|
||||
return asyncio.run(_go())
|
||||
|
||||
|
||||
def _call(app_mod, token, range_hdr):
|
||||
"""Invoke the /stream handler in-process (no TestClient: it wants httpx).
|
||||
Returns (status, headers_dict, body_bytes)."""
|
||||
req = types.SimpleNamespace(headers={"range": range_hdr} if range_hdr else {})
|
||||
resp = app_mod.stream(token, request=req)
|
||||
body = _drain(resp.body_iterator) if getattr(resp, "body_iterator", None) is not None else resp.body
|
||||
return resp.status_code, dict(resp.headers), body
|
||||
|
||||
|
||||
def test_stream_range_forwards_206_and_chunk(app_mod):
|
||||
import server.providers.animekai as a
|
||||
a.STREAM_HOSTS[HOST] = {"Referer": "https://" + HOST + "/"}
|
||||
token = base64.urlsafe_b64encode(VIDEO.encode()).decode()
|
||||
status, hdrs, body = _call(app_mod, token, "bytes=0-1023")
|
||||
assert status == 206, status
|
||||
assert hdrs.get("content-range") == f"bytes 0-1023/{1024 * 1024}"
|
||||
assert len(body) == 1024
|
||||
|
||||
|
||||
def test_stream_range_not_cached(app_mod):
|
||||
import server.providers.animekai as a
|
||||
a.STREAM_HOSTS[HOST] = {"Referer": "https://" + HOST + "/"}
|
||||
token = base64.urlsafe_b64encode(VIDEO.encode()).decode()
|
||||
_call(app_mod, token, "bytes=0-1023")
|
||||
assert VIDEO not in app_mod._STREAM_CACHE, "ranged chunk poisoned the LRU"
|
||||
@@ -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")
|
||||
Reference in new issue
Block a user