Compare commits

...

5 Commits

Author SHA1 Message Date
Peter Rowlands (변기호)
71f245d22e
Merge bd62cdba1a into c699bafc50 2024-11-16 12:11:31 +09:00
Peter Rowlands
bd62cdba1a [fd/dash] support DASH SEA (AES-128-CBC) decryption 2024-10-05 17:21:50 +09:00
Peter Rowlands
e0ce6eed92 [extractor] Parse DASH-SEA content protection in DASH manifests 2024-10-05 17:21:47 +09:00
Peter Rowlands
6b0ce31939 [fd/dash, pp/ffmpeg] support DASH CENC decryption 2024-10-05 00:59:58 +09:00
Peter Rowlands
a95757d3b7 [extractor] parse CENC + Clear Key information in DASH manifests 2024-10-04 21:02:26 +09:00
9 changed files with 528 additions and 11 deletions

View File

@ -1381,6 +1381,175 @@ jwplayer("mediaplayer").setup({"abouttext":"Visit Indie DB","aboutlink":"http:\/
}, },
], ],
}, },
), (
# Clear Key with CENC default_KID
'clearkey_cenc',
'https://media.axprod.net/TestVectors/v7-MultiDRM-SingleKey/Manifest_1080p_ClearKey.mpd', # mpd_url
'https://media.axprod.net/TestVectors/v7-MultiDRM-SingleKey/', # mpd_base_url
[{
'manifest_url': 'https://media.axprod.net/TestVectors/v7-MultiDRM-SingleKey/Manifest_1080p_ClearKey.mpd',
'ext': 'mp4',
'format_id': '1',
'format_note': 'DASH video',
'protocol': 'http_dash_segments',
'acodec': 'none',
'vcodec': 'avc1.64001f',
'tbr': 389.802,
'width': 512,
'height': 288,
'dash_cenc': {
'laurl': 'https://drm-clearkey-testvectors.axtest.net/AcquireLicense',
'key_ids': ['9eb4050de44b4802932e27d75083e266'],
},
}, {
'manifest_url': 'https://media.axprod.net/TestVectors/v7-MultiDRM-SingleKey/Manifest_1080p_ClearKey.mpd',
'ext': 'mp4',
'format_id': '2',
'format_note': 'DASH video',
'protocol': 'http_dash_segments',
'acodec': 'none',
'vcodec': 'avc1.64001f',
'tbr': 764.935,
'width': 640,
'height': 360,
'dash_cenc': {
'laurl': 'https://drm-clearkey-testvectors.axtest.net/AcquireLicense',
'key_ids': ['9eb4050de44b4802932e27d75083e266'],
},
}, {
'manifest_url': 'https://media.axprod.net/TestVectors/v7-MultiDRM-SingleKey/Manifest_1080p_ClearKey.mpd',
'ext': 'mp4',
'format_id': '3',
'format_note': 'DASH video',
'protocol': 'http_dash_segments',
'acodec': 'none',
'vcodec': 'avc1.640028',
'tbr': 1120.439,
'width': 852,
'height': 480,
'dash_cenc': {
'laurl': 'https://drm-clearkey-testvectors.axtest.net/AcquireLicense',
'key_ids': ['9eb4050de44b4802932e27d75083e266'],
},
}, {
'manifest_url': 'https://media.axprod.net/TestVectors/v7-MultiDRM-SingleKey/Manifest_1080p_ClearKey.mpd',
'ext': 'mp4',
'format_id': '4',
'format_note': 'DASH video',
'protocol': 'http_dash_segments',
'acodec': 'none',
'vcodec': 'avc1.640032',
'tbr': 1945.258,
'width': 1280,
'height': 720,
'dash_cenc': {
'laurl': 'https://drm-clearkey-testvectors.axtest.net/AcquireLicense',
'key_ids': ['9eb4050de44b4802932e27d75083e266'],
},
}, {
'manifest_url': 'https://media.axprod.net/TestVectors/v7-MultiDRM-SingleKey/Manifest_1080p_ClearKey.mpd',
'ext': 'mp4',
'format_id': '5',
'format_note': 'DASH video',
'protocol': 'http_dash_segments',
'acodec': 'none',
'vcodec': 'avc1.640033',
'tbr': 2726.377,
'width': 1920,
'height': 1080,
'dash_cenc': {
'laurl': 'https://drm-clearkey-testvectors.axtest.net/AcquireLicense',
'key_ids': ['9eb4050de44b4802932e27d75083e266'],
},
}],
{},
), (
# default CENC KID overridden via W3C PSSH box, no license server in manifest
'w3c_pssh',
'https://unknown/manifest.mpd', # mpd_url
'https://unknown/', # mpd_base_url
[{
'manifest_url': 'https://unknown/manifest.mpd',
'ext': 'mp4',
'format_id': '1',
'format_note': 'DASH video',
'protocol': 'http_dash_segments',
'acodec': 'none',
'vcodec': 'avc1.64001f',
'tbr': 389.802,
'width': 512,
'height': 288,
'dash_cenc': {
'key_ids': ['43215678123412341234123412341234'],
},
'has_drm': True,
}],
{},
), (
# DASH SEA with AES-128-CBC
'dash_sea',
'https://unknown/manifest.mpd', # mpd_url
'https://unknown/', # mpd_base_url
[{
'manifest_url': 'https://unknown/manifest.mpd',
'ext': 'm4a',
'format_id': '5_A_aac_eng_2_127999_2_1_1',
'format_note': 'DASH audio',
'protocol': 'http_dash_segments',
'acodec': 'mp4a.40.2',
'vcodec': 'none',
'tbr': 127.999,
'hls_aes': {
'uri': 'https://zavideoplatform.keydelivery.eastus.media.azure.net/?kid=9280864f-064e-48c0-97e0-f2bcb1d8d012',
'iv': '0x7BD31E102B0CE9CCD39691782533656C',
},
}, {
'manifest_url': 'https://unknown/manifest.mpd',
'ext': 'mp4',
'format_id': '1_V_video_3',
'format_note': 'DASH video',
'protocol': 'http_dash_segments',
'acodec': 'none',
'vcodec': 'avc1.64001F',
'tbr': 258.591,
'width': 960,
'height': 540,
'hls_aes': {
'uri': 'https://zavideoplatform.keydelivery.eastus.media.azure.net/?kid=9280864f-064e-48c0-97e0-f2bcb1d8d012',
'iv': '0x7BD31E102B0CE9CCD39691782533656C',
},
}, {
'manifest_url': 'https://unknown/manifest.mpd',
'ext': 'mp4',
'format_id': '1_V_video_2',
'format_note': 'DASH video',
'protocol': 'http_dash_segments',
'acodec': 'none',
'vcodec': 'avc1.64001F',
'tbr': 422.519,
'width': 1280,
'height': 720,
'hls_aes': {
'uri': 'https://zavideoplatform.keydelivery.eastus.media.azure.net/?kid=9280864f-064e-48c0-97e0-f2bcb1d8d012',
'iv': '0x7BD31E102B0CE9CCD39691782533656C',
},
}, {
'manifest_url': 'https://unknown/manifest.mpd',
'ext': 'mp4',
'format_id': '1_V_video_1',
'format_note': 'DASH video',
'protocol': 'http_dash_segments',
'acodec': 'none',
'vcodec': 'avc1.640028',
'tbr': 628.102,
'width': 1920,
'height': 1080,
'hls_aes': {
'uri': 'https://zavideoplatform.keydelivery.eastus.media.azure.net/?kid=9280864f-064e-48c0-97e0-f2bcb1d8d012',
'iv': '0x7BD31E102B0CE9CCD39691782533656C',
},
}],
{},
), ),
] ]

