Decode MegaPlay's encrypted getSourcesNew payload

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.
This commit is contained in:
jozamudi committed 2026-09-29 17:17:33 +00:00
1 parent 731f44b99c
commit 0cd79dd701
4 files changed
+43 -10

No files matched your search

+1
View File
@@ -5,6 +5,7 @@ fastapi==0.141.1
uvicorn==0.52.4 uvicorn==0.52.4
curl_cffi==0.16.0 curl_cffi==0.16.0
itsdangerous==2.2.0 itsdangerous==2.2.0
pycryptodome==3.23.0
# tests # tests
pytest==9.1.1 pytest==9.1.1
+3 -3
View File
@@ -10,7 +10,7 @@ times until a CDN domain actually serves us.
""" """
import re import re
from ..upstream import _cached, _get from ..upstream import _cached, _get, megaplay_sources
BASE = "https://anikototv.to" BASE = "https://anikototv.to"
XHR = {"X-Requested-With": "XMLHttpRequest"} XHR = {"X-Requested-With": "XMLHttpRequest"}
@@ -100,8 +100,8 @@ def _player_file(embed_url):
if not m: if not m:
return None return None
host = re.match(r'https?://([^/]+)/', embed_url).group(1) host = re.match(r'https?://([^/]+)/', embed_url).group(1)
d = _get(f"https://{host}/stream/getSourcesNew?id={m.group(1)}", d = megaplay_sources(_get(f"https://{host}/stream/getSourcesNew?id={m.group(1)}",
headers={"Referer": embed_url, "X-Requested-With": "XMLHttpRequest"}).json() headers={"Referer": embed_url, "X-Requested-With": "XMLHttpRequest"}).json())
return (d.get("sources") or {}).get("file") return (d.get("sources") or {}).get("file")
+16 -7
View File
@@ -13,7 +13,7 @@ import json
import re import re
from urllib.parse import urljoin, urlparse from urllib.parse import urljoin, urlparse
from ..upstream import _cached, _get from ..upstream import _cached, _get, megaplay_sources
BASE = "https://hianime.at" BASE = "https://hianime.at"
KEY = b"otaku-embed-v1" KEY = b"otaku-embed-v1"
@@ -106,9 +106,9 @@ def _embed_payload(embed):
return None, [], None return None, [], None
host = re.match(r'https?://([^/]+)', embed).group(1) host = re.match(r'https?://([^/]+)', embed).group(1)
for _ in range(5): 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"}, headers={"Referer": embed, "X-Requested-With": "XMLHttpRequest"},
timeout=15).json() timeout=15).json())
src = (d.get("sources") or {}).get("file") src = (d.get("sources") or {}).get("file")
if not src: if not src:
continue continue
@@ -129,13 +129,18 @@ def _master_m3u8(ep_id, lang="eng"):
want = "sub" if lang in ("jpn", "sub") else "dub" want = "sub" if lang in ("jpn", "sub") else "dub"
# HD-1 has rotated between player chains (zokoanime blob, megaplay); # HD-1 has rotated between player chains (zokoanime blob, megaplay);
# try every listed server of the wanted type until one resolves # try every listed server of the wanted type until one resolves
errs = []
for typ in (want, "sub") if want == "dub" else (want,): 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'data-type="{typ}"[\s\S]*?data-server-name="([^"]+)"'
rf'[\s\S]*?data-hash="([^"]+)"', t): rf'[\s\S]*?data-hash="([^"]+)"', t):
embed = base64.b64decode(h).decode() try:
master, subs, refr = _embed_payload(embed) master, subs, refr = _embed_payload(base64.b64decode(h).decode())
except Exception as e:
errs.append(f"{name}: {e}")
continue
if not master: if not master:
errs.append(f"{name}: no source")
continue continue
REFERER = refr REFERER = refr
# prove the CDN serves us and register the hosts so the /stream proxy # 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"): if pick and pick.get("src"):
SUBS[ep_id] = pick["src"] SUBS[ep_id] = pick["src"]
return master 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:". # Provider interface. Cache keys are namespaced with "hianime:".
+23
View File
@@ -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; impersonation, TTL cache) live here so every provider gets them for free;
they work on any URL, not just anidb's. they work on any URL, not just anidb's.
""" """
import base64
import importlib import importlib
import json
import os import os
import re import re
import threading import threading
import time import time
from Crypto.Cipher import AES
from curl_cffi import requests from curl_cffi import requests
H = dict(impersonate="chrome", timeout=20) H = dict(impersonate="chrome", timeout=20)
@@ -55,6 +58,26 @@ def _cached(key, ttl, fn, *a, **kw):
def clear_cache(): def clear_cache():
_CACHE.clear() _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): def _get(url, **kw):
# ponytail: one retry (300ms) on transient upstream errors (Cloudflare # ponytail: one retry (300ms) on transient upstream errors (Cloudflare