diff --git a/src/controller/audio-stream-controller.js b/src/controller/audio-stream-controller.js index 0a13ce03e6b..f7489cb3561 100644 --- a/src/controller/audio-stream-controller.js +++ b/src/controller/audio-stream-controller.js @@ -12,7 +12,7 @@ import { ErrorTypes, ErrorDetails } from '../errors'; import { logger } from '../utils/logger'; import { findFragWithCC } from '../utils/discontinuities'; import { FragmentState } from './fragment-tracker'; -import Fragment from '../loader/fragment'; +import Fragment, { ElementaryStreamTypes } from '../loader/fragment'; import BaseStreamController, { State } from './base-stream-controller'; const { performance } = window; @@ -615,7 +615,7 @@ class AudioStreamController extends BaseStreamController { data.endDTS = data.startDTS + fragCurrent.duration; } - fragCurrent.addElementaryStream(Fragment.ElementaryStreamTypes.AUDIO); + fragCurrent.addElementaryStream(ElementaryStreamTypes.AUDIO); logger.log(`parsed ${data.type},PTS:[${data.startPTS.toFixed(3)},${data.endPTS.toFixed(3)}],DTS:[${data.startDTS.toFixed(3)}/${data.endDTS.toFixed(3)}],nb:${data.nb}`); LevelHelper.updateFragPTSDTS(track.details, fragCurrent, data.startPTS, data.endPTS); diff --git a/src/controller/stream-controller.js b/src/controller/stream-controller.js index 3624ae25fbb..5975475c914 100644 --- a/src/controller/stream-controller.js +++ b/src/controller/stream-controller.js @@ -7,7 +7,7 @@ import { BufferHelper } from '../utils/buffer-helper'; import Demuxer from '../demux/demuxer'; import Event from '../events'; import { FragmentState } from './fragment-tracker'; -import Fragment from '../loader/fragment'; +import Fragment, { ElementaryStreamTypes } from '../loader/fragment'; import PlaylistLoader from '../loader/playlist-loader'; import * as LevelHelper from './level-helper'; import TimeRanges from '../utils/time-ranges'; @@ -1000,11 +1000,11 @@ class StreamController extends BaseStreamController { } if (data.hasAudio === true) { - frag.addElementaryStream(Fragment.ElementaryStreamTypes.AUDIO); + frag.addElementaryStream(ElementaryStreamTypes.AUDIO); } if (data.hasVideo === true) { - frag.addElementaryStream(Fragment.ElementaryStreamTypes.VIDEO); + frag.addElementaryStream(ElementaryStreamTypes.VIDEO); } logger.log(`Parsed ${data.type},PTS:[${data.startPTS.toFixed(3)},${data.endPTS.toFixed(3)}],DTS:[${data.startDTS.toFixed(3)}/${data.endDTS.toFixed(3)}],nb:${data.nb},dropped:${data.dropped || 0}`); @@ -1305,7 +1305,7 @@ class StreamController extends BaseStreamController { const media = this.mediaBuffer ? this.mediaBuffer : this.media; if (media) { // filter fragments potentially evicted from buffer. this is to avoid memleak on live streams - this.fragmentTracker.detectEvictedFragments(Fragment.ElementaryStreamTypes.VIDEO, media.buffered); + this.fragmentTracker.detectEvictedFragments(ElementaryStreamTypes.VIDEO, media.buffered); } // move to IDLE once flush complete. this should trigger new fragment loading this.state = State.IDLE; diff --git a/src/loader/fragment.js b/src/loader/fragment.js deleted file mode 100644 index ef898c50236..00000000000 --- a/src/loader/fragment.js +++ /dev/null @@ -1,150 +0,0 @@ - -import * as URLToolkit from 'url-toolkit'; - -import LevelKey from './level-key'; - -export default class Fragment { - constructor () { - this._url = null; - this._byteRange = null; - this._decryptdata = null; - this.tagList = []; - this.programDateTime = null; - this.rawProgramDateTime = null; - - // Holds the types of data this fragment supports - this._elementaryStreams = { - [Fragment.ElementaryStreamTypes.AUDIO]: false, - [Fragment.ElementaryStreamTypes.VIDEO]: false - }; - } - - /** - * `type` property for this._elementaryStreams - * - * @enum - */ - static get ElementaryStreamTypes () { - return { - AUDIO: 'audio', - VIDEO: 'video' - }; - } - - get url () { - if (!this._url && this.relurl) { - this._url = URLToolkit.buildAbsoluteURL(this.baseurl, this.relurl, { alwaysNormalize: true }); - } - - return this._url; - } - - set url (value) { - this._url = value; - } - - get byteRange () { - if (!this._byteRange && !this.rawByteRange) { - return []; - } - - if (this._byteRange) { - return this._byteRange; - } - - let byteRange = []; - if (this.rawByteRange) { - const params = this.rawByteRange.split('@', 2); - if (params.length === 1) { - const lastByteRangeEndOffset = this.lastByteRangeEndOffset; - byteRange[0] = lastByteRangeEndOffset || 0; - } else { - byteRange[0] = parseInt(params[1]); - } - byteRange[1] = parseInt(params[0]) + byteRange[0]; - this._byteRange = byteRange; - } - return byteRange; - } - - /** - * @type {number} - */ - get byteRangeStartOffset () { - return this.byteRange[0]; - } - - get byteRangeEndOffset () { - return this.byteRange[1]; - } - - get decryptdata () { - if (!this._decryptdata) { - this._decryptdata = this.fragmentDecryptdataFromLevelkey(this.levelkey, this.sn); - } - - return this._decryptdata; - } - - get endProgramDateTime () { - if (!Number.isFinite(this.programDateTime)) { - return null; - } - - let duration = !Number.isFinite(this.duration) ? 0 : this.duration; - - return this.programDateTime + (duration * 1000); - } - - get encrypted () { - return !!((this.decryptdata && this.decryptdata.uri !== null) && (this.decryptdata.key === null)); - } - - /** - * @param {ElementaryStreamType} type - */ - addElementaryStream (type) { - this._elementaryStreams[type] = true; - } - - /** - * @param {ElementaryStreamType} type - */ - hasElementaryStream (type) { - return this._elementaryStreams[type] === true; - } - - /** - * Utility method for parseLevelPlaylist to create an initialization vector for a given segment - * @returns {Uint8Array} - */ - createInitializationVector (segmentNumber) { - let uint8View = new Uint8Array(16); - - for (let i = 12; i < 16; i++) { - uint8View[i] = (segmentNumber >> 8 * (15 - i)) & 0xff; - } - - return uint8View; - } - - /** - * Utility method for parseLevelPlaylist to get a fragment's decryption data from the currently parsed encryption key data - * @param levelkey - a playlist's encryption info - * @param segmentNumber - the fragment's segment number - * @returns {*} - an object to be applied as a fragment's decryptdata - */ - fragmentDecryptdataFromLevelkey (levelkey, segmentNumber) { - let decryptdata = levelkey; - - if (levelkey && levelkey.method && levelkey.uri && !levelkey.iv) { - decryptdata = new LevelKey(); - decryptdata.method = levelkey.method; - decryptdata.baseuri = levelkey.baseuri; - decryptdata.reluri = levelkey.reluri; - decryptdata.iv = this.createInitializationVector(segmentNumber); - } - - return decryptdata; - } -} diff --git a/src/loader/fragment.ts b/src/loader/fragment.ts new file mode 100644 index 00000000000..f32dc2b09b5 --- /dev/null +++ b/src/loader/fragment.ts @@ -0,0 +1,183 @@ + +import { buildAbsoluteURL } from 'url-toolkit'; +import { logger } from '../utils/logger'; +import LevelKey from './level-key'; + +export enum ElementaryStreamTypes { + AUDIO = 'audio', + VIDEO = 'video', +} + +export default class Fragment { + private _url: string | null = null; + private _byteRange: number[] | null = null; + private _decryptdata: LevelKey | null = null; + + // Holds the types of data this fragment supports + private _elementaryStreams: Record = { + [ElementaryStreamTypes.AUDIO]: false, + [ElementaryStreamTypes.VIDEO]: false + }; + + public rawProgramDateTime: string | null = null; + public programDateTime: number | null = null; + public tagList: Array = []; + + // TODO: Move at least baseurl to constructor. + // Currently we do a two-pass construction as use the Fragment class almost like a object for holding parsing state. + // It may make more sense to just use a POJO to keep state during the parsing phase. + // Have Fragment be the representation once we have a known state? + // Something to think on. + + // relurl is the portion of the URL that comes from inside the playlist. + public relurl!: string; + // baseurl is the URL to the playlist + public baseurl!: string; + // EXTINF has to be present for a m3u8 to be considered valid + public duration!: number; + // sn notates the sequence number for a segment, and if set to a string can be 'initSegment' + public sn: number | 'initSegment' = 0; + // levelkey is the EXT-X-KEY that applies to this segment for decryption + // core difference from the private field _decryptdata is the lack of the initialized IV + // _decryptdata will set the IV for this segment based on the segment number in the fragment + public levelkey?: LevelKey; + + // setByteRange converts a EXT-X-BYTERANGE attribute into a two element array + setByteRange (value: string, previousFrag?: Fragment) { + const params = value.split('@', 2); + const byteRange: number[] = []; + if (params.length === 1) { + byteRange[0] = previousFrag ? previousFrag.byteRangeEndOffset : 0; + } else { + byteRange[0] = parseInt(params[1]); + } + byteRange[1] = parseInt(params[0]) + byteRange[0]; + this._byteRange = byteRange; + } + + get url () { + if (!this._url && this.relurl) { + this._url = buildAbsoluteURL(this.baseurl, this.relurl, { alwaysNormalize: true }); + } + + return this._url; + } + + set url (value) { + this._url = value; + } + + get byteRange (): number[] { + if (!this._byteRange) { + return []; + } + + return this._byteRange; + } + + /** + * @type {number} + */ + get byteRangeStartOffset () { + return this.byteRange[0]; + } + + get byteRangeEndOffset () { + return this.byteRange[1]; + } + + get decryptdata (): LevelKey | null { + if (!this.levelkey && !this._decryptdata) { + return null; + } + + if (!this._decryptdata && this.levelkey) { + let sn = this.sn; + if (typeof sn !== 'number') { + // We are fetching decryption data for a initialization segment + // If the segment was encrypted with AES-128 + // It must have an IV defined. We cannot substitute the Segment Number in. + if (this.levelkey && this.levelkey.method === 'AES-128' && !this.levelkey.iv) { + logger.warn(`missing IV for initialization segment with method="${this.levelkey.method}" - compliance issue`); + } + + /* + Be converted to a Number. + 'initSegment' will become NaN. + NaN, which when converted through ToInt32() -> +0. + --- + Explicitly set sn to resulting value from implicit conversions 'initSegment' values for IV generation. + */ + sn = 0; + } + this._decryptdata = this.setDecryptDataFromLevelKey(this.levelkey, sn); + } + + return this._decryptdata; + } + + get endProgramDateTime () { + if (this.programDateTime === null) { + return null; + } + + if (!Number.isFinite(this.programDateTime)) { + return null; + } + + let duration = !Number.isFinite(this.duration) ? 0 : this.duration; + + return this.programDateTime + (duration * 1000); + } + + get encrypted () { + return !!((this.decryptdata && this.decryptdata.uri !== null) && (this.decryptdata.key === null)); + } + + /** + * @param {ElementaryStreamTypes} type + */ + addElementaryStream (type: ElementaryStreamTypes) { + this._elementaryStreams[type] = true; + } + + /** + * @param {ElementaryStreamTypes} type + */ + hasElementaryStream (type: ElementaryStreamTypes) { + return this._elementaryStreams[type] === true; + } + + /** + * Utility method for parseLevelPlaylist to create an initialization vector for a given segment + * @param {number} segmentNumber - segment number to generate IV with + * @returns {Uint8Array} + */ + createInitializationVector (segmentNumber: number): Uint8Array { + let uint8View = new Uint8Array(16); + + for (let i = 12; i < 16; i++) { + uint8View[i] = (segmentNumber >> 8 * (15 - i)) & 0xff; + } + + return uint8View; + } + + /** + * Utility method for parseLevelPlaylist to get a fragment's decryption data from the currently parsed encryption key data + * @param levelkey - a playlist's encryption info + * @param segmentNumber - the fragment's segment number + * @returns {LevelKey} - an object to be applied as a fragment's decryptdata + */ + setDecryptDataFromLevelKey (levelkey: LevelKey, segmentNumber: number): LevelKey { + let decryptdata = levelkey; + + if (levelkey && levelkey.method && levelkey.uri && !levelkey.iv) { + decryptdata = new LevelKey(levelkey.baseuri, levelkey.reluri); + decryptdata.method = levelkey.method; + decryptdata.iv = this.createInitializationVector(segmentNumber); + } + + return decryptdata; + } +} diff --git a/src/loader/level-key.js b/src/loader/level-key.js deleted file mode 100644 index 94238f627f1..00000000000 --- a/src/loader/level-key.js +++ /dev/null @@ -1,18 +0,0 @@ -import * as URLToolkit from 'url-toolkit'; - -export default class LevelKey { - constructor () { - this.method = null; - this.key = null; - this.iv = null; - this._uri = null; - } - - get uri () { - if (!this._uri && this.reluri) { - this._uri = URLToolkit.buildAbsoluteURL(this.baseuri, this.reluri, { alwaysNormalize: true }); - } - - return this._uri; - } -} diff --git a/src/loader/level-key.ts b/src/loader/level-key.ts new file mode 100644 index 00000000000..f5abc95e79e --- /dev/null +++ b/src/loader/level-key.ts @@ -0,0 +1,24 @@ +import { buildAbsoluteURL } from 'url-toolkit'; + +export default class LevelKey { + private _uri: string | null = null; + + public baseuri: string; + public reluri: string; + public method: string | null = null; + public key: Uint8Array | null = null; + public iv: Uint8Array | null = null; + + constructor (baseURI: string, relativeURI: string) { + this.baseuri = baseURI; + this.reluri = relativeURI; + } + + get uri () { + if (!this._uri && this.reluri) { + this._uri = buildAbsoluteURL(this.baseuri, this.reluri, { alwaysNormalize: true }); + } + + return this._uri; + } +} diff --git a/src/loader/m3u8-parser.js b/src/loader/m3u8-parser.js index f499a9edafd..943c84a6856 100644 --- a/src/loader/m3u8-parser.js +++ b/src/loader/m3u8-parser.js @@ -189,12 +189,11 @@ export default class M3U8Parser { frag = new Fragment(); } } else if (result[4]) { // X-BYTERANGE - frag.rawByteRange = (' ' + result[4]).slice(1); + const data = (' ' + result[4]).slice(1); if (prevFrag) { - const lastByteRangeEndOffset = prevFrag.byteRangeEndOffset; - if (lastByteRangeEndOffset) { - frag.lastByteRangeEndOffset = lastByteRangeEndOffset; - } + frag.setByteRange(data, prevFrag); + } else { + frag.setByteRange(data); } } else if (result[5]) { // PROGRAM-DATE-TIME // avoid sliced strings https://github.com/video-dev/hls.js/issues/939 @@ -276,7 +275,9 @@ export default class M3U8Parser { case 'MAP': let mapAttrs = new AttrList(value1); frag.relurl = mapAttrs.URI; - frag.rawByteRange = mapAttrs.BYTERANGE; + if (mapAttrs.BYTERANGE) { + frag.setByteRange(mapAttrs.BYTERANGE); + } frag.baseurl = baseurl; frag.level = id; frag.type = type; diff --git a/src/loader/playlist-loader.js b/src/loader/playlist-loader.js index 03ac882720d..7bcc111d005 100644 --- a/src/loader/playlist-loader.js +++ b/src/loader/playlist-loader.js @@ -407,10 +407,10 @@ class PlaylistLoader extends EventHandler { const frag = levelDetails.fragments[index]; if (frag.byteRange.length === 0) { - frag.rawByteRange = String(1 + segRefInfo.end - segRefInfo.start) + '@' + String(segRefInfo.start); + frag.setByteRange(String(1 + segRefInfo.end - segRefInfo.start) + '@' + String(segRefInfo.start)); } }); - levelDetails.initSegment.rawByteRange = String(sidxInfo.moovEndOffset) + '@0'; + levelDetails.initSegment.setByteRange(String(sidxInfo.moovEndOffset) + '@0'); } _handleManifestParsingError (response, context, reason, networkDetails) { diff --git a/tests/unit/loader/fragment.js b/tests/unit/loader/fragment.js index c172c264f76..41c359aa9c6 100644 --- a/tests/unit/loader/fragment.js +++ b/tests/unit/loader/fragment.js @@ -1,12 +1,37 @@ import Fragment from '../../../src/loader/fragment'; describe('Fragment class tests', function () { + /** + * @type {Fragment} + */ let frag; - describe('endProgramDateTime getter', function () { - beforeEach(function () { - frag = new Fragment(); + beforeEach(function () { + frag = new Fragment(); + }); + + describe('setByteRange', function () { + it('set byte range with length@offset', function () { + frag.setByteRange('1000@10000'); + expect(frag.byteRangeStartOffset).to.equal(10000); + expect(frag.byteRangeEndOffset).to.equal(11000); + }); + + it('set byte range with no offset and uses 0 as offset', function () { + frag.setByteRange('5000'); + expect(frag.byteRangeStartOffset).to.equal(0); + expect(frag.byteRangeEndOffset).to.equal(5000); }); + it('set byte range with no offset and uses 0 as offset', function () { + const prevFrag = new Fragment(); + prevFrag.setByteRange('1000@10000'); + frag.setByteRange('5000', prevFrag); + expect(frag.byteRangeStartOffset).to.equal(11000); + expect(frag.byteRangeEndOffset).to.equal(16000); + }); + }); + + describe('endProgramDateTime getter', function () { it('computes endPdt when pdt and duration are valid', function () { frag.programDateTime = 1000; frag.duration = 1;