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
@@ -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
|
||||
Reference in new issue
Block a user