diff --git a/api-extractor/report/hls.js.api.md b/api-extractor/report/hls.js.api.md index 6f8d0e54f9f..27d39b1ce3c 100644 --- a/api-extractor/report/hls.js.api.md +++ b/api-extractor/report/hls.js.api.md @@ -85,7 +85,7 @@ export type ABRControllerConfig = { // // @public (undocumented) export class AttrList { - constructor(attrs: string | Record); + constructor(attrs: string | Record, parsed?: Pick); // (undocumented) [key: string]: any; // (undocumented) @@ -104,13 +104,19 @@ export class AttrList { // (undocumented) enumeratedString(attrName: string): string | undefined; // (undocumented) + enumeratedStringList(attrName: string, dict: T): { + [key in keyof T]: boolean; + }; + // (undocumented) hexadecimalInteger(attrName: string): Uint8Array | null; // (undocumented) hexadecimalIntegerAsNumber(attrName: string): number; // (undocumented) optionalFloat(attrName: string, defaultValue: number): number; // (undocumented) - static parseAttrList(input: string): Record; + static parseAttrList(input: string, parsed?: Pick): Record; } // Warning: (ae-missing-release-tag) "AudioPlaylistType" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -838,12 +844,14 @@ export interface CuesParsedData { // // @public (undocumented) export class DateRange { - constructor(dateRangeAttr: AttrList, dateRangeWithSameId?: DateRange); + constructor(dateRangeAttr: AttrList, dateRangeWithSameId?: DateRange | undefined, frag?: Fragment | null, tagCount?: number); // (undocumented) attr: AttrList; // (undocumented) get class(): string; // (undocumented) + get cue(): DateRangeCue; + // (undocumented) get duration(): number | null; // (undocumented) get endDate(): Date | null; @@ -852,13 +860,30 @@ export class DateRange { // (undocumented) get id(): string; // (undocumented) + get isInterstitial(): boolean; + // (undocumented) get isValid(): boolean; // (undocumented) get plannedDuration(): number | null; // (undocumented) get startDate(): Date; + // (undocumented) + get startTime(): number; + // (undocumented) + tagAnchor: Fragment | null; + // (undocumented) + tagOrder: number; } +// Warning: (ae-missing-release-tag) "DateRangeCue" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type DateRangeCue = { + pre: boolean; + post: boolean; + once: boolean; +}; + // Warning: (ae-missing-release-tag) "DRMSystemOptions" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -2177,6 +2202,8 @@ export class LevelDetails { // (undocumented) dateRanges: Record; // (undocumented) + dateRangeTagCount: number; + // (undocumented) deltaUpdateFailed?: boolean; // (undocumented) get drift(): number; @@ -2996,6 +3023,20 @@ export interface NonNativeTextTracksData { tracks: Array; } +// Warning: (ae-missing-release-tag) "ParsedMultivariantPlaylist" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type ParsedMultivariantPlaylist = { + contentSteering: ContentSteeringOptions | null; + levels: LevelParsed[]; + playlistParsingError: Error | null; + sessionData: Record | null; + sessionKeys: LevelKey[] | null; + startTimeOffset: number | null; + variableList: VariableMap | null; + hasVariableRefs: boolean; +}; + // Warning: (ae-missing-release-tag) "Part" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public diff --git a/src/controller/audio-stream-controller.ts b/src/controller/audio-stream-controller.ts index fc064588f16..acd4ceb2b6a 100644 --- a/src/controller/audio-stream-controller.ts +++ b/src/controller/audio-stream-controller.ts @@ -128,7 +128,9 @@ class AudioStreamController if (id === 'main') { const cc = frag.cc; this.initPTS[frag.cc] = { baseTime: initPTS, timescale }; - this.log(`InitPTS for cc: ${cc} found from main: ${initPTS}`); + this.log( + `InitPTS for cc: ${cc} found from main: ${initPTS}/${timescale}`, + ); this.videoTrackCC = cc; // If we are waiting, tick immediately to unblock audio fragment transmuxing if (this.state === State.WAITING_INIT_PTS) { diff --git a/src/controller/id3-track-controller.ts b/src/controller/id3-track-controller.ts index fb90c019128..896deb1fbe7 100644 --- a/src/controller/id3-track-controller.ts +++ b/src/controller/id3-track-controller.ts @@ -69,10 +69,6 @@ const MAX_CUE_ENDTIME = (() => { return Number.POSITIVE_INFINITY; })(); -function dateRangeDateToTimelineSeconds(date: Date, offset: number): number { - return date.getTime() / 1000 - offset; -} - function hexToArrayBuffer(str): ArrayBuffer { return Uint8Array.from( str @@ -329,26 +325,20 @@ class ID3TrackController implements ComponentAPI { this.id3Track = this.createTrack(this.media); } - const dateTimeOffset = - (lastFragment.programDateTime as number) / 1000 - lastFragment.start; const Cue = getCueClass(); - for (let i = 0; i < ids.length; i++) { const id = ids[i]; const dateRange = dateRanges[id]; - const startTime = dateRangeDateToTimelineSeconds( - dateRange.startDate, - dateTimeOffset, - ); + const startTime = dateRange.startTime; // Process DateRanges to determine end-time (known DURATION, END-DATE, or END-ON-NEXT) const appendedDateRangeCues = dateRangeCuesAppended[id]; const cues = appendedDateRangeCues?.cues || {}; let durationKnown = appendedDateRangeCues?.durationKnown || false; let endTime = MAX_CUE_ENDTIME; - const endDate = dateRange.endDate; - if (endDate) { - endTime = dateRangeDateToTimelineSeconds(endDate, dateTimeOffset); + const { duration, endDate } = dateRange; + if (endDate && duration !== null) { + endTime = startTime + duration; durationKnown = true; } else if (dateRange.endOnNext && !durationKnown) { const nextDateRangeWithSameClass = ids.reduce( @@ -369,10 +359,7 @@ class ID3TrackController implements ComponentAPI { null, ); if (nextDateRangeWithSameClass) { - endTime = dateRangeDateToTimelineSeconds( - nextDateRangeWithSameClass.startDate, - dateTimeOffset, - ); + endTime = nextDateRangeWithSameClass.startTime; durationKnown = true; } } diff --git a/src/hls.ts b/src/hls.ts index 7ae3d0ab333..a49e9e4b6cd 100644 --- a/src/hls.ts +++ b/src/hls.ts @@ -1068,7 +1068,7 @@ export type { KeySystems, KeySystemFormats, } from './utils/mediakeys-helper'; -export type { DateRange } from './loader/date-range'; +export type { DateRange, DateRangeCue } from './loader/date-range'; export type { LoadStats } from './loader/load-stats'; export type { LevelKey } from './loader/level-key'; export type { LevelDetails } from './loader/level-details'; @@ -1184,3 +1184,4 @@ export type { IErrorAction, } from './controller/error-controller'; export type { AttrList } from './utils/attr-list'; +export type { ParsedMultivariantPlaylist } from './loader/m3u8-parser'; diff --git a/src/loader/date-range.ts b/src/loader/date-range.ts index f841d8bbb7b..c1fef6517b2 100644 --- a/src/loader/date-range.ts +++ b/src/loader/date-range.ts @@ -1,10 +1,12 @@ import { AttrList } from '../utils/attr-list'; import { logger } from '../utils/logger'; +import type { Fragment } from './fragment'; // Avoid exporting const enum so that these values can be inlined const enum DateRangeAttribute { ID = 'ID', CLASS = 'CLASS', + CUE = 'CUE', START_DATE = 'START-DATE', DURATION = 'DURATION', END_DATE = 'END-DATE', @@ -12,12 +14,22 @@ const enum DateRangeAttribute { PLANNED_DURATION = 'PLANNED-DURATION', SCTE35_OUT = 'SCTE35-OUT', SCTE35_IN = 'SCTE35-IN', + SCTE35_CMD = 'SCTE35-CMD', } +export type DateRangeCue = { + pre: boolean; + post: boolean; + once: boolean; +}; + +const CLASS_INTERSTITIAL = 'com.apple.hls.interstitial'; + export function isDateRangeCueAttribute(attrName: string): boolean { return ( attrName !== DateRangeAttribute.ID && attrName !== DateRangeAttribute.CLASS && + attrName !== DateRangeAttribute.CUE && attrName !== DateRangeAttribute.START_DATE && attrName !== DateRangeAttribute.DURATION && attrName !== DateRangeAttribute.END_DATE && @@ -28,17 +40,28 @@ export function isDateRangeCueAttribute(attrName: string): boolean { export function isSCTE35Attribute(attrName: string): boolean { return ( attrName === DateRangeAttribute.SCTE35_OUT || - attrName === DateRangeAttribute.SCTE35_IN + attrName === DateRangeAttribute.SCTE35_IN || + attrName === DateRangeAttribute.SCTE35_CMD ); } export class DateRange { public attr: AttrList; + public tagAnchor: Fragment | null; + public tagOrder: number; private _startDate: Date; private _endDate?: Date; + private _cue?: DateRangeCue; private _badValueForSameId?: string; - constructor(dateRangeAttr: AttrList, dateRangeWithSameId?: DateRange) { + constructor( + dateRangeAttr: AttrList, + dateRangeWithSameId?: DateRange | undefined, + frag: Fragment | null = null, + tagCount: number = 0, + ) { + this.tagAnchor = dateRangeWithSameId?.tagAnchor ?? frag; + this.tagOrder = dateRangeWithSameId?.tagOrder ?? tagCount; if (dateRangeWithSameId) { const previousAttr = dateRangeWithSameId.attr; for (const key in previousAttr) { @@ -78,6 +101,36 @@ export class DateRange { return this.attr.CLASS; } + get cue(): DateRangeCue { + const _cue = this._cue; + if (_cue === undefined) { + return (this._cue = this.attr.enumeratedStringList( + this.attr.CUE ? 'CUE' : 'X-CUE', + { + pre: false, + post: false, + once: false, + }, + )); + } + return _cue; + } + + get startTime(): number { + const { tagAnchor } = this; + // eslint-disable-next-line @typescript-eslint/prefer-optional-chain + if (tagAnchor === null || tagAnchor.programDateTime === null) { + logger.warn( + `Expected tagAnchor Fragment with PDT set for DateRange "${this.id}": ${tagAnchor}`, + ); + return NaN; + } + return ( + tagAnchor.start + + (this.startDate.getTime() - tagAnchor.programDateTime) / 1000 + ); + } + get startDate(): Date { return this._startDate; } @@ -120,13 +173,23 @@ export class DateRange { return this.attr.bool(DateRangeAttribute.END_ON_NEXT); } + get isInterstitial(): boolean { + return this.class === CLASS_INTERSTITIAL; + } + get isValid(): boolean { return ( !!this.id && !this._badValueForSameId && Number.isFinite(this.startDate.getTime()) && (this.duration === null || this.duration >= 0) && - (!this.endOnNext || !!this.class) + (!this.endOnNext || !!this.class) && + (!this.attr.CUE || + (!this.cue.pre && !this.cue.post) || + this.cue.pre !== this.cue.post) && + (!this.isInterstitial || + 'X-ASSET-URI' in this.attr || + 'X-ASSET-LIST' in this.attr) ); } } diff --git a/src/loader/level-details.ts b/src/loader/level-details.ts index d554f55722a..5d46e0d5e90 100644 --- a/src/loader/level-details.ts +++ b/src/loader/level-details.ts @@ -19,6 +19,7 @@ export class LevelDetails { public fragmentHint?: Fragment; public partList: Part[] | null = null; public dateRanges: Record; + public dateRangeTagCount: number = 0; public live: boolean = true; public ageHeader: number = 0; public advancedDateTime?: number; diff --git a/src/loader/m3u8-parser.ts b/src/loader/m3u8-parser.ts index 6f8937871ac..b037c14605c 100644 --- a/src/loader/m3u8-parser.ts +++ b/src/loader/m3u8-parser.ts @@ -10,7 +10,6 @@ import { hasVariableReferences, importVariableDefinition, substituteVariables, - substituteVariablesInAttributes, } from '../utils/variable-substitution'; import { isCodecType } from '../utils/codecs'; import type { CodecType } from '../utils/codecs'; @@ -120,21 +119,7 @@ export default class M3U8Parser { while ((result = MASTER_PLAYLIST_REGEX.exec(string)) != null) { if (result[1]) { // '#EXT-X-STREAM-INF' is found, parse level tag in group 1 - const attrs = new AttrList(result[1]) as LevelAttributes; - if (__USE_VARIABLE_SUBSTITUTION__) { - substituteVariablesInAttributes(parsed, attrs, [ - 'CODECS', - 'SUPPLEMENTAL-CODECS', - 'ALLOWED-CPC', - 'PATHWAY-ID', - 'STABLE-VARIANT-ID', - 'AUDIO', - 'VIDEO', - 'SUBTITLES', - 'CLOSED-CAPTIONS', - 'NAME', - ]); - } + const attrs = new AttrList(result[1], parsed) as LevelAttributes; const uri = __USE_VARIABLE_SUBSTITUTION__ ? substituteVariables(parsed, result[2]) : result[2]; @@ -166,15 +151,7 @@ export default class M3U8Parser { switch (tag) { case 'SESSION-DATA': { // #EXT-X-SESSION-DATA - const sessionAttrs = new AttrList(attributes); - if (__USE_VARIABLE_SUBSTITUTION__) { - substituteVariablesInAttributes(parsed, sessionAttrs, [ - 'DATA-ID', - 'LANGUAGE', - 'VALUE', - 'URI', - ]); - } + const sessionAttrs = new AttrList(attributes, parsed); const dataId = sessionAttrs['DATA-ID']; if (dataId) { if (parsed.sessionData === null) { @@ -202,26 +179,14 @@ export default class M3U8Parser { case 'DEFINE': { // #EXT-X-DEFINE if (__USE_VARIABLE_SUBSTITUTION__) { - const variableAttributes = new AttrList(attributes); - substituteVariablesInAttributes(parsed, variableAttributes, [ - 'NAME', - 'VALUE', - 'QUERYPARAM', - ]); + const variableAttributes = new AttrList(attributes, parsed); addVariableDefinition(parsed, variableAttributes, baseurl); } break; } case 'CONTENT-STEERING': { // #EXT-X-CONTENT-STEERING - const contentSteeringAttributes = new AttrList(attributes); - if (__USE_VARIABLE_SUBSTITUTION__) { - substituteVariablesInAttributes( - parsed, - contentSteeringAttributes, - ['SERVER-URI', 'PATHWAY-ID'], - ); - } + const contentSteeringAttributes = new AttrList(attributes, parsed); parsed.contentSteering = { uri: M3U8Parser.resolve( contentSteeringAttributes['SERVER-URI'], @@ -278,26 +243,13 @@ export default class M3U8Parser { let id = 0; MASTER_PLAYLIST_MEDIA_REGEX.lastIndex = 0; while ((result = MASTER_PLAYLIST_MEDIA_REGEX.exec(string)) !== null) { - const attrs = new AttrList(result[1]) as MediaAttributes; + const attrs = new AttrList(result[1], parsed) as MediaAttributes; const type = attrs.TYPE; if (type) { const groups: (typeof groupsByType)[keyof typeof groupsByType] = groupsByType[type]; const medias: MediaPlaylist[] = results[type] || []; results[type] = medias; - if (__USE_VARIABLE_SUBSTITUTION__) { - substituteVariablesInAttributes(parsed, attrs, [ - 'URI', - 'GROUP-ID', - 'LANGUAGE', - 'ASSOC-LANGUAGE', - 'STABLE-RENDITION-ID', - 'NAME', - 'INSTREAM-ID', - 'CHARACTERISTICS', - 'CHANNELS', - ]); - } const lang = attrs.LANGUAGE; const assocLang = attrs['ASSOC-LANGUAGE']; const channels = attrs.CHANNELS; @@ -355,6 +307,7 @@ export default class M3U8Parser { ): LevelDetails { const level = new LevelDetails(baseurl); const fragments: M3U8ParserFragments = level.fragments; + const unMappedDateRanges: DateRange[] = []; // The most recent init segment seen (applies to all subsequent segments) let currentInitSegment: Fragment | null = null; let currentSN = 0; @@ -393,6 +346,15 @@ export default class M3U8Parser { frag.setByteRange(nextByteRange); nextByteRange = null; } + for (let i = unMappedDateRanges.length; i--; ) { + const dateRange = unMappedDateRanges[i]; + if (dateRange.tagAnchor === currentInitSegment) { + dateRange.tagAnchor = frag; + } + if (Number.isFinite(dateRange.tagAnchor?.programDateTime)) { + unMappedDateRanges.splice(i, 1); + } + } } } @@ -468,12 +430,7 @@ export default class M3U8Parser { currentSN = level.startSN = parseInt(value1); break; case 'SKIP': { - const skipAttrs = new AttrList(value1); - if (__USE_VARIABLE_SUBSTITUTION__) { - substituteVariablesInAttributes(level, skipAttrs, [ - 'RECENTLY-REMOVED-DATERANGES', - ]); - } + const skipAttrs = new AttrList(value1, level); const skippedSegments = skipAttrs.decimalInteger('SKIPPED-SEGMENTS'); if (Number.isFinite(skippedSegments)) { @@ -522,29 +479,17 @@ export default class M3U8Parser { frag.tagList.push([tag, value1]); break; case 'DATERANGE': { - const dateRangeAttr = new AttrList(value1); - if (__USE_VARIABLE_SUBSTITUTION__) { - substituteVariablesInAttributes(level, dateRangeAttr, [ - 'ID', - 'CLASS', - 'START-DATE', - 'END-DATE', - 'SCTE35-CMD', - 'SCTE35-OUT', - 'SCTE35-IN', - ]); - substituteVariablesInAttributes( - level, - dateRangeAttr, - dateRangeAttr.clientAttrs, - ); - } + const dateRangeAttr = new AttrList(value1, level); const dateRange = new DateRange( dateRangeAttr, level.dateRanges[dateRangeAttr.ID], + discontinuityCounter === frag.cc ? frag : prevFrag, + level.dateRangeTagCount, ); + level.dateRangeTagCount++; if (dateRange.isValid || level.skippedSegments) { level.dateRanges[dateRange.id] = dateRange; + unMappedDateRanges.push(dateRange); } else { logger.warn(`Ignoring invalid DATERANGE tag: "${value1}"`); } @@ -554,13 +499,7 @@ export default class M3U8Parser { } case 'DEFINE': { if (__USE_VARIABLE_SUBSTITUTION__) { - const variableAttributes = new AttrList(value1); - substituteVariablesInAttributes(level, variableAttributes, [ - 'NAME', - 'VALUE', - 'IMPORT', - 'QUERYPARAM', - ]); + const variableAttributes = new AttrList(value1, level); if ('IMPORT' in variableAttributes) { importVariableDefinition( level, @@ -600,13 +539,7 @@ export default class M3U8Parser { level.startTimeOffset = parseStartTimeOffset(value1); break; case 'MAP': { - const mapAttrs = new AttrList(value1); - if (__USE_VARIABLE_SUBSTITUTION__) { - substituteVariablesInAttributes(level, mapAttrs, [ - 'BYTERANGE', - 'URI', - ]); - } + const mapAttrs = new AttrList(value1, level); if (frag.duration) { // Initial segment tag is after segment duration tag. // #EXTINF: 6.0 @@ -667,13 +600,7 @@ export default class M3U8Parser { const previousFragmentPart = currentPart > 0 ? partList[partList.length - 1] : undefined; const index = currentPart++; - const partAttrs = new AttrList(value1); - if (__USE_VARIABLE_SUBSTITUTION__) { - substituteVariablesInAttributes(level, partAttrs, [ - 'BYTERANGE', - 'URI', - ]); - } + const partAttrs = new AttrList(value1, level); const part = new Part( partAttrs, frag, @@ -686,20 +613,12 @@ export default class M3U8Parser { break; } case 'PRELOAD-HINT': { - const preloadHintAttrs = new AttrList(value1); - if (__USE_VARIABLE_SUBSTITUTION__) { - substituteVariablesInAttributes(level, preloadHintAttrs, ['URI']); - } + const preloadHintAttrs = new AttrList(value1, level); level.preloadHint = preloadHintAttrs; break; } case 'RENDITION-REPORT': { - const renditionReportAttrs = new AttrList(value1); - if (__USE_VARIABLE_SUBSTITUTION__) { - substituteVariablesInAttributes(level, renditionReportAttrs, [ - 'URI', - ]); - } + const renditionReportAttrs = new AttrList(value1, level); level.renditionReports = level.renditionReports || []; level.renditionReports.push(renditionReportAttrs); break; @@ -760,7 +679,12 @@ export default class M3U8Parser { if (firstPdtIndex > 0) { backfillProgramDateTimes(fragments, firstPdtIndex); } - + for (let i = unMappedDateRanges.length; i--; ) { + const dateRange = unMappedDateRanges[i]; + if (!Number.isFinite(dateRange.tagAnchor?.programDateTime)) { + dateRange.tagAnchor = lastFragment; + } + } return level; } } @@ -771,16 +695,7 @@ function parseKey( parsed: ParsedMultivariantPlaylist | LevelDetails, ): LevelKey { // https://tools.ietf.org/html/rfc8216#section-4.3.2.4 - const keyAttrs = new AttrList(keyTagAttributes); - if (__USE_VARIABLE_SUBSTITUTION__) { - substituteVariablesInAttributes(parsed, keyAttrs, [ - 'KEYFORMAT', - 'KEYFORMATVERSIONS', - 'URI', - 'IV', - 'URI', - ]); - } + const keyAttrs = new AttrList(keyTagAttributes, parsed); const decryptmethod = keyAttrs.METHOD ?? ''; const decrypturi = keyAttrs.URI; const decryptiv = keyAttrs.hexadecimalInteger('IV'); @@ -864,7 +779,7 @@ function backfillProgramDateTimes( } } -function assignProgramDateTime(frag, prevFrag) { +function assignProgramDateTime(frag: Fragment, prevFrag: Fragment | null) { if (frag.rawProgramDateTime) { frag.programDateTime = Date.parse(frag.rawProgramDateTime); } else if (prevFrag?.programDateTime) { diff --git a/src/utils/attr-list.ts b/src/utils/attr-list.ts index 32984bc29c0..0209fab844d 100755 --- a/src/utils/attr-list.ts +++ b/src/utils/attr-list.ts @@ -1,3 +1,8 @@ +import type { LevelDetails } from '../loader/level-details'; +import type { ParsedMultivariantPlaylist } from '../loader/m3u8-parser'; +import { logger } from './logger'; +import { substituteVariables } from './variable-substitution'; + const DECIMAL_RESOLUTION_REGEX = /^(\d+)x(\d+)$/; const ATTR_LIST_REGEX = /(.+?)=(".*?"|.*?)(?:,|$)/g; @@ -5,9 +10,15 @@ const ATTR_LIST_REGEX = /(.+?)=(".*?"|.*?)(?:,|$)/g; export class AttrList { [key: string]: any; - constructor(attrs: string | Record) { + constructor( + attrs: string | Record, + parsed?: Pick< + ParsedMultivariantPlaylist | LevelDetails, + 'variableList' | 'hasVariableRefs' | 'playlistParsingError' + >, + ) { if (typeof attrs === 'string') { - attrs = AttrList.parseAttrList(attrs); + attrs = AttrList.parseAttrList(attrs, parsed); } Object.assign(this, attrs); } @@ -63,6 +74,20 @@ export class AttrList { return this[attrName]; } + enumeratedStringList( + attrName: string, + dict: T, + ): { [key in keyof T]: boolean } { + const attrValue = this[attrName]; + return (attrValue ? attrValue.split(/[ ,]+/) : []).reduce( + (result: { [key in keyof T]: boolean }, identifier: string) => { + result[identifier.toLowerCase() as keyof T] = true; + return result; + }, + dict, + ); + } + bool(attrName: string): boolean { return this[attrName] === 'YES'; } @@ -84,21 +109,83 @@ export class AttrList { }; } - static parseAttrList(input: string): Record { - let match; + static parseAttrList( + input: string, + parsed?: Pick< + ParsedMultivariantPlaylist | LevelDetails, + 'variableList' | 'hasVariableRefs' | 'playlistParsingError' + >, + ): Record { + let match: RegExpExecArray | null; const attrs = {}; const quote = '"'; ATTR_LIST_REGEX.lastIndex = 0; while ((match = ATTR_LIST_REGEX.exec(input)) !== null) { + const name = match[1].trim(); let value = match[2]; - - if ( + const quotedString = value.indexOf(quote) === 0 && - value.lastIndexOf(quote) === value.length - 1 - ) { + value.lastIndexOf(quote) === value.length - 1; + let hexadecimalSequence = false; + if (quotedString) { value = value.slice(1, -1); + } else { + switch (name) { + case 'IV': + case 'SCTE35-CMD': + case 'SCTE35-IN': + case 'SCTE35-OUT': + hexadecimalSequence = true; + } + } + if (parsed && (quotedString || hexadecimalSequence)) { + if (__USE_VARIABLE_SUBSTITUTION__) { + value = substituteVariables(parsed, value); + } + } else if (!hexadecimalSequence && !quotedString) { + switch (name) { + case 'CLOSED-CAPTIONS': + if (value === 'NONE') { + break; + } + // falls through + case 'ALLOWED-CPC': + case 'CLASS': + case 'ASSOC-LANGUAGE': + case 'AUDIO': + case 'BYTERANGE': + case 'CHANNELS': + case 'CHARACTERISTICS': + case 'CODECS': + case 'DATA-ID': + case 'END-DATE': + case 'GROUP-ID': + case 'ID': + case 'IMPORT': + case 'INSTREAM-ID': + case 'KEYFORMAT': + case 'KEYFORMATVERSIONS': + case 'LANGUAGE': + case 'NAME': + case 'PATHWAY-ID': + case 'QUERYPARAM': + case 'RECENTLY-REMOVED-DATERANGES': + case 'SERVER-URI': + case 'STABLE-RENDITION-ID': + case 'STABLE-VARIANT-ID': + case 'START-DATE': + case 'SUBTITLES': + case 'SUPPLEMENTAL-CODECS': + case 'URI': + case 'VALUE': + case 'VIDEO': + case 'X-ASSET-LIST': + case 'X-ASSET-URI': + // Since we are not checking tag:attribute combination, just warn rather than ignoring attribute + logger.warn(`${input}: attribute ${name} is missing quotes`); + // continue; + } } - const name = match[1].trim(); attrs[name] = value; } return attrs; diff --git a/src/utils/variable-substitution.ts b/src/utils/variable-substitution.ts index 7ecbde87d10..d2b7db20c38 100644 --- a/src/utils/variable-substitution.ts +++ b/src/utils/variable-substitution.ts @@ -9,25 +9,6 @@ export function hasVariableReferences(str: string): boolean { return VARIABLE_REPLACEMENT_REGEX.test(str); } -export function substituteVariablesInAttributes( - parsed: Pick< - ParsedMultivariantPlaylist | LevelDetails, - 'variableList' | 'hasVariableRefs' | 'playlistParsingError' - >, - attr: AttrList, - attributeNames: string[], -) { - if (parsed.variableList !== null || parsed.hasVariableRefs) { - for (let i = attributeNames.length; i--; ) { - const name = attributeNames[i]; - const value = attr[name]; - if (value) { - attr[name] = substituteVariables(parsed, value); - } - } - } -} - export function substituteVariables( parsed: Pick< ParsedMultivariantPlaylist | LevelDetails, diff --git a/tests/unit/loader/date-range.ts b/tests/unit/loader/date-range.ts index 4acc58154ab..385404590c6 100644 --- a/tests/unit/loader/date-range.ts +++ b/tests/unit/loader/date-range.ts @@ -7,13 +7,13 @@ const expect = chai.expect; describe('DateRange class', function () { const startDateAndDuration = new AttrList( - 'ID="ad1",CLASS="com.apple.hls.interstitial",START-DATE="2020-01-02T21:55:44.000Z",DURATION=15.0', + 'ID="ad1",CLASS="com.apple.hls.interstitial",START-DATE="2020-01-02T21:55:44.000Z",DURATION=15.0,X-ASSET-URI="i.m3u8"', ); const startDateAndEndDate = new AttrList( - 'ID="ad2",CLASS="com.apple.hls.interstitial",START-DATE="2020-01-02T21:55:44.000Z",END-DATE="2020-01-02T21:56:44.001Z"', + 'ID="ad2",CLASS="com.apple.hls.interstitial",START-DATE="2020-01-02T21:55:44.000Z",END-DATE="2020-01-02T21:56:44.001Z",X-ASSET-URI="i.m3u8"', ); const startDateAndEndOnNext = new AttrList( - 'ID="ad3",CLASS="com.apple.hls.interstitial",START-DATE="2022-01-01T00:00:00.100Z",END-ON-NEXT=YES', + 'ID="ad3",CLASS="com.apple.hls.interstitial",START-DATE="2022-01-01T00:00:00.100Z",END-ON-NEXT=YES,X-ASSET-URI="i.m3u8"', ); const sctePlanned = new AttrList( @@ -44,6 +44,27 @@ describe('DateRange class', function () { const endOnNextWithNoClass = new AttrList( 'ID="ad3",START-DATE="2022-01-01T00:00:00.100Z",END-ON-NEXT=YES', ); + const cueWithPre = new AttrList( + 'ID="mid1",CLASS="com.apple.hls.interstitial",CUE="PRE",START-DATE="2024-01-12T10:00:10.000Z",DURATION=15.0,X-ASSET-URI="b.m3u8"', + ); + const cueWithPost = new AttrList( + 'ID="mid1",CLASS="com.apple.hls.interstitial",CUE="POST",START-DATE="2024-01-12T10:00:10.000Z",DURATION=15.0,X-ASSET-URI="b.m3u8"', + ); + const cueWithPreOnce = new AttrList( + 'ID="mid1",CLASS="com.apple.hls.interstitial",CUE="PRE,ONCE",START-DATE="2024-01-12T10:00:10.000Z",DURATION=15.0,X-ASSET-URI="b.m3u8"', + ); + const cueWithPostOnce = new AttrList( + 'ID="mid1",CLASS="com.apple.hls.interstitial",CUE="POST,ONCE",START-DATE="2024-01-12T10:00:10.000Z",DURATION=15.0,X-ASSET-URI="b.m3u8"', + ); + const cueWithPreAndPost = new AttrList( + 'ID="mid1",CLASS="com.apple.hls.interstitial",CUE="PRE,POST",START-DATE="2024-01-12T10:00:10.000Z",DURATION=15.0,X-ASSET-URI="b.m3u8"', + ); + const invalidQuotedAttributeId = new AttrList( + 'ID=bad,START-DATE="2020-01-02T21:55:44.000Z",DURATION=1.0', + ); + const invalidQuotedAttributeStartDate = new AttrList( + 'ID="ok",START-DATE=2020-01-02T21:55:44.000Z,DURATION=1.0', + ); it('parses id, class, date, duration, and end-on-next attributes', function () { const dateRangeDuration = new DateRange(startDateAndDuration); @@ -92,7 +113,7 @@ describe('DateRange class', function () { expect(dateRangeEndDate.duration).to.equal(60.001); }); - describe('merges tags with matching ID attributes', function () { + it('merges tags with matching ID attributes', function () { const scteOut = new DateRange(sctePlanned); const scteIn = new DateRange(scteDurationUpdate, scteOut); expect(scteIn.startDate.toISOString()).to.equal('2014-03-05T11:15:00.000Z'); @@ -103,6 +124,26 @@ describe('DateRange class', function () { expect(scteIn.isValid).to.equal(true); }); + describe('isInterstitial', function () { + it('identifies Interstitial DateRange tags with CLASS="com.apple.hls.interstitial"', function () { + expect(new DateRange(startDateAndDuration).isInterstitial).to.be.true; + expect(new DateRange(startDateAndEndDate).isInterstitial).to.be.true; + expect(new DateRange(startDateAndEndOnNext).isInterstitial).to.be.true; + }); + it('is false for non-Interstitial DateRanges', function () { + expect(new DateRange(sctePlanned).isInterstitial).to.be.false; + expect(new DateRange(scteDurationUpdate).isInterstitial).to.be.false; + expect(new DateRange(scteInvalidChange).isInterstitial).to.be.false; + expect(new DateRange(missingId).isInterstitial).to.be.false; + expect(new DateRange(missingStartDate).isInterstitial).to.be.false; + expect(new DateRange(invalidStartDate).isInterstitial).to.be.false; + expect(new DateRange(negativeDuration).isInterstitial).to.be.false; + expect(new DateRange(endDateEarlierThanStartDate).isInterstitial).to.be + .false; + expect(new DateRange(endOnNextWithNoClass).isInterstitial).to.be.false; + }); + }); + describe('isValid indicates that DATERANGE tag:', function () { function validateDateRange(attributeList: AttrList, expected: boolean) { expect(new DateRange(attributeList).isValid).to.equal( @@ -150,5 +191,36 @@ describe('DateRange class', function () { )}\n${JSON.stringify(scteIn)}`, ); }); + + it('parses the CUE attribute PRE, POST, and ONCE Trigger Identifiers', function () { + const pre = new DateRange(cueWithPre); + const post = new DateRange(cueWithPost); + const preOnce = new DateRange(cueWithPreOnce); + const postOnce = new DateRange(cueWithPostOnce); + expect(pre.isValid).to.equal(true, JSON.stringify(pre)); + expect(post.isValid).to.equal(true, JSON.stringify(post)); + expect(preOnce.isValid).to.equal(true, JSON.stringify(preOnce)); + expect(postOnce.isValid).to.equal(true, JSON.stringify(postOnce)); + }); + + it('MUST NOT include both PRE and POST CUE Trigger Identifiers', function () { + const preAndPost = new DateRange(cueWithPreAndPost); + expect(preAndPost.isValid).to.equal( + false, + `Expected DateRange with CUE to have PRE or POST enumerated string values, but not both\n${JSON.stringify( + preAndPost, + )}`, + ); + }); + + // it('considers tags invalid when attributes whose values are expected to be quoted-strings are missing quotes', function () { + // const invalidId = new DateRange(invalidQuotedAttributeId); + // expect(invalidId.isValid).to.equal(false, 'ID is missing quotes'); + // const invalidDate = new DateRange(invalidQuotedAttributeStartDate); + // expect(invalidDate.isValid).to.equal( + // false, + // 'START-DATE is missing quotes', + // ); + // }); }); }); diff --git a/tests/unit/loader/playlist-loader.ts b/tests/unit/loader/playlist-loader.ts index 3b9da4564e5..0cfb8193132 100644 --- a/tests/unit/loader/playlist-loader.ts +++ b/tests/unit/loader/playlist-loader.ts @@ -1715,7 +1715,77 @@ fileSequence2.ts expect(fragments[2].gap).to.equal(undefined); }); - it('adds unhandled tags (DATERANGE) and comments to fragment.tagList', function () { + it('parsed DATERANGE tags including Interstitials', function () { + const playlist = `#EXTM3U +#EXT-X-TARGETDURATION:6 +#EXT-X-VERSION:10 +#EXT-X-DISCONTINUITY-SEQUENCE:1 +#EXT-X-MEDIA-SEQUENCE:1 +#EXT-X-TIMESTAMP:1705081564 +#EXT-X-PROGRAM-DATE-TIME:2024-01-12T10:00:00.000Z +#EXT-X-DATERANGE:ID="pre",CLASS="com.apple.hls.interstitial",CUE="PRE",START-DATE="2024-01-12T08:00:00.000Z",DURATION=15.0,X-ASSET-URI="b.m3u8?_HLS_interstitial_id=pre",X-RESTRICT="SKIP,JUMP",X-SNAP="IN" +#EXT-X-MAP:URI="init_0.mp4" +#EXTINF:6, +segment.m4s +#EXTINF:6, +segment.m4s +#EXT-X-DATERANGE:ID="mid1",CLASS="com.apple.hls.interstitial",CUE="ONCE",START-DATE="2024-01-12T10:00:10.000Z",DURATION=15.0,X-ASSET-URI="b.m3u8?_HLS_interstitial_id=mid1",X-RESTRICT="SKIP,JUMP" +#EXTINF:4, +segment.m4s +#EXT-X-DISCONTINUITY +#EXT-X-PROGRAM-DATE-TIME:2024-01-12T10:00:16.000Z +#EXT-X-MAP:URI="init_1.mp4" +#EXTINF:6, +segment.m4s +#EXTINF:4, +segment.m4s +#EXT-X-DATERANGE:ID="mid2",CLASS="com.apple.hls.interstitial",START-DATE="2024-01-12T10:00:25.000Z",DURATION=15.0,X-ASSET-URI="b.m3u8?_HLS_interstitial_id=mid2",X-SNAP="OUT,IN" +#EXT-X-DISCONTINUITY +#EXT-X-PROGRAM-DATE-TIME:2024-01-12T10:00:26.000Z +#EXT-X-MAP:URI="init_2.mp4" +#EXTINF:6, +segment.m4s +#EXTINF:6, +segment.m4s + +#EXT-X-DATERANGE:ID="post",CLASS="com.apple.hls.interstitial",CUE="POST,ONCE",START-DATE="2024-01-12T10:00:00.000Z",DURATION=15.0,X-ASSET-URI="e.m3u8?_HLS_interstitial_id=post"`; + const details = M3U8Parser.parseLevelPlaylist( + playlist, + 'http://dummy.url.com/playlist.m3u8', + 0, + PlaylistLevelType.MAIN, + 0, + null, + ); + expect(details.dateRangeTagCount).to.equal(4); + expect(details.dateRanges.pre.isInterstitial).to.be.true; + expect(details.dateRanges.mid1.isInterstitial).to.be.true; + expect(details.dateRanges.mid2.isInterstitial).to.be.true; + expect(details.dateRanges.post.isInterstitial).to.be.true; + expect(details.dateRanges).to.have.property('pre').which.deep.includes({ + tagOrder: 0, + }); + expect(details.dateRanges).to.have.property('mid1').which.deep.includes({ + tagOrder: 1, + }); + expect(details.dateRanges).to.have.property('mid2').which.deep.includes({ + tagOrder: 2, + }); + expect(details.dateRanges).to.have.property('post').which.deep.includes({ + tagOrder: 3, + }); + expect(details.dateRanges.pre.cue.pre).to.be.true; + expect(details.dateRanges.mid1.cue.once).to.be.true; + expect(details.dateRanges.post.cue.post).to.be.true; + expect(details.dateRanges.post.cue.once).to.be.true; + // DateRange start times are mapped to the primary timeline and not changed by CUE Interstitial DURATION + expect(details.dateRanges.pre.startTime).to.equal(-7200); + expect(details.dateRanges.mid1.startTime).to.equal(10); + expect(details.dateRanges.mid2.startTime).to.equal(25); + expect(details.dateRanges.post.startTime).to.equal(0); + }); + + it('adds PROGRAM-DATE-TIME and DATERANGE tag text to fragment[].tagList for backwards compatibility', function () { const playlist = `#EXTM3U #EXT-X-TARGETDURATION:10 #EXT-X-VERSION:4 @@ -2086,10 +2156,10 @@ http://proxy-21.x.com/sec(3ae40f708f79ca9471f52b86da76a3a8)/video/107/282/158282 describe('#EXT-X-START', function () { it('parses EXT-X-START in Multivariant Playlists', function () { const manifest = `#EXTM3U - #EXT-X-START:TIME-OFFSET=300.0,PRECISE=YES +#EXT-X-START:TIME-OFFSET=300.0,PRECISE=YES - #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=836280,CODECS="mp4a.40.2,avc1.64001f",RESOLUTION=848x360,NAME="480" - http://proxy-62.x.com/sec(3ae40f708f79ca9471f52b86da76a3a8)/video/107/282/158282701_mp4_h264_aac_hq.m3u8#cell=core`; +#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=836280,CODECS="mp4a.40.2,avc1.64001f",RESOLUTION=848x360,NAME="480" +http://proxy-62.x.com/sec(3ae40f708f79ca9471f52b86da76a3a8)/video/107/282/158282701_mp4_h264_aac_hq.m3u8#cell=core`; const result = M3U8Parser.parseMasterPlaylist(manifest, 'http://www.x.com'); expect(result.startTimeOffset).to.equal(300); @@ -2097,10 +2167,9 @@ describe('#EXT-X-START', function () { it('parses negative EXT-X-START values in Multivariant Playlists', function () { const manifest = `#EXTM3U - #EXT-X-START:TIME-OFFSET=-30.0 - - #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=836280,CODECS="mp4a.40.2,avc1.64001f",RESOLUTION=848x360,NAME="480" - http://proxy-62.x.com/sec(3ae40f708f79ca9471f52b86da76a3a8)/video/107/282/158282701_mp4_h264_aac_hq.m3u8#cell=core`; +#EXT-X-START:TIME-OFFSET=-30.0 +#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=836280,CODECS="mp4a.40.2,avc1.64001f",RESOLUTION=848x360,NAME="480" +http://proxy-62.x.com/sec(3ae40f708f79ca9471f52b86da76a3a8)/video/107/282/158282701_mp4_h264_aac_hq.m3u8#cell=core`; const result = M3U8Parser.parseMasterPlaylist(manifest, 'http://www.x.com'); expect(result.startTimeOffset).to.equal(-30); @@ -2108,9 +2177,8 @@ describe('#EXT-X-START', function () { it('result is null when EXT-X-START is not present', function () { const manifest = `#EXTM3U - - #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=836280,CODECS="mp4a.40.2,avc1.64001f",RESOLUTION=848x360,NAME="480" - http://proxy-62.x.com/sec(3ae40f708f79ca9471f52b86da76a3a8)/video/107/282/158282701_mp4_h264_aac_hq.m3u8#cell=core`; +#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=836280,CODECS="mp4a.40.2,avc1.64001f",RESOLUTION=848x360,NAME="480" +http://proxy-62.x.com/sec(3ae40f708f79ca9471f52b86da76a3a8)/video/107/282/158282701_mp4_h264_aac_hq.m3u8#cell=core`; const result = M3U8Parser.parseMasterPlaylist(manifest, 'http://www.x.com'); expect(result.startTimeOffset).to.equal(null); @@ -2120,12 +2188,11 @@ describe('#EXT-X-START', function () { describe('#EXT-X-DEFINE', function () { it('parses EXT-X-DEFINE Variables in Multivariant Playlists', function () { const manifest = `#EXTM3U - #EXT-X-DEFINE:NAME="x",VALUE="1" - #EXT-X-DEFINE:NAME="y",VALUE="2" - #EXT-X-DEFINE:NAME="hello-var",VALUE="Hello there!" - - #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=836280,CODECS="mp4a.40.2,avc1.64001f",RESOLUTION=848x360,NAME="480" - http://proxy-62.x.com/sec(3ae40f708f79ca9471f52b86da76a3a8)/video/107/282/158282701_mp4_h264_aac_hq.m3u8#cell=core`; +#EXT-X-DEFINE:NAME="x",VALUE="1" +#EXT-X-DEFINE:NAME="y",VALUE="2" +#EXT-X-DEFINE:NAME="hello-var",VALUE="Hello there!" +#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=836280,CODECS="mp4a.40.2,avc1.64001f",RESOLUTION=848x360,NAME="480" +http://proxy-62.x.com/sec(3ae40f708f79ca9471f52b86da76a3a8)/video/107/282/158282701_mp4_h264_aac_hq.m3u8#cell=core`; const result = M3U8Parser.parseMasterPlaylist(manifest, 'http://www.x.com'); if (result.variableList === null) { @@ -2139,12 +2206,12 @@ describe('#EXT-X-DEFINE', function () { it('returns an error when duplicate Variables are found in Multivariant Playlists', function () { const manifest = `#EXTM3U - #EXT-X-DEFINE:NAME="foo",VALUE="ok" - #EXT-X-DEFINE:NAME="bar",VALUE="ok" - #EXT-X-DEFINE:NAME="foo",VALUE="duped" +#EXT-X-DEFINE:NAME="foo",VALUE="ok" +#EXT-X-DEFINE:NAME="bar",VALUE="ok" +#EXT-X-DEFINE:NAME="foo",VALUE="duped" - #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=836280,CODECS="mp4a.40.2,avc1.64001f",RESOLUTION=848x360,NAME="480" - http://proxy-62.x.com/sec(3ae40f708f79ca9471f52b86da76a3a8)/video/107/282/158282701_mp4_h264_aac_hq.m3u8#cell=core`; +#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=836280,CODECS="mp4a.40.2,avc1.64001f",RESOLUTION=848x360,NAME="480" +http://proxy-62.x.com/sec(3ae40f708f79ca9471f52b86da76a3a8)/video/107/282/158282701_mp4_h264_aac_hq.m3u8#cell=core`; const result = M3U8Parser.parseMasterPlaylist(manifest, 'http://www.x.com'); if (result.variableList === null) { @@ -2165,29 +2232,29 @@ describe('#EXT-X-DEFINE', function () { it('substitutes variable references in quoted strings, URI lines, and hexidecimal attributes, following EXT-X-DEFINE tags in Multivariant Playlists', function () { const manifest = `#EXTM3U - #EXT-X-DEFINE:NAME="host",VALUE="example.com" - #EXT-X-DEFINE:NAME="foo",VALUE="ok" - #EXT-X-DEFINE:NAME="bar",VALUE="{$foo}" - #EXT-X-DEFINE:NAME="vcodec",VALUE="avc1.64001f" +#EXT-X-DEFINE:NAME="host",VALUE="example.com" +#EXT-X-DEFINE:NAME="foo",VALUE="ok" +#EXT-X-DEFINE:NAME="bar",VALUE="{$foo}" +#EXT-X-DEFINE:NAME="vcodec",VALUE="avc1.64001f" - #EXT-X-CONTENT-STEERING:SERVER-URI="https://{$host}/steering-manifest.json",PATHWAY-ID="{$foo}-CDN" +#EXT-X-CONTENT-STEERING:SERVER-URI="https://{$host}/steering-manifest.json",PATHWAY-ID="{$foo}-CDN" - #EXT-X-DEFINE:NAME="session-var",VALUE="hmm" - #EXT-X-SESSION-DATA:DATA-ID="var-applied",VALUE="{$session-var}" +#EXT-X-DEFINE:NAME="session-var",VALUE="hmm" +#EXT-X-SESSION-DATA:DATA-ID="var-applied",VALUE="{$session-var}" - #EXT-X-DEFINE:NAME="p",VALUE="." - #EXT-X-DEFINE:NAME="v1",VALUE="1" - #EXT-X-DEFINE:NAME="two",VALUE="2" - #EXT-X-SESSION-KEY:METHOD=SAMPLE-AES,URI="skd://{$session-var}",KEYFORMAT="com.apple{$p}streamingkeydelivery",KEYFORMATVERSIONS="{$v1}/2",IV=0x0000000{$two} +#EXT-X-DEFINE:NAME="p",VALUE="." +#EXT-X-DEFINE:NAME="v1",VALUE="1" +#EXT-X-DEFINE:NAME="two",VALUE="2" +#EXT-X-SESSION-KEY:METHOD=SAMPLE-AES,URI="skd://{$session-var}",KEYFORMAT="com.apple{$p}streamingkeydelivery",KEYFORMATVERSIONS="{$v1}/2",IV=0x0000000{$two} - #EXT-X-DEFINE:NAME="language",VALUE="eng" - #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="{$two}00k",LANGUAGE="{$language}",NAME="Audio",AUTOSELECT=YES,DEFAULT=YES,URI="https://{$host}/{$two}00k.m3u8",BANDWIDTH=614400 +#EXT-X-DEFINE:NAME="language",VALUE="eng" +#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="{$two}00k",LANGUAGE="{$language}",NAME="Audio",AUTOSELECT=YES,DEFAULT=YES,URI="https://{$host}/{$two}00k.m3u8",BANDWIDTH=614400 - #EXT-X-STREAM-INF:BANDWIDTH=836280,CODECS="mp4a.40.2,{$vcodec}",RESOLUTION=848x360,AUDIO="{$two}00k",NAME="{$bar}1" - https://{$host}/sec/video/1.m3u8 +#EXT-X-STREAM-INF:BANDWIDTH=836280,CODECS="mp4a.40.2,{$vcodec}",RESOLUTION=848x360,AUDIO="{$two}00k",NAME="{$bar}1" +https://{$host}/sec/video/1.m3u8 - #EXT-X-STREAM-INF:BANDWIDTH=1836280,CODECS="mp4a.40.2,{$vcodec}",RESOLUTION=848x360,NAME="{$bar}{$two}" - https://{$host}/sec/{$vcodec}/{$two}.m3u8`; +#EXT-X-STREAM-INF:BANDWIDTH=1836280,CODECS="mp4a.40.2,{$vcodec}",RESOLUTION=848x360,NAME="{$bar}{$two}" +https://{$host}/sec/{$vcodec}/{$two}.m3u8`; const result = M3U8Parser.parseMasterPlaylist( manifest,