29
test/testdata/mpd/clearkey_cenc.mpd vendored Normal file
View File

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Version information:
Axinom.MediaProcessing v3.0.0 targeting General Purpose Media Format specification v7
ffmpeg version N-81423-g61fac0e Copyright (c) 2000-2016 the FFmpeg developers
x265 [info]: HEVC encoder version 2.0+12-49a0d1176aef5bc6
x264 0.148.2705 3f5ed56
MP4Box - GPAC version 0.6.2-DEV-rev683-g7b29fbe-master
MediaInfoLib - v0.7.87
For more info about this video, see https://github.com/Axinom/dash-test-vectors
-->
<MPD xmlns="urn:mpeg:dash:schema:mpd:2011" minBufferTime="PT1.500S" type="static" mediaPresentationDuration="PT0H12M14.000S" maxSegmentDuration="PT0H0M4.000S" profiles="urn:mpeg:dash:profile:isoff-live:2011,http://dashif.org/guidelines/dash264" xmlns:cenc="urn:mpeg:cenc:2013" xmlns:clearkey="http://dashif.org/guidelines/clearKey">
<Period duration="PT0H12M14.000S">
<AdaptationSet segmentAlignment="true" group="1" maxWidth="1920" maxHeight="1080" maxFrameRate="24" par="16:9" lang="und">
<ContentProtection schemeIdUri="urn:mpeg:dash:mp4protection:2011" value="cenc" cenc:default_KID="9eb4050d-e44b-4802-932e-27d75083e266" />
<ContentProtection value="ClearKey1.0" schemeIdUri="urn:uuid:e2719d58-a985-b3c9-781a-b030af78d30e">
<clearkey:Laurl Lic_type="EME-1.0">https://drm-clearkey-testvectors.axtest.net/AcquireLicense</clearkey:Laurl>
</ContentProtection>
<Role schemeIdUri="urn:mpeg:dash:role:2011" value="main" />
<SegmentTemplate timescale="24" media="$RepresentationID$/$Number%04d$.m4s" startNumber="1" duration="96" initialization="$RepresentationID$/init.mp4" />
<Representation id="1" mimeType="video/mp4" codecs="avc1.64001f" width="512" height="288" frameRate="24" sar="1:1" startWithSAP="1" bandwidth="389802"></Representation>
<Representation id="2" mimeType="video/mp4" codecs="avc1.64001f" width="640" height="360" frameRate="24" sar="1:1" startWithSAP="1" bandwidth="764935"></Representation>
<Representation id="3" mimeType="video/mp4" codecs="avc1.640028" width="852" height="480" frameRate="24" sar="640:639" startWithSAP="1" bandwidth="1120439"></Representation>
<Representation id="4" mimeType="video/mp4" codecs="avc1.640032" width="1280" height="720" frameRate="24" sar="1:1" startWithSAP="1" bandwidth="1945258"></Representation>
<Representation id="5" mimeType="video/mp4" codecs="avc1.640033" width="1920" height="1080" frameRate="24" sar="1:1" startWithSAP="1" bandwidth="2726377"></Representation>
</AdaptationSet>
</Period>
</MPD>

