diff --git a/.eslintrc.js b/.eslintrc.js index bd78a68fdc2..3fb2f7365c4 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -14,6 +14,8 @@ module.exports = { __USE_ALT_AUDIO__: true, __USE_EME_DRM__: true, __USE_CMCD__: true, + __USE_CONTENT_STEERING__: true, + __USE_VARIABLE_SUBSTITUTION__: true, }, // see https://github.com/standard/eslint-config-standard // 'prettier' (https://github.com/prettier/eslint-config-prettier) must be last diff --git a/README.md b/README.md index 9ca8549d206..6a1365dff41 100644 --- a/README.md +++ b/README.md @@ -65,23 +65,26 @@ HLS.js is written in [ECMAScript6] (`*.js`) and [TypeScript] (`*.ts`) (strongly - Retry mechanism embedded in the library - Recovery actions can be triggered fix fatal media or network errors - [Redundant/Failover Playlists](https://developer.apple.com/library/ios/documentation/NetworkingInternet/Conceptual/StreamingMediaGuide/UsingHTTPLiveStreaming/UsingHTTPLiveStreaming.html#//apple_ref/doc/uid/TP40008332-CH102-SW22) +- HLS Variable Substitution -### Supported M3U8 tags +### Supported HLS tags -For details on the HLS format and these tags' meanings, see https://tools.ietf.org/html/draft-pantos-hls-rfc8216bis-08 +For details on the HLS format and these tags' meanings, see https://datatracker.ietf.org/doc/html/draft-pantos-hls-rfc8216bis -#### Manifest tags +#### Multi-Variant Playlist tags - `#EXT-X-STREAM-INF:` `` - `#EXT-X-MEDIA:` - `#EXT-X-SESSION-DATA:` +- `#EXT-X-START:TIME-OFFSET=` +- `#EXT-X-DEFINE` Variable Substitution The following properties are added to their respective variants' attribute list but are not implemented in their selection and playback. - `VIDEO-RANGE` and `HDCP-LEVEL` (See [#2489](https://github.com/video-dev/hls.js/issues/2489)) -#### Playlist tags +#### Media Playlist tags - `#EXTM3U` - `#EXT-X-VERSION=` @@ -99,16 +102,23 @@ The following properties are added to their respective variants' attribute list - `#EXT-X-SERVER-CONTROL:` - `#EXT-X-PART-INF:PART-TARGET=` - `#EXT-X-PART:` -- `#EXT-X-PRELOAD-HINT:` -- `#EXT-X-SKIP:` +- `#EXT-X-SKIP:` Delta Playlists - `#EXT-X-RENDITION-REPORT:` -- `#EXT-X-DATERANGE:` +- `#EXT-X-DATERANGE:` Metadata +- `#EXT-X-DEFINE` Variable Substitution The following tags are added to their respective fragment's attribute list but are not implemented in streaming and playback. - `#EXT-X-BITRATE` (Not used in ABR controller) - `#EXT-X-GAP` (Not implemented. See [#2940](https://github.com/video-dev/hls.js/issues/2940)) +Parsed but missing feature support + +- `#EXT-X-CONTENT-STEERING:` (See [#3988](https://github.com/video-dev/hls.js/issues/3988)) + - #3988 +- `#EXT-X-PRELOAD-HINT:` (See [#5074](https://github.com/video-dev/hls.js/issues/3988)) + - #5074 + ### Not Supported For a complete list of issues, see ["Top priorities" in the Release Planning and Backlog project tab](https://github.com/video-dev/hls.js/projects/6). Codec support is dependent on the runtime environment (for example, not all browsers on the same OS support HEVC). @@ -116,7 +126,6 @@ For a complete list of issues, see ["Top priorities" in the Release Planning and - Advanced variant selection based on runtime media capabilities (See issues labeled [`media-capabilities`](https://github.com/video-dev/hls.js/labels/media-capabilities)) - HLS Content Steering - HLS Interstitials -- `#EXT-X-DEFINE` variable substitution - `#EXT-X-GAP` filling [#2940](https://github.com/video-dev/hls.js/issues/2940) - `#EXT-X-I-FRAME-STREAM-INF` I-frame Media Playlist files - `SAMPLE-AES` with fmp4, aac, mp3, vtt... segments (MPEG-2 TS only) diff --git a/api-extractor/report/hls.js.api.md b/api-extractor/report/hls.js.api.md index e494638a1f5..fa80bdb11ac 100644 --- a/api-extractor/report/hls.js.api.md +++ b/api-extractor/report/hls.js.api.md @@ -482,6 +482,8 @@ export enum ErrorDetails { // (undocumented) LEVEL_LOAD_TIMEOUT = "levelLoadTimeOut", // (undocumented) + LEVEL_PARSING_ERROR = "levelParsingError", + // (undocumented) LEVEL_SWITCH_ERROR = "levelSwitchError", // (undocumented) MANIFEST_INCOMPATIBLE_CODECS_ERROR = "manifestIncompatibleCodecsError", @@ -1502,6 +1504,8 @@ export class LevelDetails { // (undocumented) partTarget: number; // (undocumented) + playlistParsingError: Error | null; + // (undocumented) preloadHint?: AttrList; // (undocumented) PTSKnown: boolean; @@ -1532,6 +1536,8 @@ export class LevelDetails { // (undocumented) url: string; // (undocumented) + variableList: VariableMap | null; + // (undocumented) version: number | null; } @@ -1860,6 +1866,8 @@ export interface ManifestLoadedData { // (undocumented) captions?: MediaPlaylist[]; // (undocumented) + contentSteering: Object | null; + // (undocumented) levels: LevelParsed[]; // (undocumented) networkDetails: any; @@ -1868,11 +1876,15 @@ export interface ManifestLoadedData { // (undocumented) sessionKeys: LevelKey[] | null; // (undocumented) + startTimeOffset: number | null; + // (undocumented) stats: LoaderStats; // (undocumented) subtitles?: MediaPlaylist[]; // (undocumented) url: string; + // (undocumented) + variableList: VariableMap | null; } // Warning: (ae-missing-release-tag) "ManifestLoadingData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -2312,6 +2324,11 @@ export interface UserdataSample { uuid?: string; } +// Warning: (ae-missing-release-tag) "VariableMap" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type VariableMap = Record; + // Warnings were encountered during analysis: // // src/config.ts:90:3 - (ae-forgotten-export) The symbol "MediaKeySessionContext" needs to be exported by the entry point hls.d.ts diff --git a/docs/API.md b/docs/API.md index dbf7383873c..608f00e4954 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1722,6 +1722,8 @@ Full list of errors is described below: - data: { type : `NETWORK_ERROR`, details : `Hls.ErrorDetails.LEVEL_LOAD_ERROR`, fatal : `true`, url : level URL, response : { code: error code, text: error text }, loader : URL loader } - `Hls.ErrorDetails.LEVEL_LOAD_TIMEOUT` - raised when level loading fails because of a timeout - data: { type : `NETWORK_ERROR`, details : `Hls.ErrorDetails.LEVEL_LOAD_TIMEOUT`, fatal : `false`, url : level URL, loader : URL loader } +- `Hls.ErrorDetails.LEVEL_PARSING_ERROR` - raised when level parsing failed or found invalid content + - data: { type : `NETWORK_ERROR`, details : `Hls.ErrorDetails.LEVEL_PARSING_ERROR`, fatal : `false`, url : level URL, error: Error } - `Hls.ErrorDetails.AUDIO_TRACK_LOAD_ERROR` - raised when audio playlist loading fails because of a network error - data: { type : `NETWORK_ERROR`, details : `Hls.ErrorDetails.AUDIO_TRACK_LOAD_ERROR`, fatal : `false`, url : audio URL, response : { code: error code, text: error text }, loader : URL loader } - `Hls.ErrorDetails.AUDIO_TRACK_LOAD_TIMEOUT` - raised when audio playlist loading fails because of a timeout diff --git a/src/controller/base-stream-controller.ts b/src/controller/base-stream-controller.ts index 4292f906714..942c6439100 100644 --- a/src/controller/base-stream-controller.ts +++ b/src/controller/base-stream-controller.ts @@ -37,6 +37,7 @@ import type { MediaAttachingData, BufferFlushingData, LevelSwitchingData, + ManifestLoadedData, } from '../types/events'; import type { FragmentTracker } from './fragment-tracker'; import type { Level } from '../types/level'; @@ -82,6 +83,7 @@ export default class BaseStreamController protected lastCurrentTime: number = 0; protected nextLoadPosition: number = 0; protected startPosition: number = 0; + protected startTimeOffset: number | null = null; protected loadedmetadata: boolean = false; protected fragLoadError: number = 0; protected retryDate: number = 0; @@ -115,6 +117,7 @@ export default class BaseStreamController this.fragmentTracker = fragmentTracker; this.config = hls.config; this.decrypter = new Decrypter(hls.config); + hls.on(Events.MANIFEST_LOADED, this.onManifestLoaded, this); hls.on(Events.LEVEL_SWITCHING, this.onLevelSwitching, this); } @@ -283,6 +286,13 @@ export default class BaseStreamController this.startPosition = this.lastCurrentTime = 0; } + protected onManifestLoaded( + event: Events.MANIFEST_LOADED, + data: ManifestLoadedData + ): void { + this.startTimeOffset = data.startTimeOffset; + } + protected onLevelSwitching( event: Events.LEVEL_SWITCHING, data: LevelSwitchingData @@ -1254,9 +1264,13 @@ export default class BaseStreamController startPosition = -1; } if (startPosition === -1 || this.lastCurrentTime === -1) { - // first, check if start time offset has been set in playlist, if yes, use this value - const startTimeOffset = details.startTimeOffset!; - if (Number.isFinite(startTimeOffset)) { + // Use Playlist EXT-X-START:TIME-OFFSET when set + // Prioritize Multi-Variant Playlist offset so that main, audio, and subtitle stream-controller start times match + const offsetInMultiVariantPlaylist = this.startTimeOffset !== null; + const startTimeOffset = offsetInMultiVariantPlaylist + ? this.startTimeOffset + : details.startTimeOffset; + if (startTimeOffset !== null && Number.isFinite(startTimeOffset)) { startPosition = sliding + startTimeOffset; if (startTimeOffset < 0) { startPosition += details.totalduration; @@ -1266,7 +1280,9 @@ export default class BaseStreamController sliding + details.totalduration ); this.log( - `Start time offset ${startTimeOffset} found in playlist, adjust startPosition to ${startPosition}` + `Start time offset ${startTimeOffset} found in ${ + offsetInMultiVariantPlaylist ? 'multi-variant' : 'media' + } playlist, adjust startPosition to ${startPosition}` ); this.startPosition = startPosition; } else if (details.live) { diff --git a/src/controller/level-controller.ts b/src/controller/level-controller.ts index 495183e4906..1dd90ca9a2d 100644 --- a/src/controller/level-controller.ts +++ b/src/controller/level-controller.ts @@ -407,6 +407,7 @@ export default class LevelController extends BasePlaylistController { break; case ErrorDetails.LEVEL_LOAD_ERROR: case ErrorDetails.LEVEL_LOAD_TIMEOUT: + case ErrorDetails.LEVEL_PARSING_ERROR: // Do not perform level switch if an error occurred using delivery directives // Attempt to reload level without directives first if (context) { diff --git a/src/controller/stream-controller.ts b/src/controller/stream-controller.ts index e95b739cf64..d8c5589efd0 100644 --- a/src/controller/stream-controller.ts +++ b/src/controller/stream-controller.ts @@ -871,6 +871,7 @@ export default class StreamController break; case ErrorDetails.LEVEL_LOAD_ERROR: case ErrorDetails.LEVEL_LOAD_TIMEOUT: + case ErrorDetails.LEVEL_PARSING_ERROR: if (this.state !== State.ERROR) { if (data.fatal) { // if fatal error, stop processing diff --git a/src/define-plugin.d.ts b/src/define-plugin.d.ts index 8aa26559a35..f60282a1244 100644 --- a/src/define-plugin.d.ts +++ b/src/define-plugin.d.ts @@ -5,3 +5,5 @@ declare const __USE_ALT_AUDIO__: boolean; declare const __USE_EME_DRM__: boolean; declare const __USE_SUBTITLES__: boolean; declare const __USE_CMCD__: boolean; +declare const __USE_CONTENT_STEERING__: boolean; +declare const __USE_VARIABLE_SUBSTITUTION__: boolean; diff --git a/src/errors.ts b/src/errors.ts index b1662631ee3..fa39eb9dfee 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -40,6 +40,8 @@ export enum ErrorDetails { LEVEL_LOAD_ERROR = 'levelLoadError', // Identifier for a level load timeout - data: { url : faulty URL, response : { code: error code, text: error text }} LEVEL_LOAD_TIMEOUT = 'levelLoadTimeOut', + // Identifier for a level parse error - data: { url : faulty URL, error: Error, reason: error message } + LEVEL_PARSING_ERROR = 'levelParsingError', // Identifier for a level switch error - data: { level : faulty level Id, event : error description} LEVEL_SWITCH_ERROR = 'levelSwitchError', // Identifier for an audio track load error - data: { url : faulty URL, response : { code: error code, text: error text }} diff --git a/src/hls.ts b/src/hls.ts index f63db7bf72a..0f7ded3a7ca 100644 --- a/src/hls.ts +++ b/src/hls.ts @@ -23,7 +23,7 @@ import type SubtitleTrackController from './controller/subtitle-track-controller import type { ComponentAPI, NetworkComponentAPI } from './types/component-api'; import type { MediaPlaylist } from './types/media-playlist'; import type { HlsConfig } from './config'; -import { HdcpLevel, HdcpLevels, Level } from './types/level'; +import { HdcpLevel, HdcpLevels, Level, VariableMap } from './types/level'; import type { Fragment } from './loader/fragment'; import { BufferInfo } from './utils/buffer-helper'; @@ -909,6 +909,7 @@ export type { HlsUrlParameters, LevelAttributes, LevelParsed, + VariableMap, } from './types/level'; export type { PlaylistLevelType, diff --git a/src/loader/fragment-loader.ts b/src/loader/fragment-loader.ts index 9139e539eb4..ee30aae2937 100644 --- a/src/loader/fragment-loader.ts +++ b/src/loader/fragment-loader.ts @@ -314,8 +314,8 @@ function createLoaderContext( export class LoadError extends Error { public readonly data: FragLoadFailResult; - constructor(data: FragLoadFailResult, ...params) { - super(...params); + constructor(data: FragLoadFailResult, message?: string) { + super(message); this.data = data; } } diff --git a/src/loader/key-loader.ts b/src/loader/key-loader.ts index b193d7d5bd5..281fef68245 100644 --- a/src/loader/key-loader.ts +++ b/src/loader/key-loader.ts @@ -68,16 +68,19 @@ export default class KeyLoader implements ComponentAPI { createKeyLoadError( frag: Fragment, details: ErrorDetails = ErrorDetails.KEY_LOAD_ERROR, - networkDetails?: any, - message?: string + message: string, + networkDetails?: any ): LoadError { - return new LoadError({ - type: ErrorTypes.NETWORK_ERROR, - details, - fatal: false, - frag, - networkDetails, - }); + return new LoadError( + { + type: ErrorTypes.NETWORK_ERROR, + details, + fatal: false, + frag, + networkDetails, + }, + message + ); } loadClear( @@ -127,12 +130,7 @@ export default class KeyLoader implements ComponentAPI { ? `Expected frag.decryptdata to be defined after setting format ${keySystemFormat}` : 'Missing decryption data on fragment in onKeyLoading'; return Promise.reject( - this.createKeyLoadError( - frag, - ErrorDetails.KEY_LOAD_ERROR, - null, - errorMessage - ) + this.createKeyLoadError(frag, ErrorDetails.KEY_LOAD_ERROR, errorMessage) ); } const uri = decryptdata.uri; @@ -141,7 +139,6 @@ export default class KeyLoader implements ComponentAPI { this.createKeyLoadError( frag, ErrorDetails.KEY_LOAD_ERROR, - null, `Invalid key URI: "${uri}"` ) ); @@ -190,7 +187,6 @@ export default class KeyLoader implements ComponentAPI { this.createKeyLoadError( frag, ErrorDetails.KEY_LOAD_ERROR, - null, `Key supplied with unsupported METHOD: "${decryptdata.method}"` ) ); @@ -256,8 +252,8 @@ export default class KeyLoader implements ComponentAPI { this.createKeyLoadError( frag, ErrorDetails.KEY_LOAD_ERROR, - networkDetails, - 'after key load, decryptdata unset or changed' + 'after key load, decryptdata unset or changed', + networkDetails ) ); } @@ -282,6 +278,7 @@ export default class KeyLoader implements ComponentAPI { this.createKeyLoadError( frag, ErrorDetails.KEY_LOAD_ERROR, + 'error loading key', networkDetails ) ); @@ -297,6 +294,7 @@ export default class KeyLoader implements ComponentAPI { this.createKeyLoadError( frag, ErrorDetails.KEY_LOAD_TIMEOUT, + 'key loading timed out', networkDetails ) ); @@ -312,6 +310,7 @@ export default class KeyLoader implements ComponentAPI { this.createKeyLoadError( frag, ErrorDetails.INTERNAL_ABORTED, + 'key loading aborted', networkDetails ) ); diff --git a/src/loader/level-details.ts b/src/loader/level-details.ts index 0c71cb6db7b..f7262df1eea 100644 --- a/src/loader/level-details.ts +++ b/src/loader/level-details.ts @@ -2,6 +2,7 @@ import { Part } from './fragment'; import type { Fragment } from './fragment'; import type { AttrList } from '../utils/attr-list'; import type { DateRange } from './date-range'; +import type { VariableMap } from '../types/level'; const DEFAULT_TARGET_DURATION = 10; @@ -48,6 +49,8 @@ export class LevelDetails { public driftStart: number = 0; public driftEnd: number = 0; public encryptedFragments: Fragment[]; + public playlistParsingError: Error | null = null; + public variableList: VariableMap | null = null; constructor(baseUrl) { this.fragments = []; diff --git a/src/loader/m3u8-parser.ts b/src/loader/m3u8-parser.ts index f77e1dd300a..cfc3ff2d2b1 100644 --- a/src/loader/m3u8-parser.ts +++ b/src/loader/m3u8-parser.ts @@ -5,27 +5,45 @@ import { LevelDetails } from './level-details'; import { LevelKey } from './level-key'; import { AttrList } from '../utils/attr-list'; import { logger } from '../utils/logger'; -import type { CodecType } from '../utils/codecs'; +import { + substituteVariables, + substituteVariablesInAttributes, +} from '../utils/variable-substitution'; import { isCodecType } from '../utils/codecs'; +import type { CodecType } from '../utils/codecs'; import type { MediaPlaylist, AudioGroup, MediaPlaylistType, } from '../types/media-playlist'; import type { PlaylistLevelType } from '../types/loader'; -import type { LevelAttributes, LevelParsed } from '../types/level'; +import type { LevelAttributes, LevelParsed, VariableMap } from '../types/level'; type M3U8ParserFragments = Array; -type ParsedMultiVariantPlaylist = { +type ContentSteering = { + uri: string; + pathwayId: string; +}; + +export type ParsedMultiVariantPlaylist = { + contentSteering: ContentSteering | null; levels: LevelParsed[]; + playlistParsingError: Error | null; sessionData: Record | null; sessionKeys: LevelKey[] | null; + startTimeOffset: number | null; + variableList: VariableMap | null; +}; + +type ParsedMultiVariantMediaOptions = { + AUDIO?: MediaPlaylist[]; + SUBTITLES?: MediaPlaylist[]; + 'CLOSED-CAPTIONS'?: MediaPlaylist[]; }; -// https://regex101.com is your friend const MASTER_PLAYLIST_REGEX = - /#EXT-X-STREAM-INF:([^\r\n]*)(?:[\r\n](?:#[^\r\n]*)?)*([^\r\n]+)|#EXT-X-SESSION-DATA:([^\r\n]*)[\r\n]+|#EXT-X-SESSION-KEY:([^\n\r]*)[\r\n]+/g; + /#EXT-X-STREAM-INF:([^\r\n]*)(?:[\r\n](?:#[^\r\n]*)?)*([^\r\n]+)|#EXT-X-(SESSION-DATA|SESSION-KEY|DEFINE|CONTENT-STEERING|START):([^\r\n]*)[\r\n]+/g; const MASTER_PLAYLIST_MEDIA_REGEX = /#EXT-X-MEDIA:(.*)/g; const LEVEL_PLAYLIST_REGEX_FAST = new RegExp( @@ -42,7 +60,7 @@ const LEVEL_PLAYLIST_REGEX_FAST = new RegExp( const LEVEL_PLAYLIST_REGEX_SLOW = new RegExp( [ /#(EXTM3U)/.source, - /#EXT-X-(DATERANGE|KEY|MAP|PART|PART-INF|PLAYLIST-TYPE|PRELOAD-HINT|RENDITION-REPORT|SERVER-CONTROL|SKIP|START):(.+)/ + /#EXT-X-(DATERANGE|DEFINE|KEY|MAP|PART|PART-INF|PLAYLIST-TYPE|PRELOAD-HINT|RENDITION-REPORT|SERVER-CONTROL|SKIP|START):(.+)/ .source, /#EXT-X-(BITRATE|DISCONTINUITY-SEQUENCE|MEDIA-SEQUENCE|TARGETDURATION|VERSION): *(\d+)/ .source, @@ -91,6 +109,11 @@ export default class M3U8Parser { const sessionKeys: LevelKey[] = []; let hasSessionData = false; + let startTimeOffset: number | null = null; + let contentSteering: ContentSteering | undefined; + let variableList: VariableMap | undefined; + let playlistParsingError: Error | null = null; + MASTER_PLAYLIST_REGEX.lastIndex = 0; let result: RegExpExecArray | null; @@ -98,13 +121,30 @@ export default class M3U8Parser { if (result[1]) { // '#EXT-X-STREAM-INF' is found, parse level tag in group 1 const attrs = new AttrList(result[1]); + if (__USE_VARIABLE_SUBSTITUTION__) { + substituteVariablesInAttributes(variableList, attrs, [ + 'CODECS', + 'SUPPLEMENTAL-CODECS', + 'ALLOWED-CPC', + 'PATHWAY-ID', + 'STABLE-VARIANT-ID', + 'AUDIO', + 'VIDEO', + 'SUBTITLES', + 'CLOSED-CAPTIONS', + 'NAME', + ]); + } + const uri = __USE_VARIABLE_SUBSTITUTION__ + ? substituteVariables(variableList, result[2]) + : result[2]; const level: LevelParsed = { attrs, bitrate: attrs.decimalInteger('AVERAGE-BANDWIDTH') || attrs.decimalInteger('BANDWIDTH'), name: attrs.NAME, - url: M3U8Parser.resolve(result[2], baseurl), + url: M3U8Parser.resolve(uri, baseurl), }; const resolution = attrs.decimalResolution('RESOLUTION'); @@ -114,7 +154,7 @@ export default class M3U8Parser { } setCodecs( - (attrs.CODECS || '').split(/[ ,]+/).filter((c) => c), + ((attrs.CODECS as string) || '').split(/[ ,]+/).filter((c) => c), level ); @@ -128,22 +168,85 @@ export default class M3U8Parser { levels.push(level); } else if (result[3]) { - // '#EXT-X-SESSION-DATA' is found, parse session data in group 3 - const sessionAttrs = new AttrList(result[3]); - if (sessionAttrs['DATA-ID']) { - hasSessionData = true; - sessionData[sessionAttrs['DATA-ID']] = sessionAttrs; - } - } else if (result[4]) { - // '#EXT-X-SESSION-KEY' is found - const keyTag = result[4]; - const sessionKey = parseKey(keyTag, baseurl); - if (sessionKey.encrypted && sessionKey.isSupported()) { - sessionKeys.push(sessionKey); - } else { - logger.warn( - `[Keys] Ignoring invalid EXT-X-SESSION-KEY tag: "${keyTag}"` - ); + const tag = result[3]; + const attributes = result[4]; + switch (tag) { + case 'SESSION-DATA': { + // #EXT-X-SESSION-DATA + const sessionAttrs = new AttrList(attributes); + if (__USE_VARIABLE_SUBSTITUTION__) { + substituteVariablesInAttributes(variableList, sessionAttrs, [ + 'DATA-ID', + 'LANGUAGE', + 'VALUE', + 'URI', + ]); + } + const dataId = sessionAttrs['DATA-ID']; + if (dataId) { + hasSessionData = true; + sessionData[dataId] = sessionAttrs; + } + break; + } + case 'SESSION-KEY': { + // #EXT-X-SESSION-KEY + const sessionKey = parseKey(attributes, baseurl, variableList); + if (sessionKey.encrypted && sessionKey.isSupported()) { + sessionKeys.push(sessionKey); + } else { + logger.warn( + `[Keys] Ignoring invalid EXT-X-SESSION-KEY tag: "${attributes}"` + ); + } + break; + } + case 'DEFINE': { + // #EXT-X-DEFINE + if (__USE_VARIABLE_SUBSTITUTION__) { + const variableAttributes = new AttrList(attributes); + substituteVariablesInAttributes( + variableList, + variableAttributes, + ['NAME', 'VALUE'] + ); + const NAME = variableAttributes.NAME; + if (!variableList) { + variableList = {}; + } + if (NAME in variableList) { + playlistParsingError = new Error( + `EXT-X-DEFINE duplicate Variable Name declarations: "${NAME}"` + ); + } else { + variableList[NAME] = variableAttributes.VALUE || ''; + } + } + break; + } + case 'CONTENT-STEERING': { + // #EXT-X-CONTENT-STEERING + const contentSteeringAttributes = new AttrList(attributes); + if (__USE_VARIABLE_SUBSTITUTION__) { + substituteVariablesInAttributes( + variableList, + contentSteeringAttributes, + ['SERVER-URI', 'PATHWAY-ID'] + ); + } + contentSteering = { + uri: contentSteeringAttributes['SERVER-URI'], + pathwayId: contentSteeringAttributes['PATHWAY-ID'] || '.', + }; + break; + } + case 'START': { + // #EXT-X-START + startTimeOffset = parseStartTimeOffset(attributes); + break; + } + default: + break; } } } @@ -152,26 +255,71 @@ export default class M3U8Parser { levelsWithKnownCodecs.length > 0 && levelsWithKnownCodecs.length < levels.length; + const usableLevels = stripUnknownCodecLevels + ? levelsWithKnownCodecs + : levels; + if (usableLevels.length === 0) { + playlistParsingError = new Error( + `no level found in manifest: ${ + stripUnknownCodecLevels ? 'unknown codecs' : 'empty' + }` + ); + } + return { - levels: stripUnknownCodecLevels ? levelsWithKnownCodecs : levels, + contentSteering: contentSteering || null, + levels: usableLevels, + playlistParsingError, sessionData: hasSessionData ? sessionData : null, sessionKeys: sessionKeys.length ? sessionKeys : null, + startTimeOffset, + variableList: variableList || null, }; } static parseMasterPlaylistMedia( string: string, baseurl: string, - type: MediaPlaylistType, - groups: Array = [] - ): Array { + levels: LevelParsed[], + variableList: VariableMap | null + ): ParsedMultiVariantMediaOptions { let result: RegExpExecArray | null; - const medias: Array = []; + const results: ParsedMultiVariantMediaOptions = {}; + const groupsByType = { + AUDIO: levels.map((level: LevelParsed) => ({ + id: level.attrs.AUDIO, + audioCodec: level.audioCodec, + })), + SUBTITLES: levels.map((level: LevelParsed) => ({ + id: level.attrs.SUBTITLES, + textCodec: level.textCodec, + })), + 'CLOSED-CAPTIONS': [], + }; 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 LevelAttributes; - if (attrs.TYPE === type) { + const type: MediaPlaylistType | undefined = attrs.TYPE as + | MediaPlaylistType + | undefined; + if (type) { + const groups = groupsByType[type]; + const medias: MediaPlaylist[] = results[type] || []; + results[type] = medias; + if (__USE_VARIABLE_SUBSTITUTION__) { + substituteVariablesInAttributes(variableList, attrs, [ + 'URI', + 'GROUP-ID', + 'LANGUAGE', + 'ASSOC-LANGUAGE', + 'STABLE-RENDITION-ID', + 'NAME', + 'INSTREAM-ID', + 'CHARACTERISTICS', + 'CHANNELS', + ]); + } const media: MediaPlaylist = { attrs, bitrate: 0, @@ -187,7 +335,7 @@ export default class M3U8Parser { url: attrs.URI ? M3U8Parser.resolve(attrs.URI, baseurl) : '', }; - if (groups.length) { + if (groups?.length) { // If there are audio or text groups signalled in the manifest, let's look for a matching codec string for this track // If we don't find the track signalled, lets use the first audio groups codec we have // Acting as a best guess @@ -200,7 +348,7 @@ export default class M3U8Parser { medias.push(media); } } - return medias; + return results; } static parseLevelPlaylist( @@ -208,7 +356,8 @@ export default class M3U8Parser { baseurl: string, id: number, type: PlaylistLevelType, - levelUrlId: number + levelUrlId: number, + multiVariantVariableList: VariableMap | null ): LevelDetails { const level = new LevelDetails(baseurl); const fragments: M3U8ParserFragments = level.fragments; @@ -278,7 +427,10 @@ export default class M3U8Parser { frag.urlId = levelUrlId; fragments.push(frag); // avoid sliced strings https://github.com/video-dev/hls.js/issues/939 - frag.relurl = (' ' + result[3]).slice(1); + const uri = (' ' + result[3]).slice(1); + frag.relurl = __USE_VARIABLE_SUBSTITUTION__ + ? substituteVariables(level.variableList, uri) + : uri; assignProgramDateTime(frag, prevFrag); prevFrag = frag; totalduration += frag.duration; @@ -328,6 +480,11 @@ export default class M3U8Parser { break; case 'SKIP': { const skipAttrs = new AttrList(value1); + if (__USE_VARIABLE_SUBSTITUTION__) { + substituteVariablesInAttributes(level.variableList, skipAttrs, [ + 'RECENTLY-REMOVED-DATERANGES', + ]); + } const skippedSegments = skipAttrs.decimalInteger('SKIPPED-SEGMENTS'); if (Number.isFinite(skippedSegments)) { @@ -375,6 +532,26 @@ export default class M3U8Parser { break; case 'DATERANGE': { const dateRangeAttr = new AttrList(value1); + if (__USE_VARIABLE_SUBSTITUTION__) { + substituteVariablesInAttributes( + level.variableList, + dateRangeAttr, + [ + 'ID', + 'CLASS', + 'START-DATE', + 'END-DATE', + 'SCTE35-CMD', + 'SCTE35-OUT', + 'SCTE35-IN', + ] + ); + substituteVariablesInAttributes( + level.variableList, + dateRangeAttr, + dateRangeAttr.clientAttrs + ); + } const dateRange = new DateRange( dateRangeAttr, level.dateRanges[dateRangeAttr.ID] @@ -388,11 +565,53 @@ export default class M3U8Parser { frag.tagList.push(['EXT-X-DATERANGE', value1]); break; } + case 'DEFINE': { + if (__USE_VARIABLE_SUBSTITUTION__) { + const variableAttributes = new AttrList(value1); + let variableList = level.variableList; + substituteVariablesInAttributes( + variableList, + variableAttributes, + ['NAME', 'VALUE', 'IMPORT'] + ); + if ('IMPORT' in variableAttributes) { + const IMPORT = variableAttributes.IMPORT; + if ( + multiVariantVariableList && + IMPORT in multiVariantVariableList + ) { + if (!variableList) { + variableList = level.variableList = {}; + } + variableList[IMPORT] = multiVariantVariableList[IMPORT]; + } else { + level.playlistParsingError = new Error( + `EXT-X-DEFINE IMPORT attribute not found in Multivariant Playlist: "${IMPORT}"` + ); + } + } else { + const NAME = variableAttributes.NAME; + if (!variableList) { + variableList = level.variableList = {}; + } + if (NAME in variableList) { + level.playlistParsingError = new Error( + `EXT-X-DEFINE duplicate Variable Name declarations: "${NAME}"` + ); + return level; + } else { + variableList[NAME] = variableAttributes.VALUE || ''; + } + } + } + break; + } + case 'DISCONTINUITY-SEQUENCE': discontinuityCounter = parseInt(value1); break; case 'KEY': { - const levelKey = parseKey(value1, baseurl); + const levelKey = parseKey(value1, baseurl, level.variableList); if (levelKey.isSupported()) { if (levelKey.method === 'NONE') { levelkeys = undefined; @@ -410,18 +629,17 @@ export default class M3U8Parser { } break; } - case 'START': { - const startAttrs = new AttrList(value1); - const startTimeOffset = - startAttrs.decimalFloatingPoint('TIME-OFFSET'); - // TIME-OFFSET can be 0 - if (Number.isFinite(startTimeOffset)) { - level.startTimeOffset = startTimeOffset; - } + case 'START': + level.startTimeOffset = parseStartTimeOffset(value1); break; - } case 'MAP': { const mapAttrs = new AttrList(value1); + if (__USE_VARIABLE_SUBSTITUTION__) { + substituteVariablesInAttributes(level.variableList, mapAttrs, [ + 'BYTERANGE', + 'URI', + ]); + } if (frag.duration) { // Initial segment tag is after segment duration tag. // #EXTINF: 6.0 @@ -474,8 +692,15 @@ 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.variableList, partAttrs, [ + 'BYTERANGE', + 'URI', + ]); + } const part = new Part( - new AttrList(value1), + partAttrs, frag, baseurl, index, @@ -487,11 +712,25 @@ export default class M3U8Parser { } case 'PRELOAD-HINT': { const preloadHintAttrs = new AttrList(value1); + if (__USE_VARIABLE_SUBSTITUTION__) { + substituteVariablesInAttributes( + level.variableList, + preloadHintAttrs, + ['URI'] + ); + } level.preloadHint = preloadHintAttrs; break; } case 'RENDITION-REPORT': { const renditionReportAttrs = new AttrList(value1); + if (__USE_VARIABLE_SUBSTITUTION__) { + substituteVariablesInAttributes( + level.variableList, + renditionReportAttrs, + ['URI'] + ); + } level.renditionReports = level.renditionReports || []; level.renditionReports.push(renditionReportAttrs); break; @@ -554,16 +793,28 @@ export default class M3U8Parser { } } -function parseKey(keyTag: string, baseurl: string): LevelKey { +function parseKey( + keyTagAttributes: string, + baseurl: string, + variableList: VariableMap | null | undefined +): LevelKey { // https://tools.ietf.org/html/rfc8216#section-4.3.2.4 - const keyAttrs = new AttrList(keyTag); - const decryptmethod = keyAttrs.enumeratedString('METHOD') ?? ''; + const keyAttrs = new AttrList(keyTagAttributes); + if (__USE_VARIABLE_SUBSTITUTION__) { + substituteVariablesInAttributes(variableList, keyAttrs, [ + 'KEYFORMAT', + 'KEYFORMATVERSIONS', + 'URI', + 'IV', + 'URI', + ]); + } + const decryptmethod = keyAttrs.METHOD ?? ''; const decrypturi = keyAttrs.URI; const decryptiv = keyAttrs.hexadecimalInteger('IV'); - const decryptkeyformatversions = - keyAttrs.enumeratedString('KEYFORMATVERSIONS'); + const decryptkeyformatversions = keyAttrs.KEYFORMATVERSIONS; // From RFC: This attribute is OPTIONAL; its absence indicates an implicit value of "identity". - const decryptkeyformat = keyAttrs.enumeratedString('KEYFORMAT') ?? 'identity'; + const decryptkeyformat = keyAttrs.KEYFORMAT ?? 'identity'; if (decrypturi && keyAttrs.IV && !decryptiv) { logger.error(`Invalid IV: ${keyAttrs.IV}`); @@ -587,6 +838,15 @@ function parseKey(keyTag: string, baseurl: string): LevelKey { ); } +function parseStartTimeOffset(startAttributes: string): number | null { + const startAttrs = new AttrList(startAttributes); + const startTimeOffset = startAttrs.decimalFloatingPoint('TIME-OFFSET'); + if (Number.isFinite(startTimeOffset)) { + return startTimeOffset; + } + return null; +} + function setCodecs(codecs: Array, level: LevelParsed) { ['video', 'audio', 'text'].forEach((type: CodecType) => { const filtered = codecs.filter((codec) => isCodecType(codec, type)); diff --git a/src/loader/playlist-loader.ts b/src/loader/playlist-loader.ts index d2a2387a7d4..713988e843f 100644 --- a/src/loader/playlist-loader.ts +++ b/src/loader/playlist-loader.ts @@ -13,7 +13,7 @@ import { Events } from '../events'; import { ErrorDetails, ErrorTypes } from '../errors'; import { logger } from '../utils/logger'; import M3U8Parser from './m3u8-parser'; -import type { LevelParsed } from '../types/level'; +import type { LevelParsed, VariableMap } from '../types/level'; import type { Loader, LoaderConfiguration, @@ -68,6 +68,7 @@ class PlaylistLoader implements NetworkComponentAPI { private readonly loaders: { [key: string]: Loader; } = Object.create(null); + private variableList: VariableMap | null = null; constructor(hls: Hls) { this.hls = hls; @@ -142,6 +143,7 @@ class PlaylistLoader implements NetworkComponentAPI { } public destroy(): void { + this.variableList = null; this.unregisterListeners(); this.destroyInternalLoaders(); } @@ -151,6 +153,7 @@ class PlaylistLoader implements NetworkComponentAPI { data: ManifestLoadingData ) { const { url } = data; + this.variableList = null; this.load({ id: null, groupId: null, @@ -324,7 +327,7 @@ class PlaylistLoader implements NetworkComponentAPI { this.handleManifestParsingError( response, context, - 'no EXTM3U delimiter', + new Error('no EXTM3U delimiter'), networkDetails ); return; @@ -369,48 +372,34 @@ class PlaylistLoader implements NetworkComponentAPI { const url = getResponseUrl(response, context); - const { levels, sessionData, sessionKeys } = M3U8Parser.parseMasterPlaylist( - string, - url - ); - if (!levels.length) { + const parsedResult = M3U8Parser.parseMasterPlaylist(string, url); + + if (parsedResult.playlistParsingError) { this.handleManifestParsingError( response, context, - 'no level found in manifest', + parsedResult.playlistParsingError, networkDetails ); return; } - // multi level playlist, parse level info - const audioGroups = levels.map((level: LevelParsed) => ({ - id: level.attrs.AUDIO, - audioCodec: level.audioCodec, - })); + const { + contentSteering, + levels, + sessionData, + sessionKeys, + startTimeOffset, + variableList, + } = parsedResult; - const subtitleGroups = levels.map((level: LevelParsed) => ({ - id: level.attrs.SUBTITLES, - textCodec: level.textCodec, - })); + this.variableList = variableList; - const audioTracks = M3U8Parser.parseMasterPlaylistMedia( - string, - url, - 'AUDIO', - audioGroups - ); - const subtitles = M3U8Parser.parseMasterPlaylistMedia( - string, - url, - 'SUBTITLES', - subtitleGroups - ); - const captions = M3U8Parser.parseMasterPlaylistMedia( - string, - url, - 'CLOSED-CAPTIONS' - ); + const { + AUDIO: audioTracks = [], + SUBTITLES: subtitles, + 'CLOSED-CAPTIONS': captions, + } = M3U8Parser.parseMasterPlaylistMedia(string, url, levels, variableList); if (audioTracks.length) { // check if we have found an audio track embedded in main playlist (audio track without URI attribute) @@ -449,11 +438,14 @@ class PlaylistLoader implements NetworkComponentAPI { audioTracks, subtitles, captions, + contentSteering, url, stats, networkDetails, sessionData, sessionKeys, + startTimeOffset, + variableList, }); } @@ -467,17 +459,34 @@ class PlaylistLoader implements NetworkComponentAPI { const { id, level, type } = context; const url = getResponseUrl(response, context); - const levelUrlId = Number.isFinite(id as number) ? id : 0; - const levelId = Number.isFinite(level as number) ? level : levelUrlId; + const levelUrlId = Number.isFinite(id as number) ? (id as number) : 0; + const levelId = Number.isFinite(level as number) + ? (level as number) + : levelUrlId; const levelType = mapContextToLevelType(context); const levelDetails: LevelDetails = M3U8Parser.parseLevelPlaylist( response.data as string, url, - levelId!, + levelId, levelType, - levelUrlId! + levelUrlId, + this.variableList ); + const error = levelDetails.playlistParsingError; + if (error) { + hls.trigger(Events.ERROR, { + type: ErrorTypes.NETWORK_ERROR, + details: ErrorDetails.LEVEL_PARSING_ERROR, + fatal: false, + url: url, + err: error, + error, + reason: error.message, + level: typeof context.level === 'number' ? context.level : undefined, + }); + return; + } if (!levelDetails.fragments.length) { hls.trigger(Events.ERROR, { type: ErrorTypes.NETWORK_ERROR, @@ -511,6 +520,9 @@ class PlaylistLoader implements NetworkComponentAPI { networkDetails, sessionData: null, sessionKeys: null, + contentSteering: null, + startTimeOffset: null, + variableList: null, }); } @@ -526,7 +538,7 @@ class PlaylistLoader implements NetworkComponentAPI { private handleManifestParsingError( response: LoaderResponse, context: PlaylistLoaderContext, - reason: string, + error: Error, networkDetails: any ): void { this.hls.trigger(Events.ERROR, { @@ -534,7 +546,9 @@ class PlaylistLoader implements NetworkComponentAPI { details: ErrorDetails.MANIFEST_PARSING_ERROR, fatal: context.type === PlaylistContextType.MANIFEST, url: response.url, - reason, + err: error, + error, + reason: error.message, response, context, networkDetails, @@ -627,7 +641,7 @@ class PlaylistLoader implements NetworkComponentAPI { this.handleManifestParsingError( response, context, - 'invalid target duration', + new Error('invalid target duration'), networkDetails ); return; diff --git a/src/types/events.ts b/src/types/events.ts index 64c927a6f0b..5c7d05401f8 100644 --- a/src/types/events.ts +++ b/src/types/events.ts @@ -3,7 +3,12 @@ import type { Fragment } from '../loader/fragment'; // eslint-disable-next-line import/no-duplicates import type { Part } from '../loader/fragment'; import type { LevelDetails } from '../loader/level-details'; -import type { HlsUrlParameters, Level, LevelParsed } from './level'; +import type { + HlsUrlParameters, + Level, + LevelParsed, + VariableMap, +} from './level'; import type { MediaPlaylist, MediaPlaylistType } from './media-playlist'; import type { Loader, @@ -81,13 +86,16 @@ export interface ManifestLoadingData { export interface ManifestLoadedData { audioTracks: MediaPlaylist[]; captions?: MediaPlaylist[]; + contentSteering: Object | null; levels: LevelParsed[]; networkDetails: any; sessionData: Record | null; sessionKeys: LevelKey[] | null; + startTimeOffset: number | null; stats: LoaderStats; subtitles?: MediaPlaylist[]; url: string; + variableList: VariableMap | null; } export interface ManifestParsedData { diff --git a/src/types/level.ts b/src/types/level.ts index bb8b41ba613..166c8649416 100644 --- a/src/types/level.ts +++ b/src/types/level.ts @@ -46,6 +46,8 @@ export interface LevelAttributes extends AttrList { export const HdcpLevels = ['NONE', 'TYPE-0', 'TYPE-1', 'TYPE-2', null] as const; export type HdcpLevel = typeof HdcpLevels[number]; +export type VariableMap = Record; + export enum HlsSkip { No = '', Yes = 'YES', diff --git a/src/utils/attr-list.ts b/src/utils/attr-list.ts index 08aec03b91d..82faa4ea78a 100755 --- a/src/utils/attr-list.ts +++ b/src/utils/attr-list.ts @@ -12,6 +12,10 @@ export class AttrList { for (const attr in attrs) { if (attrs.hasOwnProperty(attr)) { + if (attr.substring(0, 2) === 'X-') { + this.clientAttrs = this.clientAttrs || []; + this.clientAttrs.push(attr); + } this[attr] = attrs[attr]; } } diff --git a/src/utils/fetch-loader.ts b/src/utils/fetch-loader.ts index 90075e44bbb..7d45884f2f4 100644 --- a/src/utils/fetch-loader.ts +++ b/src/utils/fetch-loader.ts @@ -27,6 +27,8 @@ export function fetchSupported() { return false; } +const BYTERANGE = /(\d+)-(\d+)\/(\d+)/; + class FetchLoader implements Loader { private fetchSetup: Function; private requestTimeout?: number; @@ -109,7 +111,8 @@ class FetchLoader implements Loader { self.performance.now(), stats.loading.start ); - stats.total = parseInt(response.headers.get('Content-Length') || '0'); + + stats.total = getContentLength(response.headers) || stats.total; if (onProgress && Number.isFinite(config.highWaterMark)) { return this.loadProgressively( @@ -243,6 +246,27 @@ function getRequestParameters(context: LoaderContext, signal): any { return initParams; } +function getByteRangeLength(byteRangeHeader: string): number | undefined { + const result = BYTERANGE.exec(byteRangeHeader); + if (result) { + return parseInt(result[2]) - parseInt(result[1]) + 1; + } +} + +function getContentLength(headers: Headers): number | undefined { + const contentRange = headers.get('Content-Range'); + if (contentRange) { + const byteRangeLength = getByteRangeLength(contentRange); + if (Number.isFinite(byteRangeLength)) { + return byteRangeLength; + } + } + const contentLength = headers.get('Content-Length'); + if (contentLength) { + return parseInt(contentLength); + } +} + function getRequest(context: LoaderContext, initParams: any): Request { return new self.Request(context.url, initParams); } diff --git a/src/utils/variable-substitution.ts b/src/utils/variable-substitution.ts new file mode 100644 index 00000000000..e5b4b8b6cec --- /dev/null +++ b/src/utils/variable-substitution.ts @@ -0,0 +1,47 @@ +import { logger } from './logger'; +import type { AttrList } from './attr-list'; +import type { VariableMap } from '../types/level'; + +const VARIABLE_REPLACEMENT_REGEX = /\{\$([a-zA-Z0-9-_]+)\}/g; + +export function substituteVariablesInAttributes( + variableList: VariableMap | null | undefined, + attr: AttrList, + attributeNames: string[] +) { + if (variableList) { + for (let i = attributeNames.length; i--; ) { + const name = attributeNames[i]; + const value = attr[name]; + if (value) { + attr[name] = substituteVariables(variableList, value); + } + } + } +} + +export function substituteVariables( + variableList: VariableMap | null | undefined, + value: string +): string { + if (variableList) { + return value.replace( + VARIABLE_REPLACEMENT_REGEX, + (variableReference: string) => { + const variableName = variableReference.substring( + 2, + variableReference.length - 1 + ); + const variableValue = variableList[variableName]; + if (variableValue === undefined) { + logger.error( + `Missing preceding EXT-X-DEFINE tag for Variable Reference "${variableName}"` + ); + return variableReference; + } + return variableValue; + } + ); + } + return value; +} diff --git a/src/utils/xhr-loader.ts b/src/utils/xhr-loader.ts index aaec2f68bd6..6728661aad6 100644 --- a/src/utils/xhr-loader.ts +++ b/src/utils/xhr-loader.ts @@ -8,7 +8,7 @@ import type { } from '../types/loader'; import { LoadStats } from '../loader/load-stats'; -const AGE_HEADER_LINE_REGEX = /^age:\s*[\d.]+\s*$/m; +const AGE_HEADER_LINE_REGEX = /^age:\s*[\d.]+\s*$/im; class XhrLoader implements Loader { private xhrSetup: Function | null; diff --git a/tests/unit/controller/level-controller.ts b/tests/unit/controller/level-controller.ts index 0a9b531750b..6ec29fc7064 100644 --- a/tests/unit/controller/level-controller.ts +++ b/tests/unit/controller/level-controller.ts @@ -103,6 +103,9 @@ describe('LevelController', function () { networkDetails: '', sessionData: null, sessionKeys: null, + contentSteering: null, + startTimeOffset: null, + variableList: null, stats: {} as any, subtitles: [], url: 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8', @@ -192,6 +195,9 @@ describe('LevelController', function () { subtitles: [], sessionData: null, sessionKeys: null, + contentSteering: null, + startTimeOffset: null, + variableList: null, stats: {} as any, url: 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8', }; @@ -230,6 +236,9 @@ describe('LevelController', function () { subtitles: [], sessionData: null, sessionKeys: null, + contentSteering: null, + startTimeOffset: null, + variableList: null, stats: {} as any, url: 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8', }; @@ -271,6 +280,9 @@ describe('LevelController', function () { subtitles: [], sessionData: null, sessionKeys: null, + contentSteering: null, + startTimeOffset: null, + variableList: null, stats: {} as any, url: 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8', }; @@ -307,6 +319,9 @@ describe('LevelController', function () { subtitles: [], sessionData: null, sessionKeys: null, + contentSteering: null, + startTimeOffset: null, + variableList: null, stats: {} as any, url: 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8', }; @@ -342,6 +357,9 @@ describe('LevelController', function () { subtitles: [], sessionData: null, sessionKeys: null, + contentSteering: null, + startTimeOffset: null, + variableList: null, stats: {} as any, url: 'foo', }; diff --git a/tests/unit/controller/stream-controller.ts b/tests/unit/controller/stream-controller.ts index 7d43a700625..d9da551925f 100644 --- a/tests/unit/controller/stream-controller.ts +++ b/tests/unit/controller/stream-controller.ts @@ -11,9 +11,11 @@ import { mockFragments } from '../../mocks/data'; import { Fragment } from '../../../src/loader/fragment'; import { LevelDetails } from '../../../src/loader/level-details'; import M3U8Parser from '../../../src/loader/m3u8-parser'; +import { LoadStats } from '../../../src/loader/load-stats'; import { PlaylistLevelType } from '../../../src/types/loader'; import { AttrList } from '../../../src/utils/attr-list'; import { Level, LevelAttributes } from '../../../src/types/level'; +import type { ParsedMultiVariantPlaylist } from '../../../src/loader/m3u8-parser'; import * as sinon from 'sinon'; import * as chai from 'chai'; @@ -23,18 +25,24 @@ chai.use(sinonChai); const expect = chai.expect; describe('StreamController', function () { + let fake; let hls: Hls; let fragmentTracker: FragmentTracker; let streamController: StreamController; const attrs: LevelAttributes = new AttrList({}); beforeEach(function () { + fake = sinon.useFakeXMLHttpRequest(); hls = new Hls({}); streamController = hls['streamController']; fragmentTracker = streamController['fragmentTracker']; streamController['startFragRequested'] = true; }); + this.afterEach(function () { + fake.restore(); + }); + /** * Assert: streamController should be started * @param {StreamController} streamController @@ -59,6 +67,34 @@ describe('StreamController', function () { ); }; + const loadManifest = (manifest: string): ParsedMultiVariantPlaylist => { + const result = M3U8Parser.parseMasterPlaylist( + manifest, + 'http://www.example.com' + ); + const { + contentSteering, + levels, + sessionData, + sessionKeys, + startTimeOffset, + variableList, + } = result; + hls.trigger(Events.MANIFEST_LOADED, { + levels, + audioTracks: [], + contentSteering, + url: 'http://www.example.com', + stats: new LoadStats(), + networkDetails: {}, + sessionData, + sessionKeys, + startTimeOffset, + variableList, + }); + return result; + }; + describe('StreamController', function () { it('should be STOPPED when it is initialized', function () { assertStreamControllerStopped(streamController); @@ -69,31 +105,82 @@ describe('StreamController', function () { assertStreamControllerStopped(streamController); }); - it('should start without levels data', function () { - const manifest = `#EXTM3U + it('should start without level details', function () { + loadManifest(`#EXTM3U + #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=836280,RESOLUTION=848x360,NAME="480" + http://proxy-62.dailymotion.com/sec(3ae40f708f79ca9471f52b86da76a3a8)/video/107/282/158282701_mp4_h264_aac_hq.m3u8#cell=core`); + assertStreamControllerStarted(streamController); + streamController.stopLoad(); + assertStreamControllerStopped(streamController); + }); + + it('should use EXT-X-START from Multi-Variant Playlist when not overridden by startPosition', function () { + loadManifest(`#EXTM3U + #EXT-X-START:TIME-OFFSET=130.5 #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=836280,RESOLUTION=848x360,NAME="480" - http://proxy-62.dailymotion.com/sec(3ae40f708f79ca9471f52b86da76a3a8)/video/107/282/158282701_mp4_h264_aac_hq.m3u8#cell=core`; - const { levels: levelsParsed } = M3U8Parser.parseMasterPlaylist( - manifest, - 'http://www.dailymotion.com' - ); - // load levels data - const levels = levelsParsed.map((levelParsed) => new Level(levelParsed)); - streamController['onManifestParsed'](Events.MANIFEST_PARSED, { - altAudio: false, - audio: false, - audioTracks: [], - firstLevel: 0, - // @ts-ignore - stats: undefined, - subtitleTracks: [], - video: false, + http://www.example.com/media.m3u8`); + assertStreamControllerStarted(streamController); + // Trigger Level Loaded + const details = new LevelDetails(''); + details.live = false; + details.totalduration = 200; + details.fragments.push({} as any); + hls.trigger(Events.LEVEL_LOADED, { + details, + id: 0, + level: 0, + networkDetails: {}, + stats: new LoadStats(), + deliveryDirectives: null, + }); + expect(streamController['startPosition']).to.equal(130.5); + expect(streamController['nextLoadPosition']).to.equal(130.5); + expect(streamController['lastCurrentTime']).to.equal(130.5); + }); + + it('should use EXT-X-START from Multi-Variant Playlist when not overridden by startPosition with live playlists', function () { + const result = loadManifest(`#EXTM3U + #EXT-X-START:TIME-OFFSET=-12.0 + #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=836280,RESOLUTION=848x360,NAME="480" + http://www.example.com/media.m3u8`); + const { + contentSteering, + levels, + sessionData, + sessionKeys, + startTimeOffset, + variableList, + } = result; + hls.trigger(Events.MANIFEST_LOADED, { levels, + audioTracks: [], + contentSteering, + url: 'http://www.example.com', + stats: new LoadStats(), + networkDetails: {}, + sessionData, + sessionKeys, + startTimeOffset, + variableList, }); - streamController.startLoad(1); assertStreamControllerStarted(streamController); - streamController.stopLoad(); - assertStreamControllerStopped(streamController); + + // Trigger Level Loaded + const details = new LevelDetails(''); + details.live = true; + details.totalduration = 30; + details.fragments.push({ start: 0 } as any); + hls.trigger(Events.LEVEL_LOADED, { + details, + id: 0, + level: 0, + networkDetails: {}, + stats: new LoadStats(), + deliveryDirectives: null, + }); + expect(streamController['startPosition']).to.equal(18); + expect(streamController['nextLoadPosition']).to.equal(18); + expect(streamController['lastCurrentTime']).to.equal(18); }); }); diff --git a/tests/unit/loader/playlist-loader.ts b/tests/unit/loader/playlist-loader.ts index f27f7c284c8..b34bd9d0214 100644 --- a/tests/unit/loader/playlist-loader.ts +++ b/tests/unit/loader/playlist-loader.ts @@ -1,11 +1,11 @@ import M3U8Parser from '../../../src/loader/m3u8-parser'; import { AttrList } from '../../../src/utils/attr-list'; import { PlaylistLevelType } from '../../../src/types/loader'; +import { LevelKey } from '../../../src/loader/level-key'; +import { Fragment, Part } from '../../../src/loader/fragment'; import * as chai from 'chai'; import * as sinonChai from 'sinon-chai'; -import { LevelKey } from '../../../src/loader/level-key'; -import { Fragment, Part } from '../../../src/loader/fragment'; chai.use(sinonChai); const expect = chai.expect; @@ -290,10 +290,12 @@ http://proxy-62.dailymotion.com/sec(3ae40f708f79ca9471f52b86da76a3a8)/video/107/ 'http://proxy-62.dailymotion.com/sec(3ae40f708f79ca9471f52b86da76a3a8)/video/107/282/158282701_mp4_h264_aac_hq.m3u8#cell=core', 0, PlaylistLevelType.MAIN, - 0 + 0, + null ); expect(result.fragments).to.have.lengthOf(0); expect(result.totalduration).to.equal(0); + expect(result.variableList).to.equal(null); }); it('level with 0 frag returns empty fragment array', function () { @@ -306,7 +308,8 @@ http://proxy-62.dailymotion.com/sec(3ae40f708f79ca9471f52b86da76a3a8)/video/107/ 'http://proxy-62.dailymotion.com/sec(3ae40f708f79ca9471f52b86da76a3a8)/video/107/282/158282701_mp4_h264_aac_hq.m3u8#cell=core', 0, PlaylistLevelType.MAIN, - 0 + 0, + null ); expect(result.fragments).to.have.lengthOf(0); expect(result.totalduration).to.equal(0); @@ -334,8 +337,10 @@ http://proxy-62.dailymotion.com/sec(3ae40f708f79ca9471f52b86da76a3a8)/video/107/ 'http://proxy-62.dailymotion.com/sec(3ae40f708f79ca9471f52b86da76a3a8)/video/107/282/158282701_mp4_h264_aac_hq.m3u8#cell=core', 0, PlaylistLevelType.MAIN, - 0 + 0, + null ); + expect(result.variableList).to.equal(null); expect(result.totalduration).to.equal(51.24); expect(result.startSN).to.equal(0); expect(result.version).to.equal(3); @@ -372,7 +377,8 @@ http://proxy-62.dailymotion.com/sec(3ae40f708f79ca9471f52b86da76a3a8)/video/107/ 'http://proxy-62.dailymotion.com/sec(3ae40f708f79ca9471f52b86da76a3a8)/frag(5)/video/107/282/158282701_mp4_h264_aac_hq.m3u8#cell=core', 0, PlaylistLevelType.MAIN, - 0 + 0, + null ); expect(result.totalduration).to.equal(4); expect(result.startSN).to.equal(0); @@ -412,7 +418,8 @@ chop/segment-5.ts 'http://proxy-62.dailymotion.com/sec(3ae40f708f79ca9471f52b86da76a3a8)/frag(5)/video/107/282/158282701_mp4_h264_aac_hq.m3u8#cell=core', 0, PlaylistLevelType.MAIN, - 0 + 0, + null ); expect(result.totalduration).to.equal(30); expect(result.startSN).to.equal(0); @@ -455,7 +462,8 @@ chop/segment-5.ts 'http://proxy-62.dailymotion.com/sec(3ae40f708f79ca9471f52b86da76a3a8)/video/107/282/158282701_mp4_h264_aac_hq.m3u8#cell=core', 0, PlaylistLevelType.MAIN, - 0 + 0, + null ); expect(result.totalduration).to.equal(51.24); expect(result.startSN).to.equal(0); @@ -484,7 +492,8 @@ oceans_aes-audio=65000-video=236000-3.ts 'http://foo.com/adaptive/oceans_aes/oceans_aes.m3u8', 0, PlaylistLevelType.MAIN, - 0 + 0, + null ); expect(result.totalduration).to.equal(25); expect(result.startSN).to.equal(1); @@ -532,7 +541,8 @@ oceans_aes-audio=65000-video=236000-3.ts 'http://foo.com/adaptive/oceans_aes/oceans_aes.m3u8', 0, PlaylistLevelType.MAIN, - 0 + 0, + null ); expect(result.totalduration).to.equal(25); expect(result.startSN).to.equal(1); @@ -613,7 +623,8 @@ lo008ts`; 'http://dummy.com/playlist.m3u8', 0, PlaylistLevelType.MAIN, - 0 + 0, + null ); expect(result.fragments.length).to.equal(10); expect(result.fragments[0].url).to.equal('http://dummy.com/lo007ts'); @@ -668,7 +679,8 @@ lo008ts`; 'http://dummy.com/playlist.m3u8', 0, PlaylistLevelType.MAIN, - 0 + 0, + null ); expect(result.fragments).to.have.lengthOf(10); expect(result.fragments[0].url).to.equal('http://dummy.com/lo007ts'); @@ -702,7 +714,8 @@ lo007ts`; 'http://dummy.com/playlist.m3u8', 0, PlaylistLevelType.MAIN, - 0 + 0, + null ); expect(result.fragments.length).to.equal(3); expect(result.fragments[0].url).to.equal('http://dummy.com/lo007ts'); @@ -738,7 +751,8 @@ lo007ts`; 'http://video.example.com/disc.m3u8', 0, PlaylistLevelType.MAIN, - 0 + 0, + null ); expect(result.fragments).to.have.lengthOf(5); expect(result.totalduration).to.equal(45); @@ -771,7 +785,8 @@ lo007ts`; 'http://video.example.com/disc.m3u8', 0, PlaylistLevelType.MAIN, - 0 + 0, + null ); expect(result.fragments).to.have.lengthOf(5); expect(result.totalduration).to.equal(45); @@ -783,10 +798,11 @@ lo007ts`; it('parses manifest with one audio track', function () { const manifest = `#EXTM3U #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="600k",LANGUAGE="eng",NAME="Audio",AUTOSELECT=YES,DEFAULT=YES,URI="/videos/ZakEbrahim_2014/audio/600k.m3u8?qr=true&preroll=Blank",BANDWIDTH=614400`; - const result = M3U8Parser.parseMasterPlaylistMedia( + const { AUDIO: result = [] } = M3U8Parser.parseMasterPlaylistMedia( manifest, 'https://hls.ted.com/', - 'AUDIO' + [], + null ); expect(result.length).to.equal(1); expect(result[0].autoselect).to.be.true; @@ -828,7 +844,8 @@ lo007ts`; 'http://dummy.com/playlist.m3u8', 0, PlaylistLevelType.MAIN, - 0 + 0, + null ); expect(result.fragments).to.have.lengthOf(8); expect(result.totalduration).to.equal(80); @@ -892,7 +909,8 @@ http://dummy.url.com/hls/live/segment/segment_022916_164500865_719935.ts`; 'http://dummy.url.com/playlist.m3u8', 0, PlaylistLevelType.MAIN, - 0 + 0, + null ); expect(result.fragments).to.have.lengthOf(10); expect(result.totalduration).to.equal(84.94); @@ -934,7 +952,8 @@ Rollover38803/20160525T064049-01-69844069.ts 'http://video.example.com/disc.m3u8', 0, PlaylistLevelType.MAIN, - 0 + 0, + null ); expect(result.fragments).to.have.lengthOf(3); expect(result.hasProgramDateTime).to.be.true; @@ -966,7 +985,8 @@ Rollover38803/20160525T064049-01-69844069.ts 'http://proxy-62.dailymotion.com/sec(3ae40f708f79ca9471f52b86da76a3a8)/video/107/282/158282701_mp4_h264_aac_hq.m3u8#cell=core', 0, PlaylistLevelType.MAIN, - 0 + 0, + null ); expect(result.fragments).to.have.lengthOf(1); expect(result.fragments[0].duration).to.equal(0.36); @@ -988,7 +1008,8 @@ main.mp4`; 'http://proxy-62.dailymotion.com/sec(3ae40f708f79ca9471f52b86da76a3a8)/video/107/282/158282701_mp4_h264_aac_hq.m3u8#cell=core', 0, PlaylistLevelType.MAIN, - 0 + 0, + null ); const initSegment = result.fragments[0].initSegment; expect(initSegment?.url).to.equal( @@ -1019,7 +1040,8 @@ frag2.mp4 'http://video.example.com/disc.m3u8', 0, PlaylistLevelType.MAIN, - 0 + 0, + null ); expect(result.fragments[0].initSegment?.url).to.equal( 'http://video.example.com/main.mp4' @@ -1052,7 +1074,8 @@ Rollover38803/20160525T064049-01-69844069.ts 'http://video.example.com/disc.m3u8', 0, PlaylistLevelType.MAIN, - 0 + 0, + null ); expect(result.hasProgramDateTime).to.be.true; expect(result.fragments[0].rawProgramDateTime).to.equal( @@ -1085,7 +1108,8 @@ frag2.ts 'http://video.example.com/disc.m3u8', 0, PlaylistLevelType.MAIN, - 0 + 0, + null ); expect(result.hasProgramDateTime).to.be.true; expect(result.fragments[2].rawProgramDateTime).to.equal( @@ -1111,7 +1135,8 @@ frag2.ts 'http://video.example.com/disc.m3u8', 0, PlaylistLevelType.MAIN, - 0 + 0, + null ); expect(result.hasProgramDateTime).to.be.true; expect(result.fragments[0].rawProgramDateTime).to.equal( @@ -1149,7 +1174,8 @@ frag5.ts 'http://video.example.com/disc.m3u8', 0, PlaylistLevelType.MAIN, - 0 + 0, + null ); expect(result.hasProgramDateTime).to.be.true; expect(result.fragments[0].programDateTime).to.equal(1464366904000); @@ -1183,7 +1209,8 @@ frag1.ts 'http://video.example.com/disc.m3u8', 0, PlaylistLevelType.MAIN, - 0 + 0, + null ); expect(result.hasProgramDateTime).to.be.true; expect(result.fragments[0].rawProgramDateTime).to.equal( @@ -1205,7 +1232,8 @@ frag1.ts 'http://video.example.com/disc.m3u8', 0, PlaylistLevelType.MAIN, - 0 + 0, + null ); expect(result.hasProgramDateTime).to.be.false; expect(result.fragments[0].rawProgramDateTime).to.not.exist; @@ -1247,16 +1275,17 @@ fileSequence1151232.ts fileSequence1151233.ts #EXT-X-PRELOAD-HINT:TYPE=PART,URI="lowLatencyHLS.php?segment=filePart1151234.1.ts" #EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,CAN-SKIP-UNTIL=24,PART-HOLD-BACK=3.012 -#EXT-X-RENDITION-REPORT:URI="/media0/lowLatencyHLS.php",LAST-MSN=1151201,LAST-PART=3,LAST-I-MSN=1151201,LAST-I-PART=3 -#EXT-X-RENDITION-REPORT:URI="/media2/lowLatencyHLS.php",LAST-MSN=1151201,LAST-PART=3,LAST-I-MSN=1151201,LAST-I-PART=3`; +#EXT-X-RENDITION-REPORT:URI="/media0/lowLatencyHLS.php",LAST-MSN=1151201,LAST-PART=3 +#EXT-X-RENDITION-REPORT:URI="/media2/lowLatencyHLS.php",LAST-MSN=1151201,LAST-PART=3`; - it('Parses the SERVER-CONTROL tag', function () { + it('Parses EXT-X-SERVER-CONTROL', function () { const details = M3U8Parser.parseLevelPlaylist( playlist, 'http://dummy.url.com/playlist.m3u8', 0, PlaylistLevelType.MAIN, - 0 + 0, + null ); expect(details.canBlockReload).to.be.true; expect(details.canSkipUntil).to.equal(24); @@ -1266,7 +1295,7 @@ fileSequence1151233.ts expect(details.canSkipDateRanges).to.be.false; }); - it('Parses the SERVER-CONTROL CAN-SKIP-DATERANGES and HOLD-BACK attributes', function () { + it('Parses EXT-X-SERVER-CONTROL CAN-SKIP-DATERANGES and HOLD-BACK attributes', function () { const details = M3U8Parser.parseLevelPlaylist( `#EXTM3U #EXT-X-TARGETDURATION:4 @@ -1277,7 +1306,8 @@ fileSequence1151226.ts`, 'http://dummy.url.com/playlist.m3u8', 0, PlaylistLevelType.MAIN, - 0 + 0, + null ); expect(details.canSkipUntil).to.equal(20); expect(details.holdBack).to.equal(15.1); @@ -1288,24 +1318,26 @@ fileSequence1151226.ts`, expect(details.partTarget).to.equal(0); }); - it('Parses the PART-INF tag', function () { + it('Parses EXT-X-PART-INF', function () { const details = M3U8Parser.parseLevelPlaylist( playlist, 'http://dummy.url.com/playlist.m3u8', 0, PlaylistLevelType.MAIN, - 0 + 0, + null ); expect(details.partTarget).to.equal(1.004); }); - it('Parses the PART tags', function () { + it('Parses EXT-X-PART', function () { const details = M3U8Parser.parseLevelPlaylist( playlist, 'http://dummy.url.com/playlist.m3u8', 0, PlaylistLevelType.MAIN, - 0 + 0, + null ); // TODO: Partial Segments for a yet to be appended EXT-INF entry will be added to the fragments list // once PartLoader is implemented to abstract away part loading complexity using progressive loader events @@ -1378,36 +1410,96 @@ fileSequence1151226.ts`, }); }); - it('Parses the PRELOAD-HINT tag', function () { + it('Parses EXT-X-PRELOAD-HINT', function () { const details = M3U8Parser.parseLevelPlaylist( playlist, 'http://dummy.url.com/playlist.m3u8', 0, PlaylistLevelType.MAIN, - 0 + 0, + null ); expect(details.preloadHint).to.be.an('object'); - expect(details.preloadHint?.TYPE).to.equal('PART'); - expect(details.preloadHint?.URI).to.equal( - 'lowLatencyHLS.php?segment=filePart1151234.1.ts' - ); + expect(details.preloadHint).to.deep.include({ + TYPE: 'PART', + URI: 'lowLatencyHLS.php?segment=filePart1151234.1.ts', + }); }); - it('Parses the RENDITION-REPORT tag', function () { + it('Parses EXT-X-RENDITION-REPORT', function () { const details = M3U8Parser.parseLevelPlaylist( playlist, 'http://dummy.url.com/playlist.m3u8', 0, PlaylistLevelType.MAIN, - 0 + 0, + null ); const renditionReports = details.renditionReports as AttrList[]; expect(renditionReports).to.be.an('array').which.has.lengthOf(2); - expect(renditionReports[0].URI).to.equal('/media0/lowLatencyHLS.php'); - expect(renditionReports[0]['LAST-MSN']).to.equal('1151201'); - expect(renditionReports[0]['LAST-PART']).to.equal('3'); - expect(renditionReports[0]['LAST-I-MSN']).to.equal('1151201'); - expect(renditionReports[0]['LAST-I-PART']).to.equal('3'); + expect(renditionReports[0]).to.deep.include({ + URI: '/media0/lowLatencyHLS.php', + 'LAST-MSN': '1151201', + 'LAST-PART': '3', + }); + }); + + it('Parses EXT-X-SKIP delta playlists', function () { + const details = M3U8Parser.parseLevelPlaylist( + `#EXTM3U +#EXT-X-TARGETDURATION:4 +#EXT-X-VERSION:9 +#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,CAN-SKIP-UNTIL=24,PART-HOLD-BACK=3.012 +#EXT-X-PART-INF:PART-TARGET=1.004000 +#EXT-X-MEDIA-SEQUENCE:81541 +#EXT-X-SKIP:SKIPPED-SEGMENTS=9,RECENTLY-REMOVED-DATERANGES="DrTag tdl" +#EXTINF:3.98933, +fileSequence81635.m4s +#EXTINF:3.98933, +fileSequence81636.m4s +#EXTINF:3.98933, +fileSequence81637.m4s +#EXT-X-PROGRAM-DATE-TIME:2023-01-15T02:28:01.425Z +#EXTINF:3.98933, +fileSequence81638.m4s +#EXTINF:3.98933, +fileSequence81639.m4s +#EXT-X-PART:DURATION=1.00267,URI="lowLatencySeg.m4s?segment=filePart81640.1.m4s" +#EXT-X-PART:DURATION=1.00267,URI="lowLatencySeg.m4s?segment=filePart81640.2.m4s" +#EXT-X-PART:DURATION=1.00267,URI="lowLatencySeg.m4s?segment=filePart81640.3.m4s" +#EXT-X-PART:DURATION=0.98133,URI="lowLatencySeg.m4s?segment=filePart81640.4.m4s" +#EXTINF:3.98933, +fileSequence81640.m4s +#EXT-X-PART:DURATION=1.00267,URI="lowLatencySeg.m4s?segment=filePart81641.1.m4s" +#EXT-X-PART:DURATION=1.00267,URI="lowLatencySeg.m4s?segment=filePart81641.2.m4s" +#EXT-X-PART:DURATION=1.00267,URI="lowLatencySeg.m4s?segment=filePart81641.3.m4s" +#EXT-X-PART:DURATION=0.98133,URI="lowLatencySeg.m4s?segment=filePart81641.4.m4s" +#EXTINF:3.98933, +fileSequence81641.m4s +#EXT-X-PART:DURATION=1.00267,URI="lowLatencySeg.m4s?segment=filePart81642.1.m4s" +#EXT-X-PART:DURATION=1.00267,URI="lowLatencySeg.m4s?segment=filePart81642.2.m4s" +#EXT-X-PART:DURATION=1.00267,URI="lowLatencySeg.m4s?segment=filePart81642.3.m4s" +#EXT-X-PRELOAD-HINT:TYPE=PART,URI="lowLatencySeg.m4s?segment=filePart81642.4.m4s" +#`, + 'http://dummy.url.com/playlist.m3u8', + 0, + PlaylistLevelType.MAIN, + 0, + null + ); + expect(details.skippedSegments).to.equal(9); + expect(details.recentlyRemovedDateranges).to.deep.equal(['DrTag', 'tdl']); + expect(details.fragments[0]).to.be.null; + expect(details.fragments[8]).to.be.null; + expect(details.fragments[9]).to.deep.include({ + relurl: 'fileSequence81635.m4s', + }); + expect(details.fragments).to.have.lengthOf(16); + expect(details.partList).to.be.an('array').which.has.lengthOf(11); + expect(details.preloadHint).to.deep.include({ + TYPE: 'PART', + URI: 'lowLatencySeg.m4s?segment=filePart81642.4.m4s', + }); }); }); @@ -1432,7 +1524,8 @@ fileSequence2.ts 'http://dummy.url.com/playlist.m3u8', 0, PlaylistLevelType.MAIN, - 0 + 0, + null ); const fragments = details.fragments as Fragment[]; expectWithJSONMessage(fragments[0].tagList).to.deep.equal([ @@ -1468,7 +1561,8 @@ fileSequence2.ts 'http://dummy.url.com/playlist.m3u8', 0, PlaylistLevelType.MAIN, - 0 + 0, + null ); const fragments = details.fragments as Fragment[]; expectWithJSONMessage(fragments[0].tagList).to.deep.equal([ @@ -1506,7 +1600,8 @@ main4.aac 'http://dummy.url.com/playlist.m3u8', 0, PlaylistLevelType.MAIN, - 0 + 0, + null ); expectWithJSONMessage(details.fragments[0].tagList).to.deep.equal([ ['PROGRAM-DATE-TIME', '2018-09-28T16:50:26Z'], @@ -1553,7 +1648,8 @@ http://dummy.url.com/hls/live/segment/segment_022916_164500865_719928.ts 'http://dummy.url.com/playlist.m3u8', 0, PlaylistLevelType.MAIN, - 0 + 0, + null ); expect(result.fragments[2].tagList[0][0]).to.equal('EXT-X-CUSTOM-DATE'); expect(result.fragments[2].tagList[0][1]).to.equal('2016-05-27T16:34:44Z'); @@ -1582,7 +1678,8 @@ http://dummy.url.com/hls/live/segment/segment_022916_164500865_719928.ts 'http://dummy.url.com/playlist.m3u8', 0, PlaylistLevelType.MAIN, - 0 + 0, + null ); expect(result.fragments.length).to.equal(2); expect(result.totalduration).to.equal(12.012); @@ -1613,7 +1710,8 @@ http://dummy.url.com/hls/live/segment/segment_022916_164500865_719928.ts 'http://dummy.url.com/playlist.m3u8', 0, PlaylistLevelType.MAIN, - 0 + 0, + null ); expect(result.fragments.length).to.equal(2); expect(result.totalduration).to.equal(12.012); @@ -1692,7 +1790,8 @@ media_1638278.m4s`; 'http://foo.com/adaptive/test.m3u8', 0, PlaylistLevelType.MAIN, - 0 + 0, + null ); expect(result.fragments.length).to.equal(22); let pdt = 1636514824000; @@ -1745,7 +1844,8 @@ media_1638278.m4s`; 'http://foo.com/adaptive/test.m3u8', 0, PlaylistLevelType.MAIN, - 0 + 0, + null ); expect(result.fragments.length).to.equal(8); expect(result.fragments[0].levelkeys, 'first segment has no keys').to.equal( @@ -1843,6 +1943,342 @@ http://proxy-21.x.com/sec(3ae40f708f79ca9471f52b86da76a3a8)/video/107/282/158282 }); }); +describe('#EXT-X-START', function () { + it('parses EXT-X-START in Multi-Variant Playlists', function () { + const manifest = `#EXTM3U + #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`; + + const result = M3U8Parser.parseMasterPlaylist(manifest, 'http://www.x.com'); + expect(result.startTimeOffset).to.equal(300); + }); + + it('parses negative EXT-X-START values in Multi-Variant 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`; + + const result = M3U8Parser.parseMasterPlaylist(manifest, 'http://www.x.com'); + expect(result.startTimeOffset).to.equal(-30); + }); + + 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`; + + const result = M3U8Parser.parseMasterPlaylist(manifest, 'http://www.x.com'); + expect(result.startTimeOffset).to.equal(null); + }); +}); + +describe('#EXT-X-DEFINE', function () { + it('parses EXT-X-DEFINE Variables in Multi-Variant 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`; + + const result = M3U8Parser.parseMasterPlaylist(manifest, 'http://www.x.com'); + if (result.variableList === null) { + expect(result.variableList, 'variableList').to.not.equal(null); + return; + } + expect(result.variableList.x).to.equal('1'); + expect(result.variableList.y).to.equal('2'); + expect(result.variableList['hello-var']).to.equal('Hello there!'); + }); + + it('returns an error when duplicate Variables are found in Multi-Variant 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-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) { + expect(result.variableList, 'variableList').to.not.equal(null); + return; + } + expect(result.variableList.foo).to.equal('ok'); + expect(result.variableList.bar).to.equal('ok'); + expect(result) + .to.have.property('playlistParsingError') + .with.property('message') + .which.equals('EXT-X-DEFINE duplicate Variable Name declarations: "foo"'); + }); + + it('substitutes variable references in quoted strings, URI lines, and hexidecimal attributes, following EXT-X-DEFINE tags in Multi-Variant 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-CONTENT-STEERING:SERVER-URI="https://{$host}/steering-manifest.json",PATHWAY-ID="{$foo}-CDN" + + #EXT-X-SESSION-DATA:DATA-ID="not-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="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=1836280,CODECS="mp4a.40.2,{$vcodec}",RESOLUTION=848x360,NAME="{$bar}{$two}" + https://{$host}/sec/{$vcodec}/{$two}.m3u8`; + + const result = M3U8Parser.parseMasterPlaylist( + manifest, + 'https://www.x.com' + ); + + if (result.variableList === null) { + expect(result.variableList, 'variableList').to.not.equal(null); + return; + } + expect(result.variableList.bar).to.equal('ok'); + + if (result.sessionData === null) { + expect(result.sessionData, 'sessionData').to.not.equal(null); + return; + } + expect(result.sessionData['not-applied'].VALUE).to.equal('{$session-var}'); + expect(result.sessionData['var-applied'].VALUE).to.equal('hmm'); + + if (result.sessionKeys === null) { + expect(result.sessionKeys).to.not.equal(null); + return; + } + expect(result.sessionKeys[0].keyFormat).to.equal( + 'com.apple.streamingkeydelivery' + ); + expect(result.sessionKeys[0].keyFormatVersions).to.deep.equal([1, 2]); + expect(result.sessionKeys[0].iv).to.deep.equal( + new Uint8Array([0, 0, 0, 2]) + ); + + expect(result.contentSteering).to.deep.include({ + uri: 'https://example.com/steering-manifest.json', + pathwayId: 'ok-CDN', + }); + + expect(result.levels[0]).to.deep.include( + { + name: 'ok1', + url: 'https://example.com/sec/video/1.m3u8', + videoCodec: 'avc1.64001f', + }, + JSON.stringify(result.levels[0], null, 2) + ); + + expect(result.levels[1]).to.deep.include( + { + name: 'ok2', + url: 'https://example.com/sec/avc1.64001f/2.m3u8', + videoCodec: 'avc1.64001f', + }, + JSON.stringify(result.levels[0], null, 2) + ); + + const { AUDIO: audioTracks = [] } = M3U8Parser.parseMasterPlaylistMedia( + manifest, + 'https://www.x.com', + result.levels, + result.variableList + ); + + expect(audioTracks[0]).to.deep.include( + { + groupId: '200k', + lang: 'eng', + url: 'https://example.com/200k.m3u8', + }, + JSON.stringify(audioTracks[0], null, 2) + ); + }); + + it('imports and substitutes variable references in quoted strings, URI lines, and hexidecimal attributes, following EXT-X-DEFINE tags in Media Playlists', function () { + const level = `#EXTM3U +#EXT-X-VERSION:1 +#EXT-X-MEDIA-SEQUENCE:1 +#EXT-X-ALLOW-CACHE:NO +#EXT-X-TARGETDURATION:5 +#EXT-X-DEFINE:IMPORT="mvpVariable" +#EXT-X-DEFINE:NAME="p",VALUE="part-" +#EXT-X-DEFINE:NAME="skd",VALUE="key-data" +#EXT-X-DEFINE:NAME="fps",VALUE="com.apple.streamingkeydelivery" +#EXT-X-DEFINE:NAME="init-bytes",VALUE="718@0" +#EXT-X-DEFINE:NAME="v1",VALUE="1" +#EXT-X-DEFINE:NAME="two",VALUE="2" +#EXT-X-DEFINE:NAME="metadata-id",VALUE="drMeta" +#EXT-X-DEFINE:NAME="date",VALUE="2018-09-28T16:50:48Z" +#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,CAN-SKIP-UNTIL=24,PART-HOLD-BACK=3.012 +#EXT-X-SKIP:SKIPPED-SEGMENTS=3,RECENTLY-REMOVED-DATERANGES="DrTag tdl {$metadata-id} foo" +#EXT-X-KEY:METHOD=SAMPLE-AES,URI="skd://{$skd}",KEYFORMAT="{$fps}",KEYFORMATVERSIONS="{$v1}/2",IV=0x0000000{$two} +#EXT-X-MAP:URI="{$mvpVariable}.mp4",BYTERANGE="{$init-bytes}" +#EXTINF:4,no desc {$mvpVariable} +a{$mvpVariable}.mp4 +#EXTINF:4,no desc +2.mp4 +#EXTINF:4,no desc +3.mp4 +#EXT-X-PROGRAM-DATE-TIME:2018-09-28T16:50:36Z +#EXT-X-DATERANGE:ID="{$metadata-id}",START-DATE="{$date}",END-DATE="{$date}",X-CUSTOM="{$mvpVariable}!",SCTE35-OUT=0x{$two}0000000 +#EXT-X-PART:DURATION=1.00000,INDEPENDENT=YES,URI="{$p}4-1.mp4",BYTERANGE="{$init-bytes}" +#EXT-X-PART:DURATION=0.99999,INDEPENDENT=YES,URI="{$p}4-2.mp4" +#EXT-X-PART:DURATION=1.00000,URI="{$p}4-3.mp4" +#EXT-X-PART:DURATION=1.00000,GAP=YES,INDEPENDENT=YES,URI="{$p}4-4.mp4" +#EXTINF:4.00000, +4.mp4 +#EXT-X-PRELOAD-HINT:TYPE=PART,URI="{$p}5.1.mp4" +#EXT-X-RENDITION-REPORT:URI="/media0/{$mvpVariable}.m3u8",LAST-MSN=4,LAST-PART=3 +#EXT-X-RENDITION-REPORT:URI="/media2/{$mvpVariable}.m3u8"`; + const details = M3U8Parser.parseLevelPlaylist( + level, + 'http://example.com/hls/index.m3u8', + 0, + PlaylistLevelType.MAIN, + 0, + { mvpVariable: 'ok' } + ); + if (details.variableList === null) { + expect(details.variableList, 'variableList').to.not.equal(null); + return; + } + expect(details.variableList.mvpVariable).to.equal('ok'); + expect(details.variableList.p).to.equal('part-'); + expect(details.totalduration).to.equal(31); + expect(details.startSN).to.equal(1); + expect(details.targetduration).to.equal(5); + expect(details.live).to.be.true; + expect(details.skippedSegments).to.equal(3); + expect(details.recentlyRemovedDateranges).to.deep.equal([ + 'DrTag', + 'tdl', + 'drMeta', + 'foo', + ]); + expect(details.fragments).to.have.lengthOf(7); + expect(details.fragments[3].title).to.equal( + 'no desc {$mvpVariable}', + 'does not substitute vars in segment "title"' + ); + expect(details.fragments[3]).to.deep.include({ + relurl: 'aok.mp4', + url: 'http://example.com/hls/aok.mp4', + }); + expect(details.fragments[3].initSegment).to.deep.include({ + relurl: 'ok.mp4', + url: 'http://example.com/hls/ok.mp4', + byteRange: [0, 718], + }); + if (details.partList === null) { + expect(details.partList, 'partList').to.not.equal(null); + return; + } + expect(details.partList[0]).to.deep.include({ + relurl: 'part-4-1.mp4', + url: 'http://example.com/hls/part-4-1.mp4', + byteRange: [0, 718], + }); + expect(details.dateRanges) + .to.have.property('drMeta') + .which.has.property('attr') + .which.deep.includes({ + ID: 'drMeta', + 'START-DATE': '2018-09-28T16:50:48Z', + 'END-DATE': '2018-09-28T16:50:48Z', + 'X-CUSTOM': 'ok!', + 'SCTE35-OUT': '0x20000000', + }); + expectWithJSONMessage( + details.fragments[3].levelkeys?.['com.apple.streamingkeydelivery'], + 'levelkeys' + ).to.deep.include({ + uri: 'skd://key-data', + method: 'SAMPLE-AES', + keyFormat: 'com.apple.streamingkeydelivery', + keyFormatVersions: [1, 2], + iv: new Uint8Array([0, 0, 0, 2]), + key: null, + keyId: null, + }); + expect(details.preloadHint).to.deep.include({ + TYPE: 'PART', + URI: 'part-5.1.mp4', + }); + if (details.partList === null) { + expect(details.partList, 'partList').to.not.equal(null); + return; + } + if (!details.renditionReports) { + expect(details.renditionReports, 'renditionReports').to.not.be.undefined; + return; + } + expect(details.renditionReports[0]).to.deep.include({ + URI: '/media0/ok.m3u8', + 'LAST-MSN': '4', + 'LAST-PART': '3', + }); + expect(details.renditionReports[1]).to.deep.include({ + URI: '/media2/ok.m3u8', + }); + }); + + it('fails to parse Media Playlist when IMPORT variable is not present', function () { + const level = `#EXTM3U +#EXT-X-VERSION:1 +#EXT-X-MEDIA-SEQUENCE:1 +#EXT-X-TARGETDURATION:5 +#EXT-X-DEFINE:IMPORT="mvpVar" +#EXTINF:4 +a{$mvpVar}.mp4 +#EXTINF:4 +2.mp4 +#EXTINF:4 +3.mp4`; + const details = M3U8Parser.parseLevelPlaylist( + level, + 'http://example.com/hls/index.m3u8', + 0, + PlaylistLevelType.MAIN, + 0, + null + ); + expect(details.variableList).to.equal(null); + expect(details.playlistParsingError) + .to.have.property('message') + .which.equals( + 'EXT-X-DEFINE IMPORT attribute not found in Multivariant Playlist: "mvpVar"' + ); + expect(details.fragments[0].relurl).to.equal('a{$mvpVar}.mp4'); + }); +}); + +describe('#EXT-X-CONTENT-STEERING', function () { + // TODO: CONTENT-STEERING + it('', function () {}); +}); + function expectWithJSONMessage(value: any, msg?: string) { return expect(value, `${msg || 'actual:'} ${JSON.stringify(value, null, 2)}`); } diff --git a/webpack.config.js b/webpack.config.js index 5a94b245c58..f49f77d0683 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -11,6 +11,10 @@ const addSubtitleSupport = !!env.SUBTITLE || !!env.USE_SUBTITLES; const addAltAudioSupport = !!env.ALT_AUDIO || !!env.USE_ALT_AUDIO; const addEMESupport = !!env.EME_DRM || !!env.USE_EME_DRM; const addCMCDSupport = !!env.CMCD || !!env.USE_CMCD; +const addContentSteeringSupport = + !!env.CONTENT_STEERING || !!env.USE_CONTENT_STEERING; +const addVariableSubstitutionSupport = + !!env.VARIABLE_SUBSTITUTION || !!env.USE_VARIABLE_SUBSTITUTION; const createDefinePlugin = (type) => { const buildConstants = { @@ -19,6 +23,12 @@ const createDefinePlugin = (type) => { __USE_ALT_AUDIO__: JSON.stringify(type === 'main' || addAltAudioSupport), __USE_EME_DRM__: JSON.stringify(type === 'main' || addEMESupport), __USE_CMCD__: JSON.stringify(type === 'main' || addCMCDSupport), + __USE_CONTENT_STEERING__: JSON.stringify( + type === 'main' || addContentSteeringSupport + ), + __USE_VARIABLE_SUBSTITUTION__: JSON.stringify( + type === 'main' || addVariableSubstitutionSupport + ), }; return new webpack.DefinePlugin(buildConstants); }; @@ -143,6 +153,12 @@ function getAliasesForLightDist() { }); } + if (!addVariableSubstitutionSupport) { + aliases = Object.assign({}, aliases, { + './utils/variable-substitution': './empty.js', + }); + } + return aliases; }