10 Commits

Author SHA1 Message Date
kenzuya
81661a44b9 Update config 2026-03-11 00:48:08 +07:00
kenzuya
b22c422408 Update config and .gitignore 2026-03-11 00:45:24 +07:00
kenzuya
f4152bc777 Add Widevine and Playready Devices 2026-03-11 00:44:40 +07:00
kenzuya
9c7af72cad feat(netflix): support templated Android ESN generation
Add support for `{randomchar_N}` placeholders in Netflix Android `esn_map` values and generate those segments at runtime. Reuse a cached ESN only when it matches the derived template pattern, is Android-typed, and is not expired; otherwise regenerate and refresh the cache.

This keeps static ESN mappings working as before while enabling dynamic ESN templates (e.g., system_id `7110`) to avoid fixed identifiers and keep ESNs valid per template.
2026-03-10 14:58:08 +07:00
kenzuya
1244141df2 fix(netflix): align MSL manifest payload with Chrome Widevine
Update Netflix manifest request construction to better match current
Widevine-on-Chrome behavior by:
- setting top-level and param `clientVersion` to `9999999`
- sending `challenge` only for Chrome Widevine requests
- removing hardcoded device/platform fields from params

Also refresh Android TV ESN mappings in config by replacing ESN `7110`
and adding ESN `16401` for Hisense devices to improve request validity.
2026-03-10 12:45:59 +07:00
kenzuya
5dde031bd8 feat(netflix-msl): support UserIDToken auth and raw responses
Add `UserAuthentication.UserIDToken()` to build MSL user auth payloads
for token-based Netflix authentication flows.

Extend MSL message handling to be more flexible by:
- allowing custom HTTP headers in `send_message()`
- adding `unwrap_result` to `send_message()`, `parse_message()`, and
  `decrypt_payload_chunks()` so callers can receive either full payload
  data or only `result`

Also lower key/KID and payload logging from `info` to `debug` to reduce
noisy and sensitive runtime logs while keeping diagnostics available.
2026-03-10 00:54:59 +07:00
kenzuya
a07302cb88 chore(gitignore): ignore capitalized Logs directory too
Add `Logs` to `.gitignore` so log output from environments that use an uppercase directory name is not accidentally staged or committed.
2026-03-10 00:54:47 +07:00
kenzuya
0a820e6552 fix(dl): normalize non-scene episode folder/file naming
Ensure episode downloads use a consistent non-scene layout when
`scene_naming` is disabled by:

- adding a sanitized series-title directory before season/episode folders
  for sidecars, sample-based paths, and muxed outputs
- updating `Episode.get_filename()` to return `Season XX` for folder names
  in non-scene mode
- generating non-scene episode file names as
  `Title SXXEXX - Episode Name`
- adding token append helpers to avoid duplicate/empty naming tokens

This keeps output paths predictable across download stages and prevents
naming inconsistencies or duplicated suffixes.fix(dl): normalize non-scene episode folder/file naming

Ensure episode downloads use a consistent non-scene layout when
`scene_naming` is disabled by:

- adding a sanitized series-title directory before season/episode folders
  for sidecars, sample-based paths, and muxed outputs
- updating `Episode.get_filename()` to return `Season XX` for folder names
  in non-scene mode
- generating non-scene episode file names as
  `Title SXXEXX - Episode Name`
- adding token append helpers to avoid duplicate/empty naming tokens

This keeps output paths predictable across download stages and prevents
naming inconsistencies or duplicated suffixes.
2026-03-02 22:35:48 +07:00
kenzuya
8748ce8a11 feat(movie): improve non-scene filename sanitization
Add dedicated non-scene filename sanitization for movies to produce cleaner, filesystem-safe names when `scene_naming` is disabled. The new logic:
- optionally transliterates unicode based on `unicode_filenames`
- removes combining marks/diacritics and disallowed punctuation
- normalizes separators and extra whitespace

Also update movie name construction to explicitly format `Name (Year)` and append a trailing ` -` for non-scene naming before metadata tokens, improving readability and consistency.

Update default config values in `unshackle.yaml` to match non-scene/local usage:
- disable `scene_naming` by default
- comment out default `tag`
- change downloads directory to `Downloads`feat(movie): improve non-scene filename sanitization

Add dedicated non-scene filename sanitization for movies to produce cleaner, filesystem-safe names when `scene_naming` is disabled. The new logic:
- optionally transliterates unicode based on `unicode_filenames`
- removes combining marks/diacritics and disallowed punctuation
- normalizes separators and extra whitespace

Also update movie name construction to explicitly format `Name (Year)` and append a trailing ` -` for non-scene naming before metadata tokens, improving readability and consistency.

Update default config values in `unshackle.yaml` to match non-scene/local usage:
- disable `scene_naming` by default
- comment out default `tag`
- change downloads directory to `Downloads`
2026-03-02 19:42:53 +07:00
kenzuya
3e45f3efe7 fix(netflix): harden ESN cache checks and Widevine type test
Handle Netflix ESN cache values more defensively to avoid key/type errors and
stale reuse by validating cache shape, cache expiry, and device type before
reusing values. Also log the final ESN safely when cache data is not a dict.

Alias `pywidevine.Cdm` to `WidevineCDM` and use it in DRM system detection so
Widevine instances are identified correctly.

Also include related config updates: add ESN map entry for system ID `12063`,
ignore `binaries/`, and refresh local runtime defaults in `unshackle.yaml`.fix(netflix): harden ESN cache checks and Widevine type test

Handle Netflix ESN cache values more defensively to avoid key/type errors and
stale reuse by validating cache shape, cache expiry, and device type before
reusing values. Also log the final ESN safely when cache data is not a dict.

Alias `pywidevine.Cdm` to `WidevineCDM` and use it in DRM system detection so
Widevine instances are identified correctly.

Also include related config updates: add ESN map entry for system ID `12063`,
ignore `binaries/`, and refresh local runtime defaults in `unshackle.yaml`.
2026-03-02 17:29:32 +07:00
17 changed files with 420 additions and 180 deletions

6
.gitignore vendored
View File

