From 6294566823307e899b0a8cd24cc74702dfba4530 Mon Sep 17 00:00:00 2001 From: imSp4rky Date: Mon, 22 Jun 2026 16:50:59 -0600 Subject: [PATCH] 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. --- docs/DRM_CONFIG.md | 2 +- tests/core/test_config_resolution.py | 73 ++++++++++++++++++++++++++++ unshackle/commands/dl.py | 27 +++------- unshackle/core/api/handlers.py | 13 ++--- unshackle/core/config.py | 12 +++++ unshackle/core/remote_service.py | 3 -- unshackle/core/utils/collections.py | 13 +++++ 7 files changed, 114 insertions(+), 29 deletions(-) create mode 100644 tests/core/test_config_resolution.py diff --git a/docs/DRM_CONFIG.md b/docs/DRM_CONFIG.md index 0ab8972e..1674e9f0 100644 --- a/docs/DRM_CONFIG.md +++ b/docs/DRM_CONFIG.md @@ -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. diff --git a/tests/core/test_config_resolution.py b/tests/core/test_config_resolution.py new file mode 100644 index 00000000..09d64caf --- /dev/null +++ b/tests/core/test_config_resolution.py @@ -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 diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index 796dde30..66fe718e 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -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 diff --git a/unshackle/core/api/handlers.py b/unshackle/core/api/handlers.py index 9108c084..1f18e518 100644 --- a/unshackle/core/api/handlers.py +++ b/unshackle/core/api/handlers.py @@ -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 diff --git a/unshackle/core/config.py b/unshackle/core/config.py index 8be0e54b..f847cbd6 100644 --- a/unshackle/core/config.py +++ b/unshackle/core/config.py @@ -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: diff --git a/unshackle/core/remote_service.py b/unshackle/core/remote_service.py index 706aeb28..63b9ac37 100644 --- a/unshackle/core/remote_service.py +++ b/unshackle/core/remote_service.py @@ -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"] diff --git a/unshackle/core/utils/collections.py b/unshackle/core/utils/collections.py index ae01bb24..7acf8fcf 100644 --- a/unshackle/core/utils/collections.py +++ b/unshackle/core/utils/collections.py @@ -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: