[fd/dash, pp/ffmpeg] support DASH CENC decryption

This commit is contained in:
Peter Rowlands 2024-10-05 00:59:58 +09:00
parent a95757d3b7
commit 6b0ce31939
4 changed files with 79 additions and 4 deletions

View file

@ -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)

View file

@ -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')

View file

@ -8,6 +8,7 @@
FFmpegCopyStreamPP,
FFmpegEmbedSubtitlePP,
FFmpegExtractAudioPP,
FFmpegCENCDecryptPP,
FFmpegFixupDuplicateMoovPP,
FFmpegFixupDurationPP,
FFmpegFixupM3u8PP,

View file

@ -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