@@ -6,8 +6,6 @@ update_check.json
*.exe *.exe
*.dll *.dll
*.crt *.crt
*.wvd
*.prd
*.der *.der
*.pem *.pem
*.bin *.bin
@@ -21,11 +19,11 @@ device_vmp_blob
unshackle/cache/ unshackle/cache/
unshackle/cookies/ unshackle/cookies/
unshackle/certs/ unshackle/certs/
unshackle/WVDs/
unshackle/PRDs/
temp/ temp/
logs/ logs/
Temp/ Temp/
binaries/
Logs
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/

View File

@@ -60,7 +60,7 @@ from unshackle.core.tracks import Audio, Subtitle, Tracks, Video
from unshackle.core.tracks.attachment import Attachment from unshackle.core.tracks.attachment import Attachment
from unshackle.core.tracks.hybrid import Hybrid from unshackle.core.tracks.hybrid import Hybrid
from unshackle.core.utilities import (find_font_with_fallbacks, get_debug_logger, get_system_fonts, init_debug_logger, from unshackle.core.utilities import (find_font_with_fallbacks, get_debug_logger, get_system_fonts, init_debug_logger,
is_close_match, suggest_font_packages, time_elapsed_since) is_close_match, sanitize_filename, suggest_font_packages, time_elapsed_since)
from unshackle.core.utils import tags from unshackle.core.utils import tags
from unshackle.core.utils.click_types import (AUDIO_CODEC_LIST, LANGUAGE_RANGE, QUALITY_LIST, SEASON_RANGE, from unshackle.core.utils.click_types import (AUDIO_CODEC_LIST, LANGUAGE_RANGE, QUALITY_LIST, SEASON_RANGE,
ContextData, MultipleChoice, SubtitleCodecChoice, VideoCodecChoice) ContextData, MultipleChoice, SubtitleCodecChoice, VideoCodecChoice)
@@ -2015,6 +2015,8 @@ class dl:
sidecar_dir = config.directories.downloads sidecar_dir = config.directories.downloads
if not no_folder and isinstance(title, (Episode, Song)) and media_info: if not no_folder and isinstance(title, (Episode, Song)) and media_info:
if isinstance(title, Episode) and not config.scene_naming:
sidecar_dir /= sanitize_filename(title.title.replace("$", "S"), " ")
sidecar_dir /= title.get_filename(media_info, show_service=not no_source, folder=True) sidecar_dir /= title.get_filename(media_info, show_service=not no_source, folder=True)
sidecar_dir.mkdir(parents=True, exist_ok=True) sidecar_dir.mkdir(parents=True, exist_ok=True)
@@ -2080,6 +2082,8 @@ class dl:
) )
if sample_track and sample_track.path: if sample_track and sample_track.path:
media_info = MediaInfo.parse(sample_track.path) media_info = MediaInfo.parse(sample_track.path)
if isinstance(title, Episode) and not config.scene_naming:
final_dir /= sanitize_filename(title.title.replace("$", "S"), " ")
final_dir /= title.get_filename(media_info, show_service=not no_source, folder=True) final_dir /= title.get_filename(media_info, show_service=not no_source, folder=True)
final_dir.mkdir(parents=True, exist_ok=True) final_dir.mkdir(parents=True, exist_ok=True)
@@ -2122,6 +2126,8 @@ class dl:
audio_codec_suffix = muxed_audio_codecs.get(muxed_path) audio_codec_suffix = muxed_audio_codecs.get(muxed_path)
if not no_folder and isinstance(title, (Episode, Song)): if not no_folder and isinstance(title, (Episode, Song)):
if isinstance(title, Episode) and not config.scene_naming:
final_dir /= sanitize_filename(title.title.replace("$", "S"), " ")
final_dir /= title.get_filename(media_info, show_service=not no_source, folder=True) final_dir /= title.get_filename(media_info, show_service=not no_source, folder=True)
final_dir.mkdir(parents=True, exist_ok=True) final_dir.mkdir(parents=True, exist_ok=True)

View File

