From 731058b1b8130773d46704941fae9024ce7df576 Mon Sep 17 00:00:00 2001 From: Walter Seymour Date: Tue, 7 Nov 2023 13:42:03 -0600 Subject: [PATCH] feat: Content Steering HLS Pathway Cloning (#1432) --- src/content-steering-controller.js | 23 +- src/manifest.js | 2 +- src/playlist-controller.js | 103 ++++++++ src/playlist-loader.js | 267 ++++++++++++++++++- test/playlist-controller.test.js | 395 +++++++++++++++++++++++++++++ test/playlist-loader.test.js | 232 +++++++++++++++++ 6 files changed, 1017 insertions(+), 5 deletions(-) diff --git a/src/content-steering-controller.js b/src/content-steering-controller.js index 860992cfb..afffc98f5 100644 --- a/src/content-steering-controller.js +++ b/src/content-steering-controller.js @@ -11,10 +11,12 @@ import videojs from 'video.js'; * TTL: number in seconds (optional) until the next content steering manifest reload. * RELOAD-URI: string (optional) uri to fetch the next content steering manifest. * SERVICE-LOCATION-PRIORITY or PATHWAY-PRIORITY a non empty array of unique string values. + * PATHWAY-CLONES: array (optional) (HLS only) pathway clone objects to copy from other playlists. */ class SteeringManifest { constructor() { this.priority_ = []; + this.pathwayClones_ = new Map(); } set version(number) { @@ -43,6 +45,13 @@ class SteeringManifest { } } + set pathwayClones(array) { + // pathwayClones must be non-empty. + if (array && array.length) { + this.pathwayClones_ = new Map(array.map((clone) => [clone.ID, clone])); + } + } + get version() { return this.version_; } @@ -58,6 +67,10 @@ class SteeringManifest { get priority() { return this.priority_; } + + get pathwayClones() { + return this.pathwayClones_; + } } /** @@ -77,12 +90,13 @@ export default class ContentSteeringController extends videojs.EventTarget { this.defaultPathway = null; this.queryBeforeStart = false; this.availablePathways_ = new Set(); - this.excludedPathways_ = new Set(); this.steeringManifest = new SteeringManifest(); this.proxyServerUrl_ = null; this.manifestType_ = null; this.ttlTimeout_ = null; this.request_ = null; + this.currentPathwayClones = new Map(); + this.nextPathwayClones = new Map(); this.excludedSteeringManifestURLs = new Set(); this.logger_ = logger('Content Steering'); this.xhr_ = xhr; @@ -265,7 +279,11 @@ export default class ContentSteeringController extends videojs.EventTarget { this.steeringManifest.reloadUri = steeringJson['RELOAD-URI']; // HLS = PATHWAY-PRIORITY required. DASH = SERVICE-LOCATION-PRIORITY optional this.steeringManifest.priority = steeringJson['PATHWAY-PRIORITY'] || steeringJson['SERVICE-LOCATION-PRIORITY']; - // TODO: HLS handle PATHWAY-CLONES. See section 7.2 https://datatracker.ietf.org/doc/draft-pantos-hls-rfc8216bis/ + + // Pathway clones to be created/updated in HLS. + // See section 7.2 https://datatracker.ietf.org/doc/draft-pantos-hls-rfc8216bis/ + this.steeringManifest.pathwayClones = steeringJson['PATHWAY-CLONES']; + this.nextPathwayClones = this.steeringManifest.pathwayClones; // 1. apply first pathway from the array. // 2. if first pathway doesn't exist in manifest, try next pathway. @@ -393,7 +411,6 @@ export default class ContentSteeringController extends videojs.EventTarget { this.request_ = null; this.excludedSteeringManifestURLs = new Set(); this.availablePathways_ = new Set(); - this.excludedPathways_ = new Set(); this.steeringManifest = new SteeringManifest(); } diff --git a/src/manifest.js b/src/manifest.js index a3f741209..d804710fb 100644 --- a/src/manifest.js +++ b/src/manifest.js @@ -11,7 +11,7 @@ export const createPlaylistID = (index, uri) => { }; // default function for creating a group id -const groupID = (type, group, label) => { +export const groupID = (type, group, label) => { return `placeholder-uri-${type}-${group}-${label}`; }; diff --git a/src/playlist-controller.js b/src/playlist-controller.js index fdac03be5..3819ee946 100644 --- a/src/playlist-controller.js +++ b/src/playlist-controller.js @@ -2197,6 +2197,9 @@ export class PlaylistController extends videojs.EventTarget { if (!currentPathway) { return; } + + this.handlePathwayClones_(); + const main = this.main(); const playlists = main.playlists; const ids = new Set(); @@ -2246,6 +2249,106 @@ export class PlaylistController extends videojs.EventTarget { } } + /** + * Add, update, or delete playlists and media groups for + * the pathway clones for HLS Content Steering. + * + * See https://datatracker.ietf.org/doc/draft-pantos-hls-rfc8216bis/ + * + * NOTE: Pathway cloning does not currently support the `PER_VARIANT_URIS` and + * `PER_RENDITION_URIS` as we do not handle `STABLE-VARIANT-ID` or + * `STABLE-RENDITION-ID` values. + */ + handlePathwayClones_() { + const main = this.main(); + const playlists = main.playlists; + const currentPathwayClones = this.contentSteeringController_.currentPathwayClones; + const nextPathwayClones = this.contentSteeringController_.nextPathwayClones; + + const hasClones = (currentPathwayClones && currentPathwayClones.size) || (nextPathwayClones && nextPathwayClones.size); + + if (!hasClones) { + return; + } + + for (const [id, clone] of currentPathwayClones.entries()) { + const newClone = nextPathwayClones.get(id); + + // Delete the old pathway clone. + if (!newClone) { + this.mainPlaylistLoader_.updateOrDeleteClone(clone); + this.contentSteeringController_.excludePathway(id); + } + } + + for (const [id, clone] of nextPathwayClones.entries()) { + const oldClone = currentPathwayClones.get(id); + + // Create a new pathway if it is a new pathway clone object. + if (!oldClone) { + const playlistsToClone = playlists.filter(p => { + return p.attributes['PATHWAY-ID'] === clone['BASE-ID']; + }); + + playlistsToClone.forEach((p) => { + this.mainPlaylistLoader_.addClonePathway(clone, p); + }); + + this.contentSteeringController_.addAvailablePathway(id); + continue; + } + + // There have not been changes to the pathway clone object, so skip. + if (this.equalPathwayClones_(oldClone, clone)) { + continue; + } + + // Update a preexisting cloned pathway. + // True is set for the update flag. + this.mainPlaylistLoader_.updateOrDeleteClone(clone, true); + this.contentSteeringController_.addAvailablePathway(id); + } + + // Deep copy contents of next to current pathways. + this.contentSteeringController_.currentPathwayClones = new Map(JSON.parse(JSON.stringify([...nextPathwayClones]))); + } + + /** + * Determines whether two pathway clone objects are equivalent. + * + * @param {Object} a The first pathway clone object. + * @param {Object} b The second pathway clone object. + * @return {boolean} True if the pathway clone objects are equal, false otherwise. + */ + equalPathwayClones_(a, b) { + if ( + a['BASE-ID'] !== b['BASE-ID'] || + a.ID !== b.ID || + a['URI-REPLACEMENT'].HOST !== b['URI-REPLACEMENT'].HOST + ) { + return false; + } + + const aParams = a['URI-REPLACEMENT'].PARAMS; + const bParams = b['URI-REPLACEMENT'].PARAMS; + + // We need to iterate through both lists of params because one could be + // missing a parameter that the other has. + for (const p in aParams) { + if (aParams[p] !== bParams[p]) { + return false; + } + } + + for (const p in bParams) { + if (aParams[p] !== bParams[p]) { + return false; + } + } + + return true; + } + /** * Changes the current playlists for audio, video and subtitles after a new pathway * is chosen from content steering. diff --git a/src/playlist-loader.js b/src/playlist-loader.js index 35a1259ad..5f270e5eb 100644 --- a/src/playlist-loader.js +++ b/src/playlist-loader.js @@ -14,7 +14,9 @@ import { addPropertiesToMain, mainForMedia, setupMediaPlaylist, - forEachMediaGroup + forEachMediaGroup, + createPlaylistID, + groupID } from './manifest'; import {getKnownPartCount} from './playlist.js'; import {merge} from './util/vjs-compat'; @@ -929,4 +931,267 @@ export default class PlaylistLoader extends EventTarget { this.trigger('loadedmetadata'); } + /** + * Updates or deletes a preexisting pathway clone. + * Ensures that all playlists related to the old pathway clone are + * either updated or deleted. + * + * @param {Object} clone On update, the pathway clone object for the newly updated pathway clone. + * On delete, the old pathway clone object to be deleted. + * @param {boolean} isUpdate True if the pathway is to be updated, + * false if it is meant to be deleted. + */ + updateOrDeleteClone(clone, isUpdate) { + const main = this.main; + const pathway = clone.ID; + + let i = main.playlists.length; + + // Iterate backwards through the playlist so we can remove playlists if necessary. + while (i--) { + const p = main.playlists[i]; + + if (p.attributes['PATHWAY-ID'] === pathway) { + const oldPlaylistUri = p.resolvedUri; + const oldPlaylistId = p.id; + + // update the indexed playlist and add new playlists by ID and URI + if (isUpdate) { + const newPlaylistUri = this.createCloneURI_(p.resolvedUri, clone); + const newPlaylistId = createPlaylistID(pathway, newPlaylistUri); + const attributes = this.createCloneAttributes_(pathway, p.attributes); + const updatedPlaylist = this.createClonePlaylist_(p, newPlaylistId, clone, attributes); + + main.playlists[i] = updatedPlaylist; + main.playlists[newPlaylistId] = updatedPlaylist; + main.playlists[newPlaylistUri] = updatedPlaylist; + } else { + // Remove the indexed playlist. + main.playlists.splice(i, 1); + } + + // Remove playlists by the old ID and URI. + delete main.playlists[oldPlaylistId]; + delete main.playlists[oldPlaylistUri]; + } + } + + this.updateOrDeleteCloneMedia(clone, isUpdate); + } + + /** + * Updates or deletes media data based on the pathway clone object. + * Due to the complexity of the media groups and playlists, in all cases + * we remove all of the old media groups and playlists. + * On updates, we then create new media groups and playlists based on the + * new pathway clone object. + * + * @param {Object} clone The pathway clone object for the newly updated pathway clone. + * @param {boolean} isUpdate True if the pathway is to be updated, + * false if it is meant to be deleted. + */ + updateOrDeleteCloneMedia(clone, isUpdate) { + const main = this.main; + const id = clone.ID; + + ['AUDIO', 'SUBTITLES', 'CLOSED-CAPTIONS'].forEach((mediaType) => { + if (!main.mediaGroups[mediaType] || !main.mediaGroups[mediaType][id]) { + return; + } + + for (const groupKey in main.mediaGroups[mediaType]) { + // Remove all media playlists for the media group for this pathway clone. + if (groupKey === id) { + for (const labelKey in main.mediaGroups[mediaType][groupKey]) { + const oldMedia = main.mediaGroups[mediaType][groupKey][labelKey]; + + oldMedia.playlists.forEach((p, i) => { + const oldMediaPlaylist = main.playlists[p.id]; + const oldPlaylistId = oldMediaPlaylist.id; + const oldPlaylistUri = oldMediaPlaylist.resolvedUri; + + delete main.playlists[oldPlaylistId]; + delete main.playlists[oldPlaylistUri]; + }); + } + + // Delete the old media group. + delete main.mediaGroups[mediaType][groupKey]; + } + } + }); + + // Create the new media groups and playlists if there is an update. + if (isUpdate) { + this.createClonedMediaGroups_(clone); + } + } + + /** + * Given a pathway clone object, clones all necessary playlists. + * + * @param {Object} clone The pathway clone object. + * @param {Object} basePlaylist The original playlist to clone from. + */ + addClonePathway(clone, basePlaylist = {}) { + const main = this.main; + const index = main.playlists.length; + const uri = this.createCloneURI_(basePlaylist.resolvedUri, clone); + const playlistId = createPlaylistID(clone.ID, uri); + const attributes = this.createCloneAttributes_(clone.ID, basePlaylist.attributes); + + const playlist = this.createClonePlaylist_(basePlaylist, playlistId, clone, attributes); + + main.playlists[index] = playlist; + + // add playlist by ID and URI + main.playlists[playlistId] = playlist; + main.playlists[uri] = playlist; + + this.createClonedMediaGroups_(clone); + } + + /** + * Given a pathway clone object we create clones of all media. + * In this function, all necessary information and updated playlists + * are added to the `mediaGroup` object. + * Playlists are also added to the `playlists` array so the media groups + * will be properly linked. + * + * @param {Object} clone The pathway clone object. + */ + createClonedMediaGroups_(clone) { + const id = clone.ID; + const baseID = clone['BASE-ID']; + const main = this.main; + + ['AUDIO', 'SUBTITLES', 'CLOSED-CAPTIONS'].forEach((mediaType) => { + // If the media type doesn't exist, or there is already a clone, skip + // to the next media type. + if (!main.mediaGroups[mediaType] || main.mediaGroups[mediaType][id]) { + return; + } + + for (const groupKey in main.mediaGroups[mediaType]) { + if (groupKey === baseID) { + // Create the group. + main.mediaGroups[mediaType][id] = {}; + } else { + // There is no need to iterate over label keys in this case. + continue; + } + + for (const labelKey in main.mediaGroups[mediaType][groupKey]) { + const oldMedia = main.mediaGroups[mediaType][groupKey][labelKey]; + + main.mediaGroups[mediaType][id][labelKey] = Object.assign({}, oldMedia); + const newMedia = main.mediaGroups[mediaType][id][labelKey]; + + // update URIs on the media + const newUri = this.createCloneURI_(oldMedia.resolvedUri, clone); + + newMedia.resolvedUri = newUri; + newMedia.uri = newUri; + + // Reset playlists in the new media group. + newMedia.playlists = []; + + // Create new playlists in the newly cloned media group. + oldMedia.playlists.forEach((p, i) => { + const oldMediaPlaylist = main.playlists[p.id]; + const group = groupID(mediaType, id, labelKey); + const newPlaylistID = createPlaylistID(id, group); + + // Check to see if it already exists + if (oldMediaPlaylist && !main.playlists[newPlaylistID]) { + const newMediaPlaylist = this.createClonePlaylist_(oldMediaPlaylist, newPlaylistID, clone); + + const newPlaylistUri = newMediaPlaylist.resolvedUri; + + main.playlists[newPlaylistID] = newMediaPlaylist; + main.playlists[newPlaylistUri] = newMediaPlaylist; + } + + newMedia.playlists[i] = this.createClonePlaylist_(p, newPlaylistID, clone); + }); + } + } + }); + } + + /** + * Using the original playlist to be cloned, and the pathway clone object + * information, we create a new playlist. + * + * @param {Object} basePlaylist The original playlist to be cloned from. + * @param {string} id The desired id of the newly cloned playlist. + * @param {Object} clone The pathway clone object. + * @param {Object} attributes An optional object to populate the `attributes` property in the playlist. + * + * @return {Object} The combined cloned playlist. + */ + createClonePlaylist_(basePlaylist, id, clone, attributes) { + const uri = this.createCloneURI_(basePlaylist.resolvedUri, clone); + const newProps = { + resolvedUri: uri, + uri, + id + }; + + // Remove all segments from previous playlist in the clone. + if (basePlaylist.segments) { + newProps.segments = []; + } + + if (attributes) { + newProps.attributes = attributes; + } + + return merge(basePlaylist, newProps); + } + + /** + * Generates an updated URI for a cloned pathway based on the original + * pathway's URI and the paramaters from the pathway clone object in the + * content steering server response. + * + * @param {string} baseUri URI to be updated in the cloned pathway. + * @param {Object} clone The pathway clone object. + * + * @return {string} The updated URI for the cloned pathway. + */ + createCloneURI_(baseURI, clone) { + const uri = new URL(baseURI); + + uri.hostname = clone['URI-REPLACEMENT'].HOST; + + const params = clone['URI-REPLACEMENT'].PARAMS; + + // Add params to the cloned URL. + for (const key of Object.keys(params)) { + uri.searchParams.set(key, params[key]); + } + + return uri.href; + } + + /** + * Helper function to create the attributes needed for the new clone. + * This mainly adds the necessary media attributes. + * + * @param {string} id The pathway clone object ID. + * @param {Object} oldAttributes The old attributes to compare to. + * @return {Object} The new attributes to add to the playlist. + */ + createCloneAttributes_(id, oldAttributes) { + const attributes = { ['PATHWAY-ID']: id }; + + ['AUDIO', 'SUBTITLES', 'CLOSED-CAPTIONS'].forEach((mediaType) => { + if (oldAttributes[mediaType]) { + attributes[mediaType] = id; + } + }); + + return attributes; + } } diff --git a/test/playlist-controller.test.js b/test/playlist-controller.test.js index f176da40c..e2580e3dd 100644 --- a/test/playlist-controller.test.js +++ b/test/playlist-controller.test.js @@ -6882,3 +6882,398 @@ QUnit.test('playlists should not change when there is no currentPathway', functi // media is never switched assert.notOk(switchMediaSpy.called); }); + +QUnit.test('Pathway cloning - add a new pathway when the clone has not existed', function(assert) { + const options = { + src: 'test', + tech: this.player.tech_, + sourceType: 'hls' + }; + + const pc = new PlaylistController(options); + + this.csMainPlaylist.playlists.forEach(p => { + p.attributes['PATHWAY-ID'] = p.attributes.serviceLocation; + p.attributes.serviceLocation = undefined; + }); + + pc.main = () => this.csMainPlaylist; + pc.initContentSteeringController_(); + + const addCloneStub = sinon.stub(pc.mainPlaylistLoader_, 'addClonePathway'); + + const clone = { + ID: 'cdn-z', + ['BASE-ID']: 'cdn-a', + ['URI-REPLACEMENT']: { + HOST: 'www.cdn-z.com', + PARAMS: { + test: 123 + } + } + }; + + const steeringManifestJson = { + VERSION: 1, + TTL: 10, + ['RELOAD-URI']: 'https://fastly-server.content-steering.com/dash.dcsm', + ['PATHWAY-PRIORITY']: [ + 'cdn-b', + 'cdn-a', + 'cdn-z' + ], + ['PATHWAY-CLONES']: [clone] + }; + + // This triggers `handlePathwayClones_()` + pc.contentSteeringController_.assignSteeringProperties_(steeringManifestJson); + + // Assert that we add a clone and it is added to the available pathways If not already. + assert.equal(addCloneStub.getCall(0).args[0], clone); + assert.equal(pc.contentSteeringController_.availablePathways_.has('cdn-z'), true); + + const cloneMap = new Map(); + + cloneMap.set(clone.ID, clone); + + // Ensure we set the current pathway clones from next. + assert.deepEqual(pc.contentSteeringController_.currentPathwayClones.get('cdn-z'), cloneMap.get('cdn-z')); +}); + +QUnit.test('Pathway cloning - update the pathway when the BASE-ID does not match', function(assert) { + const options = { + src: 'test', + tech: this.player.tech_, + sourceType: 'hls' + }; + + const pc = new PlaylistController(options); + + this.csMainPlaylist.playlists.forEach(p => { + p.attributes['PATHWAY-ID'] = p.attributes.serviceLocation; + p.attributes.serviceLocation = undefined; + }); + + pc.main = () => this.csMainPlaylist; + pc.initContentSteeringController_(); + + const updateCloneStub = sinon.stub(pc.mainPlaylistLoader_, 'updateOrDeleteClone'); + + const pastClone = { + ID: 'cdn-z', + ['BASE-ID']: 'cdn-a', + ['URI-REPLACEMENT']: { + HOST: 'www.cdn-z.com', + PARAMS: { + test: 123 + } + } + }; + + const nextClone = { + ID: 'cdn-z', + ['BASE-ID']: 'cdn-b', + ['URI-REPLACEMENT']: { + HOST: 'www.cdn-b.com', + PARAMS: { + test: 123 + } + } + }; + + pc.contentSteeringController_.currentPathwayClones = new Map(); + pc.contentSteeringController_.currentPathwayClones.set(pastClone.ID, pastClone); + + const steeringManifestJson = { + VERSION: 1, + TTL: 10, + ['RELOAD-URI']: 'https://fastly-server.content-steering.com/dash.dcsm', + ['PATHWAY-PRIORITY']: [ + 'cdn-b', + 'cdn-a' + ], + ['PATHWAY-CLONES']: [nextClone] + }; + + // This triggers `handlePathwayClones()`. + pc.contentSteeringController_.assignSteeringProperties_(steeringManifestJson); + + // Assert that we update the clone and it is still in the available pathways. + assert.equal(updateCloneStub.getCall(0).args[0], nextClone); + assert.equal(updateCloneStub.getCall(0).args[1], true); + assert.equal(pc.contentSteeringController_.availablePathways_.has('cdn-z'), true); + + const nextClonesMap = new Map(); + + nextClonesMap.set(nextClone.ID, nextClone); + + // Ensure we set the current pathway clones from next. + assert.deepEqual(pc.contentSteeringController_.currentPathwayClones, nextClonesMap); +}); + +QUnit.test('Pathway cloning - update the pathway when there is a new param', function(assert) { + const options = { + src: 'test', + tech: this.player.tech_, + sourceType: 'hls' + }; + + const pc = new PlaylistController(options); + + this.csMainPlaylist.playlists.forEach(p => { + p.attributes['PATHWAY-ID'] = p.attributes.serviceLocation; + p.attributes.serviceLocation = undefined; + }); + + pc.main = () => this.csMainPlaylist; + pc.initContentSteeringController_(); + + const updateCloneStub = sinon.stub(pc.mainPlaylistLoader_, 'updateOrDeleteClone'); + + const pastClone = { + ID: 'cdn-z', + ['BASE-ID']: 'cdn-a', + ['URI-REPLACEMENT']: { + HOST: 'www.cdn-z.com', + PARAMS: { + test: 123 + } + } + }; + + const nextClone = { + ID: 'cdn-z', + ['BASE-ID']: 'cdn-b', + ['URI-REPLACEMENT']: { + HOST: 'www.cdn-b.com', + PARAMS: { + test: 123, + newParam: 456 + } + } + }; + + pc.contentSteeringController_.currentPathwayClones = new Map(); + pc.contentSteeringController_.currentPathwayClones.set(pastClone.ID, pastClone); + + const steeringManifestJson = { + VERSION: 1, + TTL: 10, + ['RELOAD-URI']: 'https://fastly-server.content-steering.com/dash.dcsm', + ['PATHWAY-PRIORITY']: [ + 'cdn-b', + 'cdn-a', + 'cdn-z' + ], + ['PATHWAY-CLONES']: [nextClone] + }; + + // This triggers `handlePathwayClones()`. + pc.contentSteeringController_.assignSteeringProperties_(steeringManifestJson); + + // Assert that we update the clone and it is still in the available pathways. + assert.equal(updateCloneStub.getCall(0).args[0], nextClone); + assert.equal(updateCloneStub.getCall(0).args[1], true); + assert.equal(pc.contentSteeringController_.availablePathways_.has('cdn-z'), true); + + const nextClonesMap = new Map(); + + nextClonesMap.set(nextClone.ID, nextClone); + + // Ensure we set the current pathway clones from next. + assert.deepEqual(pc.contentSteeringController_.currentPathwayClones, nextClonesMap); +}); + +QUnit.test('Pathway cloning - update the pathway when a param is missing', function(assert) { + const options = { + src: 'test', + tech: this.player.tech_, + sourceType: 'hls' + }; + + const pc = new PlaylistController(options); + + this.csMainPlaylist.playlists.forEach(p => { + p.attributes['PATHWAY-ID'] = p.attributes.serviceLocation; + p.attributes.serviceLocation = undefined; + }); + + pc.main = () => this.csMainPlaylist; + pc.initContentSteeringController_(); + + const updateCloneStub = sinon.stub(pc.mainPlaylistLoader_, 'updateOrDeleteClone'); + + const pastClone = { + ID: 'cdn-z', + ['BASE-ID']: 'cdn-a', + ['URI-REPLACEMENT']: { + HOST: 'www.cdn-z.com', + PARAMS: { + test: 123 + } + } + }; + + const nextClone = { + ID: 'cdn-z', + ['BASE-ID']: 'cdn-b', + ['URI-REPLACEMENT']: { + HOST: 'www.cdn-b.com', + PARAMS: {} + } + }; + + pc.contentSteeringController_.currentPathwayClones = new Map(); + pc.contentSteeringController_.currentPathwayClones.set(pastClone.ID, pastClone); + + const steeringManifestJson = { + VERSION: 1, + TTL: 10, + ['RELOAD-URI']: 'https://fastly-server.content-steering.com/dash.dcsm', + ['PATHWAY-PRIORITY']: [ + 'cdn-b', + 'cdn-a', + 'cdn-z' + ], + ['PATHWAY-CLONES']: [nextClone] + }; + + // This triggers `handlePathwayClones()`. + pc.contentSteeringController_.assignSteeringProperties_(steeringManifestJson); + + // Assert that we update the clone and it is still in the available pathways. + assert.equal(updateCloneStub.getCall(0).args[0], nextClone); + assert.equal(updateCloneStub.getCall(0).args[1], true); + assert.equal(pc.contentSteeringController_.availablePathways_.has('cdn-z'), true); + + const nextClonesMap = new Map(); + + nextClonesMap.set(nextClone.ID, nextClone); + + // Ensure we set the current pathway clones from next. + assert.deepEqual(pc.contentSteeringController_.currentPathwayClones, nextClonesMap); +}); + +QUnit.test('Pathway cloning - delete the pathway when it is no longer in the steering response', function(assert) { + const options = { + src: 'test', + tech: this.player.tech_, + sourceType: 'hls' + }; + + const pc = new PlaylistController(options); + + this.csMainPlaylist.playlists.forEach(p => { + p.attributes['PATHWAY-ID'] = p.attributes.serviceLocation; + p.attributes.serviceLocation = undefined; + }); + + pc.main = () => this.csMainPlaylist; + pc.initContentSteeringController_(); + + const updateCloneStub = sinon.stub(pc.mainPlaylistLoader_, 'updateOrDeleteClone'); + + const pastClone = { + ID: 'cdn-z', + ['BASE-ID']: 'cdn-a', + ['URI-REPLACEMENT']: { + HOST: 'www.cdn-z.com', + PARAMS: { + test: 123 + } + } + }; + + pc.contentSteeringController_.currentPathwayClones = new Map(); + pc.contentSteeringController_.currentPathwayClones.set(pastClone.ID, pastClone); + + const steeringManifestJson = { + VERSION: 1, + TTL: 10, + ['RELOAD-URI']: 'https://fastly-server.content-steering.com/dash.dcsm', + ['PATHWAY-PRIORITY']: [ + 'cdn-b', + 'cdn-a' + ], + // empty response + ['PATHWAY-CLONES']: [] + }; + + // This triggers `handlePathwayClones()`. + pc.contentSteeringController_.assignSteeringProperties_(steeringManifestJson); + + // Assert that we update the clone and it is still in the available pathways. + assert.equal(updateCloneStub.getCall(0).args[0], pastClone); + // undefined means we are deleting. + assert.equal(updateCloneStub.getCall(0).args[1], undefined); + // The value is no longer in the available pathways. + assert.equal(!pc.contentSteeringController_.availablePathways_.has('cdn-z'), true); + + assert.deepEqual(pc.contentSteeringController_.currentPathwayClones, new Map()); +}); + +QUnit.test('Pathway cloning - do nothing when next and past clones are the same', function(assert) { + const options = { + src: 'test', + tech: this.player.tech_, + sourceType: 'hls' + }; + + const pc = new PlaylistController(options); + + this.csMainPlaylist.playlists.forEach(p => { + p.attributes['PATHWAY-ID'] = p.attributes.serviceLocation; + p.attributes.serviceLocation = undefined; + }); + + pc.main = () => this.csMainPlaylist; + pc.initContentSteeringController_(); + + const addCloneStub = sinon.stub(pc.mainPlaylistLoader_, 'addClonePathway'); + const updateCloneStub = sinon.stub(pc.mainPlaylistLoader_, 'updateOrDeleteClone'); + + const clone = { + ID: 'cdn-z', + ['BASE-ID']: 'cdn-a', + ['URI-REPLACEMENT']: { + HOST: 'www.cdn-z.com', + PARAMS: { + test: 123 + } + } + }; + + pc.contentSteeringController_.currentPathwayClones = new Map(); + pc.contentSteeringController_.currentPathwayClones.set(clone.ID, clone); + + const steeringManifestJson = { + VERSION: 1, + TTL: 10, + ['RELOAD-URI']: 'https://fastly-server.content-steering.com/dash.dcsm', + ['PATHWAY-PRIORITY']: [ + 'cdn-b', + 'cdn-a', + 'cdn-z' + ], + ['PATHWAY-CLONES']: [clone] + }; + + // By adding this we are saying that the pathway was previously available. + pc.contentSteeringController_.addAvailablePathway('cdn-z'); + + // This triggers `handlePathwayClones()`. + pc.contentSteeringController_.assignSteeringProperties_(steeringManifestJson); + + // Assert that we do not add, update, or delete any pathway clones. + assert.equal(addCloneStub.callCount, 0); + assert.equal(updateCloneStub.callCount, 0); + + // The value is still in the available pathways. + assert.equal(pc.contentSteeringController_.availablePathways_.has('cdn-z'), true); + + const clonesMap = new Map(); + + clonesMap.set(clone.ID, clone); + + assert.deepEqual(pc.contentSteeringController_.currentPathwayClones, clonesMap); +}); diff --git a/test/playlist-loader.test.js b/test/playlist-loader.test.js index 4a32c7a4e..3ae3b2d93 100644 --- a/test/playlist-loader.test.js +++ b/test/playlist-loader.test.js @@ -2887,3 +2887,235 @@ QUnit.module('Playlist Loader', function(hooks) { assert.strictEqual(addDateRangesToTextTrackSpy.callCount, 1); }); }); + +QUnit.module('Pathway Cloning', { + before() { + this.fakeVhs = { + xhr: xhrFactory() + }; + this.loader = new PlaylistLoader('http://example.com/media.m3u8', this.fakeVhs); + + this.loader.load(); + + // Setup video playlists and media groups/playlists + + const videoUri = '//test.com/playlist.m3u8'; + const videoId = `0-${videoUri}`; + const videoPlaylist = { + attributes: { + ['PATHWAY-ID']: 'cdn-a', + AUDIO: 'cdn-a', + BANDWIDTH: 9, + CODECS: 'avc1.640028,mp4a.40.2' + }, + id: videoId, + uri: videoUri, + resolvedUri: 'https://test.com/playlist.m3u8', + segments: [] + }; + + const audioUri = 'https://test.com/audio_128kbps/playlist.m3u8'; + const audioId = '0-placeholder-uri-AUDIO-cdn-a-English'; + const audioPlaylist = { + attributes: {}, + autoselect: true, + default: false, + id: audioId, + language: 'en', + uri: audioUri, + resolvedUri: audioUri + }; + + this.loader.main = { + mediaGroups: { + AUDIO: { + 'cdn-a': { + English: { + autoselect: true, + default: false, + language: 'en', + resolvedUri: audioUri, + uri: audioUri, + playlists: [audioPlaylist] + } + }, + // Ensures we hit the code where we skip this. + 'cdn-other': {} + } + }, + playlists: [videoPlaylist] + }; + + // link all playlists by ID and URI + this.loader.main.playlists[videoId] = videoPlaylist; + this.loader.main.playlists[videoUri] = videoPlaylist; + this.loader.main.playlists[audioId] = audioPlaylist; + this.loader.main.playlists[audioUri] = audioPlaylist; + }, + after() { + this.loader.dispose(); + } +}); + +QUnit.test('add a new pathway clone', function(assert) { + // The cloned pathway already exists due to the previous test. + + const clone = { + ID: 'cdn-b', + ['BASE-ID']: 'cdn-a', + ['URI-REPLACEMENT']: { + HOST: 'www.cdn-b.com', + PARAMS: { + test: 123 + } + } + }; + + const videoUri = 'https://www.cdn-b.com/playlist.m3u8?test=123'; + const videoId = `cdn-b-${videoUri}`; + const expectedVideoPlaylist = { + attributes: { + AUDIO: 'cdn-b', + BANDWIDTH: 9, + CODECS: 'avc1.640028,mp4a.40.2', + ['PATHWAY-ID']: 'cdn-b' + }, + id: videoId, + resolvedUri: videoUri, + segments: [], + uri: videoUri + }; + + const audioUri = 'https://www.cdn-b.com/audio_128kbps/playlist.m3u8?test=123'; + const audioId = 'cdn-b-placeholder-uri-AUDIO-cdn-b-English'; + const expectedAudioPlaylist = { + attributes: {}, + autoselect: true, + default: false, + id: audioId, + language: 'en', + resolvedUri: audioUri, + uri: audioUri + }; + + const expectedMediaGroup = { + English: { + autoselect: true, + default: false, + language: 'en', + playlists: [expectedAudioPlaylist], + resolvedUri: audioUri, + uri: audioUri + } + }; + + this.loader.addClonePathway(clone, this.loader.main.playlists[0]); + + assert.deepEqual(this.loader.main.playlists[1], expectedVideoPlaylist); + assert.deepEqual(this.loader.main.playlists[videoUri], expectedVideoPlaylist); + assert.deepEqual(this.loader.main.playlists[videoId], expectedVideoPlaylist); + + assert.deepEqual(this.loader.main.playlists[audioId], expectedAudioPlaylist); + assert.deepEqual(this.loader.main.playlists[audioUri], expectedAudioPlaylist); + assert.deepEqual(this.loader.main.mediaGroups.AUDIO['cdn-b'], expectedMediaGroup); +}); + +QUnit.test('update the pathway clone', function(assert) { + // The cloned pathway already exists due to the previous test. + + // The old clone to be deleted. + const clone = { + ID: 'cdn-b', + ['BASE-ID']: 'cdn-a', + ['URI-REPLACEMENT']: { + HOST: 'www.newurl.com', + PARAMS: { + test: 'updatedValue' + } + } + }; + + // These values have been updated. + const videoUri = 'https://www.newurl.com/playlist.m3u8?test=updatedValue'; + const videoId = `cdn-b-${videoUri}`; + const expectedVideoPlaylist = { + attributes: { + AUDIO: 'cdn-b', + BANDWIDTH: 9, + CODECS: 'avc1.640028,mp4a.40.2', + ['PATHWAY-ID']: 'cdn-b' + }, + id: videoId, + resolvedUri: videoUri, + segments: [], + uri: videoUri + }; + + // These values have been updated. + const audioUri = 'https://www.newurl.com/audio_128kbps/playlist.m3u8?test=updatedValue'; + const audioId = 'cdn-b-placeholder-uri-AUDIO-cdn-b-English'; + const expectedAudioPlaylist = { + attributes: {}, + autoselect: true, + default: false, + id: audioId, + language: 'en', + resolvedUri: audioUri, + uri: audioUri + }; + + const expectedMediaGroup = { + English: { + autoselect: true, + default: false, + language: 'en', + playlists: [expectedAudioPlaylist], + resolvedUri: audioUri, + uri: audioUri + } + }; + + // set the flag to true to ensure we update. + this.loader.updateOrDeleteClone(clone, true); + + assert.deepEqual(this.loader.main.playlists[1], expectedVideoPlaylist); + assert.deepEqual(this.loader.main.playlists[videoUri], expectedVideoPlaylist); + assert.deepEqual(this.loader.main.playlists[videoId], expectedVideoPlaylist); + + assert.deepEqual(this.loader.main.playlists[audioId], expectedAudioPlaylist); + assert.deepEqual(this.loader.main.playlists[audioUri], expectedAudioPlaylist); + assert.deepEqual(this.loader.main.mediaGroups.AUDIO['cdn-b'], expectedMediaGroup); +}); + +QUnit.test('delete the pathway clone', function(assert) { + // The old clone to be deleted. + const clone = { + ID: 'cdn-b', + ['BASE-ID']: 'cdn-a', + ['URI-REPLACEMENT']: { + HOST: 'www.cdn-b.com', + PARAMS: { + test: 123 + } + } + }; + + // the playlist exists before the deletion. + assert.deepEqual(this.loader.main.playlists[1].attributes['PATHWAY-ID'], 'cdn-b'); + + const videoUri = 'https://www.cdn-b.com/playlist.m3u8?test=123'; + const videoId = `cdn-b-${videoUri}`; + const audioUri = 'https://www.cdn-b.com/audio_128kbps/playlist.m3u8?test=123'; + const audioId = 'cdn-b-placeholder-uri-AUDIO-cdn-b-English'; + + // set the flag to false to ensure we delete. + this.loader.updateOrDeleteClone(clone, false); + + assert.deepEqual(this.loader.main.playlists[1], undefined); + assert.deepEqual(this.loader.main.playlists[videoUri], undefined); + assert.deepEqual(this.loader.main.playlists[videoId], undefined); + + assert.deepEqual(this.loader.main.playlists[audioId], undefined); + assert.deepEqual(this.loader.main.playlists[audioUri], undefined); + assert.deepEqual(this.loader.main.mediaGroups.AUDIO['cdn-b'], undefined); +});