3 Commits

Author SHA1 Message Date
Andy
a82828768d feat: automatic audio language metadata for embedded audio tracks
- Add intelligent embedded audio language detection at mux stage
- Automatically set audio language metadata when no separate audio tracks exist
- Respect user flags (-V, --no-audio) to avoid unnecessary processing
- Smart video track selection based on title language with fallbacks
- Improved default track selection to prioritize title language matches
- Enhanced FFmpeg repackaging with audio stream metadata injection
- Works automatically for all services without service-specific code
2025-09-10 00:57:14 +00:00
Andy
d18a5de0d0 fix: Improve import ordering and code formatting
- Reorder imports in decrypt_labs_remote_cdm.py for better organization
- Clean up trailing whitespace in SQLite.py
2025-09-10 00:53:52 +00:00
Andy
04b540b363 fix: Resolve service name transmission and vault case sensitivity issues
Fixed DecryptLabsRemoteCDM sending 'generic' instead of proper service names and added case-insensitive vault lookups for SQLite/MySQL vaults. Also added local vault integration to DecryptLabsRemoteCDM
2025-09-09 18:53:11 +00:00
6 changed files with 213 additions and 84 deletions

View File

@@ -1147,8 +1147,9 @@ class dl:
with Live(Padding(progress, (0, 5, 1, 5)), console=console): with Live(Padding(progress, (0, 5, 1, 5)), console=console):
for task_id, task_tracks in multiplex_tasks: for task_id, task_tracks in multiplex_tasks:
progress.start_task(task_id) # TODO: Needed? progress.start_task(task_id) # TODO: Needed?
audio_expected = not video_only and not no_audio
muxed_path, return_code, errors = task_tracks.mux( muxed_path, return_code, errors = task_tracks.mux(
str(title), progress=partial(progress.update, task_id=task_id), delete=False str(title), progress=partial(progress.update, task_id=task_id), delete=False, audio_expected=audio_expected, title_language=title.language
) )
muxed_paths.append(muxed_path) muxed_paths.append(muxed_path)
if return_code >= 2: if return_code >= 2:

View File