109
test/testdata/mpd/dash_sea.mpd vendored Normal file
View File

@ -0,0 +1,109 @@
<?xml version="1.0" encoding="utf-8"?>
<MPD
xmlns="urn:mpeg:dash:schema:mpd:2011"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" profiles="urn:mpeg:dash:profile:isoff-live:2011" type="static"
xmlns:sea="urn:mpeg:dash:schema:sea:2012" mediaPresentationDuration="PT3M32.949S" minBufferTime="PT3S">
<Period>
<AdaptationSet id="1" group="5" profiles="ccff" bitstreamSwitching="false" segmentAlignment="true" contentType="audio" mimeType="audio/mp4" codecs="mp4a.40.2" lang="en">
<ContentProtection schemeIdUri="urn:mpeg:dash:sea:2012">
<sea:SegmentEncryption schemeIdUri="urn:mpeg:dash:sea:aes128-cbc:2013"/>
<sea:KeySystem keySystemUri="urn:mpeg:dash:sea:keysys:http:2013"/>
<sea:CryptoPeriod keyUriTemplate="https://zavideoplatform.keydelivery.eastus.media.azure.net/?kid=9280864f-064e-48c0-97e0-f2bcb1d8d012" IV="0x7BD31E102B0CE9CCD39691782533656C"/>
</ContentProtection>
<Label>aac_eng_2_127999_2_1</Label>
<SegmentTemplate timescale="10000000" media="QualityLevels($Bandwidth$)/Fragments(aac_eng_2_127999_2_1=$Time$,format=mpd-time-csf)" initialization="QualityLevels($Bandwidth$)/Fragments(aac_eng_2_127999_2_1=i,format=mpd-time-csf)">
<SegmentTimeline>
<S d="20053333" r="1"/>
<S d="20053334"/>
<S d="20053333" r="1"/>
<S d="20053334"/>
<S d="20053333" r="1"/>
<S d="20053334"/>
<S d="20053333" r="1"/>
<S d="20053334"/>
<S d="20053333" r="1"/>
<S d="20053334"/>
<S d="20053333" r="1"/>
<S d="20053334"/>
<S d="20053333" r="1"/>
<S d="20053334"/>
<S d="20053333" r="1"/>
<S d="20053334"/>
<S d="20053333" r="1"/>
<S d="20053334"/>
<S d="20053333" r="1"/>
<S d="20053334"/>
<S d="20053333" r="1"/>
<S d="20053334"/>
<S d="20053333" r="1"/>
<S d="20053334"/>
<S d="20053333" r="1"/>
<S d="20053334"/>
<S d="20053333" r="1"/>
<S d="20053334"/>
<S d="20053333" r="1"/>
<S d="20053334"/>
<S d="20053333" r="1"/>
<S d="20053334"/>
<S d="20053333" r="1"/>
<S d="20053334"/>
<S d="20053333" r="1"/>
<S d="20053334"/>
<S d="20053333" r="1"/>
<S d="20053334"/>
<S d="20053333" r="1"/>
<S d="20053334"/>
<S d="20053333" r="1"/>
<S d="20053334"/>
<S d="20053333" r="1"/>
<S d="20053334"/>
<S d="20053333" r="1"/>
<S d="20053334"/>
<S d="20053333" r="1"/>
<S d="20053334"/>
<S d="20053333" r="1"/>
<S d="20053334"/>
<S d="20053333" r="1"/>
<S d="20053334"/>
<S d="20053333" r="1"/>
<S d="20053334"/>
<S d="20053333" r="1"/>
<S d="20053334"/>
<S d="20053333" r="1"/>
<S d="20053334"/>
<S d="20053333" r="1"/>
<S d="20053334"/>
<S d="20053333" r="1"/>
<S d="20053334"/>
<S d="20053333" r="1"/>
<S d="20053334"/>
<S d="20053333" r="1"/>
<S d="20053334"/>
<S d="20053333" r="1"/>
<S d="20053334"/>
<S d="20053333" r="1"/>
<S d="20053334"/>
<S d="20053333"/>
<S d="3840000"/>
</SegmentTimeline>
</SegmentTemplate>
<Representation id="5_A_aac_eng_2_127999_2_1_1" bandwidth="127999" audioSamplingRate="48000"/>
</AdaptationSet>
<AdaptationSet id="2" group="1" profiles="ccff" bitstreamSwitching="false" segmentAlignment="true" contentType="video" mimeType="video/mp4" codecs="avc1.640028" maxWidth="1920" maxHeight="1080" startWithSAP="1">
<ContentProtection schemeIdUri="urn:mpeg:dash:sea:2012">
<sea:SegmentEncryption schemeIdUri="urn:mpeg:dash:sea:aes128-cbc:2013"/>
<sea:KeySystem keySystemUri="urn:mpeg:dash:sea:keysys:http:2013"/>
<sea:CryptoPeriod keyUriTemplate="https://zavideoplatform.keydelivery.eastus.media.azure.net/?kid=9280864f-064e-48c0-97e0-f2bcb1d8d012" IV="0x7BD31E102B0CE9CCD39691782533656C"/>
</ContentProtection>
<SegmentTemplate timescale="10000000" media="QualityLevels($Bandwidth$)/Fragments(video=$Time$,format=mpd-time-csf)" initialization="QualityLevels($Bandwidth$)/Fragments(video=i,format=mpd-time-csf)">
<SegmentTimeline>
<S d="20000000" r="105"/>
<S d="8666666"/>
</SegmentTimeline>
</SegmentTemplate>
<Representation id="1_V_video_1" bandwidth="628102" width="1920" height="1080"/>
<Representation id="1_V_video_2" bandwidth="422519" codecs="avc1.64001F" width="1280" height="720"/>
<Representation id="1_V_video_3" bandwidth="258591" codecs="avc1.64001F" width="960" height="540"/>
</AdaptationSet>
</Period>
</MPD>

