UNPKG

parse-hls

Version:

Parse HLS Manifests with zero dependencies

384 lines 12.3 kB
"use strict"; 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