parse-hls
Version:
Parse HLS Manifests with zero dependencies
384 lines • 12.3 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.M3ULine = exports.Item = void 0;
const TAG_PATTERN = /#(?:-X-)?([^:]+):?(.*)$/;
const TAG_PAIR_SPLITTER = /([^,="]+)((="[^"]+")|(=[^,]+))*/g;
const coerce = {
number: (value) => {
return parseFloat(value);
},
integer: (value) => {
return parseInt(value);
},
date: (value) => {
return new Date(value);
},
string: (value) => {
return String(value).trim();
},
stringArray: (value) => {
return String(value).split(",");
},
boolean: (value) => {
return value.toUpperCase() !== "NO";
},
};
const knownKeys = {
bandwidth: "integer",
averageBandwidth: "integer",
frameRate: "number",
duration: "number",
targetduration: "number",
elapsedtime: "number",
timeOffset: "number",
codecs: "stringArray",
default: "boolean",
autoselect: "boolean",
forced: "boolean",
precise: "boolean",
programDateTime: "date",
publishedTime: "date",
startDate: "date",
endDate: "date",
version: "integer",
mediaSequence: "integer",
discontinuitySequence: "integer",
byterange: "string",
key: "string",
uri: "string",
scte35Out: "string",
id: "string",
xAdId: "string",
title: "string",
playlistType: "string",
method: "string",
iv: "string",
caid: "string",
cue: "string",
type: "string",
groupId: "string",
language: "string",
closedCaptions: "string",
subtitles: "string",
audio: "string",
video: "string",
resolution: "string",
instreamId: "string",
name: "string",
layout: "string",
allowCache: "boolean",
upid: "string",
cueIn: "boolean",
cueOut: "number",
cueOutCont: "string",
blackout: "boolean",
time: "number",
elapsed: "number",
oatclsScte35: "string",
scte35: "string",
plannedDuration: "number",
programId: "number",
endOnNext: "boolean",
class: "string",
assocLanguage: "string",
characteristics: "string",
channels: "string",
hdcpLevel: "string",
dataId: "string",
value: "string",
};
const segmentTags = [
"inf",
"programDateTime",
"key",
"cueIn",
"cueOut",
"cueOutCont",
"scte35",
"daterange",
"asset",
"tiles",
"byterange",
"discontinuity",
"map",
"beacon",
"gap",
"partInf",
"part",
"skip",
];
const renditionProperties = ["streamInf"];
const alternateRenditionsTags = [
"media",
"iFrameStreamInf",
"imageStreamInf",
];
class Item {
constructor(uri, properties) {
this.uri = uri;
this.properties = properties;
}
serialize() {
const out = { uri: this.uri };
this.properties.forEach((prop) => {
prop.name && (out[prop.name] = prop.serialize());
});
return out;
}
}
exports.Item = Item;
class M3ULine {
constructor(line) {
const matched = TAG_PATTERN.exec(line);
if (matched) {
this.tagName = matched[1];
this.name = toTagFriendlyName(matched[1]);
this.content = line;
this.attributes = parseAttributes(matched[2], this.name);
this.type = "TAG";
}
else {
this.type = "URI";
this.tagName = "URI";
this.name = "uri";
this.content = line;
this.attributes = { value: line };
}
}
serialize() {
const keys = Object.keys(this.attributes);
return keys.length === 0
? true
: keys.length === 1 && keys[0] === "value"
? this.attributes.value
: this.attributes;
}
getAttribute(key) {
return this.attributes[key];
}
setAttribute(key, value) {
this.attributes[key] = value;
return this;
}
getUri() {
return this.name === "uri"
? String(this.getAttribute("value"))
: this.getAttribute("uri")
? String(this.getAttribute("uri"))
: "";
}
}
exports.M3ULine = M3ULine;
const toTagFriendlyName = (tag) => {
if (tag.startsWith("EXT")) {
tag = tag.substr(3);
}
return toCamelCase(tag
.split(/[- ]/)
.filter((part) => part.length > 0)
.filter((part, i) => !(part === "X" && i == 0))
.join("-"));
};
const toCamelCase = (str) => {
return str.split("-").reduce((all, c) => {
if (!all) {
all += c.toLowerCase();
return all;
}
all += c.charAt(0) + c.slice(1).toLowerCase();
return all;
}, "");
};
const parseLine = (line) => new M3ULine(line);
const parseAttributes = (str, tag = "") => {
const matched = str.match(TAG_PAIR_SPLITTER);
if (matched === null) {
return {};
}
const list = (() => {
var _a;
if (tag === "inf") {
const duration = matched[0].split(" ");
let additionalPairs = [];
if (duration.length > 0) {
duration
.splice(1)
.forEach((pairStr) => additionalPairs.push(parseAttributePair(pairStr, tag)));
}
return [
{ key: "duration", value: parseFloat(duration[0]) },
{ key: "title", value: (_a = matched[1]) === null || _a === void 0 ? void 0 : _a.trim() },
...additionalPairs,
];
}
return matched.map((pairStr) => parseAttributePair(pairStr, tag));
})();
return list.reduce((all, current) => {
if (all[current.key] === undefined) {
all[current.key] = current.value;
}
return all;
}, {});
};
const parseAttributePair = (str, tag) => {
const pairs = str.trim().replace("=", "|").split("|");
if (pairs.length == 2) {
let key = toCamelCase(pairs[0]);
let value = pairs[1].replace(/("|')/g, "");
if (knownKeys.hasOwnProperty(key)) {
value = coerce[knownKeys[key]](value);
}
return {
key: key,
value: value,
};
}
const value = (() => {
if (knownKeys.hasOwnProperty(tag)) {
return coerce[knownKeys[tag]](pairs[0]);
}
return Number.isNaN(pairs[0]) ? pairs[0] : parseFloat(pairs[0]);
})();
return {
key: "value",
value,
};
};
class HLS {
constructor(content) {
this.lines = [];
this.segments = [];
this.streamRenditions = [];
this.iFrameRenditions = [];
this.imageRenditions = [];
this.audioRenditions = [];
this.alternateVideoRenditions = [];
this.subtitlesRenditions = [];
this.closedCaptionsRenditions = [];
this.totalDuration = 0;
content = content.trim();
if (!content.startsWith("#EXTM3U")) {
throw new Error("Invalid M3U8 maifest");
}
this.lines = content
.split(/\r?\n/)
.filter((line) => line.trim().length > 0)
.map((line) => parseLine(line));
this.isMaster = this.lines.some((line) => line.name === "streamInf");
this.isLive = !this.lines.some((line) => line.name === "endlist");
if (this.isMaster) {
this.streamRenditions = this.accumulateItems((line) => line.type == "URI", (line) => {
return !!(line.type === "TAG" &&
line.name &&
renditionProperties.includes(line.name));
});
this.iFrameRenditions = this.getStreamItems((line) => line.name == "iFrameStreamInf");
this.imageRenditions = this.getStreamItems((line) => line.name == "imageStreamInf");
this.audioRenditions = this.getStreamItems((line) => line.name == "media" &&
String(line.getAttribute("type")).toLocaleLowerCase() == "audio");
this.alternateVideoRenditions = this.getStreamItems((line) => line.name == "media" &&
String(line.getAttribute("type")).toLocaleLowerCase() == "video");
this.subtitlesRenditions = this.getStreamItems((line) => line.name == "media" &&
String(line.getAttribute("type")).toLocaleLowerCase() == "subtitles");
this.closedCaptionsRenditions = this.getStreamItems((line) => line.name == "media" &&
String(line.getAttribute("type")).toLocaleLowerCase() ==
"closed-captions");
}
else {
this.segments = this.accumulateItems((line) => line.type == "URI", (line) => {
return !!(line.type === "TAG" &&
line.name &&
segmentTags.includes(line.name));
});
this.totalDuration = this.segments.reduce((sum, current) => {
const duration = (() => {
var _a;
const val = Number((_a = current.properties
.find((prop) => prop.name === "inf")) === null || _a === void 0 ? void 0 : _a.getAttribute("duration"));
return isNaN(val) ? 0 : val;
})();
return sum + duration;
}, 0);
}
}
static parse(content) {
return new HLS(content);
}
get type() {
return this.isMaster ? "master" : this.isLive ? "live" : "vod";
}
get manifestProperties() {
const props = {};
this.lines
.filter((line) => line.name &&
line.type !== "URI" &&
!segmentTags.includes(line.name) &&
!alternateRenditionsTags.includes(line.name) &&
!renditionProperties.includes(line.name))
.forEach((line) => {
if (line.name) {
props[line.name] = line.serialize();
}
});
return props;
}
findAll(tag) {
tag = tag.toLocaleLowerCase();
return this.lines.filter((line) => {
var _a, _b;
return ((_a = line.tagName) === null || _a === void 0 ? void 0 : _a.toLocaleLowerCase()) == tag ||
((_b = line.name) === null || _b === void 0 ? void 0 : _b.toLocaleLowerCase()) == tag;
});
}
find(tag) {
tag = tag.toLocaleLowerCase();
return this.lines.filter((line) => {
var _a, _b;
return ((_a = line.tagName) === null || _a === void 0 ? void 0 : _a.toLocaleLowerCase()) == tag ||
((_b = line.name) === null || _b === void 0 ? void 0 : _b.toLocaleLowerCase()) == tag;
})[0];
}
serialize() {
const out = {
type: this.type,
};
if (this.isMaster) {
out.variants = this.streamRenditions.map((item) => item.serialize());
out.trickplay = {
iframes: this.iFrameRenditions.map((item) => item.serialize()),
images: this.imageRenditions.map((item) => item.serialize()),
};
out.media = {
audio: this.audioRenditions.map((item) => item.serialize()),
video: this.alternateVideoRenditions.map((item) => item.serialize()),
subtitles: this.subtitlesRenditions.map((item) => item.serialize()),
closedCaptions: this.closedCaptionsRenditions.map((item) => item.serialize()),
};
}
else {
out.segments = this.segments.map((item) => item.serialize());
out.totalDuration = this.totalDuration;
}
return Object.assign(Object.assign({}, out), this.manifestProperties);
}
getStreamItems(filter) {
return this.lines
.filter(filter)
.map((line) => new Item(line.getUri(), [line]));
}
accumulateItems(until, filter) {
let properties = [];
return this.lines.reduce((list, line) => {
if (until(line)) {
list.push(new Item(line.getUri(), properties));
properties = [];
}
else if (filter(line)) {
properties.push(line);
}
return list;
}, []);
}
}
exports.default = HLS;
//# sourceMappingURL=index.js.map