Files
ani-web/server/providers/anikoto.py
T
jozamudi e3ef51d141 Stop spamming upstream: drop video download from probe, kill re-rolls
- 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.
2026-09-29 17:33:54 +00:00

139 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, megaplay_sources
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 = megaplay_sources(_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
# ponytail: one attempt per server — the enc payload is deterministic,
# re-rolling would just spam the provider
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)