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).
197 lines
7.6 KiB
Python
197 lines
7.6 KiB
Python
"""providers/animekai.py — animekai.be source (hianime-family CMS, own ID space).
|
||
|
||
Each episode lists one or more servers, allocated per-episode across TWO
|
||
backends, handled by separate _backend_* functions (add a third backend =
|
||
add one branch in _master_m3u8):
|
||
|
||
- my.1anime.site -> direct progressive MP4 (<source> on the embed page).
|
||
Independent CDN; its file host demands a Referer (403 without).
|
||
- megaplay.buzz -> MegaPlay player, shared with hianime via
|
||
upstream.megaplay_master. Same CDN family.
|
||
|
||
master_m3u8() prefers 1anime first (independent of the MegaPlay CDN), then
|
||
megaplay. The returned "master" is just "the URL the player fetches":
|
||
.m3u8 -> hls.js, anything else -> native <video> through the /stream
|
||
proxy's Range path (app.py decides by suffix — providers stay agnostic).
|
||
|
||
Politeness: every endpoint rides the shared _cached TTL cache (search
|
||
300s, details 3600s, episodes 300s — airing shows gain eps, sources
|
||
3600s, master 3600s), so repeated UI requests cost the provider one fetch.
|
||
"""
|
||
import re
|
||
from urllib.parse import urlparse
|
||
|
||
from ..upstream import _cached, _get, megaplay_master
|
||
|
||
BASE = "https://animekai.be"
|
||
RETRIES = {"sub": 300, "details": 3600, "episodes": 300, "sources": 3600, "master": 3600}
|
||
|
||
# ponytail: process-local slug<->id registry. details(slug) always precedes
|
||
# episodes(anime_id) in the app flow (search -> details -> episodes), so an
|
||
# id reaching episodes() was minted by this process's details(). A restart
|
||
# orphans in-flight ids (user re-navigates) — same failure mode as any
|
||
# source switch.
|
||
_REG, _BYID = {}, {}
|
||
_N = 0
|
||
|
||
# host -> Referer its file CDN demands; {} entry = allowed, no headers.
|
||
# stream_meta() non-None is the /stream proxy's allowlist.
|
||
STREAM_HOSTS = {}
|
||
|
||
|
||
def _search(q):
|
||
html = _get(f"{BASE}/browse", params={"keyword": q, "sort": "most_viewed"}, timeout=20).text
|
||
out, seen = [], set()
|
||
for m in re.finditer(
|
||
r'<a class="title" href="[^"]*/watch/([^"?/]+)"[^>]*title="([^"]*)"', html):
|
||
slug, name = m.group(1), m.group(2)
|
||
if slug in seen:
|
||
continue
|
||
seen.add(slug)
|
||
img = re.search(r'<img[^>]+src="([^"]+)"', html[max(0, m.start() - 400):m.end() + 200])
|
||
out.append({"id": 0, "slug_id": slug, "name": name,
|
||
"poster": img.group(1) if img else None})
|
||
return out
|
||
|
||
|
||
def _details(slug):
|
||
t = _get(f"{BASE}/watch/{slug}", timeout=20).text
|
||
global _N
|
||
if slug not in _REG:
|
||
_N += 1
|
||
_REG[slug] = _N
|
||
_BYID[_N] = slug
|
||
title = re.search(r'property="og:title" content="([^"]+)"', t)
|
||
name = re.sub(r'\s*Episode\s*\d+.*$', "", title.group(1)) if title else None
|
||
name = re.sub(r'\s*[-|–]\s*AnimeKai\s*$', "", name or "").strip() or None
|
||
post = re.search(r'property="og:image" content="([^"]+)"', t)
|
||
syn = re.search(r'<div class="desc text-expand"[^>]*>([\s\S]*?)</div>', t)
|
||
synopsis = re.sub(r"<[^>]+>", "", syn.group(1)).strip() if syn else None
|
||
return {"id": _REG[slug], "name": name, "synopsis": synopsis,
|
||
"mal_id": None,
|
||
"poster": post.group(1) if post else None,
|
||
"seasons": []}
|
||
|
||
|
||
def _episodes(anime_id):
|
||
slug = _BYID.get(anime_id)
|
||
if slug is None:
|
||
raise RuntimeError(f"animekai: unknown anime id {anime_id} (stale after restart?)")
|
||
t = _get(f"{BASE}/watch/{slug}", timeout=20).text
|
||
out, seen = [], set()
|
||
for num in re.findall(rf'/watch/{re.escape(slug)}/ep-(\d+)"', t):
|
||
n = int(num)
|
||
if n in seen:
|
||
continue
|
||
seen.add(n)
|
||
out.append({"id": anime_id * 1000 + n, "number": n, "filler": False})
|
||
out.sort(key=lambda e: (isinstance(e["number"], str), e["number"]))
|
||
return out
|
||
|
||
|
||
def _sources(slug, num):
|
||
d = _get(f"{BASE}/watch/{slug}/ep/{num}/sources", timeout=20).json()
|
||
return d.get("sources") or {}
|
||
|
||
|
||
_sources_cached = lambda slug, num: _cached(
|
||
f"animekai:sources:{slug}:{num}", RETRIES["sources"], _sources, slug, num)
|
||
|
||
|
||
def _languages(ep_id):
|
||
anime_id, num = divmod(ep_id, 1000)
|
||
slug = _BYID.get(anime_id)
|
||
if slug is None:
|
||
return []
|
||
s = _sources_cached(slug, num)
|
||
out = []
|
||
if s.get("sub"):
|
||
out.append({"code": "jpn", "name": "Sub", "embed_url": "sub"})
|
||
if s.get("dub"):
|
||
out.append({"code": "eng", "name": "Dub", "embed_url": "dub"})
|
||
return out
|
||
|
||
|
||
def _backend_oneanime(source_url):
|
||
"""1anime embed -> direct MP4 url, or None. The file CDN 403s without a
|
||
Referer, so the proof fetch doubles as the referer discovery."""
|
||
page = _get(source_url, headers={"Referer": BASE + "/"}, timeout=15).text
|
||
m = re.search(r'<source src="([^"]+)"', page)
|
||
if not m:
|
||
return None
|
||
url = m.group(1)
|
||
up = urlparse(source_url)
|
||
host = urlparse(url).netloc
|
||
# prove it serves us AND learn the referer the file host demands
|
||
for refr in (f"{up.scheme}://{up.netloc}/", f"https://{host}/", None):
|
||
try:
|
||
_get(url, headers={"Range": "bytes=0-1023", **({"Referer": refr} if refr else {})},
|
||
timeout=10)
|
||
STREAM_HOSTS[host] = {"Referer": refr} if refr else {}
|
||
return url
|
||
except Exception:
|
||
continue
|
||
return None
|
||
|
||
|
||
def _backend_megaplay(source_url):
|
||
"""MegaPlay embed -> master m3u8 url, or None (shared chain)."""
|
||
master, _subs, refr = megaplay_master(source_url, BASE + "/")
|
||
if not master:
|
||
return None
|
||
host = urlparse(master).netloc
|
||
STREAM_HOSTS[host] = {"Referer": refr} if refr else {}
|
||
return master
|
||
|
||
|
||
_BACKENDS = (("1anime", _backend_oneanime), ("megaplay", _backend_megaplay))
|
||
|
||
|
||
def _master_m3u8(ep_id, lang="eng"):
|
||
anime_id, num = divmod(ep_id, 1000)
|
||
slug = _BYID.get(anime_id)
|
||
if slug is None:
|
||
raise RuntimeError(f"animekai: unknown anime id {anime_id} (stale after restart?)")
|
||
s = _sources_cached(slug, num)
|
||
want = "sub" if lang in ("jpn", "sub") else "dub"
|
||
# 1anime first: independent CDN, so it rides through MegaPlay outages
|
||
for mode in ((want, "sub") if want == "dub" else (want,)):
|
||
servers = s.get(mode) or []
|
||
if not servers:
|
||
continue
|
||
for _name, url in ((x.get("server_name"), x.get("source_url")) for x in servers):
|
||
for _bname, backend in _BACKENDS:
|
||
if url and _bname in url:
|
||
try:
|
||
out = backend(url)
|
||
except Exception:
|
||
out = None
|
||
if out:
|
||
return out
|
||
# distinguish "no servers listed" (legit None = no dub) from "listed but
|
||
# dead" (raise -> ANI_SOURCE fallback), same semantics as hianime
|
||
if s.get(want) or (want == "dub" and s.get("sub")):
|
||
raise RuntimeError(f"animekai: ep {num} servers listed but none resolved")
|
||
return None
|
||
|
||
|
||
# Provider interface. Cache keys namespaced "animekai:"; TTLs keep the
|
||
# provider from seeing the same request twice (see module docstring).
|
||
search = lambda q: _cached(f"animekai:search:{q.lower()}", RETRIES["sub"], _search, q)
|
||
details = lambda s: _cached(f"animekai:details:{s}", RETRIES["details"], _details, s)
|
||
episodes = lambda i: _cached(f"animekai:episodes:{i}", RETRIES["episodes"], _episodes, i)
|
||
languages = lambda e: _cached(f"animekai:languages:{e}", RETRIES["sources"], _languages, e)
|
||
master_m3u8 = lambda e, l="eng": _cached(f"animekai:master:{e}:{l}", RETRIES["master"], _master_m3u8, e, l)
|
||
|
||
|
||
def stream_meta(url):
|
||
"""{Referer} (or {}) for our own file hosts, else None — non-None
|
||
doubles as the /stream allowlist."""
|
||
return STREAM_HOSTS.get(urlparse(url).netloc)
|
||
|
||
|
||
def subtitles(ep_id):
|
||
# 1anime MP4s carry no separate subs; megaplay subs are in the tracks
|
||
# the player bakes in — nothing external to hand the frontend
|
||
return None
|