Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d12ff79d29 | ||
|
|
e3ef51d141 | ||
|
|
1154203569 | ||
|
|
0cd79dd701 | ||
|
|
731f44b99c |
No files matched your search
@@ -5,6 +5,7 @@ fastapi==0.141.1
|
|||||||
uvicorn==0.52.4
|
uvicorn==0.52.4
|
||||||
curl_cffi==0.16.0
|
curl_cffi==0.16.0
|
||||||
itsdangerous==2.2.0
|
itsdangerous==2.2.0
|
||||||
|
pycryptodome==3.23.0
|
||||||
|
|
||||||
# tests
|
# tests
|
||||||
pytest==9.1.1
|
pytest==9.1.1
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ fi
|
|||||||
# ANIWEB_TRUST_XFF=1: prod traffic arrives via the local HTTPS proxy, so the
|
# 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
|
# 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).
|
# 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 $! > "$PIDF"
|
||||||
echo "ani-web on http://localhost:8321 (pid $(cat "$PIDF")) — use the HTTPS proxy to log in"
|
echo "ani-web on http://localhost:8321 (pid $(cat "$PIDF")) — use the HTTPS proxy to log in"
|
||||||
+61
-32
@@ -44,55 +44,59 @@ def _version():
|
|||||||
|
|
||||||
VERSION = _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": []}
|
_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
|
# origin (primary provider pages/API) health — separate host, separate failure
|
||||||
# mode (observed 2026-09-03: CDN fine while origin 503'd "Under Maintenance")
|
# mode (observed 2026-09-03: CDN fine while origin 503'd "Under Maintenance")
|
||||||
_ORIGIN = {"state": "unknown", "msg": "", "ts": 0.0, "hist": []}
|
_ORIGIN = {"state": "unknown", "msg": "", "ts": 0.0, "hist": []}
|
||||||
# ponytail: fixed threshold; if the CDN's normal throughput drifts below it
|
# kept for the /api/cdn response shape (the panel shows it only when a rate
|
||||||
# the pill sits on "slow" — tune via ANIWEB_CDN_KBPS, not a percentile.
|
# was actually measured; the probe now checks reachability, not throughput)
|
||||||
_CDN_THRESHOLD = float(os.environ.get("ANIWEB_CDN_KBPS", "40"))
|
_CDN_THRESHOLD = float(os.environ.get("ANIWEB_CDN_KBPS", "40"))
|
||||||
|
|
||||||
|
|
||||||
def _cdn_probe():
|
def _cdn_probe():
|
||||||
"""Background probe: pull 64KB from the CDN every 3 min and log state
|
"""Background probe: fetch ONE small playlist and log state flips, so
|
||||||
flips, so server.log shows when a degraded window ends (CDN RECOVERED).
|
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.
|
Probe target: ep_id 1 = One Piece E1 on the primary provider.
|
||||||
ponytail: single fixed episode; if it ever disappears, the probe logs
|
ponytail: single fixed episode; if it ever disappears, the probe logs
|
||||||
DEGRADED forever — swap the ID, don't build a picker."""
|
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
|
state = None
|
||||||
while True:
|
while True:
|
||||||
|
if time.time() - _LAST_PLAY > _IDLE_SECS:
|
||||||
|
time.sleep(60) # nobody's watching: don't burn upstream bandwidth
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
m = upstream.master_m3u8(1, "jpn")
|
m = upstream.master_m3u8(1, "jpn")
|
||||||
if not m:
|
if not m:
|
||||||
raise RuntimeError("no master m3u8")
|
raise RuntimeError("no master m3u8")
|
||||||
# hianime's CDN 403s without the embed-origin Referer (anidb needs none)
|
# hianime's CDN 403s without the embed-origin Referer (anidb needs none)
|
||||||
hdrs = upstream.stream_meta(m) or {}
|
hdrs = upstream.stream_meta(m) or {}
|
||||||
base = m.rsplit("/", 1)[0] + "/"
|
upstream._get(m, timeout=10, headers=hdrs)
|
||||||
v = first(upstream._get(m, timeout=10, headers=hdrs).text, base)
|
now = "OK"
|
||||||
# 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)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
kbps = 0.0
|
now = "DEGRADED"
|
||||||
now = "OK" if kbps >= _CDN_THRESHOLD else "DEGRADED"
|
|
||||||
if now != state:
|
if now != state:
|
||||||
print(f"[probe] CDN {now} ({kbps:.0f} KB/s)", flush=True)
|
print(f"[probe] CDN {now}", flush=True)
|
||||||
state = now
|
state = now
|
||||||
_CDN.update(state=now, kbps=round(kbps), ts=time.time())
|
_CDN.update(state=now, kbps=0, ts=time.time())
|
||||||
_CDN["hist"].append({"t": time.time(), "kbps": round(kbps)})
|
_CDN["hist"].append({"t": time.time(), "kbps": 0})
|
||||||
del _CDN["hist"][:-20]
|
del _CDN["hist"][:-20]
|
||||||
_origin_probe()
|
_origin_probe()
|
||||||
time.sleep(180)
|
time.sleep(_DEGRADED_RETRY if now == "DEGRADED" else 180)
|
||||||
|
|
||||||
|
|
||||||
def _origin_probe():
|
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"]:
|
if s["last_ts"] > a["last_seen"]:
|
||||||
a["last_seen"] = s["last_ts"]
|
a["last_seen"] = s["last_ts"]
|
||||||
a["cur_ep"] = s["last_ep"]
|
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
|
a["completed"] += 1
|
||||||
completed += 1
|
completed += 1
|
||||||
now = time.time()
|
now = time.time()
|
||||||
@@ -554,11 +558,16 @@ def api_episodes(anime_id: int):
|
|||||||
|
|
||||||
@app.get("/api/episode/{ep_id}/stream")
|
@app.get("/api/episode/{ep_id}/stream")
|
||||||
def api_stream(ep_id: int, lang: str = "jpn", request: Request = None):
|
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():
|
def go():
|
||||||
master = _upstream(upstream.master_m3u8, ep_id, lang)
|
master = _upstream(upstream.master_m3u8, ep_id, lang)
|
||||||
if not master:
|
if not master:
|
||||||
raise HTTPException(404, f"no {lang} stream for episode {ep_id}")
|
raise HTTPException(404, f"no {lang} stream for episode {ep_id}")
|
||||||
out = {"token": _b64(master)}
|
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
|
sub = upstream.subtitles(ep_id) # external .vtt (hianime); anidb bakes subs in
|
||||||
if sub:
|
if sub:
|
||||||
out["sub"] = _b64(sub)
|
out["sub"] = _b64(sub)
|
||||||
@@ -609,9 +618,11 @@ def _cache_put(url, ct, body):
|
|||||||
_STREAM_CACHE.popitem(last=False)
|
_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
|
"""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
|
import time
|
||||||
t0, t_first, buf, complete = time.time(), None, [], False
|
t0, t_first, buf, complete = time.time(), None, [], False
|
||||||
try:
|
try:
|
||||||
@@ -623,7 +634,7 @@ def _seg_stream(r, url, ct):
|
|||||||
complete = True
|
complete = True
|
||||||
finally:
|
finally:
|
||||||
r.close()
|
r.close()
|
||||||
if complete and buf:
|
if complete and buf and cache:
|
||||||
_cache_put(url, ct, b"".join(buf))
|
_cache_put(url, ct, b"".join(buf))
|
||||||
# observability: a slow segment is the fragLoadTimeOut culprit. Log
|
# observability: a slow segment is the fragLoadTimeOut culprit. Log
|
||||||
# only the slow ones so normal playback stays quiet. TTFB>3s or
|
# 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}")
|
@app.get("/stream/{token}")
|
||||||
def stream(token: str):
|
def stream(token: str, request: Request = None):
|
||||||
url = _url(token)
|
url = _url(token)
|
||||||
p = urlparse(url)
|
p = urlparse(url)
|
||||||
# ponytail: token is plain base64, so allowlist the host — otherwise any
|
# 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)
|
return Response(hit[1], media_type=hit[0], headers=_STREAM_CACHE_HDRS)
|
||||||
if url.endswith(".m3u8"):
|
if url.endswith(".m3u8"):
|
||||||
return _stream_playlist(url, meta)
|
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
|
# segment miss: try parallel byte-range lanes first (4 concurrent fetches
|
||||||
# beat per-connection CDN throttling); ANY failure falls back to the
|
# beat per-connection CDN throttling); ANY failure falls back to the
|
||||||
# single streaming path, so this can never be worse than before
|
# single streaming path, so this can never be worse than before
|
||||||
@@ -692,6 +716,9 @@ def stream(token: str):
|
|||||||
name = url.rsplit("/", 1)[-1][:40]
|
name = url.rsplit("/", 1)[-1][:40]
|
||||||
print(f"[stream-slow] {name} parallel total={time.time()-t0:.1f}s "
|
print(f"[stream-slow] {name} parallel total={time.time()-t0:.1f}s "
|
||||||
f"bytes={len(body)}", flush=True)
|
f"bytes={len(body)}", flush=True)
|
||||||
|
# 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)
|
_cache_put(url, ct, body)
|
||||||
return Response(body, media_type=ct, headers=_STREAM_CACHE_HDRS)
|
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)
|
@app.get("/index.html", include_in_schema=False)
|
||||||
def index():
|
def index():
|
||||||
# index.html is served (not mounted) so ?v={{VERSION}} becomes the git
|
# index.html is served (not mounted) so ?v={{VERSION}} becomes the git
|
||||||
# SHA at startup; other statics go through the mount below
|
# SHA and {{DONE_RATIO}} the watched threshold at startup; other statics
|
||||||
return HTMLResponse(_INDEX_HTML.replace("{{VERSION}}", VERSION))
|
# 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()
|
_INDEX_HTML = (ROOT / "static" / "index.html").read_text()
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ times until a CDN domain actually serves us.
|
|||||||
"""
|
"""
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from ..upstream import _cached, _get
|
from ..upstream import _cached, _get, megaplay_sources
|
||||||
|
|
||||||
BASE = "https://anikototv.to"
|
BASE = "https://anikototv.to"
|
||||||
XHR = {"X-Requested-With": "XMLHttpRequest"}
|
XHR = {"X-Requested-With": "XMLHttpRequest"}
|
||||||
@@ -100,8 +100,8 @@ def _player_file(embed_url):
|
|||||||
if not m:
|
if not m:
|
||||||
return None
|
return None
|
||||||
host = re.match(r'https?://([^/]+)/', embed_url).group(1)
|
host = re.match(r'https?://([^/]+)/', embed_url).group(1)
|
||||||
d = _get(f"https://{host}/stream/getSourcesNew?id={m.group(1)}",
|
d = megaplay_sources(_get(f"https://{host}/stream/getSourcesNew?id={m.group(1)}",
|
||||||
headers={"Referer": embed_url, "X-Requested-With": "XMLHttpRequest"}).json()
|
headers={"Referer": embed_url, "X-Requested-With": "XMLHttpRequest"}).json())
|
||||||
return (d.get("sources") or {}).get("file")
|
return (d.get("sources") or {}).get("file")
|
||||||
|
|
||||||
|
|
||||||
@@ -117,7 +117,8 @@ def _master_m3u8(ep_id, lang="eng"):
|
|||||||
url = (d.get("result") or {}).get("url")
|
url = (d.get("result") or {}).get("url")
|
||||||
if not url:
|
if not url:
|
||||||
continue
|
continue
|
||||||
for _ in range(2): # re-roll: CDN domains rotate, most 403 our IP
|
# ponytail: one attempt per server — the enc payload is deterministic,
|
||||||
|
# re-rolling would just spam the provider
|
||||||
try:
|
try:
|
||||||
f = _player_file(url)
|
f = _player_file(url)
|
||||||
if not f:
|
if not f:
|
||||||
|
|||||||
@@ -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
@@ -13,7 +13,7 @@ import json
|
|||||||
import re
|
import re
|
||||||
from urllib.parse import urljoin, urlparse
|
from urllib.parse import urljoin, urlparse
|
||||||
|
|
||||||
from ..upstream import _cached, _get
|
from ..upstream import _cached, _get, megaplay_master
|
||||||
|
|
||||||
BASE = "https://hianime.at"
|
BASE = "https://hianime.at"
|
||||||
KEY = b"otaku-embed-v1"
|
KEY = b"otaku-embed-v1"
|
||||||
@@ -88,9 +88,8 @@ def _embed_payload(embed):
|
|||||||
"""embed url -> (master_m3u8, [{label, src} subs], referer_or_None).
|
"""embed url -> (master_m3u8, [{label, src} subs], referer_or_None).
|
||||||
|
|
||||||
Two player chains have appeared: zokoanime (window.__P blob,
|
Two player chains have appeared: zokoanime (window.__P blob,
|
||||||
base64+XOR) and megaplay (page needs the hianime referer; data-id ->
|
base64+XOR — hianime-specific) and megaplay (shared: upstream.
|
||||||
getSourcesNew; CDN domains rotate and most 403 us, so re-roll until
|
megaplay_master, used by animekai too)."""
|
||||||
one serves)."""
|
|
||||||
page = _get(embed, timeout=15).text
|
page = _get(embed, timeout=15).text
|
||||||
blob = re.search(r'window\.__P="([^"]+)"', page)
|
blob = re.search(r'window\.__P="([^"]+)"', page)
|
||||||
if blob:
|
if blob:
|
||||||
@@ -98,29 +97,7 @@ def _embed_payload(embed):
|
|||||||
subs = [{"label": (x or {}).get("label", ""), "src": (x or {}).get("src")}
|
subs = [{"label": (x or {}).get("label", ""), "src": (x or {}).get("src")}
|
||||||
for x in j.get("subtitles") or []]
|
for x in j.get("subtitles") or []]
|
||||||
return j.get("src"), subs, re.match(r'https?://[^/]+', embed).group(0) + "/"
|
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
|
return megaplay_master(embed, BASE + "/")
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def _master_m3u8(ep_id, lang="eng"):
|
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"
|
want = "sub" if lang in ("jpn", "sub") else "dub"
|
||||||
# HD-1 has rotated between player chains (zokoanime blob, megaplay);
|
# HD-1 has rotated between player chains (zokoanime blob, megaplay);
|
||||||
# try every listed server of the wanted type until one resolves
|
# try every listed server of the wanted type until one resolves
|
||||||
|
errs = []
|
||||||
for typ in (want, "sub") if want == "dub" else (want,):
|
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'data-type="{typ}"[\s\S]*?data-server-name="([^"]+)"'
|
||||||
rf'[\s\S]*?data-hash="([^"]+)"', t):
|
rf'[\s\S]*?data-hash="([^"]+)"', t):
|
||||||
embed = base64.b64decode(h).decode()
|
try:
|
||||||
master, subs, refr = _embed_payload(embed)
|
master, subs, refr = _embed_payload(base64.b64decode(h).decode())
|
||||||
|
except Exception as e:
|
||||||
|
errs.append(f"{name}: {e}")
|
||||||
|
continue
|
||||||
if not master:
|
if not master:
|
||||||
|
errs.append(f"{name}: no source")
|
||||||
continue
|
continue
|
||||||
REFERER = refr
|
REFERER = refr
|
||||||
# prove the CDN serves us and register the hosts so the /stream proxy
|
# 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"):
|
if pick and pick.get("src"):
|
||||||
SUBS[ep_id] = pick["src"]
|
SUBS[ep_id] = pick["src"]
|
||||||
return master
|
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:".
|
# Provider interface. Cache keys are namespaced with "hianime:".
|
||||||
|
|||||||
@@ -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;
|
impersonation, TTL cache) live here so every provider gets them for free;
|
||||||
they work on any URL, not just anidb's.
|
they work on any URL, not just anidb's.
|
||||||
"""
|
"""
|
||||||
|
import base64
|
||||||
import importlib
|
import importlib
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
from Crypto.Cipher import AES
|
||||||
from curl_cffi import requests
|
from curl_cffi import requests
|
||||||
|
|
||||||
H = dict(impersonate="chrome", timeout=20)
|
H = dict(impersonate="chrome", timeout=20)
|
||||||
@@ -55,6 +58,59 @@ def _cached(key, ttl, fn, *a, **kw):
|
|||||||
def clear_cache():
|
def clear_cache():
|
||||||
_CACHE.clear()
|
_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):
|
def _get(url, **kw):
|
||||||
# ponytail: one retry (300ms) on transient upstream errors (Cloudflare
|
# ponytail: one retry (300ms) on transient upstream errors (Cloudflare
|
||||||
|
|||||||
+21
-14
@@ -10,6 +10,9 @@ const BASE = (() => {
|
|||||||
return p.endsWith("/") ? p : p.slice(0, p.lastIndexOf("/") + 1);
|
return p.endsWith("/") ? p : p.slice(0, p.lastIndexOf("/") + 1);
|
||||||
})();
|
})();
|
||||||
const main = document.getElementById("main");
|
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;
|
let hls = null, detail = null, lang = "jpn", seasonId = null;
|
||||||
|
|
||||||
async function api(path) {
|
async function api(path) {
|
||||||
@@ -190,7 +193,7 @@ const homeCard = x => {
|
|||||||
c.append(poster(x.poster, x.title));
|
c.append(poster(x.poster, x.title));
|
||||||
let meta = `E${x.ep} \u00b7 ${x.lang === "eng" ? "dub" : "sub"}`;
|
let meta = `E${x.ep} \u00b7 ${x.lang === "eng" ? "dub" : "sub"}`;
|
||||||
if (done) meta += " \u00b7 \u2713";
|
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`;
|
else if (hold) meta += ` \u00b7 ${Math.round(Date.now() / 1000 / 86400 - x.t / 86400)}d ago`;
|
||||||
const info = el("div", "mini-info");
|
const info = el("div", "mini-info");
|
||||||
info.append(el("div", "card-name", x.title), el("div", "mini-meta", meta));
|
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"),
|
body.append(el("div", "hero-kicker", "continue watching"),
|
||||||
el("h2", null, hero.title),
|
el("h2", null, hero.title),
|
||||||
el("div", "muted", `Ep ${hero.ep} · ${hero.lang === "eng" ? "dub" : "sub"} · ${fmtPos(hero.pos)} in` +
|
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)}`));
|
` · ${timeago(hero.t)}`));
|
||||||
const p = bar(hero);
|
const p = bar(hero);
|
||||||
if (p) body.append(p);
|
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");
|
const go = el("button", "btn resume", heroDone ? "\u25b6 Next ep" : "\u25b6 Resume");
|
||||||
go.onclick = () => location.href = histHref(hero);
|
go.onclick = () => location.href = histHref(hero);
|
||||||
right.append(body, go);
|
right.append(body, go);
|
||||||
@@ -503,7 +506,7 @@ async function showAnime(slug_id, opts = {}) {
|
|||||||
for (const x of await api("/api/history?all=1")) {
|
for (const x of await api("/api/history?all=1")) {
|
||||||
if (x.slug_id !== slug_id || x.season_id !== seasonId) continue;
|
if (x.slug_id !== slug_id || x.season_id !== seasonId) continue;
|
||||||
started = true;
|
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)));
|
else if (x.dur > 0) epProg.set(String(x.ep_id), Math.min(100, Math.round(100 * x.pos / x.dur)));
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
@@ -529,7 +532,7 @@ async function showAnime(slug_id, opts = {}) {
|
|||||||
function langLabel() { return lang === "eng" ? "▶ dub" : "▶ sub"; }
|
function langLabel() { return lang === "eng" ? "▶ dub" : "▶ sub"; }
|
||||||
|
|
||||||
// ---------- history page (full list + delete + client-side filter) ----------
|
// ---------- 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");
|
: (x.t && Date.now() / 1000 - x.t > 7 * 86400 ? "hold" : "watching");
|
||||||
function remapBoard() {
|
function remapBoard() {
|
||||||
// home: one stale card per stale anime (latest row), above the hero, so a
|
// 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(".d").className = "d" + (st ? " " + st : "");
|
||||||
p.querySelector("span").textContent =
|
p.querySelector("span").textContent =
|
||||||
!st ? "…" : st === "down" ? "down"
|
!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 : "")
|
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"
|
p.setAttribute("aria-label", "status: " + (!st ? "checking"
|
||||||
: st === "down" ? "down — upstream " + d.origin.state
|
: 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"));
|
: "up"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -979,12 +982,12 @@ function renderStatusPanel(d) {
|
|||||||
w.replaceChildren();
|
w.replaceChildren();
|
||||||
w.append(el("div", "sp-row", "upstream: " + d.origin.state
|
w.append(el("div", "sp-row", "upstream: " + d.origin.state
|
||||||
+ (d.origin.msg ? " (" + d.origin.msg + ")" : "")));
|
+ (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 || [];
|
const ch = d.hist || [];
|
||||||
if (ch.length > 1) {
|
if (ch.length > 1) {
|
||||||
w.append(sparkline(ch.map(s => s.kbps), d.threshold));
|
w.append(sparkline(ch.map(s => s.kbps), d.kbps ? d.threshold : null));
|
||||||
w.append(el("div", "sp-cap", "CDN, last " + _mins(ch)
|
w.append(el("div", "sp-cap", "CDN, last " + _mins(ch) + " min"
|
||||||
+ " min (dashed = slow threshold " + Math.round(d.threshold) + " KB/s)"));
|
+ (d.kbps ? " (dashed = slow threshold " + Math.round(d.threshold) + " KB/s)" : "")));
|
||||||
}
|
}
|
||||||
const oh = d.origin.hist || [];
|
const oh = d.origin.hist || [];
|
||||||
if (oh.length > 1) {
|
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",
|
branch: (!window.Hls || !Hls.isSupported()) ? (nativeOK !== "none" && nativeOK !== "" ? "native" : "none-throw") : "hlsjs",
|
||||||
hls_lib: typeof window.Hls, mse, native_hls: nativeOK, ua: navigator.userAgent});
|
hls_lib: typeof window.Hls, mse, native_hls: nativeOK, ua: navigator.userAgent});
|
||||||
} catch {}
|
} 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.
|
// 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,
|
// iOS Safari plays standard m3u8 in <video> directly — same proxied URL,
|
||||||
// same-origin, so no CORS needed.
|
// same-origin, so no CORS needed.
|
||||||
@@ -1215,7 +1222,7 @@ async function play(ep_id, ep_no) {
|
|||||||
}
|
}
|
||||||
if (saved?.pos > 10) {
|
if (saved?.pos > 10) {
|
||||||
v.addEventListener("loadedmetadata", () => {
|
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});
|
}, {once: true});
|
||||||
}
|
}
|
||||||
v.onerror = () => playerFail("media error");
|
v.onerror = () => playerFail("media error");
|
||||||
|
|||||||
@@ -35,6 +35,9 @@
|
|||||||
route) with the git SHA at startup — a correct cache key per commit.
|
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. -->
|
No hand-bumping; JSV in app.js reads the same value from its own URL. -->
|
||||||
<script src="hls.min.js"></script>
|
<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>
|
<script src="app.js?v={{VERSION}}"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -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