Skip to content

Commit

Permalink
fix(Offline): Allow downloading AES content (#7827)
Browse files Browse the repository at this point in the history
Also adds integration tests for DASH AES-128, HLS AES-256 and HLS
SAMPLE-AES download and playback
  • Loading branch information
avelad authored Jan 7, 2025
1 parent 8b1bbaf commit 21bbd93
Show file tree
Hide file tree
Showing 5 changed files with 196 additions and 17 deletions.
1 change: 1 addition & 0 deletions externs/shaka/manifest.js
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,7 @@ shaka.extern.SegmentIndex = class {
* @property {boolean} encrypted
* <i>Defaults to false.</i><br>
* True if the stream is encrypted.
* Note: DRM encryption only, so AES encryption is not taken into account.
* @property {!Array.<!shaka.extern.DrmInfo>} drmInfos
* <i>Defaults to [] (i.e., no DRM).</i> <br>
* An array of DrmInfo objects which describe DRM schemes are compatible with
Expand Down
12 changes: 6 additions & 6 deletions lib/hls/hls_parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 = {
Expand All @@ -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(
Expand Down
6 changes: 5 additions & 1 deletion lib/offline/download_info.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -35,6 +36,9 @@ shaka.offline.DownloadInfo = class {

/** @type {boolean} */
this.isInitSegment = isInitSegment;

/** @type {number} */
this.refPosition = refPosition;
}

/**
Expand Down
27 changes: 17 additions & 10 deletions lib/offline/storage.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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;
}
}
Expand Down Expand Up @@ -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;
};
Expand Down Expand Up @@ -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;
Expand All @@ -1674,7 +1679,8 @@ shaka.offline.Storage = class {
segment,
estimateId,
groupId,
/* isInitSegment= */ false);
/* isInitSegment= */ false,
pos);
toDownload.set(pendingSegmentRefId, segmentDownload);
}

Expand All @@ -1689,7 +1695,8 @@ shaka.offline.Storage = class {
segment.initSegmentReference,
estimateId,
groupId,
/* isInitSegment= */ true);
/* isInitSegment= */ true,
pos);
toDownload.set(pendingInitSegmentRefId, initDownload);
}
}
Expand Down Expand Up @@ -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) {
Expand All @@ -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);
}
}
Expand Down
167 changes: 167 additions & 0 deletions test/offline/storage_playback_integration.js
Original file line number Diff line number Diff line change
@@ -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();
});
});

0 comments on commit 21bbd93

Please sign in to comment.