feat: add support for #EXT-X-DEFINE (#185)

* feat: add support for EXT-X-DEFINE

* tests

* readme

* cleanup

* work with relative URLs

* missing return

* fix typo

Co-authored-by: Dzianis Dashkevich <98566601+dzianis-dashkevich@users.noreply.github.com>

* lint

* lint

---------

Co-authored-by: Dzianis Dashkevich <98566601+dzianis-dashkevich@users.noreply.github.com>
This commit is contained in:
mister-ben 2024-08-21 18:10:14 +02:00 committed by GitHub
commit ba6e7cbafe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 331 additions and 39 deletions

View file

@ -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&param_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

View file

@ -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', {

View file

@ -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() {

View file

@ -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) {
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();
QUnit.module('define', {
// https://datatracker.ietf.org/doc/html/draft-pantos-hls-rfc8216bis#section-4.4.2.3
beforeEach() {
this.errors = [];
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);
this.parser.on('error', (err) => this.errors.push(err.message));
}
});
QUnit.test('warns when #EXT-X-I-FRAME-STREAM-INF missing BANDWIDTH/URI attributes', function(assert) {
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: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('query params substituted with relative URL', function(assert) {
this.parser = new Parser({
uri: 'playlist.m3u8?aParam=aValue'
});
this.parser.push([
'#EXTM3U',
'#EXT-X-DEFINE:QUERYPARAM="aParam"',
'#EXTINF:10',
'segment.ts?replaced_param={$aParam}'
].join('\n'));
this.parser.end();
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');