diff --git a/README.md b/README.md index b78815b..105041a 100644 --- a/README.md +++ b/README.md @@ -13,12 +13,13 @@ m3u8 parser - [Installation](#installation) - [Usage](#usage) + - [Constructor Options](#constructor-options) - [Parsed Output](#parsed-output) - [Supported Tags](#supported-tags) - [Basic Playlist Tags](#basic-playlist-tags) - [Media Segment Tags](#media-segment-tags) - [Media Playlist Tags](#media-playlist-tags) - - [Master Playlist Tags](#master-playlist-tags) + - [Main Playlist Tags](#main-playlist-tags) - [Experimental Tags](#experimental-tags) - [EXT-X-CUE-OUT](#ext-x-cue-out) - [EXT-X-CUE-OUT-CONT](#ext-x-cue-out-cont) @@ -70,6 +71,21 @@ parser.end(); var parsedManifest = parser.manifest; ``` +### Constructor Options + +The constructor optinally takes an options object with two properties. These are needed when using `#EXT-X-DEFINE` for variable replacement. + +```js +var parser = new m3u8Parser.Parser({ + url: 'https://exmaple.com/video.m3u8?param_a=34¶m_b=abc', + mainDefinitions: { + param_c: 'def' + } +}); +``` + +* `options.url` _string_ The URL from which the playlist was fetched. If the request was redirected this should be the final URL. This is required if using `QUERYSTRING` rules with `#EXT-X-DEFINE`. +* `options.mainDefinitions` _object_ An object of definitions from the main playlist. This is required if using `IMPORT` rules with `#EXT-X-DEFINE`. ### Parsed Output @@ -174,13 +190,15 @@ Manifest { * [EXT-X-PLAYLIST-TYPE](http://tools.ietf.org/html/draft-pantos-http-live-streaming#section-4.3.3.5) * [EXT-X-START](http://tools.ietf.org/html/draft-pantos-http-live-streaming#section-4.3.5.2) * [EXT-X-INDEPENDENT-SEGMENTS](http://tools.ietf.org/html/draft-pantos-http-live-streaming#section-4.3.5.1) +* [EXT-X-DEFINE](https://datatracker.ietf.org/doc/html/draft-pantos-hls-rfc8216bis#section-4.4.2.3) -### Master Playlist Tags +### Main Playlist Tags * [EXT-X-MEDIA](http://tools.ietf.org/html/draft-pantos-http-live-streaming#section-4.3.4.1) * [EXT-X-STREAM-INF](http://tools.ietf.org/html/draft-pantos-http-live-streaming#section-4.3.4.2) * [EXT-X-I-FRAME-STREAM-INF](http://tools.ietf.org/html/draft-pantos-http-live-streaming#section-4.3.4.3) * [EXT-X-CONTENT-STEERING](https://datatracker.ietf.org/doc/html/draft-pantos-hls-rfc8216bis#section-4.4.6.6) +* [EXT-X-DEFINE](https://datatracker.ietf.org/doc/html/draft-pantos-hls-rfc8216bis#section-4.4.2.3) ### Experimental Tags diff --git a/src/parse-stream.js b/src/parse-stream.js index f7fccdd..7b4eef5 100644 --- a/src/parse-stream.js +++ b/src/parse-stream.js @@ -693,6 +693,16 @@ export default class ParseStream extends Stream { return; } + match = (/^#EXT-X-DEFINE:(.*)$/).exec(newLine); + if (match) { + event = { + type: 'tag', + tagType: 'define' + }; + event.attributes = parseAttributes(match[1]); + this.trigger('data', event); + return; + } // unknown tag type this.trigger('data', { diff --git a/src/parser.js b/src/parser.js index bec4b7d..bcded03 100644 --- a/src/parser.js +++ b/src/parser.js @@ -88,15 +88,19 @@ const setHoldBack = function(manifest) { * requires some property of the manifest object to be defaulted. * * @class Parser + * @param {Object} [opts] Options for the constructor, needed for substitutions + * @param {string} [opts.uri] URL to check for query params + * @param {Object} [opts.mainDefinitions] Definitions on main playlist that can be imported * @extends Stream */ export default class Parser extends Stream { - constructor() { + constructor(opts = {}) { super(); this.lineStream = new LineStream(); this.parseStream = new ParseStream(); this.lineStream.pipe(this.parseStream); - + this.mainDefinitions = opts.mainDefinitions || {}; + this.params = new URL(opts.uri, 'https://a.com').searchParams; this.lastProgramDateTime = null; /* eslint-disable consistent-this */ @@ -164,6 +168,22 @@ export default class Parser extends Stream { let mediaGroup; let rendition; + // Replace variables in uris and attributes as defined in #EXT-X-DEFINE tags + if (self.manifest.definitions) { + for (const def in self.manifest.definitions) { + if (entry.uri) { + entry.uri = entry.uri.replace(`{$${def}}`, self.manifest.definitions[def]); + } + if (entry.attributes) { + for (const attr in entry.attributes) { + if (typeof entry.attributes[attr] === 'string') { + entry.attributes[attr] = entry.attributes[attr].replace(`{$${def}}`, self.manifest.definitions[def]); + } + } + } + } + } + ({ tag() { // switch based on the tag type @@ -737,6 +757,100 @@ export default class Parser extends Stream { ['SERVER-URI'] ); }, + + /** @this {Parser} */ + define() { + this.manifest.definitions = this.manifest.definitions || { }; + + const addDef = (n, v) => { + if (n in this.manifest.definitions) { + // An EXT-X-DEFINE tag MUST NOT specify the same Variable Name as any other + // EXT-X-DEFINE tag in the same Playlist. Parsers that encounter duplicate + // Variable Name declarations MUST fail to parse the Playlist. + this.trigger('error', { + message: `EXT-X-DEFINE: Duplicate name ${n}` + }); + return; + } + this.manifest.definitions[n] = v; + }; + + if ('QUERYPARAM' in entry.attributes) { + if ('NAME' in entry.attributes || 'IMPORT' in entry.attributes) { + // An EXT-X-DEFINE tag MUST contain either a NAME, an IMPORT, or a + // QUERYPARAM attribute, but only one of the three. Otherwise, the + // client MUST fail to parse the Playlist. + this.trigger('error', { + message: 'EXT-X-DEFINE: Invalid attributes' + }); + return; + } + const val = this.params.get(entry.attributes.QUERYPARAM); + + if (!val) { + // If the QUERYPARAM attribute value does not match any query parameter in + // the URI or the matching parameter has no associated value, the parser + // MUST fail to parse the Playlist. If more than one parameter matches, + // any of the associated values MAY be used. + this.trigger('error', { + message: `EXT-X-DEFINE: No query param ${entry.attributes.QUERYPARAM}` + }); + return; + } + addDef(entry.attributes.QUERYPARAM, decodeURIComponent(val)); + return; + } + + if ('NAME' in entry.attributes) { + if ('IMPORT' in entry.attributes) { + // An EXT-X-DEFINE tag MUST contain either a NAME, an IMPORT, or a + // QUERYPARAM attribute, but only one of the three. Otherwise, the + // client MUST fail to parse the Playlist. + this.trigger('error', { + message: 'EXT-X-DEFINE: Invalid attributes' + }); + return; + } + if (!('VALUE' in entry.attributes) || typeof entry.attributes.VALUE !== 'string') { + // This attribute is REQUIRED if the EXT-X-DEFINE tag has a NAME attribute. + // The quoted-string MAY be empty. + this.trigger('error', { + message: `EXT-X-DEFINE: No value for ${entry.attributes.NAME}` + }); + return; + } + addDef(entry.attributes.NAME, entry.attributes.VALUE); + return; + } + + if ('IMPORT' in entry.attributes) { + if (!this.mainDefinitions[entry.attributes.IMPORT]) { + // Covers two conditions, as mainDefinitions will always be empty on main + // + // EXT-X-DEFINE tags containing the IMPORT attribute MUST NOT occur in + // Multivariant Playlists; they are only allowed in Media Playlists. + // + // If the IMPORT attribute value does not match any Variable Name in the + // Multivariant Playlist, or if the Media Playlist loaded from a + // Multivariant Playlist, the parser MUST fail the Playlist. + this.trigger('error', { + message: `EXT-X-DEFINE: No value ${entry.attributes.IMPORT} to import, or IMPORT used on main playlist` + }); + return; + } + addDef(entry.attributes.IMPORT, this.mainDefinitions[entry.attributes.IMPORT]); + return; + + } + + // An EXT-X-DEFINE tag MUST contain either a NAME, an IMPORT, or a QUERYPARAM + // attribute, but only one of the three. Otherwise, the client MUST fail to + // parse the Playlist. + this.trigger('error', { + message: 'EXT-X-DEFINE: No attribute' + }); + }, + 'i-frame-playlist'() { this.manifest.iFramePlaylists.push({ attributes: entry.attributes, @@ -750,6 +864,7 @@ export default class Parser extends Stream { ['BANDWIDTH', 'URI'] ); } + })[entry.tagType] || noop).call(self); }, uri() { diff --git a/test/parser.test.js b/test/parser.test.js index 4993e69..cad4a59 100644 --- a/test/parser.test.js +++ b/test/parser.test.js @@ -1158,6 +1158,57 @@ QUnit.module('m3u8s', function(hooks) { assert.equal(this.parser.manifest.independentSegments, true); }); + QUnit.test('parses #EXT-X-I-FRAME-STREAM-INF', function(assert) { + this.parser.push([ + '#EXTM3U', + '#EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=86000,URI="low/iframe.m3u8"', + '#EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=150000,URI="mid/iframe.m3u8"', + '#EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=550000,URI="hi/iframe.m3u8"', + '#EXT-X-STREAM-INF:BANDWIDTH=1280000', + 'low/audio-video.m3u8', + '#EXT-X-STREAM-INF:BANDWIDTH=2560000', + 'mid/audio-video.m3u8', + '#EXT-X-STREAM-INF:BANDWIDTH=7680000', + 'hi/audio-video.m3u8', + '#EXT-X-STREAM-INF:BANDWIDTH=65000,CODECS="mp4a.40.5"', + 'audio-only.m3u8' + ].join('\n')); + this.parser.end(); + + assert.equal(this.parser.manifest.iFramePlaylists.length, 3); + assert.equal(this.parser.manifest.iFramePlaylists[0].uri, 'low/iframe.m3u8'); + assert.strictEqual(this.parser.manifest.iFramePlaylists[0].attributes.BANDWIDTH, 86000); + }); + + QUnit.test('warns when #EXT-X-I-FRAME-STREAM-INF missing BANDWIDTH/URI attributes', function(assert) { + this.parser.push([ + '#EXTM3U', + '#EXT-X-I-FRAME-STREAM-INF:URI="low/iframe.m3u8"', + '#EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=150000,URI="mid/iframe.m3u8"', + '#EXT-X-I-FRAME-STREAM-INF:', + '#EXT-X-STREAM-INF:BANDWIDTH=1280000', + 'low/audio-video.m3u8', + '#EXT-X-STREAM-INF:BANDWIDTH=2560000', + 'mid/audio-video.m3u8', + '#EXT-X-STREAM-INF:BANDWIDTH=7680000', + 'hi/audio-video.m3u8', + '#EXT-X-STREAM-INF:BANDWIDTH=65000,CODECS="mp4a.40.5"', + 'audio-only.m3u8' + ].join('\n')); + this.parser.end(); + + const warnings = [ + '#EXT-X-I-FRAME-STREAM-INF lacks required attribute(s): BANDWIDTH', + '#EXT-X-I-FRAME-STREAM-INF lacks required attribute(s): BANDWIDTH, URI' + ]; + + assert.deepEqual( + this.warnings, + warnings, + 'warnings as expected' + ); + }); + QUnit.test('warns when #EXT-X-I-FRAMES-ONLY the minimum version required is not supported', function(assert) { this.parser.push([ '#EXTM3U', @@ -1256,55 +1307,153 @@ QUnit.module('m3u8s', function(hooks) { assert.deepEqual(this.warnings, warning, 'warnings as expected'); }); - QUnit.test('parses #EXT-X-I-FRAME-STREAM-INF', function(assert) { + QUnit.module('define', { + // https://datatracker.ietf.org/doc/html/draft-pantos-hls-rfc8216bis#section-4.4.2.3 + beforeEach() { + this.errors = []; + + this.parser.on('error', (err) => this.errors.push(err.message)); + } + }); + + QUnit.test('fails on missing attributes', function(assert) { + const err = ['EXT-X-DEFINE: No attribute']; + + this.parser.push('#EXT-X-DEFINE:'); + this.parser.end(); + assert.deepEqual(this.errors, err, 'errors as expected'); + }); + + QUnit.test('fails on disallowed combinatons', function(assert) { + const permutations = [ + '#EXT-X-DEFINE:NAME="a",QUERYPARAM="b"', + '#EXT-X-DEFINE:NAME="a",IMPORT="b"', + '#EXT-X-DEFINE:QUERYPARAM="a",IMPORT="b"', + '#EXT-X-DEFINE:NAME="a",QUERYPARAM="b",IMPORT="c"' + ]; + + assert.expect(permutations.length); + + permutations.forEach((p) => { + this.parser = new Parser(); + this.parser.on('error', (e) => { + assert.equal(e.message, 'EXT-X-DEFINE: Invalid attributes', `${p} errors as expected`); + }); + this.parser.push(p); + this.parser.end(); + }); + }); + + QUnit.test('query params substituted', function(assert) { + this.parser = new Parser({ + uri: 'https://example.com?aParam=aValue' + }); this.parser.push([ '#EXTM3U', - '#EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=86000,URI="low/iframe.m3u8"', - '#EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=150000,URI="mid/iframe.m3u8"', - '#EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=550000,URI="hi/iframe.m3u8"', - '#EXT-X-STREAM-INF:BANDWIDTH=1280000', - 'low/audio-video.m3u8', - '#EXT-X-STREAM-INF:BANDWIDTH=2560000', - 'mid/audio-video.m3u8', - '#EXT-X-STREAM-INF:BANDWIDTH=7680000', - 'hi/audio-video.m3u8', - '#EXT-X-STREAM-INF:BANDWIDTH=65000,CODECS="mp4a.40.5"', - 'audio-only.m3u8' + '#EXT-X-DEFINE:QUERYPARAM="aParam"', + '#EXTINF:10', + 'segment.ts?replaced_param={$aParam}' ].join('\n')); this.parser.end(); - assert.equal(this.parser.manifest.iFramePlaylists.length, 3); - assert.equal(this.parser.manifest.iFramePlaylists[0].uri, 'low/iframe.m3u8'); - assert.strictEqual(this.parser.manifest.iFramePlaylists[0].attributes.BANDWIDTH, 86000); + assert.equal('aValue', this.parser.manifest.definitions.aParam, 'value of param stored'); + assert.equal('segment.ts?replaced_param=aValue', this.parser.manifest.segments[0].uri, 'substituted in url'); }); - QUnit.test('warns when #EXT-X-I-FRAME-STREAM-INF missing BANDWIDTH/URI attributes', function(assert) { + QUnit.test('query params substituted with relative URL', function(assert) { + this.parser = new Parser({ + uri: 'playlist.m3u8?aParam=aValue' + }); this.parser.push([ '#EXTM3U', - '#EXT-X-I-FRAME-STREAM-INF:URI="low/iframe.m3u8"', - '#EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=150000,URI="mid/iframe.m3u8"', - '#EXT-X-I-FRAME-STREAM-INF:', - '#EXT-X-STREAM-INF:BANDWIDTH=1280000', - 'low/audio-video.m3u8', - '#EXT-X-STREAM-INF:BANDWIDTH=2560000', - 'mid/audio-video.m3u8', - '#EXT-X-STREAM-INF:BANDWIDTH=7680000', - 'hi/audio-video.m3u8', - '#EXT-X-STREAM-INF:BANDWIDTH=65000,CODECS="mp4a.40.5"', - 'audio-only.m3u8' + '#EXT-X-DEFINE:QUERYPARAM="aParam"', + '#EXTINF:10', + 'segment.ts?replaced_param={$aParam}' ].join('\n')); this.parser.end(); - const warnings = [ - '#EXT-X-I-FRAME-STREAM-INF lacks required attribute(s): BANDWIDTH', - '#EXT-X-I-FRAME-STREAM-INF lacks required attribute(s): BANDWIDTH, URI' + assert.equal('aValue', this.parser.manifest.definitions.aParam, 'value of param stored'); + assert.equal('segment.ts?replaced_param=aValue', this.parser.manifest.segments[0].uri, 'substituted in url'); + }); + + QUnit.test('fails with missing query params', function(assert) { + assert.expect(1); + this.parser = new Parser({ + uri: 'https://example.com?bParam=bValue' + }); + this.parser.on('error', (e) => { + assert.equal(e.message, 'EXT-X-DEFINE: No query param aParam'); + }); + this.parser.push([ + '#EXTM3U', + '#EXT-X-DEFINE:QUERYPARAM="aParam"', + '#EXTINF:10', + 'segment.ts?replacedparam={$aParam}' + ].join('\n')); + this.parser.end(); + }); + + QUnit.test('fails on redefinition', function(assert) { + const permutations = [ + ['#EXT-X-DEFINE:NAME="a",VALUE="b"', '#EXT-X-DEFINE:NAME="a",VALUE="c"'], + ['#EXT-X-DEFINE:NAME="a",VALUE="b"', '#EXT-X-DEFINE:IMPORT="a"'], + ['#EXT-X-DEFINE:NAME="a",VALUE="b"', '#EXT-X-DEFINE:QUERYPARAM="a"'], + ['#EXT-X-DEFINE:IMPORT="a"', '#EXT-X-DEFINE:IMPORT="a"'], + ['#EXT-X-DEFINE:IMPORT="a"', '#EXT-X-DEFINE:QUERYPARAM="a"'], + ['#EXT-X-DEFINE:IMPORT="a"', '#EXT-X-DEFINE:NAME="a",VALUE="c"'], + ['#EXT-X-DEFINE:QUERYPARAM="a"', '#EXT-X-DEFINE:IMPORT="a"'], + ['#EXT-X-DEFINE:QUERYPARAM="a"', '#EXT-X-DEFINE:QUERYPARAM="a"'], + ['#EXT-X-DEFINE:QUERYPARAM="a"', '#EXT-X-DEFINE:NAME="a",VALUE="c"'] ]; - assert.deepEqual( - this.warnings, - warnings, - 'warnings as expected' - ); + assert.expect(permutations.length); + + permutations.forEach((p) => { + this.parser = new Parser({ + uri: 'https:example.com?a=1', + mainDefinitions: { + a: 2 + } + }); + this.parser.on('error', (e) => { + assert.equal(e.message, 'EXT-X-DEFINE: Duplicate name a', 'errosr on combination'); + }); + this.parser.push(p.join('\n')); + this.parser.end(); + }); + }); + + QUnit.test('fails with IMPORT on main playlist', function(assert) { + this.parser.on('error', function(e) { + assert.equal(e.message, 'EXT-X-DEFINE: No value imported_param to import, or IMPORT used on main playlist', 'fails when missing'); + }); + this.parser.push('#EXT-X-DEFINE:IMPORT="imported_param"'); + this.parser.end(); + }); + + QUnit.test('named and imported substiutions work', function(assert) { + this.parser = new Parser({ + mainDefinitions: { + aParam: 'aValue', + engLabel: 'Anglais' + } + }); + this.parser.push([ + '#EXTM3U', + '#EXT-X-DEFINE:IMPORT="aParam"', + '#EXT-X-DEFINE:NAME="bParam",VALUE="bValue"', + '#EXT-X-DEFINE:IMPORT="engLabel"', + '#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aac",LANGUAGE="eng",NAME="{$engLabel}",AUTOSELECT=YES,DEFAULT=YES,URI="eng/prog_index.m3u8?bParam={$bParam}"', + '#EXTINF:10', + 'segment.ts?aParam={$aParam}&bParam={$bParam}' + ].join('\n')); + this.parser.end(); + + assert.equal('aValue', this.parser.manifest.definitions.aParam, 'value of param from import stored'); + assert.equal('bValue', this.parser.manifest.definitions.bParam, 'value of param from name stored'); + assert.equal('segment.ts?aParam=aValue&bParam=bValue', this.parser.manifest.segments[0].uri, 'substituted in uri'); + assert.ok(this.parser.manifest.mediaGroups.AUDIO.aac.hasOwnProperty('Anglais'), 'replacement in attribute'); + assert.equal('eng/prog_index.m3u8?bParam=bValue', this.parser.manifest.mediaGroups.AUDIO.aac.Anglais.uri, 'replacement in uri in attribute'); }); QUnit.module('integration');