mirror of
https://github.com/Ap0dexMe0/NF-MSL.git
synced 2026-09-17 14:42:01 +02:00
387 lines
15 KiB
Python
387 lines
15 KiB
Python
from __future__ import annotations
|
|
import json
|
|
import random
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
from urllib.parse import quote
|
|
from pywidevine import Cdm as WidevineCdm, Device as WidevineDevice
|
|
from modules.msl.android import MSL_ANDROID
|
|
from modules.helpers import (
|
|
ensure_output_dir, restore_auth_cookies, get_nfvdid, get_flow_session_cookies,
|
|
save_session_cookies,
|
|
generate_netflix_uuid, generate_request_id, generate_esn_random_suffix,
|
|
decrypt_msl_header, extract_clcs_session_id, extract_rendition_id,
|
|
)
|
|
from modules.config import setup_config
|
|
from modules.logging import setup_logger
|
|
from modules.session import setup_session
|
|
|
|
config = setup_config()
|
|
EMAIL = config["NETFLIX"]["EMAIL"]
|
|
PASSWORD = config["NETFLIX"]["PASSWORD"]
|
|
|
|
|
|
def run_android(wvd_path: Path,
|
|
new_msl: bool = False, no_verify: bool = False,
|
|
proxy: Optional[str] = None):
|
|
log = setup_logger('ANDROID MSL')
|
|
OUTPUT_DIR = ensure_output_dir("android")
|
|
|
|
NETFLIX_HOME_URL = "https://www.netflix.com/"
|
|
NETFLIX_CANONICAL_URL = "https://netflix.com/"
|
|
LOGIN_URL = "https://www.netflix.com/login"
|
|
APPBOOT_URL = "https://android15.appboot.netflix.com/appboot/NFANDROID1-PRV-P-"
|
|
MSL_HANDSHAKE_ENDPOINT = "https://android.prod.ftl.netflix.com/nq/androidui/pbo_license/~1.0.0/router"
|
|
VERIFY_LOGIN_URL = "https://android.prod.ftl.netflix.com/nq/androidui/samurai/v1/config"
|
|
|
|
USER_AGENT = "com.netflix.mediaclient/63988 (Linux; U; Android 15; en_US; SM-F711N; Build/AP3A.240905.015.A2; Cronet/143.0.7445.0)"
|
|
CLIENT_VERSION = "18.26.0"
|
|
APP_VERSION = "9.60.0"
|
|
HAWKINS_VERSION = "5.15.0"
|
|
UI_FLAVOR = "android"
|
|
OS_VERSION = "35"
|
|
FORM_FACTOR = "phone"
|
|
FEATURE_CAPABILITIES = "supportsStudioBranding"
|
|
LOCALE = "en-US"
|
|
DEVICE_MODEL = "SM-F711N"
|
|
|
|
VERIFY_TLS = not no_verify
|
|
RESTORE_AUTH_COOKIES = False
|
|
|
|
if not wvd_path.exists():
|
|
raise FileNotFoundError(f"Missing WVD file: {wvd_path}")
|
|
widevine_device = WidevineDevice.load(wvd_path)
|
|
cdm = WidevineCdm.from_device(widevine_device)
|
|
_sid = widevine_device.system_id
|
|
MSL_CACHE_PATH = OUTPUT_DIR / f"msl_keys_cache_android_{_sid}.json"
|
|
AUTH_COOKIES_PATH = OUTPUT_DIR / f"netflix_auth_cookies_{_sid}.json"
|
|
USERIDTOKEN_PATH = OUTPUT_DIR / f"netflix_auth_useridtoken_{_sid}.json"
|
|
TOKENS_OUTPUT_PATH = OUTPUT_DIR / f"netflix_auth_tokens_{_sid}.json"
|
|
|
|
ESN = f"NFANDROID1-PRV-P-SAMSUSM-F711N-{_sid}-{generate_esn_random_suffix(64)}"
|
|
log.info("ESN: %s", ESN)
|
|
|
|
REQUEST_CLIENT_CONTEXT_UNKNOWN = '{"appView":"unknown","appState":"foreground"}'
|
|
APPBOOT_REQUEST_CLIENT_CONTEXT = '{"appView":"unknown","appState":"foreground"}'
|
|
|
|
session = setup_session(verify_tls=VERIFY_TLS, proxy=proxy)
|
|
_proxy = {"http": proxy, "https": proxy} if proxy else None
|
|
|
|
if RESTORE_AUTH_COOKIES:
|
|
restore_auth_cookies(session, AUTH_COOKIES_PATH, log)
|
|
|
|
log.info("Initializing session")
|
|
response = session.get(NETFLIX_CANONICAL_URL, timeout=30, allow_redirects=True)
|
|
response.raise_for_status()
|
|
|
|
response = session.get(NETFLIX_HOME_URL, timeout=30)
|
|
response.raise_for_status()
|
|
|
|
log.info("Requesting initial nfvdid cookie")
|
|
appboot_headers = {
|
|
"Host": "android15.appboot.netflix.com",
|
|
"Connection": "keep-alive",
|
|
"X-Netflix.Request.Client.Context": APPBOOT_REQUEST_CLIENT_CONTEXT,
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
"User-Agent": USER_AGENT,
|
|
"Accept-Encoding": "gzip, deflate, br",
|
|
}
|
|
|
|
response = session.post(
|
|
APPBOOT_URL,
|
|
params={"keyVersion": "1"},
|
|
headers=appboot_headers,
|
|
timeout=30,
|
|
)
|
|
|
|
nfvdid = get_nfvdid(session, response)
|
|
|
|
log.info("Initial nfvdid cookie obtained")
|
|
|
|
log.info("Starting MSL Widevine exchange")
|
|
|
|
msl_headers = MSL_ANDROID.build_request_headers(
|
|
request_name="getProxyEsn",
|
|
user_agent=USER_AGENT,
|
|
referer=None,
|
|
esn=ESN,
|
|
expiry_timeout=12750,
|
|
host="android15.prod.cloud.netflix.com",
|
|
language="en-US,en",
|
|
device_model=quote(DEVICE_MODEL, safe=""),
|
|
extra_headers={
|
|
"Accept-Encoding": "gzip, deflate, br",
|
|
"Content-Encoding": "msl_v1",
|
|
"x-netflix.zuul.brotli.allowed": "true",
|
|
"x-netflix.appver": APP_VERSION,
|
|
"x-netflix.clienttype": "samurai",
|
|
"x-netflix.request.client.context": REQUEST_CLIENT_CONTEXT_UNKNOWN,
|
|
"x-netflix.esnprefix": "NFANDROID1-PRV-P-",
|
|
"x-netflix.request.uuid": (
|
|
generate_netflix_uuid()
|
|
),
|
|
"x-netflix.androidapi": "35",
|
|
"x-netflix.deviceformfactor": "PHONE",
|
|
"x-netflix.devicememorylevel": "HIGH",
|
|
"x-netflix.request.attempt": "1",
|
|
"x-netflix.request.id": generate_request_id(),
|
|
"Content-Type": "application/json",
|
|
"x-netflix.client.request.name": "getProxyEsn",
|
|
"x-netflix.request.routing": '{"path":"\\/nq\\/android\\/playback\\/~1.0.0\\/router"}',
|
|
"user-agent": USER_AGENT,
|
|
},
|
|
)
|
|
|
|
handshake_cookies = {
|
|
"nfvdid": nfvdid,
|
|
}
|
|
|
|
msl_keys = MSL_ANDROID.handshake(
|
|
msl_keys_path=str(MSL_CACHE_PATH),
|
|
session=session,
|
|
sender=ESN,
|
|
cdm=cdm,
|
|
cdm_device=str(wvd_path),
|
|
new_msl=False,
|
|
cookies=handshake_cookies,
|
|
drm="widevine",
|
|
endpoint=MSL_HANDSHAKE_ENDPOINT,
|
|
headers=msl_headers,
|
|
)
|
|
|
|
msl_client = MSL_ANDROID(
|
|
session=session,
|
|
keys=msl_keys,
|
|
message_id=random.randint(0, 2**52),
|
|
sender=ESN,
|
|
drm="widevine",
|
|
proxy=_proxy,
|
|
)
|
|
|
|
nfvdid, flow_session_id = get_flow_session_cookies(session)
|
|
|
|
log.info("MSL Widevine exchange completed")
|
|
|
|
log.info("Loading login page")
|
|
response = session.get(LOGIN_URL, timeout=30)
|
|
response.raise_for_status()
|
|
login_html = response.text
|
|
|
|
cookie_dict = session.cookies.get_dict()
|
|
flow_session_id = cookie_dict.get("flwssn", flow_session_id)
|
|
if not flow_session_id:
|
|
raise RuntimeError("The flwssn flow session cookie is missing")
|
|
|
|
log.info("Submitting VerifyLoginMslRequest")
|
|
|
|
confirm_login_query = {
|
|
"api": "33",
|
|
"appType": "samurai",
|
|
"appVer": "62902",
|
|
"appVersion": "9.18.0",
|
|
"chipset": "sm8150",
|
|
"chipsetHardware": "qcom",
|
|
"clientAppState": "FOREGROUND",
|
|
"clientAppVersionState": "NORMAL",
|
|
"countryIsoCode": "US",
|
|
"ctgr": "phone",
|
|
"dbg": "false",
|
|
"deviceLocale": "en-US",
|
|
"devmod": f"samsung_{DEVICE_MODEL}",
|
|
"ffbc": "phone",
|
|
"flwssn": flow_session_id,
|
|
"installType": "regular",
|
|
"isAutomation": "false",
|
|
"isConsumptionOnly": "true",
|
|
"isNetflixPreloaded": "false",
|
|
"isPlayBillingEnabled": "true",
|
|
"isStubInSystemPartition": "false",
|
|
"lackLocale": "false",
|
|
"landingOrigin": "https://www.netflix.com",
|
|
"mId": "SAMSUSM-F711N",
|
|
"memLevel": "HIGH",
|
|
"method": "get",
|
|
"mnf": "samsung",
|
|
"model": DEVICE_MODEL,
|
|
"netflixClientPlatform": "androidNative",
|
|
"netflixId": cookie_dict.get("NetflixId", ""),
|
|
"networkType": "wifi",
|
|
"osBoard": "kona",
|
|
"osDevice": "bloom",
|
|
"osDisplay": "RP1A.200720.012",
|
|
"password": PASSWORD,
|
|
"path": '["signInVerify"]',
|
|
"pathFormat": "hierarchical",
|
|
"platform": "android",
|
|
"preloadSignupRoValue": "",
|
|
"progressive": "false",
|
|
"qlty": "hd",
|
|
"recaptchaResponseTime": 445,
|
|
"recaptchaResponseToken": "",
|
|
"responseFormat": "json",
|
|
"roBspVer": "RP1A.200720.012",
|
|
"secureNetflixId": cookie_dict.get("SecureNetflixId", ""),
|
|
"sid": "7176",
|
|
"store": "google",
|
|
"userLoginId": EMAIL,
|
|
}
|
|
|
|
confirm_login_headers = {
|
|
"X-Netflix.Request.NqTracking": "VerifyLoginMslRequest",
|
|
"X-Netflix.Client.Request.Name": "VerifyLoginMslRequest",
|
|
"X-Netflix.Request.Client.Context": '{"appState":"foreground"}',
|
|
"X-Netflix-Esn": ESN,
|
|
"X-Netflix.EsnPrefix": "NFANDROID1-PRV-P-",
|
|
"X-Netflix.msl-header-friendly-client": "true",
|
|
"content-encoding": "msl_v1",
|
|
}
|
|
|
|
_THROTTLE_RETRIES = 3
|
|
_THROTTLE_WAIT = 60
|
|
_clcs_attempted = False
|
|
|
|
for _attempt in range(1, _THROTTLE_RETRIES + 2): # +1 slot for the CLCS retry
|
|
try:
|
|
confirm_login_header, confirm_login_payload_chunks = msl_client.send_message(endpoint=VERIFY_LOGIN_URL,
|
|
params=confirm_login_query,
|
|
application_data={},
|
|
headers=confirm_login_headers)
|
|
except Exception:
|
|
log.error("VerifyLoginMslRequest failed")
|
|
log.debug("Request URL: %s", VERIFY_LOGIN_URL)
|
|
log.debug("Request params: %s", json.dumps(confirm_login_query, indent=2))
|
|
log.debug("Request headers: %s", json.dumps(confirm_login_headers, indent=2))
|
|
log.debug("Session cookies: %s", json.dumps(session.cookies.get_dict(), indent=2))
|
|
log.exception("Exception occurred")
|
|
sys.exit(1)
|
|
|
|
_error_code = None
|
|
if (
|
|
isinstance(confirm_login_payload_chunks, dict)
|
|
and "errorCode" in confirm_login_payload_chunks.get("jsonGraph", {}).get("signInVerify", {}).get("value", {}).get("fields", {})
|
|
):
|
|
_error_code = (
|
|
confirm_login_payload_chunks.get("jsonGraph", {})
|
|
.get("signInVerify", {})
|
|
.get("value", {})
|
|
.get("fields", {})
|
|
.get("errorCode", {})
|
|
.get("value")
|
|
)
|
|
|
|
if _error_code == "throttling_failure" and _attempt < _THROTTLE_RETRIES:
|
|
log.warning("Throttled by Netflix (attempt %d/%d), retrying in %ds...", _attempt, _THROTTLE_RETRIES, _THROTTLE_WAIT)
|
|
time.sleep(_THROTTLE_WAIT)
|
|
continue
|
|
|
|
elif _error_code == "incorrect_password" and not _clcs_attempted:
|
|
# Samurai rejected credentials without auth cookies — fall back to CLCS
|
|
# web login to obtain NetflixId/SecureNetflixId, then retry once.
|
|
log.warning("incorrect_password — falling back to CLCS web login")
|
|
clcs_session_id = extract_clcs_session_id(login_html)
|
|
rendition_id = extract_rendition_id(login_html)
|
|
if not clcs_session_id or not rendition_id:
|
|
log.error("Cannot extract CLCS session IDs from login page for fallback")
|
|
sys.exit(1)
|
|
clcs_resp = session.post(
|
|
"https://web.prod.cloud.netflix.com/graphql",
|
|
json={
|
|
"operationName": "CLCSScreenUpdate",
|
|
"variables": {
|
|
"format": "HTML",
|
|
"imageFormat": "PNG",
|
|
"locale": "en-US",
|
|
"serverState": json.dumps({
|
|
"realm": "growth",
|
|
"name": "PASSWORD_LOGIN",
|
|
"clcsSessionId": clcs_session_id,
|
|
"sessionContext": {
|
|
"session-breadcrumbs": {"funnel_name": "loginWeb"},
|
|
"login.navigationSettings": {"hideOtpToggle": True},
|
|
},
|
|
}, separators=(",", ":")),
|
|
"serverScreenUpdate": json.dumps({
|
|
"realm": "custom",
|
|
"name": "growthLoginByPassword",
|
|
"metadata": {"recaptchaSiteKey": "6Lf8hrcUAAAAAIpQAFW2VFjtiYnThOjZOA5xvLyR"},
|
|
"loggingAction": "Submitted",
|
|
"loggingCommand": "SubmitCommand",
|
|
"referrerRenditionId": rendition_id,
|
|
}, separators=(",", ":")),
|
|
"inputFields": [
|
|
{"name": "password", "value": {"stringValue": PASSWORD}},
|
|
{"name": "userLoginId", "value": {"stringValue": EMAIL}},
|
|
{"name": "countryCode", "value": {"stringValue": "1"}},
|
|
{"name": "countryIsoCode", "value": {"stringValue": "US"}},
|
|
{"name": "recaptchaResponseTime", "value": {"intValue": 445}},
|
|
{"name": "recaptchaResponseToken", "value": {"stringValue": ""}},
|
|
],
|
|
},
|
|
"extensions": {"persistedQuery": {"id": "1c276cdf-caef-49cf-b38e-384972c2b47e", "version": 102}},
|
|
},
|
|
headers={
|
|
"User-Agent": USER_AGENT,
|
|
"Accept": "application/json",
|
|
"Content-Type": "application/json",
|
|
"Origin": "https://www.netflix.com",
|
|
"Referer": LOGIN_URL,
|
|
},
|
|
timeout=30,
|
|
)
|
|
if "errors" in clcs_resp.json():
|
|
log.error("CLCS fallback login failed: %s", clcs_resp.json().get("errors"))
|
|
sys.exit(1)
|
|
session.get("https://www.netflix.com/browse", timeout=30)
|
|
_fresh = session.cookies.get_dict()
|
|
confirm_login_query["netflixId"] = _fresh.get("NetflixId", "")
|
|
confirm_login_query["secureNetflixId"] = _fresh.get("SecureNetflixId", "")
|
|
confirm_login_query["flwssn"] = _fresh.get("flwssn", flow_session_id)
|
|
_clcs_attempted = True
|
|
log.info("CLCS fallback complete, retrying VerifyLoginMslRequest")
|
|
continue
|
|
|
|
elif _error_code:
|
|
log.error("Login errorCode: %s", _error_code)
|
|
sys.exit(1)
|
|
break
|
|
|
|
if "headerdata" not in confirm_login_header:
|
|
log.critical("Missing 'headerdata' in MSL response")
|
|
sys.exit(1)
|
|
|
|
try:
|
|
header_data = decrypt_msl_header(confirm_login_header["headerdata"], msl_client.keys.encryption, msl_client.keys.sign)
|
|
except Exception:
|
|
log.exception("Failed to decrypt MSL header")
|
|
sys.exit(1)
|
|
|
|
tokens = header_data.get("useridtoken")
|
|
if not tokens:
|
|
log.error("Authentication failed: invalid ESN, email, or password")
|
|
sys.exit(1)
|
|
|
|
try:
|
|
TOKENS_OUTPUT_PATH.write_text(json.dumps(header_data, indent=4), encoding="utf-8")
|
|
USERIDTOKEN_PATH.write_text(json.dumps(tokens, indent=2), encoding="utf-8")
|
|
log.info("User ID token data saved to: %s", TOKENS_OUTPUT_PATH)
|
|
log.info("User ID token saved to: %s", USERIDTOKEN_PATH)
|
|
except Exception:
|
|
log.exception("Failed to save token files")
|
|
sys.exit(1)
|
|
|
|
try:
|
|
auth_cookies = save_session_cookies(session, AUTH_COOKIES_PATH, log)
|
|
except Exception:
|
|
sys.exit(1)
|
|
|
|
result = {
|
|
"useridtoken": tokens,
|
|
"auth_cookies": auth_cookies,
|
|
"header_data": header_data,
|
|
}
|
|
|
|
log.info("VerifyLoginMslRequest succeeded")
|
|
# print(json.dumps(result, indent=2))
|