Skip to content

Commit

Permalink
feat: add support for #EXT-X-DEFINE (#185)
Browse files Browse the repository at this point in the history
* feat: add support for EXT-X-DEFINE

* tests

* readme

* cleanup

* work with relative URLs

* missing return

* fix typo

Co-authored-by: Dzianis Dashkevich <[email protected]>

* lint

* lint

---------

Co-authored-by: Dzianis Dashkevich <[email protected]>
  • Loading branch information
mister-ben and dzianis-dashkevich authored Aug 21, 2024
1 parent e5dbdb6 commit ba6e7cb
Show file tree
Hide file tree
Showing 4 changed files with 331 additions and 39 deletions.
22 changes: 20 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
10 changes: 10 additions & 0 deletions src/parse-stream.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', {
Expand Down
119 changes: 117 additions & 2 deletions src/parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -750,6 +864,7 @@ export default class Parser extends Stream {
['BANDWIDTH', 'URI']
);
}

})[entry.tagType] || noop).call(self);
},
uri() {
Expand Down
Loading

0 comments on commit ba6e7cb

Please sign in to comment.