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:
jozamudi committed 2026-09-07 01:38:53 +00:00
1 parent ff8311c1a8
commit 834ca80a8c
8 files changed
+553 -123

No files matched your search

+1
View File
@@ -0,0 +1 @@
"""providers/ — anime source providers. See upstream.py for the interface."""
+89
View File
@@ -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'<a href="https://anidb\.app/anime/([a-z0-9-]+-\d+)"[^>]*title="([^"]*)"[^>]*>(.*?)</a>',
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'<img src="([^"]+)"', block)
out.append({"id": int(slug_id.rsplit("-", 1)[1]), "slug_id": slug_id,
"name": name, "poster": img.group(1) if img else None})
return out
def _details(slug_id):
"""Details page -> {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</h2>.*?<p[^>]*>(.*?)</p>', p, re.S)
h1 = re.search(r'<h1[^>]*>(.*?)</h1>', p, re.S)
post = re.search(r'src="(https://cdn\.xlsbox\.com/poster/[^"\']+)"', p)
seasons, seen = [], set()
i = p.find(">Seasons<")
if i > 0:
cnt = re.search(r">(\d+) entries in this series<", p[i:i + 400])
n = int(cnt.group(1)) if cnt else 10 # fall back to cap if count markup changes
for m in _ANIME_LINK.finditer(p[i:i + 20000]):
sid = f"{m.group(1)}-{m.group(2)}"
if sid in seen:
continue
seen.add(sid)
seasons.append({"slug_id": sid, "name": m.group(3), "id": int(m.group(2))})
if len(seasons) == n:
break
return {"id": int(slug_id.rsplit("-", 1)[1]),
"name": h1.group(1).strip() if h1 else None,
"synopsis": re.sub(r"<[^>]+>", "", syn.group(1)).strip() if syn else None,
"mal_id": int(mal.group(1)) if mal else None,
"poster": post.group(1) if post else None,
"seasons": seasons}
def _episodes(anime_id):
"""JSON API -> [{id, number, filler}]"""
d = _get(f"{BASE}/api/frontend/anime/{anime_id}/episodes").json()
return [{"id": e["id"], "number": e["number"], "filler": bool(e.get("filler"))}
for e in d["episodes"]]
def _languages(ep_id):
"""JSON API -> [{code, name, embed_url}] (jpn=sub, eng=dub)"""
return _get(f"{BASE}/api/frontend/episode/{ep_id}/languages").json()["languages"]
def _master_m3u8(ep_id, lang="eng"):
"""Embed page -> 'file: <master m3u8>' URL (None if unavailable)"""
for l in languages(ep_id):
if l["code"] == lang:
m = re.search(r"file: '([^']*)'", _get(l["embed_url"]).text)
if m:
return m.group(1)
return None
# Provider interface. Cache keys are namespaced with "anidb:" so a shared
# cache dict can't leak another provider's value into this one.
search = lambda q: _cached(f"anidb:search:{q.lower()}", 300, _search, q)
details = lambda s: _cached(f"anidb:details:{s}", 3600, _details, s)
episodes = lambda i: _cached(f"anidb:episodes:{i}", 300, _episodes, i) # 5m: airing shows gain eps
languages = lambda e: _cached(f"anidb:languages:{e}", 3600, _languages, e)
master_m3u8 = lambda e, l="eng": _cached(f"anidb:master:{e}:{l}", 3600, _master_m3u8, e, l)
+137
View File
@@ -0,0 +1,137 @@
"""providers/anikoto.py — anikototv.to (AniKoto / Hianime) fallback source.
Flow: search page -> watch page (numeric anime id) -> ajax episode list
(per-episode token) -> ajax server list (sub/dub groups) -> ajax/server?get=
(MegaPlay embed page) -> getSourcesNew?id= (master m3u8).
MegaPlay's CDN domains rotate per getSources call and some (e.g. norami.top)
hard-403 non-browser clients, so _master_m3u8 re-rolls the playlist a few
times until a CDN domain actually serves us.
"""
import re
from ..upstream import _cached, _get
BASE = "https://anikototv.to"
XHR = {"X-Requested-With": "XMLHttpRequest"}
_MEGAPLAY_ID = re.compile(r'data-id="(\d+)"')
def _search(q):
html = _get(f"{BASE}/search", params={"keyword": q}).text
out, seen = [], set()
for m in re.finditer(
r'href="https://anikototv\.to/watch/([a-z0-9-]+)[^"]*"[^>]*>\s*([^<]{2,80})</a>',
html):
slug, name = m.group(1), m.group(2).strip()
if slug in seen:
continue
seen.add(slug)
imgs = re.findall(r'<img src="(https://cdn\.anipixcdn\.co/[^"]+)"',
html[max(0, m.start() - 600):m.start()])
out.append({"id": 0, "slug_id": slug, "name": name,
"poster": imgs[-1] if imgs else None})
return out
def _details(slug):
t = _get(f"{BASE}/watch/{slug}").text
h1 = re.search(r'<h1[^>]*>(.*?)</h1>', t, re.S)
syn = re.search(r'class="synopsis[^"]*"[\s\S]*?class="content"[\s\S]*?>(.*?)</div>', t)
post = re.search(r'(https://cdn\.anipixcdn\.co/thumbnail/[^"\']+)', t)
aid = re.search(r'data-anime-id="(\d+)"', t)
return {"id": int(aid.group(1)) if aid else 0,
"name": re.sub(r"<[^>]+>", "", h1.group(1)).strip() if h1 else None,
"synopsis": re.sub(r"<[^>]+>", "", syn.group(1)).strip() if syn else None,
"mal_id": None,
"poster": post.group(1) if post else None,
"seasons": []}
def _ep_entries(anime_id):
d = _get(f"{BASE}/ajax/episode/list/{anime_id}", headers=XHR).json()
out = []
for tag in re.findall(r'<a[^>]+>', d.get("result") or ""):
num = re.search(r'data-num="(\d+)"', tag)
tok = re.search(r'data-ids="([^"]+)"', tag)
if num and tok:
out.append({"num": int(num.group(1)), "token": tok.group(1),
"sub": 'data-sub="1"' in tag, "dub": 'data-dub="1"' in tag})
return out
def _ep_entry(ep_id):
# ep_id encodes (anime_id, num): anime_id * 1000 + num (num < 1000)
anime_id, num = divmod(ep_id, 1000)
return next((e for e in _ep_entries(anime_id) if e["num"] == num), None)
def _episodes(anime_id):
return [{"id": anime_id * 1000 + e["num"], "number": e["num"], "filler": False}
for e in _ep_entries(anime_id)]
def _languages(ep_id):
e = _ep_entry(ep_id)
if not e:
return []
out = []
if e["sub"]:
out.append({"code": "jpn", "name": "Sub", "embed_url": "sub"})
if e["dub"]:
out.append({"code": "eng", "name": "Dub", "embed_url": "dub"})
return out
def _server_link_ids(token, want):
d = _get(f"{BASE}/ajax/server/list", params={"servers": token}, headers=XHR).json()
res = d.get("result") or ""
for m in re.finditer(r'data-type="(\w+)"[\s\S]*?<ul>([\s\S]*?)</ul>', res):
if m.group(1) == want:
return re.findall(r'data-link-id="([^"]+)"', m.group(2))
return re.findall(r'data-link-id="([^"]+)"', res) # no group split: take all
def _player_file(embed_url):
"""MegaPlay AND its re-skins (vidtube etc.): same player libs, so the
same getSourcesNew endpoint on the embed's own host."""
page = _get(embed_url, timeout=15)
m = _MEGAPLAY_ID.search(page.text)
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()
return (d.get("sources") or {}).get("file")
def _master_m3u8(ep_id, lang="eng"):
e = _ep_entry(ep_id)
if not e:
return None
want = "sub" if lang in ("jpn", "sub") else "dub"
ids = _server_link_ids(e["token"], want) or _server_link_ids(e["token"], "sub")
errs = []
for lid in ids[:3]:
d = _get(f"{BASE}/ajax/server", params={"get": lid}, headers=XHR).json()
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])
raise RuntimeError("anikoto: no working server: " + "; ".join(errs[:3]))
# Provider interface (cache keys namespaced "anikoto:").
search = lambda q: _cached(f"anikoto:search:{q.lower()}", 300, _search, q)
details = lambda s: _cached(f"anikoto:details:{s}", 3600, _details, s)
episodes = lambda i: _cached(f"anikoto:episodes:{i}", 300, _episodes, i)
languages = lambda e: _cached(f"anikoto:languages:{e}", 3600, _languages, e)
master_m3u8 = lambda e, l="eng": _cached(f"anikoto:master:{e}:{l}", 3600, _master_m3u8, e, l)
+126
View File
@@ -0,0 +1,126 @@
"""providers/hianime.py — hianime.at source (pystardust/ani-cli#1897 flow).
Flow: search page -> ajax episode list (numeric anime id) -> ajax server list
(sub/dub groups) -> HD-1 hash (base64 embed url) -> embed page
(window.__P = base64(json XOR "otaku-embed-v1")) -> master m3u8.
The CDN hard-403s without the embed origin as Referer, so _master_m3u8
records it in REFERER and registers the playlist hosts in STREAM_HOSTS;
the /stream proxy reads both via stream_meta().
"""
import base64
import json
import re
from urllib.parse import urljoin, urlparse
from ..upstream import _cached, _get
BASE = "https://hianime.at"
KEY = b"otaku-embed-v1"
REFERER = None # embed origin, set when a master playlist is resolved
STREAM_HOSTS = set() # playlist/segment hosts the /stream proxy may fetch
def _search(q):
html = _get(f"{BASE}/search", params={"keyword": q}).text
# the top-10 sidebar repeats the result markup; cut it off (ani-cli#1897)
html = html.split('id="main-sidebar"')[0]
out, seen = [], set()
for m in re.finditer(
r'<h3 class="film-name">\s*<a href="[^"]*/([^"/]+)"[^>]*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[m.start():m.start() + 3000])
out.append({"id": int(slug.rsplit("-", 1)[1]) if slug.rsplit("-", 1)[1].isdigit() else 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}").text
# no details/synopsis page: og tags carry name + poster
title = re.search(r'property="og:title" content="([^"]+)"', t)
post = re.search(r'property="og:image" content="([^"]+)"', t)
name = re.sub(r'\s*Episode\s*\d+.*$', "", title.group(1)) if title else None
name = re.sub(r'\s*\|.*$', "", name).strip() if name else None
return {"id": int(slug.rsplit("-", 1)[1]) if slug.rsplit("-", 1)[1].isdigit() else 0,
"name": name, "synopsis": None, "mal_id": None,
"poster": post.group(1) if post else None,
"seasons": []} # hianime has no seasons block (ani-cli#1897 re-searches)
def _episodes(anime_id):
t = _get(f"{BASE}/api/theme/episode/list/{anime_id}").text.replace("\\", "")
out = []
for num, epid in re.findall(r'data-number="([^"]*)"[\s\S]*?data-id="(\d+)"', t):
out.append({"id": int(epid), "number": int(num) if num.isdigit() else num,
"filler": False})
return out
def _servers(ep_id):
return _get(f"{BASE}/api/theme/episode/servers",
params={"episodeId": ep_id}).text.replace("\\", "")
def _languages(ep_id):
t = _servers(ep_id)
out = []
if re.search(r'data-type="sub"[\s\S]*?data-server-name="HD-1"', t):
out.append({"code": "jpn", "name": "Sub", "embed_url": "sub"})
if re.search(r'data-type="dub"[\s\S]*?data-server-name="HD-1"', t):
out.append({"code": "eng", "name": "Dub", "embed_url": "dub"})
return out
def _deobfuscate(blob):
raw = base64.b64decode(blob)
return json.loads(bytes(b ^ KEY[i % len(KEY)] for i, b in enumerate(raw)))
def _master_m3u8(ep_id, lang="eng"):
global REFERER, STREAM_HOSTS
t = _servers(ep_id)
want = "sub" if lang in ("jpn", "sub") else "dub"
for typ in (want, "sub") if want == "dub" else (want,):
m = re.search(rf'data-type="{typ}"[\s\S]*?data-server-name="HD-1"'
r'[\s\S]*?data-hash="([^"]+)"', t)
if not m:
continue
embed = base64.b64decode(m.group(1)).decode()
REFERER = re.match(r'https?://[^/]+', embed).group(0) + "/"
j = _deobfuscate(re.search(r'window\.__P="([^"]+)"',
_get(embed, timeout=15).text).group(1))
master = j.get("src")
if not master:
continue
# prove the CDN serves us (it 403s without the referer) and register
# the hosts so the /stream proxy allowlist survives CDN rotation
r = _get(master, headers={"Referer": REFERER}, timeout=10)
hosts = {urlparse(master).netloc}
for line in r.text.splitlines():
if line and not line.startswith("#"):
hosts.add(urlparse(urljoin(master, line)).netloc)
STREAM_HOSTS |= hosts
return master
return None
# Provider interface. Cache keys are namespaced with "hianime:".
search = lambda q: _cached(f"hianime:search:{q.lower()}", 300, _search, q)
details = lambda s: _cached(f"hianime:details:{s}", 3600, _details, s)
episodes = lambda i: _cached(f"hianime:episodes:{i}", 300, _episodes, i) # 5m: airing shows gain eps
languages = lambda e: _cached(f"hianime:languages:{e}", 3600, _languages, e)
master_m3u8 = lambda e, l="eng": _cached(f"hianime:master:{e}:{l}", 3600, _master_m3u8, e, l)
def stream_meta(url):
"""{Referer} for our own playlist/segment hosts, else None."""
if REFERER and urlparse(url).netloc in STREAM_HOSTS:
return {"Referer": REFERER}
return None