Compare commits

..
Author SHA1 Message Date
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
jozamudi e3ef51d141 Stop spamming upstream: drop video download from probe, kill re-rolls
- CDN probe no longer downloads 64KB of video (or even a variant
  playlist): one small master-playlist fetch proves reachability.
  State stays OK/DEGRADED, kbps is no longer measured, and the pill/
  panel only show a rate when one exists.
- hianime: 5x getSourcesNew re-roll -> 1 attempt. The enc payload is
  deterministic per data-id (observed identical across calls), so
  re-rolling only hammered the provider.
- anikoto: 2x re-roll -> 1 attempt per server, same reason.

Kept: idle-gated + 30-min degraded backoff from the previous commit,
the single _get retry (protects real user playback from CF blips),
and the origin page check (the 'down' signal). Net upstream traffic is
now what an actual viewer would generate, nothing more.
2026-09-29 17:33:54 +00:00
jozamudi 1154203569 CDN probe: skip while idle, back off while degraded
The 3-min 64KB video download ran on a timer regardless of whether
anyone was using the app — wasteful upstream bandwidth, and pure churn
while the CDN 403s our IP. Now: no fetch while no stream has been
requested in 15 min (api_stream bumps _LAST_PLAY), and 30-min retry
while DEGRADED. Pill state goes stale while idle, which is fine — the
next viewer's own request is the true signal, and the probe resumes
within a minute of it.
2026-09-29 17:23:18 +00:00
jozamudi 0cd79dd701 Decode MegaPlay's encrypted getSourcesNew payload
MegaPlay (behind both hianime and anikoto) stopped returning
sources.file in plaintext and now ships the stream URL in an
AES-256-CBC 'enc' field. Key/IV are static, hardcoded in their public
player JS (newclient.min.js) — anti-scrape obscurity, not access
control. Shared megaplay_sources() in upstream.py tolerates both
shapes, so a revert upstream breaks nothing.

Also: hianime master_m3u8 now raises when servers are listed but none
resolve (403s, stale format) instead of returning None — a listed-but
dead server is a provider fault, not 'no dub', and the old None
silently short-circuited the ANI_SOURCE fallback chain.
2026-09-29 17:17:33 +00:00
jozamudi 731f44b99c watched: single DONE_RATIO source constant replaces the seven hardcoded 0.95 literals
server/app.py DONE_RATIO is injected into index.html like {{VERSION}}, app.js
reads it as window.DONE_RATIO (fallback = same default). Change the threshold
in exactly one place now.
2026-09-21 01:41:44 +00:00
11 changed files with 609 additions and 91 deletions

No files matched your search