@@ -10,8 +10,8 @@ from pywidevine.cdm import Cdm as WidevineCdm
from pywidevine.device import DeviceTypes from pywidevine.device import DeviceTypes
from requests import Session from requests import Session
from unshackle.core.vaults import Vaults
from unshackle.core import __version__ from unshackle.core import __version__
from unshackle.core.vaults import Vaults
class MockCertificateChain: class MockCertificateChain:
@@ -353,17 +353,19 @@ class DecryptLabsRemoteCDM:
Generate a license challenge using Decrypt Labs API with intelligent caching. Generate a license challenge using Decrypt Labs API with intelligent caching.
This method implements smart caching logic that: This method implements smart caching logic that:
1. First attempts to retrieve cached keys from the API 1. First checks local vaults for required keys
2. If required KIDs are set, compares cached keys against requirements 2. Attempts to retrieve cached keys from the API
3. Only makes a license request if keys are missing 3. If required KIDs are set, compares available keys (vault + cached) against requirements
4. Returns empty challenge if all required keys are cached 4. Only makes a license request if keys are missing
5. Returns empty challenge if all required keys are available
The intelligent caching works as follows: The intelligent caching works as follows:
- Local vaults: Always checked first if available
- For L1/L2 devices: Always prioritizes cached keys (API automatically optimizes) - For L1/L2 devices: Always prioritizes cached keys (API automatically optimizes)
- For other devices: Uses cache retry logic based on session state - For other devices: Uses cache retry logic based on session state
- With required KIDs set: Only requests license for missing keys - With required KIDs set: Only requests license for missing keys
- Without required KIDs: Returns any available cached keys - Without required KIDs: Returns any available cached keys
- For PlayReady: Combines cached keys with license keys seamlessly - For PlayReady: Combines vault, cached, and license keys seamlessly
Args: Args:
session_id: Session identifier session_id: Session identifier
@@ -372,7 +374,7 @@ class DecryptLabsRemoteCDM:
privacy_mode: Whether to use privacy mode - for compatibility only privacy_mode: Whether to use privacy mode - for compatibility only
Returns: Returns:
License challenge as bytes, or empty bytes if cached keys satisfy requirements License challenge as bytes, or empty bytes if available keys satisfy requirements
Raises: Raises:
InvalidSession: If session ID is invalid InvalidSession: If session ID is invalid
@@ -381,6 +383,7 @@ class DecryptLabsRemoteCDM:
Note: Note:
Call set_required_kids() before this method for optimal caching behavior. Call set_required_kids() before this method for optimal caching behavior.
L1/L2 devices automatically use cached keys when available per API design. L1/L2 devices automatically use cached keys when available per API design.
Local vault keys are always checked first when vaults are available.
""" """
_ = license_type, privacy_mode _ = license_type, privacy_mode
@@ -393,6 +396,31 @@ class DecryptLabsRemoteCDM:
init_data = self._get_init_data_from_pssh(pssh_or_wrm) init_data = self._get_init_data_from_pssh(pssh_or_wrm)
already_tried_cache = session.get("tried_cache", False) already_tried_cache = session.get("tried_cache", False)
if self.vaults and self._required_kids:
vault_keys = []
for kid_str in self._required_kids:
try:
clean_kid = kid_str.replace("-", "")
if len(clean_kid) == 32:
kid_uuid = UUID(hex=clean_kid)
else:
kid_uuid = UUID(hex=clean_kid.ljust(32, "0"))
key, _ = self.vaults.get_key(kid_uuid)
if key and key.count("0") != len(key):
vault_keys.append({"kid": kid_str, "key": key, "type": "CONTENT"})
except (ValueError, TypeError):
continue
if vault_keys:
vault_kids = set(k["kid"] for k in vault_keys)
required_kids = set(self._required_kids)
if required_kids.issubset(vault_kids):
session["keys"] = vault_keys
return b""
else:
session["vault_keys"] = vault_keys
if self.device_name in ["L1", "L2"]: if self.device_name in ["L1", "L2"]:
get_cached_keys = True get_cached_keys = True
else: else:
@@ -404,7 +432,7 @@ class DecryptLabsRemoteCDM:
"get_cached_keys_if_exists": get_cached_keys, "get_cached_keys_if_exists": get_cached_keys,
} }
if self.device_name in ["L1", "L2", "SL2", "SL3"] and self.service_name: if self.service_name:
request_data["service"] = self.service_name request_data["service"] = self.service_name
if session["service_certificate"]: if session["service_certificate"]:
@@ -441,17 +469,22 @@ class DecryptLabsRemoteCDM:
""" """
cached_keys = data.get("cached_keys", []) cached_keys = data.get("cached_keys", [])
parsed_keys = self._parse_cached_keys(cached_keys) parsed_keys = self._parse_cached_keys(cached_keys)
session["keys"] = parsed_keys
all_available_keys = list(parsed_keys)
if "vault_keys" in session:
all_available_keys.extend(session["vault_keys"])
session["keys"] = all_available_keys
session["tried_cache"] = True session["tried_cache"] = True
if self._required_kids: if self._required_kids:
cached_kids = set() available_kids = set()
for key in parsed_keys: for key in all_available_keys:
if isinstance(key, dict) and "kid" in key: if isinstance(key, dict) and "kid" in key:
cached_kids.add(key["kid"].replace("-", "").lower()) available_kids.add(key["kid"].replace("-", "").lower())
required_kids = set(self._required_kids) required_kids = set(self._required_kids)
missing_kids = required_kids - cached_kids missing_kids = required_kids - available_kids
if missing_kids: if missing_kids:
session["cached_keys"] = parsed_keys session["cached_keys"] = parsed_keys
@@ -585,51 +618,62 @@ class DecryptLabsRemoteCDM:
license_keys = self._parse_keys_response(data) license_keys = self._parse_keys_response(data)
if self.is_playready and "cached_keys" in session: all_keys = []
"""
Combine cached keys with license keys for PlayReady content.
This ensures we have both the cached keys (obtained earlier) and if "vault_keys" in session:
any additional keys from the license response, without duplicates. all_keys.extend(session["vault_keys"])
"""
if "cached_keys" in session:
cached_keys = session.get("cached_keys", []) cached_keys = session.get("cached_keys", [])
all_keys = list(cached_keys) for cached_key in cached_keys:
all_keys.append(cached_key)
for license_key in license_keys: for license_key in license_keys:
already_exists = False already_exists = False
license_kid = None license_kid = None
if isinstance(license_key, dict) and "kid" in license_key: if isinstance(license_key, dict) and "kid" in license_key:
license_kid = license_key["kid"].replace("-", "").lower() license_kid = license_key["kid"].replace("-", "").lower()
elif hasattr(license_key, "kid"): elif hasattr(license_key, "kid"):
license_kid = str(license_key.kid).replace("-", "").lower() license_kid = str(license_key.kid).replace("-", "").lower()
elif hasattr(license_key, "key_id"): elif hasattr(license_key, "key_id"):
license_kid = str(license_key.key_id).replace("-", "").lower() license_kid = str(license_key.key_id).replace("-", "").lower()
if license_kid: if license_kid:
for cached_key in cached_keys: for existing_key in all_keys:
cached_kid = None existing_kid = None
if isinstance(cached_key, dict) and "kid" in cached_key: if isinstance(existing_key, dict) and "kid" in existing_key:
cached_kid = cached_key["kid"].replace("-", "").lower() existing_kid = existing_key["kid"].replace("-", "").lower()
elif hasattr(cached_key, "kid"): elif hasattr(existing_key, "kid"):
cached_kid = str(cached_key.kid).replace("-", "").lower() existing_kid = str(existing_key.kid).replace("-", "").lower()
elif hasattr(cached_key, "key_id"): elif hasattr(existing_key, "key_id"):
cached_kid = str(cached_key.key_id).replace("-", "").lower() existing_kid = str(existing_key.key_id).replace("-", "").lower()
if cached_kid == license_kid: if existing_kid == license_kid:
already_exists = True already_exists = True
break break
if not already_exists: if not already_exists:
all_keys.append(license_key) all_keys.append(license_key)
session["keys"] = all_keys session["keys"] = all_keys
session["cached_keys"] = None session.pop("cached_keys", None)
else: session.pop("vault_keys", None)
session["keys"] = license_keys
if self.vaults and session["keys"]: if self.vaults and session["keys"]:
key_dict = {UUID(hex=key["kid"]): key["key"] for key in session["keys"] if key["type"] == "CONTENT"} key_dict = {}
self.vaults.add_keys(key_dict) for key in session["keys"]:
if key["type"] == "CONTENT":
try:
clean_kid = key["kid"].replace("-", "")
if len(clean_kid) == 32:
kid_uuid = UUID(hex=clean_kid)
else:
kid_uuid = UUID(hex=clean_kid.ljust(32, "0"))
key_dict[kid_uuid] = key["key"]
except (ValueError, TypeError):
continue
if key_dict:
self.vaults.add_keys(key_dict)
def get_keys(self, session_id: bytes, type_: Optional[str] = None) -> List[Key]: def get_keys(self, session_id: bytes, type_: Optional[str] = None) -> List[Key]:
""" """

