Provider layer + hianime source (primary)
- providers/{anidb,anikoto,hianime}.py: per-source modules behind the
ANI_SOURCE fallback dispatch (upstream.py _call/_providers)
- hianime.py: pystardust/ani-cli#1897 flow — search/episode-list/
servers API, HD-1 hash -> base64 embed url, window.__P blob
(base64 + XOR 'otaku-embed-v1') -> master m3u8; registers CDN hosts
and the required Referer via stream_meta()
- upstream.py: get_stream/fetch_parallel accept extra headers;
default source order hianime,anidb,anikoto (anidb.app is 503)
- app.py: /stream allowlist accepts provider-registered hosts and
sends their Referer; status probe follows the primary provider
(hianime variant URIs are relative to the variant path)
- app.js: error-time position save floors at furthest point reached
(hls.js resets currentTime to 0 before playerFail on fatal errors)
- tests: default-order + hianime import checks; live browser suite green
This commit is contained in:
1 parent
ff8311c1a8
commit
834ca80a8c
8 files changed
+553
-123
No files matched your search
+26
-19
@@ -45,15 +45,15 @@ VERSION = _version()
|
||||
|
||||
# latest probe result + last 20 samples (~1h at 3-min spacing), for GET /api/cdn
|
||||
_CDN = {"state": "unknown", "kbps": 0.0, "ts": 0.0, "hist": []}
|
||||
# origin (anidb.app pages/API) health — separate host, separate failure mode
|
||||
# (observed 2026-09-03: CDN fine while origin 503'd "Under Maintenance")
|
||||
# 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}
|
||||
|
||||
|
||||
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).
|
||||
Probe target: any valid episode (36019 = JJK S2 E25).
|
||||
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):
|
||||
@@ -64,14 +64,18 @@ def _cdn_probe():
|
||||
state = None
|
||||
while True:
|
||||
try:
|
||||
m = upstream.master_m3u8(36019, "jpn")
|
||||
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).text, base)
|
||||
seg = first(upstream._get(v, timeout=10).text, base)
|
||||
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={"Range": "bytes=0-65535"},
|
||||
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:
|
||||
@@ -92,8 +96,7 @@ def _origin_probe():
|
||||
track OK/DOWN — this is the host that 503s 'Under Maintenance'.
|
||||
ponytail: one URL, no history; the log line + ts tell the when."""
|
||||
try:
|
||||
r = upstream._get("https://anidb.app/anime/jujutsu-kaisen-season-2-2554",
|
||||
timeout=10)
|
||||
r = upstream._get("https://hianime.at/watch/one-piece-1", timeout=10)
|
||||
state, msg = "OK", f"http {r.status_code}"
|
||||
except Exception as e:
|
||||
state, msg = "DOWN", str(e)
|
||||
@@ -606,12 +609,12 @@ def _seg_stream(r, url, ct):
|
||||
f"bytes={sum(len(b) for b in buf)} complete={complete}", flush=True)
|
||||
|
||||
|
||||
def _stream_playlist(url):
|
||||
def _stream_playlist(url, meta=None):
|
||||
# buffered: the full body is needed to rewrite URIs through the proxy
|
||||
# ponytail: 12s < hls.js's fragLoadingTimeOut (20s) — on a hang the proxy
|
||||
# 502s first, so hls.js retries the frag (backoff) instead of freezing
|
||||
# on a fatal timeout race
|
||||
r = _upstream(upstream._get, url, timeout=12)
|
||||
r = _upstream(upstream._get, url, timeout=12, **({"headers": meta} if meta else {}))
|
||||
body = r.content
|
||||
if "mpegurl" in r.headers.get("content-type", ""):
|
||||
body = _rewrite_m3u8(body.decode(), url).encode()
|
||||
@@ -622,13 +625,15 @@ def _stream_playlist(url):
|
||||
|
||||
@app.get("/stream/{token}")
|
||||
def stream(token: str):
|
||||
# ponytail: token is plain base64, so allowlist the host — otherwise any
|
||||
# logged-in user could use this as a GET proxy to arbitrary URLs (SSRF).
|
||||
# Verified 2026-08: master, variant and segment URLs all live on
|
||||
# hls.anidb.app; if the CDN moves, this 400s loudly and the list updates.
|
||||
url = _url(token)
|
||||
p = urlparse(url)
|
||||
if p.scheme != "https" or p.netloc != "hls.anidb.app":
|
||||
# ponytail: token is plain base64, so allowlist the host — otherwise any
|
||||
# logged-in user could use this as a GET proxy to arbitrary URLs (SSRF).
|
||||
# anidb's CDN is static (hls.anidb.app); hianime's CDN rotates, so its
|
||||
# provider self-registers resolved hosts and supplies the Referer the
|
||||
# CDN demands — stream_meta() non-None IS the allowlist entry.
|
||||
meta = upstream.stream_meta(url) if p.scheme == "https" else None
|
||||
if p.scheme != "https" or (p.netloc != "hls.anidb.app" and not meta):
|
||||
raise HTTPException(400, "bad stream token")
|
||||
# cache hit (a playlist, or a segment downloaded earlier): serve at once
|
||||
hit = _STREAM_CACHE.get(url)
|
||||
@@ -636,15 +641,17 @@ def stream(token: str):
|
||||
_STREAM_CACHE.move_to_end(url)
|
||||
return Response(hit[1], media_type=hit[0], headers=_STREAM_CACHE_HDRS)
|
||||
if url.endswith(".m3u8"):
|
||||
return _stream_playlist(url)
|
||||
return _stream_playlist(url, meta)
|
||||
# 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
|
||||
t0 = time.time()
|
||||
try:
|
||||
body, ct = _upstream(upstream.fetch_parallel, url)
|
||||
body, ct = _upstream(upstream.fetch_parallel, url,
|
||||
**({"headers": meta} if meta else {}))
|
||||
except HTTPException:
|
||||
r = _upstream(upstream.get_stream, url, timeout=12)
|
||||
r = _upstream(upstream.get_stream, url, timeout=12,
|
||||
**({"headers": meta} if meta else {}))
|
||||
ct = r.headers.get("content-type", "application/octet-stream")
|
||||
if time.time() - t0 > 8:
|
||||
name = url.rsplit("/", 1)[-1][:40]
|
||||
|
||||
Reference in new issue
Block a user