/*! * Copyright (c) 2026-present, Vanilagy and contributors * * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ import { Logging } from '../logging'; import { getQueryParameter } from '../misc'; export const HLS_MIME_TYPE = 'application/vnd.apple.mpegurl'; export const TAG_STREAM_INF = '#EXT-X-STREAM-INF:'; export const TAG_I_FRAME_STREAM_INF = '#EXT-X-I-FRAME-STREAM-INF:'; export const TAG_MEDIA = '#EXT-X-MEDIA:'; export const TAG_DEFINE = '#EXT-X-DEFINE:'; export const TAG_EXTINF = '#EXTINF:'; export const TAG_MAP = '#EXT-X-MAP:'; export const TAG_KEY = '#EXT-X-KEY:'; export const TAG_MEDIA_SEQUENCE = '#EXT-X-MEDIA-SEQUENCE:'; export const TAG_BYTERANGE = '#EXT-X-BYTERANGE:'; export const TAG_PROGRAM_DATE_TIME = '#EXT-X-PROGRAM-DATE-TIME:'; export const TAG_DISCONTINUITY = '#EXT-X-DISCONTINUITY'; export const TAG_TARGETDURATION = '#EXT-X-TARGETDURATION:'; export const TAG_ENDLIST = '#EXT-X-ENDLIST'; export const TAG_PLAYLIST_TYPE = '#EXT-X-PLAYLIST-TYPE:'; export const TAG_I_FRAMES_ONLY = '#EXT-X-I-FRAMES-ONLY'; export const canIgnoreLine = (line: string) => line.length === 0 || (line.startsWith('#') && !line.startsWith('#EXT')); export class AttributeList { _attributes: Record = {}; constructor(str: string, variables?: HlsPlaylistVariables) { let key = ''; let value = ''; let inValue = false; let inQuotes = false; let quotedValue = false; const flushAttribute = () => { if (!key) { return; } const attributeName = key.trim().toLowerCase(); if (Object.prototype.hasOwnProperty.call(this._attributes, attributeName)) { // "AttributeName MUST NOT appear more than once in a given attribute-list. // Clients SHOULD refuse to parse such Playlists." Logging._warn(`Duplicate AttributeName "${attributeName}"; using last value`); } // quoted-string and hexadecimal-sequence AttributeValues are subject to variable substitution const shouldSubstitute = quotedValue || value.startsWith('0x') || value.startsWith('0X'); this._attributes[attributeName] = shouldSubstitute && variables ? variables.substitute(value) : value; key = ''; value = ''; inValue = false; quotedValue = false; }; for (let i = 0; i < str.length; i++) { const char = str[i]!; if (char === '"') { if (inValue && !inQuotes && value.length === 0) { quotedValue = true; } inQuotes = !inQuotes; } else if (char === '=' && !inValue && !inQuotes) { inValue = true; } else if (char === ',' && !inQuotes) { flushAttribute(); } else if (inValue) { value += char; } else { key += char; } } if (inQuotes) { throw new Error('Invalid M3U8 file; unterminated quoted-string AttributeValue.'); } flushAttribute(); } get(name: string) { return this._attributes[name.toLowerCase()] ?? null; } getAsNumber(name: string) { const value = this.get(name); if (value === null) { return null; } const num = Number(value); return Number.isFinite(num) ? num : null; } merge(other: AttributeList) { Object.assign(this._attributes, other._attributes); } } // Variable name `token_1` is referenced as `{$token_1}` const VARIABLE_NAME_PATTERN = /^[A-Za-z0-9_-]+$/; const VARIABLE_REFERENCE_PATTERN = /\{\$([A-Za-z0-9_-]+)\}/g; export class HlsPlaylistVariables { variables = new Map(); constructor( readonly playlistPath: string, readonly importedVariables: HlsPlaylistVariables | null, ) {} define(str: string) { const attributes = new AttributeList(str, this); const name = attributes.get('name'); const importedName = attributes.get('import'); const queryParameterName = attributes.get('queryparam'); const declarationCount = [name, importedName, queryParameterName] .filter(value => value !== null) .length; if (declarationCount !== 1) { throw new Error( 'Invalid #EXT-X-DEFINE tag; exactly one of NAME, IMPORT, or QUERYPARAM must be present.', ); } if (name !== null) { const value = attributes.get('value'); if (value === null) { throw new Error('Invalid #EXT-X-DEFINE tag; NAME requires VALUE.'); } this.set(name, value); } else if (importedName !== null) { const value = this.importedVariables?.get(importedName); if (value === undefined) { throw new Error(`Invalid #EXT-X-DEFINE tag; cannot import undefined variable "${importedName}".`); } this.set(importedName, value); } else if (queryParameterName !== null) { const value = getQueryParameter(this.playlistPath, queryParameterName); if (value === null) { throw new Error(`Invalid #EXT-X-DEFINE tag; query parameter "${queryParameterName}" is missing.`); } this.set(queryParameterName, value); } } substitute(value: string) { return value.replaceAll(VARIABLE_REFERENCE_PATTERN, (_reference, name: string) => { const replacement = this.variables.get(name); if (replacement === undefined) { throw new Error(`Invalid M3U8 file; variable "${name}" is referenced before being defined.`); } return replacement; }); } get(name: string) { return this.variables.get(name); } set(name: string, value: string) { if (!VARIABLE_NAME_PATTERN.test(name)) { throw new Error(`Invalid #EXT-X-DEFINE tag; invalid variable name "${name}".`); } if (this.variables.has(name)) { // "Parsers that encounter duplicate Variable Name declarations MUST fail to parse the Playlist." throw new Error(`Invalid #EXT-X-DEFINE tag; variable "${name}" is already defined.`); } this.variables.set(name, value); } }