mirror of https://github.com/yt-dlp/yt-dlp.git
[fd/dash, pp/ffmpeg] support DASH CENC decryption
This commit is contained in:
parent
a95757d3b7
commit
6b0ce31939
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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')
|
||||||
|
|
|
@ -8,6 +8,7 @@ from .ffmpeg import (
|
||||||
FFmpegCopyStreamPP,
|
FFmpegCopyStreamPP,
|
||||||
FFmpegEmbedSubtitlePP,
|
FFmpegEmbedSubtitlePP,
|
||||||
FFmpegExtractAudioPP,
|
FFmpegExtractAudioPP,
|
||||||
|
FFmpegCENCDecryptPP,
|
||||||
FFmpegFixupDuplicateMoovPP,
|
FFmpegFixupDuplicateMoovPP,
|
||||||
FFmpegFixupDurationPP,
|
FFmpegFixupDurationPP,
|
||||||
FFmpegFixupM3u8PP,
|
FFmpegFixupM3u8PP,
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue