From 31ab38c208699278d05918d89205580549641baf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?A=CC=81lvaro=20Velad=20Galva=CC=81n?= Date: Fri, 3 Jan 2025 13:07:09 +0100 Subject: [PATCH 1/2] fix(Offline): Allow downloading AES-128 content Also adds integration tests for DASH AES-128 and HLS SAMPLE-AES download and playback --- lib/hls/hls_parser.js | 12 +- lib/offline/download_info.js | 6 +- lib/offline/storage.js | 27 +-- test/offline/storage_playback_integration.js | 167 +++++++++++++++++++ 4 files changed, 195 insertions(+), 17 deletions(-) create mode 100644 test/offline/storage_playback_integration.js diff --git a/lib/hls/hls_parser.js b/lib/hls/hls_parser.js index 476992a817..90dac24902 100644 --- a/lib/hls/hls_parser.js +++ b/lib/hls/hls_parser.js @@ -2971,7 +2971,7 @@ shaka.hls.HlsParser = class { const stream = this.makeStreamObject_(streamId, codecs, type, languageValue, primary, name, channelsCount, closedCaptions, characteristics, forced, sampleRate, spatialAudio); - stream.encrypted = encrypted; + stream.encrypted = encrypted && !aesEncrypted; stream.drmInfos = drmInfos; stream.keyIds = keyIds; stream.mimeType = mimeType; @@ -3302,7 +3302,11 @@ shaka.hls.HlsParser = class { } } - const aesKeyInfoKey = `${drmTag.toString()}-${firstMediaSequenceNumber}`; + const keyUris = shaka.hls.Utils.constructSegmentUris( + getUris(), drmTag.getRequiredAttrValue('URI'), variables); + const keyMapKey = keyUris.sort().join(''); + const aesKeyInfoKey = + `${drmTag.toString()}-${firstMediaSequenceNumber}-${keyMapKey}`; if (!this.aesKeyInfoMap_.has(aesKeyInfoKey)) { // Default AES-128 const keyInfo = { @@ -3326,10 +3330,6 @@ shaka.hls.HlsParser = class { // Don't download the key object until the segment is parsed, to avoid a // startup delay for long manifests with lots of keys. keyInfo.fetchKey = async () => { - const keyUris = shaka.hls.Utils.constructSegmentUris( - getUris(), drmTag.getRequiredAttrValue('URI'), variables); - - const keyMapKey = keyUris.sort().join(''); if (!this.aesKeyMap_.has(keyMapKey)) { const requestType = shaka.net.NetworkingEngine.RequestType.KEY; const request = shaka.net.NetworkingEngine.makeRequest( diff --git a/lib/offline/download_info.js b/lib/offline/download_info.js index 62831f5329..1b18618628 100644 --- a/lib/offline/download_info.js +++ b/lib/offline/download_info.js @@ -22,8 +22,9 @@ shaka.offline.DownloadInfo = class { * @param {number} estimateId * @param {number} groupId * @param {boolean} isInitSegment + * @param {number} refPosition */ - constructor(ref, estimateId, groupId, isInitSegment) { + constructor(ref, estimateId, groupId, isInitSegment, refPosition) { /** @type {shaka.media.SegmentReference|shaka.media.InitSegmentReference} */ this.ref = ref; @@ -35,6 +36,9 @@ shaka.offline.DownloadInfo = class { /** @type {boolean} */ this.isInitSegment = isInitSegment; + + /** @type {number} */ + this.refPosition = refPosition; } /** diff --git a/lib/offline/storage.js b/lib/offline/storage.js index e393477a5d..76f0ba7d6b 100644 --- a/lib/offline/storage.js +++ b/lib/offline/storage.js @@ -13,6 +13,7 @@ goog.require('shaka.log'); goog.require('shaka.media.ManifestParser'); goog.require('shaka.media.SegmentIndex'); goog.require('shaka.media.SegmentReference'); +goog.require('shaka.media.SegmentUtils'); goog.require('shaka.net.NetworkingEngine'); goog.require('shaka.net.NetworkingUtils'); goog.require('shaka.offline.DownloadInfo'); @@ -400,14 +401,14 @@ shaka.offline.Storage = class { const clearKeyDataLicenseServerUri = manifest.variants.some((v) => { if (v.audio) { for (const drmInfo of v.audio.drmInfos) { - if (!drmInfo.licenseServerUri.startsWith('data:')) { + if (drmInfo.licenseServerUri.startsWith('data:')) { return true; } } } if (v.video) { for (const drmInfo of v.video.drmInfos) { - if (!drmInfo.licenseServerUri.startsWith('data:')) { + if (drmInfo.licenseServerUri.startsWith('data:')) { return true; } } @@ -531,14 +532,18 @@ shaka.offline.Storage = class { const isInitSegment = download.isInitSegment; const onDownloaded = async (data) => { + const ref = /** @type {!shaka.media.SegmentReference} */ ( + download.ref); + const id = shaka.offline.DownloadInfo.idForSegmentRef(ref); + if (ref.aesKey) { + data = await shaka.media.SegmentUtils.aesDecrypt( + data, ref.aesKey, download.refPosition); + } // Store the data. const dataKeys = await storage.addSegments([{data}]); this.ensureNotDestroyed_(); // Store the necessary update to the manifest, to be processed later. - const ref = /** @type {!shaka.media.SegmentReference} */ ( - download.ref); - const id = shaka.offline.DownloadInfo.idForSegmentRef(ref); pendingManifestUpdates[id] = dataKeys[0]; pendingDataSize += data.byteLength; }; @@ -1660,7 +1665,7 @@ shaka.offline.Storage = class { const numberOfParallelDownloads = config.offline.numberOfParallelDownloads; let groupId = numberOfParallelDownloads === 0 ? stream.id : 0; - shaka.offline.Storage.forEachSegment_(stream, startTime, (segment) => { + shaka.offline.Storage.forEachSegment_(stream, startTime, (segment, pos) => { const pendingSegmentRefId = shaka.offline.DownloadInfo.idForSegmentRef(segment); let pendingInitSegmentRefId = undefined; @@ -1674,7 +1679,8 @@ shaka.offline.Storage = class { segment, estimateId, groupId, - /* isInitSegment= */ false); + /* isInitSegment= */ false, + pos); toDownload.set(pendingSegmentRefId, segmentDownload); } @@ -1689,7 +1695,8 @@ shaka.offline.Storage = class { segment.initSegmentReference, estimateId, groupId, - /* isInitSegment= */ true); + /* isInitSegment= */ true, + pos); toDownload.set(pendingInitSegmentRefId, initDownload); } } @@ -1722,7 +1729,7 @@ shaka.offline.Storage = class { /** * @param {shaka.extern.Stream} stream * @param {number} startTime - * @param {function(!shaka.media.SegmentReference)} callback + * @param {function(!shaka.media.SegmentReference, number)} callback * @private */ static forEachSegment_(stream, startTime, callback) { @@ -1736,7 +1743,7 @@ shaka.offline.Storage = class { /** @type {?shaka.media.SegmentReference} */ let ref = stream.segmentIndex.get(i); while (ref) { - callback(ref); + callback(ref, i); ref = stream.segmentIndex.get(++i); } } diff --git a/test/offline/storage_playback_integration.js b/test/offline/storage_playback_integration.js new file mode 100644 index 0000000000..79570111df --- /dev/null +++ b/test/offline/storage_playback_integration.js @@ -0,0 +1,167 @@ +/*! @license + * Shaka Player + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** @return {boolean} */ +function checkStorageSupport() { + return shaka.offline.Storage.support(); +} + +filterDescribe('Storage', checkStorageSupport, () => { + const Util = shaka.test.Util; + + /** @type {!jasmine.Spy} */ + let onErrorSpy; + + /** @type {!HTMLVideoElement} */ + let video; + /** @type {shaka.Player} */ + let player; + /** @type {shaka.offline.Storage} */ + let storage; + /** @type {!shaka.util.EventManager} */ + let eventManager; + + let compiledShaka; + + /** @type {!shaka.test.Waiter} */ + let waiter; + + function checkClearKeySupport() { + const clearKeySupport = shakaSupport.drm['org.w3.clearkey']; + if (!clearKeySupport) { + return false; + } + return clearKeySupport.encryptionSchemes.includes('cenc'); + } + + async function eraseStorage() { + /** @type {!shaka.offline.StorageMuxer} */ + const muxer = new shaka.offline.StorageMuxer(); + + try { + await muxer.erase(); + } finally { + await muxer.destroy(); + } + } + + beforeAll(async () => { + video = shaka.test.UiUtils.createVideoElement(); + document.body.appendChild(video); + compiledShaka = + await shaka.test.Loader.loadShaka(getClientArg('uncompiled')); + }); + + beforeEach(async () => { + // Make sure we start with a clean slate between each run. + await eraseStorage(); + + await shaka.test.TestScheme.createManifests(compiledShaka, '_compiled'); + player = new compiledShaka.Player(); + storage = new compiledShaka.offline.Storage(player); + await player.attach(video); + + // Disable stall detection, which can interfere with playback tests. + player.configure('streaming.stallEnabled', false); + + // Grab event manager from the uncompiled library: + eventManager = new shaka.util.EventManager(); + waiter = new shaka.test.Waiter(eventManager); + waiter.setPlayer(player); + + onErrorSpy = jasmine.createSpy('onError'); + onErrorSpy.and.callFake((event) => fail(event.detail)); + eventManager.listen(player, 'error', Util.spyFunc(onErrorSpy)); + }); + + afterEach(async () => { + eventManager.release(); + await storage.destroy(); + await player.destroy(); + + // Make sure we don't leave anything behind. + await eraseStorage(); + }); + + afterAll(() => { + document.body.removeChild(video); + }); + + it('supports DASH AES-128 download and playback', async () => { + const url = '/base/test/test/assets/dash-aes-128/dash.mpd'; + const metadata = { + 'title': 'DASH AES-128', + 'downloaded': new Date(), + }; + + storage.store(url, metadata); + + await player.load(url); + await video.play(); + expect(player.isLive()).toBe(false); + + // Wait for the video to start playback. If it takes longer than 10 + // seconds, fail the test. + await waiter.waitForMovementOrFailOnTimeout(video, 10); + + // Play for 2 seconds, but stop early if the video ends. If it takes + // longer than 10 seconds, fail the test. + await waiter.waitUntilPlayheadReachesOrFailOnTimeout(video, 2, 10); + + await player.unload(); + }); + + it('supports HLS AES-256 download and playback', async () => { + const url = '/base/test/test/assets/hls-aes-256/media.m3u8'; + const metadata = { + 'title': 'HLS AES-256', + 'downloaded': new Date(), + }; + + storage.store(url, metadata); + + await player.load(url); + await video.play(); + expect(player.isLive()).toBe(false); + + // Wait for the video to start playback. If it takes longer than 10 + // seconds, fail the test. + await waiter.waitForMovementOrFailOnTimeout(video, 10); + + // Play for 2 seconds, but stop early if the video ends. If it takes + // longer than 10 seconds, fail the test. + await waiter.waitUntilPlayheadReachesOrFailOnTimeout(video, 2, 10); + + await player.unload(); + }); + + drmIt('supports HLS SAMPLE-AES download and playback', async () => { + if (!checkClearKeySupport()) { + pending('ClearKey is not supported'); + } + const url = '/base/test/test/assets/hls-sample-aes/index.m3u8'; + const metadata = { + 'title': 'HLS SAMPLE-AES', + 'downloaded': new Date(), + }; + + const result = await storage.store(url, metadata).promise; + + await player.load(result.offlineUri); + await video.play(); + expect(player.isLive()).toBe(false); + + // Wait for the video to start playback. If it takes longer than 10 + // seconds, fail the test. + await waiter.waitForMovementOrFailOnTimeout(video, 10); + + // Play for 2 seconds, but stop early if the video ends. If it takes + // longer than 10 seconds, fail the test. + await waiter.waitUntilPlayheadReachesOrFailOnTimeout(video, 2, 10); + + await player.unload(); + }); +}); From 07c2f08cdcdaff08e329147ad0af70527a97197e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?A=CC=81lvaro=20Velad=20Galva=CC=81n?= Date: Tue, 7 Jan 2025 08:40:13 +0100 Subject: [PATCH 2/2] Update comment --- externs/shaka/manifest.js | 1 + 1 file changed, 1 insertion(+) diff --git a/externs/shaka/manifest.js b/externs/shaka/manifest.js index 4c3758ce5e..cac0a24bae 100644 --- a/externs/shaka/manifest.js +++ b/externs/shaka/manifest.js @@ -452,6 +452,7 @@ shaka.extern.SegmentIndex = class { * @property {boolean} encrypted * Defaults to false.
* True if the stream is encrypted. + * Note: DRM encryption only, so AES encryption is not taken into account. * @property {!Array.} drmInfos * Defaults to [] (i.e., no DRM).
* An array of DrmInfo objects which describe DRM schemes are compatible with