From 50a1851f03a9a12f714eecd6ba0b56f04d7e21ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Velad=20Galv=C3=A1n?= Date: Tue, 31 Dec 2024 15:51:59 +0100 Subject: [PATCH] feat(Offline): Allow downloading some clearkey content without persistent license support (#7811) Cases: - Configured `drm.clearKeys` - HLS SAMPLE-AES --- demo/common/asset.js | 7 ++++ demo/main.js | 7 ++++ lib/drm/drm_engine.js | 58 +++++++++++++++------------------ lib/offline/storage.js | 74 ++++++++++++++++++++++++++++++------------ 4 files changed, 94 insertions(+), 52 deletions(-) diff --git a/demo/common/asset.js b/demo/common/asset.js index df72105790..ca05dab5c5 100644 --- a/demo/common/asset.js +++ b/demo/common/asset.js @@ -241,6 +241,13 @@ const ShakaDemoAssetInfo = class { return this; } + /** + * @return {!Map.} + */ + getLicenseServers() { + return this.licenseServers; + } + /** * @param {string} keySystem * @param {string} licenseServer diff --git a/demo/main.js b/demo/main.js index 6251c697f1..bffc8b5e27 100644 --- a/demo/main.js +++ b/demo/main.js @@ -713,6 +713,13 @@ shakaDemo.Main = class { if (needOffline) { const hasSupportedOfflineDRM = asset.drm.some((drm) => { const identifier = shakaAssets.identifierForKeySystem(drm); + // Special case when using clear keys. + if (identifier == 'org.w3.clearkey') { + const licenseServers = asset.getLicenseServers(); + if (!licenseServers.has(identifier)) { + return this.support_.drm[identifier]; + } + } return this.support_.drm[identifier] && this.support_.drm[identifier].persistentState; }); diff --git a/lib/drm/drm_engine.js b/lib/drm/drm_engine.js index cea21c6e28..4f4782be5e 100644 --- a/lib/drm/drm_engine.js +++ b/lib/drm/drm_engine.js @@ -355,21 +355,7 @@ shaka.drm.DrmEngine = class { goog.asserts.assert(this.config_, 'DrmEngine configure() must be called before init()!'); - // ClearKey config overrides the manifest DrmInfo if present. The variants - // are modified so that filtering in Player still works. - // This comes before hadDrmInfo because it influences the value of that. - /** @type {?shaka.extern.DrmInfo} */ - const clearKeyDrmInfo = this.configureClearKey_(); - if (clearKeyDrmInfo) { - for (const variant of variants) { - if (variant.video) { - variant.video.drmInfos = [clearKeyDrmInfo]; - } - if (variant.audio) { - variant.audio.drmInfos = [clearKeyDrmInfo]; - } - } - } + shaka.drm.DrmEngine.configureClearKey(this.config_.clearKeys, variants); const hadDrmInfo = variants.some((variant) => { if (variant.video && variant.video.drmInfos.length) { @@ -1221,23 +1207,6 @@ shaka.drm.DrmEngine = class { return mediaKeySystemAccess; } - /** - * Create a DrmInfo using configured clear keys. - * The server URI will be a data URI which decodes to a clearkey license. - * @return {?shaka.extern.DrmInfo} or null if clear keys are not configured. - * @private - * @see https://bit.ly/2K8gOnv for the spec on the clearkey license format. - */ - configureClearKey_() { - const clearKeys = shaka.util.MapUtils.asMap(this.config_.clearKeys); - if (clearKeys.size == 0) { - return null; - } - - const ManifestParserUtils = shaka.util.ManifestParserUtils; - return ManifestParserUtils.createDrmInfoFromClearKeys(clearKeys); - } - /** * Resolves the allSessionsLoaded_ promise when all the sessions are loaded * @@ -2698,6 +2667,31 @@ shaka.drm.DrmEngine = class { this.newInitData('cenc', combinedData); return this.allSessionsLoaded_; } + + /** + * Create a DrmInfo using configured clear keys and assign it to each variant. + * Only modify variants if clear keys have been set. + * @see https://bit.ly/2K8gOnv for the spec on the clearkey license format. + * + * @param {!Object.} configClearKeys + * @param {!Array.} variants + */ + static configureClearKey(configClearKeys, variants) { + const clearKeys = shaka.util.MapUtils.asMap(configClearKeys); + if (clearKeys.size == 0) { + return; + } + const clearKeyDrmInfo = + shaka.util.ManifestParserUtils.createDrmInfoFromClearKeys(clearKeys); + for (const variant of variants) { + if (variant.video) { + variant.video.drmInfos = [clearKeyDrmInfo]; + } + if (variant.audio) { + variant.audio.drmInfos = [clearKeyDrmInfo]; + } + } + } }; diff --git a/lib/offline/storage.js b/lib/offline/storage.js index 2a208b46a9..e393477a5d 100644 --- a/lib/offline/storage.js +++ b/lib/offline/storage.js @@ -394,11 +394,38 @@ shaka.offline.Storage = class { this.ensureNotDestroyed_(); } + shaka.drm.DrmEngine.configureClearKey( + config.drm.clearKeys, manifest.variants); + + const clearKeyDataLicenseServerUri = manifest.variants.some((v) => { + if (v.audio) { + for (const drmInfo of v.audio.drmInfos) { + if (!drmInfo.licenseServerUri.startsWith('data:')) { + return true; + } + } + } + if (v.video) { + for (const drmInfo of v.video.drmInfos) { + if (!drmInfo.licenseServerUri.startsWith('data:')) { + return true; + } + } + } + return false; + }); + + let usePersistentLicense = config.offline.usePersistentLicense; + if (clearKeyDataLicenseServerUri) { + usePersistentLicense = false; + } + // Create the DRM engine, and load the keys in the manifest. drmEngine = await this.createDrmEngine( manifest, (e) => { drmError = drmError || e; }, - config); + config, + usePersistentLicense); // We could have been asked to destroy ourselves while we were "away" // creating the drm engine. @@ -407,7 +434,8 @@ shaka.offline.Storage = class { throw drmError; } - await this.filterManifest_(manifest, drmEngine, config); + await this.filterManifest_( + manifest, drmEngine, config, usePersistentLicense); await muxer.init(); this.ensureNotDestroyed_(); @@ -420,7 +448,8 @@ shaka.offline.Storage = class { goog.asserts.assert(drmEngine, 'drmEngine should be non-null here.'); const {manifestDB, toDownload} = this.makeManifestDB_( - drmEngine, manifest, uri, appMetadata, config, downloader); + drmEngine, manifest, uri, appMetadata, config, downloader, + usePersistentLicense); // Store the empty manifest, before downloading the segments. const ids = await activeHandle.cell.addManifests([manifestDB]); @@ -434,10 +463,12 @@ shaka.offline.Storage = class { } await this.downloadSegments_(toDownload, manifestId, manifestDB, - downloader, config, activeHandle.cell, manifest, drmEngine); + downloader, config, activeHandle.cell, manifest, drmEngine, + usePersistentLicense); this.ensureNotDestroyed_(); - this.setManifestDrmFields_(manifest, manifestDB, drmEngine, config); + this.setManifestDrmFields_( + manifest, manifestDB, drmEngine, usePersistentLicense); await activeHandle.cell.updateManifest(manifestId, manifestDB); this.ensureNotDestroyed_(); @@ -479,12 +510,13 @@ shaka.offline.Storage = class { * @param {shaka.extern.StorageCell} storage * @param {shaka.extern.Manifest} manifest * @param {!shaka.drm.DrmEngine} drmEngine + * @param {boolean} usePersistentLicense * @return {!Promise} * @private */ async downloadSegments_( toDownload, manifestId, manifestDB, downloader, config, storage, - manifest, drmEngine) { + manifest, drmEngine, usePersistentLicense) { let pendingManifestUpdates = {}; let pendingDataSize = 0; @@ -531,7 +563,8 @@ shaka.offline.Storage = class { // process of downloading and storing, and assignSegmentsToManifest // does not know about the DRM engine. this.ensureNotDestroyed_(); - this.setManifestDrmFields_(manifest, manifestDB, drmEngine, config); + this.setManifestDrmFields_( + manifest, manifestDB, drmEngine, usePersistentLicense); await storage.updateManifest(manifestId, manifestDB); } }; @@ -696,10 +729,11 @@ shaka.offline.Storage = class { * @param {shaka.extern.Manifest} manifest * @param {!shaka.drm.DrmEngine} drmEngine * @param {shaka.extern.PlayerConfiguration} config + * @param {boolean} usePersistentLicense * @return {!Promise} * @private */ - async filterManifest_(manifest, drmEngine, config) { + async filterManifest_(manifest, drmEngine, config, usePersistentLicense) { // Filter the manifest based on the restrictions given in the player // configuration. const maxHwRes = {width: Infinity, height: Infinity}; @@ -709,7 +743,7 @@ shaka.offline.Storage = class { // Filter the manifest based on what we know MediaCapabilities will be able // to play later (no point storing something we can't play). await shaka.util.StreamUtils.filterManifestByMediaCapabilities( - drmEngine, manifest, config.offline.usePersistentLicense, + drmEngine, manifest, usePersistentLicense, config.drm.preferredKeySystems, config.drm.keySystemsMapping); // Gather all tracks. @@ -815,13 +849,15 @@ shaka.offline.Storage = class { * @param {!Object} metadata * @param {shaka.extern.PlayerConfiguration} config * @param {!shaka.offline.DownloadManager} downloader + * @param {boolean} usePersistentLicense * @return {{ * manifestDB: shaka.extern.ManifestDB, * toDownload: !Array. * }} * @private */ - makeManifestDB_(drmEngine, manifest, uri, metadata, config, downloader) { + makeManifestDB_(drmEngine, manifest, uri, metadata, config, downloader, + usePersistentLicense) { const pendingContent = shaka.offline.StoredContentUtils.fromManifest( uri, manifest, /* size= */ 0, metadata); // In https://github.com/shaka-project/shaka-player/issues/2652, we found @@ -837,7 +873,7 @@ shaka.offline.Storage = class { progressCallback(pendingContent, progress); }; const onInitData = (initData, systemId) => { - if (needsInitData && config.offline.usePersistentLicense && + if (needsInitData && usePersistentLicense && currentSystemId == systemId) { drmEngine.newInitData('cenc', initData); } @@ -869,7 +905,6 @@ shaka.offline.Storage = class { downloader, estimator, drmEngine, manifest, config); const drmInfo = drmEngine.getDrmInfo(); - const usePersistentLicense = config.offline.usePersistentLicense; if (drmInfo && usePersistentLicense) { // Don't store init data, since we have stored sessions. drmInfo.initData = []; @@ -926,18 +961,17 @@ shaka.offline.Storage = class { * @param {shaka.extern.Manifest} manifest * @param {shaka.extern.ManifestDB} manifestDB * @param {!shaka.drm.DrmEngine} drmEngine - * @param {shaka.extern.PlayerConfiguration} config + * @param {boolean} usePersistentLicense * @private */ - setManifestDrmFields_(manifest, manifestDB, drmEngine, config) { + setManifestDrmFields_(manifest, manifestDB, drmEngine, usePersistentLicense) { manifestDB.expiration = drmEngine.getExpiration(); const sessions = drmEngine.getSessionIds(); - manifestDB.sessionIds = config.offline.usePersistentLicense ? - sessions : []; + manifestDB.sessionIds = usePersistentLicense ? sessions : []; if (this.getManifestIsEncrypted_(manifest) && - config.offline.usePersistentLicense && !sessions.length) { + usePersistentLicense && !sessions.length) { throw new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.STORAGE, @@ -1498,9 +1532,10 @@ shaka.offline.Storage = class { * @param {shaka.extern.Manifest} manifest * @param {function(shaka.util.Error)} onError * @param {shaka.extern.PlayerConfiguration} config + * @param {boolean} usePersistentLicense * @return {!Promise.} */ - async createDrmEngine(manifest, onError, config) { + async createDrmEngine(manifest, onError, config, usePersistentLicense) { goog.asserts.assert( this.networkingEngine_, 'Cannot call |createDrmEngine| after |destroy|'); @@ -1515,8 +1550,7 @@ shaka.offline.Storage = class { }); drmEngine.configure(config.drm); - await drmEngine.initForStorage( - manifest.variants, config.offline.usePersistentLicense); + await drmEngine.initForStorage(manifest.variants, usePersistentLicense); await drmEngine.createOrLoad(); return drmEngine;