View File

@@ -420,7 +420,7 @@ class Track:
for drm in self.drm: for drm in self.drm:
if isinstance(drm, PlayReady): if isinstance(drm, PlayReady):
return drm return drm
elif hasattr(cdm, 'is_playready'): elif hasattr(cdm, "is_playready"):
if cdm.is_playready: if cdm.is_playready:
for drm in self.drm: for drm in self.drm:
if isinstance(drm, PlayReady): if isinstance(drm, PlayReady):
@@ -567,15 +567,32 @@ class Track:
output_path = original_path.with_stem(f"{original_path.stem}_repack") output_path = original_path.with_stem(f"{original_path.stem}_repack")
def _ffmpeg(extra_args: list[str] = None): def _ffmpeg(extra_args: list[str] = None):
subprocess.run( args = [
binaries.FFMPEG,
"-hide_banner",
"-loglevel",
"error",
"-i",
original_path,
*(extra_args or []),
]
if hasattr(self, "data") and self.data.get("audio_language"):
audio_lang = self.data["audio_language"]
audio_name = self.data.get("audio_language_name", audio_lang)
args.extend(
[
"-metadata:s:a:0",
f"language={audio_lang}",
"-metadata:s:a:0",
f"title={audio_name}",
"-metadata:s:a:0",
f"handler_name={audio_name}",
]
)
args.extend(
[ [
binaries.FFMPEG,
"-hide_banner",
"-loglevel",
"error",
"-i",
original_path,
*(extra_args or []),
# Following are very important! # Following are very important!
"-map_metadata", "-map_metadata",
"-1", # don't transfer metadata to output file "-1", # don't transfer metadata to output file
@@ -584,7 +601,11 @@ class Track:
"-codec", "-codec",
"copy", "copy",
str(output_path), str(output_path),
], ]
)
subprocess.run(
args,
check=True, check=True,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, stderr=subprocess.PIPE,

View File

@@ -305,7 +305,14 @@ class Tracks:
) )
return selected return selected
def mux(self, title: str, delete: bool = True, progress: Optional[partial] = None) -> tuple[Path, int, list[str]]: def mux(
self,
title: str,
delete: bool = True,
progress: Optional[partial] = None,
audio_expected: bool = True,
title_language: Optional[Language] = None,
) -> tuple[Path, int, list[str]]:
""" """
Multiplex all the Tracks into a Matroska Container file. Multiplex all the Tracks into a Matroska Container file.
@@ -315,7 +322,28 @@ class Tracks:
delete: Delete all track files after multiplexing. delete: Delete all track files after multiplexing.
progress: Update a rich progress bar via `completed=...`. This must be the progress: Update a rich progress bar via `completed=...`. This must be the
progress object's update() func, pre-set with task id via functools.partial. progress object's update() func, pre-set with task id via functools.partial.
audio_expected: Whether audio is expected in the output. Used to determine
if embedded audio metadata should be added.
title_language: The title's intended language. Used to select the best video track
for audio metadata when multiple video tracks exist.
""" """
if self.videos and not self.audio and audio_expected:
video_track = None
if title_language:
video_track = next((v for v in self.videos if v.language == title_language), None)
if not video_track:
video_track = next((v for v in self.videos if v.is_original_lang), None)
video_track = video_track or self.videos[0]
if video_track.language.is_valid():
lang_code = str(video_track.language)
lang_name = video_track.language.display_name()
for video in self.videos:
video.needs_repack = True
video.data["audio_language"] = lang_code
video.data["audio_language_name"] = lang_name
if not binaries.MKVToolNix: if not binaries.MKVToolNix:
raise RuntimeError("MKVToolNix (mkvmerge) is required for muxing but was not found") raise RuntimeError("MKVToolNix (mkvmerge) is required for muxing but was not found")
@@ -332,12 +360,20 @@ class Tracks:
raise ValueError("Video Track must be downloaded before muxing...") raise ValueError("Video Track must be downloaded before muxing...")
events.emit(events.Types.TRACK_MULTIPLEX, track=vt) events.emit(events.Types.TRACK_MULTIPLEX, track=vt)
is_default = False
if title_language:
is_default = vt.language == title_language
if not any(v.language == title_language for v in self.videos):
is_default = vt.is_original_lang or i == 0
else:
is_default = i == 0
# Prepare base arguments # Prepare base arguments
video_args = [ video_args = [
"--language", "--language",
f"0:{vt.language}", f"0:{vt.language}",
"--default-track", "--default-track",
f"0:{i == 0}", f"0:{is_default}",
"--original-flag", "--original-flag",
f"0:{vt.is_original_lang}", f"0:{vt.is_original_lang}",
"--compression", "--compression",
@@ -363,6 +399,18 @@ class Tracks:
] ]
) )
if hasattr(vt, "data") and vt.data.get("audio_language"):
audio_lang = vt.data["audio_language"]
audio_name = vt.data.get("audio_language_name", audio_lang)
video_args.extend(
[
"--language",
f"1:{audio_lang}",
"--track-name",
f"1:{audio_name}",
]
)
cl.extend(video_args + ["(", str(vt.path), ")"]) cl.extend(video_args + ["(", str(vt.path), ")"])
for i, at in enumerate(self.audio): for i, at in enumerate(self.audio):

