From 0cd79dd70163a48368d37062be503f5d57040f1b Mon Sep 17 00:00:00 2001 From: Josue Zamudio Date: Tue, 29 Sep 2026 17:17:33 +0000 Subject: [PATCH] Decode MegaPlay's encrypted getSourcesNew payload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MegaPlay (behind both hianime and anikoto) stopped returning sources.file in plaintext and now ships the stream URL in an AES-256-CBC 'enc' field. Key/IV are static, hardcoded in their public player JS (newclient.min.js) — anti-scrape obscurity, not access control. Shared megaplay_sources() in upstream.py tolerates both shapes, so a revert upstream breaks nothing. Also: hianime master_m3u8 now raises when servers are listed but none resolve (403s, stale format) instead of returning None — a listed-but dead server is a provider fault, not 'no dub', and the old None silently short-circuited the ANI_SOURCE fallback chain. --- requirements.txt | 1 + server/providers/anikoto.py | 6 +++--- server/providers/hianime.py | 23 ++++++++++++++++------- server/upstream.py | 23 +++++++++++++++++++++++ 4 files changed, 43 insertions(+), 10 deletions(-) diff --git a/requirements.txt b/requirements.txt index 45b5e24..2a02a0a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,7 @@ fastapi==0.141.1 uvicorn==0.52.4 curl_cffi==0.16.0 itsdangerous==2.2.0 +pycryptodome==3.23.0 # tests pytest==9.1.1 diff --git a/server/providers/anikoto.py b/server/providers/anikoto.py index 8cd6cd8..819f5b1 100644 --- a/server/providers/anikoto.py +++ b/server/providers/anikoto.py @@ -10,7 +10,7 @@ times until a CDN domain actually serves us. """ import re -from ..upstream import _cached, _get +from ..upstream import _cached, _get, megaplay_sources BASE = "https://anikototv.to" XHR = {"X-Requested-With": "XMLHttpRequest"} @@ -100,8 +100,8 @@ def _player_file(embed_url): 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() + 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") diff --git a/server/providers/hianime.py b/server/providers/hianime.py index 9a23537..019746e 100644 --- a/server/providers/hianime.py +++ b/server/providers/hianime.py @@ -13,7 +13,7 @@ import json import re from urllib.parse import urljoin, urlparse -from ..upstream import _cached, _get +from ..upstream import _cached, _get, megaplay_sources BASE = "https://hianime.at" KEY = b"otaku-embed-v1" @@ -106,9 +106,9 @@ def _embed_payload(embed): return None, [], None host = re.match(r'https?://([^/]+)', embed).group(1) for _ in range(5): - d = _get(f"https://{host}/stream/getSourcesNew?id={mid.group(1)}", + d = megaplay_sources(_get(f"https://{host}/stream/getSourcesNew?id={mid.group(1)}", headers={"Referer": embed, "X-Requested-With": "XMLHttpRequest"}, - timeout=15).json() + timeout=15).json()) src = (d.get("sources") or {}).get("file") if not src: continue @@ -129,13 +129,18 @@ def _master_m3u8(ep_id, lang="eng"): 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( + for name, h in re.findall( rf'data-type="{typ}"[\s\S]*?data-server-name="([^"]+)"' rf'[\s\S]*?data-hash="([^"]+)"', t): - embed = base64.b64decode(h).decode() - master, subs, refr = _embed_payload(embed) + 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 @@ -154,7 +159,11 @@ def _master_m3u8(ep_id, lang="eng"): if pick and pick.get("src"): SUBS[ep_id] = pick["src"] return master - return None + 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:". diff --git a/server/upstream.py b/server/upstream.py index 231d0a1..434c9e5 100644 --- a/server/upstream.py +++ b/server/upstream.py @@ -17,12 +17,15 @@ Shared HTTP helpers (_get/get_stream/fetch_parallel, Chrome TLS impersonation, TTL cache) live here so every provider gets them for free; they work on any URL, not just anidb's. """ +import base64 import importlib +import json import os import re import threading import time +from Crypto.Cipher import AES from curl_cffi import requests H = dict(impersonate="chrome", timeout=20) @@ -55,6 +58,26 @@ def _cached(key, ttl, fn, *a, **kw): def clear_cache(): _CACHE.clear() +# MegaPlay's getSourcesNew hid the stream URL in an AES-256-CBC "enc" field. +# Key/IV are static and hardcoded in their public player JS (newclient.min.js) +# — anti-scrape theater, not access control. ponytail: breaks if they rotate +# the key; upgrade path = re-read newclient.min.js (or let it fail: both +# shapes are tolerated below, so the old code path still works if reverted). +_MP_KEY = b"i?LMTAx0Q6,:}50U".ljust(32, b"\x00") +_MP_IV = b"W0;27ToaUpl_P%'c" + +def megaplay_sources(d): + """getSourcesNew response -> same dict with sources["file"] filled. + Old players return it plaintext; current ones base64url+AES-wrap it in + "enc" (decrypted payload is {"file": ...}).""" + if not (d.get("sources") or {}).get("file") and d.get("enc"): + raw = base64.urlsafe_b64decode(d["enc"] + "=" * (-len(d["enc"]) % 4)) + pt = AES.new(_MP_KEY, AES.MODE_CBC, _MP_IV).decrypt(raw) + j = json.loads(pt[:-pt[-1]]) # strip PKCS7 + if j.get("file"): + d = {**d, "sources": {"file": j["file"]}} + return d + def _get(url, **kw): # ponytail: one retry (300ms) on transient upstream errors (Cloudflare