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:
1 parent
731f44b99c
commit
0cd79dd701
4 files changed
+43
-10
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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:".
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user