Skip to content

Commit 5321796

Browse files
committed
Map default_KID to kid:key
1 parent 4945c0c commit 5321796

File tree

3 files changed

+105
-114
lines changed

3 files changed

+105
-114
lines changed

StreamingCommunity/Lib/DASH/cdm_helpher.py

Lines changed: 39 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -33,53 +33,6 @@ def filter_valid_keys(content_keys: list) -> list:
3333
return valid_keys
3434

3535

36-
def select_best_key(valid_keys: list) -> dict:
37-
"""
38-
Select the best key from valid keys based on heuristics.
39-
40-
Args:
41-
valid_keys (list): List of valid key dictionaries
42-
"""
43-
if len(valid_keys) == 1:
44-
return valid_keys[0]
45-
46-
# Heuristics for key selection:
47-
# 1. Prefer keys that are not all the same character
48-
# 2. Prefer keys with more entropy (variety in hex characters)
49-
scored_keys = []
50-
for key_info in valid_keys:
51-
key_value = key_info.get('key', '')
52-
score = 0
53-
54-
# Score based on character variety
55-
unique_chars = len(set(key_value))
56-
score += unique_chars
57-
58-
# Penalize keys with too many repeated patterns
59-
if len(key_value) > 8:
60-
61-
# Check for repeating patterns
62-
has_pattern = False
63-
for i in range(2, len(key_value) // 2):
64-
pattern = key_value[:i]
65-
if key_value.startswith(pattern * (len(key_value) // i)):
66-
has_pattern = True
67-
break
68-
69-
if not has_pattern:
70-
score += 10
71-
72-
scored_keys.append((score, key_info))
73-
console.log(f"[cyan]Found key [red]{key_info.get('kid', 'unknown')}[cyan] with score: [red]{score}")
74-
75-
# Sort by score (descending) and return the best key
76-
scored_keys.sort(key=lambda x: x[0], reverse=True)
77-
best_key = scored_keys[0][1]
78-
79-
console.log(f"[cyan]Selected key: [red]{best_key.get('kid', 'unknown')}[cyan] with score: [red]{scored_keys[0][0]}")
80-
return best_key
81-
82-
8336
def get_widevine_keys(pssh: str, license_url: str, cdm_device_path: str, headers: dict = None, query_params: dict =None, key: str=None):
8437
"""
8538
Extract Widevine CONTENT keys (KID/KEY) from a license using pywidevine.
@@ -174,24 +127,14 @@ def get_widevine_keys(pssh: str, license_url: str, cdm_device_path: str, headers
174127
console.print("[yellow]⚠️ No CONTENT keys found in license.")
175128
return None
176129

177-
# Filter and select the best key
130+
# Filter valid keys
178131
valid_keys = filter_valid_keys(content_keys)
179132

180-
# Select the best key automatically
181-
best_key = select_best_key(valid_keys)
182-
183-
if best_key:
184-
console.log(f"[cyan]Selected KID: [green]{best_key['kid']} [white]| [cyan]KEY: [green]{best_key['key']}")
185-
186-
# Return all valid keys but with the best one first
187-
result_keys = [best_key]
188-
for key_info in valid_keys:
189-
if key_info['kid'] != best_key['kid']:
190-
result_keys.append(key_info)
191-
192-
return result_keys
133+
if valid_keys:
134+
console.log(f"[cyan]Found {len(valid_keys)} valid keys for testing")
135+
return valid_keys
193136
else:
194-
console.print("[red]❌ Could not select best key")
137+
console.print("[red]❌ No valid keys found")
195138
return None
196139
else:
197140
content_keys = []
@@ -210,6 +153,40 @@ def get_widevine_keys(pssh: str, license_url: str, cdm_device_path: str, headers
210153
cdm.close(session_id)
211154

212155

156+
def map_keys_to_representations(keys: list, representations: list) -> dict:
157+
"""
158+
Map decryption keys to representations based on their default_KID.
159+
160+
Args:
161+
keys (list): List of key dictionaries with 'kid' and 'key' fields
162+
representations (list): List of representation dictionaries with 'default_kid' field
163+
164+
Returns:
165+
dict: Mapping of representation type to key info
166+
"""
167+
key_mapping = {}
168+
169+
for rep in representations:
170+
rep_type = rep.get('type', 'unknown')
171+
default_kid = rep.get('default_kid')
172+
173+
if not default_kid:
174+
continue
175+
176+
for key_info in keys:
177+
if key_info['kid'].lower() == default_kid.lower():
178+
key_mapping[rep_type] = {
179+
'kid': key_info['kid'],
180+
'key': key_info['key'],
181+
'representation_id': rep.get('id'),
182+
'default_kid': default_kid
183+
}
184+
#console.log(f"[cyan]Mapped {rep_type} representation [yellow]{rep.get('id')} [cyan]to key: [red]{key_info['kid']}")
185+
break
186+
187+
return key_mapping
188+
189+
213190
def get_info_wvd(cdm_device_path):
214191
"""
215192
Extract device information from a Widevine CDM device file (.wvd).

StreamingCommunity/Lib/DASH/downloader.py

Lines changed: 39 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from .parser import MPD_Parser
2121
from .segments import MPD_Segments
2222
from .decrypt import decrypt_with_mp4decrypt
23-
from .cdm_helpher import get_widevine_keys
23+
from .cdm_helpher import get_widevine_keys, map_keys_to_representations
2424

2525

2626
# FFmpeg functions
@@ -32,7 +32,6 @@
3232
DOWNLOAD_SPECIFIC_SUBTITLE = config_manager.get_list('M3U8_DOWNLOAD', 'specific_list_subtitles')
3333
MERGE_SUBTITLE = config_manager.get_bool('M3U8_DOWNLOAD', 'merge_subs')
3434
CLEANUP_TMP = config_manager.get_bool('M3U8_DOWNLOAD', 'cleanup_tmp_folder')
35-
RETRY_LIMIT = config_manager.get_int('REQUESTS', 'max_retry')
3635
EXTENSION_OUTPUT = config_manager.get("M3U8_CONVERSION", "extension")
3736

3837

@@ -160,7 +159,7 @@ def get_representation_by_type(self, typ):
160159

161160
def download_subtitles(self) -> bool:
162161
"""
163-
Download subtitle files based on parser's selected subtitles with retry mechanism.
162+
Download subtitle files based on parser's selected subtitles.
164163
Returns True if successful or if no subtitles to download, False on critical error.
165164
"""
166165
if not self.selected_subs or self.mpd_sub_list is None:
@@ -193,12 +192,11 @@ def download_subtitles(self) -> bool:
193192

194193
def download_and_decrypt(self, custom_headers=None, query_params=None, key=None) -> bool:
195194
"""
196-
Download and decrypt video/audio streams. Skips download if file already exists.
195+
Download and decrypt video/audio streams using automatic key mapping based on default_KID.
197196
198197
Args:
199198
- custom_headers (dict): Optional HTTP headers for the license request.
200199
- query_params (dict): Optional query parameters to append to the license URL.
201-
- license_data (str/bytes): Optional raw license data to bypass HTTP request.
202200
- key (str): Optional raw license data to bypass HTTP request.
203201
"""
204202
if self.file_already_exists:
@@ -228,12 +226,26 @@ def download_and_decrypt(self, custom_headers=None, query_params=None, key=None)
228226
console.print("[red]No keys found, cannot proceed with download.")
229227
return False
230228

229+
# Map keys to representations based on default_KID
230+
key_mapping = map_keys_to_representations(keys, self.parser.representations)
231+
232+
if not key_mapping:
233+
console.print("[red]Could not map any keys to representations.")
234+
return False
235+
231236
# Download subtitles
232237
self.download_subtitles()
233238

234-
# Download the video to get segment count
239+
# Download and decrypt video
235240
video_rep = self.get_representation_by_type("video")
236241
if video_rep:
242+
video_key_info = key_mapping.get("video")
243+
if not video_key_info:
244+
self.error = "No key found for video representation"
245+
return False
246+
247+
console.log(f"[cyan]Using video key: [red]{video_key_info['kid']} [cyan]for representation [yellow]{video_key_info.get('representation_id')}")
248+
237249
video_downloader = MPD_Segments(tmp_folder=self.encrypted_dir, representation=video_rep, pssh=self.parser.pssh, custom_headers=custom_headers)
238250
encrypted_path = video_downloader.get_concat_path(self.encrypted_dir)
239251

@@ -266,33 +278,28 @@ def download_and_decrypt(self, custom_headers=None, query_params=None, key=None)
266278
self.current_downloader = None
267279
self.current_download_type = None
268280

269-
# Try decryption with multiple keys if the first one fails
270-
decrypted_path = os.path.join(self.decrypted_dir, f"video.{extension_output}")
271-
result_path = None
272-
273-
for i, key_info in enumerate(keys):
274-
KID = key_info['kid']
275-
KEY = key_info['key']
276-
console.log(f"[cyan]Trying video decryption with key: [red]{KID}")
277-
result_path = decrypt_with_mp4decrypt("Video", encrypted_path, KID, KEY, output_path=decrypted_path)
278-
279-
if result_path:
280-
working_key = key_info # Store the working key for audio decryption
281-
break
282-
else:
283-
console.log(f"[yellow]✗ Video decryption failed with key {i+1}")
281+
# Decrypt video using the mapped key
282+
decrypted_path = os.path.join(self.decrypted_dir, f"video.{EXTENSION_OUTPUT}")
283+
result_path = decrypt_with_mp4decrypt("Video", encrypted_path, video_key_info['kid'], video_key_info['key'], output_path=decrypted_path)
284284

285285
if not result_path:
286-
self.error = "Video decryption failed with all available keys"
286+
self.error = f"Video decryption failed with key {video_key_info['kid']}"
287287
return False
288288

289289
else:
290290
self.error = "No video found"
291291
return False
292292

293-
# Now download audio with segment limiting
293+
# Download and decrypt audio
294294
audio_rep = self.get_representation_by_type("audio")
295295
if audio_rep:
296+
audio_key_info = key_mapping.get("audio")
297+
if not audio_key_info:
298+
self.error = "No key found for audio representation"
299+
return False
300+
301+
console.log(f"[cyan]Using audio key: [red]{audio_key_info['kid']} [cyan]for representation [yellow]{audio_key_info.get('representation_id')}")
302+
296303
audio_language = audio_rep.get('language', 'Unknown')
297304
audio_downloader = MPD_Segments(tmp_folder=self.encrypted_dir, representation=audio_rep, pssh=self.parser.pssh, limit_segments=video_segments_count if video_segments_count > 0 else None, custom_headers=custom_headers)
298305
encrypted_path = audio_downloader.get_concat_path(self.encrypted_dir)
@@ -325,32 +332,13 @@ def download_and_decrypt(self, custom_headers=None, query_params=None, key=None)
325332
self.current_downloader = None
326333
self.current_download_type = None
327334

328-
# Decrypt audio
329-
decrypted_path = os.path.join(self.decrypted_dir, f"audio.{extension_output}")
330-
result_path = decrypt_with_mp4decrypt(
331-
f"Audio {audio_language}", encrypted_path, working_key['kid'], working_key['key'], output_path=decrypted_path
332-
)
335+
# Decrypt audio using the mapped key
336+
decrypted_path = os.path.join(self.decrypted_dir, f"audio.{EXTENSION_OUTPUT}")
337+
result_path = decrypt_with_mp4decrypt(f"Audio {audio_language}", encrypted_path, audio_key_info['kid'], audio_key_info['key'], output_path=decrypted_path)
333338

334339
if not result_path:
335-
# If the video key doesn't work for audio, try other keys
336-
console.log("[yellow]Video key failed for audio, trying other keys...")
337-
338-
for i, key_info in enumerate(keys):
339-
if key_info['kid'] == working_key['kid']:
340-
continue # Skip the already tried key
341-
342-
console.log(f"[cyan]Trying audio decryption with alternative key: {key_info['kid']}")
343-
result_path = decrypt_with_mp4decrypt(f"Audio {audio_language}", encrypted_path, key_info['kid'], key_info['key'], output_path=decrypted_path)
344-
345-
if result_path:
346-
console.log("[green]Audio decryption successful with alternative key")
347-
break
348-
else:
349-
console.log("[yellow]Audio decryption failed with alternative key")
350-
351-
if not result_path:
352-
self.error = "Audio decryption failed with all available keys"
353-
return False
340+
self.error = f"Audio decryption failed with key {audio_key_info['kid']}"
341+
return False
354342

355343
else:
356344
self.error = "No audio found"
@@ -415,7 +403,7 @@ def download_segments(self, clear=False):
415403
self.current_download_type = None
416404

417405
# NO DECRYPTION: just copy/move to decrypted folder
418-
decrypted_path = os.path.join(self.decrypted_dir, f"video.{extension_output}")
406+
decrypted_path = os.path.join(self.decrypted_dir, f"video.{EXTENSION_OUTPUT}")
419407
if os.path.exists(encrypted_path) and not os.path.exists(decrypted_path):
420408
shutil.copy2(encrypted_path, decrypted_path)
421409

@@ -459,7 +447,7 @@ def download_segments(self, clear=False):
459447
self.current_download_type = None
460448

461449
# NO DECRYPTION: just copy/move to decrypted folder
462-
decrypted_path = os.path.join(self.decrypted_dir, f"audio.{extension_output}")
450+
decrypted_path = os.path.join(self.decrypted_dir, f"audio.{EXTENSION_OUTPUT}")
463451
if os.path.exists(encrypted_path) and not os.path.exists(decrypted_path):
464452
shutil.copy2(encrypted_path, decrypted_path)
465453

@@ -480,8 +468,8 @@ def finalize_output(self):
480468
return output_file
481469

482470
# Definition of decrypted files
483-
video_file = os.path.join(self.decrypted_dir, f"video.{extension_output}")
484-
audio_file = os.path.join(self.decrypted_dir, f"audio.{extension_output}")
471+
video_file = os.path.join(self.decrypted_dir, f"video.{EXTENSION_OUTPUT}")
472+
audio_file = os.path.join(self.decrypted_dir, f"audio.{EXTENSION_OUTPUT}")
485473
output_file = self.original_output_path
486474

487475
# Set the output file path for status tracking

StreamingCommunity/Lib/DASH/parser.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,25 @@ def is_protected(self, element: etree._Element) -> bool:
222222

223223
return False
224224

225+
def extract_default_kid(self, element: etree._Element) -> Optional[str]:
226+
"""Extract default_KID from ContentProtection element"""
227+
for cp in self.ns.findall(element, 'mpd:ContentProtection'):
228+
scheme_id = (cp.get('schemeIdUri') or '').lower()
229+
230+
# Look for CENC protection with default_KID
231+
if 'urn:mpeg:dash:mp4protection:2011' in scheme_id:
232+
kid = cp.get('{urn:mpeg:cenc:2013}default_KID')
233+
if not kid:
234+
kid = cp.get('cenc:default_KID')
235+
if not kid:
236+
kid = cp.get('default_KID')
237+
238+
if kid:
239+
# Remove dashes and return normalized KID
240+
return kid.replace('-', '').lower()
241+
242+
return None
243+
225244
def extract_pssh(self, root: etree._Element) -> Optional[str]:
226245
"""Extract PSSH (Protection System Specific Header)"""
227246
# Try Widevine first
@@ -446,12 +465,14 @@ def parse_adaptation_set(
446465
mime_type = adapt_set.get('mimeType', '')
447466
lang = adapt_set.get('lang', '')
448467
adapt_frame_rate = adapt_set.get('frameRate')
468+
content_type = adapt_set.get('contentType', '')
449469

450470
# Resolve base URL
451471
adapt_base = self.url_resolver.resolve_base_url(adapt_set, base_url)
452472

453-
# Check protection
473+
# Check protection and extract default_KID
454474
adapt_protected = self.protection_handler.is_protected(adapt_set)
475+
adapt_default_kid = self.protection_handler.extract_default_kid(adapt_set)
455476

456477
# Get segment template
457478
adapt_seg_template = self.ns.find(adapt_set, 'mpd:SegmentTemplate')
@@ -469,6 +490,11 @@ def parse_adaptation_set(
469490
rep['channels'] = self.metadata_extractor.get_audio_channels(rep_elem, adapt_set)
470491
rep_protected = adapt_protected or self.protection_handler.is_protected(rep_elem)
471492
rep['protected'] = bool(rep_protected)
493+
rep_default_kid = self.protection_handler.extract_default_kid(rep_elem) or adapt_default_kid
494+
rep['default_kid'] = rep_default_kid
495+
if content_type:
496+
rep['type'] = content_type
497+
472498
representations.append(rep)
473499

474500
return representations

0 commit comments

Comments
 (0)