- 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
138 lines
5.3 KiB
Python
138 lines
5.3 KiB
Python
"""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)
|