From db8ad31bfbe1c2b9a372daad893d900e77d5e484 Mon Sep 17 00:00:00 2001 From: Theodore Abshire Date: Wed, 11 Aug 2021 17:35:43 -0700 Subject: [PATCH] feat(offline): Load init segments first for keys It is unlikely that we will be able to load DRM sessions inside the service worker for BG fetch. However, sometimes we have to get the DRM keys from the init segments. This changes Storage.downloadSegments_ to download the init segments first if it looks like they will contain needed init data to create license requests. This also fixes a typo that was preventing us from getting init data from segments, and adds a test that would catch that issue. Issue #879 Change-Id: Ide859ed0eb2d9208150787f14d915135df681d96 --- lib/offline/storage.js | 136 +++++++++++++++------------- test/offline/storage_integration.js | 104 +++++++++++++++++++++ test/test/util/fake_drm_engine.js | 11 ++- 3 files changed, 187 insertions(+), 64 deletions(-) diff --git a/lib/offline/storage.js b/lib/offline/storage.js index a4eedbd443..5364bf30fb 100644 --- a/lib/offline/storage.js +++ b/lib/offline/storage.js @@ -439,13 +439,7 @@ shaka.offline.Storage = class { } await this.downloadSegments_(toDownload, manifestId, manifestDB, - downloader, config, activeHandle.cell); - this.ensureNotDestroyed_(); - - // Now that we have the keys loaded into the DrmEngine, we can attach - // those fields to the manifest. - this.setManifestDrmFields_(manifest, manifestDB, drmEngine, config); - await activeHandle.cell.updateManifest(manifestId, manifestDB); + downloader, config, activeHandle.cell, manifest, drmEngine); this.ensureNotDestroyed_(); const offlineUri = shaka.offline.OfflineUri.manifest( @@ -498,69 +492,87 @@ shaka.offline.Storage = class { * @param {!shaka.offline.DownloadManager} downloader * @param {shaka.extern.PlayerConfiguration} config * @param {shaka.extern.StorageCell} storage + * @param {shaka.extern.Manifest} manifest + * @param {!shaka.media.DrmEngine} drmEngine * @return {!Promise} * @private */ async downloadSegments_( - toDownload, manifestId, manifestDB, downloader, config, storage) { - for (const download of toDownload) { - /** @param {?BufferSource} data */ - let data; - const request = download.makeSegmentRequest(config); - const estimateId = download.estimateId; - const isInitSegment = download.isInitSegment; - const onDownloaded = (d) => { - data = d; - return Promise.resolve(); - }; - downloader.queue(download.groupId, - request, estimateId, isInitSegment, onDownloaded); - downloader.queueWork(download.groupId, async () => { - goog.asserts.assert(data, 'We should have loaded data by now'); - - const ref = /** @type {!shaka.media.SegmentReference} */ ( - download.ref); - const idForRef = shaka.offline.DownloadInfo.idForSegmentRef(ref); - - // Store the segment. - const ids = await storage.addSegments([{data: data}]); - this.ensureNotDestroyed_(); - this.segmentsFromStore_.push(ids[0]); - - // Attach the segment to the manifest. - let complete = true; - for (const stream of manifestDB.streams) { - for (const segment of stream.segments) { - if (segment.pendingSegmentRefId == idForRef) { - segment.dataKey = ids[0]; - // Now that the segment has been associated with the - // appropriate dataKey, the pendingSegmentRefId is no longer - // necessary. - segment.pendingSegmentRefId = undefined; - } - if (segment.pendingInitSegmentRefId == idForRef) { - segment.initSegmentKey = ids[0]; - // Now that the init segment has been associated with the - // appropriate initSegmentKey, the pendingInitSegmentRefId is - // no longer necessary. - segment.pendingInitSegmentRefId = undefined; - } - if (segment.pendingSegmentRefId) { - complete = false; - } - if (segment.pendingInitSegmentRefId) { - complete = false; + toDownload, manifestId, manifestDB, downloader, config, storage, + manifest, drmEngine) { + /** @param {!Array.} toDownload */ + const download = (toDownload) => { + for (const download of toDownload) { + /** @param {?BufferSource} data */ + let data; + const request = download.makeSegmentRequest(config); + const estimateId = download.estimateId; + const isInitSegment = download.isInitSegment; + const onDownloaded = (d) => { + data = d; + return Promise.resolve(); + }; + downloader.queue(download.groupId, + request, estimateId, isInitSegment, onDownloaded); + downloader.queueWork(download.groupId, async () => { + goog.asserts.assert(data, 'We should have loaded data by now'); + + const ref = /** @type {!shaka.media.SegmentReference} */ ( + download.ref); + const idForRef = shaka.offline.DownloadInfo.idForSegmentRef(ref); + + // Store the segment. + const ids = await storage.addSegments([{data: data}]); + this.ensureNotDestroyed_(); + this.segmentsFromStore_.push(ids[0]); + + // Attach the segment to the manifest. + let complete = true; + for (const stream of manifestDB.streams) { + for (const segment of stream.segments) { + if (segment.pendingSegmentRefId == idForRef) { + segment.dataKey = ids[0]; + // Now that the segment has been associated with the + // appropriate dataKey, the pendingSegmentRefId is no longer + // necessary. + segment.pendingSegmentRefId = undefined; + } + if (segment.pendingInitSegmentRefId == idForRef) { + segment.initSegmentKey = ids[0]; + // Now that the init segment has been associated with the + // appropriate initSegmentKey, the pendingInitSegmentRefId is + // no longer necessary. + segment.pendingInitSegmentRefId = undefined; + } + if (segment.pendingSegmentRefId) { + complete = false; + } + if (segment.pendingInitSegmentRefId) { + complete = false; + } } } - } - if (complete) { - manifestDB.isIncomplete = false; - } - }); + if (complete) { + manifestDB.isIncomplete = false; + } + }); + } + }; + + if (this.getManifestIsEncrypted_(manifest) && + !this.getManifestIncludesInitData_(manifest)) { + // Background fetch can't make DRM sessions, so if we have to get the + // init data from the init segments, download those first before anything + // else. + download(toDownload.filter((info) => info.isInitSegment)); + toDownload = toDownload.filter((info) => !info.isInitSegment); } - // Re-store the manifest. + download(toDownload); + + // Re-store the manifest, to update the size and attach session IDs. manifestDB.size = await downloader.waitToFinish(); + this.setManifestDrmFields_(manifest, manifestDB, drmEngine, config); goog.asserts.assert( !manifestDB.isIncomplete, 'The manifest should be complete by now'); await storage.updateManifest(manifestId, manifestDB); @@ -723,7 +735,7 @@ shaka.offline.Storage = class { downloader.setCallbacks(onProgress, onInitData); const needsInitData = this.getManifestIsEncrypted_(manifest) && - this.getManifestIncludesInitData_(manifest); + !this.getManifestIncludesInitData_(manifest); let currentSystemId = null; if (needsInitData) { diff --git a/test/offline/storage_integration.js b/test/offline/storage_integration.js index d0a0ec893e..e926aa76f1 100644 --- a/test/offline/storage_integration.js +++ b/test/offline/storage_integration.js @@ -22,10 +22,12 @@ goog.require('shaka.test.ManifestGenerator'); goog.require('shaka.test.TestScheme'); goog.require('shaka.test.Util'); goog.require('shaka.util.AbortableOperation'); +goog.require('shaka.util.BufferUtils'); goog.require('shaka.util.Error'); goog.require('shaka.util.EventManager'); goog.require('shaka.util.PlayerConfiguration'); goog.require('shaka.util.PublicPromise'); +goog.require('shaka.util.Uint8ArrayUtils'); goog.requireType('shaka.media.SegmentReference'); /** @return {boolean} */ @@ -58,12 +60,16 @@ filterDescribe('Storage', storageSupport, () => { const manifestWithNonZeroStartUri = 'fake:manifest-with-non-zero-start'; const manifestWithLiveTimelineUri = 'fake:manifest-with-live-timeline'; const manifestWithAlternateSegmentsUri = 'fake:manifest-with-alt-segments'; + const manifestWithVideoInitSegmentsUri = + 'fake:manifest-with-video-init-segments'; + const initSegmentUri = 'fake:init-segment'; const segment1Uri = 'fake:segment-1'; const segment2Uri = 'fake:segment-2'; const segment3Uri = 'fake:segment-3'; const segment4Uri = 'fake:segment-4'; + const alternateInitSegmentUri = 'fake:alt-init-segment'; const alternateSegment1Uri = 'fake:alt-segment-1'; const alternateSegment2Uri = 'fake:alt-segment-2'; const alternateSegment3Uri = 'fake:alt-segment-3'; @@ -945,6 +951,46 @@ filterDescribe('Storage', storageSupport, () => { } }); + it('can extract DRM info from segments', async () => { + const pssh1 = + '00000028' + // atom size + '70737368' + // atom type='pssh' + '00000000' + // v0, flags=0 + 'edef8ba979d64acea3c827dcd51d21ed' + // system id (Widevine) + '00000008' + // data size + '0102030405060708'; // data + const psshData1 = shaka.util.Uint8ArrayUtils.fromHex(pssh1); + const pssh2 = + '00000028' + // atom size + '70737368' + // atom type='pssh' + '00000000' + // v0, flags=0 + 'edef8ba979d64acea3c827dcd51d21ed' + // system id (Widevine) + '00000008' + // data size + '1337420123456789'; // data + const psshData2 = shaka.util.Uint8ArrayUtils.fromHex(pssh2); + netEngine.setResponseValue(initSegmentUri, + shaka.util.BufferUtils.toArrayBuffer(psshData1)); + netEngine.setResponseValue(alternateInitSegmentUri, + shaka.util.BufferUtils.toArrayBuffer(psshData2)); + + const drm = new shaka.test.FakeDrmEngine(); + const drmInfo = makeDrmInfo(); + drmInfo.keySystem = 'com.widevine.alpha'; + drm.setDrmInfo(drmInfo); + overrideDrmAndManifest( + storage, + drm, + makeManifestWithVideoInitSegments()); + + const stored = await storage.store( + manifestWithVideoInitSegmentsUri, noMetadata, fakeMimeType).promise; + goog.asserts.assert(stored.offlineUri != null, 'URI should not be null!'); + + // The manifest chooses the alternate stream, so expect only the alt init + // segment. + expect(drm.newInitData).toHaveBeenCalledWith('cenc', psshData2); + }); + it('can store multiple assets at once', async () => { // Block the network so that we won't finish the first store command. /** @type {!shaka.util.PublicPromise} */ @@ -1399,6 +1445,62 @@ filterDescribe('Storage', storageSupport, () => { }; } + /** @return {shaka.extern.Manifest} */ + function makeManifestWithVideoInitSegments() { + const manifest = shaka.test.ManifestGenerator.generate((manifest) => { + manifest.presentationTimeline.setDuration(20); + + manifest.addVariant(0, (variant) => { + variant.bandwidth = kbps(13); + variant.addVideo(1, (stream) => { + stream.bandwidth = kbps(13); + stream.size(100, 200); + }); + }); + manifest.addVariant(2, (variant) => { + variant.bandwidth = kbps(20); + variant.addVideo(3, (stream) => { + stream.bandwidth = kbps(20); + stream.size(200, 400); + }); + }); + }); + + const stream = manifest.variants[0].video; + goog.asserts.assert(stream, 'The first stream should exist'); + stream.encrypted = true; + const init = new shaka.media.InitSegmentReference( + () => [initSegmentUri], 0, null); + const refs = [ + makeReference(segment1Uri, 0, 1), + makeReference(segment2Uri, 1, 2), + makeReference(segment3Uri, 2, 3), + makeReference(segment4Uri, 3, 4), + ]; + for (const ref of refs) { + ref.initSegmentReference = init; + } + overrideSegmentIndex(stream, refs); + + const streamAlt = manifest.variants[1].video; + goog.asserts.assert(streamAlt, 'The second stream should exist'); + streamAlt.encrypted = true; + const initAlt = new shaka.media.InitSegmentReference( + () => [alternateInitSegmentUri], 0, null); + const refsAlt = [ + makeReference(alternateSegment1Uri, 0, 1), + makeReference(alternateSegment2Uri, 1, 2), + makeReference(alternateSegment3Uri, 2, 3), + makeReference(alternateSegment4Uri, 3, 4), + ]; + for (const ref of refsAlt) { + ref.initSegmentReference = initAlt; + } + overrideSegmentIndex(streamAlt, refsAlt); + + return manifest; + } + /** @return {shaka.extern.Manifest} */ function makeManifestWithPerStreamBandwidth() { const manifest = shaka.test.ManifestGenerator.generate((manifest) => { @@ -1612,6 +1714,8 @@ filterDescribe('Storage', storageSupport, () => { makeManifestWithLiveTimeline(); this.map_[manifestWithAlternateSegmentsUri] = makeManifestWithAlternateSegments(); + this.map_[manifestWithVideoInitSegmentsUri] = + makeManifestWithVideoInitSegments(); } /** @override */ diff --git a/test/test/util/fake_drm_engine.js b/test/test/util/fake_drm_engine.js index 1682a1425f..1720f6b6c0 100644 --- a/test/test/util/fake_drm_engine.js +++ b/test/test/util/fake_drm_engine.js @@ -17,7 +17,7 @@ goog.require('shaka.test.Util'); */ shaka.test.FakeDrmEngine = class { constructor() { - /** @private {!Array.} */ + /** @private {!Array.} */ this.offlineSessions_ = []; /** @private {?shaka.extern.DrmInfo} */ this.drmInfo_ = null; @@ -45,6 +45,13 @@ shaka.test.FakeDrmEngine = class { // be returned. this.getDrmInfo.and.callFake(() => this.drmInfo_); + /** @type {!jasmine.Spy} */ + this.newInitData = jasmine.createSpy('newInitData'); + this.newInitData.and.callFake((initDataType, initData) => { + const num = 1 + this.offlineSessions_.length; + this.offlineSessions_.push('session-' + num); + }); + /** @type {!jasmine.Spy} */ this.getExpiration = jasmine.createSpy('getExpiration'); this.getExpiration.and.returnValue(Infinity); @@ -90,7 +97,7 @@ shaka.test.FakeDrmEngine = class { } /** - * @param {!Array.} sessions + * @param {!Array.} sessions */ setSessionIds(sessions) { // Copy the values to break the reference to the input value.