@@ -123,46 +123,71 @@ class Episode(Title):
scan_suffix = "i" scan_suffix = "i"
return f"{resolution}{scan_suffix}" return f"{resolution}{scan_suffix}"
def _append_token(current: str, token: Optional[str]) -> str:
token = (token or "").strip()
current = current.rstrip()
if not token:
return current
if current.endswith(f" {token}"):
return current
return f"{current} {token}"
def _append_unique_token(tokens: list[str], token: Optional[str]) -> None:
token = (token or "").strip()
if token and token not in tokens:
tokens.append(token)
if folder and not config.scene_naming:
return sanitize_filename(f"Season {self.season:02}", " ")
non_scene_episode_file = not config.scene_naming and not folder
extra_tokens: list[str] = []
# Title [Year] SXXEXX Name (or Title [Year] SXX if folder) # Title [Year] SXXEXX Name (or Title [Year] SXX if folder)
if folder: if folder:
name = f"{self.title}" name = f"{self.title}"
if self.year and config.series_year: if self.year and config.series_year:
name += f" {self.year}" name += f" {self.year}"
name += f" S{self.season:02}" name += f" S{self.season:02}"
elif non_scene_episode_file:
episode_label = self.name or f"Episode {self.number:02}"
name = f"{self.title.replace('$', 'S')} S{self.season:02}E{self.number:02} - {episode_label}"
elif config.dash_naming:
# Format: Title - SXXEXX - Episode Name
name = self.title.replace("$", "S") # e.g., Arli$$
# Add year if configured
if self.year and config.series_year:
name += f" {self.year}"
# Add season and episode
name += f" - S{self.season:02}E{self.number:02}"
# Add episode name with dash separator
if self.name:
name += f" - {self.name}"
name = name.strip()
else: else:
if config.dash_naming: # Standard format without extra dashes
# Format: Title - SXXEXX - Episode Name name = "{title}{year} S{season:02}E{number:02} {name}".format(
name = self.title.replace("$", "S") # e.g., Arli$$ title=self.title.replace("$", "S"), # e.g., Arli$$
year=f" {self.year}" if self.year and config.series_year else "",
# Add year if configured season=self.season,
if self.year and config.series_year: number=self.number,
name += f" {self.year}" name=self.name or "",
).strip()
# Add season and episode
name += f" - S{self.season:02}E{self.number:02}"
# Add episode name with dash separator
if self.name:
name += f" - {self.name}"
name = name.strip()
else:
# Standard format without extra dashes
name = "{title}{year} S{season:02}E{number:02} {name}".format(
title=self.title.replace("$", "S"), # e.g., Arli$$
year=f" {self.year}" if self.year and config.series_year else "",
season=self.season,
number=self.number,
name=self.name or "",
).strip()
if primary_video_track: if primary_video_track:
resolution_token = _get_resolution_token(primary_video_track) resolution_token = _get_resolution_token(primary_video_track)
if resolution_token: if resolution_token:
name += f" {resolution_token}" if non_scene_episode_file:
_append_unique_token(extra_tokens, resolution_token)
else:
name += f" {resolution_token}"
# Service (use track source if available) # Service (use track source if available)
if show_service: if show_service and config.scene_naming:
source_name = None source_name = None
if self.tracks: if self.tracks:
first_track = next(iter(self.tracks), None) first_track = next(iter(self.tracks), None)
@@ -171,15 +196,22 @@ class Episode(Title):
name += f" {source_name or self.service.__name__}" name += f" {source_name or self.service.__name__}"
# 'WEB-DL' # 'WEB-DL'
name += " WEB-DL" if config.scene_naming:
name += " WEB-DL"
# DUAL # DUAL
if unique_audio_languages == 2: if unique_audio_languages == 2:
name += " DUAL" if non_scene_episode_file:
_append_unique_token(extra_tokens, "DUAL")
else:
name += " DUAL"
# MULTi # MULTi
if unique_audio_languages > 2: if unique_audio_languages > 2:
name += " MULTi" if non_scene_episode_file:
_append_unique_token(extra_tokens, "MULTi")
else:
name += " MULTi"
# Audio Codec + Channels (+ feature) # Audio Codec + Channels (+ feature)
if primary_audio_track: if primary_audio_track:
@@ -194,9 +226,16 @@ class Episode(Title):
channels = float(channel_count) channels = float(channel_count)
features = primary_audio_track.format_additionalfeatures or "" features = primary_audio_track.format_additionalfeatures or ""
name += f" {AUDIO_CODEC_MAP.get(codec, codec)}{channels:.1f}" audio_token = f"{AUDIO_CODEC_MAP.get(codec, codec)}{channels:.1f}"
if non_scene_episode_file:
_append_unique_token(extra_tokens, audio_token)
else:
name += f" {audio_token}"
if "JOC" in features or primary_audio_track.joc: if "JOC" in features or primary_audio_track.joc:
name += " Atmos" if non_scene_episode_file:
_append_unique_token(extra_tokens, "Atmos")
else:
name += " Atmos"
# Video (dynamic range + hfr +) Codec # Video (dynamic range + hfr +) Codec
if primary_video_track: if primary_video_track:
@@ -210,36 +249,55 @@ class Episode(Title):
) )
frame_rate = float(primary_video_track.frame_rate) frame_rate = float(primary_video_track.frame_rate)
def _append_token(current: str, token: Optional[str]) -> str:
token = (token or "").strip()
current = current.rstrip()
if not token:
return current
if current.endswith(f" {token}"):
return current
return f"{current} {token}"
# Primary HDR format detection # Primary HDR format detection
if hdr_format: if hdr_format:
if hdr_format_full.startswith("Dolby Vision"): if hdr_format_full.startswith("Dolby Vision"):
name = _append_token(name, "DV") if non_scene_episode_file:
_append_unique_token(extra_tokens, "DV")
else:
name = _append_token(name, "DV")
if any( if any(
indicator in (hdr_format_full + " " + hdr_format) indicator in (hdr_format_full + " " + hdr_format)
for indicator in ["HDR10", "SMPTE ST 2086"] for indicator in ["HDR10", "SMPTE ST 2086"]
): ):
name = _append_token(name, "HDR") if non_scene_episode_file:
_append_unique_token(extra_tokens, "HDR")
else:
name = _append_token(name, "HDR")
elif "HDR Vivid" in hdr_format: elif "HDR Vivid" in hdr_format:
name = _append_token(name, "HDR") if non_scene_episode_file:
_append_unique_token(extra_tokens, "HDR")
else:
name = _append_token(name, "HDR")
else: else:
dynamic_range = DYNAMIC_RANGE_MAP.get(hdr_format) or hdr_format or "" dynamic_range = DYNAMIC_RANGE_MAP.get(hdr_format) or hdr_format or ""
name = _append_token(name, dynamic_range) if non_scene_episode_file:
_append_unique_token(extra_tokens, dynamic_range)
else:
name = _append_token(name, dynamic_range)
elif "HLG" in trc or "Hybrid Log-Gamma" in trc or "ARIB STD-B67" in trc or "arib-std-b67" in trc.lower(): elif "HLG" in trc or "Hybrid Log-Gamma" in trc or "ARIB STD-B67" in trc or "arib-std-b67" in trc.lower():
name += " HLG" if non_scene_episode_file:
_append_unique_token(extra_tokens, "HLG")
else:
name += " HLG"
elif any(indicator in trc for indicator in ["PQ", "SMPTE ST 2084", "BT.2100"]) or "smpte2084" in trc.lower() or "bt.2020-10" in trc.lower(): elif any(indicator in trc for indicator in ["PQ", "SMPTE ST 2084", "BT.2100"]) or "smpte2084" in trc.lower() or "bt.2020-10" in trc.lower():
name += " HDR" if non_scene_episode_file:
_append_unique_token(extra_tokens, "HDR")
else:
name += " HDR"
if frame_rate > 30: if frame_rate > 30:
name += " HFR" if non_scene_episode_file:
name += f" {VIDEO_CODEC_MAP.get(codec, codec)}" _append_unique_token(extra_tokens, "HFR")
else:
name += " HFR"
video_codec_token = VIDEO_CODEC_MAP.get(codec, codec)
if non_scene_episode_file:
_append_unique_token(extra_tokens, video_codec_token)
else:
name += f" {video_codec_token}"
if non_scene_episode_file and extra_tokens:
name += f" - {' '.join(extra_tokens)}"
if config.tag: if config.tag:
name += f"-{config.tag}" name += f"-{config.tag}"

