Files
ani-web/tests/test_animekai.py
T
jozamudi d12ff79d29 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).
2026-09-29 18:35:22 +00:00

134 lines
4.7 KiB
Python

"""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"