From 6b0ce3193961dafbd2ac8eb9a9d1df062b2aa03a Mon Sep 17 00:00:00 2001
From: Peter Rowlands <peter@pmrowla.com>
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 .plugins import directories as plugin_directories
 from .postprocessor import _PLUGIN_CLASSES as plugin_pps
 from .postprocessor import (
     EmbedThumbnailPP,
+    FFmpegCENCDecryptPP,
     FFmpegFixupDuplicateMoovPP,
     FFmpegFixupDurationPP,
     FFmpegFixupM3u8PP,
@@ -3384,6 +3385,8 @@ class YoutubeDL:
                         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 @@ class YoutubeDL:
                                 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 @@ class YoutubeDL:
                         # 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 @@ class DashSegmentsFD(FragmentFD):
 
             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 @@ class DashSegmentsFD(FragmentFD):
                 '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 @@ from .ffmpeg import (
     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 @@ class FFmpegPostProcessor(PostProcessor):
             [(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 @@ class FFmpegPostProcessor(PostProcessor):
         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 @@ class FFmpegMergerPP(FFmpegPostProcessor):
         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