fix(config): match per-service cdm keys case-insensitively

cdm lookups used the raw service key while decryption upper-cases its keys, so a case-mismatched cdm entry silently used the default. Add a shared ci_get helper, route cdm/decryption through resolvers, drop a redundant decryption mutation and dead downloader writes, and add a regression bench.
This commit is contained in:
imSp4rky
2026-06-22 16:50:59 -06:00
parent ce69e8a028
commit 6294566823
7 changed files with 114 additions and 29 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ This document covers Digital Rights Management (DRM) and Content Decryption Modu
## cdm (dict)
Pre-define which Widevine or PlayReady device to use for each Service by Service Tag as Key (case-sensitive).
Pre-define which Widevine or PlayReady device to use for each Service by Service Tag as Key (case-insensitive).
The value should be a WVD or PRD filename without the file extension. When
loading the device, unshackle will look in both the `WVDs` and `PRDs` directories
for a matching file.
+73
View File
@@ -0,0 +1,73 @@
"""Pins per-service decryption-tool and CDM resolution, including key casing.
A per-service `cdm:`/`decryption:` entry must resolve regardless of the key's case vs the
service tag, falling back to the configured default otherwise. Guards the silent
"falls back to default on a case mismatch" foot-gun.
"""
from __future__ import annotations
import pytest
from unshackle.core.config import Config, resolve_cdm_name, resolve_decryption
from unshackle.core.utils.collections import ci_get
pytestmark = pytest.mark.unit
def test_ci_get_exact_and_case_insensitive():
m = {"NF": "a", "Default": "b"}
assert ci_get(m, "NF") == "a"
assert ci_get(m, "nf") == "a"
assert ci_get(m, "default") == "b"
assert ci_get(m, "missing") is None
assert ci_get(m, "missing", "fallback") == "fallback"
assert ci_get({}, "NF", "fallback") == "fallback"
def test_decryption_map_keys_upper_cased_on_build():
c = Config(decryption={"cr": "mp4decrypt", "default": "shaka"})
assert c.decryption_map == {"CR": "mp4decrypt", "DEFAULT": "shaka"}
assert c.decryption == "shaka"
def test_resolve_decryption_per_service_and_default():
c = Config(decryption={"CR": "mp4decrypt", "default": "shaka"})
assert resolve_decryption(c.decryption_map, c.decryption, "CR") == "mp4decrypt"
# service not mapped -> default
assert resolve_decryption(c.decryption_map, c.decryption, "NF") == "shaka"
# case-insensitive both ways (lowercase yaml key, mixed-case lookup)
c2 = Config(decryption={"cr": "mp4decrypt", "default": "shaka"})
assert resolve_decryption(c2.decryption_map, c2.decryption, "Cr") == "mp4decrypt"
def test_decryption_scalar_form():
c = Config(decryption="mp4decrypt")
assert c.decryption_map == {}
assert resolve_decryption(c.decryption_map, c.decryption, "ANY") == "mp4decrypt"
def test_cdm_keys_preserved_on_build():
c = Config(cdm={"nf": "dev_a", "default": "dev_b"})
assert c.cdm == {"nf": "dev_a", "default": "dev_b"}
def test_resolve_cdm_name_case_insensitive():
# lowercase yaml key must resolve for an uppercase service tag (the bug being fixed)
c = Config(cdm={"nf": "dev_a", "default": "dev_b"})
assert resolve_cdm_name(c.cdm, "NF") == "dev_a"
# unmapped service -> default
assert resolve_cdm_name(c.cdm, "ATV") == "dev_b"
# uppercase yaml key, lowercase lookup
c2 = Config(cdm={"NF": "dev_a", "default": "dev_b"})
assert resolve_cdm_name(c2.cdm, "nf") == "dev_a"
def test_resolve_cdm_name_override_wins():
c = Config(cdm={"NF": "dev_a", "default": "dev_b"})
assert resolve_cdm_name(c.cdm, "NF", override="dev_override") == "dev_override"
def test_resolve_cdm_name_no_match_no_default():
c = Config(cdm={"NF": "dev_a"})
assert resolve_cdm_name(c.cdm, "ATV") is None
+8 -19
View File
@@ -42,22 +42,14 @@ from rich.tree import Tree
from unshackle.core import binaries, providers
from unshackle.core.cdm import DecryptLabsRemoteCDM
from unshackle.core.cdm.detect import is_playready_cdm, is_widevine_cdm
from unshackle.core.config import config
from unshackle.core.config import config, resolve_cdm_name, resolve_decryption
from unshackle.core.console import console
from unshackle.core.constants import DOWNLOAD_CANCELLED, DOWNLOAD_LICENCE_ONLY, AnyTrack, context_settings
from unshackle.core.credential import Credential
from unshackle.core.drm import DRM_T, ClearKeyCENC, MonaLisa, PlayReady, Widevine
from unshackle.core.events import events
from unshackle.core.music import (
MusicAudioIntegrityError,
MusicMetadataResult,
MusicPlanner,
MusicRenderer,
file_md5,
verify_music_audio,
write_music_manifest,
write_music_metadata,
)
from unshackle.core.music import (MusicAudioIntegrityError, MusicMetadataResult, MusicPlanner, MusicRenderer,
file_md5, verify_music_audio, write_music_manifest, write_music_metadata)
from unshackle.core.proxies import Basic, ExpressVPN, Gluetun, Hola, NordVPN, SurfsharkVPN, WindscribeVPN
from unshackle.core.service import Service
from unshackle.core.services import Services
@@ -76,7 +68,7 @@ from unshackle.core.utils.bitrate import apply_real_bitrates
from unshackle.core.utils.click_types import (AUDIO_CODEC_LIST, LANGUAGE_RANGE, QUALITY_LIST, SEASON_RANGE,
SLOW_DELAY_RANGE, ContextData, MultipleChoice, MultipleVideoCodecChoice,
SubtitleCodecChoice)
from unshackle.core.utils.collections import merge_dict
from unshackle.core.utils.collections import ci_get, merge_dict
from unshackle.core.utils.selector import select_multiple
from unshackle.core.utils.subprocess import ffprobe
from unshackle.core.vaults import Vaults
@@ -909,9 +901,6 @@ class dl:
pass
merge_dict(config.services.get(self.service), self.service_config)
if getattr(config, "decryption_map", None):
config.decryption = config.decryption_map.get(self.service, config.decryption)
service_config = config.services.get(self.service, {})
if service_config:
reserved_keys = {
@@ -2982,7 +2971,7 @@ class dl:
# Handle DRM decryption BEFORE repacking (must decrypt first!)
service_name = service.__class__.__name__.upper()
decryption_method = config.decryption_map.get(service_name, config.decryption)
decryption_method = resolve_decryption(config.decryption_map, config.decryption, service_name)
decrypt_tool = "mp4decrypt" if decryption_method.lower() == "mp4decrypt" else "Shaka Packager"
drm_tracks = [track for track in title.tracks if track.drm]
@@ -4187,7 +4176,7 @@ class dl:
cookie_file.write_text(cookie_data, "utf8")
cookie_jar.load(ignore_discard=True, ignore_expires=True)
return cookie_jar
return None
@staticmethod
@@ -4230,7 +4219,7 @@ class dl:
"""
# A per-request override (set by the REST API per job) takes precedence over the
# global config, so a job can select a specific CDM device without mutating shared state.
cdm_name = getattr(self, "cdm_override", None) or config.cdm.get(service) or config.cdm.get("default")
cdm_name = resolve_cdm_name(config.cdm, service, getattr(self, "cdm_override", None))
if not cdm_name:
return None
@@ -4308,7 +4297,7 @@ class dl:
}.get(drm.lower())
cdm_name = lower_keys.get(drm_key or "widevine") or lower_keys.get("playready")
else:
cdm_name = cdm_name.get(profile) or cdm_name.get("default") or config.cdm.get("default")
cdm_name = cdm_name.get(profile) or cdm_name.get("default") or ci_get(config.cdm, "default")
if not cdm_name:
return None
+7 -6
View File
@@ -15,6 +15,7 @@ from unshackle.core.proxies.resolve import initialize_proxy_providers, resolve_p
from unshackle.core.services import Services
from unshackle.core.titles import Episode, Movie, Title_T
from unshackle.core.tracks import Audio, Subtitle, Video
from unshackle.core.utils.collections import ci_get
log = logging.getLogger("api")
@@ -104,7 +105,7 @@ def load_full_cdm(service: str, profile: Optional[str], cdm_type: Optional[str]
from unshackle.core.cdm import load_cdm
from unshackle.core.config import config as app_config
cdm_name = app_config.cdm.get(service) or app_config.cdm.get("default")
cdm_name = ci_get(app_config.cdm, service) or ci_get(app_config.cdm, "default")
if isinstance(cdm_name, dict):
lower_keys = {k.lower(): v for k, v in cdm_name.items()}
if {"widevine", "playready"} & lower_keys.keys():
@@ -115,7 +116,7 @@ def load_full_cdm(service: str, profile: Optional[str], cdm_type: Optional[str]
)
cdm_name = lower_keys.get(drm_key or "widevine") or lower_keys.get("playready")
else:
cdm_name = cdm_name.get(profile) or cdm_name.get("default") or app_config.cdm.get("default")
cdm_name = cdm_name.get(profile) or cdm_name.get("default") or ci_get(app_config.cdm, "default")
if not cdm_name or not isinstance(cdm_name, str):
return _resolve_server_cdm(service, profile, cdm_type)
@@ -1625,7 +1626,7 @@ def _resolve_server_cdm(service: str, profile: Optional[str], cdm_type: Optional
"""
from unshackle.core.config import config as app_config
cdm_name = app_config.cdm.get(service)
cdm_name = ci_get(app_config.cdm, service)
if cdm_name:
if isinstance(cdm_name, dict):
lower_keys = {k.lower(): v for k, v in cdm_name.items()}
@@ -1646,7 +1647,7 @@ def _resolve_server_cdm(service: str, profile: Optional[str], cdm_type: Optional
def _detect_cdm_type_for_service(service: str, app_config: Any) -> Optional[str]:
"""Detect the CDM type configured for a service in config.cdm."""
cdm_name = app_config.cdm.get(service)
cdm_name = ci_get(app_config.cdm, service)
if not cdm_name:
return None
if isinstance(cdm_name, dict):
@@ -1899,11 +1900,11 @@ def _resolve_device_name(user_config: dict, drm_type: str, service_tag: str = ""
"""
from unshackle.core.config import config as app_config
cdm_name = app_config.cdm.get(service_tag) if service_tag else None
cdm_name = ci_get(app_config.cdm, service_tag) if service_tag else None
if isinstance(cdm_name, dict):
drm_key = {"widevine": "widevine", "playready": "playready"}.get(drm_type)
lower_keys = {k.lower(): v for k, v in cdm_name.items()}
cdm_name = lower_keys.get(drm_key) or lower_keys.get("default") or app_config.cdm.get("default")
cdm_name = lower_keys.get(drm_key) or lower_keys.get("default") or ci_get(app_config.cdm, "default")
if cdm_name and isinstance(cdm_name, str):
return cdm_name
+12
View File
@@ -8,6 +8,18 @@ from typing import Any, Optional
import yaml
from appdirs import AppDirs
from unshackle.core.utils.collections import ci_get
def resolve_decryption(decryption_map: dict, default: str, service: str) -> str:
"""Pick the decryption tool for a service (case-insensitive), falling back to default."""
return ci_get(decryption_map, service, default)
def resolve_cdm_name(cdm: dict, service: str, override: Any = None) -> Any:
"""Resolve a service's top-level CDM entry (case-insensitive), with default fallback."""
return override or ci_get(cdm, service) or ci_get(cdm, "default")
class Config:
class _Directories:
-3
View File
@@ -435,7 +435,6 @@ class RemoteService:
config_maps = {
"cdm": ("cdm", self.service_tag),
"decryption": ("decryption_map", self.service_tag),
"downloader": ("downloader_map", self.service_tag),
}
for key, (attr, tag) in config_maps.items():
if svc_config.get(key):
@@ -445,8 +444,6 @@ class RemoteService:
target = getattr(config, attr)
target[tag] = svc_config[key]
if svc_config.get("downloader"):
config.downloader = svc_config["downloader"]
if svc_config.get("decryption"):
config.decryption = svc_config["decryption"]
+13
View File
@@ -38,6 +38,19 @@ def flatten(items: Any, ignore_types: Union[Type, Tuple[Type, ...]] = str) -> It
yield items
def ci_get(mapping: dict, key: str, default: Any = None) -> Any:
"""Case-insensitive top-level lookup, for per-service config maps."""
if not mapping:
return default
if key in mapping:
return mapping[key]
kl = key.lower()
for k, v in mapping.items():
if isinstance(k, str) and k.lower() == kl:
return v
return default
def merge_dict(source: dict, destination: dict) -> None:
"""Recursively merge Source into Destination in-place."""
if not source: