mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-12 12:05:07 +00:00
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:
+1
-1
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user