diff --git a/server/app.py b/server/app.py
index 828984f..3523936 100644
--- a/server/app.py
+++ b/server/app.py
@@ -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]
diff --git a/server/providers/__init__.py b/server/providers/__init__.py
new file mode 100644
index 0000000..75bddbe
--- /dev/null
+++ b/server/providers/__init__.py
@@ -0,0 +1 @@
+"""providers/ — anime source providers. See upstream.py for the interface."""
diff --git a/server/providers/anidb.py b/server/providers/anidb.py
new file mode 100644
index 0000000..d45f42d
--- /dev/null
+++ b/server/providers/anidb.py
@@ -0,0 +1,89 @@
+"""providers/anidb.py — anidb.app source (primary).
+
+Sits behind Cloudflare: plain curl gets 403. All requests go through the
+shared `_get` (curl_cffi Chrome impersonation). Parsers are regex against
+live markup — they break here, in one place, when the layout changes.
+"""
+import re
+
+from ..upstream import _cached, _get
+
+BASE = "https://anidb.app"
+
+_ANIME_LINK = re.compile(r'anime/([a-z0-9-]+)-(\d+)"[^>]*title="([^"]*)"')
+
+
+def _search(q):
+ """Browse page -> [{id, slug_id, name, poster}]"""
+ html = _get(f"{BASE}/browse", params={"q": q}).text
+ out, seen = [], set()
+ for m in re.finditer(
+ r']*title="([^"]*)"[^>]*>(.*?)',
+ html, re.S):
+ slug_id, name, block = m.group(1), m.group(2), m.group(3)
+ if slug_id in seen:
+ continue
+ seen.add(slug_id)
+ img = re.search(r' {name, synopsis, mal_id, poster, seasons}"""
+ p = _get(f"{BASE}/anime/{slug_id}").text
+ mal = re.search(r'myanimelist\.net/anime/(\d+)', p)
+ syn = re.search(r'>Synopsis.*?
]*>(.*?)
', p, re.S) + h1 = re.search(r']*>(.*?)
', p, re.S) - h1 = re.search(r'