"""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})', html): slug, name = m.group(1), m.group(2).strip() if slug in seen: continue seen.add(slug) imgs = re.findall(r']*>(.*?)', t, re.S) syn = re.search(r'class="synopsis[^"]*"[\s\S]*?class="content"[\s\S]*?>(.*?)', 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']+>', 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]*?', 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 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)