Skip to content

Commit

Permalink
fix(Offline): Allow downloading HLS AES-128 content
Browse files Browse the repository at this point in the history
Also adds integration tests for HLS AES-256 and HLS SAMPLE-AES download and playback
  • Loading branch information
avelad committed Jan 3, 2025
1 parent b22e546 commit d6e8419
Show file tree
Hide file tree
Showing 4 changed files with 174 additions and 21 deletions.
16 changes: 7 additions & 9 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,8 +3302,10 @@ shaka.hls.HlsParser = class {
}
}

const aesKeyInfoKey = `${drmTag.toString()}-${firstMediaSequenceNumber}`;
if (!this.aesKeyInfoMap_.has(aesKeyInfoKey)) {
const keyUris = shaka.hls.Utils.constructSegmentUris(
getUris(), drmTag.getRequiredAttrValue('URI'), variables);
const keyMapKey = keyUris.sort().join('');
if (!this.aesKeyInfoMap_.has(keyMapKey)) {
// Default AES-128
const keyInfo = {
bitsKey: 128,
Expand All @@ -3326,10 +3328,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 Expand Up @@ -3357,9 +3355,9 @@ shaka.hls.HlsParser = class {
'raw', keyResponse.data, algorithm, true, ['decrypt']);
keyInfo.fetchKey = undefined; // No longer needed.
};
this.aesKeyInfoMap_.set(aesKeyInfoKey, keyInfo);
this.aesKeyInfoMap_.set(keyMapKey, keyInfo);
}
return this.aesKeyInfoMap_.get(aesKeyInfoKey);
return this.aesKeyInfoMap_.get(keyMapKey);
}


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
29 changes: 18 additions & 11 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 @@ -401,18 +402,18 @@ shaka.offline.Storage = class {
if (v.audio) {
for (const drmInfo of v.audio.drmInfos) {
if (!drmInfo.licenseServerUri.startsWith('data:')) {
return true;
return false;
}
}
}
if (v.video) {
for (const drmInfo of v.video.drmInfos) {
if (!drmInfo.licenseServerUri.startsWith('data:')) {
return true;
return false;
}
}
}
return false;
return true;
});

let usePersistentLicense = config.offline.usePersistentLicense;
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
144 changes: 144 additions & 0 deletions test/offline/storage_playback_integration.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
/*! @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 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 d6e8419

Please sign in to comment.