diff --git a/src/manifest.js b/src/manifest.js index da74aeb27..a65780a37 100644 --- a/src/manifest.js +++ b/src/manifest.js @@ -2,6 +2,7 @@ import videojs from 'video.js'; import window from 'global/window'; import { Parser as M3u8Parser } from 'm3u8-parser'; import { resolveUrl } from './resolve-url'; +import { getLastParts } from './playlist.js'; const { log } = videojs; @@ -92,6 +93,18 @@ export const parseManifest = ({ manifest.targetDuration = targetDuration; } + const parts = getLastParts(manifest); + + if (parts.length && !manifest.partTargetDuration) { + const partTargetDuration = parts.reduce((acc, p) => Math.max(acc, p.duration), 0); + + if (onwarn) { + onwarn(`manifest has no partTargetDuration defaulting to ${partTargetDuration}`); + log.error('LL-HLS manifest has parts but lacks required #EXT-X-PART-INF:PART-TARGET value. See https://datatracker.ietf.org/doc/html/draft-pantos-hls-rfc8216bis-09#section-4.4.3.7. Playback is not guaranteed.'); + } + manifest.partTargetDuration = partTargetDuration; + } + return manifest; }; diff --git a/src/playlist-loader.js b/src/playlist-loader.js index 42af61063..2d7b330be 100644 --- a/src/playlist-loader.js +++ b/src/playlist-loader.js @@ -16,9 +16,69 @@ import { setupMediaPlaylist, forEachMediaGroup } from './manifest'; +import {getKnownPartCount} from './playlist.js'; const { mergeOptions, EventTarget } = videojs; +const addLLHLSQueryDirectives = (uri, media) => { + if (media.endList) { + return uri; + } + const query = []; + + if (media.serverControl && media.serverControl.canBlockReload) { + const {preloadSegment} = media; + // next msn is a zero based value, length is not. + let nextMSN = media.mediaSequence + media.segments.length; + + // If preload segment has parts then it is likely + // that we are going to request a part of that preload segment. + // the logic below is used to determine that. + if (preloadSegment) { + const parts = preloadSegment.parts || []; + // _HLS_part is a zero based index + const nextPart = getKnownPartCount(media) - 1; + + // if nextPart is > -1 and not equal to just the + // length of parts, then we know we had part preload hints + // and we need to add the _HLS_part= query + if (nextPart > -1 && nextPart !== (parts.length - 1)) { + // add existing parts to our preload hints + query.push(`_HLS_part=${nextPart}`); + } + + // this if statement makes sure that we request the msn + // of the preload segment if: + // 1. the preload segment had parts (and was not yet a full segment) + // but was added to our segments array + // 2. the preload segment had preload hints for parts that are not in + // the manifest yet. + // in all other cases we want the segment after the preload segment + // which will be given by using media.segments.length because it is 1 based + // rather than 0 based. + if (nextPart > -1 || parts.length) { + nextMSN--; + } + } + + // add _HLS_msn= in front of any _HLS_part query + query.unshift(`_HLS_msn=${nextMSN}`); + } + + if (media.serverControl && media.serverControl.canSkipUntil) { + // add _HLS_skip= infront of all other queries. + query.unshift('_HLS_skip=' + (media.serverControl.canSkipDateranges ? 'v2' : 'YES')); + } + + query.forEach(function(str, i) { + const symbol = i === 0 ? '?' : '&'; + + uri += `${symbol}${str}`; + }); + + return uri; +}; + /** * Returns a new segment object with properties and * the parts array merged. @@ -35,6 +95,12 @@ export const updateSegment = (a, b) => { const result = mergeOptions(a, b); + // if only the old segment has preload hints + // and the new one does not, remove preload hints. + if (a.preloadHints && !b.preloadHints) { + delete result.preloadHints; + } + // if only the old segment has parts // then the parts are no longer valid if (a.parts && !b.parts) { @@ -50,6 +116,18 @@ export const updateSegment = (a, b) => { } } + // set skipped to false for segments that have + // have had information merged from the old segment. + if (!a.skipped && b.skipped) { + result.skipped = false; + } + + // set preload to false for segments that have + // had information added in the new segment. + if (a.preload && !b.preload) { + result.preload = false; + } + return result; }; @@ -70,15 +148,30 @@ export const updateSegment = (a, b) => { */ export const updateSegments = (original, update, offset) => { const oldSegments = original.slice(); - const result = update.slice(); + const newSegments = update.slice(); offset = offset || 0; - const length = Math.min(original.length, update.length + offset); + const result = []; + + let currentMap; + + for (let newIndex = 0; newIndex < newSegments.length; newIndex++) { + const oldSegment = oldSegments[newIndex + offset]; + const newSegment = newSegments[newIndex]; + + if (oldSegment) { + currentMap = oldSegment.map || currentMap; + + result.push(updateSegment(oldSegment, newSegment)); + } else { + // carry over map to new segment if it is missing + if (currentMap && !newSegment.map) { + newSegment.map = currentMap; + } - for (let i = offset; i < length; i++) { - const newIndex = i - offset; + result.push(newSegment); - result[newIndex] = updateSegment(oldSegments[i], result[newIndex]); + } } return result; }; @@ -116,12 +209,27 @@ export const resolveSegmentUris = (segment, baseUri) => { const getAllSegments = function(media) { const segments = media.segments || []; + const preloadSegment = media.preloadSegment; // a preloadSegment with only preloadHints is not currently // a usable segment, only include a preloadSegment that has // parts. - if (media.preloadSegment && media.preloadSegment.parts) { - segments.push(media.preloadSegment); + if (preloadSegment && preloadSegment.parts && preloadSegment.parts.length) { + // if preloadHints has a MAP that means that the + // init segment is going to change. We cannot use any of the parts + // from this preload segment. + if (preloadSegment.preloadHints) { + for (let i = 0; i < preloadSegment.preloadHints.length; i++) { + if (preloadSegment.preloadHints[i].type === 'MAP') { + return segments; + } + } + } + // set the duration for our preload segment to target duration. + preloadSegment.duration = media.targetDuration; + preloadSegment.preload = true; + + segments.push(preloadSegment); } return segments; @@ -147,28 +255,41 @@ export const isPlaylistUnchanged = (a, b) => a === b || * master playlist with the updated media playlist merged in, or * null if the merge produced no change. */ -export const updateMaster = (master, media, unchangedCheck = isPlaylistUnchanged) => { +export const updateMaster = (master, newMedia, unchangedCheck = isPlaylistUnchanged) => { const result = mergeOptions(master, {}); - const playlist = result.playlists[media.id]; + const oldMedia = result.playlists[newMedia.id]; - if (!playlist) { + if (!oldMedia) { return null; } - if (unchangedCheck(playlist, media)) { + if (unchangedCheck(oldMedia, newMedia)) { return null; } - const mergedPlaylist = mergeOptions(playlist, media); + newMedia.segments = getAllSegments(newMedia); + + const mergedPlaylist = mergeOptions(oldMedia, newMedia); - media.segments = getAllSegments(media); + // always use the new media's preload segment + if (mergedPlaylist.preloadSegment && !newMedia.preloadSegment) { + delete mergedPlaylist.preloadSegment; + } // if the update could overlap existing segment information, merge the two segment lists - if (playlist.segments) { + if (oldMedia.segments) { + if (newMedia.skip) { + newMedia.segments = newMedia.segments || []; + // add back in objects for skipped segments, so that we merge + // old properties into the new segments + for (let i = 0; i < newMedia.skip.skippedSegments; i++) { + newMedia.segments.unshift({skipped: true}); + } + } mergedPlaylist.segments = updateSegments( - playlist.segments, - media.segments, - media.mediaSequence - playlist.mediaSequence + oldMedia.segments, + newMedia.segments, + newMedia.mediaSequence - oldMedia.mediaSequence ); } @@ -181,13 +302,13 @@ export const updateMaster = (master, media, unchangedCheck = isPlaylistUnchanged // that is referenced by index, and one by URI. The index reference may no longer be // necessary. for (let i = 0; i < result.playlists.length; i++) { - if (result.playlists[i].id === media.id) { + if (result.playlists[i].id === newMedia.id) { result.playlists[i] = mergedPlaylist; } } - result.playlists[media.id] = mergedPlaylist; + result.playlists[newMedia.id] = mergedPlaylist; // URI reference added for backwards compatibility - result.playlists[media.uri] = mergedPlaylist; + result.playlists[newMedia.uri] = mergedPlaylist; // update media group playlist references. forEachMediaGroup(master, (properties, mediaType, groupKey, labelKey) => { @@ -195,8 +316,8 @@ export const updateMaster = (master, media, unchangedCheck = isPlaylistUnchanged return; } for (let i = 0; i < properties.playlists.length; i++) { - if (media.id === properties.playlists[i].id) { - properties.playlists[i] = media; + if (newMedia.id === properties.playlists[i].id) { + properties.playlists[i] = newMedia; } } }); @@ -263,34 +384,44 @@ export default class PlaylistLoader extends EventTarget { this.state = 'HAVE_NOTHING'; // live playlist staleness timeout - this.on('mediaupdatetimeout', () => { - if (this.state !== 'HAVE_METADATA') { - // only refresh the media playlist if no other activity is going on - return; - } + this.handleMediaupdatetimeout_ = this.handleMediaupdatetimeout_.bind(this); + this.on('mediaupdatetimeout', this.handleMediaupdatetimeout_); + } - this.state = 'HAVE_CURRENT_METADATA'; + handleMediaupdatetimeout_() { + if (this.state !== 'HAVE_METADATA') { + // only refresh the media playlist if no other activity is going on + return; + } + const media = this.media(); - this.request = this.vhs_.xhr({ - uri: resolveUrl(this.master.uri, this.media().uri), - withCredentials: this.withCredentials - }, (error, req) => { - // disposed - if (!this.request) { - return; - } + let uri = resolveUrl(this.master.uri, media.uri); - if (error) { - return this.playlistRequestError(this.request, this.media(), 'HAVE_METADATA'); - } + if (this.experimentalLLHLS) { + uri = addLLHLSQueryDirectives(uri, media); + } + this.state = 'HAVE_CURRENT_METADATA'; - this.haveMetadata({ - playlistString: this.request.responseText, - url: this.media().uri, - id: this.media().id - }); + this.request = this.vhs_.xhr({ + uri, + withCredentials: this.withCredentials + }, (error, req) => { + // disposed + if (!this.request) { + return; + } + + if (error) { + return this.playlistRequestError(this.request, this.media(), 'HAVE_METADATA'); + } + + this.haveMetadata({ + playlistString: this.request.responseText, + url: this.media().uri, + id: this.media().id }); }); + } playlistRequestError(xhr, playlist, startingState) { @@ -317,6 +448,17 @@ export default class PlaylistLoader extends EventTarget { this.trigger('error'); } + parseManifest_({url, manifestString}) { + return parseManifest({ + onwarn: ({message}) => this.logger_(`m3u8-parser warn for ${url}: ${message}`), + oninfo: ({message}) => this.logger_(`m3u8-parser info for ${url}: ${message}`), + manifestString, + customTagParsers: this.customTagParsers, + customTagMappers: this.customTagMappers, + experimentalLLHLS: this.experimentalLLHLS + }); + } + /** * Update the playlist loader's state in response to a new or updated playlist. * @@ -334,13 +476,9 @@ export default class PlaylistLoader extends EventTarget { this.request = null; this.state = 'HAVE_METADATA'; - const playlist = playlistObject || parseManifest({ - onwarn: ({message}) => this.logger_(`m3u8-parser warn for ${id}: ${message}`), - oninfo: ({message}) => this.logger_(`m3u8-parser info for ${id}: ${message}`), - manifestString: playlistString, - customTagParsers: this.customTagParsers, - customTagMappers: this.customTagMappers, - experimentalLLHLS: this.experimentalLLHLS + const playlist = playlistObject || this.parseManifest_({ + url, + manifestString: playlistString }); playlist.lastRequest = Date.now(); @@ -647,11 +785,9 @@ export default class PlaylistLoader extends EventTarget { this.src = resolveManifestRedirect(this.handleManifestRedirects, this.src, req); - const manifest = parseManifest({ + const manifest = this.parseManifest_({ manifestString: req.responseText, - customTagParsers: this.customTagParsers, - customTagMappers: this.customTagMappers, - experimentalLLHLS: this.experimentalLLHLS + url: this.src }); this.setupInitialPlaylist(manifest); diff --git a/src/playlist.js b/src/playlist.js index dd4eb0784..5076e0ad7 100644 --- a/src/playlist.js +++ b/src/playlist.js @@ -29,6 +29,24 @@ const getPartsAndSegments = (playlist) => (playlist.segments || []).reduce((acc, return acc; }, []); +export const getLastParts = (media) => { + const lastSegment = media.segments && media.segments.length && media.segments[media.segments.length - 1]; + + return lastSegment && lastSegment.parts || []; +}; + +export const getKnownPartCount = ({preloadSegment}) => { + if (!preloadSegment) { + return; + } + const {parts, preloadHints} = preloadSegment; + let partCount = (preloadHints || []) + .reduce((count, hint) => count + (hint.type === 'PART' ? 1 : 0), 0); + + partCount += (parts && parts.length) ? parts.length : 0; + + return partCount; +}; /** * Get the number of seconds to delay from the end of a * live playlist. @@ -47,8 +65,7 @@ export const liveEdgeDelay = (master, media) => { return master.suggestedPresentationDelay; } - const lastSegment = media.segments && media.segments.length && media.segments[media.segments.length - 1]; - const hasParts = lastSegment && lastSegment.parts && lastSegment.parts.length; + const hasParts = getLastParts(media).length > 0; // look for "part" delays from ll-hls first if (hasParts && media.serverControl && media.serverControl.partHoldBack) { diff --git a/src/segment-loader.js b/src/segment-loader.js index e691f476e..a3ccf8d50 100644 --- a/src/segment-loader.js +++ b/src/segment-loader.js @@ -24,6 +24,7 @@ import shallowEqual from './util/shallow-equal.js'; import { QUOTA_EXCEEDED_ERR } from './error-codes'; import { timeRangesToArray } from './ranges'; import {lastBufferedEnd} from './ranges.js'; +import {getKnownPartCount} from './playlist.js'; /** * The segment loader has no recourse except to fetch a segment in the @@ -135,11 +136,7 @@ const segmentInfoString = (segmentInfo) => { const { startOfSegment, duration, - segment: { - start, - end, - parts - }, + segment, playlist: { mediaSequence: seq, id, @@ -158,11 +155,13 @@ const segmentInfoString = (segmentInfo) => { } else if (segmentInfo.isSyncRequest) { selection = 'getSyncSegmentCandidate (isSyncRequest)'; } - + const {start, end} = segment; + const hasPartIndex = typeof partIndex === 'number'; const name = segmentInfo.segment.uri ? 'segment' : 'pre-segment'; + const totalParts = hasPartIndex ? getKnownPartCount({preloadSegment: segment}) - 1 : 0; return `${name} [${index}/${segmentLen}]` + - (partIndex ? ` part [${partIndex}/${parts.length - 1}]` : '') + + (hasPartIndex ? ` part [${partIndex}/${totalParts}]` : '') + ` mediaSequenceNumber [${seq}/${seq + segmentLen}]` + ` start/end [${start} => ${end}]` + ` startOfSegment [${startOfSegment}]` + diff --git a/test/playlist-loader.test.js b/test/playlist-loader.test.js index d049f61a8..9596c109b 100644 --- a/test/playlist-loader.test.js +++ b/test/playlist-loader.test.js @@ -16,182 +16,148 @@ import { } from '../src/manifest.js'; import manifests from 'create-test-data!manifests'; -QUnit.module('Playlist Loader', { - beforeEach(assert) { +QUnit.module('Playlist Loader', function(hooks) { + hooks.beforeEach(function(assert) { this.env = useFakeEnvironment(assert); this.clock = this.env.clock; this.requests = this.env.requests; this.fakeVhs = { xhr: xhrFactory() }; - }, - afterEach() { + }); + hooks.afterEach(function(assert) { this.env.restore(); - } -}); + }); -QUnit.test('updateSegments copies over properties', function(assert) { - assert.deepEqual( - [ - { uri: 'test-uri-0', startTime: 0, endTime: 10 }, - { - uri: 'test-uri-1', - startTime: 10, - endTime: 20, - map: { someProp: 99, uri: '4' } - } - ], - updateSegments( + QUnit.test('updateSegments copies over properties', function(assert) { + assert.deepEqual( [ { uri: 'test-uri-0', startTime: 0, endTime: 10 }, - { uri: 'test-uri-1', startTime: 10, endTime: 20, map: { someProp: 1 } } - ], - [ - { uri: 'test-uri-0' }, - { uri: 'test-uri-1', map: { someProp: 99, uri: '4' } } + { + uri: 'test-uri-1', + startTime: 10, + endTime: 20, + map: { someProp: 99, uri: '4' } + } ], - 0 - ), - 'retains properties from original segment' - ); + updateSegments( + [ + { uri: 'test-uri-0', startTime: 0, endTime: 10 }, + { uri: 'test-uri-1', startTime: 10, endTime: 20, map: { someProp: 1 } } + ], + [ + { uri: 'test-uri-0' }, + { uri: 'test-uri-1', map: { someProp: 99, uri: '4' } } + ], + 0 + ), + 'retains properties from original segment' + ); - assert.deepEqual( - [ - { uri: 'test-uri-0', map: { someProp: 100 } }, - { uri: 'test-uri-1', map: { someProp: 99, uri: '4' } } - ], - updateSegments( - [ - { uri: 'test-uri-0' }, - { uri: 'test-uri-1', map: { someProp: 1 } } - ], + assert.deepEqual( [ { uri: 'test-uri-0', map: { someProp: 100 } }, { uri: 'test-uri-1', map: { someProp: 99, uri: '4' } } ], - 0 - ), - 'copies over/overwrites properties without offset' - ); + updateSegments( + [ + { uri: 'test-uri-0' }, + { uri: 'test-uri-1', map: { someProp: 1 } } + ], + [ + { uri: 'test-uri-0', map: { someProp: 100 } }, + { uri: 'test-uri-1', map: { someProp: 99, uri: '4' } } + ], + 0 + ), + 'copies over/overwrites properties without offset' + ); - assert.deepEqual( - [ - { uri: 'test-uri-1', map: { someProp: 1 } }, - { uri: 'test-uri-2', map: { someProp: 100, uri: '2' } } - ], - updateSegments( - [ - { uri: 'test-uri-0' }, - { uri: 'test-uri-1', map: { someProp: 1 } } - ], + assert.deepEqual( [ - { uri: 'test-uri-1' }, + { uri: 'test-uri-1', map: { someProp: 1 } }, { uri: 'test-uri-2', map: { someProp: 100, uri: '2' } } ], - 1 - ), - 'copies over/overwrites properties with offset of 1' - ); + updateSegments( + [ + { uri: 'test-uri-0' }, + { uri: 'test-uri-1', map: { someProp: 1 } } + ], + [ + { uri: 'test-uri-1' }, + { uri: 'test-uri-2', map: { someProp: 100, uri: '2' } } + ], + 1 + ), + 'copies over/overwrites properties with offset of 1' + ); - assert.deepEqual( - [ - { uri: 'test-uri-2' }, - { uri: 'test-uri-3', map: { someProp: 100, uri: '2' } } - ], - updateSegments( - [ - { uri: 'test-uri-0' }, - { uri: 'test-uri-1', map: { someProp: 1 } } - ], + assert.deepEqual( [ { uri: 'test-uri-2' }, { uri: 'test-uri-3', map: { someProp: 100, uri: '2' } } ], - 2 - ), - 'copies over/overwrites properties with offset of 2' - ); -}); + updateSegments( + [ + { uri: 'test-uri-0' }, + { uri: 'test-uri-1', map: { someProp: 1 } } + ], + [ + { uri: 'test-uri-2' }, + { uri: 'test-uri-3', map: { someProp: 100, uri: '2' } } + ], + 2 + ), + 'copies over/overwrites properties with offset of 2' + ); + }); -QUnit.test('updateMaster returns null when no playlists', function(assert) { - const master = { - playlists: [] - }; - const media = {}; + QUnit.test('updateMaster returns null when no playlists', function(assert) { + const master = { + playlists: [] + }; + const media = {}; - assert.deepEqual(updateMaster(master, media), null, 'returns null when no playlists'); -}); + assert.deepEqual(updateMaster(master, media), null, 'returns null when no playlists'); + }); -QUnit.test('updateMaster returns null when no change', function(assert) { - const master = { - playlists: [{ - mediaSequence: 0, - attributes: { - BANDWIDTH: 9 - }, - uri: 'playlist-0-uri', - id: 'playlist-0-uri', - resolvedUri: urlTo('playlist-0-uri'), - segments: [{ - duration: 10, - uri: 'segment-0-uri', - resolvedUri: urlTo('segment-0-uri') + QUnit.test('updateMaster returns null when no change', function(assert) { + const master = { + playlists: [{ + mediaSequence: 0, + attributes: { + BANDWIDTH: 9 + }, + uri: 'playlist-0-uri', + id: 'playlist-0-uri', + resolvedUri: urlTo('playlist-0-uri'), + segments: [{ + duration: 10, + uri: 'segment-0-uri', + resolvedUri: urlTo('segment-0-uri') + }] }] - }] - }; - const media = { - mediaSequence: 0, - attributes: { - BANDWIDTH: 9 - }, - uri: 'playlist-0-uri', - id: 'playlist-0-uri', - segments: [{ - duration: 10, - uri: 'segment-0-uri' - }] - }; - - assert.deepEqual(updateMaster(master, media), null, 'returns null'); -}); - -QUnit.test('updateMaster updates master when new media sequence', function(assert) { - const master = { - playlists: [{ + }; + const media = { mediaSequence: 0, attributes: { BANDWIDTH: 9 }, uri: 'playlist-0-uri', id: 'playlist-0-uri', - resolvedUri: urlTo('playlist-0-uri'), segments: [{ duration: 10, - uri: 'segment-0-uri', - resolvedUri: urlTo('segment-0-uri') + uri: 'segment-0-uri' }] - }] - }; - const media = { - mediaSequence: 1, - attributes: { - BANDWIDTH: 9 - }, - uri: 'playlist-0-uri', - id: 'playlist-0-uri', - segments: [{ - duration: 10, - uri: 'segment-0-uri' - }] - }; - - master.playlists[media.id] = master.playlists[0]; - - assert.deepEqual( - updateMaster(master, media), - { + }; + + assert.deepEqual(updateMaster(master, media), null, 'returns null'); + }); + + QUnit.test('updateMaster updates master when new media sequence', function(assert) { + const master = { playlists: [{ - mediaSequence: 1, + mediaSequence: 0, attributes: { BANDWIDTH: 9 }, @@ -204,50 +170,48 @@ QUnit.test('updateMaster updates master when new media sequence', function(asser resolvedUri: urlTo('segment-0-uri') }] }] - }, - 'updates master when new media sequence' - ); -}); - -QUnit.test('updateMaster updates master when endList changes', function(assert) { - const master = { - playlists: [{ - endList: false, - mediaSequence: 0, + }; + const media = { + mediaSequence: 1, attributes: { BANDWIDTH: 9 }, - id: 'playlist-0-uri', uri: 'playlist-0-uri', - resolvedUri: urlTo('playlist-0-uri'), + id: 'playlist-0-uri', segments: [{ duration: 10, - uri: 'segment-0-uri', - resolvedUri: urlTo('segment-0-uri') + uri: 'segment-0-uri' }] - }] - }; - const media = { - endList: true, - mediaSequence: 0, - attributes: { - BANDWIDTH: 9 - }, - id: 'playlist-0-uri', - uri: 'playlist-0-uri', - segments: [{ - duration: 10, - uri: 'segment-0-uri' - }] - }; - - master.playlists[media.id] = master.playlists[0]; - - assert.deepEqual( - updateMaster(master, media), - { + }; + + master.playlists[media.id] = master.playlists[0]; + + assert.deepEqual( + updateMaster(master, media), + { + playlists: [{ + mediaSequence: 1, + attributes: { + BANDWIDTH: 9 + }, + uri: 'playlist-0-uri', + id: 'playlist-0-uri', + resolvedUri: urlTo('playlist-0-uri'), + segments: [{ + duration: 10, + uri: 'segment-0-uri', + resolvedUri: urlTo('segment-0-uri') + }] + }] + }, + 'updates master when new media sequence' + ); + }); + + QUnit.test('updateMaster updates master when endList changes', function(assert) { + const master = { playlists: [{ - endList: true, + endList: false, mediaSequence: 0, attributes: { BANDWIDTH: 9 @@ -261,54 +225,48 @@ QUnit.test('updateMaster updates master when endList changes', function(assert) resolvedUri: urlTo('segment-0-uri') }] }] - }, - 'updates master when endList changes' - ); -}); - -QUnit.test('updateMaster retains top level values in master', function(assert) { - const master = { - mediaGroups: { - AUDIO: { - 'GROUP-ID': { - default: true, - uri: 'audio-uri' - } - } - }, - playlists: [{ + }; + const media = { + endList: true, mediaSequence: 0, attributes: { BANDWIDTH: 9 }, id: 'playlist-0-uri', uri: 'playlist-0-uri', - resolvedUri: urlTo('playlist-0-uri'), segments: [{ duration: 10, - uri: 'segment-0-uri', - resolvedUri: urlTo('segment-0-uri') + uri: 'segment-0-uri' }] - }] - }; - const media = { - mediaSequence: 1, - attributes: { - BANDWIDTH: 9 - }, - id: 'playlist-0-uri', - uri: 'playlist-0-uri', - segments: [{ - duration: 10, - uri: 'segment-0-uri' - }] - }; - - master.playlists[media.id] = master.playlists[0]; - - assert.deepEqual( - updateMaster(master, media), - { + }; + + master.playlists[media.id] = master.playlists[0]; + + assert.deepEqual( + updateMaster(master, media), + { + playlists: [{ + endList: true, + mediaSequence: 0, + attributes: { + BANDWIDTH: 9 + }, + id: 'playlist-0-uri', + uri: 'playlist-0-uri', + resolvedUri: urlTo('playlist-0-uri'), + segments: [{ + duration: 10, + uri: 'segment-0-uri', + resolvedUri: urlTo('segment-0-uri') + }] + }] + }, + 'updates master when endList changes' + ); + }); + + QUnit.test('updateMaster retains top level values in master', function(assert) { + const master = { mediaGroups: { AUDIO: { 'GROUP-ID': { @@ -318,7 +276,7 @@ QUnit.test('updateMaster retains top level values in master', function(assert) { } }, playlists: [{ - mediaSequence: 1, + mediaSequence: 0, attributes: { BANDWIDTH: 9 }, @@ -331,57 +289,54 @@ QUnit.test('updateMaster retains top level values in master', function(assert) { resolvedUri: urlTo('segment-0-uri') }] }] - }, - 'retains top level values in master' - ); -}); - -QUnit.test('updateMaster adds new segments to master', function(assert) { - const master = { - mediaGroups: { - AUDIO: { - 'GROUP-ID': { - default: true, - uri: 'audio-uri' - } - } - }, - playlists: [{ - mediaSequence: 0, + }; + const media = { + mediaSequence: 1, attributes: { BANDWIDTH: 9 }, id: 'playlist-0-uri', uri: 'playlist-0-uri', - resolvedUri: urlTo('playlist-0-uri'), segments: [{ duration: 10, - uri: 'segment-0-uri', - resolvedUri: urlTo('segment-0-uri') + uri: 'segment-0-uri' }] - }] - }; - const media = { - mediaSequence: 1, - attributes: { - BANDWIDTH: 9 - }, - id: 'playlist-0-uri', - uri: 'playlist-0-uri', - segments: [{ - duration: 10, - uri: 'segment-0-uri' - }, { - duration: 9, - uri: 'segment-1-uri' - }] - }; - - master.playlists[media.id] = master.playlists[0]; - - assert.deepEqual( - updateMaster(master, media), - { + }; + + master.playlists[media.id] = master.playlists[0]; + + assert.deepEqual( + updateMaster(master, media), + { + mediaGroups: { + AUDIO: { + 'GROUP-ID': { + default: true, + uri: 'audio-uri' + } + } + }, + playlists: [{ + mediaSequence: 1, + attributes: { + BANDWIDTH: 9 + }, + id: 'playlist-0-uri', + uri: 'playlist-0-uri', + resolvedUri: urlTo('playlist-0-uri'), + segments: [{ + duration: 10, + uri: 'segment-0-uri', + resolvedUri: urlTo('segment-0-uri') + }] + }] + }, + 'retains top level values in master' + ); + }); + + QUnit.test('updateMaster adds new segments to master', function(assert) { + const master = { mediaGroups: { AUDIO: { 'GROUP-ID': { @@ -391,7 +346,7 @@ QUnit.test('updateMaster adds new segments to master', function(assert) { } }, playlists: [{ - mediaSequence: 1, + mediaSequence: 0, attributes: { BANDWIDTH: 9 }, @@ -402,64 +357,63 @@ QUnit.test('updateMaster adds new segments to master', function(assert) { duration: 10, uri: 'segment-0-uri', resolvedUri: urlTo('segment-0-uri') - }, { - duration: 9, - uri: 'segment-1-uri', - resolvedUri: urlTo('segment-1-uri') }] }] - }, - 'adds new segment to master' - ); -}); - -QUnit.test('updateMaster changes old values', function(assert) { - const master = { - mediaGroups: { - AUDIO: { - 'GROUP-ID': { - default: true, - uri: 'audio-uri' - } - } - }, - playlists: [{ - mediaSequence: 0, + }; + const media = { + mediaSequence: 1, attributes: { BANDWIDTH: 9 }, id: 'playlist-0-uri', uri: 'playlist-0-uri', - resolvedUri: urlTo('playlist-0-uri'), segments: [{ duration: 10, - uri: 'segment-0-uri', - resolvedUri: urlTo('segment-0-uri') + uri: 'segment-0-uri' + }, { + duration: 9, + uri: 'segment-1-uri' }] - }] - }; - const media = { - mediaSequence: 1, - attributes: { - BANDWIDTH: 8, - newField: 1 - }, - id: 'playlist-0-uri', - uri: 'playlist-0-uri', - segments: [{ - duration: 8, - uri: 'segment-0-uri' - }, { - duration: 10, - uri: 'segment-1-uri' - }] - }; - - master.playlists[media.id] = master.playlists[0]; - - assert.deepEqual( - updateMaster(master, media), - { + }; + + master.playlists[media.id] = master.playlists[0]; + + assert.deepEqual( + updateMaster(master, media), + { + mediaGroups: { + AUDIO: { + 'GROUP-ID': { + default: true, + uri: 'audio-uri' + } + } + }, + playlists: [{ + mediaSequence: 1, + attributes: { + BANDWIDTH: 9 + }, + id: 'playlist-0-uri', + uri: 'playlist-0-uri', + resolvedUri: urlTo('playlist-0-uri'), + segments: [{ + duration: 10, + uri: 'segment-0-uri', + resolvedUri: urlTo('segment-0-uri') + }, { + duration: 9, + uri: 'segment-1-uri', + resolvedUri: urlTo('segment-1-uri') + }] + }] + }, + 'adds new segment to master' + ); + }); + + QUnit.test('updateMaster changes old values', function(assert) { + const master = { mediaGroups: { AUDIO: { 'GROUP-ID': { @@ -469,903 +423,1460 @@ QUnit.test('updateMaster changes old values', function(assert) { } }, playlists: [{ - mediaSequence: 1, + mediaSequence: 0, attributes: { - BANDWIDTH: 8, - newField: 1 + BANDWIDTH: 9 }, id: 'playlist-0-uri', uri: 'playlist-0-uri', resolvedUri: urlTo('playlist-0-uri'), segments: [{ - duration: 8, + duration: 10, uri: 'segment-0-uri', resolvedUri: urlTo('segment-0-uri') - }, { - duration: 10, - uri: 'segment-1-uri', - resolvedUri: urlTo('segment-1-uri') }] }] - }, - 'changes old values' - ); -}); - -QUnit.test('updateMaster retains saved segment values', function(assert) { - const master = { - playlists: [{ - mediaSequence: 0, + }; + const media = { + mediaSequence: 1, + attributes: { + BANDWIDTH: 8, + newField: 1 + }, id: 'playlist-0-uri', uri: 'playlist-0-uri', - resolvedUri: urlTo('playlist-0-uri'), segments: [{ + duration: 8, + uri: 'segment-0-uri' + }, { duration: 10, - uri: 'segment-0-uri', - resolvedUri: urlTo('segment-0-uri'), - startTime: 0, - endTime: 10 + uri: 'segment-1-uri' }] - }] - }; - const media = { - mediaSequence: 0, - id: 'playlist-0-uri', - uri: 'playlist-0-uri', - segments: [{ - duration: 8, - uri: 'segment-0-uri' - }, { - duration: 10, - uri: 'segment-1-uri' - }] - }; - - master.playlists[media.id] = master.playlists[0]; - - assert.deepEqual( - updateMaster(master, media), - { + }; + + master.playlists[media.id] = master.playlists[0]; + + assert.deepEqual( + updateMaster(master, media), + { + mediaGroups: { + AUDIO: { + 'GROUP-ID': { + default: true, + uri: 'audio-uri' + } + } + }, + playlists: [{ + mediaSequence: 1, + attributes: { + BANDWIDTH: 8, + newField: 1 + }, + id: 'playlist-0-uri', + uri: 'playlist-0-uri', + resolvedUri: urlTo('playlist-0-uri'), + segments: [{ + duration: 8, + uri: 'segment-0-uri', + resolvedUri: urlTo('segment-0-uri') + }, { + duration: 10, + uri: 'segment-1-uri', + resolvedUri: urlTo('segment-1-uri') + }] + }] + }, + 'changes old values' + ); + }); + + QUnit.test('updateMaster retains saved segment values', function(assert) { + const master = { playlists: [{ mediaSequence: 0, id: 'playlist-0-uri', uri: 'playlist-0-uri', resolvedUri: urlTo('playlist-0-uri'), segments: [{ - duration: 8, + duration: 10, uri: 'segment-0-uri', resolvedUri: urlTo('segment-0-uri'), startTime: 0, endTime: 10 - }, { - duration: 10, - uri: 'segment-1-uri', - resolvedUri: urlTo('segment-1-uri') }] }] - }, - 'retains saved segment values' - ); -}); - -QUnit.test('updateMaster resolves key and map URIs', function(assert) { - const master = { - playlists: [{ + }; + const media = { mediaSequence: 0, - attributes: { - BANDWIDTH: 9 - }, id: 'playlist-0-uri', uri: 'playlist-0-uri', - resolvedUri: urlTo('playlist-0-uri'), segments: [{ - duration: 10, - uri: 'segment-0-uri', - resolvedUri: urlTo('segment-0-uri') + duration: 8, + uri: 'segment-0-uri' }, { duration: 10, - uri: 'segment-1-uri', - resolvedUri: urlTo('segment-1-uri') + uri: 'segment-1-uri' }] - }] - }; - const media = { - mediaSequence: 3, - attributes: { - BANDWIDTH: 9 - }, - id: 'playlist-0-uri', - uri: 'playlist-0-uri', - segments: [{ - duration: 9, - uri: 'segment-2-uri', - key: { - uri: 'key-2-uri' - }, - map: { - uri: 'map-2-uri' - } - }, { - duration: 11, - uri: 'segment-3-uri', - key: { - uri: 'key-3-uri' - }, - map: { - uri: 'map-3-uri' - } - }] - }; + }; + + master.playlists[media.id] = master.playlists[0]; - master.playlists[media.id] = master.playlists[0]; + assert.deepEqual( + updateMaster(master, media), + { + playlists: [{ + mediaSequence: 0, + id: 'playlist-0-uri', + uri: 'playlist-0-uri', + resolvedUri: urlTo('playlist-0-uri'), + segments: [{ + duration: 8, + uri: 'segment-0-uri', + resolvedUri: urlTo('segment-0-uri'), + startTime: 0, + endTime: 10 + }, { + duration: 10, + uri: 'segment-1-uri', + resolvedUri: urlTo('segment-1-uri') + }] + }] + }, + 'retains saved segment values' + ); + }); - assert.deepEqual( - updateMaster(master, media), - { + QUnit.test('updateMaster resolves key and map URIs', function(assert) { + const master = { playlists: [{ - mediaSequence: 3, + mediaSequence: 0, attributes: { BANDWIDTH: 9 }, - uri: 'playlist-0-uri', id: 'playlist-0-uri', + uri: 'playlist-0-uri', resolvedUri: urlTo('playlist-0-uri'), segments: [{ - duration: 9, - uri: 'segment-2-uri', - resolvedUri: urlTo('segment-2-uri'), - key: { - uri: 'key-2-uri', - resolvedUri: urlTo('key-2-uri') - }, - map: { - uri: 'map-2-uri', - resolvedUri: urlTo('map-2-uri') - } + duration: 10, + uri: 'segment-0-uri', + resolvedUri: urlTo('segment-0-uri') }, { - duration: 11, - uri: 'segment-3-uri', - resolvedUri: urlTo('segment-3-uri'), - key: { - uri: 'key-3-uri', - resolvedUri: urlTo('key-3-uri') - }, - map: { - uri: 'map-3-uri', - resolvedUri: urlTo('map-3-uri') - } + duration: 10, + uri: 'segment-1-uri', + resolvedUri: urlTo('segment-1-uri') }] }] - }, - 'resolves key and map URIs' - ); -}); + }; + const media = { + mediaSequence: 3, + attributes: { + BANDWIDTH: 9 + }, + id: 'playlist-0-uri', + uri: 'playlist-0-uri', + segments: [{ + duration: 9, + uri: 'segment-2-uri', + key: { + uri: 'key-2-uri' + }, + map: { + uri: 'map-2-uri' + } + }, { + duration: 11, + uri: 'segment-3-uri', + key: { + uri: 'key-3-uri' + }, + map: { + uri: 'map-3-uri' + } + }] + }; -QUnit.test('uses last segment duration for refresh delay', function(assert) { - const media = { targetDuration: 7, segments: [] }; + master.playlists[media.id] = master.playlists[0]; - assert.equal( - refreshDelay(media, true), 3500, - 'used half targetDuration when no segments' - ); + assert.deepEqual( + updateMaster(master, media), + { + playlists: [{ + mediaSequence: 3, + attributes: { + BANDWIDTH: 9 + }, + uri: 'playlist-0-uri', + id: 'playlist-0-uri', + resolvedUri: urlTo('playlist-0-uri'), + segments: [{ + duration: 9, + uri: 'segment-2-uri', + resolvedUri: urlTo('segment-2-uri'), + key: { + uri: 'key-2-uri', + resolvedUri: urlTo('key-2-uri') + }, + map: { + uri: 'map-2-uri', + resolvedUri: urlTo('map-2-uri') + } + }, { + duration: 11, + uri: 'segment-3-uri', + resolvedUri: urlTo('segment-3-uri'), + key: { + uri: 'key-3-uri', + resolvedUri: urlTo('key-3-uri') + }, + map: { + uri: 'map-3-uri', + resolvedUri: urlTo('map-3-uri') + } + }] + }] + }, + 'resolves key and map URIs' + ); + }); - media.segments = [ { duration: 6}, { duration: 4 }, { } ]; - assert.equal( - refreshDelay(media, true), 3500, - 'used half targetDuration when last segment duration cannot be determined' - ); + QUnit.test('uses last segment duration for refresh delay', function(assert) { + const media = { targetDuration: 7, segments: [] }; - media.segments = [ { duration: 6}, { duration: 4}, { duration: 5 } ]; - assert.equal(refreshDelay(media, true), 5000, 'used last segment duration for delay'); + assert.equal( + refreshDelay(media, true), 3500, + 'used half targetDuration when no segments' + ); - assert.equal( - refreshDelay(media, false), 3500, - 'used half targetDuration when update is false' - ); -}); + media.segments = [ { duration: 6}, { duration: 4 }, { } ]; + assert.equal( + refreshDelay(media, true), 3500, + 'used half targetDuration when last segment duration cannot be determined' + ); -QUnit.test('throws if the playlist src is empty or undefined', function(assert) { - assert.throws( - () => new PlaylistLoader(), - /A non-empty playlist URL or object is required/, - 'requires an argument' - ); - assert.throws( - () => new PlaylistLoader(''), - /A non-empty playlist URL or object is required/, - 'does not accept the empty string' - ); -}); + media.segments = [ { duration: 6}, { duration: 4}, { duration: 5 } ]; + assert.equal(refreshDelay(media, true), 5000, 'used last segment duration for delay'); -QUnit.test('starts without any metadata', function(assert) { - const loader = new PlaylistLoader('master.m3u8', this.fakeVhs); + assert.equal( + refreshDelay(media, false), 3500, + 'used half targetDuration when update is false' + ); + }); - loader.load(); + QUnit.test('throws if the playlist src is empty or undefined', function(assert) { + assert.throws( + () => new PlaylistLoader(), + /A non-empty playlist URL or object is required/, + 'requires an argument' + ); + assert.throws( + () => new PlaylistLoader(''), + /A non-empty playlist URL or object is required/, + 'does not accept the empty string' + ); + }); - assert.strictEqual(loader.state, 'HAVE_NOTHING', 'no metadata has loaded yet'); -}); + QUnit.test('starts without any metadata', function(assert) { + const loader = new PlaylistLoader('master.m3u8', this.fakeVhs); -QUnit.test('requests the initial playlist immediately', function(assert) { - const loader = new PlaylistLoader('master.m3u8', this.fakeVhs); + loader.load(); - loader.load(); + assert.strictEqual(loader.state, 'HAVE_NOTHING', 'no metadata has loaded yet'); + }); - assert.strictEqual(this.requests.length, 1, 'made a request'); - assert.strictEqual( - this.requests[0].url, - 'master.m3u8', - 'requested the initial playlist' - ); -}); + QUnit.test('requests the initial playlist immediately', function(assert) { + const loader = new PlaylistLoader('master.m3u8', this.fakeVhs); -QUnit.test('moves to HAVE_MASTER after loading a master playlist', function(assert) { - const loader = new PlaylistLoader('master.m3u8', this.fakeVhs); - let state; + loader.load(); - loader.load(); + assert.strictEqual(this.requests.length, 1, 'made a request'); + assert.strictEqual( + this.requests[0].url, + 'master.m3u8', + 'requested the initial playlist' + ); + }); - loader.on('loadedplaylist', function() { - state = loader.state; + QUnit.test('moves to HAVE_MASTER after loading a master playlist', function(assert) { + const loader = new PlaylistLoader('master.m3u8', this.fakeVhs); + let state; + + loader.load(); + + loader.on('loadedplaylist', function() { + state = loader.state; + }); + this.requests.pop().respond( + 200, null, + '#EXTM3U\n' + + '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + + 'media.m3u8\n' + ); + assert.ok(loader.master, 'the master playlist is available'); + assert.strictEqual(state, 'HAVE_MASTER', 'the state at loadedplaylist correct'); }); - this.requests.pop().respond( - 200, null, - '#EXTM3U\n' + - '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + - 'media.m3u8\n' - ); - assert.ok(loader.master, 'the master playlist is available'); - assert.strictEqual(state, 'HAVE_MASTER', 'the state at loadedplaylist correct'); -}); -QUnit.test('logs warning for master playlist with invalid STREAM-INF', function(assert) { - const loader = new PlaylistLoader('master.m3u8', this.fakeVhs); + QUnit.test('logs warning for master playlist with invalid STREAM-INF', function(assert) { + const loader = new PlaylistLoader('master.m3u8', this.fakeVhs); - loader.load(); + loader.load(); - this.requests.pop().respond( - 200, null, - '#EXTM3U\n' + - '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + - 'video1/media.m3u8\n' + - '#EXT-X-STREAM-INF:\n' + - 'video2/media.m3u8\n' - ); + this.requests.pop().respond( + 200, null, + '#EXTM3U\n' + + '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + + 'video1/media.m3u8\n' + + '#EXT-X-STREAM-INF:\n' + + 'video2/media.m3u8\n' + ); + + assert.ok(loader.master, 'infers a master playlist'); + assert.equal( + loader.master.playlists[1].uri, 'video2/media.m3u8', + 'parsed invalid stream' + ); + assert.ok(loader.master.playlists[1].attributes, 'attached attributes property'); + assert.equal(this.env.log.warn.calls, 1, 'logged a warning'); + assert.equal( + this.env.log.warn.args[0], + 'Invalid playlist STREAM-INF detected. Missing BANDWIDTH attribute.', + 'logged a warning' + ); + }); + + QUnit.test('executes custom parsers and mappers', function(assert) { + const customTagParsers = [{ + expression: /#PARSER/, + customType: 'test', + segment: true + }]; + const customTagMappers = [{ + expression: /#MAPPER/, + map(line) { + const regex = /#MAPPER:(\d+)/g; + const match = regex.exec(line); + const ISOdate = new Date(Number(match[1])).toISOString(); + + return `#EXT-X-PROGRAM-DATE-TIME:${ISOdate}`; + } + }]; + + this.fakeVhs.options_ = { customTagParsers, customTagMappers }; + + const loader = new PlaylistLoader('master.m3u8', this.fakeVhs); + + loader.load(); + this.requests.pop().respond( + 200, null, + '#EXTM3U\n' + + '#PARSER:parsed\n' + + '#MAPPER:1511816599485\n' + + '#EXTINF:10,\n' + + '0.ts\n' + + '#EXT-X-ENDLIST\n' + ); + + const segment = loader.master.playlists[0].segments[0]; - assert.ok(loader.master, 'infers a master playlist'); - assert.equal( - loader.master.playlists[1].uri, 'video2/media.m3u8', - 'parsed invalid stream' + assert.strictEqual(segment.custom.test, '#PARSER:parsed', 'parsed custom tag'); + assert.ok(segment.dateTimeObject, 'converted and parsed custom time'); + + delete this.fakeVhs.options_; + }); + + QUnit.test( + 'adds properties to playlists array when given a master playlist object', + function(assert) { + const masterPlaylist = JSON.parse(JSON.stringify(parseManifest({ + manifestString: manifests.master + }))); + const firstPlaylistId = createPlaylistID(0, masterPlaylist.playlists[0].uri); + + assert.notOk( + firstPlaylistId in masterPlaylist.playlists, + 'parsed manifest playlists array does not contain playlist ID property' + ); + + const loader = new PlaylistLoader(masterPlaylist, this.fakeVhs); + + loader.load(); + // even for vhs-json manifest objects, load is an async operation + this.clock.tick(1); + + assert.ok( + firstPlaylistId in masterPlaylist.playlists, + 'parsed manifest playlists array contains playlist ID property' + ); + } ); - assert.ok(loader.master.playlists[1].attributes, 'attached attributes property'); - assert.equal(this.env.log.warn.calls, 1, 'logged a warning'); - assert.equal( - this.env.log.warn.args[0], - 'Invalid playlist STREAM-INF detected. Missing BANDWIDTH attribute.', - 'logged a warning' + + QUnit.test( + 'jumps to HAVE_METADATA when initialized with a media playlist', + function(assert) { + let loadedmetadatas = 0; + const loader = new PlaylistLoader('media.m3u8', this.fakeVhs); + + loader.load(); + + loader.on('loadedmetadata', function() { + loadedmetadatas++; + }); + this.requests.pop().respond( + 200, null, + '#EXTM3U\n' + + '#EXTINF:10,\n' + + '0.ts\n' + + '#EXT-X-ENDLIST\n' + ); + assert.ok(loader.master, 'infers a master playlist'); + assert.ok(loader.media(), 'sets the media playlist'); + assert.ok(loader.media().uri, 'sets the media playlist URI'); + assert.ok(loader.media().attributes, 'sets the media playlist attributes'); + assert.strictEqual(loader.state, 'HAVE_METADATA', 'the state is correct'); + assert.strictEqual(this.requests.length, 0, 'no more requests are made'); + assert.strictEqual(loadedmetadatas, 1, 'fired one loadedmetadata'); + } ); -}); -QUnit.test('executes custom parsers and mappers', function(assert) { - const customTagParsers = [{ - expression: /#PARSER/, - customType: 'test', - segment: true - }]; - const customTagMappers = [{ - expression: /#MAPPER/, - map(line) { - const regex = /#MAPPER:(\d+)/g; - const match = regex.exec(line); - const ISOdate = new Date(Number(match[1])).toISOString(); - - return `#EXT-X-PROGRAM-DATE-TIME:${ISOdate}`; + QUnit.test( + 'moves to HAVE_METADATA without a request when initialized with a media playlist' + + ' object', + function(assert) { + const mediaPlaylist = parseManifest({ manifestString: manifests.media }); + + const loader = new PlaylistLoader(mediaPlaylist, this.fakeVhs); + let loadedmetadataEvents = 0; + + loader.on('loadedmetadata', () => loadedmetadataEvents++); + loader.load(); + + assert.equal(this.requests.length, 0, 'no requests'); + assert.equal(loadedmetadataEvents, 0, 'no loadedmetadata events'); + assert.equal(loader.state, 'HAVE_NOTHING', 'state is HAVE_NOTHING'); + + // preparing of manifest by playlist loader is still asynchronous for source objects + this.clock.tick(1); + + assert.equal(this.requests.length, 0, 'no requests'); + assert.equal(loadedmetadataEvents, 1, 'one loadedmetadata event'); + assert.ok(loader.master, 'inferred a master playlist'); + assert.deepEqual(mediaPlaylist, loader.media(), 'set the media playlist'); + assert.equal(loader.state, 'HAVE_METADATA', 'state is HAVE_METADATA'); } - }]; + ); + + QUnit.test( + 'stays at HAVE_MASTER and makes a request when initialized with a master playlist ' + + 'without resolved media playlists', + function(assert) { + const masterPlaylist = parseManifest({ manifestString: manifests.master }); - this.fakeVhs.options_ = { customTagParsers, customTagMappers }; + const loader = new PlaylistLoader(masterPlaylist, this.fakeVhs); + let loadedmetadataEvents = 0; - const loader = new PlaylistLoader('master.m3u8', this.fakeVhs); + loader.on('loadedmetadata', () => loadedmetadataEvents++); + loader.load(); - loader.load(); - this.requests.pop().respond( - 200, null, - '#EXTM3U\n' + - '#PARSER:parsed\n' + - '#MAPPER:1511816599485\n' + - '#EXTINF:10,\n' + - '0.ts\n' + - '#EXT-X-ENDLIST\n' + assert.equal(this.requests.length, 0, 'no requests'); + assert.equal(loadedmetadataEvents, 0, 'no loadedmetadata events'); + assert.equal(loader.state, 'HAVE_NOTHING', 'state is HAVE_NOTHING'); + + // preparing of manifest by playlist loader is still asynchronous for source objects + this.clock.tick(1); + + assert.equal(this.requests.length, 1, 'one request'); + assert.equal(loadedmetadataEvents, 0, 'no loadedmetadata event'); + assert.deepEqual(loader.master, masterPlaylist, 'set the master playlist'); + assert.equal(loader.state, 'SWITCHING_MEDIA', 'state is SWITCHING_MEDIA'); + } ); - const segment = loader.master.playlists[0].segments[0]; + QUnit.test( + 'moves to HAVE_METADATA without a request when initialized with a master playlist ' + + 'object with resolved media playlists', + function(assert) { + const masterPlaylist = parseManifest({ manifestString: manifests.master }); + const mediaPlaylist = parseManifest({ manifestString: manifests.media }); + + // since the playlist is getting overwritten in the master (to fake a resolved media + // playlist), attributes should be copied over to prevent warnings or errors due to + // a missing BANDWIDTH attribute + mediaPlaylist.attributes = masterPlaylist.playlists[0].attributes; + + // If no playlist is selected after the first loadedplaylist event, then playlist loader + // defaults to the first playlist. Here it's already resolved, so loadedmetadata should + // fire immediately. + masterPlaylist.playlists[0] = mediaPlaylist; + + const loader = new PlaylistLoader(masterPlaylist, this.fakeVhs); + let loadedmetadataEvents = 0; + + loader.on('loadedmetadata', () => loadedmetadataEvents++); + loader.load(); + + assert.equal(this.requests.length, 0, 'no requests'); + assert.equal(loadedmetadataEvents, 0, 'no loadedmetadata events'); + assert.equal(loader.state, 'HAVE_NOTHING', 'state is HAVE_NOTHING'); + + // preparing of manifest by playlist loader is still asynchronous for source objects + this.clock.tick(1); + + assert.equal(this.requests.length, 0, 'no requests'); + assert.equal(loadedmetadataEvents, 1, 'one loadedmetadata event'); + assert.deepEqual(loader.master, masterPlaylist, 'set the master playlist'); + assert.deepEqual(mediaPlaylist, loader.media(), 'set the media playlist'); + assert.equal(loader.state, 'HAVE_METADATA', 'state is HAVE_METADATA'); + } + ); - assert.strictEqual(segment.custom.test, '#PARSER:parsed', 'parsed custom tag'); - assert.ok(segment.dateTimeObject, 'converted and parsed custom time'); + QUnit.test('resolves relative media playlist URIs', function(assert) { + const loader = new PlaylistLoader('master.m3u8', this.fakeVhs); - delete this.fakeVhs.options_; -}); + loader.load(); -QUnit.test( - 'adds properties to playlists array when given a master playlist object', - function(assert) { - const masterPlaylist = JSON.parse(JSON.stringify(parseManifest({ - manifestString: manifests.master - }))); - const firstPlaylistId = createPlaylistID(0, masterPlaylist.playlists[0].uri); + this.requests.shift().respond( + 200, null, + '#EXTM3U\n' + + '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + + 'video/media.m3u8\n' + ); + assert.equal( + loader.master.playlists[0].resolvedUri, urlTo('video/media.m3u8'), + 'resolved media URI' + ); + }); - assert.notOk( - firstPlaylistId in masterPlaylist.playlists, - 'parsed manifest playlists array does not contain playlist ID property' + QUnit.test('resolves media initialization segment URIs', function(assert) { + const loader = new PlaylistLoader('video/fmp4.m3u8', this.fakeVhs); + + loader.load(); + this.requests.shift().respond( + 200, null, + '#EXTM3U\n' + + '#EXT-X-MAP:URI="main.mp4",BYTERANGE="720@0"\n' + + '#EXTINF:10,\n' + + '0.ts\n' + + '#EXT-X-ENDLIST\n' + ); + + assert.equal( + loader.media().segments[0].map.resolvedUri, urlTo('video/main.mp4'), + 'resolved init segment URI' ); + }); - const loader = new PlaylistLoader(masterPlaylist, this.fakeVhs); + QUnit.test('recognizes redirect, when media requested', function(assert) { + const loader = new PlaylistLoader('manifest/media.m3u8', this.fakeVhs, { + handleManifestRedirects: true + }); loader.load(); - // even for vhs-json manifest objects, load is an async operation - this.clock.tick(1); - assert.ok( - firstPlaylistId in masterPlaylist.playlists, - 'parsed manifest playlists array contains playlist ID property' + this.requests.shift().respond( + 200, null, + '#EXTM3U\n' + + '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + + '/media.m3u8\n' + ); + assert.equal( + loader.master.playlists[0].resolvedUri, + window.location.protocol + '//' + + window.location.host + '/media.m3u8', + 'resolved media URI' ); - } -); -QUnit.test( - 'jumps to HAVE_METADATA when initialized with a media playlist', - function(assert) { - let loadedmetadatas = 0; - const loader = new PlaylistLoader('media.m3u8', this.fakeVhs); + const mediaRequest = this.requests.shift(); + + mediaRequest.responseURL = window.location.protocol + '//' + + 'foo-bar.com/media.m3u8'; + mediaRequest.respond( + 200, null, + '#EXTM3U\n' + + '#EXTINF:10,\n' + + '/00001.ts\n' + + '#EXT-X-ENDLIST\n' + ); + assert.equal( + loader.media().segments[0].resolvedUri, + window.location.protocol + '//' + + 'foo-bar.com/00001.ts', + 'resolved segment URI' + ); + }); + + QUnit.test('recognizes absolute URIs and requests them unmodified', function(assert) { + const loader = new PlaylistLoader('manifest/media.m3u8', this.fakeVhs); loader.load(); - loader.on('loadedmetadata', function() { - loadedmetadatas++; - }); - this.requests.pop().respond( + this.requests.shift().respond( 200, null, '#EXTM3U\n' + - '#EXTINF:10,\n' + - '0.ts\n' + - '#EXT-X-ENDLIST\n' + '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + + 'http://example.com/video/media.m3u8\n' + ); + assert.equal( + loader.master.playlists[0].resolvedUri, + 'http://example.com/video/media.m3u8', 'resolved media URI' ); - assert.ok(loader.master, 'infers a master playlist'); - assert.ok(loader.media(), 'sets the media playlist'); - assert.ok(loader.media().uri, 'sets the media playlist URI'); - assert.ok(loader.media().attributes, 'sets the media playlist attributes'); - assert.strictEqual(loader.state, 'HAVE_METADATA', 'the state is correct'); - assert.strictEqual(this.requests.length, 0, 'no more requests are made'); - assert.strictEqual(loadedmetadatas, 1, 'fired one loadedmetadata'); - } -); -QUnit.test( - 'moves to HAVE_METADATA without a request when initialized with a media playlist' + - ' object', - function(assert) { - const mediaPlaylist = parseManifest({ manifestString: manifests.media }); + this.requests.shift().respond( + 200, null, + '#EXTM3U\n' + + '#EXTINF:10,\n' + + 'http://example.com/00001.ts\n' + + '#EXT-X-ENDLIST\n' + ); + assert.equal( + loader.media().segments[0].resolvedUri, + 'http://example.com/00001.ts', 'resolved segment URI' + ); + }); - const loader = new PlaylistLoader(mediaPlaylist, this.fakeVhs); - let loadedmetadataEvents = 0; + QUnit.test('recognizes domain-relative URLs', function(assert) { + const loader = new PlaylistLoader('manifest/media.m3u8', this.fakeVhs); - loader.on('loadedmetadata', () => loadedmetadataEvents++); loader.load(); - assert.equal(this.requests.length, 0, 'no requests'); - assert.equal(loadedmetadataEvents, 0, 'no loadedmetadata events'); - assert.equal(loader.state, 'HAVE_NOTHING', 'state is HAVE_NOTHING'); + this.requests.shift().respond( + 200, null, + '#EXTM3U\n' + + '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + + '/media.m3u8\n' + ); + assert.equal( + loader.master.playlists[0].resolvedUri, + window.location.protocol + '//' + + window.location.host + '/media.m3u8', + 'resolved media URI' + ); - // preparing of manifest by playlist loader is still asynchronous for source objects - this.clock.tick(1); + this.requests.shift().respond( + 200, null, + '#EXTM3U\n' + + '#EXTINF:10,\n' + + '/00001.ts\n' + + '#EXT-X-ENDLIST\n' + ); + assert.equal( + loader.media().segments[0].resolvedUri, + window.location.protocol + '//' + + window.location.host + '/00001.ts', + 'resolved segment URI' + ); + }); - assert.equal(this.requests.length, 0, 'no requests'); - assert.equal(loadedmetadataEvents, 1, 'one loadedmetadata event'); - assert.ok(loader.master, 'inferred a master playlist'); - assert.deepEqual(mediaPlaylist, loader.media(), 'set the media playlist'); - assert.equal(loader.state, 'HAVE_METADATA', 'state is HAVE_METADATA'); - } -); + QUnit.test('recognizes key URLs relative to master and playlist', function(assert) { + const loader = new PlaylistLoader('/video/media-encrypted.m3u8', this.fakeVhs); -QUnit.test( - 'stays at HAVE_MASTER and makes a request when initialized with a master playlist ' + - 'without resolved media playlists', - function(assert) { - const masterPlaylist = parseManifest({ manifestString: manifests.master }); + loader.load(); + + this.requests.shift().respond( + 200, null, + '#EXTM3U\n' + + '#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=17\n' + + 'playlist/playlist.m3u8\n' + + '#EXT-X-ENDLIST\n' + ); + assert.equal( + loader.master.playlists[0].resolvedUri, + window.location.protocol + '//' + + window.location.host + '/video/playlist/playlist.m3u8', + 'resolved media URI' + ); + + this.requests.shift().respond( + 200, null, + '#EXTM3U\n' + + '#EXT-X-TARGETDURATION:15\n' + + '#EXT-X-KEY:METHOD=AES-128,URI="keys/key.php"\n' + + '#EXTINF:2.833,\n' + + 'http://example.com/000001.ts\n' + + '#EXT-X-ENDLIST\n' + ); + assert.equal( + loader.media().segments[0].key.resolvedUri, + window.location.protocol + '//' + + window.location.host + '/video/playlist/keys/key.php', + 'resolved multiple relative paths for key URI' + ); + }); - const loader = new PlaylistLoader(masterPlaylist, this.fakeVhs); - let loadedmetadataEvents = 0; + QUnit.test('trigger an error event when a media playlist 404s', function(assert) { + let count = 0; + const loader = new PlaylistLoader('manifest/master.m3u8', this.fakeVhs); - loader.on('loadedmetadata', () => loadedmetadataEvents++); loader.load(); - assert.equal(this.requests.length, 0, 'no requests'); - assert.equal(loadedmetadataEvents, 0, 'no loadedmetadata events'); - assert.equal(loader.state, 'HAVE_NOTHING', 'state is HAVE_NOTHING'); + loader.on('error', function() { + count += 1; + }); + + // master + this.requests.shift().respond( + 200, null, + '#EXTM3U\n' + + '#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=17\n' + + 'playlist/playlist.m3u8\n' + + '#EXT-X-STREAM-INF:PROGRAM-ID=2,BANDWIDTH=170\n' + + 'playlist/playlist2.m3u8\n' + + '#EXT-X-ENDLIST\n' + ); + assert.equal( + count, 0, + 'error not triggered before requesting playlist' + ); - // preparing of manifest by playlist loader is still asynchronous for source objects - this.clock.tick(1); + // playlist + this.requests.shift().respond(404); - assert.equal(this.requests.length, 1, 'one request'); - assert.equal(loadedmetadataEvents, 0, 'no loadedmetadata event'); - assert.deepEqual(loader.master, masterPlaylist, 'set the master playlist'); - assert.equal(loader.state, 'SWITCHING_MEDIA', 'state is SWITCHING_MEDIA'); - } -); + assert.equal( + count, 1, + 'error triggered after playlist 404' + ); + }); -QUnit.test( - 'moves to HAVE_METADATA without a request when initialized with a master playlist ' + - 'object with resolved media playlists', - function(assert) { - const masterPlaylist = parseManifest({ manifestString: manifests.master }); - const mediaPlaylist = parseManifest({ manifestString: manifests.media }); + QUnit.test('recognizes absolute key URLs', function(assert) { + const loader = new PlaylistLoader('/video/media-encrypted.m3u8', this.fakeVhs); - // since the playlist is getting overwritten in the master (to fake a resolved media - // playlist), attributes should be copied over to prevent warnings or errors due to - // a missing BANDWIDTH attribute - mediaPlaylist.attributes = masterPlaylist.playlists[0].attributes; + loader.load(); - // If no playlist is selected after the first loadedplaylist event, then playlist loader - // defaults to the first playlist. Here it's already resolved, so loadedmetadata should - // fire immediately. - masterPlaylist.playlists[0] = mediaPlaylist; + this.requests.shift().respond( + 200, null, + '#EXTM3U\n' + + '#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=17\n' + + 'playlist/playlist.m3u8\n' + + '#EXT-X-ENDLIST\n' + ); + assert.equal( + loader.master.playlists[0].resolvedUri, + window.location.protocol + '//' + + window.location.host + '/video/playlist/playlist.m3u8', + 'resolved media URI' + ); - const loader = new PlaylistLoader(masterPlaylist, this.fakeVhs); - let loadedmetadataEvents = 0; + this.requests.shift().respond( + 200, + null, + '#EXTM3U\n' + + '#EXT-X-TARGETDURATION:15\n' + + '#EXT-X-KEY:METHOD=AES-128,URI="http://example.com/keys/key.php"\n' + + '#EXTINF:2.833,\n' + + 'http://example.com/000001.ts\n' + + '#EXT-X-ENDLIST\n' + ); + assert.equal( + loader.media().segments[0].key.resolvedUri, + 'http://example.com/keys/key.php', 'resolved absolute path for key URI' + ); + }); + + QUnit.test( + 'jumps to HAVE_METADATA when initialized with a live media playlist', + function(assert) { + const loader = new PlaylistLoader('media.m3u8', this.fakeVhs); + + loader.load(); + + this.requests.pop().respond( + 200, null, + '#EXTM3U\n' + + '#EXTINF:10,\n' + + '0.ts\n' + ); + assert.ok(loader.master, 'infers a master playlist'); + assert.ok(loader.media(), 'sets the media playlist'); + assert.ok(loader.media().attributes, 'sets the media playlist attributes'); + assert.strictEqual(loader.state, 'HAVE_METADATA', 'the state is correct'); + } + ); + + QUnit.test('moves to HAVE_METADATA after loading a media playlist', function(assert) { + let loadedPlaylist = 0; + let loadedMetadata = 0; + const loader = new PlaylistLoader('master.m3u8', this.fakeVhs); - loader.on('loadedmetadata', () => loadedmetadataEvents++); loader.load(); - assert.equal(this.requests.length, 0, 'no requests'); - assert.equal(loadedmetadataEvents, 0, 'no loadedmetadata events'); - assert.equal(loader.state, 'HAVE_NOTHING', 'state is HAVE_NOTHING'); + loader.on('loadedplaylist', function() { + loadedPlaylist++; + }); + loader.on('loadedmetadata', function() { + loadedMetadata++; + }); + this.requests.pop().respond( + 200, null, + '#EXTM3U\n' + + '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + + 'media.m3u8\n' + + '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + + 'alt.m3u8\n' + ); + assert.strictEqual(loadedPlaylist, 1, 'fired loadedplaylist once'); + assert.strictEqual(loadedMetadata, 0, 'did not fire loadedmetadata'); + assert.strictEqual(this.requests.length, 1, 'requests the media playlist'); + assert.strictEqual(this.requests[0].method, 'GET', 'GETs the media playlist'); + assert.strictEqual( + this.requests[0].url, + urlTo('media.m3u8'), + 'requests the first playlist' + ); - // preparing of manifest by playlist loader is still asynchronous for source objects - this.clock.tick(1); + this.requests.pop().respond( + 200, null, + '#EXTM3U\n' + + '#EXTINF:10,\n' + + '0.ts\n' + ); + assert.ok(loader.master, 'sets the master playlist'); + assert.ok(loader.media(), 'sets the media playlist'); + assert.strictEqual(loadedPlaylist, 2, 'fired loadedplaylist twice'); + assert.strictEqual(loadedMetadata, 1, 'fired loadedmetadata once'); + assert.strictEqual(loader.state, 'HAVE_METADATA', 'the state is correct'); + }); - assert.equal(this.requests.length, 0, 'no requests'); - assert.equal(loadedmetadataEvents, 1, 'one loadedmetadata event'); - assert.deepEqual(loader.master, masterPlaylist, 'set the master playlist'); - assert.deepEqual(mediaPlaylist, loader.media(), 'set the media playlist'); - assert.equal(loader.state, 'HAVE_METADATA', 'state is HAVE_METADATA'); - } -); + QUnit.test('defaults missing media groups for a media playlist', function(assert) { + const loader = new PlaylistLoader('master.m3u8', this.fakeVhs); -QUnit.test('resolves relative media playlist URIs', function(assert) { - const loader = new PlaylistLoader('master.m3u8', this.fakeVhs); + loader.load(); + this.requests.pop().respond( + 200, null, + '#EXTM3U\n' + + '#EXTINF:10,\n' + + '0.ts\n' + ); - loader.load(); + assert.ok(loader.master.mediaGroups.AUDIO, 'defaulted audio'); + assert.ok(loader.master.mediaGroups.VIDEO, 'defaulted video'); + assert.ok(loader.master.mediaGroups['CLOSED-CAPTIONS'], 'defaulted closed captions'); + assert.ok(loader.master.mediaGroups.SUBTITLES, 'defaulted subtitles'); + }); - this.requests.shift().respond( - 200, null, - '#EXTM3U\n' + - '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + - 'video/media.m3u8\n' - ); - assert.equal( - loader.master.playlists[0].resolvedUri, urlTo('video/media.m3u8'), - 'resolved media URI' + QUnit.test( + 'moves to HAVE_CURRENT_METADATA when refreshing the playlist', + function(assert) { + const loader = new PlaylistLoader('live.m3u8', this.fakeVhs); + + loader.load(); + + this.requests.pop().respond( + 200, null, + '#EXTM3U\n' + + '#EXTINF:10,\n' + + '0.ts\n' + ); + // 10s, one target duration + this.clock.tick(10 * 1000); + assert.strictEqual(loader.state, 'HAVE_CURRENT_METADATA', 'the state is correct'); + assert.strictEqual(this.requests.length, 1, 'requested playlist'); + assert.strictEqual( + this.requests[0].url, + urlTo('live.m3u8'), + 'refreshes the media playlist' + ); + } ); -}); -QUnit.test('resolves media initialization segment URIs', function(assert) { - const loader = new PlaylistLoader('video/fmp4.m3u8', this.fakeVhs); - - loader.load(); - this.requests.shift().respond( - 200, null, - '#EXTM3U\n' + - '#EXT-X-MAP:URI="main.mp4",BYTERANGE="720@0"\n' + - '#EXTINF:10,\n' + - '0.ts\n' + - '#EXT-X-ENDLIST\n' - ); + QUnit.test('returns to HAVE_METADATA after refreshing the playlist', function(assert) { + const loader = new PlaylistLoader('live.m3u8', this.fakeVhs); - assert.equal( - loader.media().segments[0].map.resolvedUri, urlTo('video/main.mp4'), - 'resolved init segment URI' - ); -}); + loader.load(); -QUnit.test('recognizes redirect, when media requested', function(assert) { - const loader = new PlaylistLoader('manifest/media.m3u8', this.fakeVhs, { - handleManifestRedirects: true + this.requests.pop().respond( + 200, null, + '#EXTM3U\n' + + '#EXTINF:10,\n' + + '0.ts\n' + ); + // 10s, one target duration + this.clock.tick(10 * 1000); + this.requests.pop().respond( + 200, null, + '#EXTM3U\n' + + '#EXTINF:10,\n' + + '1.ts\n' + ); + assert.strictEqual(loader.state, 'HAVE_METADATA', 'the state is correct'); }); - loader.load(); + QUnit.test('refreshes the playlist after last segment duration', function(assert) { + const loader = new PlaylistLoader('live.m3u8', this.fakeVhs); + let refreshes = 0; - this.requests.shift().respond( - 200, null, - '#EXTM3U\n' + - '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + - '/media.m3u8\n' - ); - assert.equal( - loader.master.playlists[0].resolvedUri, - window.location.protocol + '//' + - window.location.host + '/media.m3u8', - 'resolved media URI' - ); + loader.on('mediaupdatetimeout', () => refreshes++); - const mediaRequest = this.requests.shift(); + loader.load(); - mediaRequest.responseURL = window.location.protocol + '//' + - 'foo-bar.com/media.m3u8'; - mediaRequest.respond( - 200, null, - '#EXTM3U\n' + - '#EXTINF:10,\n' + - '/00001.ts\n' + - '#EXT-X-ENDLIST\n' - ); - assert.equal( - loader.media().segments[0].resolvedUri, - window.location.protocol + '//' + - 'foo-bar.com/00001.ts', - 'resolved segment URI' - ); -}); + this.requests.pop().respond( + 200, null, + '#EXTM3U\n' + + '#EXT-X-TARGETDURATION:10\n' + + '#EXTINF:10,\n' + + '0.ts\n' + + '#EXTINF:4\n' + + '1.ts\n' + ); + // 4s, last segment duration + this.clock.tick(4 * 1000); -QUnit.test('recognizes absolute URIs and requests them unmodified', function(assert) { - const loader = new PlaylistLoader('manifest/media.m3u8', this.fakeVhs); + assert.equal(refreshes, 1, 'refreshed playlist after last segment duration'); + }); - loader.load(); + QUnit.test('emits an error when an initial playlist request fails', function(assert) { + const errors = []; + const loader = new PlaylistLoader('master.m3u8', this.fakeVhs); - this.requests.shift().respond( - 200, null, - '#EXTM3U\n' + - '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + - 'http://example.com/video/media.m3u8\n' - ); - assert.equal( - loader.master.playlists[0].resolvedUri, - 'http://example.com/video/media.m3u8', 'resolved media URI' - ); + loader.load(); - this.requests.shift().respond( - 200, null, - '#EXTM3U\n' + - '#EXTINF:10,\n' + - 'http://example.com/00001.ts\n' + - '#EXT-X-ENDLIST\n' - ); - assert.equal( - loader.media().segments[0].resolvedUri, - 'http://example.com/00001.ts', 'resolved segment URI' - ); -}); + loader.on('error', function() { + errors.push(loader.error); + }); + this.requests.pop().respond(500); -QUnit.test('recognizes domain-relative URLs', function(assert) { - const loader = new PlaylistLoader('manifest/media.m3u8', this.fakeVhs); + assert.strictEqual(errors.length, 1, 'emitted one error'); + assert.strictEqual(errors[0].status, 500, 'http status is captured'); + }); - loader.load(); + QUnit.test('errors when an initial media playlist request fails', function(assert) { + const errors = []; + const loader = new PlaylistLoader('master.m3u8', this.fakeVhs); - this.requests.shift().respond( - 200, null, - '#EXTM3U\n' + - '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + - '/media.m3u8\n' - ); - assert.equal( - loader.master.playlists[0].resolvedUri, - window.location.protocol + '//' + - window.location.host + '/media.m3u8', - 'resolved media URI' - ); + loader.load(); - this.requests.shift().respond( - 200, null, - '#EXTM3U\n' + - '#EXTINF:10,\n' + - '/00001.ts\n' + - '#EXT-X-ENDLIST\n' - ); - assert.equal( - loader.media().segments[0].resolvedUri, - window.location.protocol + '//' + - window.location.host + '/00001.ts', - 'resolved segment URI' - ); -}); + loader.on('error', function() { + errors.push(loader.error); + }); + this.requests.pop().respond( + 200, null, + '#EXTM3U\n' + + '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + + 'media.m3u8\n' + ); -QUnit.test('recognizes key URLs relative to master and playlist', function(assert) { - const loader = new PlaylistLoader('/video/media-encrypted.m3u8', this.fakeVhs); + assert.strictEqual(errors.length, 0, 'emitted no errors'); - loader.load(); + this.requests.pop().respond(500); - this.requests.shift().respond( - 200, null, - '#EXTM3U\n' + - '#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=17\n' + - 'playlist/playlist.m3u8\n' + - '#EXT-X-ENDLIST\n' - ); - assert.equal( - loader.master.playlists[0].resolvedUri, - window.location.protocol + '//' + - window.location.host + '/video/playlist/playlist.m3u8', - 'resolved media URI' - ); + assert.strictEqual(errors.length, 1, 'emitted one error'); + assert.strictEqual(errors[0].status, 500, 'http status is captured'); + }); - this.requests.shift().respond( - 200, null, - '#EXTM3U\n' + - '#EXT-X-TARGETDURATION:15\n' + - '#EXT-X-KEY:METHOD=AES-128,URI="keys/key.php"\n' + - '#EXTINF:2.833,\n' + - 'http://example.com/000001.ts\n' + - '#EXT-X-ENDLIST\n' - ); - assert.equal( - loader.media().segments[0].key.resolvedUri, - window.location.protocol + '//' + - window.location.host + '/video/playlist/keys/key.php', - 'resolved multiple relative paths for key URI' + // http://tools.ietf.org/html/draft-pantos-http-live-streaming-12#section-6.3.4 + QUnit.test( + 'halves the refresh timeout if a playlist is unchanged since the last reload', + function(assert) { + const loader = new PlaylistLoader('live.m3u8', this.fakeVhs); + + loader.load(); + + this.requests.pop().respond( + 200, null, + '#EXTM3U\n' + + '#EXT-X-MEDIA-SEQUENCE:0\n' + + '#EXTINF:10,\n' + + '0.ts\n' + ); + // trigger a refresh + this.clock.tick(10 * 1000); + this.requests.pop().respond( + 200, null, + '#EXTM3U\n' + + '#EXT-X-MEDIA-SEQUENCE:0\n' + + '#EXTINF:10,\n' + + '0.ts\n' + ); + // half the default target-duration + this.clock.tick(5 * 1000); + + assert.strictEqual(this.requests.length, 1, 'sent a request'); + assert.strictEqual( + this.requests[0].url, + urlTo('live.m3u8'), + 'requested the media playlist' + ); + } ); -}); -QUnit.test('trigger an error event when a media playlist 404s', function(assert) { - let count = 0; - const loader = new PlaylistLoader('manifest/master.m3u8', this.fakeVhs); + QUnit.test('preserves segment metadata across playlist refreshes', function(assert) { + const loader = new PlaylistLoader('live.m3u8', this.fakeVhs); - loader.load(); + loader.load(); - loader.on('error', function() { - count += 1; - }); + this.requests.pop().respond( + 200, null, + '#EXTM3U\n' + + '#EXT-X-MEDIA-SEQUENCE:0\n' + + '#EXTINF:10,\n' + + '0.ts\n' + + '#EXTINF:10,\n' + + '1.ts\n' + + '#EXTINF:10,\n' + + '2.ts\n' + ); + // add PTS info to 1.ts + const segment = loader.media().segments[1]; - // master - this.requests.shift().respond( - 200, null, - '#EXTM3U\n' + - '#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=17\n' + - 'playlist/playlist.m3u8\n' + - '#EXT-X-STREAM-INF:PROGRAM-ID=2,BANDWIDTH=170\n' + - 'playlist/playlist2.m3u8\n' + - '#EXT-X-ENDLIST\n' - ); - assert.equal( - count, 0, - 'error not triggered before requesting playlist' - ); + segment.minVideoPts = 14; + segment.maxAudioPts = 27; + segment.preciseDuration = 10.045; - // playlist - this.requests.shift().respond(404); + // trigger a refresh + this.clock.tick(10 * 1000); + this.requests.pop().respond( + 200, null, + '#EXTM3U\n' + + '#EXT-X-MEDIA-SEQUENCE:1\n' + + '#EXTINF:10,\n' + + '1.ts\n' + + '#EXTINF:10,\n' + + '2.ts\n' + ); - assert.equal( - count, 1, - 'error triggered after playlist 404' - ); -}); + assert.deepEqual(loader.media().segments[0], segment, 'preserved segment attributes'); + }); -QUnit.test('recognizes absolute key URLs', function(assert) { - const loader = new PlaylistLoader('/video/media-encrypted.m3u8', this.fakeVhs); + QUnit.test('clears the update timeout when switching quality', function(assert) { + const loader = new PlaylistLoader('live-master.m3u8', this.fakeVhs); + let refreshes = 0; - loader.load(); + loader.load(); - this.requests.shift().respond( - 200, null, - '#EXTM3U\n' + - '#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=17\n' + - 'playlist/playlist.m3u8\n' + - '#EXT-X-ENDLIST\n' - ); - assert.equal( - loader.master.playlists[0].resolvedUri, - window.location.protocol + '//' + - window.location.host + '/video/playlist/playlist.m3u8', - 'resolved media URI' - ); + // track the number of playlist refreshes triggered + loader.on('mediaupdatetimeout', function() { + refreshes++; + }); + // deliver the master + this.requests.pop().respond( + 200, null, + '#EXTM3U\n' + + '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + + 'live-low.m3u8\n' + + '#EXT-X-STREAM-INF:BANDWIDTH=2\n' + + 'live-high.m3u8\n' + ); + // deliver the low quality playlist + this.requests.pop().respond( + 200, null, + '#EXTM3U\n' + + '#EXT-X-MEDIA-SEQUENCE:0\n' + + '#EXTINF:10,\n' + + 'low-0.ts\n' + ); + // change to a higher quality playlist + loader.media('1-live-high.m3u8'); + this.requests.pop().respond( + 200, null, + '#EXTM3U\n' + + '#EXT-X-MEDIA-SEQUENCE:0\n' + + '#EXTINF:10,\n' + + 'high-0.ts\n' + ); + // trigger a refresh + this.clock.tick(10 * 1000); - this.requests.shift().respond( - 200, - null, - '#EXTM3U\n' + - '#EXT-X-TARGETDURATION:15\n' + - '#EXT-X-KEY:METHOD=AES-128,URI="http://example.com/keys/key.php"\n' + - '#EXTINF:2.833,\n' + - 'http://example.com/000001.ts\n' + - '#EXT-X-ENDLIST\n' - ); - assert.equal( - loader.media().segments[0].key.resolvedUri, - 'http://example.com/keys/key.php', 'resolved absolute path for key URI' - ); -}); + assert.equal(1, refreshes, 'only one refresh was triggered'); + }); -QUnit.test( - 'jumps to HAVE_METADATA when initialized with a live media playlist', - function(assert) { - const loader = new PlaylistLoader('media.m3u8', this.fakeVhs); + QUnit.test('media-sequence updates are considered a playlist change', function(assert) { + const loader = new PlaylistLoader('live.m3u8', this.fakeVhs); loader.load(); this.requests.pop().respond( 200, null, '#EXTM3U\n' + - '#EXTINF:10,\n' + - '0.ts\n' + '#EXT-X-MEDIA-SEQUENCE:0\n' + + '#EXTINF:10,\n' + + '0.ts\n' ); - assert.ok(loader.master, 'infers a master playlist'); - assert.ok(loader.media(), 'sets the media playlist'); - assert.ok(loader.media().attributes, 'sets the media playlist attributes'); - assert.strictEqual(loader.state, 'HAVE_METADATA', 'the state is correct'); - } -); + // trigger a refresh + this.clock.tick(10 * 1000); + this.requests.pop().respond( + 200, null, + '#EXTM3U\n' + + '#EXT-X-MEDIA-SEQUENCE:1\n' + + '#EXTINF:10,\n' + + '0.ts\n' + ); + // half the default target-duration + this.clock.tick(5 * 1000); -QUnit.test('moves to HAVE_METADATA after loading a media playlist', function(assert) { - let loadedPlaylist = 0; - let loadedMetadata = 0; - const loader = new PlaylistLoader('master.m3u8', this.fakeVhs); + assert.strictEqual(this.requests.length, 0, 'no request is sent'); + }); - loader.load(); + QUnit.test('emits an error if a media refresh fails', function(assert) { + let errors = 0; + const errorResponseText = 'custom error message'; + const loader = new PlaylistLoader('live.m3u8', this.fakeVhs); - loader.on('loadedplaylist', function() { - loadedPlaylist++; + loader.load(); + + loader.on('error', function() { + errors++; + }); + this.requests.pop().respond( + 200, null, + '#EXTM3U\n' + + '#EXT-X-MEDIA-SEQUENCE:0\n' + + '#EXTINF:10,\n' + + '0.ts\n' + ); + // trigger a refresh + this.clock.tick(10 * 1000); + this.requests.pop().respond(500, null, errorResponseText); + + assert.strictEqual(errors, 1, 'emitted an error'); + assert.strictEqual(loader.error.status, 500, 'captured the status code'); + assert.strictEqual( + loader.error.responseText, + errorResponseText, + 'captured the responseText' + ); }); - loader.on('loadedmetadata', function() { - loadedMetadata++; + + QUnit.test('switches media playlists when requested', function(assert) { + const loader = new PlaylistLoader('master.m3u8', this.fakeVhs); + + loader.load(); + + this.requests.pop().respond( + 200, null, + '#EXTM3U\n' + + '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + + 'low.m3u8\n' + + '#EXT-X-STREAM-INF:BANDWIDTH=2\n' + + 'high.m3u8\n' + ); + this.requests.pop().respond( + 200, null, + '#EXTM3U\n' + + '#EXT-X-MEDIA-SEQUENCE:0\n' + + '#EXTINF:10,\n' + + 'low-0.ts\n' + ); + + loader.media(loader.master.playlists[1]); + assert.strictEqual(loader.state, 'SWITCHING_MEDIA', 'updated the state'); + + this.requests.pop().respond( + 200, null, + '#EXTM3U\n' + + '#EXT-X-MEDIA-SEQUENCE:0\n' + + '#EXTINF:10,\n' + + 'high-0.ts\n' + ); + assert.strictEqual(loader.state, 'HAVE_METADATA', 'switched active media'); + assert.strictEqual( + loader.media(), + loader.master.playlists[1], + 'updated the active media' + ); }); - this.requests.pop().respond( - 200, null, - '#EXTM3U\n' + - '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + - 'media.m3u8\n' + - '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + - 'alt.m3u8\n' - ); - assert.strictEqual(loadedPlaylist, 1, 'fired loadedplaylist once'); - assert.strictEqual(loadedMetadata, 0, 'did not fire loadedmetadata'); - assert.strictEqual(this.requests.length, 1, 'requests the media playlist'); - assert.strictEqual(this.requests[0].method, 'GET', 'GETs the media playlist'); - assert.strictEqual( - this.requests[0].url, - urlTo('media.m3u8'), - 'requests the first playlist' - ); - this.requests.pop().respond( - 200, null, - '#EXTM3U\n' + - '#EXTINF:10,\n' + - '0.ts\n' + QUnit.test( + 'can switch playlists immediately after the master is downloaded', + function(assert) { + const loader = new PlaylistLoader('master.m3u8', this.fakeVhs); + + loader.load(); + + loader.on('loadedplaylist', function() { + loader.media('1-high.m3u8'); + }); + this.requests.pop().respond( + 200, null, + '#EXTM3U\n' + + '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + + 'low.m3u8\n' + + '#EXT-X-STREAM-INF:BANDWIDTH=2\n' + + 'high.m3u8\n' + ); + assert.equal(this.requests[0].url, urlTo('high.m3u8'), 'switched variants immediately'); + } ); - assert.ok(loader.master, 'sets the master playlist'); - assert.ok(loader.media(), 'sets the media playlist'); - assert.strictEqual(loadedPlaylist, 2, 'fired loadedplaylist twice'); - assert.strictEqual(loadedMetadata, 1, 'fired loadedmetadata once'); - assert.strictEqual(loader.state, 'HAVE_METADATA', 'the state is correct'); -}); -QUnit.test('defaults missing media groups for a media playlist', function(assert) { - const loader = new PlaylistLoader('master.m3u8', this.fakeVhs); + QUnit.test('can switch media playlists based on ID', function(assert) { + const loader = new PlaylistLoader('master.m3u8', this.fakeVhs); + + loader.load(); - loader.load(); - this.requests.pop().respond( - 200, null, - '#EXTM3U\n' + - '#EXTINF:10,\n' + - '0.ts\n' - ); + this.requests.pop().respond( + 200, null, + '#EXTM3U\n' + + '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + + 'low.m3u8\n' + + '#EXT-X-STREAM-INF:BANDWIDTH=2\n' + + 'high.m3u8\n' + ); + this.requests.pop().respond( + 200, null, + '#EXTM3U\n' + + '#EXT-X-MEDIA-SEQUENCE:0\n' + + '#EXTINF:10,\n' + + 'low-0.ts\n' + ); + + loader.media('1-high.m3u8'); + assert.strictEqual(loader.state, 'SWITCHING_MEDIA', 'updated the state'); + + this.requests.pop().respond( + 200, null, + '#EXTM3U\n' + + '#EXT-X-MEDIA-SEQUENCE:0\n' + + '#EXTINF:10,\n' + + 'high-0.ts\n' + ); + assert.strictEqual(loader.state, 'HAVE_METADATA', 'switched active media'); + assert.strictEqual( + loader.media(), + loader.master.playlists[1], + 'updated the active media' + ); + }); + + QUnit.test('aborts in-flight playlist refreshes when switching', function(assert) { + const loader = new PlaylistLoader('master.m3u8', this.fakeVhs); + + loader.load(); + + this.requests.pop().respond( + 200, null, + '#EXTM3U\n' + + '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + + 'low.m3u8\n' + + '#EXT-X-STREAM-INF:BANDWIDTH=2\n' + + 'high.m3u8\n' + ); + this.requests.pop().respond( + 200, null, + '#EXTM3U\n' + + '#EXT-X-MEDIA-SEQUENCE:0\n' + + '#EXTINF:10,\n' + + 'low-0.ts\n' + ); + this.clock.tick(10 * 1000); + loader.media('1-high.m3u8'); + assert.strictEqual(this.requests[0].aborted, true, 'aborted refresh request'); + assert.ok( + !this.requests[0].onreadystatechange, + 'onreadystatechange handlers should be removed on abort' + ); + assert.strictEqual(loader.state, 'SWITCHING_MEDIA', 'updated the state'); + }); + + QUnit.test('switching to the active playlist is a no-op', function(assert) { + const loader = new PlaylistLoader('master.m3u8', this.fakeVhs); + + loader.load(); + + this.requests.pop().respond( + 200, null, + '#EXTM3U\n' + + '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + + 'low.m3u8\n' + + '#EXT-X-STREAM-INF:BANDWIDTH=2\n' + + 'high.m3u8\n' + ); + this.requests.pop().respond( + 200, null, + '#EXTM3U\n' + + '#EXT-X-MEDIA-SEQUENCE:0\n' + + '#EXTINF:10,\n' + + 'low-0.ts\n' + + '#EXT-X-ENDLIST\n' + ); + loader.media('0-low.m3u8'); - assert.ok(loader.master.mediaGroups.AUDIO, 'defaulted audio'); - assert.ok(loader.master.mediaGroups.VIDEO, 'defaulted video'); - assert.ok(loader.master.mediaGroups['CLOSED-CAPTIONS'], 'defaulted closed captions'); - assert.ok(loader.master.mediaGroups.SUBTITLES, 'defaulted subtitles'); -}); + assert.strictEqual(this.requests.length, 0, 'no requests are sent'); + }); -QUnit.test( - 'moves to HAVE_CURRENT_METADATA when refreshing the playlist', - function(assert) { - const loader = new PlaylistLoader('live.m3u8', this.fakeVhs); + QUnit.test('switching to the active live playlist is a no-op', function(assert) { + const loader = new PlaylistLoader('master.m3u8', this.fakeVhs); loader.load(); this.requests.pop().respond( 200, null, '#EXTM3U\n' + - '#EXTINF:10,\n' + - '0.ts\n' + '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + + 'low.m3u8\n' + + '#EXT-X-STREAM-INF:BANDWIDTH=2\n' + + 'high.m3u8\n' ); - // 10s, one target duration - this.clock.tick(10 * 1000); - assert.strictEqual(loader.state, 'HAVE_CURRENT_METADATA', 'the state is correct'); - assert.strictEqual(this.requests.length, 1, 'requested playlist'); - assert.strictEqual( - this.requests[0].url, - urlTo('live.m3u8'), - 'refreshes the media playlist' + this.requests.pop().respond( + 200, null, + '#EXTM3U\n' + + '#EXT-X-MEDIA-SEQUENCE:0\n' + + '#EXTINF:10,\n' + + 'low-0.ts\n' ); - } -); - -QUnit.test('returns to HAVE_METADATA after refreshing the playlist', function(assert) { - const loader = new PlaylistLoader('live.m3u8', this.fakeVhs); + loader.media('0-low.m3u8'); - loader.load(); + assert.strictEqual(this.requests.length, 0, 'no requests are sent'); + }); - this.requests.pop().respond( - 200, null, - '#EXTM3U\n' + - '#EXTINF:10,\n' + - '0.ts\n' - ); - // 10s, one target duration - this.clock.tick(10 * 1000); - this.requests.pop().respond( - 200, null, - '#EXTM3U\n' + - '#EXTINF:10,\n' + - '1.ts\n' + QUnit.test( + 'switches back to loaded playlists without re-requesting them', + function(assert) { + const loader = new PlaylistLoader('master.m3u8', this.fakeVhs); + + loader.load(); + + this.requests.pop().respond( + 200, null, + '#EXTM3U\n' + + '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + + 'low.m3u8\n' + + '#EXT-X-STREAM-INF:BANDWIDTH=2\n' + + 'high.m3u8\n' + ); + this.requests.pop().respond( + 200, null, + '#EXTM3U\n' + + '#EXT-X-MEDIA-SEQUENCE:0\n' + + '#EXTINF:10,\n' + + 'low-0.ts\n' + + '#EXT-X-ENDLIST\n' + ); + loader.media('1-high.m3u8'); + this.requests.pop().respond( + 200, null, + '#EXTM3U\n' + + '#EXT-X-MEDIA-SEQUENCE:0\n' + + '#EXTINF:10,\n' + + 'high-0.ts\n' + + '#EXT-X-ENDLIST\n' + ); + loader.media('0-low.m3u8'); + + assert.strictEqual(this.requests.length, 0, 'no outstanding requests'); + assert.strictEqual(loader.state, 'HAVE_METADATA', 'returned to loaded playlist'); + } ); - assert.strictEqual(loader.state, 'HAVE_METADATA', 'the state is correct'); -}); -QUnit.test('refreshes the playlist after last segment duration', function(assert) { - const loader = new PlaylistLoader('live.m3u8', this.fakeVhs); - let refreshes = 0; - - loader.on('mediaupdatetimeout', () => refreshes++); + QUnit.test( + 'aborts outstanding requests if switching back to an already loaded playlist', + function(assert) { + const loader = new PlaylistLoader('master.m3u8', this.fakeVhs); + + loader.load(); + + this.requests.pop().respond( + 200, null, + '#EXTM3U\n' + + '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + + 'low.m3u8\n' + + '#EXT-X-STREAM-INF:BANDWIDTH=2\n' + + 'high.m3u8\n' + ); + this.requests.pop().respond( + 200, null, + '#EXTM3U\n' + + '#EXT-X-MEDIA-SEQUENCE:0\n' + + '#EXTINF:10,\n' + + 'low-0.ts\n' + + '#EXT-X-ENDLIST\n' + ); + loader.media('1-high.m3u8'); + loader.media('0-low.m3u8'); + + assert.strictEqual( + this.requests.length, + 1, + 'requested high playlist' + ); + assert.ok( + this.requests[0].aborted, + 'aborted playlist request' + ); + assert.ok( + !this.requests[0].onreadystatechange, + 'onreadystatechange handlers should be removed on abort' + ); + assert.strictEqual( + loader.state, + 'HAVE_METADATA', + 'returned to loaded playlist' + ); + assert.strictEqual( + loader.media(), + loader.master.playlists[0], + 'switched to loaded playlist' + ); + } + ); - loader.load(); + QUnit.test( + 'does not abort requests when the same playlist is re-requested', + function(assert) { + const loader = new PlaylistLoader('master.m3u8', this.fakeVhs); + + loader.load(); + + this.requests.pop().respond( + 200, null, + '#EXTM3U\n' + + '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + + 'low.m3u8\n' + + '#EXT-X-STREAM-INF:BANDWIDTH=2\n' + + 'high.m3u8\n' + ); + this.requests.pop().respond( + 200, null, + '#EXTM3U\n' + + '#EXT-X-MEDIA-SEQUENCE:0\n' + + '#EXTINF:10,\n' + + 'low-0.ts\n' + + '#EXT-X-ENDLIST\n' + ); + loader.media('1-high.m3u8'); + loader.media('1-high.m3u8'); - this.requests.pop().respond( - 200, null, - '#EXTM3U\n' + - '#EXT-X-TARGETDURATION:10\n' + - '#EXTINF:10,\n' + - '0.ts\n' + - '#EXTINF:4\n' + - '1.ts\n' + assert.strictEqual(this.requests.length, 1, 'made only one request'); + assert.ok(!this.requests[0].aborted, 'request not aborted'); + } ); - // 4s, last segment duration - this.clock.tick(4 * 1000); - assert.equal(refreshes, 1, 'refreshed playlist after last segment duration'); -}); + QUnit.test('throws an error if a media switch is initiated too early', function(assert) { + const loader = new PlaylistLoader('master.m3u8', this.fakeVhs); -QUnit.test('emits an error when an initial playlist request fails', function(assert) { - const errors = []; - const loader = new PlaylistLoader('master.m3u8', this.fakeVhs); + loader.load(); - loader.load(); + assert.throws(function() { + loader.media('1-high.m3u8'); + }, 'threw an error from HAVE_NOTHING'); - loader.on('error', function() { - errors.push(loader.error); + this.requests.pop().respond( + 200, null, + '#EXTM3U\n' + + '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + + 'low.m3u8\n' + + '#EXT-X-STREAM-INF:BANDWIDTH=2\n' + + 'high.m3u8\n' + ); }); - this.requests.pop().respond(500); - assert.strictEqual(errors.length, 1, 'emitted one error'); - assert.strictEqual(errors[0].status, 500, 'http status is captured'); -}); + QUnit.test( + 'throws an error if a switch to an unrecognized playlist is requested', + function(assert) { + const loader = new PlaylistLoader('master.m3u8', this.fakeVhs); -QUnit.test('errors when an initial media playlist request fails', function(assert) { - const errors = []; - const loader = new PlaylistLoader('master.m3u8', this.fakeVhs); + loader.load(); - loader.load(); + this.requests.pop().respond( + 200, null, + '#EXTM3U\n' + + '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + + 'media.m3u8\n' + ); - loader.on('error', function() { - errors.push(loader.error); - }); - this.requests.pop().respond( - 200, null, - '#EXTM3U\n' + - '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + - 'media.m3u8\n' + assert.throws(function() { + loader.media('unrecognized.m3u8'); + }, 'throws an error'); + } ); - assert.strictEqual(errors.length, 0, 'emitted no errors'); - - this.requests.pop().respond(500); - - assert.strictEqual(errors.length, 1, 'emitted one error'); - assert.strictEqual(errors[0].status, 500, 'http status is captured'); -}); - -// http://tools.ietf.org/html/draft-pantos-http-live-streaming-12#section-6.3.4 -QUnit.test( - 'halves the refresh timeout if a playlist is unchanged since the last reload', - function(assert) { + QUnit.test('dispose cancels the refresh timeout', function(assert) { const loader = new PlaylistLoader('live.m3u8', this.fakeVhs); loader.load(); @@ -1373,689 +1884,555 @@ QUnit.test( this.requests.pop().respond( 200, null, '#EXTM3U\n' + - '#EXT-X-MEDIA-SEQUENCE:0\n' + - '#EXTINF:10,\n' + - '0.ts\n' + '#EXT-X-MEDIA-SEQUENCE:0\n' + + '#EXTINF:10,\n' + + '0.ts\n' ); - // trigger a refresh - this.clock.tick(10 * 1000); + loader.dispose(); + // a lot of time passes... + this.clock.tick(15 * 1000); + + assert.strictEqual(this.requests.length, 0, 'no refresh request was made'); + }); + + QUnit.test('dispose aborts pending refresh requests', function(assert) { + const loader = new PlaylistLoader('live.m3u8', this.fakeVhs); + + loader.load(); + this.requests.pop().respond( 200, null, '#EXTM3U\n' + - '#EXT-X-MEDIA-SEQUENCE:0\n' + - '#EXTINF:10,\n' + - '0.ts\n' + '#EXT-X-MEDIA-SEQUENCE:0\n' + + '#EXTINF:10,\n' + + '0.ts\n' ); - // half the default target-duration - this.clock.tick(5 * 1000); + this.clock.tick(10 * 1000); - assert.strictEqual(this.requests.length, 1, 'sent a request'); - assert.strictEqual( - this.requests[0].url, - urlTo('live.m3u8'), - 'requested the media playlist' - ); - } -); - -QUnit.test('preserves segment metadata across playlist refreshes', function(assert) { - const loader = new PlaylistLoader('live.m3u8', this.fakeVhs); - - loader.load(); - - this.requests.pop().respond( - 200, null, - '#EXTM3U\n' + - '#EXT-X-MEDIA-SEQUENCE:0\n' + - '#EXTINF:10,\n' + - '0.ts\n' + - '#EXTINF:10,\n' + - '1.ts\n' + - '#EXTINF:10,\n' + - '2.ts\n' - ); - // add PTS info to 1.ts - const segment = loader.media().segments[1]; - - segment.minVideoPts = 14; - segment.maxAudioPts = 27; - segment.preciseDuration = 10.045; - - // trigger a refresh - this.clock.tick(10 * 1000); - this.requests.pop().respond( - 200, null, - '#EXTM3U\n' + - '#EXT-X-MEDIA-SEQUENCE:1\n' + - '#EXTINF:10,\n' + - '1.ts\n' + - '#EXTINF:10,\n' + - '2.ts\n' - ); + loader.dispose(); + assert.ok(this.requests[0].aborted, 'refresh request aborted'); + assert.ok( + !this.requests[0].onreadystatechange, + 'onreadystatechange handler should not exist after dispose called' + ); + }); - assert.deepEqual(loader.media().segments[0], segment, 'preserved segment attributes'); -}); + QUnit.test('errors if requests take longer than 45s', function(assert) { + const loader = new PlaylistLoader('media.m3u8', this.fakeVhs); + let errors = 0; -QUnit.test('clears the update timeout when switching quality', function(assert) { - const loader = new PlaylistLoader('live-master.m3u8', this.fakeVhs); - let refreshes = 0; + loader.load(); - loader.load(); + loader.on('error', function() { + errors++; + }); + this.clock.tick(45 * 1000); - // track the number of playlist refreshes triggered - loader.on('mediaupdatetimeout', function() { - refreshes++; + assert.strictEqual(errors, 1, 'fired one error'); + assert.strictEqual(loader.error.code, 2, 'fired a network error'); }); - // deliver the master - this.requests.pop().respond( - 200, null, - '#EXTM3U\n' + - '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + - 'live-low.m3u8\n' + - '#EXT-X-STREAM-INF:BANDWIDTH=2\n' + - 'live-high.m3u8\n' - ); - // deliver the low quality playlist - this.requests.pop().respond( - 200, null, - '#EXTM3U\n' + - '#EXT-X-MEDIA-SEQUENCE:0\n' + - '#EXTINF:10,\n' + - 'low-0.ts\n' - ); - // change to a higher quality playlist - loader.media('1-live-high.m3u8'); - this.requests.pop().respond( - 200, null, - '#EXTM3U\n' + - '#EXT-X-MEDIA-SEQUENCE:0\n' + - '#EXTINF:10,\n' + - 'high-0.ts\n' - ); - // trigger a refresh - this.clock.tick(10 * 1000); - assert.equal(1, refreshes, 'only one refresh was triggered'); -}); + QUnit.test('triggers an event when the active media changes', function(assert) { + const loader = new PlaylistLoader('master.m3u8', this.fakeVhs); + let mediaChanges = 0; + let mediaChangings = 0; + let loadedPlaylists = 0; + let loadedMetadata = 0; -QUnit.test('media-sequence updates are considered a playlist change', function(assert) { - const loader = new PlaylistLoader('live.m3u8', this.fakeVhs); + loader.on('mediachange', () => { + mediaChanges++; + }); + loader.on('mediachanging', () => { + mediaChangings++; + }); + loader.on('loadedplaylist', () => { + loadedPlaylists++; + }); + loader.on('loadedmetadata', () => { + loadedMetadata++; + }); - loader.load(); + loader.load(); + this.requests.pop().respond( + 200, null, + '#EXTM3U\n' + + '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + + 'low.m3u8\n' + + '#EXT-X-STREAM-INF:BANDWIDTH=2\n' + + 'high.m3u8\n' + ); + assert.strictEqual(loadedPlaylists, 1, 'trigger loadedplaylist'); + assert.strictEqual(loadedMetadata, 0, 'no loadedmetadata yet'); - this.requests.pop().respond( - 200, null, - '#EXTM3U\n' + - '#EXT-X-MEDIA-SEQUENCE:0\n' + - '#EXTINF:10,\n' + - '0.ts\n' - ); - // trigger a refresh - this.clock.tick(10 * 1000); - this.requests.pop().respond( - 200, null, - '#EXTM3U\n' + - '#EXT-X-MEDIA-SEQUENCE:1\n' + - '#EXTINF:10,\n' + - '0.ts\n' - ); - // half the default target-duration - this.clock.tick(5 * 1000); + this.requests.shift().respond( + 200, null, + '#EXTM3U\n' + + '#EXT-X-MEDIA-SEQUENCE:0\n' + + '#EXTINF:10,\n' + + 'low-0.ts\n' + + '#EXT-X-ENDLIST\n' + ); + assert.strictEqual(mediaChangings, 0, 'initial selection is not a media changing'); + assert.strictEqual(mediaChanges, 0, 'initial selection is not a media change'); + assert.strictEqual(loadedPlaylists, 2, 'two loadedplaylists'); + assert.strictEqual(loadedMetadata, 1, 'fired loadedMetadata'); - assert.strictEqual(this.requests.length, 0, 'no request is sent'); -}); + loader.media('1-high.m3u8'); + assert.strictEqual(mediaChangings, 1, 'mediachanging fires immediately'); + assert.strictEqual(mediaChanges, 0, 'mediachange does not fire immediately'); + assert.strictEqual(loadedPlaylists, 2, 'still two loadedplaylists'); + assert.strictEqual(loadedMetadata, 1, 'still one loadedmetadata'); -QUnit.test('emits an error if a media refresh fails', function(assert) { - let errors = 0; - const errorResponseText = 'custom error message'; - const loader = new PlaylistLoader('live.m3u8', this.fakeVhs); + this.requests.shift().respond( + 200, null, + '#EXTM3U\n' + + '#EXT-X-MEDIA-SEQUENCE:0\n' + + '#EXTINF:10,\n' + + 'high-0.ts\n' + + '#EXT-X-ENDLIST\n' + ); + assert.strictEqual(mediaChangings, 1, 'still one mediachanging'); + assert.strictEqual(mediaChanges, 1, 'fired a mediachange'); + assert.strictEqual(loadedPlaylists, 3, 'three loadedplaylists'); + assert.strictEqual(loadedMetadata, 1, 'still one loadedmetadata'); - loader.load(); + // switch back to an already loaded playlist + loader.media('0-low.m3u8'); + assert.strictEqual(this.requests.length, 0, 'no requests made'); + assert.strictEqual(mediaChangings, 2, 'mediachanging fires'); + assert.strictEqual(mediaChanges, 2, 'fired a mediachange'); + assert.strictEqual(loadedPlaylists, 3, 'still three loadedplaylists'); + assert.strictEqual(loadedMetadata, 1, 'still one loadedmetadata'); - loader.on('error', function() { - errors++; + // trigger a no-op switch + loader.media('0-low.m3u8'); + assert.strictEqual(this.requests.length, 0, 'no requests made'); + assert.strictEqual(mediaChangings, 2, 'mediachanging ignored the no-op'); + assert.strictEqual(mediaChanges, 2, 'ignored a no-op media change'); + assert.strictEqual(loadedPlaylists, 3, 'still three loadedplaylists'); + assert.strictEqual(loadedMetadata, 1, 'still one loadedmetadata'); }); - this.requests.pop().respond( - 200, null, - '#EXTM3U\n' + - '#EXT-X-MEDIA-SEQUENCE:0\n' + - '#EXTINF:10,\n' + - '0.ts\n' - ); - // trigger a refresh - this.clock.tick(10 * 1000); - this.requests.pop().respond(500, null, errorResponseText); - - assert.strictEqual(errors, 1, 'emitted an error'); - assert.strictEqual(loader.error.status, 500, 'captured the status code'); - assert.strictEqual( - loader.error.responseText, - errorResponseText, - 'captured the responseText' - ); -}); - -QUnit.test('switches media playlists when requested', function(assert) { - const loader = new PlaylistLoader('master.m3u8', this.fakeVhs); - - loader.load(); - - this.requests.pop().respond( - 200, null, - '#EXTM3U\n' + - '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + - 'low.m3u8\n' + - '#EXT-X-STREAM-INF:BANDWIDTH=2\n' + - 'high.m3u8\n' - ); - this.requests.pop().respond( - 200, null, - '#EXTM3U\n' + - '#EXT-X-MEDIA-SEQUENCE:0\n' + - '#EXTINF:10,\n' + - 'low-0.ts\n' - ); - - loader.media(loader.master.playlists[1]); - assert.strictEqual(loader.state, 'SWITCHING_MEDIA', 'updated the state'); - this.requests.pop().respond( - 200, null, - '#EXTM3U\n' + - '#EXT-X-MEDIA-SEQUENCE:0\n' + - '#EXTINF:10,\n' + - 'high-0.ts\n' - ); - assert.strictEqual(loader.state, 'HAVE_METADATA', 'switched active media'); - assert.strictEqual( - loader.media(), - loader.master.playlists[1], - 'updated the active media' + QUnit.test( + 'does not misintrepret playlists missing newlines at the end', + function(assert) { + const loader = new PlaylistLoader('media.m3u8', this.fakeVhs); + + loader.load(); + + this.requests.shift().respond( + 200, null, + '#EXTM3U\n' + + '#EXT-X-MEDIA-SEQUENCE:0\n' + + '#EXTINF:10,\n' + + 'low-0.ts\n' + + '#EXT-X-ENDLIST' + ); + assert.ok(loader.media().endList, 'flushed the final line of input'); + } ); -}); -QUnit.test( - 'can switch playlists immediately after the master is downloaded', - function(assert) { + QUnit.test('Supports multiple STREAM-INF with the same URI', function(assert) { const loader = new PlaylistLoader('master.m3u8', this.fakeVhs); loader.load(); - loader.on('loadedplaylist', function() { - loader.media('1-high.m3u8'); - }); - this.requests.pop().respond( + this.requests.shift().respond( 200, null, '#EXTM3U\n' + - '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + - 'low.m3u8\n' + - '#EXT-X-STREAM-INF:BANDWIDTH=2\n' + - 'high.m3u8\n' + '#EXT-X-STREAM-INF:BANDWIDTH=1,AUDIO="aud0"\n' + + 'video/media.m3u8\n' + + '#EXT-X-STREAM-INF:BANDWIDTH=2,AUDIO="aud1"\n' + + 'video/media.m3u8\n' + ); + assert.equal( + loader.master.playlists['0-video/media.m3u8'].id, + loader.master.playlists[0].id, + 'created key based on playlist id' ); - assert.equal(this.requests[0].url, urlTo('high.m3u8'), 'switched variants immediately'); - } -); -QUnit.test('can switch media playlists based on ID', function(assert) { - const loader = new PlaylistLoader('master.m3u8', this.fakeVhs); + assert.equal( + loader.master.playlists['1-video/media.m3u8'].id, + loader.master.playlists[1].id, + 'created key based on playlist id' + ); + }); - loader.load(); + QUnit.module('llhls', { + beforeEach() { + this.fakeVhs.options_ = {experimentalLLHLS: true}; + this.loader = new PlaylistLoader('http://example.com/media.m3u8', this.fakeVhs); - this.requests.pop().respond( - 200, null, - '#EXTM3U\n' + - '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + - 'low.m3u8\n' + - '#EXT-X-STREAM-INF:BANDWIDTH=2\n' + - 'high.m3u8\n' - ); - this.requests.pop().respond( - 200, null, - '#EXTM3U\n' + - '#EXT-X-MEDIA-SEQUENCE:0\n' + - '#EXTINF:10,\n' + - 'low-0.ts\n' - ); + this.loader.load(); - loader.media('1-high.m3u8'); - assert.strictEqual(loader.state, 'SWITCHING_MEDIA', 'updated the state'); + }, + afterEach() { + this.loader.dispose(); + } + }); - this.requests.pop().respond( - 200, null, - '#EXTM3U\n' + - '#EXT-X-MEDIA-SEQUENCE:0\n' + - '#EXTINF:10,\n' + - 'high-0.ts\n' - ); - assert.strictEqual(loader.state, 'HAVE_METADATA', 'switched active media'); - assert.strictEqual( - loader.media(), - loader.master.playlists[1], - 'updated the active media' - ); -}); + QUnit.test('#EXT-X-SKIP does not add initial empty segments', function(assert) { + this.requests.shift().respond( + 200, null, + '#EXTM3U\n' + + '#EXT-X-MEDIA-SEQUENCE:0\n' + + '#EXT-X-SKIP:SKIPPED-SEGMENTS=10\n' + + '#EXTINF:2\n' + + 'low-1.ts\n' + ); + assert.equal(this.loader.media().segments.length, 1, 'only 1 segment'); + }); -QUnit.test('aborts in-flight playlist refreshes when switching', function(assert) { - const loader = new PlaylistLoader('master.m3u8', this.fakeVhs); + QUnit.test('#EXT-X-SKIP merges skipped segments', function(assert) { + let playlist = + '#EXTM3U\n' + + '#EXT-X-MEDIA-SEQUENCE:0\n'; - loader.load(); + for (let i = 0; i < 10; i++) { + playlist += '#EXTINF:2\n'; + playlist += `segment-${i}.ts\n`; + } - this.requests.pop().respond( - 200, null, - '#EXTM3U\n' + - '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + - 'low.m3u8\n' + - '#EXT-X-STREAM-INF:BANDWIDTH=2\n' + - 'high.m3u8\n' - ); - this.requests.pop().respond( - 200, null, - '#EXTM3U\n' + - '#EXT-X-MEDIA-SEQUENCE:0\n' + - '#EXTINF:10,\n' + - 'low-0.ts\n' - ); - this.clock.tick(10 * 1000); - loader.media('1-high.m3u8'); - assert.strictEqual(this.requests[0].aborted, true, 'aborted refresh request'); - assert.ok( - !this.requests[0].onreadystatechange, - 'onreadystatechange handlers should be removed on abort' - ); - assert.strictEqual(loader.state, 'SWITCHING_MEDIA', 'updated the state'); -}); + this.requests.shift().respond(200, null, playlist); + assert.equal(this.loader.media().segments.length, 10, '10 segments'); -QUnit.test('switching to the active playlist is a no-op', function(assert) { - const loader = new PlaylistLoader('master.m3u8', this.fakeVhs); + this.loader.trigger('mediaupdatetimeout'); - loader.load(); + const skippedPlaylist = + '#EXTM3U\n' + + '#EXT-X-MEDIA-SEQUENCE:0\n' + + '#EXT-X-SKIP:SKIPPED-SEGMENTS=10\n' + + '#EXTINF:2\n' + + 'segment-10.ts\n'; - this.requests.pop().respond( - 200, null, - '#EXTM3U\n' + - '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + - 'low.m3u8\n' + - '#EXT-X-STREAM-INF:BANDWIDTH=2\n' + - 'high.m3u8\n' - ); - this.requests.pop().respond( - 200, null, - '#EXTM3U\n' + - '#EXT-X-MEDIA-SEQUENCE:0\n' + - '#EXTINF:10,\n' + - 'low-0.ts\n' + - '#EXT-X-ENDLIST\n' - ); - loader.media('0-low.m3u8'); + this.requests.shift().respond(200, null, skippedPlaylist); - assert.strictEqual(this.requests.length, 0, 'no requests are sent'); -}); + assert.equal(this.loader.media().segments.length, 11, '11 segments'); -QUnit.test('switching to the active live playlist is a no-op', function(assert) { - const loader = new PlaylistLoader('master.m3u8', this.fakeVhs); + this.loader.media().segments.forEach(function(s, i) { + if (i < 10) { + assert.ok(s.hasOwnProperty('skipped'), 'has skipped property'); + assert.false(s.skipped, 'skipped property is false'); + } - loader.load(); + assert.equal(s.uri, `segment-${i}.ts`, 'segment uri as expected'); + }); - this.requests.pop().respond( - 200, null, - '#EXTM3U\n' + - '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + - 'low.m3u8\n' + - '#EXT-X-STREAM-INF:BANDWIDTH=2\n' + - 'high.m3u8\n' - ); - this.requests.pop().respond( - 200, null, - '#EXTM3U\n' + - '#EXT-X-MEDIA-SEQUENCE:0\n' + - '#EXTINF:10,\n' + - 'low-0.ts\n' - ); - loader.media('0-low.m3u8'); + this.loader.trigger('mediaupdatetimeout'); - assert.strictEqual(this.requests.length, 0, 'no requests are sent'); -}); + const skippedPlaylist2 = + '#EXTM3U\n' + + '#EXT-X-MEDIA-SEQUENCE:1\n' + + '#EXT-X-SKIP:SKIPPED-SEGMENTS=10\n' + + '#EXTINF:2\n' + + 'segment-11.ts\n'; -QUnit.test( - 'switches back to loaded playlists without re-requesting them', - function(assert) { - const loader = new PlaylistLoader('master.m3u8', this.fakeVhs); + this.requests.shift().respond(200, null, skippedPlaylist2); - loader.load(); + this.loader.media().segments.forEach(function(s, i) { + if (i < 10) { + assert.ok(s.hasOwnProperty('skipped'), 'has skipped property'); + assert.false(s.skipped, 'skipped property is false'); + } - this.requests.pop().respond( + assert.equal(s.uri, `segment-${i + 1}.ts`, 'segment uri as expected'); + }); + }); + + QUnit.test('#EXT-X-PRELOAD with parts to added to segment list', function(assert) { + this.requests.shift().respond( 200, null, '#EXTM3U\n' + - '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + - 'low.m3u8\n' + - '#EXT-X-STREAM-INF:BANDWIDTH=2\n' + - 'high.m3u8\n' + '#EXT-X-MEDIA-SEQUENCE:0\n' + + '#EXTINF:2\n' + + 'low-1.ts\n' + + '#EXT-X-PART:URI="part1.ts",DURATION=1\n' + + '#EXT-X-PART:URI="part2.ts",DURATION=1\n' ); - this.requests.pop().respond( - 200, null, - '#EXTM3U\n' + - '#EXT-X-MEDIA-SEQUENCE:0\n' + - '#EXTINF:10,\n' + - 'low-0.ts\n' + - '#EXT-X-ENDLIST\n' + const media = this.loader.media(); + + assert.equal(media.segments.length, 2, '2 segments'); + assert.deepEqual( + media.preloadSegment, + media.segments[media.segments.length - 1], + 'last segment is preloadSegment' ); - loader.media('1-high.m3u8'); - this.requests.pop().respond( + }); + + QUnit.test('#EXT-X-PRELOAD without parts not added to segment list', function(assert) { + this.requests.shift().respond( 200, null, '#EXTM3U\n' + - '#EXT-X-MEDIA-SEQUENCE:0\n' + - '#EXTINF:10,\n' + - 'high-0.ts\n' + - '#EXT-X-ENDLIST\n' + '#EXT-X-MEDIA-SEQUENCE:0\n' + + '#EXTINF:2\n' + + 'low-1.ts\n' + + '#EXT-X-PRELOAD-HINT:TYPE="PART",URI="part1.ts"\n' ); - loader.media('0-low.m3u8'); + const media = this.loader.media(); - assert.strictEqual(this.requests.length, 0, 'no outstanding requests'); - assert.strictEqual(loader.state, 'HAVE_METADATA', 'returned to loaded playlist'); - } -); - -QUnit.test( - 'aborts outstanding requests if switching back to an already loaded playlist', - function(assert) { - const loader = new PlaylistLoader('master.m3u8', this.fakeVhs); - - loader.load(); + assert.equal(media.segments.length, 1, '1 segment'); + assert.notDeepEqual( + media.preloadSegment, + media.segments[media.segments.length - 1], + 'last segment is not preloadSegment' + ); + }); - this.requests.pop().respond( + QUnit.test('#EXT-X-PART added to segments', function(assert) { + this.requests.shift().respond( 200, null, '#EXTM3U\n' + - '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + - 'low.m3u8\n' + - '#EXT-X-STREAM-INF:BANDWIDTH=2\n' + - 'high.m3u8\n' + '#EXT-X-MEDIA-SEQUENCE:0\n' + + '#EXTINF:2\n' + + 'segment0.ts\n' + + '#EXT-X-PART:URI="segment1-part1.ts",DURATION=1\n' + + '#EXT-X-PART:URI="segment1-part2.ts",DURATION=1\n' + + 'segment1.ts\n' + + '#EXT-X-PART:URI="segment2-part1.ts",DURATION=1\n' + + '#EXT-X-PART:URI="segment2-part2.ts",DURATION=1\n' + + 'segment2.ts\n' + + '#EXT-X-PART:URI="segment3-part1.ts",DURATION=1\n' + + '#EXT-X-PART:URI="segment3-part2.ts",DURATION=1\n' + + 'segment3.ts\n' ); - this.requests.pop().respond( + const segments = this.loader.media().segments; + + assert.equal(segments.length, 4, '4 segments'); + assert.notOk(segments[0].parts, 'no parts for first segment'); + assert.equal(segments[1].parts.length, 2, 'parts for second segment'); + assert.equal(segments[2].parts.length, 2, 'parts for third segment'); + assert.equal(segments[3].parts.length, 2, 'parts for forth segment'); + }); + + QUnit.test('Adds _HLS_skip=YES to url when CAN-SKIP-UNTIL is set', function(assert) { + this.requests.shift().respond( 200, null, '#EXTM3U\n' + - '#EXT-X-MEDIA-SEQUENCE:0\n' + - '#EXTINF:10,\n' + - 'low-0.ts\n' + - '#EXT-X-ENDLIST\n' - ); - loader.media('1-high.m3u8'); - loader.media('0-low.m3u8'); - - assert.strictEqual( - this.requests.length, - 1, - 'requested high playlist' - ); - assert.ok( - this.requests[0].aborted, - 'aborted playlist request' - ); - assert.ok( - !this.requests[0].onreadystatechange, - 'onreadystatechange handlers should be removed on abort' - ); - assert.strictEqual( - loader.state, - 'HAVE_METADATA', - 'returned to loaded playlist' - ); - assert.strictEqual( - loader.media(), - loader.master.playlists[0], - 'switched to loaded playlist' + '#EXT-X-MEDIA-SEQUENCE:0\n' + + '#EXT-X-SERVER-CONTROL:CAN-SKIP-UNTIL=3\n' + + '#EXTINF:2\n' + + 'segment0.ts\n' + + '#EXTINF:2\n' + + 'segment1.ts\n' + + '#EXTINF:2\n' + + 'segment2.ts\n' + + '#EXTINF:2\n' + + 'segment3.ts\n' + + '#EXTINF:2\n' + + 'segment4.ts\n' + + '#EXTINF:2\n' + + 'segment5.ts\n' + + '#EXT-X-PART:URI="segment6-part1.ts",DURATION=1\n' + + '#EXT-X-PART:URI="segment6-part2.ts",DURATION=1\n' + + 'segment6.ts\n' + + '#EXT-X-PART:URI="segment7-part1.ts",DURATION=1\n' + + '#EXT-X-PART:URI="segment7-part2.ts",DURATION=1\n' + + 'segment7.ts\n' + + '#EXT-X-PART:URI="segment8-part1.ts",DURATION=1\n' + + '#EXT-X-PART:URI="segment8-part2.ts",DURATION=1\n' + + 'segment8.ts\n' ); - } -); -QUnit.test( - 'does not abort requests when the same playlist is re-requested', - function(assert) { - const loader = new PlaylistLoader('master.m3u8', this.fakeVhs); + this.loader.trigger('mediaupdatetimeout'); - loader.load(); + assert.equal(this.requests[0].uri, 'http://example.com/media.m3u8?_HLS_skip=YES'); + }); - this.requests.pop().respond( - 200, null, - '#EXTM3U\n' + - '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + - 'low.m3u8\n' + - '#EXT-X-STREAM-INF:BANDWIDTH=2\n' + - 'high.m3u8\n' - ); - this.requests.pop().respond( + QUnit.test('Adds _HLS_skip=v2 to url when CAN-SKIP-UNTIL/CAN-SKIP-DATERANGES is set', function(assert) { + this.requests.shift().respond( 200, null, '#EXTM3U\n' + - '#EXT-X-MEDIA-SEQUENCE:0\n' + - '#EXTINF:10,\n' + - 'low-0.ts\n' + - '#EXT-X-ENDLIST\n' + '#EXT-X-MEDIA-SEQUENCE:0\n' + + '#EXT-X-SERVER-CONTROL:CAN-SKIP-UNTIL=3,CAN-SKIP-DATERANGES=YES\n' + + '#EXTINF:2\n' + + 'segment0.ts\n' + + '#EXTINF:2\n' + + 'segment1.ts\n' + + '#EXTINF:2\n' + + 'segment2.ts\n' + + '#EXTINF:2\n' + + 'segment3.ts\n' + + '#EXTINF:2\n' + + 'segment4.ts\n' + + '#EXTINF:2\n' + + 'segment5.ts\n' + + '#EXT-X-PART:URI="segment6-part1.ts",DURATION=1\n' + + '#EXT-X-PART:URI="segment6-part2.ts",DURATION=1\n' + + 'segment6.ts\n' + + '#EXT-X-PART:URI="segment7-part1.ts",DURATION=1\n' + + '#EXT-X-PART:URI="segment7-part2.ts",DURATION=1\n' + + 'segment7.ts\n' + + '#EXT-X-PART:URI="segment8-part1.ts",DURATION=1\n' + + '#EXT-X-PART:URI="segment8-part2.ts",DURATION=1\n' + + 'segment8.ts\n' ); - loader.media('1-high.m3u8'); - loader.media('1-high.m3u8'); - - assert.strictEqual(this.requests.length, 1, 'made only one request'); - assert.ok(!this.requests[0].aborted, 'request not aborted'); - } -); - -QUnit.test('throws an error if a media switch is initiated too early', function(assert) { - const loader = new PlaylistLoader('master.m3u8', this.fakeVhs); - - loader.load(); - - assert.throws(function() { - loader.media('1-high.m3u8'); - }, 'threw an error from HAVE_NOTHING'); - - this.requests.pop().respond( - 200, null, - '#EXTM3U\n' + - '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + - 'low.m3u8\n' + - '#EXT-X-STREAM-INF:BANDWIDTH=2\n' + - 'high.m3u8\n' - ); -}); -QUnit.test( - 'throws an error if a switch to an unrecognized playlist is requested', - function(assert) { - const loader = new PlaylistLoader('master.m3u8', this.fakeVhs); + this.loader.trigger('mediaupdatetimeout'); - loader.load(); + assert.equal(this.requests[0].uri, 'http://example.com/media.m3u8?_HLS_skip=v2'); + }); - this.requests.pop().respond( + QUnit.test('Adds _HLS_part= and _HLS_msn= when we have a part preload hints and parts', function(assert) { + this.requests.shift().respond( 200, null, '#EXTM3U\n' + - '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + - 'media.m3u8\n' + '#EXT-X-MEDIA-SEQUENCE:0\n' + + '#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES\n' + + '#EXTINF:2\n' + + 'segment0.ts\n' + + '#EXTINF:2\n' + + 'segment1.ts\n' + + '#EXTINF:2\n' + + 'segment2.ts\n' + + '#EXTINF:2\n' + + 'segment3.ts\n' + + '#EXTINF:2\n' + + 'segment4.ts\n' + + '#EXTINF:2\n' + + 'segment5.ts\n' + + '#EXT-X-PART:URI="segment6-part1.ts",DURATION=1\n' + + '#EXT-X-PART:URI="segment6-part2.ts",DURATION=1\n' + + 'segment6.ts\n' + + '#EXT-X-PART:URI="segment7-part1.ts",DURATION=1\n' + + '#EXT-X-PART:URI="segment7-part2.ts",DURATION=1\n' + + 'segment7.ts\n' + + '#EXT-X-PART:URI="segment8-part1.ts",DURATION=1\n' + + '#EXT-X-PRELOAD-HINT:TYPE="PART",URI="segment8-part2.ts"\n' ); - assert.throws(function() { - loader.media('unrecognized.m3u8'); - }, 'throws an error'); - } -); - -QUnit.test('dispose cancels the refresh timeout', function(assert) { - const loader = new PlaylistLoader('live.m3u8', this.fakeVhs); - - loader.load(); - - this.requests.pop().respond( - 200, null, - '#EXTM3U\n' + - '#EXT-X-MEDIA-SEQUENCE:0\n' + - '#EXTINF:10,\n' + - '0.ts\n' - ); - loader.dispose(); - // a lot of time passes... - this.clock.tick(15 * 1000); - - assert.strictEqual(this.requests.length, 0, 'no refresh request was made'); -}); - -QUnit.test('dispose aborts pending refresh requests', function(assert) { - const loader = new PlaylistLoader('live.m3u8', this.fakeVhs); - - loader.load(); - - this.requests.pop().respond( - 200, null, - '#EXTM3U\n' + - '#EXT-X-MEDIA-SEQUENCE:0\n' + - '#EXTINF:10,\n' + - '0.ts\n' - ); - this.clock.tick(10 * 1000); + this.loader.trigger('mediaupdatetimeout'); - loader.dispose(); - assert.ok(this.requests[0].aborted, 'refresh request aborted'); - assert.ok( - !this.requests[0].onreadystatechange, - 'onreadystatechange handler should not exist after dispose called' - ); -}); + assert.equal(this.requests[0].uri, 'http://example.com/media.m3u8?_HLS_msn=8&_HLS_part=1'); + }); -QUnit.test('errors if requests take longer than 45s', function(assert) { - const loader = new PlaylistLoader('media.m3u8', this.fakeVhs); - let errors = 0; + QUnit.test('Adds _HLS_part= and _HLS_msn= when we have only a part preload hint', function(assert) { + this.requests.shift().respond( + 200, null, + '#EXTM3U\n' + + '#EXT-X-MEDIA-SEQUENCE:0\n' + + '#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES\n' + + '#EXTINF:2\n' + + 'segment0.ts\n' + + '#EXTINF:2\n' + + 'segment1.ts\n' + + '#EXTINF:2\n' + + 'segment2.ts\n' + + '#EXTINF:2\n' + + 'segment3.ts\n' + + '#EXTINF:2\n' + + 'segment4.ts\n' + + '#EXTINF:2\n' + + 'segment5.ts\n' + + '#EXT-X-PART:URI="segment6-part1.ts",DURATION=1\n' + + '#EXT-X-PART:URI="segment6-part2.ts",DURATION=1\n' + + 'segment6.ts\n' + + '#EXT-X-PART:URI="segment7-part1.ts",DURATION=1\n' + + '#EXT-X-PART:URI="segment7-part2.ts",DURATION=1\n' + + 'segment7.ts\n' + + '#EXT-X-PRELOAD-HINT:TYPE="PART",URI="segment8-part1.ts"\n' + ); - loader.load(); + this.loader.trigger('mediaupdatetimeout'); - loader.on('error', function() { - errors++; + assert.equal(this.requests[0].uri, 'http://example.com/media.m3u8?_HLS_msn=7&_HLS_part=0'); }); - this.clock.tick(45 * 1000); - assert.strictEqual(errors, 1, 'fired one error'); - assert.strictEqual(loader.error.code, 2, 'fired a network error'); -}); + QUnit.test('does not add _HLS_part= when we have only a preload parts without preload hints', function(assert) { + this.requests.shift().respond( + 200, null, + '#EXTM3U\n' + + '#EXT-X-MEDIA-SEQUENCE:0\n' + + '#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES\n' + + '#EXTINF:2\n' + + 'segment0.ts\n' + + '#EXTINF:2\n' + + 'segment1.ts\n' + + '#EXTINF:2\n' + + 'segment2.ts\n' + + '#EXTINF:2\n' + + 'segment3.ts\n' + + '#EXTINF:2\n' + + 'segment4.ts\n' + + '#EXTINF:2\n' + + 'segment5.ts\n' + + '#EXT-X-PART:URI="segment6-part1.ts",DURATION=1\n' + + '#EXT-X-PART:URI="segment6-part2.ts",DURATION=1\n' + + 'segment6.ts\n' + + '#EXT-X-PART:URI="segment7-part1.ts",DURATION=1\n' + + '#EXT-X-PART:URI="segment7-part2.ts",DURATION=1\n' + + 'segment7.ts\n' + + '#EXT-X-PART:URI="segment8-part1.ts",DURATION=1\n' + ); -QUnit.test('triggers an event when the active media changes', function(assert) { - const loader = new PlaylistLoader('master.m3u8', this.fakeVhs); - let mediaChanges = 0; - let mediaChangings = 0; - let loadedPlaylists = 0; - let loadedMetadata = 0; + this.loader.trigger('mediaupdatetimeout'); - loader.on('mediachange', () => { - mediaChanges++; - }); - loader.on('mediachanging', () => { - mediaChangings++; - }); - loader.on('loadedplaylist', () => { - loadedPlaylists++; - }); - loader.on('loadedmetadata', () => { - loadedMetadata++; + assert.equal(this.requests[0].uri, 'http://example.com/media.m3u8?_HLS_msn=8'); }); - loader.load(); - this.requests.pop().respond( - 200, null, - '#EXTM3U\n' + - '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + - 'low.m3u8\n' + - '#EXT-X-STREAM-INF:BANDWIDTH=2\n' + - 'high.m3u8\n' - ); - assert.strictEqual(loadedPlaylists, 1, 'trigger loadedplaylist'); - assert.strictEqual(loadedMetadata, 0, 'no loadedmetadata yet'); - - this.requests.shift().respond( - 200, null, - '#EXTM3U\n' + - '#EXT-X-MEDIA-SEQUENCE:0\n' + - '#EXTINF:10,\n' + - 'low-0.ts\n' + - '#EXT-X-ENDLIST\n' - ); - assert.strictEqual(mediaChangings, 0, 'initial selection is not a media changing'); - assert.strictEqual(mediaChanges, 0, 'initial selection is not a media change'); - assert.strictEqual(loadedPlaylists, 2, 'two loadedplaylists'); - assert.strictEqual(loadedMetadata, 1, 'fired loadedMetadata'); - - loader.media('1-high.m3u8'); - assert.strictEqual(mediaChangings, 1, 'mediachanging fires immediately'); - assert.strictEqual(mediaChanges, 0, 'mediachange does not fire immediately'); - assert.strictEqual(loadedPlaylists, 2, 'still two loadedplaylists'); - assert.strictEqual(loadedMetadata, 1, 'still one loadedmetadata'); - - this.requests.shift().respond( - 200, null, - '#EXTM3U\n' + - '#EXT-X-MEDIA-SEQUENCE:0\n' + - '#EXTINF:10,\n' + - 'high-0.ts\n' + - '#EXT-X-ENDLIST\n' - ); - assert.strictEqual(mediaChangings, 1, 'still one mediachanging'); - assert.strictEqual(mediaChanges, 1, 'fired a mediachange'); - assert.strictEqual(loadedPlaylists, 3, 'three loadedplaylists'); - assert.strictEqual(loadedMetadata, 1, 'still one loadedmetadata'); - - // switch back to an already loaded playlist - loader.media('0-low.m3u8'); - assert.strictEqual(this.requests.length, 0, 'no requests made'); - assert.strictEqual(mediaChangings, 2, 'mediachanging fires'); - assert.strictEqual(mediaChanges, 2, 'fired a mediachange'); - assert.strictEqual(loadedPlaylists, 3, 'still three loadedplaylists'); - assert.strictEqual(loadedMetadata, 1, 'still one loadedmetadata'); - - // trigger a no-op switch - loader.media('0-low.m3u8'); - assert.strictEqual(this.requests.length, 0, 'no requests made'); - assert.strictEqual(mediaChangings, 2, 'mediachanging ignored the no-op'); - assert.strictEqual(mediaChanges, 2, 'ignored a no-op media change'); - assert.strictEqual(loadedPlaylists, 3, 'still three loadedplaylists'); - assert.strictEqual(loadedMetadata, 1, 'still one loadedmetadata'); -}); + QUnit.test('Adds only _HLS_msn= when we have segment info', function(assert) { + this.requests.shift().respond( + 200, null, + '#EXTM3U\n' + + '#EXT-X-MEDIA-SEQUENCE:0\n' + + '#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES\n' + + '#EXTINF:2\n' + + 'segment0.ts\n' + + '#EXTINF:2\n' + + 'segment1.ts\n' + + '#EXTINF:2\n' + + 'segment2.ts\n' + + '#EXTINF:2\n' + + 'segment3.ts\n' + + '#EXTINF:2\n' + + 'segment4.ts\n' + + '#EXTINF:2\n' + + 'segment5.ts\n' + + '#EXT-X-PART:URI="segment6-part1.ts",DURATION=1\n' + + '#EXT-X-PART:URI="segment6-part2.ts",DURATION=1\n' + + 'segment6.ts\n' + + '#EXT-X-PART:URI="segment7-part1.ts",DURATION=1\n' + + '#EXT-X-PART:URI="segment7-part2.ts",DURATION=1\n' + + 'segment7.ts\n' + + '#EXT-X-PART:URI="segment8-part1.ts",DURATION=1\n' + + '#EXT-X-PART:URI="segment8-part2.ts",DURATION=1\n' + + 'segment8.ts\n' + ); -QUnit.test( - 'does not misintrepret playlists missing newlines at the end', - function(assert) { - const loader = new PlaylistLoader('media.m3u8', this.fakeVhs); + this.loader.trigger('mediaupdatetimeout'); - loader.load(); + assert.equal(this.requests[0].uri, 'http://example.com/media.m3u8?_HLS_msn=9'); + }); - // no newline + QUnit.test('can add all query directives', function(assert) { this.requests.shift().respond( 200, null, '#EXTM3U\n' + - '#EXT-X-MEDIA-SEQUENCE:0\n' + - '#EXTINF:10,\n' + - 'low-0.ts\n' + - '#EXT-X-ENDLIST' + '#EXT-X-MEDIA-SEQUENCE:0\n' + + '#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,CAN-SKIP-UNTIL=3\n' + + '#EXTINF:2\n' + + 'segment0.ts\n' + + '#EXTINF:2\n' + + 'segment1.ts\n' + + '#EXTINF:2\n' + + 'segment2.ts\n' + + '#EXTINF:2\n' + + 'segment3.ts\n' + + '#EXTINF:2\n' + + 'segment4.ts\n' + + '#EXTINF:2\n' + + 'segment5.ts\n' + + '#EXT-X-PART:URI="segment6-part1.ts",DURATION=1\n' + + '#EXT-X-PART:URI="segment6-part2.ts",DURATION=1\n' + + 'segment6.ts\n' + + '#EXT-X-PART:URI="segment7-part1.ts",DURATION=1\n' + + '#EXT-X-PART:URI="segment7-part2.ts",DURATION=1\n' + + 'segment7.ts\n' + + '#EXT-X-PART:URI="segment8-part1.ts",DURATION=1\n' + + '#EXT-X-PRELOAD-HINT:TYPE="PART",URI="segment8-part2.ts"\n' ); - assert.ok(loader.media().endList, 'flushed the final line of input'); - } -); - -QUnit.test('Supports multiple STREAM-INF with the same URI', function(assert) { - const loader = new PlaylistLoader('master.m3u8', this.fakeVhs); - - loader.load(); - this.requests.shift().respond( - 200, null, - '#EXTM3U\n' + - '#EXT-X-STREAM-INF:BANDWIDTH=1,AUDIO="aud0"\n' + - 'video/media.m3u8\n' + - '#EXT-X-STREAM-INF:BANDWIDTH=2,AUDIO="aud1"\n' + - 'video/media.m3u8\n' - ); - assert.equal( - loader.master.playlists['0-video/media.m3u8'].id, - loader.master.playlists[0].id, - 'created key based on playlist id' - ); + this.loader.trigger('mediaupdatetimeout'); - assert.equal( - loader.master.playlists['1-video/media.m3u8'].id, - loader.master.playlists[1].id, - 'created key based on playlist id' - ); + assert.equal(this.requests[0].uri, 'http://example.com/media.m3u8?_HLS_skip=YES&_HLS_msn=8&_HLS_part=1'); + }); });