[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 .plugins import directories as plugin_directories
from .postprocessor import _PLUGIN_CLASSES as plugin_pps from .postprocessor import _PLUGIN_CLASSES as plugin_pps
from .postprocessor import ( from .postprocessor import (
EmbedThumbnailPP, EmbedThumbnailPP,
FFmpegCENCDecryptPP,
FFmpegFixupDuplicateMoovPP, FFmpegFixupDuplicateMoovPP,
FFmpegFixupDurationPP, FFmpegFixupDurationPP,
FFmpegFixupM3u8PP, FFmpegFixupM3u8PP,
@ -3384,6 +3385,8 @@ class YoutubeDL:
self.report_error(f'{msg}. Aborting') self.report_error(f'{msg}. Aborting')
return return
decrypter = FFmpegCENCDecryptPP(self)
info_dict.setdefault('__files_to_cenc_decrypt', [])
if info_dict.get('requested_formats') is not None: if info_dict.get('requested_formats') is not None:
old_ext = info_dict['ext'] old_ext = info_dict['ext']
if self.params.get('merge_output_format') is None: if self.params.get('merge_output_format') is None:
@ -3464,8 +3467,12 @@ class YoutubeDL:
downloaded.append(fname) downloaded.append(fname)
partial_success, real_download = self.dl(fname, new_info) partial_success, real_download = self.dl(fname, new_info)
info_dict['__real_download'] = info_dict['__real_download'] or real_download 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 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'): if downloaded and merger.available and not self.params.get('allow_unplayable_formats'):
info_dict['__postprocessors'].append(merger) info_dict['__postprocessors'].append(merger)
info_dict['__files_to_merge'] = downloaded info_dict['__files_to_merge'] = downloaded
@ -3482,6 +3489,9 @@ class YoutubeDL:
# So we should try to resume the download # So we should try to resume the download
success, real_download = self.dl(temp_filename, info_dict) success, real_download = self.dl(temp_filename, info_dict)
info_dict['__real_download'] = real_download 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: else:
self.report_file_already_downloaded(dl_filename) self.report_file_already_downloaded(dl_filename)

View File

@ -1,8 +1,13 @@
import base64
import binascii
import json
import time import time
import urllib.parse import urllib.parse
from . import get_suitable_downloader from . import get_suitable_downloader
from .fragment import FragmentFD from .fragment import FragmentFD
from ..networking import Request
from ..networking.exceptions import RequestError
from ..utils import update_url_query, urljoin from ..utils import update_url_query, urljoin
@ -60,6 +65,9 @@ class DashSegmentsFD(FragmentFD):
args.append([ctx, fragments_to_download, fmt]) 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) return self.download_and_append_fragments_multiple(*args, is_fatal=lambda idx: idx == 0)
def _resolve_fragments(self, fragments, ctx): def _resolve_fragments(self, fragments, ctx):
@ -88,3 +96,41 @@ class DashSegmentsFD(FragmentFD):
'index': i, 'index': i,
'url': fragment_url, '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 @@ from .ffmpeg import (
FFmpegCopyStreamPP, FFmpegCopyStreamPP,
FFmpegEmbedSubtitlePP, FFmpegEmbedSubtitlePP,
FFmpegExtractAudioPP, FFmpegExtractAudioPP,
FFmpegCENCDecryptPP,
FFmpegFixupDuplicateMoovPP, FFmpegFixupDuplicateMoovPP,
FFmpegFixupDurationPP, FFmpegFixupDurationPP,
FFmpegFixupM3u8PP, FFmpegFixupM3u8PP,

View File

@ -331,7 +331,7 @@ class FFmpegPostProcessor(PostProcessor):
[(path, []) for path in input_paths], [(path, []) for path in input_paths],
[(out_path, opts)], **kwargs) [(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() self.check_version()
oldest_mtime = min( oldest_mtime = min(
@ -342,6 +342,9 @@ class FFmpegPostProcessor(PostProcessor):
if self.basename == 'ffmpeg': if self.basename == 'ffmpeg':
cmd += [encodeArgument('-loglevel'), encodeArgument('repeat+info')] cmd += [encodeArgument('-loglevel'), encodeArgument('repeat+info')]
if prepend_opts:
cmd += prepend_opts
def make_args(file, args, name, number): def make_args(file, args, name, number):
keys = [f'_{name}{number}', f'_{name}'] keys = [f'_{name}{number}', f'_{name}']
if name == 'o': if name == 'o':
@ -857,12 +860,23 @@ class FFmpegMergerPP(FFmpegPostProcessor):
return True 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): class FFmpegFixupPostProcessor(FFmpegPostProcessor):
def _fixup(self, msg, filename, options): def _fixup(self, msg, filename, options, prepend_opts=None):
temp_filename = prepend_extension(filename, 'temp') temp_filename = prepend_extension(filename, 'temp')
self.to_screen(f'{msg} of "{filename}"') 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) os.replace(temp_filename, filename)
@ -934,7 +948,11 @@ class FFmpegCopyStreamPP(FFmpegFixupPostProcessor):
@PostProcessor._restrict_to(images=False) @PostProcessor._restrict_to(images=False)
def run(self, info): 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 return [], info