View File

@@ -28,26 +28,33 @@ class MySQL(Vault):
raise PermissionError(f"MySQL vault {self.slug} has no SELECT permission.") raise PermissionError(f"MySQL vault {self.slug} has no SELECT permission.")
def get_key(self, kid: Union[UUID, str], service: str) -> Optional[str]: def get_key(self, kid: Union[UUID, str], service: str) -> Optional[str]:
if not self.has_table(service):
# no table, no key, simple
return None
if isinstance(kid, UUID): if isinstance(kid, UUID):
kid = kid.hex kid = kid.hex
service_variants = [service]
if service != service.lower():
service_variants.append(service.lower())
if service != service.upper():
service_variants.append(service.upper())
conn = self.conn_factory.get() conn = self.conn_factory.get()
cursor = conn.cursor() cursor = conn.cursor()
try: try:
cursor.execute( for service_name in service_variants:
# TODO: SQL injection risk if not self.has_table(service_name):
f"SELECT `id`, `key_` FROM `{service}` WHERE `kid`=%s AND `key_`!=%s", continue
(kid, "0" * 32),
) cursor.execute(
cek = cursor.fetchone() # TODO: SQL injection risk
if not cek: f"SELECT `id`, `key_` FROM `{service_name}` WHERE `kid`=%s AND `key_`!=%s",
return None (kid, "0" * 32),
return cek["key_"] )
cek = cursor.fetchone()
if cek:
return cek["key_"]
return None
finally: finally:
cursor.close() cursor.close()