View File

@@ -1,3 +1,5 @@
import re
import unicodedata
from abc import ABC from abc import ABC
from typing import Any, Iterable, Optional, Union from typing import Any, Iterable, Optional, Union
@@ -5,6 +7,7 @@ from langcodes import Language
from pymediainfo import MediaInfo from pymediainfo import MediaInfo
from rich.tree import Tree from rich.tree import Tree
from sortedcontainers import SortedKeyList from sortedcontainers import SortedKeyList
from unidecode import unidecode
from unshackle.core.config import config from unshackle.core.config import config
from unshackle.core.constants import AUDIO_CODEC_MAP, DYNAMIC_RANGE_MAP, VIDEO_CODEC_MAP from unshackle.core.constants import AUDIO_CODEC_MAP, DYNAMIC_RANGE_MAP, VIDEO_CODEC_MAP
@@ -52,6 +55,18 @@ class Movie(Title):
return f"{self.name} ({self.year})" return f"{self.name} ({self.year})"
return self.name return self.name
@staticmethod
def _sanitize_non_scene_filename(filename: str) -> str:
if not config.unicode_filenames:
filename = unidecode(filename)
filename = "".join(c for c in filename if unicodedata.category(c) != "Mn")
filename = filename.replace("/", " & ").replace(";", " & ")
filename = re.sub(r"[\\:*!?¿,'\"<>|$#~]", "", filename)
filename = re.sub(r"\s{2,}", " ", filename)
return filename.strip()
def get_filename(self, media_info: MediaInfo, folder: bool = False, show_service: bool = True) -> str: def get_filename(self, media_info: MediaInfo, folder: bool = False, show_service: bool = True) -> str:
primary_video_track = next(iter(media_info.video_tracks), None) primary_video_track = next(iter(media_info.video_tracks), None)
primary_audio_track = None primary_audio_track = None
@@ -89,7 +104,11 @@ class Movie(Title):
return f"{resolution}{scan_suffix}" return f"{resolution}{scan_suffix}"
# Name (Year) # Name (Year)
name = str(self).replace("$", "S") # e.g., Arli$$ name = self.name.replace("$", "S") # e.g., Arli$$
if self.year:
name = f"{name} ({self.year})"
if not config.scene_naming:
name += " -"
if primary_video_track: if primary_video_track:
resolution_token = _get_resolution_token(primary_video_track) resolution_token = _get_resolution_token(primary_video_track)
@@ -179,7 +198,9 @@ class Movie(Title):
if config.tag: if config.tag:
name += f"-{config.tag}" name += f"-{config.tag}"
return sanitize_filename(name, "." if config.scene_naming else " ") if config.scene_naming:
return sanitize_filename(name, ".")
return self._sanitize_non_scene_filename(name)
class Movies(SortedKeyList, ABC): class Movies(SortedKeyList, ABC):

View File

@@ -129,20 +129,20 @@ class MSL:
raise Exception("- No CDM available") raise Exception("- No CDM available")
cdm.parse_license(msl_keys.cdm_session, key_data["cdmkeyresponse"]) cdm.parse_license(msl_keys.cdm_session, key_data["cdmkeyresponse"])
keys = cdm.get_keys(msl_keys.cdm_session) keys = cdm.get_keys(msl_keys.cdm_session)
cls.log.info(f"Keys: {keys}") cls.log.debug(f"Keys: {keys}")
encryption_key = MSL.get_widevine_key( encryption_key = MSL.get_widevine_key(
kid=base64.b64decode(key_data["encryptionkeyid"]), kid=base64.b64decode(key_data["encryptionkeyid"]),
keys=keys, keys=keys,
permissions=["allow_encrypt", "allow_decrypt"] permissions=["allow_encrypt", "allow_decrypt"]
) )
msl_keys.encryption = encryption_key msl_keys.encryption = encryption_key
cls.log.info(f"Encryption key: {encryption_key}") cls.log.debug(f"Encryption key: {encryption_key}")
sign = MSL.get_widevine_key( sign = MSL.get_widevine_key(
kid=base64.b64decode(key_data["hmackeyid"]), kid=base64.b64decode(key_data["hmackeyid"]),
keys=keys, keys=keys,
permissions=["allow_sign", "allow_signature_verify"] permissions=["allow_sign", "allow_signature_verify"]
) )
cls.log.info(f"Sign key: {sign}") cls.log.debug(f"Sign key: {sign}")
msl_keys.sign = sign msl_keys.sign = sign
elif scheme == KeyExchangeSchemes.AsymmetricWrapped: elif scheme == KeyExchangeSchemes.AsymmetricWrapped:
@@ -244,7 +244,7 @@ class MSL:
@classmethod @classmethod
def get_widevine_key(cls, kid, keys: list[Key], permissions): def get_widevine_key(cls, kid, keys: list[Key], permissions):
cls.log.info(f"KID: {Key.kid_to_uuid(kid)}") cls.log.debug(f"KID: {Key.kid_to_uuid(kid)}")
for key in keys: for key in keys:
# cls.log.info(f"KEY: {key.kid_to_uuid}") # cls.log.info(f"KEY: {key.kid_to_uuid}")
if key.kid != Key.kid_to_uuid(kid): if key.kid != Key.kid_to_uuid(kid):
@@ -258,10 +258,10 @@ class MSL:
return key.key return key.key
return None return None
def send_message(self, endpoint, params, application_data, userauthdata=None): def send_message(self, endpoint, params, application_data, userauthdata=None, headers=None, unwrap_result=True):
message = self.create_message(application_data, userauthdata) message = self.create_message(application_data, userauthdata)
res = self.session.post(url=endpoint, data=message, params=params) res = self.session.post(url=endpoint, data=message, params=params, headers=headers)
header, payload_data = self.parse_message(res.text) header, payload_data = self.parse_message(res.text, unwrap_result=unwrap_result)
if "errordata" in header: if "errordata" in header:
raise Exception( raise Exception(
"- MSL response message contains an error: {}".format( "- MSL response message contains an error: {}".format(
@@ -302,7 +302,7 @@ class MSL:
return message return message
def decrypt_payload_chunks(self, payload_chunks): def decrypt_payload_chunks(self, payload_chunks, unwrap_result=True):
""" """
Decrypt and extract data from payload chunks Decrypt and extract data from payload chunks
@@ -310,7 +310,6 @@ class MSL:
:return: json object :return: json object
""" """
raw_data = "" raw_data = ""
for payload_chunk in payload_chunks: for payload_chunk in payload_chunks:
# todo ; verify signature of payload_chunk["signature"] against payload_chunk["payload"] # todo ; verify signature of payload_chunk["signature"] against payload_chunk["payload"]
# expecting base64-encoded json string # expecting base64-encoded json string
@@ -344,10 +343,12 @@ class MSL:
self.log.critical(f"- {error}") self.log.critical(f"- {error}")
raise Exception(f"- MSL response message contains an error: {error}") raise Exception(f"- MSL response message contains an error: {error}")
# sys.exit(1) # sys.exit(1)
self.log.debug(f"Payload Chunks: {data}")
if unwrap_result:
return data["result"]
return data
return data["result"] def parse_message(self, message, unwrap_result=True):
def parse_message(self, message):
""" """
Parse an MSL message into a header and list of payload chunks Parse an MSL message into a header and list of payload chunks
@@ -359,7 +360,7 @@ class MSL:
header = parsed_message[0] header = parsed_message[0]
encrypted_payload_chunks = parsed_message[1:] if len(parsed_message) > 1 else [] encrypted_payload_chunks = parsed_message[1:] if len(parsed_message) > 1 else []
if encrypted_payload_chunks: if encrypted_payload_chunks:
payload_chunks = self.decrypt_payload_chunks(encrypted_payload_chunks) payload_chunks = self.decrypt_payload_chunks(encrypted_payload_chunks, unwrap_result=unwrap_result)
else: else:
payload_chunks = {} payload_chunks = {}

View File

@@ -31,6 +31,19 @@ class UserAuthentication(MSLObject):
} }
) )
@classmethod
def UserIDToken(cls, token_data, signature, master_token):
return cls(
scheme=UserAuthenticationSchemes.UserIDToken,
authdata={
"useridtoken": {
"tokendata": token_data,
"signature": signature
},
"mastertoken": master_token
}
)
@classmethod @classmethod
def NetflixIDCookies(cls, netflixid, securenetflixid): def NetflixIDCookies(cls, netflixid, securenetflixid):
""" """

