Files
ani-web/server/providers/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

197 lines
7.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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