- CDN probe no longer downloads 64KB of video (or even a variant playlist): one small master-playlist fetch proves reachability. State stays OK/DEGRADED, kbps is no longer measured, and the pill/ panel only show a rate when one exists. - hianime: 5x getSourcesNew re-roll -> 1 attempt. The enc payload is deterministic per data-id (observed identical across calls), so re-rolling only hammered the provider. - anikoto: 2x re-roll -> 1 attempt per server, same reason. Kept: idle-gated + 30-min degraded backoff from the previous commit, the single _get retry (protects real user playback from CF blips), and the origin page check (the 'down' signal). Net upstream traffic is now what an actual viewer would generate, nothing more.
190 lines
8.0 KiB
Python
190 lines
8.0 KiB
Python
"""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, megaplay_sources
|
|
|
|
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
|
|
SUBS = {} # ep_id -> external .vtt url (the player bakes no subs)
|
|
|
|
|
|
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 'data-type="sub"' in t:
|
|
out.append({"code": "jpn", "name": "Sub", "embed_url": "sub"})
|
|
if 'data-type="dub"' in 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 _embed_payload(embed):
|
|
"""embed url -> (master_m3u8, [{label, src} subs], referer_or_None).
|
|
|
|
Two player chains have appeared: zokoanime (window.__P blob,
|
|
base64+XOR) and megaplay (page needs the hianime referer; data-id ->
|
|
getSourcesNew; CDN domains rotate and most 403 us, so re-roll until
|
|
one serves)."""
|
|
page = _get(embed, timeout=15).text
|
|
blob = re.search(r'window\.__P="([^"]+)"', page)
|
|
if blob:
|
|
j = _deobfuscate(blob.group(1))
|
|
subs = [{"label": (x or {}).get("label", ""), "src": (x or {}).get("src")}
|
|
for x in j.get("subtitles") or []]
|
|
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
|
|
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)
|
|
# ponytail: one attempt — the enc payload is deterministic per data-id,
|
|
# so re-rolling getSourcesNew would just spam the provider
|
|
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 _master_m3u8(ep_id, lang="eng"):
|
|
global REFERER, STREAM_HOSTS
|
|
t = _servers(ep_id)
|
|
want = "sub" if lang in ("jpn", "sub") else "dub"
|
|
# HD-1 has rotated between player chains (zokoanime blob, megaplay);
|
|
# try every listed server of the wanted type until one resolves
|
|
errs = []
|
|
for typ in (want, "sub") if want == "dub" else (want,):
|
|
for name, h in re.findall(
|
|
rf'data-type="{typ}"[\s\S]*?data-server-name="([^"]+)"'
|
|
rf'[\s\S]*?data-hash="([^"]+)"', t):
|
|
try:
|
|
master, subs, refr = _embed_payload(base64.b64decode(h).decode())
|
|
except Exception as e:
|
|
errs.append(f"{name}: {e}")
|
|
continue
|
|
if not master:
|
|
errs.append(f"{name}: no source")
|
|
continue
|
|
REFERER = refr
|
|
# prove the CDN serves us and register the hosts so the /stream proxy
|
|
# allowlist survives CDN rotation
|
|
r = _get(master, headers={"Referer": refr} if refr else {}, 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
|
|
# the 'lang' field is useless (everything says 'en'); the real language
|
|
# is the label — and it isn't first (Chainsaw Man E1 leads with Arabic)
|
|
pick = next((x for x in subs if (x or {}).get("label", "").lower() == "english"), None)
|
|
if not pick and subs:
|
|
pick = subs[0]
|
|
if pick and pick.get("src"):
|
|
SUBS[ep_id] = pick["src"]
|
|
return master
|
|
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:".
|
|
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} (or {}) for our own playlist/segment hosts, else None —
|
|
non-None doubles as the /stream allowlist; {} = host is ours but the
|
|
CDN needs no referer."""
|
|
if urlparse(url).netloc in STREAM_HOSTS:
|
|
return {"Referer": REFERER} if REFERER else {}
|
|
return None
|
|
|
|
|
|
def subtitles(ep_id):
|
|
"""External .vtt for this episode, or None (anidb bakes subs in)."""
|
|
return SUBS.get(ep_id)
|