UNPKG

hls-parser

Version:

A simple library to read/write HLS playlists

1,074 lines (1,073 loc) 44.1 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); Object.defineProperty(exports, "__esModule", { value: true }); const utils = __importStar(require("./utils")); const types_1 = require("./types"); function unquote(str) { return utils.trim(str, '"'); } function getTagCategory(tagName) { switch (tagName) { case 'EXTM3U': case 'EXT-X-VERSION': case 'EXT-X-CONTENT-STEERING': return 'Basic'; case 'EXTINF': case 'EXT-X-BYTERANGE': case 'EXT-X-DISCONTINUITY': case 'EXT-X-PREFETCH-DISCONTINUITY': case 'EXT-X-KEY': case 'EXT-X-MAP': case 'EXT-X-PROGRAM-DATE-TIME': case 'EXT-X-DATERANGE': case 'EXT-X-CUE-OUT': case 'EXT-X-CUE-IN': case 'EXT-X-CUE-OUT-CONT': case 'EXT-X-CUE': case 'EXT-OATCLS-SCTE35': case 'EXT-X-ASSET': case 'EXT-X-SCTE35': case 'EXT-X-PART': case 'EXT-X-PRELOAD-HINT': case 'EXT-X-GAP': return 'Segment'; case 'EXT-X-TARGETDURATION': case 'EXT-X-MEDIA-SEQUENCE': case 'EXT-X-DISCONTINUITY-SEQUENCE': case 'EXT-X-ENDLIST': case 'EXT-X-PLAYLIST-TYPE': case 'EXT-X-I-FRAMES-ONLY': case 'EXT-X-SERVER-CONTROL': case 'EXT-X-PART-INF': case 'EXT-X-PREFETCH': case 'EXT-X-RENDITION-REPORT': case 'EXT-X-SKIP': return 'MediaPlaylist'; case 'EXT-X-MEDIA': case 'EXT-X-STREAM-INF': case 'EXT-X-I-FRAME-STREAM-INF': case 'EXT-X-SESSION-DATA': case 'EXT-X-SESSION-KEY': return 'MasterPlaylist'; case 'EXT-X-INDEPENDENT-SEGMENTS': case 'EXT-X-START': return 'MediaorMasterPlaylist'; default: return 'Unknown'; } } function parseEXTINF(param) { const pair = utils.splitAt(param, ','); return { duration: utils.toNumber(pair[0]), title: decodeURIComponent(escape(pair[1])) }; } function parseBYTERANGE(param) { const pair = utils.splitAt(param, '@'); return { length: utils.toNumber(pair[0]), offset: pair[1] ? utils.toNumber(pair[1]) : -1 }; } function parseResolution(str) { const pair = utils.splitAt(str, 'x'); return { width: utils.toNumber(pair[0]), height: utils.toNumber(pair[1]) }; } function parseAllowedCpc(str) { const message = 'ALLOWED-CPC: Each entry must consit of KEYFORMAT and Content Protection Configuration'; const list = str.split(','); if (list.length === 0) { utils.INVALIDPLAYLIST(message); } const allowedCpcList = []; for (const item of list) { const [format, cpcText] = utils.splitAt(item, ':'); if (!format || !cpcText) { utils.INVALIDPLAYLIST(message); continue; } allowedCpcList.push({ format, cpcList: cpcText.split('/') }); } return allowedCpcList; } function parseIV(str) { const iv = utils.hexToByteSequence(str); if (iv.length !== 16) { utils.INVALIDPLAYLIST('IV must be a 128-bit unsigned integer'); } return iv; } function parseUserAttribute(str) { if (str.startsWith('"')) { return unquote(str); } if (str.startsWith('0x') || str.startsWith('0X')) { return utils.hexToByteSequence(str); } return utils.toNumber(str); } function setCompatibleVersionOfKey(params, attributes) { if (attributes['IV'] && params.compatibleVersion < 2) { params.compatibleVersion = 2; } if ((attributes['KEYFORMAT'] || attributes['KEYFORMATVERSIONS']) && params.compatibleVersion < 5) { params.compatibleVersion = 5; } } function parseAttributeList(param) { const attributes = {}; for (const item of utils.splitByCommaWithPreservingQuotes(param)) { const [key, value] = utils.splitAt(item, '='); const val = unquote(value); switch (key) { case 'URI': attributes[key] = val; break; case 'START-DATE': case 'END-DATE': attributes[key] = new Date(val); break; case 'IV': attributes[key] = parseIV(val); break; case 'BYTERANGE': attributes[key] = parseBYTERANGE(val); break; case 'RESOLUTION': attributes[key] = parseResolution(val); break; case 'ALLOWED-CPC': attributes[key] = parseAllowedCpc(val); break; case 'END-ON-NEXT': case 'DEFAULT': case 'AUTOSELECT': case 'FORCED': case 'PRECISE': case 'CAN-BLOCK-RELOAD': case 'INDEPENDENT': case 'GAP': attributes[key] = val === 'YES'; break; case 'DURATION': case 'PLANNED-DURATION': case 'BANDWIDTH': case 'AVERAGE-BANDWIDTH': case 'FRAME-RATE': case 'TIME-OFFSET': case 'CAN-SKIP-UNTIL': case 'HOLD-BACK': case 'PART-HOLD-BACK': case 'PART-TARGET': case 'BYTERANGE-START': case 'BYTERANGE-LENGTH': case 'LAST-MSN': case 'LAST-PART': case 'SKIPPED-SEGMENTS': case 'SCORE': case 'PROGRAM-ID': attributes[key] = utils.toNumber(val); break; default: if (key.startsWith('SCTE35-')) { attributes[key] = utils.hexToByteSequence(val); } else if (key.startsWith('X-')) { attributes[key] = parseUserAttribute(value); } else { if (key === 'VIDEO-RANGE' && val !== 'SDR' && val !== 'HLG' && val !== 'PQ') { utils.INVALIDPLAYLIST(`VIDEO-RANGE: unknown value "${val}"`); } attributes[key] = val; } } } return attributes; } function parseTagParam(name, param) { switch (name) { case 'EXTM3U': case 'EXT-X-DISCONTINUITY': case 'EXT-X-ENDLIST': case 'EXT-X-I-FRAMES-ONLY': case 'EXT-X-INDEPENDENT-SEGMENTS': case 'EXT-X-CUE-IN': case 'EXT-X-GAP': return [null, null]; case 'EXT-X-VERSION': case 'EXT-X-TARGETDURATION': case 'EXT-X-MEDIA-SEQUENCE': case 'EXT-X-DISCONTINUITY-SEQUENCE': return [utils.toNumber(param), null]; case 'EXT-X-CUE-OUT': // For backwards compatibility: attributes list is optional, // if only a number is found, use it as the duration if (!Number.isNaN(Number(param))) { return [utils.toNumber(param), null]; } // If attributes are found, parse them out (i.e. DURATION) return [null, parseAttributeList(param)]; case 'EXT-X-KEY': case 'EXT-X-MAP': case 'EXT-X-DATERANGE': case 'EXT-X-MEDIA': case 'EXT-X-STREAM-INF': case 'EXT-X-I-FRAME-STREAM-INF': case 'EXT-X-SESSION-DATA': case 'EXT-X-SESSION-KEY': case 'EXT-X-START': case 'EXT-X-SERVER-CONTROL': case 'EXT-X-PART-INF': case 'EXT-X-PART': case 'EXT-X-PRELOAD-HINT': case 'EXT-X-RENDITION-REPORT': case 'EXT-X-SKIP': return [null, parseAttributeList(param)]; case 'EXTINF': return [parseEXTINF(param), null]; case 'EXT-X-BYTERANGE': return [parseBYTERANGE(param), null]; case 'EXT-X-PROGRAM-DATE-TIME': return [new Date(param), null]; case 'EXT-X-PLAYLIST-TYPE': return [param, null]; // <EVENT|VOD> default: return [param, null]; // Unknown tag } } function MIXEDTAGS() { utils.INVALIDPLAYLIST(`The file contains both media and master playlist tags.`); } function splitTag(line) { const index = line.indexOf(':'); if (index === -1) { return [line.slice(1).trim(), null]; } return [line.slice(1, index).trim(), line.slice(index + 1).trim()]; } function parseRendition({ attributes }) { const rendition = new types_1.Rendition({ type: attributes['TYPE'], uri: attributes['URI'], groupId: attributes['GROUP-ID'], language: attributes['LANGUAGE'], assocLanguage: attributes['ASSOC-LANGUAGE'], name: attributes['NAME'], isDefault: attributes['DEFAULT'], autoselect: attributes['AUTOSELECT'], forced: attributes['FORCED'], instreamId: attributes['INSTREAM-ID'], characteristics: attributes['CHARACTERISTICS'], channels: attributes['CHANNELS'], pathwayId: attributes['PATHWAY-ID'] }); return rendition; } function checkRedundantRendition(renditions, rendition) { let defaultFound = false; for (const item of renditions) { if (item.name === rendition.name) { return 'All EXT-X-MEDIA tags in the same Group MUST have different NAME attributes.'; } if (item.isDefault) { defaultFound = true; } } if (defaultFound && rendition.isDefault) { return 'EXT-X-MEDIA A Group MUST NOT have more than one member with a DEFAULT attribute of YES.'; } return ''; } function addRendition(variant, line, type) { const rendition = parseRendition(line); const renditions = variant[utils.camelify(type)]; const errorMessage = checkRedundantRendition(renditions, rendition); if (errorMessage) { utils.INVALIDPLAYLIST(errorMessage); } renditions.push(rendition); if (rendition.isDefault) { variant.currentRenditions[utils.camelify(type)] = renditions.length - 1; } } function matchTypes(attrs, variant, params) { for (const type of ['AUDIO', 'VIDEO', 'SUBTITLES', 'CLOSED-CAPTIONS']) { if (type === 'CLOSED-CAPTIONS' && attrs[type] === 'NONE') { params.isClosedCaptionsNone = true; variant.closedCaptions = []; } else if (attrs[type] && !variant[utils.camelify(type)].some(item => item.groupId === attrs[type])) { utils.INVALIDPLAYLIST(`${type} attribute MUST match the value of the GROUP-ID attribute of an EXT-X-MEDIA tag whose TYPE attribute is ${type}.`); } } } function parseVariant(lines, variantAttrs, uri, iFrameOnly, params) { const variant = new types_1.Variant({ uri, bandwidth: variantAttrs['BANDWIDTH'], averageBandwidth: variantAttrs['AVERAGE-BANDWIDTH'], score: variantAttrs['SCORE'], codecs: variantAttrs['CODECS'], resolution: variantAttrs['RESOLUTION'], frameRate: variantAttrs['FRAME-RATE'], hdcpLevel: variantAttrs['HDCP-LEVEL'], allowedCpc: variantAttrs['ALLOWED-CPC'], videoRange: variantAttrs['VIDEO-RANGE'], stableVariantId: variantAttrs['STABLE-VARIANT-ID'], pathwayId: variantAttrs['STABLE-PATHWAY-ID'], programId: variantAttrs['PROGRAM-ID'] }); for (const line of lines) { if (line.name === 'EXT-X-MEDIA') { const renditionAttrs = line.attributes; const renditionType = renditionAttrs['TYPE']; if (!renditionType || !renditionAttrs['GROUP-ID']) { utils.INVALIDPLAYLIST('EXT-X-MEDIA TYPE attribute is REQUIRED.'); } if (variantAttrs[renditionType] === renditionAttrs['GROUP-ID']) { addRendition(variant, line, renditionType); if (renditionType === 'CLOSED-CAPTIONS') { for (const { instreamId } of variant.closedCaptions) { if (instreamId && instreamId.startsWith('SERVICE') && params.compatibleVersion < 7) { params.compatibleVersion = 7; break; } } } } } } matchTypes(variantAttrs, variant, params); variant.isIFrameOnly = iFrameOnly; return variant; } function sameKey(key1, key2) { if (key1.method !== key2.method) { return false; } if (key1.uri !== key2.uri) { return false; } if (key1.iv) { if (!key2.iv) { return false; } if (key1.iv.byteLength !== key2.iv.byteLength) { return false; } for (let i = 0; i < key1.iv.byteLength; i++) { if (key1.iv[i] !== key2.iv[i]) { return false; } } } else if (key2.iv) { return false; } if (key1.format !== key2.format) { return false; } if (key1.formatVersion !== key2.formatVersion) { return false; } return true; } function parseMasterPlaylist(lines, params) { const playlist = new types_1.MasterPlaylist(); let variantIsScored = false; for (const [index, line] of lines.entries()) { const { name, value, attributes } = mapTo(line); if (name === 'EXT-X-VERSION') { playlist.version = value; } else if (name === 'EXT-X-CONTENT-STEERING-SERVER') { const contentSteering = new types_1.ContentSteering({ serverUri: attributes['SERVER-URI'], pathwayId: attributes['PATHWAY-ID'] }); playlist.contentSteering = contentSteering; } else if (name === 'EXT-X-STREAM-INF') { const uri = lines[index + 1]; if (typeof uri !== 'string' || uri.startsWith('#EXT')) { utils.INVALIDPLAYLIST('EXT-X-STREAM-INF must be followed by a URI line'); } const variant = parseVariant(lines, attributes, uri, false, params); if (variant) { if (typeof variant.score === 'number') { variantIsScored = true; if (variant.score < 0) { utils.INVALIDPLAYLIST('SCORE attribute on EXT-X-STREAM-INF must be positive decimal-floating-point number.'); } } playlist.variants.push(variant); } } else if (name === 'EXT-X-I-FRAME-STREAM-INF') { const variant = parseVariant(lines, attributes, attributes.URI, true, params); if (variant) { playlist.variants.push(variant); } } else if (name === 'EXT-X-SESSION-DATA') { const sessionData = new types_1.SessionData({ id: attributes['DATA-ID'], value: attributes['VALUE'], uri: attributes['URI'], language: attributes['LANGUAGE'] }); if (playlist.sessionDataList.some(item => item.id === sessionData.id && item.language === sessionData.language)) { utils.INVALIDPLAYLIST('A Playlist MUST NOT contain more than one EXT-X-SESSION-DATA tag with the same DATA-ID attribute and the same LANGUAGE attribute.'); } playlist.sessionDataList.push(sessionData); } else if (name === 'EXT-X-SESSION-KEY') { if (attributes['METHOD'] === 'NONE') { utils.INVALIDPLAYLIST('EXT-X-SESSION-KEY: The value of the METHOD attribute MUST NOT be NONE'); } const sessionKey = new types_1.Key({ method: attributes['METHOD'], uri: attributes['URI'], iv: attributes['IV'], format: attributes['KEYFORMAT'], formatVersion: attributes['KEYFORMATVERSIONS'] }); if (playlist.sessionKeyList.some(item => sameKey(item, sessionKey))) { utils.INVALIDPLAYLIST('A Master Playlist MUST NOT contain more than one EXT-X-SESSION-KEY tag with the same METHOD, URI, IV, KEYFORMAT, and KEYFORMATVERSIONS attribute values.'); } setCompatibleVersionOfKey(params, attributes); playlist.sessionKeyList.push(sessionKey); } else if (name === 'EXT-X-INDEPENDENT-SEGMENTS') { if (playlist.independentSegments) { utils.INVALIDPLAYLIST('EXT-X-INDEPENDENT-SEGMENTS tag MUST NOT appear more than once in a Playlist'); } playlist.independentSegments = true; } else if (name === 'EXT-X-START') { if (playlist.start) { utils.INVALIDPLAYLIST('EXT-X-START tag MUST NOT appear more than once in a Playlist'); } if (typeof attributes['TIME-OFFSET'] !== 'number') { utils.INVALIDPLAYLIST('EXT-X-START: TIME-OFFSET attribute is REQUIRED'); } playlist.start = { offset: attributes['TIME-OFFSET'], precise: attributes['PRECISE'] || false }; } } if (variantIsScored) { for (const variant of playlist.variants) { if (typeof variant.score !== 'number') { utils.INVALIDPLAYLIST('If any Variant Stream contains the SCORE attribute, then all Variant Streams in the Master Playlist SHOULD have a SCORE attribute'); } } } if (params.isClosedCaptionsNone) { for (const variant of playlist.variants) { if (variant.closedCaptions.length > 0) { utils.INVALIDPLAYLIST('If there is a variant with CLOSED-CAPTIONS attribute of NONE, all EXT-X-STREAM-INF tags MUST have this attribute with a value of NONE'); } } } return playlist; } function parseSegment(lines, uri, start, end, mediaSequenceNumber, discontinuitySequence, params) { const segment = new types_1.Segment({ uri, mediaSequenceNumber, discontinuitySequence }); let mapHint = false; let partHint = false; for (let i = start; i <= end; i++) { const { name, value, attributes } = mapTo(lines[i]); if (name === 'EXTINF') { if (!Number.isInteger(value.duration) && params.compatibleVersion < 3) { params.compatibleVersion = 3; } if (Math.round(value.duration) > params.targetDuration) { utils.INVALIDPLAYLIST('EXTINF duration, when rounded to the nearest integer, MUST be less than or equal to the target duration'); } segment.duration = value.duration; segment.title = value.title; } else if (name === 'EXT-X-BYTERANGE') { if (params.compatibleVersion < 4) { params.compatibleVersion = 4; } segment.byterange = value; } else if (name === 'EXT-X-DISCONTINUITY') { if (segment.parts.length > 0) { utils.INVALIDPLAYLIST('EXT-X-DISCONTINUITY must appear before the first EXT-X-PART tag of the Parent Segment.'); } segment.discontinuity = true; } else if (name === 'EXT-X-GAP') { if (params.compatibleVersion < 8) { params.compatibleVersion = 8; } segment.gap = true; } else if (name === 'EXT-X-KEY') { if (segment.parts.length > 0) { utils.INVALIDPLAYLIST('EXT-X-KEY must appear before the first EXT-X-PART tag of the Parent Segment.'); } setCompatibleVersionOfKey(params, attributes); segment.key = new types_1.Key({ method: attributes['METHOD'], uri: attributes['URI'], iv: attributes['IV'], format: attributes['KEYFORMAT'], formatVersion: attributes['KEYFORMATVERSIONS'] }); } else if (name === 'EXT-X-MAP') { if (segment.parts.length > 0) { utils.INVALIDPLAYLIST('EXT-X-MAP must appear before the first EXT-X-PART tag of the Parent Segment.'); } if (params.compatibleVersion < 5) { params.compatibleVersion = 5; } params.hasMap = true; segment.map = new types_1.MediaInitializationSection({ uri: attributes['URI'], byterange: attributes['BYTERANGE'] }); } else if (name === 'EXT-X-PROGRAM-DATE-TIME') { segment.programDateTime = value; } else if (name === 'EXT-X-DATERANGE') { const attrs = {}; for (const key of Object.keys(attributes)) { if (key.startsWith('SCTE35-') || key.startsWith('X-')) { attrs[key] = attributes[key]; } } segment.dateRange = new types_1.DateRange({ id: attributes['ID'], classId: attributes['CLASS'], start: attributes['START-DATE'], end: attributes['END-DATE'], duration: attributes['DURATION'], plannedDuration: attributes['PLANNED-DURATION'], endOnNext: attributes['END-ON-NEXT'], attributes: attrs }); } else if (name === 'EXT-X-CUE-OUT') { segment.markers.push(new types_1.SpliceInfo({ type: 'OUT', duration: (attributes && attributes.DURATION) || value })); } else if (name === 'EXT-X-CUE-IN') { segment.markers.push(new types_1.SpliceInfo({ type: 'IN' })); } else if (name === 'EXT-X-CUE-OUT-CONT' || name === 'EXT-X-CUE' || name === 'EXT-OATCLS-SCTE35' || name === 'EXT-X-ASSET' || name === 'EXT-X-SCTE35') { segment.markers.push(new types_1.SpliceInfo({ type: 'RAW', tagName: name, value })); } else if (name === 'EXT-X-PRELOAD-HINT' && !attributes['TYPE']) { utils.INVALIDPLAYLIST('EXT-X-PRELOAD-HINT: TYPE attribute is mandatory'); } else if (name === 'EXT-X-PRELOAD-HINT' && attributes['TYPE'] === 'PART' && partHint) { utils.INVALIDPLAYLIST('Servers should not add more than one EXT-X-PRELOAD-HINT tag with the same TYPE attribute to a Playlist.'); } else if ((name === 'EXT-X-PART' || name === 'EXT-X-PRELOAD-HINT') && !attributes['URI']) { utils.INVALIDPLAYLIST('EXT-X-PART / EXT-X-PRELOAD-HINT: URI attribute is mandatory'); } else if (name === 'EXT-X-PRELOAD-HINT' && attributes['TYPE'] === 'MAP') { if (mapHint) { utils.INVALIDPLAYLIST('Servers should not add more than one EXT-X-PRELOAD-HINT tag with the same TYPE attribute to a Playlist.'); } mapHint = true; params.hasMap = true; segment.map = new types_1.MediaInitializationSection({ hint: true, uri: attributes['URI'], byterange: { length: attributes['BYTERANGE-LENGTH'], offset: attributes['BYTERANGE-START'] || 0 } }); } else if (name === 'EXT-X-PART' || (name === 'EXT-X-PRELOAD-HINT' && attributes['TYPE'] === 'PART')) { if (name === 'EXT-X-PART' && !attributes['DURATION']) { utils.INVALIDPLAYLIST('EXT-X-PART: DURATION attribute is mandatory'); } if (name === 'EXT-X-PRELOAD-HINT') { partHint = true; } const partialSegment = new types_1.PartialSegment({ hint: (name === 'EXT-X-PRELOAD-HINT'), uri: attributes['URI'], byterange: (name === 'EXT-X-PART' ? attributes['BYTERANGE'] : { length: attributes['BYTERANGE-LENGTH'], offset: attributes['BYTERANGE-START'] || 0 }), duration: attributes['DURATION'], independent: attributes['INDEPENDENT'], gap: attributes['GAP'] }); if (segment.gap && !partialSegment.gap) { // https://datatracker.ietf.org/doc/html/draft-pantos-hls-rfc8216bis#section-6.2.1 utils.INVALIDPLAYLIST('Partial segments must have GAP=YES if they are in a gap (EXT-X-GAP)'); } segment.parts.push(partialSegment); } } return segment; } function parsePrefetchSegment(lines, uri, start, end, mediaSequenceNumber, discontinuitySequence, params) { const segment = new types_1.PrefetchSegment({ uri, mediaSequenceNumber, discontinuitySequence }); for (let i = start; i <= end; i++) { const { name, attributes } = lines[i]; if (name === 'EXTINF') { utils.INVALIDPLAYLIST('A prefetch segment must not be advertised with an EXTINF tag.'); } else if (name === 'EXT-X-DISCONTINUITY') { utils.INVALIDPLAYLIST('A prefetch segment must not be advertised with an EXT-X-DISCONTINUITY tag.'); } else if (name === 'EXT-X-PREFETCH-DISCONTINUITY') { segment.discontinuity = true; } else if (name === 'EXT-X-KEY') { setCompatibleVersionOfKey(params, attributes); segment.key = new types_1.Key({ method: attributes['METHOD'], uri: attributes['URI'], iv: attributes['IV'], format: attributes['KEYFORMAT'], formatVersion: attributes['KEYFORMATVERSIONS'] }); } else if (name === 'EXT-X-MAP') { utils.INVALIDPLAYLIST('Prefetch segments must not be advertised with an EXT-X-MAP tag.'); } } return segment; } function parseMediaPlaylist(lines, params) { const playlist = new types_1.MediaPlaylist(); let segmentStart = -1; let mediaSequence = 0; let discontinuityFound = false; let prefetchFound = false; let discontinuitySequence = 0; let currentKey = null; let currentMap = null; let containsParts = false; for (const [index, line] of lines.entries()) { const { name, value, attributes, category } = mapTo(line); if (category === 'Segment') { if (segmentStart === -1) { segmentStart = index; } if (name === 'EXT-X-DISCONTINUITY') { discontinuityFound = true; } continue; } if (name === 'EXT-X-VERSION') { if (playlist.version === undefined) { playlist.version = value; } else { utils.INVALIDPLAYLIST('A Playlist file MUST NOT contain more than one EXT-X-VERSION tag.'); } } else if (name === 'EXT-X-TARGETDURATION') { playlist.targetDuration = params.targetDuration = value; } else if (name === 'EXT-X-MEDIA-SEQUENCE') { if (playlist.segments.length > 0) { utils.INVALIDPLAYLIST('The EXT-X-MEDIA-SEQUENCE tag MUST appear before the first Media Segment in the Playlist.'); } playlist.mediaSequenceBase = mediaSequence = value; } else if (name === 'EXT-X-DISCONTINUITY-SEQUENCE') { if (playlist.segments.length > 0) { utils.INVALIDPLAYLIST('The EXT-X-DISCONTINUITY-SEQUENCE tag MUST appear before the first Media Segment in the Playlist.'); } if (discontinuityFound) { utils.INVALIDPLAYLIST('The EXT-X-DISCONTINUITY-SEQUENCE tag MUST appear before any EXT-X-DISCONTINUITY tag.'); } playlist.discontinuitySequenceBase = discontinuitySequence = value; } else if (name === 'EXT-X-ENDLIST') { playlist.endlist = true; } else if (name === 'EXT-X-PLAYLIST-TYPE') { playlist.playlistType = value; } else if (name === 'EXT-X-I-FRAMES-ONLY') { if (params.compatibleVersion < 4) { params.compatibleVersion = 4; } playlist.isIFrame = true; } else if (name === 'EXT-X-INDEPENDENT-SEGMENTS') { if (playlist.independentSegments) { utils.INVALIDPLAYLIST('EXT-X-INDEPENDENT-SEGMENTS tag MUST NOT appear more than once in a Playlist'); } playlist.independentSegments = true; } else if (name === 'EXT-X-START') { if (playlist.start) { utils.INVALIDPLAYLIST('EXT-X-START tag MUST NOT appear more than once in a Playlist'); } if (typeof attributes['TIME-OFFSET'] !== 'number') { utils.INVALIDPLAYLIST('EXT-X-START: TIME-OFFSET attribute is REQUIRED'); } playlist.start = { offset: attributes['TIME-OFFSET'], precise: attributes['PRECISE'] || false }; } else if (name === 'EXT-X-SERVER-CONTROL') { if (!attributes['CAN-BLOCK-RELOAD']) { utils.INVALIDPLAYLIST('EXT-X-SERVER-CONTROL: CAN-BLOCK-RELOAD=YES is mandatory for Low-Latency HLS'); } playlist.lowLatencyCompatibility = { canBlockReload: attributes['CAN-BLOCK-RELOAD'], canSkipUntil: attributes['CAN-SKIP-UNTIL'], holdBack: attributes['HOLD-BACK'], partHoldBack: attributes['PART-HOLD-BACK'] }; } else if (name === 'EXT-X-PART-INF') { if (!attributes['PART-TARGET']) { utils.INVALIDPLAYLIST('EXT-X-PART-INF: PART-TARGET attribute is mandatory'); } playlist.partTargetDuration = attributes['PART-TARGET']; } else if (name === 'EXT-X-RENDITION-REPORT') { if (!attributes['URI']) { utils.INVALIDPLAYLIST('EXT-X-RENDITION-REPORT: URI attribute is mandatory'); } if (attributes['URI'].search(/^[a-z]+:/) === 0) { utils.INVALIDPLAYLIST('EXT-X-RENDITION-REPORT: URI must be relative to the playlist uri'); } playlist.renditionReports.push(new types_1.RenditionReport({ uri: attributes['URI'], lastMSN: attributes['LAST-MSN'], lastPart: attributes['LAST-PART'] })); } else if (name === 'EXT-X-SKIP') { if (!attributes['SKIPPED-SEGMENTS']) { utils.INVALIDPLAYLIST('EXT-X-SKIP: SKIPPED-SEGMENTS attribute is mandatory'); } if (params.compatibleVersion < 9) { params.compatibleVersion = 9; } playlist.skip = attributes['SKIPPED-SEGMENTS']; mediaSequence += playlist.skip; } else if (name === 'EXT-X-PREFETCH') { const segment = parsePrefetchSegment(lines, value, segmentStart === -1 ? index : segmentStart, index - 1, mediaSequence++, discontinuitySequence, params); if (segment) { if (segment.discontinuity) { segment.discontinuitySequence++; discontinuitySequence = segment.discontinuitySequence; } if (segment.key) { currentKey = segment.key; } else { segment.key = currentKey; } playlist.prefetchSegments.push(segment); } prefetchFound = true; segmentStart = -1; } else if (typeof line === 'string') { // uri if (segmentStart === -1) { utils.INVALIDPLAYLIST('A URI line is not preceded by any segment tags'); } if (!playlist.targetDuration) { utils.INVALIDPLAYLIST('The EXT-X-TARGETDURATION tag is REQUIRED'); } if (prefetchFound) { utils.INVALIDPLAYLIST('These segments must appear after all complete segments.'); } const segment = parseSegment(lines, line, segmentStart, index - 1, mediaSequence++, discontinuitySequence, params); if (segment) { [discontinuitySequence, currentKey, currentMap] = addSegment(playlist, segment, discontinuitySequence, currentKey, currentMap); if (!containsParts && segment.parts.length > 0) { containsParts = true; } } segmentStart = -1; } } if (segmentStart !== -1) { const segment = parseSegment(lines, '', segmentStart, lines.length - 1, mediaSequence++, discontinuitySequence, params); if (segment) { const { parts } = segment; if (parts.length > 0 && !playlist.endlist && !parts.at(-1)?.hint) { utils.INVALIDPLAYLIST('If the Playlist contains EXT-X-PART tags and does not contain an EXT-X-ENDLIST tag, the Playlist must contain an EXT-X-PRELOAD-HINT tag with a TYPE=PART attribute'); } // @ts-expect-error TODO check if this is not a bug the third argument should be a discontinuitySequence addSegment(playlist, segment, currentKey, currentMap); if (!containsParts && segment.parts.length > 0) { containsParts = true; } } } checkDateRange(playlist.segments); if (playlist.lowLatencyCompatibility) { checkLowLatencyCompatibility(playlist, containsParts); } return playlist; } function addSegment(playlist, segment, discontinuitySequence, currentKey, currentMap) { const { discontinuity, key, map, byterange, uri } = segment; if (discontinuity) { segment.discontinuitySequence = discontinuitySequence + 1; } if (!key) { segment.key = currentKey; } if (!map) { segment.map = currentMap; } if (byterange && byterange.offset === -1) { const { segments } = playlist; if (segments.length > 0) { const prevSegment = segments.at(-1); if (prevSegment.byterange && prevSegment.uri === uri) { byterange.offset = prevSegment.byterange.offset + prevSegment.byterange.length; } else { utils.INVALIDPLAYLIST('If offset of EXT-X-BYTERANGE is not present, a previous Media Segment MUST be a sub-range of the same media resource'); } } else { utils.INVALIDPLAYLIST('If offset of EXT-X-BYTERANGE is not present, a previous Media Segment MUST appear in the Playlist file'); } } playlist.segments.push(segment); return [segment.discontinuitySequence, segment.key, segment.map]; } function checkDateRange(segments) { const earliestDates = new Map(); const rangeList = new Map(); let hasDateRange = false; let hasProgramDateTime = false; for (let i = segments.length - 1; i >= 0; i--) { const { programDateTime, dateRange } = segments[i]; if (programDateTime) { hasProgramDateTime = true; } if (dateRange && dateRange.start) { hasDateRange = true; if (dateRange.endOnNext && (dateRange.end || dateRange.duration)) { utils.INVALIDPLAYLIST('An EXT-X-DATERANGE tag with an END-ON-NEXT=YES attribute MUST NOT contain DURATION or END-DATE attributes.'); } const start = dateRange.start.getTime(); const duration = dateRange.duration || 0; if (dateRange.end && dateRange.duration) { if ((start + duration * 1000) !== dateRange.end.getTime()) { utils.INVALIDPLAYLIST('END-DATE MUST be equal to the value of the START-DATE attribute plus the value of the DURATION'); } } if (dateRange.endOnNext) { dateRange.end = earliestDates.get(dateRange.classId); } earliestDates.set(dateRange.classId, dateRange.start); const end = dateRange.end ? dateRange.end.getTime() : dateRange.start.getTime() + (dateRange.duration || 0) * 1000; const range = rangeList.get(dateRange.classId); if (range) { for (const entry of range) { if ((entry.start <= start && entry.end > start) || (entry.start >= start && entry.start < end)) { utils.INVALIDPLAYLIST('DATERANGE tags with the same CLASS should not overlap'); } } range.push({ start, end }); } else if (dateRange.classId) { rangeList.set(dateRange.classId, [{ start, end }]); } } } if (hasDateRange && !hasProgramDateTime) { utils.INVALIDPLAYLIST('If a Playlist contains an EXT-X-DATERANGE tag, it MUST also contain at least one EXT-X-PROGRAM-DATE-TIME tag.'); } } function checkLowLatencyCompatibility({ lowLatencyCompatibility, targetDuration, partTargetDuration, segments, renditionReports }, containsParts) { const { canSkipUntil, holdBack, partHoldBack } = lowLatencyCompatibility; if (canSkipUntil < targetDuration * 6) { utils.INVALIDPLAYLIST('The Skip Boundary must be at least six times the EXT-X-TARGETDURATION.'); } // Its value is a floating-point number of seconds and . if (holdBack < targetDuration * 3) { utils.INVALIDPLAYLIST('HOLD-BACK must be at least three times the EXT-X-TARGETDURATION.'); } if (containsParts) { if (partTargetDuration === undefined) { utils.INVALIDPLAYLIST('EXT-X-PART-INF is required if a Playlist contains one or more EXT-X-PART tags'); } if (partHoldBack === undefined) { utils.INVALIDPLAYLIST('EXT-X-PART: PART-HOLD-BACK attribute is mandatory'); } if (partHoldBack < partTargetDuration) { utils.INVALIDPLAYLIST('PART-HOLD-BACK must be at least PART-TARGET'); } for (const [segmentIndex, { parts }] of segments.entries()) { if (parts.length > 0 && segmentIndex < segments.length - 3) { utils.INVALIDPLAYLIST('Remove EXT-X-PART tags from the Playlist after they are greater than three target durations from the end of the Playlist.'); } for (const [partIndex, { duration }] of parts.entries()) { if (duration === undefined) { continue; } if (duration > partTargetDuration) { utils.INVALIDPLAYLIST('PART-TARGET is the maximum duration of any Partial Segment'); } if (partIndex < parts.length - 1 && duration < partTargetDuration * 0.85) { utils.INVALIDPLAYLIST('All Partial Segments except the last part of a segment must have a duration of at least 85% of PART-TARGET'); } } } } for (const report of renditionReports) { const lastSegment = segments.at(-1); report.lastMSN ??= lastSegment.mediaSequenceNumber; if ((report.lastPart === null || report.lastPart === undefined) && lastSegment.parts.length > 0) { report.lastPart = lastSegment.parts.length - 1; } } } function CHECKTAGCATEGORY(category, params) { if (category === 'Segment' || category === 'MediaPlaylist') { if (params.isMasterPlaylist === undefined) { params.isMasterPlaylist = false; return; } if (params.isMasterPlaylist) { MIXEDTAGS(); } return; } if (category === 'MasterPlaylist') { if (params.isMasterPlaylist === undefined) { params.isMasterPlaylist = true; return; } if (params.isMasterPlaylist === false) { MIXEDTAGS(); } } // category === 'Basic' or 'MediaorMasterPlaylist' or 'Unknown' } function parseTag(line, params) { const [name, param] = splitTag(line); const category = getTagCategory(name); CHECKTAGCATEGORY(category, params); if (category === 'Unknown') { return null; } if (category === 'MediaPlaylist' && name !== 'EXT-X-RENDITION-REPORT' && name !== 'EXT-X-PREFETCH') { if (params.hash[name]) { utils.INVALIDPLAYLIST('There MUST NOT be more than one Media Playlist tag of each type in any Media Playlist'); } params.hash[name] = true; } const [value, attributes] = parseTagParam(name, param); return { name, category, value, attributes }; } function lexicalParse(text, params) { const lines = []; for (const l of text.split('\n')) { // V8 has garbage collection issues when cleaning up substrings split from strings greater // than 13 characters so before we continue we need to safely copy over each line so that it // doesn't hold any reference to the containing string. const line = l.trim(); if (!line) { // empty line continue; } if (line.startsWith('#')) { if (line.startsWith('#EXT')) { // tag const tag = parseTag(line, params); if (tag) { lines.push(tag); } } // comment continue; } // uri lines.push(line); } if (lines.length === 0 || lines[0].name !== 'EXTM3U') { utils.INVALIDPLAYLIST('The EXTM3U tag MUST be the first line.'); } return lines; } function semanticParse(lines, params) { let playlist; if (params.isMasterPlaylist) { playlist = parseMasterPlaylist(lines, params); } else { playlist = parseMediaPlaylist(lines, params); if (!playlist.isIFrame && params.hasMap && params.compatibleVersion < 6) { params.compatibleVersion = 6; } } if (params.compatibleVersion > 1) { if (!playlist.version || playlist.version < params.compatibleVersion) { utils.INVALIDPLAYLIST(`EXT-X-VERSION needs to be ${params.compatibleVersion} or higher.`); } } return playlist; } function parse(text) { const params = { version: undefined, isMasterPlaylist: undefined, hasMap: false, targetDuration: 0, compatibleVersion: 1, isClosedCaptionsNone: false, hash: {} }; const lines = lexicalParse(text, params); const playlist = semanticParse(lines, params); playlist.source = text; return playlist; } function mapTo(value) { return typeof value === 'string' ? {} : value; } exports.default = parse;