From 6b0ce3193961dafbd2ac8eb9a9d1df062b2aa03a Mon Sep 17 00:00:00 2001 From: Peter Rowlands Date: Sat, 5 Oct 2024 00:59:58 +0900 Subject: [PATCH] [fd/dash, pp/ffmpeg] support DASH CENC decryption --- yt_dlp/YoutubeDL.py | 10 +++++++ yt_dlp/downloader/dash.py | 46 ++++++++++++++++++++++++++++++++ yt_dlp/postprocessor/__init__.py | 1 + yt_dlp/postprocessor/ffmpeg.py | 26 +++++++++++++++--- 4 files changed, 79 insertions(+), 4 deletions(-) diff --git a/yt_dlp/YoutubeDL.py b/yt_dlp/YoutubeDL.py index 4f45d7faf6..0e86fd7bcf 100644 --- a/yt_dlp/YoutubeDL.py +++ b/yt_dlp/YoutubeDL.py @@ -48,6 +48,7 @@ from .postprocessor import _PLUGIN_CLASSES as plugin_pps from .postprocessor import ( EmbedThumbnailPP, + FFmpegCENCDecryptPP, FFmpegFixupDuplicateMoovPP, FFmpegFixupDurationPP, FFmpegFixupM3u8PP, @@ -3384,6 +3385,8 @@ def existing_video_file(*filepaths): self.report_error(f'{msg}. Aborting') return + decrypter = FFmpegCENCDecryptPP(self) + info_dict.setdefault('__files_to_cenc_decrypt', []) if info_dict.get('requested_formats') is not None: old_ext = info_dict['ext'] if self.params.get('merge_output_format') is None: @@ -3464,8 +3467,12 @@ def correct_ext(filename, ext=new_ext): downloaded.append(fname) partial_success, real_download = self.dl(fname, new_info) info_dict['__real_download'] = info_dict['__real_download'] or real_download + if new_info.get('dash_cenc', {}).get('key'): + info_dict['__files_to_cenc_decrypt'].append((fname, new_info['dash_cenc']['key'])) success = success and partial_success + if downloaded and info_dict['__files_to_cenc_decrypt'] and decrypter.available: + info_dict['__postprocessors'].append(decrypter) if downloaded and merger.available and not self.params.get('allow_unplayable_formats'): info_dict['__postprocessors'].append(merger) info_dict['__files_to_merge'] = downloaded @@ -3482,6 +3489,9 @@ def correct_ext(filename, ext=new_ext): # So we should try to resume the download success, real_download = self.dl(temp_filename, info_dict) info_dict['__real_download'] = real_download + if info_dict.get('dash_cenc', {}).get('key') and decrypter.available: + info_dict['__postprocessors'].append(decrypter) + info_dict['__files_to_cenc_decrypt'] = [(temp_filename, info_dict['dash_cenc']['key'])] else: self.report_file_already_downloaded(dl_filename) diff --git a/yt_dlp/downloader/dash.py b/yt_dlp/downloader/dash.py index afc79b6caf..84ff79c8af 100644 --- a/yt_dlp/downloader/dash.py +++ b/yt_dlp/downloader/dash.py @@ -1,8 +1,13 @@ +import base64 +import binascii +import json import time import urllib.parse from . import get_suitable_downloader from .fragment import FragmentFD +from ..networking import Request +from ..networking.exceptions import RequestError from ..utils import update_url_query, urljoin @@ -60,6 +65,9 @@ def real_download(self, filename, info_dict): args.append([ctx, fragments_to_download, fmt]) + if 'dash_cenc' in info_dict and not info_dict['dash_cenc'].get('key'): + self._get_clearkey_cenc(info_dict) + return self.download_and_append_fragments_multiple(*args, is_fatal=lambda idx: idx == 0) def _resolve_fragments(self, fragments, ctx): @@ -88,3 +96,41 @@ def _get_fragments(self, fmt, ctx, extra_query): 'index': i, 'url': fragment_url, } + + def _get_clearkey_cenc(self, info_dict): + dash_cenc = info_dict.get('dash_cenc', {}) + laurl = dash_cenc.get('laurl') + if not laurl: + self.report_error('No Clear Key license server URL for encrypted DASH stream') + return + key_ids = dash_cenc.get('key_ids') + if not key_ids: + self.report_error('No requested CENC KIDs for encrypted DASH stream') + return + payload = json.dumps({ + 'kids': [ + base64.urlsafe_b64encode(bytes.fromhex(k)).decode().rstrip('=') + for k in key_ids + ], + 'type': 'temporary', + }).encode() + try: + response = self.ydl.urlopen(Request( + laurl, data=payload, headers={'Content-Type': 'application/json'})) + data = json.loads(response.read()) + except (RequestError, json.JSONDecodeError) as err: + self.report_error(f'Failed to retrieve key from Clear Key license server: {err}') + return + keys = data.get('keys', []) + if len(keys) > 1: + self.report_warning('Clear Key license server returned multiple keys but only single key CENC is supported') + for key in keys: + k = key.get('k') + if k: + try: + dash_cenc['key'] = base64.urlsafe_b64decode(f'{k}==').hex() + info_dict['dash_cenc'] = dash_cenc + return + except (ValueError, binascii.Error): + pass + self.report_error('Clear key license server did not return any valid CENC keys') diff --git a/yt_dlp/postprocessor/__init__.py b/yt_dlp/postprocessor/__init__.py index 164540b5db..8673724065 100644 --- a/yt_dlp/postprocessor/__init__.py +++ b/yt_dlp/postprocessor/__init__.py @@ -8,6 +8,7 @@ FFmpegCopyStreamPP, FFmpegEmbedSubtitlePP, FFmpegExtractAudioPP, + FFmpegCENCDecryptPP, FFmpegFixupDuplicateMoovPP, FFmpegFixupDurationPP, FFmpegFixupM3u8PP, diff --git a/yt_dlp/postprocessor/ffmpeg.py b/yt_dlp/postprocessor/ffmpeg.py index 164c46d143..6670a3d418 100644 --- a/yt_dlp/postprocessor/ffmpeg.py +++ b/yt_dlp/postprocessor/ffmpeg.py @@ -331,7 +331,7 @@ def run_ffmpeg_multiple_files(self, input_paths, out_path, opts, **kwargs): [(path, []) for path in input_paths], [(out_path, opts)], **kwargs) - def real_run_ffmpeg(self, input_path_opts, output_path_opts, *, expected_retcodes=(0,)): + def real_run_ffmpeg(self, input_path_opts, output_path_opts, *, prepend_opts=None, expected_retcodes=(0,)): self.check_version() oldest_mtime = min( @@ -342,6 +342,9 @@ def real_run_ffmpeg(self, input_path_opts, output_path_opts, *, expected_retcode if self.basename == 'ffmpeg': cmd += [encodeArgument('-loglevel'), encodeArgument('repeat+info')] + if prepend_opts: + cmd += prepend_opts + def make_args(file, args, name, number): keys = [f'_{name}{number}', f'_{name}'] if name == 'o': @@ -857,12 +860,23 @@ def can_merge(self): return True +class FFmpegCENCDecryptPP(FFmpegPostProcessor): + @PostProcessor._restrict_to(images=False) + def run(self, info): + for filename, key in info.get('__files_to_cenc_decrypt', []): + temp_filename = prepend_extension(filename, 'temp') + self.to_screen(f'Decrypting "{filename}"') + self.run_ffmpeg(filename, temp_filename, self.stream_copy_opts(), prepend_opts=['-decryption_key', key]) + os.replace(temp_filename, filename) + return [], info + + class FFmpegFixupPostProcessor(FFmpegPostProcessor): - def _fixup(self, msg, filename, options): + def _fixup(self, msg, filename, options, prepend_opts=None): temp_filename = prepend_extension(filename, 'temp') self.to_screen(f'{msg} of "{filename}"') - self.run_ffmpeg(filename, temp_filename, options) + self.run_ffmpeg(filename, temp_filename, options, prepend_opts=prepend_opts) os.replace(temp_filename, filename) @@ -934,7 +948,11 @@ class FFmpegCopyStreamPP(FFmpegFixupPostProcessor): @PostProcessor._restrict_to(images=False) def run(self, info): - self._fixup(self.MESSAGE, info['filepath'], self.stream_copy_opts()) + self._fixup( + self.MESSAGE, + info['filepath'], + self.stream_copy_opts(), + ) return [], info