13
test/testdata/mpd/w3c_pssh.mpd vendored Normal file
View File

@ -0,0 +1,13 @@
<MPD xmlns="urn:mpeg:dash:schema:mpd:2011" minBufferTime="PT1.500S" type="static" mediaPresentationDuration="PT0H12M14.000S" maxSegmentDuration="PT0H0M4.000S" profiles="urn:mpeg:dash:profile:isoff-live:2011,http://dashif.org/guidelines/dash264" xmlns:cenc="urn:mpeg:cenc:2013" xmlns:clearkey="http://dashif.org/guidelines/clearKey">
<Period duration="PT0H12M14.000S">
<AdaptationSet segmentAlignment="true" group="1" maxWidth="1920" maxHeight="1080" maxFrameRate="24" par="16:9" lang="und">
<ContentProtection schemeIdUri="urn:mpeg:dash:mp4protection:2011" value="cenc" cenc:default_KID="9eb4050d-e44b-4802-932e-27d75083e266" />
<ContentProtection schemeIdUri="urn:uuid:1077efec-c0b2-4d02-ace3-3c1e52e2fb4b">
<cenc:pssh>AAAANHBzc2gBAAAAEHfv7MCyTQKs4zweUuL7SwAAAAFDIVZ4EjQSNBI0EjQSNBI0AAAAAA==</cenc:pssh>
</ContentProtection>
<Role schemeIdUri="urn:mpeg:dash:role:2011" value="main" />
<SegmentTemplate timescale="24" media="$RepresentationID$/$Number%04d$.m4s" startNumber="1" duration="96" initialization="$RepresentationID$/init.mp4" />
<Representation id="1" mimeType="video/mp4" codecs="avc1.64001f" width="512" height="288" frameRate="24" sar="1:1" startWithSAP="1" bandwidth="389802"></Representation>
</AdaptationSet>
</Period>
</MPD>

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,
@ -3380,6 +3381,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:
@ -3460,8 +3463,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
@ -3478,6 +3485,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,9 +1,14 @@
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 ..utils import update_url_query, urljoin from ..networking import Request
from ..networking.exceptions import RequestError
from ..utils import remove_start, traverse_obj, update_url_query, urljoin
class DashSegmentsFD(FragmentFD): class DashSegmentsFD(FragmentFD):
@ -49,6 +54,25 @@ class DashSegmentsFD(FragmentFD):
if extra_param_to_segment_url: if extra_param_to_segment_url:
extra_query = urllib.parse.parse_qs(extra_param_to_segment_url) extra_query = urllib.parse.parse_qs(extra_param_to_segment_url)
hls_aes = fmt.get('hls_aes', {})
if hls_aes:
decrypt_info = {'METHOD', 'AES-128'}
key = hls_aes.get('key')
if key:
key = binascii.unhexlify(remove_start(key, '0x'))
assert len(key) in (16, 24, 32), 'Invalid length for HLS AES-128 key'
decrypt_info['KEY'] = key
iv = hls_aes.get('iv')
if iv:
iv = binascii.unhexlify(remove_start(iv, '0x').zfill(32))
decrypt_info['IV'] = iv
uri = hls_aes.get('uri')
if uri:
if extra_query:
uri = update_url_query(uri, extra_query)
decrypt_info['URI'] = uri
ctx['decrypt_info'] = decrypt_info
fragments_to_download = self._get_fragments(fmt, ctx, extra_query) fragments_to_download = self._get_fragments(fmt, ctx, extra_query)
if real_downloader: if real_downloader:
@ -60,6 +84,12 @@ class DashSegmentsFD(FragmentFD):
args.append([ctx, fragments_to_download, fmt]) args.append([ctx, fragments_to_download, fmt])
cenc_key = traverse_obj(info_dict, ('dash_cenc', 'key'))
cenc_key_ids = traverse_obj(info_dict, ('dash_cenc', 'key_ids'))
clearkey_laurl = traverse_obj(info_dict, ('dash_cenc', 'laurl'))
if not cenc_key and cenc_key_ids and clearkey_laurl:
self._get_clearkey_cenc(info_dict, clearkey_laurl, cenc_key_ids)
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):
@ -87,4 +117,35 @@ class DashSegmentsFD(FragmentFD):
'fragment_count': fragment.get('fragment_count'), 'fragment_count': fragment.get('fragment_count'),
'index': i, 'index': i,
'url': fragment_url, 'url': fragment_url,
'decrypt_info': ctx.get('decrypt_info', {'METHOD': 'NONE'}),
} }
def _get_clearkey_cenc(self, info_dict, laurl, key_ids):
dash_cenc = info_dict.get('dash_cenc', {})
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.update({'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

@ -14,12 +14,14 @@ import netrc
import os import os
import random import random
import re import re
import struct
import subprocess import subprocess
import sys import sys
import time import time
import types import types
import urllib.parse import urllib.parse
import urllib.request import urllib.request
import uuid
import xml.etree.ElementTree import xml.etree.ElementTree
from ..compat import ( from ..compat import (
@ -247,7 +249,9 @@ class InfoExtractor:
* hls_aes A dictionary of HLS AES-128 decryption information * hls_aes A dictionary of HLS AES-128 decryption information
used by the native HLS downloader to override the used by the native HLS downloader to override the
values in the media playlist when an '#EXT-X-KEY' tag values in the media playlist when an '#EXT-X-KEY' tag
is present in the playlist: is present in the playlist. Used by the native DASH downloader
when DASH-SEA with AES-128-CBC content protection is present
in the manifest.:
* uri The URI from which the key will be downloaded * uri The URI from which the key will be downloaded
* key The key (as hex) used to decrypt fragments. * key The key (as hex) used to decrypt fragments.
If `key` is given, any key URI will be ignored If `key` is given, any key URI will be ignored
@ -259,6 +263,16 @@ class InfoExtractor:
* ffmpeg_args_out Extra arguments for ffmpeg downloader (output) * ffmpeg_args_out Extra arguments for ffmpeg downloader (output)
* is_dash_periods Whether the format is a result of merging * is_dash_periods Whether the format is a result of merging
multiple DASH periods. multiple DASH periods.
* dash_cenc A dictionary of DASH CENC decryption information
used by the native DASH downloader when MPEG CENC content protection
is present in the manifest.
* laurl The Clear Key license server URL from which
CENC keys will be downloaded.
* key_ids List of key IDs (as hex) to request from the ClearKey
license server.
* key The CENC key (as hex) used to decrypt fragments.
If `key` is given, any license server URL and
key IDs will be ignored.
RTMP formats can also have the additional fields: page_url, RTMP formats can also have the additional fields: page_url,
app, play_path, tc_url, flash_version, rtmp_live, rtmp_conn, app, play_path, tc_url, flash_version, rtmp_live, rtmp_conn,
rtmp_protocol, rtmp_real_time rtmp_protocol, rtmp_real_time
@ -2680,7 +2694,11 @@ class InfoExtractor:
assert 'is_dash_periods' not in f, 'format already processed' assert 'is_dash_periods' not in f, 'format already processed'
f['is_dash_periods'] = True f['is_dash_periods'] = True
format_key = tuple(v for k, v in f.items() if k not in ( format_key = tuple(v for k, v in f.items() if k not in (
('format_id', 'fragments', 'manifest_stream_number'))) ('format_id', 'fragments', 'manifest_stream_number', 'dash_cenc', 'hls_aes')))
for k in ('dash_cenc', 'hls_aes'):
if k in f:
format_key = format_key + tuple(
tuple(v) if isinstance(v, list) else v for v in f[k].values())
if format_key not in formats: if format_key not in formats:
formats[format_key] = f formats[format_key] = f
elif 'fragments' in f: elif 'fragments' in f:
@ -2714,8 +2732,16 @@ class InfoExtractor:
def _add_ns(path): def _add_ns(path):
return self._xpath_ns(path, namespace) return self._xpath_ns(path, namespace)
def is_drm_protected(element): def extract_drm_info(element):
return element.find(_add_ns('ContentProtection')) is not None info = {}
has_drm = False
for cp_e in element.findall(_add_ns('ContentProtection')):
has_drm = True
self._extract_mpd_content_protection_info(cp_e, info)
cenc_info = info.get('dash_cenc', {})
if has_drm and not ('hls_aes' in info or cenc_info.get('key') or (cenc_info.get('laurl') and cenc_info.get('key_ids'))):
info['has_drm'] = True
return info
def extract_multisegment_info(element, ms_parent_info): def extract_multisegment_info(element, ms_parent_info):
ms_info = ms_parent_info.copy() ms_info = ms_parent_info.copy()
@ -2789,6 +2815,7 @@ class InfoExtractor:
'timescale': 1, 'timescale': 1,
}) })
for adaptation_set in period.findall(_add_ns('AdaptationSet')): for adaptation_set in period.findall(_add_ns('AdaptationSet')):
adaptation_set_drm_info = extract_drm_info(adaptation_set)
adaption_set_ms_info = extract_multisegment_info(adaptation_set, period_ms_info) adaption_set_ms_info = extract_multisegment_info(adaptation_set, period_ms_info)
for representation in adaptation_set.findall(_add_ns('Representation')): for representation in adaptation_set.findall(_add_ns('Representation')):
representation_attrib = adaptation_set.attrib.copy() representation_attrib = adaptation_set.attrib.copy()
@ -2875,8 +2902,8 @@ class InfoExtractor:
'acodec': 'none', 'acodec': 'none',
'vcodec': 'none', 'vcodec': 'none',
} }
if is_drm_protected(adaptation_set) or is_drm_protected(representation): f.update(adaptation_set_drm_info)
f['has_drm'] = True f.update(extract_drm_info(representation))
representation_ms_info = extract_multisegment_info(representation, adaption_set_ms_info) representation_ms_info = extract_multisegment_info(representation, adaption_set_ms_info)
def prepare_template(template_name, identifiers): def prepare_template(template_name, identifiers):
@ -3037,6 +3064,86 @@ class InfoExtractor:
period_entry['subtitles'][lang or 'und'].append(f) period_entry['subtitles'][lang or 'und'].append(f)
yield period_entry yield period_entry
def _extract_mpd_content_protection_info(self, cp_e, info):
"""
Extract supported DASH-CENC parameters for an MPD ContentProtection element.
Called multiple times per extracted format in an MPD (once per ContentProtection element
within AdaptationSet and Representation elements). Subclasses may override this method
when necessary (such as when the Clear Key license server URL is provided separately
from the manifest or when an extractor needs to process the optional data section in W3C
PSSH boxes).
Note that after all ContentProtection elements have been handled, the `has_drm` flag
will be set for any format that does not meet one or more of these conditions:
* `dash_cenc` is set and both `laurl` and `key_ids` are set (indicating the native
DASH downloader should use the specified Clear Key server URL to retreive the
CENC key for this format).
* `dash_cenc` is set and `key` is set (indicating the native DASH downloader should
use the specified CENC key for this format).
* `hls_aes` is set (indicating the native DASH downloader should use DASH SEA
AES-128-CBC decryption for this format).
References:
1. DASH-IF Content Protection Identifiers
https://dashif.org/identifiers/content_protection/
2. DASH-IF Content Protection Guidelines
https://dashif.org/docs/IOP-Guidelines/DASH-IF-IOP-Part6-v5.0.0.pdf
3. W3C "cenc" Initialization Data Format
https://w3c.github.io/encrypted-media/format-registry/initdata/cenc.html
"""
scheme_id = cp_e.get('schemeIdUri')
cenc_info = info.get('dash_cenc', {})
if scheme_id == 'urn:mpeg:dash:mp4protection:2011':
if cp_e.get('value') == 'cenc':
# ISO/IEC 23009-1 MPEG Common Encryption (CENC)
if not cenc_info.get('key_ids'):
try:
default_kid = uuid.UUID(cp_e.get('{urn:mpeg:cenc:2013}default_KID')).hex
cenc_info['key_ids'] = [default_kid]
except (ValueError, TypeError):
pass
elif scheme_id == 'urn:uuid:e2719d58-a985-b3c9-781a-b030af78d30e':
# Clear Key DASH-IF
for tag, ns in itertools.product(
('Laurl', 'laurl'),
('https://dashif.org/CPS', 'http://dashif.org/guidelines/clearKey'),
):
url_e = cp_e.find(self._xpath_ns(tag, ns))
if url_e is not None:
cenc_info['laurl'] = url_e.text
break
elif scheme_id == 'urn:uuid:1077efec-c0b2-4d02-ace3-3c1e52e2fb4b':
# W3C Common System ID
pssh_e = cp_e.find(self._xpath_ns('pssh', 'urn:mpeg:cenc:2013'))
if pssh_e is not None:
# W3C PSSH box (may contain Clear Key KIDs but can also be used
# to store KIDs for other DRM systems)
try:
pssh_box = base64.b64decode(pssh_e.text)
kid_count, = struct.unpack('!L', pssh_box[28:32])
kids = []
for i in range(kid_count):
kid = pssh_box[32 + i * 16:32 + (i + 1) * 16]
kids.append(kid.hex())
cenc_info['key_ids'] = kids
except (ValueError, TypeError, struct.error):
pass
elif scheme_id == 'urn:mpeg:dash:sea:2012':
# ISO/IEC 23009-4 DASH Segment Encryption and Authentication (AES-128-CBC)
sea_ns = 'urn:mpeg:dash:schema:sea:2012'
se_e = cp_e.find(self._xpath_ns('SegmentEncryption', sea_ns))
ks_e = cp_e.find(self._xpath_ns('KeySystem', sea_ns))
crypto_e = cp_e.find(self._xpath_ns('CryptoPeriod', sea_ns))
if (se_e is not None and se_e.get('schemeIdUri') == 'urn:mpeg:dash:sea:aes128-cbc:2013'
and ks_e is not None and ks_e.get('keySystemUri') == 'urn:mpeg:dash:sea:keysys:http:2013'
and crypto_e is not None and crypto_e.get('keyUriTemplate') and crypto_e.get('IV')
):
info['hls_aes'] = {'uri': crypto_e.get('keyUriTemplate'), 'iv': crypto_e.get('IV')}
if cenc_info:
info['dash_cenc'] = cenc_info
def _extract_ism_formats(self, *args, **kwargs): def _extract_ism_formats(self, *args, **kwargs):
fmts, subs = self._extract_ism_formats_and_subtitles(*args, **kwargs) fmts, subs = self._extract_ism_formats_and_subtitles(*args, **kwargs)
if subs: if subs:

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