+1
View File
@@ -5,6 +5,7 @@ fastapi==0.141.1
uvicorn==0.52.4
curl_cffi==0.16.0
itsdangerous==2.2.0
pycryptodome==3.23.0
# tests
pytest==9.1.1
+4 -1
View File
@@ -28,6 +28,9 @@ fi
# ANIWEB_TRUST_XFF=1: prod traffic arrives via the local HTTPS proxy, so the
# per-IP login budget keys on X-Forwarded-For; without the flag auth.py uses
# the socket peer only (the header is spoofable on a direct connection).
ANIWEB_SECURE=1 ANIWEB_TRUST_XFF=1 nohup .venv/bin/python -m uvicorn server.app:app --host 0.0.0.0 --port 8321 --timeout-graceful-shutdown 3 > server.log 2>&1 &
# ANI_SOURCE: animekai leads while MegaPlay's CDN 403s our egress IP (its
# 1anime servers use an independent CDN); the hianime/anidb/anikoto chain
# stays as fallback. Restore "hianime,anidb,anikoto" once the block lifts.
ANIWEB_SECURE=1 ANIWEB_TRUST_XFF=1 ANI_SOURCE=animekai,hianime,anidb,anikoto nohup .venv/bin/python -m uvicorn server.app:app --host 0.0.0.0 --port 8321 --timeout-graceful-shutdown 3 > server.log 2>&1 &
echo $! > "$PIDF"
echo "ani-web on http://localhost:8321 (pid $(cat "$PIDF")) — use the HTTPS proxy to log in"
+62 -33
View File
@@ -44,55 +44,59 @@ def _version():
VERSION = _version()
# latest probe result + last 20 samples (~1h at 3-min spacing), for GET /api/cdn
# An episode counts as "watched" once you're this far through it (fraction of
# duration). One knob for client + server: injected into index.html below so
# app.js uses the same value as these stats. Change here only.
DONE_RATIO = 0.95
# latest probe result + last 20 samples, for GET /api/cdn
_CDN = {"state": "unknown", "kbps": 0.0, "ts": 0.0, "hist": []}
# ponytail: probe only when someone is around to care — skip the 64 KB video
# download while the app has been idle (no stream requested in 15 min), and
# back off to 30 min while DEGRADED (re-hitting a blocked CDN every 3 min is
# just churn). api_stream() bumps _LAST_PLAY.
_LAST_PLAY = 0.0
_IDLE_SECS = 900
_DEGRADED_RETRY = 1800
# origin (primary provider pages/API) health — separate host, separate failure
# mode (observed 2026-09-03: CDN fine while origin 503'd "Under Maintenance")
_ORIGIN = {"state": "unknown", "msg": "", "ts": 0.0, "hist": []}
# ponytail: fixed threshold; if the CDN's normal throughput drifts below it
# the pill sits on "slow" — tune via ANIWEB_CDN_KBPS, not a percentile.
# kept for the /api/cdn response shape (the panel shows it only when a rate
# was actually measured; the probe now checks reachability, not throughput)
_CDN_THRESHOLD = float(os.environ.get("ANIWEB_CDN_KBPS", "40"))
def _cdn_probe():
"""Background probe: pull 64KB from the CDN every 3 min and log state
flips, so server.log shows when a degraded window ends (CDN RECOVERED).
"""Background probe: fetch ONE small playlist and log state flips, so
server.log shows when a degraded window ends (CDN RECOVERED). No video
is downloaded just to light a status dot. Skipped
while the app is idle and backed off while DEGRADED (see _LAST_PLAY).
Probe target: ep_id 1 = One Piece E1 on the primary provider.
ponytail: single fixed episode; if it ever disappears, the probe logs
DEGRADED forever — swap the ID, don't build a picker."""
def first(pl, base):
l = next(x.strip() for x in pl.splitlines()
if x.strip() and not x.startswith("#"))
return l if l.startswith("http") else base + l
state = None
while True:
if time.time() - _LAST_PLAY > _IDLE_SECS:
time.sleep(60) # nobody's watching: don't burn upstream bandwidth
continue
try:
m = upstream.master_m3u8(1, "jpn")
if not m:
raise RuntimeError("no master m3u8")
# hianime's CDN 403s without the embed-origin Referer (anidb needs none)
hdrs = upstream.stream_meta(m) or {}
base = m.rsplit("/", 1)[0] + "/"
v = first(upstream._get(m, timeout=10, headers=hdrs).text, base)
# hianime's variant playlists use URIs relative to THEIR OWN path
vbase = v.rsplit("/", 1)[0] + "/"
seg = first(upstream._get(v, timeout=10, headers=hdrs).text, vbase)
t = time.time()
r = upstream._session().get(seg, headers={**hdrs, "Range": "bytes=0-65535"},
impersonate="chrome", timeout=10)
kbps = len(r.content) / 1024 / max(time.time() - t, 0.01)
upstream._get(m, timeout=10, headers=hdrs)
now = "OK"
except Exception:
kbps = 0.0
now = "OK" if kbps >= _CDN_THRESHOLD else "DEGRADED"
now = "DEGRADED"
if now != state:
print(f"[probe] CDN {now} ({kbps:.0f} KB/s)", flush=True)
print(f"[probe] CDN {now}", flush=True)
state = now
_CDN.update(state=now, kbps=round(kbps), ts=time.time())
_CDN["hist"].append({"t": time.time(), "kbps": round(kbps)})
_CDN.update(state=now, kbps=0, ts=time.time())
_CDN["hist"].append({"t": time.time(), "kbps": 0})
del _CDN["hist"][:-20]
_origin_probe()
time.sleep(180)
time.sleep(_DEGRADED_RETRY if now == "DEGRADED" else 180)
def _origin_probe():
@@ -455,7 +459,7 @@ def api_stats(request: Request, days: int = Query(30, ge=1, le=365),
if s["last_ts"] > a["last_seen"]:
a["last_seen"] = s["last_ts"]
a["cur_ep"] = s["last_ep"]
if s["dur"] > 0 and s["max"] >= s["dur"] * 0.95:
if s["dur"] > 0 and s["max"] >= s["dur"] * DONE_RATIO:
a["completed"] += 1
completed += 1
now = time.time()
@@ -554,11 +558,16 @@ def api_episodes(anime_id: int):
@app.get("/api/episode/{ep_id}/stream")
def api_stream(ep_id: int, lang: str = "jpn", request: Request = None):
global _LAST_PLAY
_LAST_PLAY = time.time() # wakes the CDN probe: a viewer is here
def go():
master = _upstream(upstream.master_m3u8, ep_id, lang)
if not master:
raise HTTPException(404, f"no {lang} stream for episode {ep_id}")
out = {"token": _b64(master)}
# the player kind is a property of the URL, not the provider: .m3u8
# -> hls.js, anything else -> native <video> (app.js branches on this)
out["hls"] = master.endswith(".m3u8")
sub = upstream.subtitles(ep_id) # external .vtt (hianime); anidb bakes subs in
if sub:
out["sub"] = _b64(sub)
@@ -609,9 +618,11 @@ def _cache_put(url, ct, body):
_STREAM_CACHE.popitem(last=False)
def _seg_stream(r, url, ct):
def _seg_stream(r, url, ct, cache=True):
"""Forward the CDN's bytes as they arrive; keep the LRU warm for
completed downloads (a truncated one would poison the cache)."""
completed downloads (a truncated one would poison the cache). cache=False
for ranged file chunks — a partial body under the full-URL key would
poison the LRU."""
import time
t0, t_first, buf, complete = time.time(), None, [], False
try:
@@ -623,7 +634,7 @@ def _seg_stream(r, url, ct):
complete = True
finally:
r.close()
if complete and buf:
if complete and buf and cache:
_cache_put(url, ct, b"".join(buf))
# observability: a slow segment is the fragLoadTimeOut culprit. Log
# only the slow ones so normal playback stays quiet. TTFB>3s or
@@ -653,7 +664,7 @@ def _stream_playlist(url, meta=None):
@app.get("/stream/{token}")
def stream(token: str):
def stream(token: str, request: Request = None):
url = _url(token)
p = urlparse(url)
# ponytail: token is plain base64, so allowlist the host — otherwise any
@@ -671,6 +682,19 @@ def stream(token: str):
return Response(hit[1], media_type=hit[0], headers=_STREAM_CACHE_HDRS)
if url.endswith(".m3u8"):
return _stream_playlist(url, meta)
# progressive file (1anime's direct MP4): the browser seeks with Range —
# forward it, stream the chunk back, forward 206/Content-Range, never
# cache (partial bodies under the full-URL key would poison the LRU)
rng = request.headers.get("range") if request else None
if rng:
r = _upstream(upstream.get_stream, url, timeout=30,
headers={**(meta or {}), "Range": rng})
ct = r.headers.get("content-type", "video/mp4")
return StreamingResponse(
_seg_stream(r, url, ct, cache=False),
status_code=r.status_code, media_type=ct,
headers={k: v for k, v in r.headers.items()
if k.lower() in ("content-range", "accept-ranges")})
# segment miss: try parallel byte-range lanes first (4 concurrent fetches
# beat per-connection CDN throttling); ANY failure falls back to the
# single streaming path, so this can never be worse than before
@@ -692,7 +716,10 @@ def stream(token: str):
name = url.rsplit("/", 1)[-1][:40]
print(f"[stream-slow] {name} parallel total={time.time()-t0:.1f}s "
f"bytes={len(body)}", flush=True)
_cache_put(url, ct, body)
# ponytail: 8MB cap — the LRU is for playlists/segments; a multi-hundred-MB
# progressive file (1anime MP4) would evict everything and pin RAM
if len(body) < 8 * 1024 * 1024:
_cache_put(url, ct, body)
return Response(body, media_type=ct, headers=_STREAM_CACHE_HDRS)
@@ -700,8 +727,10 @@ def stream(token: str):
@app.get("/index.html", include_in_schema=False)
def index():
# index.html is served (not mounted) so ?v={{VERSION}} becomes the git
# SHA at startup; other statics go through the mount below
return HTMLResponse(_INDEX_HTML.replace("{{VERSION}}", VERSION))
# SHA and {{DONE_RATIO}} the watched threshold at startup; other statics
# go through the mount below
return HTMLResponse(_INDEX_HTML.replace("{{VERSION}}", VERSION)
.replace("{{DONE_RATIO}}", repr(DONE_RATIO)))
_INDEX_HTML = (ROOT / "static" / "index.html").read_text()
+13 -12
View File
@@ -10,7 +10,7 @@ times until a CDN domain actually serves us.
"""
import re
from ..upstream import _cached, _get
from ..upstream import _cached, _get, megaplay_sources
BASE = "https://anikototv.to"
XHR = {"X-Requested-With": "XMLHttpRequest"}
@@ -100,8 +100,8 @@ def _player_file(embed_url):
if not m:
return None
host = re.match(r'https?://([^/]+)/', embed_url).group(1)
d = _get(f"https://{host}/stream/getSourcesNew?id={m.group(1)}",
headers={"Referer": embed_url, "X-Requested-With": "XMLHttpRequest"}).json()
d = megaplay_sources(_get(f"https://{host}/stream/getSourcesNew?id={m.group(1)}",
headers={"Referer": embed_url, "X-Requested-With": "XMLHttpRequest"}).json())
return (d.get("sources") or {}).get("file")
@@ -117,15 +117,16 @@ def _master_m3u8(ep_id, lang="eng"):
url = (d.get("result") or {}).get("url")
if not url:
continue
for _ in range(2): # re-roll: CDN domains rotate, most 403 our IP
try:
f = _player_file(url)
if not f:
break
_get(f, timeout=10) # prove this CDN serves us before committing
return f
except Exception as ex:
errs.append(str(ex)[:60])
# ponytail: one attempt per server — the enc payload is deterministic,
# re-rolling would just spam the provider
try:
f = _player_file(url)
if not f:
break
_get(f, timeout=10) # prove this CDN serves us before committing
return f
except Exception as ex:
errs.append(str(ex)[:60])
raise RuntimeError("anikoto: no working server: " + "; ".join(errs[:3]))
+196
View File
@@ -0,0 +1,196 @@
"""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
+17 -31
View File
@@ -13,7 +13,7 @@ import json
import re
from urllib.parse import urljoin, urlparse
from ..upstream import _cached, _get
from ..upstream import _cached, _get, megaplay_master
BASE = "https://hianime.at"
KEY = b"otaku-embed-v1"
@@ -88,9 +88,8 @@ def _embed_payload(embed):
"""embed url -> (master_m3u8, [{label, src} subs], referer_or_None).
Two player chains have appeared: zokoanime (window.__P blob,
base64+XOR) and megaplay (page needs the hianime referer; data-id ->
getSourcesNew; CDN domains rotate and most 403 us, so re-roll until
one serves)."""
base64+XOR — hianime-specific) and megaplay (shared: upstream.
megaplay_master, used by animekai too)."""
page = _get(embed, timeout=15).text
blob = re.search(r'window\.__P="([^"]+)"', page)
if blob:
@@ -98,29 +97,7 @@ def _embed_payload(embed):
subs = [{"label": (x or {}).get("label", ""), "src": (x or {}).get("src")}
for x in j.get("subtitles") or []]
return j.get("src"), subs, re.match(r'https?://[^/]+', embed).group(0) + "/"
# megaplay: without the hianime referer the page is an error stub with no data-id
if not re.search(r'data-id="\d+"', page):
page = _get(embed, headers={"Referer": BASE + "/"}, timeout=15).text
mid = re.search(r'data-id="(\d+)"', page)
if not mid:
return None, [], None
host = re.match(r'https?://([^/]+)', embed).group(1)
for _ in range(5):
d = _get(f"https://{host}/stream/getSourcesNew?id={mid.group(1)}",
headers={"Referer": embed, "X-Requested-With": "XMLHttpRequest"},
timeout=15).json()
src = (d.get("sources") or {}).get("file")
if not src:
continue
subs = [{"label": (x or {}).get("label", ""), "src": (x or {}).get("file")}
for x in d.get("tracks") or []]
for cand, refr in (({"Referer": embed}, embed), ({}, None)):
try:
_get(src, headers=cand, timeout=10)
return src, subs, refr
except Exception:
continue
return None, [], None
return megaplay_master(embed, BASE + "/")
def _master_m3u8(ep_id, lang="eng"):
@@ -129,13 +106,18 @@ def _master_m3u8(ep_id, lang="eng"):
want = "sub" if lang in ("jpn", "sub") else "dub"
# HD-1 has rotated between player chains (zokoanime blob, megaplay);
# try every listed server of the wanted type until one resolves
errs = []
for typ in (want, "sub") if want == "dub" else (want,):
for _name, h in re.findall(
for name, h in re.findall(
rf'data-type="{typ}"[\s\S]*?data-server-name="([^"]+)"'
rf'[\s\S]*?data-hash="([^"]+)"', t):
embed = base64.b64decode(h).decode()
master, subs, refr = _embed_payload(embed)
try:
master, subs, refr = _embed_payload(base64.b64decode(h).decode())
except Exception as e:
errs.append(f"{name}: {e}")
continue
if not master:
errs.append(f"{name}: no source")
continue
REFERER = refr
# prove the CDN serves us and register the hosts so the /stream proxy
@@ -154,7 +136,11 @@ def _master_m3u8(ep_id, lang="eng"):
if pick and pick.get("src"):
SUBS[ep_id] = pick["src"]
return master
return None
if not errs:
return None # no servers listed at all: a legitimate "no dub"
# listed but unresolvable (403s, stale player format) is a provider fault,
# not "no dub" — raise so the ANI_SOURCE fallback chain tries the next source
raise RuntimeError("hianime: no working server: " + "; ".join(errs[:3]))
# Provider interface. Cache keys are namespaced with "hianime:".
+56
View File
@@ -17,12 +17,15 @@ Shared HTTP helpers (_get/get_stream/fetch_parallel, Chrome TLS
impersonation, TTL cache) live here so every provider gets them for free;
they work on any URL, not just anidb's.
"""
import base64
import importlib
import json
import os
import re
import threading
import time
from Crypto.Cipher import AES
from curl_cffi import requests
H = dict(impersonate="chrome", timeout=20)
@@ -55,6 +58,59 @@ def _cached(key, ttl, fn, *a, **kw):
def clear_cache():
_CACHE.clear()
# MegaPlay's getSourcesNew hid the stream URL in an AES-256-CBC "enc" field.
# Key/IV are static and hardcoded in their public player JS (newclient.min.js)
# — anti-scrape theater, not access control. ponytail: breaks if they rotate
# the key; upgrade path = re-read newclient.min.js (or let it fail: both
# shapes are tolerated below, so the old code path still works if reverted).
_MP_KEY = b"i?LMTAx0Q6,:}50U".ljust(32, b"\x00")
_MP_IV = b"W0;27ToaUpl_P%'c"
def megaplay_sources(d):
"""getSourcesNew response -> same dict with sources["file"] filled.
Old players return it plaintext; current ones base64url+AES-wrap it in
"enc" (decrypted payload is {"file": ...})."""
if not (d.get("sources") or {}).get("file") and d.get("enc"):
raw = base64.urlsafe_b64decode(d["enc"] + "=" * (-len(d["enc"]) % 4))
pt = AES.new(_MP_KEY, AES.MODE_CBC, _MP_IV).decrypt(raw)
j = json.loads(pt[:-pt[-1]]) # strip PKCS7
if j.get("file"):
d = {**d, "sources": {"file": j["file"]}}
return d
def megaplay_master(embed, referer):
"""MegaPlay embed url -> (master_url, [{label, src} subs], referer_or_None).
Shared by every provider fronting the MegaPlay player (hianime,
animekai): embed page (referer-gated) -> data-id -> getSourcesNew
(megaplay_sources) -> prove the CDN serves us, remembering which
Referer variant worked. referer = the front site's home (the embed
page 403s a stub without it). One attempt: the enc payload is
deterministic per data-id, re-rolling would just spam the provider.
The caller registers the returned master's hosts in its own
/stream allowlist."""
page = _get(embed, headers={"Referer": referer}, timeout=15).text
mid = re.search(r'data-id="(\d+)"', page)
if not mid:
return None, [], None
host = re.match(r'https?://([^/]+)', embed).group(1)
d = megaplay_sources(_get(f"https://{host}/stream/getSourcesNew?id={mid.group(1)}",
headers={"Referer": embed, "X-Requested-With": "XMLHttpRequest"},
timeout=15).json())
src = (d.get("sources") or {}).get("file")
if not src:
return None, [], None
subs = [{"label": (x or {}).get("label", ""), "src": (x or {}).get("file")}
for x in d.get("tracks") or []]
for cand, refr in (({"Referer": embed}, embed), ({}, None)):
try:
_get(src, headers=cand, timeout=10)
return src, subs, refr
except Exception:
continue
return None, [], None
def _get(url, **kw):
# ponytail: one retry (300ms) on transient upstream errors (Cloudflare
+21 -14
View File
@@ -10,6 +10,9 @@ const BASE = (() => {
return p.endsWith("/") ? p : p.slice(0, p.lastIndexOf("/") + 1);
})();
const main = document.getElementById("main");
// Watched threshold (fraction of duration) — injected inline by the server
// from DONE_RATIO in server/app.py; fallback matches that default.
const DONE_RATIO = Number(window.DONE_RATIO ?? 0.95);
let hls = null, detail = null, lang = "jpn", seasonId = null;
async function api(path) {
@@ -190,7 +193,7 @@ const homeCard = x => {
c.append(poster(x.poster, x.title));
let meta = `E${x.ep} \u00b7 ${x.lang === "eng" ? "dub" : "sub"}`;
if (done) meta += " \u00b7 \u2713";
else if (x.dur > 0 && x.pos < x.dur * 0.95) meta += ` \u00b7 ${fmtMin((x.dur - x.pos) / 60)} left`;
else if (x.dur > 0 && x.pos < x.dur * DONE_RATIO) meta += ` \u00b7 ${fmtMin((x.dur - x.pos) / 60)} left`;
else if (hold) meta += ` \u00b7 ${Math.round(Date.now() / 1000 / 86400 - x.t / 86400)}d ago`;
const info = el("div", "mini-info");
info.append(el("div", "card-name", x.title), el("div", "mini-meta", meta));
@@ -250,11 +253,11 @@ function showHome() {
body.append(el("div", "hero-kicker", "continue watching"),
el("h2", null, hero.title),
el("div", "muted", `Ep ${hero.ep} · ${hero.lang === "eng" ? "dub" : "sub"} · ${fmtPos(hero.pos)} in` +
(hero.dur > 0 && hero.pos < hero.dur * 0.95 ? ` · ${fmtMin((hero.dur - hero.pos) / 60)} left` : "") +
(hero.dur > 0 && hero.pos < hero.dur * DONE_RATIO ? ` · ${fmtMin((hero.dur - hero.pos) / 60)} left` : "") +
` · ${timeago(hero.t)}`));
const p = bar(hero);
if (p) body.append(p);
const heroDone = hero.dur > 0 && hero.pos >= hero.dur * 0.95;
const heroDone = hero.dur > 0 && hero.pos >= hero.dur * DONE_RATIO;
const go = el("button", "btn resume", heroDone ? "\u25b6 Next ep" : "\u25b6 Resume");
go.onclick = () => location.href = histHref(hero);
right.append(body, go);
@@ -503,7 +506,7 @@ async function showAnime(slug_id, opts = {}) {
for (const x of await api("/api/history?all=1")) {
if (x.slug_id !== slug_id || x.season_id !== seasonId) continue;
started = true;
if (x.dur > 0 && x.pos >= x.dur * 0.95) watchDone.add(String(x.ep_id));
if (x.dur > 0 && x.pos >= x.dur * DONE_RATIO) watchDone.add(String(x.ep_id));
else if (x.dur > 0) epProg.set(String(x.ep_id), Math.min(100, Math.round(100 * x.pos / x.dur)));
}
} catch {}
@@ -529,7 +532,7 @@ async function showAnime(slug_id, opts = {}) {
function langLabel() { return lang === "eng" ? "▶ dub" : "▶ sub"; }
// ---------- history page (full list + delete + client-side filter) ----------
const histClass = x => x.dur > 0 && x.pos >= x.dur * 0.95 ? "done"
const histClass = x => x.dur > 0 && x.pos >= x.dur * DONE_RATIO ? "done"
: (x.t && Date.now() / 1000 - x.t > 7 * 86400 ? "hold" : "watching");
function remapBoard() {
// home: one stale card per stale anime (latest row), above the hero, so a
@@ -954,12 +957,12 @@ function renderPill(d) {
p.querySelector(".d").className = "d" + (st ? " " + st : "");
p.querySelector("span").textContent =
!st ? "…" : st === "down" ? "down"
: st === "slow" ? "slow · " + d.kbps + " KB/s" : "up";
: st === "slow" ? "slow" + (d.kbps ? " · " + d.kbps + " KB/s" : "") : "up";
p.title = "upstream site: " + d.origin.state + (d.origin.msg ? " — " + d.origin.msg : "")
+ " · video CDN: " + d.state + " — " + d.kbps + " KB/s";
+ " · video CDN: " + d.state + (d.kbps ? " — " + d.kbps + " KB/s" : "");
p.setAttribute("aria-label", "status: " + (!st ? "checking"
: st === "down" ? "down — upstream " + d.origin.state
: st === "slow" ? "slow — CDN " + d.state + ", " + d.kbps + " KB/s"
: st === "slow" ? "slow — CDN " + d.state + (d.kbps ? ", " + d.kbps + " KB/s" : "")
: "up"));
}
@@ -979,12 +982,12 @@ function renderStatusPanel(d) {
w.replaceChildren();
w.append(el("div", "sp-row", "upstream: " + d.origin.state
+ (d.origin.msg ? " (" + d.origin.msg + ")" : "")));
w.append(el("div", "sp-row", "CDN: " + d.state + " — " + d.kbps + " KB/s"));
w.append(el("div", "sp-row", "CDN: " + d.state + (d.kbps ? " — " + d.kbps + " KB/s" : "")));
const ch = d.hist || [];
if (ch.length > 1) {
w.append(sparkline(ch.map(s => s.kbps), d.threshold));
w.append(el("div", "sp-cap", "CDN, last " + _mins(ch)
+ " min (dashed = slow threshold " + Math.round(d.threshold) + " KB/s)"));
w.append(sparkline(ch.map(s => s.kbps), d.kbps ? d.threshold : null));
w.append(el("div", "sp-cap", "CDN, last " + _mins(ch) + " min"
+ (d.kbps ? " (dashed = slow threshold " + Math.round(d.threshold) + " KB/s)" : "")));
}
const oh = d.origin.hist || [];
if (oh.length > 1) {
@@ -1135,7 +1138,11 @@ async function play(ep_id, ep_no) {
branch: (!window.Hls || !Hls.isSupported()) ? (nativeOK !== "none" && nativeOK !== "" ? "native" : "none-throw") : "hlsjs",
hls_lib: typeof window.Hls, mse, native_hls: nativeOK, ua: navigator.userAgent});
} catch {}
if (!window.Hls || !Hls.isSupported()) {
if (s.hls === false) {
// progressive file (1anime's direct MP4): native <video>; the /stream
// proxy forwards the browser's Range requests, so seeking works
v.src = "/stream/" + s.token;
} else if (!window.Hls || !Hls.isSupported()) {
// No MSE (e.g. iOS < 17): fall back to the browser's NATIVE HLS player.
// iOS Safari plays standard m3u8 in <video> directly — same proxied URL,
// same-origin, so no CORS needed.
@@ -1215,7 +1222,7 @@ async function play(ep_id, ep_no) {
}
if (saved?.pos > 10) {
v.addEventListener("loadedmetadata", () => {
if (saved.pos < v.duration * 0.95) v.currentTime = saved.pos;
if (saved.pos < v.duration * DONE_RATIO) v.currentTime = saved.pos;
}, {once: true});
}
v.onerror = () => playerFail("media error");
+3
View File
@@ -35,6 +35,9 @@
route) with the git SHA at startup — a correct cache key per commit.
No hand-bumping; JSV in app.js reads the same value from its own URL. -->
<script src="hls.min.js"></script>
<!-- Watched threshold: server injects its DONE_RATIO source constant here;
app.js reads it as DONE_RATIO. Server uses the same value for its stats. -->
<script>window.DONE_RATIO = {{DONE_RATIO}};</script>
<script src="app.js?v={{VERSION}}"></script>
</body>
</html>
+133
View File
@@ -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"
+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")