View File

@@ -19,22 +19,30 @@ class SQLite(Vault):
self.conn_factory = ConnectionFactory(self.path) self.conn_factory = ConnectionFactory(self.path)
def get_key(self, kid: Union[UUID, str], service: str) -> Optional[str]: def get_key(self, kid: Union[UUID, str], service: str) -> Optional[str]:
if not self.has_table(service):
# no table, no key, simple
return None
if isinstance(kid, UUID): if isinstance(kid, UUID):
kid = kid.hex kid = kid.hex
conn = self.conn_factory.get() conn = self.conn_factory.get()
cursor = conn.cursor() cursor = conn.cursor()
# Try both the original service name and lowercase version to handle case sensitivity issues
service_variants = [service]
if service != service.lower():
service_variants.append(service.lower())
if service != service.upper():
service_variants.append(service.upper())
try: try:
cursor.execute(f"SELECT `id`, `key_` FROM `{service}` WHERE `kid`=? AND `key_`!=?", (kid, "0" * 32)) for service_name in service_variants:
cek = cursor.fetchone() if not self.has_table(service_name):
if not cek: continue
return None
return cek[1] cursor.execute(f"SELECT `id`, `key_` FROM `{service_name}` WHERE `kid`=? AND `key_`!=?", (kid, "0" * 32))
cek = cursor.fetchone()
if cek:
return cek[1]
return None
finally: finally:
cursor.close() cursor.close()