View File

@@ -15,6 +15,7 @@ class EntityAuthenticationSchemes(Scheme):
class UserAuthenticationSchemes(Scheme): class UserAuthenticationSchemes(Scheme):
"""https://github.com/Netflix/msl/wiki/User-Authentication-%28Configuration%29""" """https://github.com/Netflix/msl/wiki/User-Authentication-%28Configuration%29"""
EmailPassword = "EMAIL_PASSWORD" EmailPassword = "EMAIL_PASSWORD"
UserIDToken = "USER_ID_TOKEN"
NetflixIDCookies = "NETFLIXID" NetflixIDCookies = "NETFLIXID"

View File

@@ -17,9 +17,11 @@ from Crypto.Random import get_random_bytes
import jsonpickle import jsonpickle
from pymp4.parser import Box from pymp4.parser import Box
from pywidevine import PSSH, Cdm, DeviceTypes from pywidevine import PSSH, Cdm as WidevineCDM, DeviceTypes
from pyplayready import PSSH as PlayReadyPSSH from pyplayready import PSSH as PlayReadyPSSH
import requests import requests
from Cryptodome.Cipher import AES
from Cryptodome.Util.Padding import unpad
from langcodes import Language from langcodes import Language
from unshackle.core.constants import AnyTrack from unshackle.core.constants import AnyTrack
@@ -60,6 +62,7 @@ class Netflix(Service):
"es": "es-419", "es": "es-419",
"pt": "pt-PT", "pt": "pt-PT",
} }
ANDROID_CONFIG_ENDPOINT = "https://android.prod.ftl.netflix.com/nq/androidui/samurai/v1/config"
@staticmethod @staticmethod
@click.command(name="Netflix", short_help="https://netflix.com") @click.command(name="Netflix", short_help="https://netflix.com")
@@ -481,14 +484,166 @@ class Netflix(Service):
securenetflixid=cookie["SecureNetflixId"] securenetflixid=cookie["SecureNetflixId"]
) )
else: else:
# Android like way login to Netflix using email and password
if not self.credential: if not self.credential:
raise Exception(" - Credentials are required for Android CDMs, and none were provided.") raise click.ClickException("Android sign-in requires credentials.")
self.userauthdata = UserAuthentication.EmailPassword( self.userauthdata = self.get_android_userauthdata()
email=self.credential.username,
password=self.credential.password def get_android_userauthdata(self) -> UserAuthentication:
token_cache = self.get_android_user_token_cache()
token_data = token_cache.data if token_cache and isinstance(token_cache.data, dict) else None
if not token_data or not token_data.get("tokendata") or not token_data.get("signature"):
self.log.info("Requesting Android useridtoken")
token_data = self.fetch_android_user_id_token()
token_cache.set(token_data, expiration=self.resolve_android_token_expiration(token_data))
else:
self.log.info("Using cached Android useridtoken")
return UserAuthentication.UserIDToken(
token_data=token_data["tokendata"],
signature=token_data["signature"],
master_token=self.msl.keys.mastertoken
)
def get_android_user_token_cache(self):
return self.cache.get(f"ANDROID_USER_ID_TOKEN/{self.credential.sha1}/{self.esn.data['esn']}")
def fetch_android_user_id_token(self) -> dict:
try:
header, payload_data = self.msl.send_message(
endpoint=self.ANDROID_CONFIG_ENDPOINT,
params=self.build_android_sign_in_query(),
application_data="",
headers=self.build_android_sign_in_headers(),
unwrap_result=False
) )
self.log.info(f"userauthdata: {self.userauthdata}") except Exception as exc:
raise click.ClickException(f"Android sign-in request failed: {exc}") from exc
header_data = self.decrypt_android_header(header["headerdata"])
tokens = header_data.get("useridtoken")
if not tokens:
self.log.debug(f"Android sign-in header keys: {list(header_data.keys())}")
sign_in_value = self.extract_android_sign_in_value(payload_data)
error_code = self.extract_android_sign_in_error_code(sign_in_value)
if error_code:
raise click.ClickException(f"Android sign-in failed: {error_code}")
raise click.ClickException("Android sign-in did not return a useridtoken.")
return tokens
@staticmethod
def extract_android_sign_in_value(payload_data: dict) -> Optional[dict]:
if not isinstance(payload_data, dict):
return None
json_graph = payload_data.get("jsonGraph")
if not isinstance(json_graph, dict):
return None
sign_in_verify = json_graph.get("signInVerify")
if not isinstance(sign_in_verify, dict):
return None
value = sign_in_verify.get("value")
return value if isinstance(value, dict) else None
@staticmethod
def extract_android_sign_in_error_code(sign_in_value: Optional[dict]) -> Optional[str]:
if not isinstance(sign_in_value, dict):
return None
fields = sign_in_value.get("fields")
if not isinstance(fields, dict):
return None
error_code = fields.get("errorCode")
if not isinstance(error_code, dict):
return None
value = error_code.get("value")
return value if isinstance(value, str) and value else None
def build_android_sign_in_query(self) -> dict:
cookie = self.session.cookies.get_dict()
return {
"api": "33",
"appType": "samurai",
"appVer": "62902",
"appVersion": "9.18.0",
"chipset": "sm6150",
"chipsetHardware": "qcom",
"clientAppState": "FOREGROUND",
"clientAppVersionState": "NORMAL",
"countryCode": "+385",
"countryIsoCode": "HR",
"ctgr": "phone",
"dbg": "false",
"deviceLocale": "hr",
"devmod": "samsung_SM-A705FN",
"ffbc": "phone",
"flwssn": "c3100219-d002-40c5-80a7-055c00407246",
"installType": "regular",
"isAutomation": "false",
"isConsumptionOnly": "true",
"isNetflixPreloaded": "false",
"isPlayBillingEnabled": "true",
"isStubInSystemPartition": "false",
"lackLocale": "false",
"landingOrigin": "https://www.netflix.com",
"mId": "SAMSUSM-A705FNS",
"memLevel": "HIGH",
"method": "get",
"mnf": "samsung",
"model": "SM-A705FN",
"netflixClientPlatform": "androidNative",
"netflixId": cookie["NetflixId"],
"networkType": "wifi",
"osBoard": "sm6150",
"osDevice": "a70q",
"osDisplay": "TQ1A.230205.002",
"password": self.credential.password,
"path": "[\"signInVerify\"]",
"pathFormat": "hierarchical",
"platform": "android",
"preloadSignupRoValue": "",
"progressive": "false",
"qlty": "hd",
"recaptchaResponseTime": 244,
"recaptchaResponseToken": "",
"responseFormat": "json",
"roBspVer": "Q6150-17263-1",
"secureNetflixId": cookie["SecureNetflixId"],
"sid": "7176",
"store": "google",
"userLoginId": self.credential.username
}
def build_android_sign_in_headers(self) -> dict:
return {
"X-Netflix.Request.NqTracking": "VerifyLoginMslRequest",
"X-Netflix.Client.Request.Name": "VerifyLoginMslRequest",
"X-Netflix.Request.Client.Context": "{\"appState\":\"foreground\"}",
"X-Netflix-Esn": self.esn.data["esn"],
"X-Netflix.EsnPrefix": "NFANDROID1-PRV-P-",
"X-Netflix.msl-header-friendly-client": "true",
"content-encoding": "msl_v1"
}
def decrypt_android_header(self, encrypted_header_b64: str) -> dict:
encrypted_header = json.loads(base64.b64decode(encrypted_header_b64))
iv = base64.b64decode(encrypted_header["iv"])
ciphertext = base64.b64decode(encrypted_header["ciphertext"])
cipher = AES.new(self.msl.keys.encryption, AES.MODE_CBC, iv)
decrypted = unpad(cipher.decrypt(ciphertext), AES.block_size)
return json.loads(decrypted.decode("utf-8"))
def resolve_android_token_expiration(self, token_data: dict):
for source in (token_data, self.msl.keys.mastertoken):
if not isinstance(source, dict):
continue
tokendata = source.get("tokendata")
if not tokendata:
continue
try:
parsed = json.loads(base64.b64decode(tokendata).decode("utf-8"))
except (TypeError, ValueError, json.JSONDecodeError):
continue
if parsed.get("expiration"):
return parsed["expiration"]
return None
def get_profiles(self): def get_profiles(self):
@@ -553,23 +708,49 @@ class Netflix(Service):
def get_esn(self): def get_esn(self):
if self.cdm.device_type == DeviceTypes.ANDROID: if self.cdm.device_type == DeviceTypes.ANDROID:
try: try:
# Use ESN map from config.yaml instead of generating a new one esn_template = self.config["esn_map"][self.cdm.system_id]
esn = self.config["esn_map"][self.cdm.system_id]
except KeyError: except KeyError:
self.log.error(f"ESN mapping not found for system_id: {self.cdm.system_id}") self.log.error(f"ESN mapping not found for system_id: {self.cdm.system_id}")
raise Exception(f"ESN mapping not found for system_id: {self.cdm.system_id}") raise Exception(f"ESN mapping not found for system_id: {self.cdm.system_id}")
esn_value = { cached_esn = self.esn.data.get("esn") if isinstance(self.esn.data, dict) else self.esn.data
'esn': esn, cached_type = self.esn.data.get("type") if isinstance(self.esn.data, dict) else None
'type': self.cdm.device_type cache_expired = hasattr(self.esn, "expired") and self.esn.expired
} randomchar_pattern = r"\{randomchar_(\d+)\}"
if self.esn.data["esn"] != esn:
self.esn.set(self.config["esn_map"][self.cdm.system_id], 1 * 60 * 60) if re.search(randomchar_pattern, esn_template):
esn_regex = "^" + re.sub(
r"\\\{randomchar_(\d+)\\\}",
lambda match: rf"[A-Z0-9]{{{match.group(1)}}}",
re.escape(esn_template)
) + "$"
if cached_type == DeviceTypes.ANDROID and isinstance(cached_esn, str) and re.match(esn_regex, cached_esn) and not cache_expired:
esn = cached_esn
else:
self.log.info("Generating Android ESN from configured randomchar template")
esn = re.sub(
randomchar_pattern,
lambda match: "".join(random.choice("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ") for _ in range(int(match.group(1)))),
esn_template
)
self.esn.set({
'esn': esn,
'type': self.cdm.device_type
}, expiration=1 * 60 * 60)
else:
esn = esn_template
esn_value = {
'esn': esn,
'type': self.cdm.device_type
}
if cached_esn != esn or cached_type != DeviceTypes.ANDROID or cache_expired:
self.esn.set(esn_value, expiration=1 * 60 * 60)
else: else:
ESN_GEN = "".join(random.choice("0123456789ABCDEF") for _ in range(30)) ESN_GEN = "".join(random.choice("0123456789ABCDEF") for _ in range(30))
generated_esn = f"NFCDIE-03-{ESN_GEN}" generated_esn = f"NFCDIE-03-{ESN_GEN}"
# Check if ESN is expired or doesn't exist # Check if ESN is expired or doesn't exist
if self.esn.data is None or self.esn.data == {} or (hasattr(self.esn, 'expired') and self.esn.expired) or (self.esn.data["type"] != DeviceTypes.CHROME): if not isinstance(self.esn.data, dict) or self.esn.data == {} or (hasattr(self.esn, 'expired') and self.esn.expired) or (self.esn.data.get("type") != DeviceTypes.CHROME):
# Set new ESN with 6-hour expiration # Set new ESN with 6-hour expiration
esn_value = { esn_value = {
'esn': generated_esn, 'esn': generated_esn,
@@ -579,7 +760,8 @@ class Netflix(Service):
self.log.info(f"Generated new ESN with 1-hour expiration") self.log.info(f"Generated new ESN with 1-hour expiration")
else: else:
self.log.info(f"Using cached ESN.") self.log.info(f"Using cached ESN.")
self.log.info(f"ESN: {self.esn.data["esn"]}") final_esn = self.esn.data.get("esn") if isinstance(self.esn.data, dict) else self.esn.data
self.log.info(f"ESN: {final_esn}")
def get_metadata(self, title_id: str): def get_metadata(self, title_id: str):
@@ -673,17 +855,17 @@ class Netflix(Service):
"id": int(time.time()), "id": int(time.time()),
"esn": self.esn.data["esn"], "esn": self.esn.data["esn"],
"languages": ["en-US"], "languages": ["en-US"],
"clientVersion": "6.0026.291.011", "clientVersion": "9999999",
"params": { "params": {
"clientVersion": "6.0051.090.911", "clientVersion": "9999999",
"challenge": self.config["payload_challenge_pr"] if self.drm_system == 'playready' else self.config["payload_challenge"], **({
# "challenge": base64.b64encode(challenge).decode(), "challenge": self.config["payload_challenge"]
"challanges": { } if self.drm_system == "widevine" and self.cdm.device_type == DeviceTypes.CHROME else {}),
# "default": base64.b64encode(challenge).decode() # "challanges": {
"default": self.config["payload_challenge_pr"] if self.drm_system == 'playready' else self.config["payload_challenge"] # # "default": base64.b64encode(challenge).decode()
}, # "default": self.config["payload_challenge_pr"] if self.drm_system == 'playready' else self.config["payload_challenge"]
# },
"contentPlaygraph": ["v2"], "contentPlaygraph": ["v2"],
"deviceSecurityLevel": "3000",
"drmVersion": 25, "drmVersion": 25,
"desiredVmaf": "plus_lts", "desiredVmaf": "plus_lts",
"desiredSegmentVmaf": "plus_lts", "desiredSegmentVmaf": "plus_lts",
@@ -696,10 +878,6 @@ class Netflix(Service):
"licenseType": "standard", "licenseType": "standard",
"liveAdsCapability": "replace", "liveAdsCapability": "replace",
"liveMetadataFormat": "INDEXED_SEGMENT_TEMPLATE", "liveMetadataFormat": "INDEXED_SEGMENT_TEMPLATE",
"manifestVersion": "v2",
"osName": "windows",
"osVersion": "10.0",
"platform": "145.0.0.0",
"profilesGroups": [{ "profilesGroups": [{
"name": "default", "name": "default",
"profiles": video_profiles "profiles": video_profiles
@@ -1216,7 +1394,7 @@ class Netflix(Service):
def get_drm_system(self) -> Literal["widevine", "playready"]: def get_drm_system(self) -> Literal["widevine", "playready"]:
# This is widevine? # This is widevine?
if isinstance(self.cdm, Widevine): if isinstance(self.cdm, WidevineCDM):
return "widevine" return "widevine"
elif isinstance(self.cdm, PlayReady): elif isinstance(self.cdm, PlayReady):
return "playready" return "playready"

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +1,5 @@
# Group or Username to postfix to the end of all download filenames following a dash # Group or Username to postfix to the end of all download filenames following a dash
tag: Kenzuya # tag: Kenzuya
# Enable/disable tagging with group name (default: true) # Enable/disable tagging with group name (default: true)
tag_group_name: true tag_group_name: true
@@ -13,7 +13,7 @@ set_terminal_bg: false
# Set file naming convention # Set file naming convention
# true for style - Prime.Suspect.S07E01.The.Final.Act.Part.One.1080p.ITV.WEB-DL.AAC2.0.H.264 # true for style - Prime.Suspect.S07E01.The.Final.Act.Part.One.1080p.ITV.WEB-DL.AAC2.0.H.264
# false for style - Prime Suspect S07E01 The Final Act - Part One # false for style - Prime Suspect S07E01 The Final Act - Part One
scene_naming: true scene_naming: false
# Whether to include the year in series names for episodes and folders (default: true) # Whether to include the year in series names for episodes and folders (default: true)
# true for style - Show Name (2023) S01E01 Episode Name # true for style - Show Name (2023) S01E01 Episode Name
@@ -36,62 +36,31 @@ title_cache_max_retention: 86400 # Maximum cache retention for fallback when API
muxing: muxing:
set_title: true set_title: true
# Configuration for serve
serve:
api_secret: "kenzuya"
users:
secret_key_for_user:
devices:
- generic_nexus_4464_l3
username: user
# Login credentials for each Service # Login credentials for each Service
credentials: credentials:
# Direct credentials (no profile support)
EXAMPLE: email@example.com:password
# Per-profile credentials with default fallback
SERVICE_NAME:
default: default@email.com:password # Used when no -p/--profile is specified
profile1: user1@email.com:password1
profile2: user2@email.com:password2
# Per-profile credentials without default (requires -p/--profile)
SERVICE_NAME2:
john: john@example.com:johnspassword
jane: jane@example.com:janespassword
# You can also use list format for passwords with special characters
SERVICE_NAME3:
default: ["user@email.com", ":PasswordWith:Colons"]
Netflix: Netflix:
default: ["sako.sako1109@gmail.com", "sako1109"] default: ["ariel-prinsess828@ezweb.ne.jp", "AiNe892186"]
secondary: ["csyc5478@naver.com", "wl107508!"]
# default: ["pbgarena0838@gmail.com", "Andhika1978"] # default: ["pbgarena0838@gmail.com", "Andhika1978"]
# Override default directories used across unshackle # Override default directories used across unshackle
directories: directories:
cache: Cache cache: Cache
# cookies: Cookies
dcsl: DCSL # Device Certificate Status List dcsl: DCSL # Device Certificate Status List
downloads: Downloads downloads: /mnt/ketuakenzuya/Downloads/
logs: Logs logs: Logs
temp: Temp temp: /tmp/unshackle
# wvds: WVDs
prds: PRDs
# Additional directories that can be configured:
# commands: Commands
# services:
# - /path/to/services
# - /other/path/to/services
# vaults: Vaults
# fonts: Fonts
# Pre-define which Widevine or PlayReady device to use for each Service
cdm: cdm:
# Global default CDM device (fallback for all services/profiles) default: hisense_msd6a648_4.10.2891.0_2a621b99_7110_l1
default: chromecdm
# Direct service-specific CDM
DIFFERENT_EXAMPLE: PRD_1
# Per-profile CDM configuration
EXAMPLE:
john_sd: chromecdm_903_l3 # Profile 'john_sd' uses Chrome CDM L3
jane_uhd: nexus_5_l1 # Profile 'jane_uhd' uses Nexus 5 L1
default: generic_android_l3 # Default CDM for this service
# Use pywidevine Serve-compliant Remote CDMs
remote_cdm: remote_cdm:
- name: "chromecdm" - name: "chromecdm"
device_name: widevine device_name: widevine
@@ -128,22 +97,7 @@ key_vaults:
api_mode: "decrypt_labs" api_mode: "decrypt_labs"
host: "https://keyvault.decryptlabs.com" host: "https://keyvault.decryptlabs.com"
password: "7547150416_41da0a32d6237d83_KeyXtractor_api_ext" password: "7547150416_41da0a32d6237d83_KeyXtractor_api_ext"
# Additional vault types:
# - type: API
# name: "Remote Vault"
# uri: "https://key-vault.example.com"
# token: "secret_token"
# no_push: true # This vault will only provide keys, not receive them
# - type: MySQL
# name: "MySQL Vault"
# host: "127.0.0.1"
# port: 3306
# database: vault
# username: user
# password: pass
# no_push: false # Default behavior - vault both provides and receives keys
# Choose what software to use to download data
downloader: aria2c downloader: aria2c
# Options: requests | aria2c | curl_impersonate | n_m3u8dl_re # Options: requests | aria2c | curl_impersonate | n_m3u8dl_re
# Can also be a mapping: # Can also be a mapping:
@@ -200,26 +154,10 @@ filenames:
# API key for The Movie Database (TMDB) # API key for The Movie Database (TMDB)
tmdb_api_key: "8f5c14ef648a0abdd262cf809e11fcd4" tmdb_api_key: "8f5c14ef648a0abdd262cf809e11fcd4"
# conversion_method:
# - auto (default): Smart routing - subby for WebVTT/SAMI, standard for others
# - subby: Always use subby with advanced processing
# - pycaption: Use only pycaption library (no SubtitleEdit, no subby)
# - subtitleedit: Prefer SubtitleEdit when available, fall back to pycaption
subtitle: subtitle:
conversion_method: auto conversion_method: auto
sdh_method: auto sdh_method: auto
# Configuration for pywidevine's serve functionality
serve:
users:
secret_key_for_user:
devices:
- generic_nexus_4464_l3
username: user
# devices:
# - '/path/to/device.wvd'
# Configuration data for each Service
services: services:
# Service-specific configuration goes here # Service-specific configuration goes here
# Profile-specific configurations can be nested under service names # Profile-specific configurations can be nested under service names
@@ -252,6 +190,24 @@ services:
# External proxy provider services # External proxy provider services
proxy_providers: proxy_providers:
gluetun:
base_port: 8888
auto_cleanup: true
container_prefix: "unshackle-gluetun"
verify_ip: true
providers:
protonvpn:
vpn_type: "openvpn"
credentials:
username: "L83JaCnXKIviymQm"
password: "UewUDYdthTLLhOBJDympFFxJn4uG12BV"
server_countries:
us: United States
id: Indonesia
kr: Korea
basic:
SG:
- "http://127.0.0.1:6004"
surfsharkvpn: surfsharkvpn:
username: SkyCBP7kH8KqxDwy5Qw36mQn # Service credentials from https://my.surfshark.com/vpn/manual-setup/main/openvpn username: SkyCBP7kH8KqxDwy5Qw36mQn # Service credentials from https://my.surfshark.com/vpn/manual-setup/main/openvpn
password: pcmewxKTNPvLENdbKJGh8Cgt # Service credentials (not your login password) password: pcmewxKTNPvLENdbKJGh8Cgt # Service credentials (not your login password)