diff --git a/.eslintrc.js b/.eslintrc.js index efde38431df..b336a58425e 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,3 +1,12 @@ +const asyncKeywordConstraintMsg = + 'The async keyword adds a `regenerator` dependency in the hls.js ES5 output not allowed in v1 due to bundle size constraints.'; +const selfVsWindowGlobalMsg = + 'Use `self` instead of `window` to access the global context everywhere (including workers).'; +const arrayFindCompatibilityMsg = + 'Usage of Array find methods is restricted for compatibility.'; +const arrayFindIndexCompatibilityMsg = + 'Usage of Array findIndex methods is restricted for compatibility.'; + module.exports = { env: { browser: true, commonjs: true, es6: true }, globals: { @@ -27,20 +36,13 @@ module.exports = { 2, { name: 'window', - message: - 'Use `self` instead of `window` to access the global context everywhere (including workers).', + message: selfVsWindowGlobalMsg, }, { name: 'SourceBuffer', message: 'Use `self.SourceBuffer`' }, { name: 'setTimeout', message: 'Use `self.setTimeout`' }, { name: 'setInterval', message: 'Use `self.setInterval`' }, ], - 'no-restricted-properties': [ - 2, - { property: 'findIndex' }, // Intended to block usage of Array.prototype.findIndex - { property: 'find' }, // Intended to block usage of Array.prototype.find - ], - 'import/first': 1, 'no-var': 1, 'no-empty': 1, @@ -67,6 +69,31 @@ module.exports = { 'no-unused-vars': 0, 'no-undef': 0, 'no-use-before-define': 'off', + 'no-restricted-syntax': [ + 'error', + { + selector: 'FunctionDeclaration[async=true]', + message: asyncKeywordConstraintMsg, + }, + { + selector: 'ArrowFunctionExpression[async=true]', + message: asyncKeywordConstraintMsg, + }, + { + selector: 'MethodDefinition[value.async=true]', + message: asyncKeywordConstraintMsg, + }, + { + selector: + 'MemberExpression[property.name="find"][object.type="Identifier"]', + message: arrayFindCompatibilityMsg, + }, + { + selector: + 'MemberExpression[property.name="findIndex"][object.type="Identifier"]', + message: arrayFindIndexCompatibilityMsg, + }, + ], 'import/order': [ 'warn', { @@ -98,6 +125,8 @@ module.exports = { '@typescript-eslint/consistent-type-imports': 'error', '@typescript-eslint/no-import-type-side-effects': 'error', '@typescript-eslint/no-restricted-imports': 'error', + '@typescript-eslint/no-floating-promises': 'error', + '@typescript-eslint/no-misused-promises': 'error', }, }, ], diff --git a/.node-version b/.node-version index 5b540673a82..7377d130eda 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -22.16.0 +22.17.1 diff --git a/api-extractor/report/hls.js.api.md b/api-extractor/report/hls.js.api.md index e046c50400e..efcc3d551ee 100644 --- a/api-extractor/report/hls.js.api.md +++ b/api-extractor/report/hls.js.api.md @@ -197,7 +197,7 @@ export class AudioStreamController extends BaseStreamController implements Netwo // (undocumented) protected onHandlerDestroying(): void; // (undocumented) - onInitPtsFound(event: Events.INIT_PTS_FOUND, { frag, id, initPTS, timescale }: InitPTSFoundData): void; + onInitPtsFound(event: Events.INIT_PTS_FOUND, { frag, id, initPTS, timescale, trackId }: InitPTSFoundData): void; // (undocumented) protected onManifestLoading(): void; // (undocumented) @@ -392,6 +392,8 @@ export class BaseStreamController extends TaskLoop implements NetworkComponentAP // (undocumented) protected checkLiveUpdate(details: LevelDetails): void; // (undocumented) + protected checkRetryDate(): void; + // (undocumented) protected clearTrackerIfNeeded(frag: Fragment): void; // (undocumented) protected config: HlsConfig; @@ -458,7 +460,7 @@ export class BaseStreamController extends TaskLoop implements NetworkComponentAP // (undocumented) get inFlightFrag(): InFlightData; // (undocumented) - protected initPTS: RationalTimestamp[]; + protected initPTS: TimestampOffset[]; // (undocumented) protected isLoopLoading(frag: Fragment, targetBufferTime: number): boolean; // (undocumented) @@ -528,8 +530,6 @@ export class BaseStreamController extends TaskLoop implements NetworkComponentAP // (undocumented) protected resetLoadingState(): void; // (undocumented) - protected resetStartWhenNotLoaded(level: Level | null): void; - // (undocumented) protected resetTransmuxer(): void; // (undocumented) protected resetWhenMissingContext(chunkMeta: ChunkMetadata | Fragment): void; @@ -563,6 +563,8 @@ export class BaseStreamController extends TaskLoop implements NetworkComponentAP // (undocumented) protected transmuxer: TransmuxerInterface | null; // (undocumented) + protected unhandledEncryptionError(initSegment: InitSegmentData, frag: Fragment): boolean; + // (undocumented) protected unregisterListeners(): void; // (undocumented) protected waitForCdnTuneIn(details: LevelDetails): boolean | 0; @@ -579,6 +581,8 @@ export interface BaseTrack { // (undocumented) container: string; // (undocumented) + encrypted?: boolean; + // (undocumented) id: 'audio' | 'main'; // (undocumented) levelCodec?: string; @@ -631,7 +635,7 @@ export interface BufferAppendingData { // (undocumented) chunkMeta: ChunkMetadata; // (undocumented) - data: Uint8Array; + data: Uint8Array; // (undocumented) frag: Fragment; // (undocumented) @@ -1185,8 +1189,8 @@ export type EMEControllerConfig = { licenseResponseCallback?: (this: Hls, xhr: XMLHttpRequest, url: string, keyContext: MediaKeySessionContext) => ArrayBuffer; emeEnabled: boolean; widevineLicenseUrl?: string; - drmSystems: DRMSystemsConfiguration; - drmSystemOptions: DRMSystemOptions; + drmSystems: DRMSystemsConfiguration | undefined; + drmSystemOptions: DRMSystemOptions | undefined; requestMediaKeySystemAccessFunc: MediaKeyFunc | null; requireKeySystemAccessOnStart: boolean; }; @@ -1200,9 +1204,11 @@ export const enum ErrorActionFlags { // (undocumented) MoveAllAlternatesMatchingHost = 1, // (undocumented) + MoveAllAlternatesMatchingKey = 4, + // (undocumented) None = 0, // (undocumented) - SwitchToSDR = 4 + SwitchToSDR = 8 } // Warning: (ae-missing-release-tag) "ErrorController" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -1235,6 +1241,8 @@ export interface ErrorData { // (undocumented) context?: PlaylistLoaderContext; // (undocumented) + decryptdata?: LevelKey; + // (undocumented) details: ErrorDetails; // @deprecated (undocumented) err?: { @@ -1503,8 +1511,6 @@ export enum Events { // (undocumented) KEY_LOADING = "hlsKeyLoading", // (undocumented) - KEY_STATUSES_CHANGED = "hlsKeyStatusesChanged", - // (undocumented) LEVEL_LOADED = "hlsLevelLoaded", // (undocumented) LEVEL_LOADING = "hlsLevelLoading", @@ -1809,7 +1815,7 @@ export class Fragment extends BaseSegment { level: number; // (undocumented) levelkeys?: { - [key: string]: LevelKey; + [key: string]: LevelKey | undefined; }; // (undocumented) loader: Loader | null; @@ -1927,7 +1933,7 @@ export class FragmentTracker implements ComponentAPI { detectPartialFragments(data: FragBufferedData): void; // (undocumented) fragBuffered(frag: MediaFragment, force?: true): void; - getAppendedFrag(position: number, levelType: PlaylistLevelType): Fragment | Part | null; + getAppendedFrag(position: number, levelType: PlaylistLevelType): MediaFragment | Part | null; getBufferedFrag(position: number, levelType: PlaylistLevelType): MediaFragment | null; // (undocumented) getFragAtPos(position: number, levelType: PlaylistLevelType, buffered?: boolean): MediaFragment | null; @@ -2190,12 +2196,14 @@ export class HlsAssetPlayer { // (undocumented) get duration(): number; // (undocumented) - readonly hls: Hls; + hls: Hls | null; // (undocumented) - readonly interstitial: InterstitialEvent; + interstitial: InterstitialEvent; // (undocumented) get interstitialId(): InterstitialId; // (undocumented) + loadSource(): void; + // (undocumented) get media(): HTMLMediaElement | null; // (undocumented) off(event: E, listener: HlsListeners[E], context?: Context): void; @@ -2403,8 +2411,6 @@ export interface HlsListeners { // (undocumented) [Events.KEY_LOADING]: (event: Events.KEY_LOADING, data: KeyLoadingData) => void; // (undocumented) - [Events.KEY_STATUSES_CHANGED]: (event: Events.KEY_STATUSES_CHANGED, data: KeyStatusesChangedData) => void; - // (undocumented) [Events.LEVEL_LOADED]: (event: Events.LEVEL_LOADED, data: LevelLoadedData) => void; // (undocumented) [Events.LEVEL_LOADING]: (event: Events.LEVEL_LOADING, data: LevelLoadingData) => void; @@ -2588,6 +2594,8 @@ export interface InitPTSFoundData { initPTS: number; // (undocumented) timescale: number; + // (undocumented) + trackId: number; } // Warning: (ae-missing-release-tag) "InitSegmentData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -2802,6 +2810,8 @@ export interface InterstitialPlayer { // (undocumented) assetPlayers: (HlsAssetPlayer | null)[]; // (undocumented) + bufferedEnd: number; + // (undocumented) currentTime: number; // (undocumented) duration: number; @@ -2975,8 +2985,8 @@ export interface KeyLoadedData { // Warning: (ae-missing-release-tag) "KeyLoader" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export class KeyLoader implements ComponentAPI { - constructor(config: HlsConfig); +export class KeyLoader extends Logger implements ComponentAPI { + constructor(config: HlsConfig, logger: ILogger); // (undocumented) abort(type?: PlaylistLevelType): void; // (undocumented) @@ -2993,10 +3003,6 @@ export class KeyLoader implements ComponentAPI { // (undocumented) emeController: EMEController | null; // (undocumented) - keyUriToKeyInfo: { - [keyuri: string]: KeyLoaderInfo; - }; - // (undocumented) load(frag: Fragment): Promise; // (undocumented) loadClear(loadingFrag: Fragment, encryptedFragments: Fragment[], startFragRequested: boolean): null | Promise; @@ -3040,16 +3046,6 @@ export interface KeyLoadingData { frag: Fragment; } -// Warning: (ae-missing-release-tag) "KeyStatusesChangedData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) -export interface KeyStatusesChangedData { - // (undocumented) - keyStatuses: MediaKeyStatusMap; - // (undocumented) - keySystem: string; -} - // Warning: (ae-missing-release-tag) "KeySystemFormats" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -3248,7 +3244,7 @@ export class LevelDetails { // (undocumented) canSkipUntil: number; // (undocumented) - dateRanges: Record; + dateRanges: Record; // (undocumented) dateRangeTagCount: number; // (undocumented) @@ -3282,6 +3278,8 @@ export class LevelDetails { // (undocumented) get fragmentStart(): number; // (undocumented) + hasKey(levelKey: LevelKey): boolean; + // (undocumented) get hasProgramDateTime(): boolean; // (undocumented) hasVariableRefs: boolean; @@ -4366,6 +4364,8 @@ export interface RemuxedTrack { // (undocumented) dropped?: number; // (undocumented) + encrypted?: boolean; + // (undocumented) endDTS: number; // (undocumented) endPTS: number; @@ -4810,6 +4810,13 @@ export enum TimelineOccupancy { Range = 1 } +// Warning: (ae-missing-release-tag) "TimestampOffset" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type TimestampOffset = RationalTimestamp & { + trackId: number; +}; + // Warning: (ae-missing-release-tag) "Track" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -4880,7 +4887,7 @@ export class TransmuxerInterface { // (undocumented) flush(chunkMeta: ChunkMetadata): void; // (undocumented) - push(data: ArrayBuffer, initSegmentData: Uint8Array | undefined, audioCodec: string | undefined, videoCodec: string | undefined, frag: MediaFragment, part: Part | null, duration: number, accurateTimeOffset: boolean, chunkMeta: ChunkMetadata, defaultInitPTS?: RationalTimestamp): void; + push(data: ArrayBuffer, initSegmentData: Uint8Array | undefined, audioCodec: string | undefined, videoCodec: string | undefined, frag: MediaFragment, part: Part | null, duration: number, accurateTimeOffset: boolean, chunkMeta: ChunkMetadata, defaultInitPTS?: TimestampOffset): void; // (undocumented) reset(): void; } diff --git a/build-config.js b/build-config.js index 78dbc2464e5..755e6baea58 100644 --- a/build-config.js +++ b/build-config.js @@ -136,12 +136,12 @@ const babelTsWithPresetEnvTargets = ({ targets, stripConsole }) => ], plugins: [ [ - '@babel/plugin-proposal-class-properties', + '@babel/plugin-transform-class-properties', { loose: true, }, ], - '@babel/plugin-proposal-object-rest-spread', + '@babel/plugin-transform-object-rest-spread', { visitor: { CallExpression: function (espath) { @@ -172,7 +172,7 @@ const babelTsWithPresetEnvTargets = ({ targets, stripConsole }) => }, }, ['@babel/plugin-transform-object-assign'], - ['@babel/plugin-proposal-optional-chaining'], + ['@babel/plugin-transform-optional-chaining'], ...(stripConsole ? [ diff --git a/docs/API.md b/docs/API.md index 88938b4077d..359c5ddbd7d 100644 --- a/docs/API.md +++ b/docs/API.md @@ -350,22 +350,52 @@ hls.on(Hls.Events.ERROR, function (event, data) { #### Fatal Error Recovery -HLS.js provides several methods for attempting playback recover in the event of a decoding error in the HTMLMediaElement: +HLS.js provides methods for attempting playback recover in the event of a decoding error in the HTMLMediaElement: ##### `hls.recoverMediaError()` -Resets the MediaSource and restarts streaming from the last known playhead position. +Resets the MediaSource and restarts streaming from the last known playhead position. This should only be used when the media element is in an error state. +It should not be used in response to non-fatal hls.js error events. ###### Error recovery sample code ```js -hls.on(Hls.Events.ERROR, function (event, data) { +let attemptedErrorRecovery = null; + +video.addEventListener('error', (event) => { + const mediaError = event.currentTarget.error; + if (mediaError.code === mediaError.MEDIA_ERR_DECODE) { + const now = Date.now(); + if (!attemptedErrorRecovery || now - attemptedErrorRecovery > 5000) { + attemptedErrorRecovery = now; + hls.recoverMediaError(); + } + } +}); + +hls.on(Hls.Events.ERROR, function (name, data) { + // Special handling is only needed to errors flagged as `fatal`. if (data.fatal) { switch (data.type) { - case Hls.ErrorTypes.MEDIA_ERROR: - console.log('fatal media error encountered, try to recover'); - hls.recoverMediaError(); + case Hls.ErrorTypes.MEDIA_ERROR: { + const now = Date.now(); + if (!attemptedErrorRecovery || now - attemptedErrorRecovery > 5000) { + console.log( + 'Fatal media error encountered (' + + video.error + + +'), attempting to recover', + ); + attemptedErrorRecovery = now; + hls.recoverMediaError(); + } else { + console.log( + 'Skipping media error recovery (only ' + + (now - attemptedErrorRecovery) + + 'ms since last error)', + ); + } break; + } case Hls.ErrorTypes.NETWORK_ERROR: console.error('fatal network error encountered', data); // All retries and media options have been exhausted. @@ -539,7 +569,9 @@ This configuration will be applied by default to all instances. (default: `false`) -Setting `config.debug = true;` will turn on debug logs on JS console. +Setting `config.debug = true` enables JavaScript debug console logs. Debug mode also disables catching exceptions in even handler callbacks. +In debug mode, when an event listener throws, the exception is not caught. This allows uncaught exeptions to trigger the JavaScript debugger. +In production mode (`config.debug = false`), exceptions that are caught in event handlers are redispatched as errors with `type: OTHER_ERROR, details: INTERNAL_EXCEPTION, error: `. A logger object could also be provided for custom logging: `config.debug = customLogger;`. @@ -734,10 +766,10 @@ Decreasing this value will mean that each stall will have less affect on `hls.ta (default: `Infinity`) -maximum delay allowed from edge of live, expressed in multiple of `EXT-X-TARGETDURATION`. -if set to 10, the player will seek back to `liveSyncDurationCount` whenever the next fragment to be loaded is older than N-10, N being the last fragment of the live playlist. -If set, this value must be stricly superior to `liveSyncDurationCount` -a value too close from `liveSyncDurationCount` is likely to cause playback stalls. +Maximum delay allowed from edge of live, expressed in multiple of `EXT-X-TARGETDURATION`. +If set to 10, the player will seek back to `liveSyncDurationCount` whenever the next fragment to be loaded is older than N-10, N being the last fragment of the live playlist. +If set, this value must be strictly superior to `liveSyncDurationCount`. +A value too close from `liveSyncDurationCount` is likely to cause playback stalls. ### `liveSyncDuration` @@ -754,7 +786,7 @@ A value too low (inferior to ~3 segment durations) is likely to cause playback s Alternative parameter to `liveMaxLatencyDurationCount`, expressed in seconds vs number of segments. If defined in the configuration object, `liveMaxLatencyDuration` will take precedence over the default `liveMaxLatencyDurationCount`. -If set, this value must be stricly superior to `liveSyncDuration` which must be defined as well. +If set, this value must be strictly superior to `liveSyncDuration` which must be defined as well. You can't define this parameter and either `liveSyncDurationCount` or `liveMaxLatencyDurationCount` in your configuration object at the same time. A value too close from `liveSyncDuration` is likely to cause playback stalls. diff --git a/docs/design.md b/docs/design.md index 5e2c8244cad..be372055927 100644 --- a/docs/design.md +++ b/docs/design.md @@ -64,7 +64,6 @@ design idea is pretty simple : - [src/controller/id3-track-controller.ts][] - in charge of creating the id3 metadata text track and adding cues to that track in response to the FRAG_PARSING_METADATA event. the raw id3 data is base64 encoded and stored in the cue's text property. - [src/controller/level-controller.ts][] - - handling quality level set/get ((re)loading stream manifest/switching levels) - in charge of scheduling playlist (re)loading - monitors fragment and key loading errors. Performs fragment hunt by switching between primary and backup streams and down-shifting a level till `fragLoadingMaxRetry` limit is reached. @@ -83,7 +82,6 @@ design idea is pretty simple : **Retry Recommendations** By not having multiple renditions, recovery logic will not be able to add extra value to your platform. In order to have good results for dual constraint media hunt, specify big enough limits for fragments and levels retries. - - Level: don't use total retry less than `3 - 4` - Fragment: don't use total retry less than `4 - 6` - Implement short burst retries (i.e. small retry delay `0.5 - 4` seconds), and when library returns fatal error switch to a different CDN diff --git a/package-lock.json b/package-lock.json index fd069b2d412..48847b18904 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,20 +1,20 @@ { "name": "hls.js", - "version": "1.6.8", + "version": "1.6.12", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "hls.js", - "version": "1.6.8", + "version": "1.6.12", "license": "Apache-2.0", "devDependencies": { "@babel/core": "7.28.0", "@babel/helper-module-imports": "7.27.1", - "@babel/plugin-proposal-class-properties": "7.18.6", - "@babel/plugin-proposal-object-rest-spread": "7.20.7", - "@babel/plugin-proposal-optional-chaining": "7.21.0", + "@babel/plugin-transform-class-properties": "7.27.1", "@babel/plugin-transform-object-assign": "7.27.1", + "@babel/plugin-transform-object-rest-spread": "7.28.0", + "@babel/plugin-transform-optional-chaining": "7.27.1", "@babel/preset-env": "7.28.0", "@babel/preset-typescript": "7.27.1", "@babel/register": "7.27.1", @@ -27,25 +27,25 @@ "@rollup/plugin-replace": "6.0.2", "@rollup/plugin-terser": "0.4.4", "@rollup/plugin-typescript": "12.1.4", - "@svta/common-media-library": "0.15.1", + "@svta/common-media-library": "0.17.1", "@types/chai": "4.3.20", "@types/chart.js": "2.9.41", "@types/mocha": "10.0.10", "@types/sinon-chai": "3.2.12", - "@typescript-eslint/eslint-plugin": "8.35.1", - "@typescript-eslint/parser": "8.35.1", + "@typescript-eslint/eslint-plugin": "8.38.0", + "@typescript-eslint/parser": "8.38.0", "babel-loader": "10.0.0", "babel-plugin-transform-remove-console": "6.9.4", "chai": "4.5.0", "chart.js": "2.9.4", - "chromedriver": "138.0.0", + "chromedriver": "138.0.3", "doctoc": "2.2.1", "es-check": "9.1.4", "eslint": "8.57.1", - "eslint-config-prettier": "10.1.5", + "eslint-config-prettier": "10.1.8", "eslint-plugin-import": "2.32.0", "eslint-plugin-mocha": "10.5.0", - "eslint-plugin-n": "17.20.0", + "eslint-plugin-n": "17.21.0", "eslint-plugin-no-for-of-loops": "1.0.1", "eslint-plugin-promise": "7.2.1", "eventemitter3": "5.0.1", @@ -66,9 +66,9 @@ "mocha": "11.7.1", "node-fetch": "3.3.2", "npm-run-all2": "8.0.4", - "prettier": "3.5.3", + "prettier": "3.6.2", "promise-polyfill": "8.3.0", - "rollup": "4.44.1", + "rollup": "4.45.1", "rollup-plugin-istanbul": "5.0.0", "sauce-connect-launcher": "1.3.2", "selenium-webdriver": "4.34.0", @@ -77,7 +77,7 @@ "sinon-chai": "3.7.0", "typescript": "5.8.3", "url-toolkit": "2.2.5", - "wrangler": "4.22.0" + "wrangler": "4.26.1" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -605,58 +605,6 @@ "@babel/core": "^7.0.0" } }, - "node_modules/@babel/plugin-proposal-class-properties": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.18.6.tgz", - "integrity": "sha512-cumfXOF0+nzZrrN8Rf0t7M+tF6sZc7vhQwYQck9q1/5w2OExlD+b4v4RpMJFaV1Z7WcDRgO6FqvxqxGlwo+RHQ==", - "dev": true, - "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.18.6", - "@babel/helper-plugin-utils": "^7.18.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-proposal-object-rest-spread": { - "version": "7.20.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.20.7.tgz", - "integrity": "sha512-d2S98yCiLxDVmBmE8UjGcfPvNEUbA1U5q5WxaWFUGRzJSVAZqm5W6MbPct0jxnegUZ0niLeNX+IOzEs7wYg9Dg==", - "dev": true, - "dependencies": { - "@babel/compat-data": "^7.20.5", - "@babel/helper-compilation-targets": "^7.20.7", - "@babel/helper-plugin-utils": "^7.20.2", - "@babel/plugin-syntax-object-rest-spread": "^7.8.3", - "@babel/plugin-transform-parameters": "^7.20.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-proposal-optional-chaining": { - "version": "7.21.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.21.0.tgz", - "integrity": "sha512-p4zeefM72gpmEe2fkUr/OnOXpWEf8nAgk7ZYVqqfFiyIG7oFfVZcCrU64hWn5xp4tQ9LkV4bTIa5rD0KANpKNA==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.20.2", - "@babel/helper-skip-transparent-expression-wrappers": "^7.20.0", - "@babel/plugin-syntax-optional-chaining": "^7.8.3" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "node_modules/@babel/plugin-proposal-private-property-in-object": { "version": "7.21.0-placeholder-for-preset-env.2", "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", @@ -714,30 +662,6 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-object-rest-spread": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", - "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-optional-chaining": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", - "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "node_modules/@babel/plugin-syntax-typescript": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz", @@ -1830,25 +1754,10 @@ "node": ">=10.0.0" } }, - "node_modules/@cloudflare/unenv-preset": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.3.3.tgz", - "integrity": "sha512-/M3MEcj3V2WHIRSW1eAQBPRJ6JnGQHc6JKMAPLkDb7pLs3m6X9ES/+K3ceGqxI6TKeF32AWAi7ls0AYzVxCP0A==", - "dev": true, - "peerDependencies": { - "unenv": "2.0.0-rc.17", - "workerd": "^1.20250508.0" - }, - "peerDependenciesMeta": { - "workerd": { - "optional": true - } - } - }, "node_modules/@cloudflare/workerd-darwin-64": { - "version": "1.20250617.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20250617.0.tgz", - "integrity": "sha512-toG8JUKVLIks4oOJLe9FeuixE84pDpMZ32ip7mCpE7JaFc5BqGFvevk0YC/db3T71AQlialjRwioH3jS/dzItA==", + "version": "1.20250726.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20250726.0.tgz", + "integrity": "sha512-SOpQqQ2blLY0io/vErve44vJC1M5i7RHuMBdrdEPIEtxiLBTdOOVp4nqZ3KchocxZjskgTc2N4N3b5hNYuKDGw==", "cpu": [ "x64" ], @@ -1862,9 +1771,9 @@ } }, "node_modules/@cloudflare/workerd-darwin-arm64": { - "version": "1.20250617.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20250617.0.tgz", - "integrity": "sha512-JTX0exbC9/ZtMmQQA8tDZEZFMXZrxOpTUj2hHnsUkErWYkr5SSZH04RBhPg6dU4VL8bXuB5/eJAh7+P9cZAp7g==", + "version": "1.20250726.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20250726.0.tgz", + "integrity": "sha512-I+TOQ+YQahxL/K7eS2GJzv5CZzSVaZoyqfB15Q71MT/+wyzPCaFDTt+fg3uXdwpaIQEMUfqFNpTQSqbKHAYNgA==", "cpu": [ "arm64" ], @@ -1878,9 +1787,9 @@ } }, "node_modules/@cloudflare/workerd-linux-64": { - "version": "1.20250617.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20250617.0.tgz", - "integrity": "sha512-8jkSoVRJ+1bOx3tuWlZCGaGCV2ew7/jFMl6V3CPXOoEtERUHsZBQLVkQIGKcmC/LKSj7f/mpyBUeu2EPTo2HEg==", + "version": "1.20250726.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20250726.0.tgz", + "integrity": "sha512-WSCv4o2uOW6b++ROVazrEW+jjZdBqCmXmmt7uVVfvjVxlzoYVwK9IvV2IXe4gsJ99HG9I0YCa7AT743cZ7TNNg==", "cpu": [ "x64" ], @@ -1894,9 +1803,9 @@ } }, "node_modules/@cloudflare/workerd-linux-arm64": { - "version": "1.20250617.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20250617.0.tgz", - "integrity": "sha512-YAzcOyu897z5dQKFzme1oujGWMGEJCR7/Wrrm1nSP6dqutxFPTubRADM8BHn2CV3ij//vaPnAeLmZE3jVwOwig==", + "version": "1.20250726.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20250726.0.tgz", + "integrity": "sha512-jNokAGL3EQqH+31b0dX8+tlbKdjt/0UtTLvgD1e+7bOD92lzjYMa/CixHyMIY/FVvhsN4TNqfiz4cqroABTlhg==", "cpu": [ "arm64" ], @@ -1910,9 +1819,9 @@ } }, "node_modules/@cloudflare/workerd-windows-64": { - "version": "1.20250617.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20250617.0.tgz", - "integrity": "sha512-XWM/6sagDrO0CYDKhXhPjM23qusvIN1ju9ZEml6gOQs8tNOFnq6Cn6X9FAmnyapRFCGUSEC3HZYJAm7zwVKaMA==", + "version": "1.20250726.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20250726.0.tgz", + "integrity": "sha512-DiPTY63TNh6/ylvfutNQzYZi688x6NJDjQoqf5uiCp7xHweWx+GpVs42sZPeeXqCNvhm4dYjHjuigXJNh7t8Uw==", "cpu": [ "x64" ], @@ -2479,16 +2388,6 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, - "node_modules/@fastify/busboy": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", - "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - } - }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", @@ -3274,6 +3173,44 @@ "node": ">=14" } }, + "node_modules/@poppinss/colors": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.5.tgz", + "integrity": "sha512-FvdDqtcRCtz6hThExcFOgW0cWX+xwSMWcRuQe5ZEb2m7cVQOAVZOIMt+/v9RxGiD9/OY16qJBXK4CVKWAPalBw==", + "dev": true, + "dependencies": { + "kleur": "^4.1.5" + } + }, + "node_modules/@poppinss/dumper": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.4.tgz", + "integrity": "sha512-iG0TIdqv8xJ3Lt9O8DrPRxw1MRLjNpoqiSGU03P/wNLP/s0ra0udPJ1J2Tx5M0J3H/cVyEgpbn8xUKRY9j59kQ==", + "dev": true, + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@sindresorhus/is": "^7.0.2", + "supports-color": "^10.0.0" + } + }, + "node_modules/@poppinss/dumper/node_modules/supports-color": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.0.0.tgz", + "integrity": "sha512-HRVVSbCCMbj7/kdWF9Q+bbckjBHLtHMEoJWlkmYzzdwhYMkjkOwubLM6t7NbWKjgKamGDrWL1++KrjUO1t9oAQ==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/@poppinss/exception": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.2.tgz", + "integrity": "sha512-m7bpKCD4QMlFCjA/nKTs23fuvoVFoA83brRKmObCUNmi/9tVu8Ve3w4YQAnJu4q3Tjf5fr685HYIC/IA2zHRSg==", + "dev": true + }, "node_modules/@rollup/plugin-alias": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/@rollup/plugin-alias/-/plugin-alias-5.1.1.tgz", @@ -3491,9 +3428,9 @@ "dev": true }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.44.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.44.1.tgz", - "integrity": "sha512-JAcBr1+fgqx20m7Fwe1DxPUl/hPkee6jA6Pl7n1v2EFiktAHenTaXl5aIFjUIEsfn9w3HE4gK1lEgNGMzBDs1w==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.45.1.tgz", + "integrity": "sha512-NEySIFvMY0ZQO+utJkgoMiCAjMrGvnbDLHvcmlA33UXJpYBCvlBEbMMtV837uCkS+plG2umfhn0T5mMAxGrlRA==", "cpu": [ "arm" ], @@ -3504,9 +3441,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.44.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.44.1.tgz", - "integrity": "sha512-RurZetXqTu4p+G0ChbnkwBuAtwAbIwJkycw1n6GvlGlBuS4u5qlr5opix8cBAYFJgaY05TWtM+LaoFggUmbZEQ==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.45.1.tgz", + "integrity": "sha512-ujQ+sMXJkg4LRJaYreaVx7Z/VMgBBd89wGS4qMrdtfUFZ+TSY5Rs9asgjitLwzeIbhwdEhyj29zhst3L1lKsRQ==", "cpu": [ "arm64" ], @@ -3517,9 +3454,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.44.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.44.1.tgz", - "integrity": "sha512-fM/xPesi7g2M7chk37LOnmnSTHLG/v2ggWqKj3CCA1rMA4mm5KVBT1fNoswbo1JhPuNNZrVwpTvlCVggv8A2zg==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.45.1.tgz", + "integrity": "sha512-FSncqHvqTm3lC6Y13xncsdOYfxGSLnP+73k815EfNmpewPs+EyM49haPS105Rh4aF5mJKywk9X0ogzLXZzN9lA==", "cpu": [ "arm64" ], @@ -3530,9 +3467,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.44.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.44.1.tgz", - "integrity": "sha512-gDnWk57urJrkrHQ2WVx9TSVTH7lSlU7E3AFqiko+bgjlh78aJ88/3nycMax52VIVjIm3ObXnDL2H00e/xzoipw==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.45.1.tgz", + "integrity": "sha512-2/vVn/husP5XI7Fsf/RlhDaQJ7x9zjvC81anIVbr4b/f0xtSmXQTFcGIQ/B1cXIYM6h2nAhJkdMHTnD7OtQ9Og==", "cpu": [ "x64" ], @@ -3543,9 +3480,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.44.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.44.1.tgz", - "integrity": "sha512-wnFQmJ/zPThM5zEGcnDcCJeYJgtSLjh1d//WuHzhf6zT3Md1BvvhJnWoy+HECKu2bMxaIcfWiu3bJgx6z4g2XA==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.45.1.tgz", + "integrity": "sha512-4g1kaDxQItZsrkVTdYQ0bxu4ZIQ32cotoQbmsAnW1jAE4XCMbcBPDirX5fyUzdhVCKgPcrwWuucI8yrVRBw2+g==", "cpu": [ "arm64" ], @@ -3556,9 +3493,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.44.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.44.1.tgz", - "integrity": "sha512-uBmIxoJ4493YATvU2c0upGz87f99e3wop7TJgOA/bXMFd2SvKCI7xkxY/5k50bv7J6dw1SXT4MQBQSLn8Bb/Uw==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.45.1.tgz", + "integrity": "sha512-L/6JsfiL74i3uK1Ti2ZFSNsp5NMiM4/kbbGEcOCps99aZx3g8SJMO1/9Y0n/qKlWZfn6sScf98lEOUe2mBvW9A==", "cpu": [ "x64" ], @@ -3569,9 +3506,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.44.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.44.1.tgz", - "integrity": "sha512-n0edDmSHlXFhrlmTK7XBuwKlG5MbS7yleS1cQ9nn4kIeW+dJH+ExqNgQ0RrFRew8Y+0V/x6C5IjsHrJmiHtkxQ==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.45.1.tgz", + "integrity": "sha512-RkdOTu2jK7brlu+ZwjMIZfdV2sSYHK2qR08FUWcIoqJC2eywHbXr0L8T/pONFwkGukQqERDheaGTeedG+rra6Q==", "cpu": [ "arm" ], @@ -3582,9 +3519,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.44.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.44.1.tgz", - "integrity": "sha512-8WVUPy3FtAsKSpyk21kV52HCxB+me6YkbkFHATzC2Yd3yuqHwy2lbFL4alJOLXKljoRw08Zk8/xEj89cLQ/4Nw==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.45.1.tgz", + "integrity": "sha512-3kJ8pgfBt6CIIr1o+HQA7OZ9mp/zDk3ctekGl9qn/pRBgrRgfwiffaUmqioUGN9hv0OHv2gxmvdKOkARCtRb8Q==", "cpu": [ "arm" ], @@ -3595,9 +3532,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.44.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.44.1.tgz", - "integrity": "sha512-yuktAOaeOgorWDeFJggjuCkMGeITfqvPgkIXhDqsfKX8J3jGyxdDZgBV/2kj/2DyPaLiX6bPdjJDTu9RB8lUPQ==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.45.1.tgz", + "integrity": "sha512-k3dOKCfIVixWjG7OXTCOmDfJj3vbdhN0QYEqB+OuGArOChek22hn7Uy5A/gTDNAcCy5v2YcXRJ/Qcnm4/ma1xw==", "cpu": [ "arm64" ], @@ -3608,9 +3545,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.44.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.44.1.tgz", - "integrity": "sha512-W+GBM4ifET1Plw8pdVaecwUgxmiH23CfAUj32u8knq0JPFyK4weRy6H7ooxYFD19YxBulL0Ktsflg5XS7+7u9g==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.45.1.tgz", + "integrity": "sha512-PmI1vxQetnM58ZmDFl9/Uk2lpBBby6B6rF4muJc65uZbxCs0EA7hhKCk2PKlmZKuyVSHAyIw3+/SiuMLxKxWog==", "cpu": [ "arm64" ], @@ -3621,9 +3558,9 @@ ] }, "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.44.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.44.1.tgz", - "integrity": "sha512-1zqnUEMWp9WrGVuVak6jWTl4fEtrVKfZY7CvcBmUUpxAJ7WcSowPSAWIKa/0o5mBL/Ij50SIf9tuirGx63Ovew==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.45.1.tgz", + "integrity": "sha512-9UmI0VzGmNJ28ibHW2GpE2nF0PBQqsyiS4kcJ5vK+wuwGnV5RlqdczVocDSUfGX/Na7/XINRVoUgJyFIgipoRg==", "cpu": [ "loong64" ], @@ -3634,9 +3571,9 @@ ] }, "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.44.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.44.1.tgz", - "integrity": "sha512-Rl3JKaRu0LHIx7ExBAAnf0JcOQetQffaw34T8vLlg9b1IhzcBgaIdnvEbbsZq9uZp3uAH+JkHd20Nwn0h9zPjA==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.45.1.tgz", + "integrity": "sha512-7nR2KY8oEOUTD3pBAxIBBbZr0U7U+R9HDTPNy+5nVVHDXI4ikYniH1oxQz9VoB5PbBU1CZuDGHkLJkd3zLMWsg==", "cpu": [ "ppc64" ], @@ -3647,9 +3584,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.44.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.44.1.tgz", - "integrity": "sha512-j5akelU3snyL6K3N/iX7otLBIl347fGwmd95U5gS/7z6T4ftK288jKq3A5lcFKcx7wwzb5rgNvAg3ZbV4BqUSw==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.45.1.tgz", + "integrity": "sha512-nlcl3jgUultKROfZijKjRQLUu9Ma0PeNv/VFHkZiKbXTBQXhpytS8CIj5/NfBeECZtY2FJQubm6ltIxm/ftxpw==", "cpu": [ "riscv64" ], @@ -3660,9 +3597,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.44.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.44.1.tgz", - "integrity": "sha512-ppn5llVGgrZw7yxbIm8TTvtj1EoPgYUAbfw0uDjIOzzoqlZlZrLJ/KuiE7uf5EpTpCTrNt1EdtzF0naMm0wGYg==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.45.1.tgz", + "integrity": "sha512-HJV65KLS51rW0VY6rvZkiieiBnurSzpzore1bMKAhunQiECPuxsROvyeaot/tcK3A3aGnI+qTHqisrpSgQrpgA==", "cpu": [ "riscv64" ], @@ -3673,9 +3610,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.44.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.44.1.tgz", - "integrity": "sha512-Hu6hEdix0oxtUma99jSP7xbvjkUM/ycke/AQQ4EC5g7jNRLLIwjcNwaUy95ZKBJJwg1ZowsclNnjYqzN4zwkAw==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.45.1.tgz", + "integrity": "sha512-NITBOCv3Qqc6hhwFt7jLV78VEO/il4YcBzoMGGNxznLgRQf43VQDae0aAzKiBeEPIxnDrACiMgbqjuihx08OOw==", "cpu": [ "s390x" ], @@ -3686,9 +3623,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.44.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.44.1.tgz", - "integrity": "sha512-EtnsrmZGomz9WxK1bR5079zee3+7a+AdFlghyd6VbAjgRJDbTANJ9dcPIPAi76uG05micpEL+gPGmAKYTschQw==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.45.1.tgz", + "integrity": "sha512-+E/lYl6qu1zqgPEnTrs4WysQtvc/Sh4fC2nByfFExqgYrqkKWp1tWIbe+ELhixnenSpBbLXNi6vbEEJ8M7fiHw==", "cpu": [ "x64" ], @@ -3699,9 +3636,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.44.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.44.1.tgz", - "integrity": "sha512-iAS4p+J1az6Usn0f8xhgL4PaU878KEtutP4hqw52I4IO6AGoyOkHCxcc4bqufv1tQLdDWFx8lR9YlwxKuv3/3g==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.45.1.tgz", + "integrity": "sha512-a6WIAp89p3kpNoYStITT9RbTbTnqarU7D8N8F2CV+4Cl9fwCOZraLVuVFvlpsW0SbIiYtEnhCZBPLoNdRkjQFw==", "cpu": [ "x64" ], @@ -3712,9 +3649,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.44.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.44.1.tgz", - "integrity": "sha512-NtSJVKcXwcqozOl+FwI41OH3OApDyLk3kqTJgx8+gp6On9ZEt5mYhIsKNPGuaZr3p9T6NWPKGU/03Vw4CNU9qg==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.45.1.tgz", + "integrity": "sha512-T5Bi/NS3fQiJeYdGvRpTAP5P02kqSOpqiopwhj0uaXB6nzs5JVi2XMJb18JUSKhCOX8+UE1UKQufyD6Or48dJg==", "cpu": [ "arm64" ], @@ -3725,9 +3662,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.44.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.44.1.tgz", - "integrity": "sha512-JYA3qvCOLXSsnTR3oiyGws1Dm0YTuxAAeaYGVlGpUsHqloPcFjPg+X0Fj2qODGLNwQOAcCiQmHub/V007kiH5A==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.45.1.tgz", + "integrity": "sha512-lxV2Pako3ujjuUe9jiU3/s7KSrDfH6IgTSQOnDWr9aJ92YsFd7EurmClK0ly/t8dzMkDtd04g60WX6yl0sGfdw==", "cpu": [ "ia32" ], @@ -3738,9 +3675,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.44.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.44.1.tgz", - "integrity": "sha512-J8o22LuF0kTe7m+8PvW9wk3/bRq5+mRo5Dqo6+vXb7otCm3TPhYOJqOaQtGU9YMWQSL3krMnoOxMr0+9E6F3Ug==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.45.1.tgz", + "integrity": "sha512-M/fKi4sasCdM8i0aWJjCSFm2qEnYRR8AMLG2kxp6wD13+tMGA4Z1tVAuHkNRjud5SW2EM3naLuK35w9twvf6aA==", "cpu": [ "x64" ], @@ -3895,6 +3832,18 @@ "string-argv": "~0.3.1" } }, + "node_modules/@sindresorhus/is": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.0.2.tgz", + "integrity": "sha512-d9xRovfKNz1SKieM0qJdO+PQonjnnIfSNWfHYnBSJ9hkjm0ZPw6HlxscDXYstp3z+7V2GOFHc+J0CYrYTjqCJw==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, "node_modules/@sinonjs/commons": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", @@ -3945,10 +3894,16 @@ "integrity": "sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg==", "dev": true }, + "node_modules/@speed-highlight/core": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.7.tgz", + "integrity": "sha512-0dxmVj4gxg3Jg879kvFS/msl4s9F3T9UXC1InxgOf7t5NvcPD97u/WTA5vL/IxWHMn7qSxBozqrnnE2wvl1m8g==", + "dev": true + }, "node_modules/@svta/common-media-library": { - "version": "0.15.1", - "resolved": "https://registry.npmjs.org/@svta/common-media-library/-/common-media-library-0.15.1.tgz", - "integrity": "sha512-Ig26anQU/MVRxrikBv1cMmeJqhXG683jxA7q9iscoMygcYd4+XmLjM0kcshZ574izow4X+j/NcU5vTrvVXF9sQ==", + "version": "0.17.1", + "resolved": "https://registry.npmjs.org/@svta/common-media-library/-/common-media-library-0.17.1.tgz", + "integrity": "sha512-UcmqRe1cJ/OloNEeXqKJjbw6MAKPaGZUk4yLsy1QOwtzUmo7hDVvZeIoqXlmoXZNQRQ/P1MsNuboKirsTw9e7w==", "dev": true, "engines": { "node": ">=20" @@ -4146,16 +4101,16 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.35.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.35.1.tgz", - "integrity": "sha512-9XNTlo7P7RJxbVeICaIIIEipqxLKguyh+3UbXuT2XQuFp6d8VOeDEGuz5IiX0dgZo8CiI6aOFLg4e8cF71SFVg==", + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.38.0.tgz", + "integrity": "sha512-CPoznzpuAnIOl4nhj4tRr4gIPj5AfKgkiJmGQDaq+fQnRJTYlcBjbX3wbciGmpoPf8DREufuPRe1tNMZnGdanA==", "dev": true, "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.35.1", - "@typescript-eslint/type-utils": "8.35.1", - "@typescript-eslint/utils": "8.35.1", - "@typescript-eslint/visitor-keys": "8.35.1", + "@typescript-eslint/scope-manager": "8.38.0", + "@typescript-eslint/type-utils": "8.38.0", + "@typescript-eslint/utils": "8.38.0", + "@typescript-eslint/visitor-keys": "8.38.0", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", @@ -4169,7 +4124,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.35.1", + "@typescript-eslint/parser": "^8.38.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } @@ -4184,15 +4139,15 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.35.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.35.1.tgz", - "integrity": "sha512-3MyiDfrfLeK06bi/g9DqJxP5pV74LNv4rFTyvGDmT3x2p1yp1lOd+qYZfiRPIOf/oON+WRZR5wxxuF85qOar+w==", + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.38.0.tgz", + "integrity": "sha512-Zhy8HCvBUEfBECzIl1PKqF4p11+d0aUJS1GeUiuqK9WmOug8YCmC4h4bjyBvMyAMI9sbRczmrYL5lKg/YMbrcQ==", "dev": true, "dependencies": { - "@typescript-eslint/scope-manager": "8.35.1", - "@typescript-eslint/types": "8.35.1", - "@typescript-eslint/typescript-estree": "8.35.1", - "@typescript-eslint/visitor-keys": "8.35.1", + "@typescript-eslint/scope-manager": "8.38.0", + "@typescript-eslint/types": "8.38.0", + "@typescript-eslint/typescript-estree": "8.38.0", + "@typescript-eslint/visitor-keys": "8.38.0", "debug": "^4.3.4" }, "engines": { @@ -4208,13 +4163,13 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.35.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.35.1.tgz", - "integrity": "sha512-VYxn/5LOpVxADAuP3NrnxxHYfzVtQzLKeldIhDhzC8UHaiQvYlXvKuVho1qLduFbJjjy5U5bkGwa3rUGUb1Q6Q==", + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.38.0.tgz", + "integrity": "sha512-dbK7Jvqcb8c9QfH01YB6pORpqX1mn5gDZc9n63Ak/+jD67oWXn3Gs0M6vddAN+eDXBCS5EmNWzbSxsn9SzFWWg==", "dev": true, "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.35.1", - "@typescript-eslint/types": "^8.35.1", + "@typescript-eslint/tsconfig-utils": "^8.38.0", + "@typescript-eslint/types": "^8.38.0", "debug": "^4.3.4" }, "engines": { @@ -4229,13 +4184,13 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.35.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.35.1.tgz", - "integrity": "sha512-s/Bpd4i7ht2934nG+UoSPlYXd08KYz3bmjLEb7Ye1UVob0d1ENiT3lY8bsCmik4RqfSbPw9xJJHbugpPpP5JUg==", + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.38.0.tgz", + "integrity": "sha512-WJw3AVlFFcdT9Ri1xs/lg8LwDqgekWXWhH3iAF+1ZM+QPd7oxQ6jvtW/JPwzAScxitILUIFs0/AnQ/UWHzbATQ==", "dev": true, "dependencies": { - "@typescript-eslint/types": "8.35.1", - "@typescript-eslint/visitor-keys": "8.35.1" + "@typescript-eslint/types": "8.38.0", + "@typescript-eslint/visitor-keys": "8.38.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4246,9 +4201,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.35.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.35.1.tgz", - "integrity": "sha512-K5/U9VmT9dTHoNowWZpz+/TObS3xqC5h0xAIjXPw+MNcKV9qg6eSatEnmeAwkjHijhACH0/N7bkhKvbt1+DXWQ==", + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.38.0.tgz", + "integrity": "sha512-Lum9RtSE3EroKk/bYns+sPOodqb2Fv50XOl/gMviMKNvanETUuUcC9ObRbzrJ4VSd2JalPqgSAavwrPiPvnAiQ==", "dev": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4262,13 +4217,14 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.35.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.35.1.tgz", - "integrity": "sha512-HOrUBlfVRz5W2LIKpXzZoy6VTZzMu2n8q9C2V/cFngIC5U1nStJgv0tMV4sZPzdf4wQm9/ToWUFPMN9Vq9VJQQ==", + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.38.0.tgz", + "integrity": "sha512-c7jAvGEZVf0ao2z+nnz8BUaHZD09Agbh+DY7qvBQqLiz8uJzRgVPj5YvOh8I8uEiH8oIUGIfHzMwUcGVco/SJg==", "dev": true, "dependencies": { - "@typescript-eslint/typescript-estree": "8.35.1", - "@typescript-eslint/utils": "8.35.1", + "@typescript-eslint/types": "8.38.0", + "@typescript-eslint/typescript-estree": "8.38.0", + "@typescript-eslint/utils": "8.38.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, @@ -4285,9 +4241,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.35.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.35.1.tgz", - "integrity": "sha512-q/O04vVnKHfrrhNAscndAn1tuQhIkwqnaW+eu5waD5IPts2eX1dgJxgqcPx5BX109/qAz7IG6VrEPTOYKCNfRQ==", + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.38.0.tgz", + "integrity": "sha512-wzkUfX3plUqij4YwWaJyqhiPE5UCRVlFpKn1oCRn2O1bJ592XxWJj8ROQ3JD5MYXLORW84063z3tZTb/cs4Tyw==", "dev": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4298,15 +4254,15 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.35.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.35.1.tgz", - "integrity": "sha512-Vvpuvj4tBxIka7cPs6Y1uvM7gJgdF5Uu9F+mBJBPY4MhvjrjWGK4H0lVgLJd/8PWZ23FTqsaJaLEkBCFUk8Y9g==", + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.38.0.tgz", + "integrity": "sha512-fooELKcAKzxux6fA6pxOflpNS0jc+nOQEEOipXFNjSlBS6fqrJOVY/whSn70SScHrcJ2LDsxWrneFoWYSVfqhQ==", "dev": true, "dependencies": { - "@typescript-eslint/project-service": "8.35.1", - "@typescript-eslint/tsconfig-utils": "8.35.1", - "@typescript-eslint/types": "8.35.1", - "@typescript-eslint/visitor-keys": "8.35.1", + "@typescript-eslint/project-service": "8.38.0", + "@typescript-eslint/tsconfig-utils": "8.38.0", + "@typescript-eslint/types": "8.38.0", + "@typescript-eslint/visitor-keys": "8.38.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -4350,15 +4306,15 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.35.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.35.1.tgz", - "integrity": "sha512-lhnwatFmOFcazAsUm3ZnZFpXSxiwoa1Lj50HphnDe1Et01NF4+hrdXONSUHIcbVu2eFb1bAf+5yjXkGVkXBKAQ==", + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.38.0.tgz", + "integrity": "sha512-hHcMA86Hgt+ijJlrD8fX0j1j8w4C92zue/8LOPAFioIno+W0+L7KqE8QZKCcPGc/92Vs9x36w/4MPTJhqXdyvg==", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.35.1", - "@typescript-eslint/types": "8.35.1", - "@typescript-eslint/typescript-estree": "8.35.1" + "@typescript-eslint/scope-manager": "8.38.0", + "@typescript-eslint/types": "8.38.0", + "@typescript-eslint/typescript-estree": "8.38.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4373,12 +4329,12 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.35.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.35.1.tgz", - "integrity": "sha512-VRwixir4zBWCSTP/ljEo091lbpypz57PoeAQ9imjG+vbeof9LplljsL1mos4ccG6H9IjfrVGM359RozUnuFhpw==", + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.38.0.tgz", + "integrity": "sha512-pWrTcoFNWuwHlA9CvlfSsGWs14JxfN1TH25zM5L7o0pRLhsoZkDnTsXfQRJBEWJoV5DL0jf+Z+sxiud+K0mq1g==", "dev": true, "dependencies": { - "@typescript-eslint/types": "8.35.1", + "@typescript-eslint/types": "8.38.0", "eslint-visitor-keys": "^4.2.1" }, "engines": { @@ -4914,16 +4870,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/as-table": { - "version": "1.0.55", - "resolved": "https://registry.npmjs.org/as-table/-/as-table-1.0.55.tgz", - "integrity": "sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "printable-characters": "^1.0.42" - } - }, "node_modules/assertion-error": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", @@ -5511,9 +5457,9 @@ } }, "node_modules/chromedriver": { - "version": "138.0.0", - "resolved": "https://registry.npmjs.org/chromedriver/-/chromedriver-138.0.0.tgz", - "integrity": "sha512-bJ/DNm5Y0TbqM71ARaAohTWVwcQ2SsWciYC5Q9Ul7DC/oTxm6B1vI2h6WscFCOOi49ul4tXZVjA/LOruljjmjA==", + "version": "138.0.3", + "resolved": "https://registry.npmjs.org/chromedriver/-/chromedriver-138.0.3.tgz", + "integrity": "sha512-RKcfzbUthmQzFmy91F9StQQwNZ72khp3febF/RntpkDKhhCkwor0cgop00diwzAVSUq1s2e8B54Iema9FQnynw==", "dev": true, "hasInstallScript": true, "dependencies": { @@ -6344,6 +6290,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/error-stack-parser-es": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/es-abstract": { "version": "1.24.0", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", @@ -6739,9 +6694,9 @@ } }, "node_modules/eslint-config-prettier": { - "version": "10.1.5", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.5.tgz", - "integrity": "sha512-zc1UmCpNltmVY34vuLRV61r1K27sWuX39E+uyUnY8xS2Bex88VV9cugG+UZbRSRGtGyFboj+D8JODyme1plMpw==", + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "bin": { "eslint-config-prettier": "bin/cli.js" @@ -6916,13 +6871,12 @@ } }, "node_modules/eslint-plugin-n": { - "version": "17.20.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-n/-/eslint-plugin-n-17.20.0.tgz", - "integrity": "sha512-IRSoatgB/NQJZG5EeTbv/iAx1byOGdbbyhQrNvWdCfTnmPxUT0ao9/eGOeG7ljD8wJBsxwE8f6tES5Db0FRKEw==", + "version": "17.21.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-n/-/eslint-plugin-n-17.21.0.tgz", + "integrity": "sha512-1+iZ8We4ZlwVMtb/DcHG3y5/bZOdazIpa/4TySo22MLKdwrLcfrX0hbadnCvykSQCCmkAnWmIP8jZVb2AAq29A==", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.5.0", - "@typescript-eslint/utils": "^8.26.1", "enhanced-resolve": "^5.17.1", "eslint-plugin-es-x": "^7.8.0", "get-tsconfig": "^4.8.1", @@ -6951,18 +6905,6 @@ "balanced-match": "^1.0.0" } }, - "node_modules/eslint-plugin-n/node_modules/globals": { - "version": "15.15.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", - "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", - "dev": true, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/eslint-plugin-n/node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -7301,11 +7243,10 @@ } }, "node_modules/exsolve": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.5.tgz", - "integrity": "sha512-pz5dvkYYKQ1AHVrgOzBKWeP4u4FRb3a6DNK2ucr0OoNwYIU4QWsJ+NM36LLzORT+z845MzKHHhpXiUF5nvQoJg==", - "dev": true, - "license": "MIT" + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.7.tgz", + "integrity": "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==", + "dev": true }, "node_modules/extend": { "version": "3.0.2", @@ -7819,24 +7760,6 @@ "node": ">= 0.4" } }, - "node_modules/get-source": { - "version": "2.0.12", - "resolved": "https://registry.npmjs.org/get-source/-/get-source-2.0.12.tgz", - "integrity": "sha512-X5+4+iD+HoSeEED+uwrQ07BOQr0kEDFMVqqpBuI+RaZBpBpHCuXxo70bjar6f0b0u/DQJsJ7ssurpP0V60Az+w==", - "dev": true, - "license": "Unlicense", - "dependencies": { - "data-uri-to-buffer": "^2.0.0", - "source-map": "^0.6.1" - } - }, - "node_modules/get-source/node_modules/data-uri-to-buffer": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-2.0.2.tgz", - "integrity": "sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA==", - "dev": true, - "license": "MIT" - }, "node_modules/get-stream": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", @@ -7949,6 +7872,18 @@ "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", "dev": true }, + "node_modules/globals": { + "version": "15.15.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", + "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/globalthis": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", @@ -9482,6 +9417,15 @@ "node": ">=0.10.0" } }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/kuler": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", @@ -10389,9 +10333,9 @@ } }, "node_modules/miniflare": { - "version": "4.20250617.4", - "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20250617.4.tgz", - "integrity": "sha512-IAoApFKxOJlaaFkym5ETstVX3qWzVt3xyqCDj6vSSTgEH3zxZJ5417jZGg8iQfMHosKCcQH1doPPqqnOZm/yrw==", + "version": "4.20250726.0", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20250726.0.tgz", + "integrity": "sha512-7+/RQQ9dNsyGfR2XN2RDLultf7HHrJ5YltSXSeyQGUpzGU3iYlFhh9Smg+ygkkOJ3+trf0bgwixOnqnnWpc9ZQ==", "dev": true, "dependencies": { "@cspotcode/source-map-support": "0.8.1", @@ -10401,10 +10345,10 @@ "glob-to-regexp": "0.4.1", "sharp": "^0.33.5", "stoppable": "1.1.0", - "undici": "^5.28.5", - "workerd": "1.20250617.0", + "undici": "^7.10.0", + "workerd": "1.20250726.0", "ws": "8.18.0", - "youch": "3.3.4", + "youch": "4.1.0-beta.10", "zod": "3.22.3" }, "bin": { @@ -10774,16 +10718,6 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", "dev": true }, - "node_modules/mustache": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", - "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", - "dev": true, - "license": "MIT", - "bin": { - "mustache": "bin/mustache" - } - }, "node_modules/nano-spawn": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/nano-spawn/-/nano-spawn-1.0.2.tgz", @@ -11657,9 +11591,9 @@ } }, "node_modules/prettier": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz", - "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "bin": { "prettier": "bin/prettier.cjs" @@ -11671,13 +11605,6 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, - "node_modules/printable-characters": { - "version": "1.0.42", - "resolved": "https://registry.npmjs.org/printable-characters/-/printable-characters-1.0.42.tgz", - "integrity": "sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==", - "dev": true, - "license": "Unlicense" - }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -12170,9 +12097,9 @@ } }, "node_modules/rollup": { - "version": "4.44.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.44.1.tgz", - "integrity": "sha512-x8H8aPvD+xbl0Do8oez5f5o8eMS3trfCghc4HhLAnCkj7Vl0d1JWGs0UF/D886zLW2rOj2QymV/JcSSsw+XDNg==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.45.1.tgz", + "integrity": "sha512-4iya7Jb76fVpQyLoiVpzUrsjQ12r3dM7fIVz+4NwoYvZOShknRmiv+iu9CClZml5ZLGb0XMcYLutK6w9tgxHDw==", "dev": true, "dependencies": { "@types/estree": "1.0.8" @@ -12185,26 +12112,26 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.44.1", - "@rollup/rollup-android-arm64": "4.44.1", - "@rollup/rollup-darwin-arm64": "4.44.1", - "@rollup/rollup-darwin-x64": "4.44.1", - "@rollup/rollup-freebsd-arm64": "4.44.1", - "@rollup/rollup-freebsd-x64": "4.44.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.44.1", - "@rollup/rollup-linux-arm-musleabihf": "4.44.1", - "@rollup/rollup-linux-arm64-gnu": "4.44.1", - "@rollup/rollup-linux-arm64-musl": "4.44.1", - "@rollup/rollup-linux-loongarch64-gnu": "4.44.1", - "@rollup/rollup-linux-powerpc64le-gnu": "4.44.1", - "@rollup/rollup-linux-riscv64-gnu": "4.44.1", - "@rollup/rollup-linux-riscv64-musl": "4.44.1", - "@rollup/rollup-linux-s390x-gnu": "4.44.1", - "@rollup/rollup-linux-x64-gnu": "4.44.1", - "@rollup/rollup-linux-x64-musl": "4.44.1", - "@rollup/rollup-win32-arm64-msvc": "4.44.1", - "@rollup/rollup-win32-ia32-msvc": "4.44.1", - "@rollup/rollup-win32-x64-msvc": "4.44.1", + "@rollup/rollup-android-arm-eabi": "4.45.1", + "@rollup/rollup-android-arm64": "4.45.1", + "@rollup/rollup-darwin-arm64": "4.45.1", + "@rollup/rollup-darwin-x64": "4.45.1", + "@rollup/rollup-freebsd-arm64": "4.45.1", + "@rollup/rollup-freebsd-x64": "4.45.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.45.1", + "@rollup/rollup-linux-arm-musleabihf": "4.45.1", + "@rollup/rollup-linux-arm64-gnu": "4.45.1", + "@rollup/rollup-linux-arm64-musl": "4.45.1", + "@rollup/rollup-linux-loongarch64-gnu": "4.45.1", + "@rollup/rollup-linux-powerpc64le-gnu": "4.45.1", + "@rollup/rollup-linux-riscv64-gnu": "4.45.1", + "@rollup/rollup-linux-riscv64-musl": "4.45.1", + "@rollup/rollup-linux-s390x-gnu": "4.45.1", + "@rollup/rollup-linux-x64-gnu": "4.45.1", + "@rollup/rollup-linux-x64-musl": "4.45.1", + "@rollup/rollup-win32-arm64-msvc": "4.45.1", + "@rollup/rollup-win32-ia32-msvc": "4.45.1", + "@rollup/rollup-win32-x64-msvc": "4.45.1", "fsevents": "~2.3.2" } }, @@ -12953,17 +12880,6 @@ "node": "*" } }, - "node_modules/stacktracey": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/stacktracey/-/stacktracey-2.1.8.tgz", - "integrity": "sha512-Kpij9riA+UNg7TnphqjH7/CzctQ/owJGNbFkfEeve4Z4uxT5+JapVLFXcsurIfN34gnTWZNJ/f7NMG0E8JDzTw==", - "dev": true, - "license": "Unlicense", - "dependencies": { - "as-table": "^1.0.36", - "get-source": "^2.0.12" - } - }, "node_modules/statuses": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", @@ -13723,16 +13639,12 @@ "dev": true }, "node_modules/undici": { - "version": "5.29.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", - "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", + "version": "7.11.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.11.0.tgz", + "integrity": "sha512-heTSIac3iLhsmZhUCjyS3JQEkZELateufzZuBaVM5RHXdSBMb1LPMQf5x+FH7qjsZYDP0ttAc3nnVpUB+wYbOg==", "dev": true, - "license": "MIT", - "dependencies": { - "@fastify/busboy": "^2.0.0" - }, "engines": { - "node": ">=14.0" + "node": ">=20.18.1" } }, "node_modules/undici-types": { @@ -13742,20 +13654,6 @@ "dev": true, "license": "MIT" }, - "node_modules/unenv": { - "version": "2.0.0-rc.17", - "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.17.tgz", - "integrity": "sha512-B06u0wXkEd+o5gOCMl/ZHl5cfpYbDZKAT+HWTL+Hws6jWu7dCiqBBXXXzMFcFVJb8D4ytAnYmxJA83uwOQRSsg==", - "dev": true, - "license": "MIT", - "dependencies": { - "defu": "^6.1.4", - "exsolve": "^1.0.4", - "ohash": "^2.0.11", - "pathe": "^2.0.3", - "ufo": "^1.6.1" - } - }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", @@ -14386,9 +14284,9 @@ "dev": true }, "node_modules/workerd": { - "version": "1.20250617.0", - "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20250617.0.tgz", - "integrity": "sha512-Uv6p0PYUHp/W/aWfUPLkZVAoAjapisM27JJlwcX9wCPTfCfnuegGOxFMvvlYpmNaX4YCwEdLCwuNn3xkpSkuZw==", + "version": "1.20250726.0", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20250726.0.tgz", + "integrity": "sha512-wDZqSKfIfQ2eVTUL6UawXdXEKPPyzRTnVdbhoKGq3NFrMxd+7v1cNH92u8775Qo1zO5S+GyWonQmZPFakXLvGw==", "dev": true, "hasInstallScript": true, "bin": { @@ -14398,11 +14296,11 @@ "node": ">=16" }, "optionalDependencies": { - "@cloudflare/workerd-darwin-64": "1.20250617.0", - "@cloudflare/workerd-darwin-arm64": "1.20250617.0", - "@cloudflare/workerd-linux-64": "1.20250617.0", - "@cloudflare/workerd-linux-arm64": "1.20250617.0", - "@cloudflare/workerd-windows-64": "1.20250617.0" + "@cloudflare/workerd-darwin-64": "1.20250726.0", + "@cloudflare/workerd-darwin-arm64": "1.20250726.0", + "@cloudflare/workerd-linux-64": "1.20250726.0", + "@cloudflare/workerd-linux-arm64": "1.20250726.0", + "@cloudflare/workerd-windows-64": "1.20250726.0" } }, "node_modules/workerpool": { @@ -14412,19 +14310,19 @@ "dev": true }, "node_modules/wrangler": { - "version": "4.22.0", - "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.22.0.tgz", - "integrity": "sha512-m8qVO3YxhUTII+4U889G/f5UuLSvMkUkCNatupV2f/SJ+iqaWtP1QbuQII8bs2J/O4rqxsz46Wu2S50u7tKB5Q==", + "version": "4.26.1", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.26.1.tgz", + "integrity": "sha512-zGFEtHrjTAWOngm+zwEvYCxFwMSIBrzHa3Yu6rAxYMEzsT8PPvo2rdswyUJiUkpE9s2Depr37opceaY7JxEYFw==", "dev": true, "dependencies": { "@cloudflare/kv-asset-handler": "0.4.0", - "@cloudflare/unenv-preset": "2.3.3", + "@cloudflare/unenv-preset": "2.5.0", "blake3-wasm": "2.1.5", "esbuild": "0.25.4", - "miniflare": "4.20250617.4", + "miniflare": "4.20250726.0", "path-to-regexp": "6.3.0", - "unenv": "2.0.0-rc.17", - "workerd": "1.20250617.0" + "unenv": "2.0.0-rc.19", + "workerd": "1.20250726.0" }, "bin": { "wrangler": "bin/wrangler.js", @@ -14437,7 +14335,7 @@ "fsevents": "~2.3.2" }, "peerDependencies": { - "@cloudflare/workers-types": "^4.20250617.0" + "@cloudflare/workers-types": "^4.20250726.0" }, "peerDependenciesMeta": { "@cloudflare/workers-types": { @@ -14445,6 +14343,34 @@ } } }, + "node_modules/wrangler/node_modules/@cloudflare/unenv-preset": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.5.0.tgz", + "integrity": "sha512-CZe9B2VbjIQjBTyc+KoZcN1oUcm4T6GgCXoel9O7647djHuSRAa6sM6G+NdxWArATZgeMMbsvn9C50GCcnIatA==", + "dev": true, + "peerDependencies": { + "unenv": "2.0.0-rc.19", + "workerd": "^1.20250722.0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/wrangler/node_modules/unenv": { + "version": "2.0.0-rc.19", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.19.tgz", + "integrity": "sha512-t/OMHBNAkknVCI7bVB9OWjUUAwhVv9vsPIAGnNUxnu3FxPQN11rjh0sksLMzc3g7IlTgvHmOTl4JM7JHpcv5wA==", + "dev": true, + "dependencies": { + "defu": "^6.1.4", + "exsolve": "^1.0.7", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "ufo": "^1.6.1" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -14782,25 +14708,35 @@ } }, "node_modules/youch": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/youch/-/youch-3.3.4.tgz", - "integrity": "sha512-UeVBXie8cA35DS6+nBkls68xaBBXCye0CNznrhszZjTbRVnJKQuNsyLKBTTL4ln1o1rh2PKtv35twV7irj5SEg==", + "version": "4.1.0-beta.10", + "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", + "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", + "dev": true, + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@poppinss/dumper": "^0.6.4", + "@speed-highlight/core": "^1.2.7", + "cookie": "^1.0.2", + "youch-core": "^0.3.3" + } + }, + "node_modules/youch-core": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", + "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", "dev": true, - "license": "MIT", "dependencies": { - "cookie": "^0.7.1", - "mustache": "^4.2.0", - "stacktracey": "^2.1.8" + "@poppinss/exception": "^1.2.2", + "error-stack-parser-es": "^1.0.5" } }, "node_modules/youch/node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", "dev": true, - "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">=18" } }, "node_modules/zod": { @@ -15212,40 +15148,6 @@ "@babel/traverse": "^7.27.1" } }, - "@babel/plugin-proposal-class-properties": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.18.6.tgz", - "integrity": "sha512-cumfXOF0+nzZrrN8Rf0t7M+tF6sZc7vhQwYQck9q1/5w2OExlD+b4v4RpMJFaV1Z7WcDRgO6FqvxqxGlwo+RHQ==", - "dev": true, - "requires": { - "@babel/helper-create-class-features-plugin": "^7.18.6", - "@babel/helper-plugin-utils": "^7.18.6" - } - }, - "@babel/plugin-proposal-object-rest-spread": { - "version": "7.20.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.20.7.tgz", - "integrity": "sha512-d2S98yCiLxDVmBmE8UjGcfPvNEUbA1U5q5WxaWFUGRzJSVAZqm5W6MbPct0jxnegUZ0niLeNX+IOzEs7wYg9Dg==", - "dev": true, - "requires": { - "@babel/compat-data": "^7.20.5", - "@babel/helper-compilation-targets": "^7.20.7", - "@babel/helper-plugin-utils": "^7.20.2", - "@babel/plugin-syntax-object-rest-spread": "^7.8.3", - "@babel/plugin-transform-parameters": "^7.20.7" - } - }, - "@babel/plugin-proposal-optional-chaining": { - "version": "7.21.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.21.0.tgz", - "integrity": "sha512-p4zeefM72gpmEe2fkUr/OnOXpWEf8nAgk7ZYVqqfFiyIG7oFfVZcCrU64hWn5xp4tQ9LkV4bTIa5rD0KANpKNA==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.20.2", - "@babel/helper-skip-transparent-expression-wrappers": "^7.20.0", - "@babel/plugin-syntax-optional-chaining": "^7.8.3" - } - }, "@babel/plugin-proposal-private-property-in-object": { "version": "7.21.0-placeholder-for-preset-env.2", "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", @@ -15280,24 +15182,6 @@ "@babel/helper-plugin-utils": "^7.27.1" } }, - "@babel/plugin-syntax-object-rest-spread": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", - "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.8.0" - } - }, - "@babel/plugin-syntax-optional-chaining": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", - "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.8.0" - } - }, "@babel/plugin-syntax-typescript": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz", @@ -16020,45 +15904,38 @@ } } }, - "@cloudflare/unenv-preset": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.3.3.tgz", - "integrity": "sha512-/M3MEcj3V2WHIRSW1eAQBPRJ6JnGQHc6JKMAPLkDb7pLs3m6X9ES/+K3ceGqxI6TKeF32AWAi7ls0AYzVxCP0A==", - "dev": true, - "requires": {} - }, "@cloudflare/workerd-darwin-64": { - "version": "1.20250617.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20250617.0.tgz", - "integrity": "sha512-toG8JUKVLIks4oOJLe9FeuixE84pDpMZ32ip7mCpE7JaFc5BqGFvevk0YC/db3T71AQlialjRwioH3jS/dzItA==", + "version": "1.20250726.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20250726.0.tgz", + "integrity": "sha512-SOpQqQ2blLY0io/vErve44vJC1M5i7RHuMBdrdEPIEtxiLBTdOOVp4nqZ3KchocxZjskgTc2N4N3b5hNYuKDGw==", "dev": true, "optional": true }, "@cloudflare/workerd-darwin-arm64": { - "version": "1.20250617.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20250617.0.tgz", - "integrity": "sha512-JTX0exbC9/ZtMmQQA8tDZEZFMXZrxOpTUj2hHnsUkErWYkr5SSZH04RBhPg6dU4VL8bXuB5/eJAh7+P9cZAp7g==", + "version": "1.20250726.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20250726.0.tgz", + "integrity": "sha512-I+TOQ+YQahxL/K7eS2GJzv5CZzSVaZoyqfB15Q71MT/+wyzPCaFDTt+fg3uXdwpaIQEMUfqFNpTQSqbKHAYNgA==", "dev": true, "optional": true }, "@cloudflare/workerd-linux-64": { - "version": "1.20250617.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20250617.0.tgz", - "integrity": "sha512-8jkSoVRJ+1bOx3tuWlZCGaGCV2ew7/jFMl6V3CPXOoEtERUHsZBQLVkQIGKcmC/LKSj7f/mpyBUeu2EPTo2HEg==", + "version": "1.20250726.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20250726.0.tgz", + "integrity": "sha512-WSCv4o2uOW6b++ROVazrEW+jjZdBqCmXmmt7uVVfvjVxlzoYVwK9IvV2IXe4gsJ99HG9I0YCa7AT743cZ7TNNg==", "dev": true, "optional": true }, "@cloudflare/workerd-linux-arm64": { - "version": "1.20250617.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20250617.0.tgz", - "integrity": "sha512-YAzcOyu897z5dQKFzme1oujGWMGEJCR7/Wrrm1nSP6dqutxFPTubRADM8BHn2CV3ij//vaPnAeLmZE3jVwOwig==", + "version": "1.20250726.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20250726.0.tgz", + "integrity": "sha512-jNokAGL3EQqH+31b0dX8+tlbKdjt/0UtTLvgD1e+7bOD92lzjYMa/CixHyMIY/FVvhsN4TNqfiz4cqroABTlhg==", "dev": true, "optional": true }, "@cloudflare/workerd-windows-64": { - "version": "1.20250617.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20250617.0.tgz", - "integrity": "sha512-XWM/6sagDrO0CYDKhXhPjM23qusvIN1ju9ZEml6gOQs8tNOFnq6Cn6X9FAmnyapRFCGUSEC3HZYJAm7zwVKaMA==", + "version": "1.20250726.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20250726.0.tgz", + "integrity": "sha512-DiPTY63TNh6/ylvfutNQzYZi688x6NJDjQoqf5uiCp7xHweWx+GpVs42sZPeeXqCNvhm4dYjHjuigXJNh7t8Uw==", "dev": true, "optional": true }, @@ -16334,12 +16211,6 @@ "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", "dev": true }, - "@fastify/busboy": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", - "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", - "dev": true - }, "@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", @@ -16811,6 +16682,40 @@ "dev": true, "optional": true }, + "@poppinss/colors": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.5.tgz", + "integrity": "sha512-FvdDqtcRCtz6hThExcFOgW0cWX+xwSMWcRuQe5ZEb2m7cVQOAVZOIMt+/v9RxGiD9/OY16qJBXK4CVKWAPalBw==", + "dev": true, + "requires": { + "kleur": "^4.1.5" + } + }, + "@poppinss/dumper": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.4.tgz", + "integrity": "sha512-iG0TIdqv8xJ3Lt9O8DrPRxw1MRLjNpoqiSGU03P/wNLP/s0ra0udPJ1J2Tx5M0J3H/cVyEgpbn8xUKRY9j59kQ==", + "dev": true, + "requires": { + "@poppinss/colors": "^4.1.5", + "@sindresorhus/is": "^7.0.2", + "supports-color": "^10.0.0" + }, + "dependencies": { + "supports-color": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.0.0.tgz", + "integrity": "sha512-HRVVSbCCMbj7/kdWF9Q+bbckjBHLtHMEoJWlkmYzzdwhYMkjkOwubLM6t7NbWKjgKamGDrWL1++KrjUO1t9oAQ==", + "dev": true + } + } + }, + "@poppinss/exception": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.2.tgz", + "integrity": "sha512-m7bpKCD4QMlFCjA/nKTs23fuvoVFoA83brRKmObCUNmi/9tVu8Ve3w4YQAnJu4q3Tjf5fr685HYIC/IA2zHRSg==", + "dev": true + }, "@rollup/plugin-alias": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/@rollup/plugin-alias/-/plugin-alias-5.1.1.tgz", @@ -16922,142 +16827,142 @@ } }, "@rollup/rollup-android-arm-eabi": { - "version": "4.44.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.44.1.tgz", - "integrity": "sha512-JAcBr1+fgqx20m7Fwe1DxPUl/hPkee6jA6Pl7n1v2EFiktAHenTaXl5aIFjUIEsfn9w3HE4gK1lEgNGMzBDs1w==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.45.1.tgz", + "integrity": "sha512-NEySIFvMY0ZQO+utJkgoMiCAjMrGvnbDLHvcmlA33UXJpYBCvlBEbMMtV837uCkS+plG2umfhn0T5mMAxGrlRA==", "dev": true, "optional": true }, "@rollup/rollup-android-arm64": { - "version": "4.44.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.44.1.tgz", - "integrity": "sha512-RurZetXqTu4p+G0ChbnkwBuAtwAbIwJkycw1n6GvlGlBuS4u5qlr5opix8cBAYFJgaY05TWtM+LaoFggUmbZEQ==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.45.1.tgz", + "integrity": "sha512-ujQ+sMXJkg4LRJaYreaVx7Z/VMgBBd89wGS4qMrdtfUFZ+TSY5Rs9asgjitLwzeIbhwdEhyj29zhst3L1lKsRQ==", "dev": true, "optional": true }, "@rollup/rollup-darwin-arm64": { - "version": "4.44.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.44.1.tgz", - "integrity": "sha512-fM/xPesi7g2M7chk37LOnmnSTHLG/v2ggWqKj3CCA1rMA4mm5KVBT1fNoswbo1JhPuNNZrVwpTvlCVggv8A2zg==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.45.1.tgz", + "integrity": "sha512-FSncqHvqTm3lC6Y13xncsdOYfxGSLnP+73k815EfNmpewPs+EyM49haPS105Rh4aF5mJKywk9X0ogzLXZzN9lA==", "dev": true, "optional": true }, "@rollup/rollup-darwin-x64": { - "version": "4.44.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.44.1.tgz", - "integrity": "sha512-gDnWk57urJrkrHQ2WVx9TSVTH7lSlU7E3AFqiko+bgjlh78aJ88/3nycMax52VIVjIm3ObXnDL2H00e/xzoipw==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.45.1.tgz", + "integrity": "sha512-2/vVn/husP5XI7Fsf/RlhDaQJ7x9zjvC81anIVbr4b/f0xtSmXQTFcGIQ/B1cXIYM6h2nAhJkdMHTnD7OtQ9Og==", "dev": true, "optional": true }, "@rollup/rollup-freebsd-arm64": { - "version": "4.44.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.44.1.tgz", - "integrity": "sha512-wnFQmJ/zPThM5zEGcnDcCJeYJgtSLjh1d//WuHzhf6zT3Md1BvvhJnWoy+HECKu2bMxaIcfWiu3bJgx6z4g2XA==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.45.1.tgz", + "integrity": "sha512-4g1kaDxQItZsrkVTdYQ0bxu4ZIQ32cotoQbmsAnW1jAE4XCMbcBPDirX5fyUzdhVCKgPcrwWuucI8yrVRBw2+g==", "dev": true, "optional": true }, "@rollup/rollup-freebsd-x64": { - "version": "4.44.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.44.1.tgz", - "integrity": "sha512-uBmIxoJ4493YATvU2c0upGz87f99e3wop7TJgOA/bXMFd2SvKCI7xkxY/5k50bv7J6dw1SXT4MQBQSLn8Bb/Uw==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.45.1.tgz", + "integrity": "sha512-L/6JsfiL74i3uK1Ti2ZFSNsp5NMiM4/kbbGEcOCps99aZx3g8SJMO1/9Y0n/qKlWZfn6sScf98lEOUe2mBvW9A==", "dev": true, "optional": true }, "@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.44.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.44.1.tgz", - "integrity": "sha512-n0edDmSHlXFhrlmTK7XBuwKlG5MbS7yleS1cQ9nn4kIeW+dJH+ExqNgQ0RrFRew8Y+0V/x6C5IjsHrJmiHtkxQ==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.45.1.tgz", + "integrity": "sha512-RkdOTu2jK7brlu+ZwjMIZfdV2sSYHK2qR08FUWcIoqJC2eywHbXr0L8T/pONFwkGukQqERDheaGTeedG+rra6Q==", "dev": true, "optional": true }, "@rollup/rollup-linux-arm-musleabihf": { - "version": "4.44.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.44.1.tgz", - "integrity": "sha512-8WVUPy3FtAsKSpyk21kV52HCxB+me6YkbkFHATzC2Yd3yuqHwy2lbFL4alJOLXKljoRw08Zk8/xEj89cLQ/4Nw==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.45.1.tgz", + "integrity": "sha512-3kJ8pgfBt6CIIr1o+HQA7OZ9mp/zDk3ctekGl9qn/pRBgrRgfwiffaUmqioUGN9hv0OHv2gxmvdKOkARCtRb8Q==", "dev": true, "optional": true }, "@rollup/rollup-linux-arm64-gnu": { - "version": "4.44.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.44.1.tgz", - "integrity": "sha512-yuktAOaeOgorWDeFJggjuCkMGeITfqvPgkIXhDqsfKX8J3jGyxdDZgBV/2kj/2DyPaLiX6bPdjJDTu9RB8lUPQ==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.45.1.tgz", + "integrity": "sha512-k3dOKCfIVixWjG7OXTCOmDfJj3vbdhN0QYEqB+OuGArOChek22hn7Uy5A/gTDNAcCy5v2YcXRJ/Qcnm4/ma1xw==", "dev": true, "optional": true }, "@rollup/rollup-linux-arm64-musl": { - "version": "4.44.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.44.1.tgz", - "integrity": "sha512-W+GBM4ifET1Plw8pdVaecwUgxmiH23CfAUj32u8knq0JPFyK4weRy6H7ooxYFD19YxBulL0Ktsflg5XS7+7u9g==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.45.1.tgz", + "integrity": "sha512-PmI1vxQetnM58ZmDFl9/Uk2lpBBby6B6rF4muJc65uZbxCs0EA7hhKCk2PKlmZKuyVSHAyIw3+/SiuMLxKxWog==", "dev": true, "optional": true }, "@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.44.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.44.1.tgz", - "integrity": "sha512-1zqnUEMWp9WrGVuVak6jWTl4fEtrVKfZY7CvcBmUUpxAJ7WcSowPSAWIKa/0o5mBL/Ij50SIf9tuirGx63Ovew==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.45.1.tgz", + "integrity": "sha512-9UmI0VzGmNJ28ibHW2GpE2nF0PBQqsyiS4kcJ5vK+wuwGnV5RlqdczVocDSUfGX/Na7/XINRVoUgJyFIgipoRg==", "dev": true, "optional": true }, "@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.44.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.44.1.tgz", - "integrity": "sha512-Rl3JKaRu0LHIx7ExBAAnf0JcOQetQffaw34T8vLlg9b1IhzcBgaIdnvEbbsZq9uZp3uAH+JkHd20Nwn0h9zPjA==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.45.1.tgz", + "integrity": "sha512-7nR2KY8oEOUTD3pBAxIBBbZr0U7U+R9HDTPNy+5nVVHDXI4ikYniH1oxQz9VoB5PbBU1CZuDGHkLJkd3zLMWsg==", "dev": true, "optional": true }, "@rollup/rollup-linux-riscv64-gnu": { - "version": "4.44.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.44.1.tgz", - "integrity": "sha512-j5akelU3snyL6K3N/iX7otLBIl347fGwmd95U5gS/7z6T4ftK288jKq3A5lcFKcx7wwzb5rgNvAg3ZbV4BqUSw==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.45.1.tgz", + "integrity": "sha512-nlcl3jgUultKROfZijKjRQLUu9Ma0PeNv/VFHkZiKbXTBQXhpytS8CIj5/NfBeECZtY2FJQubm6ltIxm/ftxpw==", "dev": true, "optional": true }, "@rollup/rollup-linux-riscv64-musl": { - "version": "4.44.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.44.1.tgz", - "integrity": "sha512-ppn5llVGgrZw7yxbIm8TTvtj1EoPgYUAbfw0uDjIOzzoqlZlZrLJ/KuiE7uf5EpTpCTrNt1EdtzF0naMm0wGYg==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.45.1.tgz", + "integrity": "sha512-HJV65KLS51rW0VY6rvZkiieiBnurSzpzore1bMKAhunQiECPuxsROvyeaot/tcK3A3aGnI+qTHqisrpSgQrpgA==", "dev": true, "optional": true }, "@rollup/rollup-linux-s390x-gnu": { - "version": "4.44.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.44.1.tgz", - "integrity": "sha512-Hu6hEdix0oxtUma99jSP7xbvjkUM/ycke/AQQ4EC5g7jNRLLIwjcNwaUy95ZKBJJwg1ZowsclNnjYqzN4zwkAw==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.45.1.tgz", + "integrity": "sha512-NITBOCv3Qqc6hhwFt7jLV78VEO/il4YcBzoMGGNxznLgRQf43VQDae0aAzKiBeEPIxnDrACiMgbqjuihx08OOw==", "dev": true, "optional": true }, "@rollup/rollup-linux-x64-gnu": { - "version": "4.44.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.44.1.tgz", - "integrity": "sha512-EtnsrmZGomz9WxK1bR5079zee3+7a+AdFlghyd6VbAjgRJDbTANJ9dcPIPAi76uG05micpEL+gPGmAKYTschQw==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.45.1.tgz", + "integrity": "sha512-+E/lYl6qu1zqgPEnTrs4WysQtvc/Sh4fC2nByfFExqgYrqkKWp1tWIbe+ELhixnenSpBbLXNi6vbEEJ8M7fiHw==", "dev": true, "optional": true }, "@rollup/rollup-linux-x64-musl": { - "version": "4.44.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.44.1.tgz", - "integrity": "sha512-iAS4p+J1az6Usn0f8xhgL4PaU878KEtutP4hqw52I4IO6AGoyOkHCxcc4bqufv1tQLdDWFx8lR9YlwxKuv3/3g==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.45.1.tgz", + "integrity": "sha512-a6WIAp89p3kpNoYStITT9RbTbTnqarU7D8N8F2CV+4Cl9fwCOZraLVuVFvlpsW0SbIiYtEnhCZBPLoNdRkjQFw==", "dev": true, "optional": true }, "@rollup/rollup-win32-arm64-msvc": { - "version": "4.44.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.44.1.tgz", - "integrity": "sha512-NtSJVKcXwcqozOl+FwI41OH3OApDyLk3kqTJgx8+gp6On9ZEt5mYhIsKNPGuaZr3p9T6NWPKGU/03Vw4CNU9qg==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.45.1.tgz", + "integrity": "sha512-T5Bi/NS3fQiJeYdGvRpTAP5P02kqSOpqiopwhj0uaXB6nzs5JVi2XMJb18JUSKhCOX8+UE1UKQufyD6Or48dJg==", "dev": true, "optional": true }, "@rollup/rollup-win32-ia32-msvc": { - "version": "4.44.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.44.1.tgz", - "integrity": "sha512-JYA3qvCOLXSsnTR3oiyGws1Dm0YTuxAAeaYGVlGpUsHqloPcFjPg+X0Fj2qODGLNwQOAcCiQmHub/V007kiH5A==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.45.1.tgz", + "integrity": "sha512-lxV2Pako3ujjuUe9jiU3/s7KSrDfH6IgTSQOnDWr9aJ92YsFd7EurmClK0ly/t8dzMkDtd04g60WX6yl0sGfdw==", "dev": true, "optional": true }, "@rollup/rollup-win32-x64-msvc": { - "version": "4.44.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.44.1.tgz", - "integrity": "sha512-J8o22LuF0kTe7m+8PvW9wk3/bRq5+mRo5Dqo6+vXb7otCm3TPhYOJqOaQtGU9YMWQSL3krMnoOxMr0+9E6F3Ug==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.45.1.tgz", + "integrity": "sha512-M/fKi4sasCdM8i0aWJjCSFm2qEnYRR8AMLG2kxp6wD13+tMGA4Z1tVAuHkNRjud5SW2EM3naLuK35w9twvf6aA==", "dev": true, "optional": true }, @@ -17168,6 +17073,12 @@ "string-argv": "~0.3.1" } }, + "@sindresorhus/is": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.0.2.tgz", + "integrity": "sha512-d9xRovfKNz1SKieM0qJdO+PQonjnnIfSNWfHYnBSJ9hkjm0ZPw6HlxscDXYstp3z+7V2GOFHc+J0CYrYTjqCJw==", + "dev": true + }, "@sinonjs/commons": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", @@ -17217,10 +17128,16 @@ "integrity": "sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg==", "dev": true }, + "@speed-highlight/core": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.7.tgz", + "integrity": "sha512-0dxmVj4gxg3Jg879kvFS/msl4s9F3T9UXC1InxgOf7t5NvcPD97u/WTA5vL/IxWHMn7qSxBozqrnnE2wvl1m8g==", + "dev": true + }, "@svta/common-media-library": { - "version": "0.15.1", - "resolved": "https://registry.npmjs.org/@svta/common-media-library/-/common-media-library-0.15.1.tgz", - "integrity": "sha512-Ig26anQU/MVRxrikBv1cMmeJqhXG683jxA7q9iscoMygcYd4+XmLjM0kcshZ574izow4X+j/NcU5vTrvVXF9sQ==", + "version": "0.17.1", + "resolved": "https://registry.npmjs.org/@svta/common-media-library/-/common-media-library-0.17.1.tgz", + "integrity": "sha512-UcmqRe1cJ/OloNEeXqKJjbw6MAKPaGZUk4yLsy1QOwtzUmo7hDVvZeIoqXlmoXZNQRQ/P1MsNuboKirsTw9e7w==", "dev": true }, "@testim/chrome-version": { @@ -17413,16 +17330,16 @@ } }, "@typescript-eslint/eslint-plugin": { - "version": "8.35.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.35.1.tgz", - "integrity": "sha512-9XNTlo7P7RJxbVeICaIIIEipqxLKguyh+3UbXuT2XQuFp6d8VOeDEGuz5IiX0dgZo8CiI6aOFLg4e8cF71SFVg==", + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.38.0.tgz", + "integrity": "sha512-CPoznzpuAnIOl4nhj4tRr4gIPj5AfKgkiJmGQDaq+fQnRJTYlcBjbX3wbciGmpoPf8DREufuPRe1tNMZnGdanA==", "dev": true, "requires": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.35.1", - "@typescript-eslint/type-utils": "8.35.1", - "@typescript-eslint/utils": "8.35.1", - "@typescript-eslint/visitor-keys": "8.35.1", + "@typescript-eslint/scope-manager": "8.38.0", + "@typescript-eslint/type-utils": "8.38.0", + "@typescript-eslint/utils": "8.38.0", + "@typescript-eslint/visitor-keys": "8.38.0", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", @@ -17438,74 +17355,75 @@ } }, "@typescript-eslint/parser": { - "version": "8.35.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.35.1.tgz", - "integrity": "sha512-3MyiDfrfLeK06bi/g9DqJxP5pV74LNv4rFTyvGDmT3x2p1yp1lOd+qYZfiRPIOf/oON+WRZR5wxxuF85qOar+w==", + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.38.0.tgz", + "integrity": "sha512-Zhy8HCvBUEfBECzIl1PKqF4p11+d0aUJS1GeUiuqK9WmOug8YCmC4h4bjyBvMyAMI9sbRczmrYL5lKg/YMbrcQ==", "dev": true, "requires": { - "@typescript-eslint/scope-manager": "8.35.1", - "@typescript-eslint/types": "8.35.1", - "@typescript-eslint/typescript-estree": "8.35.1", - "@typescript-eslint/visitor-keys": "8.35.1", + "@typescript-eslint/scope-manager": "8.38.0", + "@typescript-eslint/types": "8.38.0", + "@typescript-eslint/typescript-estree": "8.38.0", + "@typescript-eslint/visitor-keys": "8.38.0", "debug": "^4.3.4" } }, "@typescript-eslint/project-service": { - "version": "8.35.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.35.1.tgz", - "integrity": "sha512-VYxn/5LOpVxADAuP3NrnxxHYfzVtQzLKeldIhDhzC8UHaiQvYlXvKuVho1qLduFbJjjy5U5bkGwa3rUGUb1Q6Q==", + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.38.0.tgz", + "integrity": "sha512-dbK7Jvqcb8c9QfH01YB6pORpqX1mn5gDZc9n63Ak/+jD67oWXn3Gs0M6vddAN+eDXBCS5EmNWzbSxsn9SzFWWg==", "dev": true, "requires": { - "@typescript-eslint/tsconfig-utils": "^8.35.1", - "@typescript-eslint/types": "^8.35.1", + "@typescript-eslint/tsconfig-utils": "^8.38.0", + "@typescript-eslint/types": "^8.38.0", "debug": "^4.3.4" } }, "@typescript-eslint/scope-manager": { - "version": "8.35.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.35.1.tgz", - "integrity": "sha512-s/Bpd4i7ht2934nG+UoSPlYXd08KYz3bmjLEb7Ye1UVob0d1ENiT3lY8bsCmik4RqfSbPw9xJJHbugpPpP5JUg==", + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.38.0.tgz", + "integrity": "sha512-WJw3AVlFFcdT9Ri1xs/lg8LwDqgekWXWhH3iAF+1ZM+QPd7oxQ6jvtW/JPwzAScxitILUIFs0/AnQ/UWHzbATQ==", "dev": true, "requires": { - "@typescript-eslint/types": "8.35.1", - "@typescript-eslint/visitor-keys": "8.35.1" + "@typescript-eslint/types": "8.38.0", + "@typescript-eslint/visitor-keys": "8.38.0" } }, "@typescript-eslint/tsconfig-utils": { - "version": "8.35.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.35.1.tgz", - "integrity": "sha512-K5/U9VmT9dTHoNowWZpz+/TObS3xqC5h0xAIjXPw+MNcKV9qg6eSatEnmeAwkjHijhACH0/N7bkhKvbt1+DXWQ==", + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.38.0.tgz", + "integrity": "sha512-Lum9RtSE3EroKk/bYns+sPOodqb2Fv50XOl/gMviMKNvanETUuUcC9ObRbzrJ4VSd2JalPqgSAavwrPiPvnAiQ==", "dev": true, "requires": {} }, "@typescript-eslint/type-utils": { - "version": "8.35.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.35.1.tgz", - "integrity": "sha512-HOrUBlfVRz5W2LIKpXzZoy6VTZzMu2n8q9C2V/cFngIC5U1nStJgv0tMV4sZPzdf4wQm9/ToWUFPMN9Vq9VJQQ==", + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.38.0.tgz", + "integrity": "sha512-c7jAvGEZVf0ao2z+nnz8BUaHZD09Agbh+DY7qvBQqLiz8uJzRgVPj5YvOh8I8uEiH8oIUGIfHzMwUcGVco/SJg==", "dev": true, "requires": { - "@typescript-eslint/typescript-estree": "8.35.1", - "@typescript-eslint/utils": "8.35.1", + "@typescript-eslint/types": "8.38.0", + "@typescript-eslint/typescript-estree": "8.38.0", + "@typescript-eslint/utils": "8.38.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" } }, "@typescript-eslint/types": { - "version": "8.35.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.35.1.tgz", - "integrity": "sha512-q/O04vVnKHfrrhNAscndAn1tuQhIkwqnaW+eu5waD5IPts2eX1dgJxgqcPx5BX109/qAz7IG6VrEPTOYKCNfRQ==", + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.38.0.tgz", + "integrity": "sha512-wzkUfX3plUqij4YwWaJyqhiPE5UCRVlFpKn1oCRn2O1bJ592XxWJj8ROQ3JD5MYXLORW84063z3tZTb/cs4Tyw==", "dev": true }, "@typescript-eslint/typescript-estree": { - "version": "8.35.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.35.1.tgz", - "integrity": "sha512-Vvpuvj4tBxIka7cPs6Y1uvM7gJgdF5Uu9F+mBJBPY4MhvjrjWGK4H0lVgLJd/8PWZ23FTqsaJaLEkBCFUk8Y9g==", + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.38.0.tgz", + "integrity": "sha512-fooELKcAKzxux6fA6pxOflpNS0jc+nOQEEOipXFNjSlBS6fqrJOVY/whSn70SScHrcJ2LDsxWrneFoWYSVfqhQ==", "dev": true, "requires": { - "@typescript-eslint/project-service": "8.35.1", - "@typescript-eslint/tsconfig-utils": "8.35.1", - "@typescript-eslint/types": "8.35.1", - "@typescript-eslint/visitor-keys": "8.35.1", + "@typescript-eslint/project-service": "8.38.0", + "@typescript-eslint/tsconfig-utils": "8.38.0", + "@typescript-eslint/types": "8.38.0", + "@typescript-eslint/visitor-keys": "8.38.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -17535,24 +17453,24 @@ } }, "@typescript-eslint/utils": { - "version": "8.35.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.35.1.tgz", - "integrity": "sha512-lhnwatFmOFcazAsUm3ZnZFpXSxiwoa1Lj50HphnDe1Et01NF4+hrdXONSUHIcbVu2eFb1bAf+5yjXkGVkXBKAQ==", + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.38.0.tgz", + "integrity": "sha512-hHcMA86Hgt+ijJlrD8fX0j1j8w4C92zue/8LOPAFioIno+W0+L7KqE8QZKCcPGc/92Vs9x36w/4MPTJhqXdyvg==", "dev": true, "requires": { "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.35.1", - "@typescript-eslint/types": "8.35.1", - "@typescript-eslint/typescript-estree": "8.35.1" + "@typescript-eslint/scope-manager": "8.38.0", + "@typescript-eslint/types": "8.38.0", + "@typescript-eslint/typescript-estree": "8.38.0" } }, "@typescript-eslint/visitor-keys": { - "version": "8.35.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.35.1.tgz", - "integrity": "sha512-VRwixir4zBWCSTP/ljEo091lbpypz57PoeAQ9imjG+vbeof9LplljsL1mos4ccG6H9IjfrVGM359RozUnuFhpw==", + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.38.0.tgz", + "integrity": "sha512-pWrTcoFNWuwHlA9CvlfSsGWs14JxfN1TH25zM5L7o0pRLhsoZkDnTsXfQRJBEWJoV5DL0jf+Z+sxiud+K0mq1g==", "dev": true, "requires": { - "@typescript-eslint/types": "8.35.1", + "@typescript-eslint/types": "8.38.0", "eslint-visitor-keys": "^4.2.1" }, "dependencies": { @@ -17986,15 +17904,6 @@ "is-array-buffer": "^3.0.4" } }, - "as-table": { - "version": "1.0.55", - "resolved": "https://registry.npmjs.org/as-table/-/as-table-1.0.55.tgz", - "integrity": "sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ==", - "dev": true, - "requires": { - "printable-characters": "^1.0.42" - } - }, "assertion-error": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", @@ -18429,9 +18338,9 @@ "peer": true }, "chromedriver": { - "version": "138.0.0", - "resolved": "https://registry.npmjs.org/chromedriver/-/chromedriver-138.0.0.tgz", - "integrity": "sha512-bJ/DNm5Y0TbqM71ARaAohTWVwcQ2SsWciYC5Q9Ul7DC/oTxm6B1vI2h6WscFCOOi49ul4tXZVjA/LOruljjmjA==", + "version": "138.0.3", + "resolved": "https://registry.npmjs.org/chromedriver/-/chromedriver-138.0.3.tgz", + "integrity": "sha512-RKcfzbUthmQzFmy91F9StQQwNZ72khp3febF/RntpkDKhhCkwor0cgop00diwzAVSUq1s2e8B54Iema9FQnynw==", "dev": true, "requires": { "@testim/chrome-version": "^1.1.4", @@ -19067,6 +18976,12 @@ "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", "dev": true }, + "error-stack-parser-es": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", + "dev": true + }, "es-abstract": { "version": "1.24.0", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", @@ -19447,9 +19362,9 @@ } }, "eslint-config-prettier": { - "version": "10.1.5", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.5.tgz", - "integrity": "sha512-zc1UmCpNltmVY34vuLRV61r1K27sWuX39E+uyUnY8xS2Bex88VV9cugG+UZbRSRGtGyFboj+D8JODyme1plMpw==", + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "requires": {} }, @@ -19582,13 +19497,12 @@ } }, "eslint-plugin-n": { - "version": "17.20.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-n/-/eslint-plugin-n-17.20.0.tgz", - "integrity": "sha512-IRSoatgB/NQJZG5EeTbv/iAx1byOGdbbyhQrNvWdCfTnmPxUT0ao9/eGOeG7ljD8wJBsxwE8f6tES5Db0FRKEw==", + "version": "17.21.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-n/-/eslint-plugin-n-17.21.0.tgz", + "integrity": "sha512-1+iZ8We4ZlwVMtb/DcHG3y5/bZOdazIpa/4TySo22MLKdwrLcfrX0hbadnCvykSQCCmkAnWmIP8jZVb2AAq29A==", "dev": true, "requires": { "@eslint-community/eslint-utils": "^4.5.0", - "@typescript-eslint/utils": "^8.26.1", "enhanced-resolve": "^5.17.1", "eslint-plugin-es-x": "^7.8.0", "get-tsconfig": "^4.8.1", @@ -19608,12 +19522,6 @@ "balanced-match": "^1.0.0" } }, - "globals": { - "version": "15.15.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", - "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", - "dev": true - }, "minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -19765,9 +19673,9 @@ "dev": true }, "exsolve": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.5.tgz", - "integrity": "sha512-pz5dvkYYKQ1AHVrgOzBKWeP4u4FRb3a6DNK2ucr0OoNwYIU4QWsJ+NM36LLzORT+z845MzKHHhpXiUF5nvQoJg==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.7.tgz", + "integrity": "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==", "dev": true }, "extend": { @@ -20137,24 +20045,6 @@ "es-object-atoms": "^1.0.0" } }, - "get-source": { - "version": "2.0.12", - "resolved": "https://registry.npmjs.org/get-source/-/get-source-2.0.12.tgz", - "integrity": "sha512-X5+4+iD+HoSeEED+uwrQ07BOQr0kEDFMVqqpBuI+RaZBpBpHCuXxo70bjar6f0b0u/DQJsJ7ssurpP0V60Az+w==", - "dev": true, - "requires": { - "data-uri-to-buffer": "^2.0.0", - "source-map": "^0.6.1" - }, - "dependencies": { - "data-uri-to-buffer": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-2.0.2.tgz", - "integrity": "sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA==", - "dev": true - } - } - }, "get-stream": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", @@ -20239,6 +20129,12 @@ "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", "dev": true }, + "globals": { + "version": "15.15.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", + "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", + "dev": true + }, "globalthis": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", @@ -21330,6 +21226,12 @@ "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", "dev": true }, + "kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true + }, "kuler": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", @@ -21976,9 +21878,9 @@ "dev": true }, "miniflare": { - "version": "4.20250617.4", - "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20250617.4.tgz", - "integrity": "sha512-IAoApFKxOJlaaFkym5ETstVX3qWzVt3xyqCDj6vSSTgEH3zxZJ5417jZGg8iQfMHosKCcQH1doPPqqnOZm/yrw==", + "version": "4.20250726.0", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20250726.0.tgz", + "integrity": "sha512-7+/RQQ9dNsyGfR2XN2RDLultf7HHrJ5YltSXSeyQGUpzGU3iYlFhh9Smg+ygkkOJ3+trf0bgwixOnqnnWpc9ZQ==", "dev": true, "requires": { "@cspotcode/source-map-support": "0.8.1", @@ -21988,10 +21890,10 @@ "glob-to-regexp": "0.4.1", "sharp": "^0.33.5", "stoppable": "1.1.0", - "undici": "^5.28.5", - "workerd": "1.20250617.0", + "undici": "^7.10.0", + "workerd": "1.20250726.0", "ws": "8.18.0", - "youch": "3.3.4", + "youch": "4.1.0-beta.10", "zod": "3.22.3" } }, @@ -22261,12 +22163,6 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", "dev": true }, - "mustache": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", - "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", - "dev": true - }, "nano-spawn": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/nano-spawn/-/nano-spawn-1.0.2.tgz", @@ -22928,15 +22824,9 @@ "dev": true }, "prettier": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz", - "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", - "dev": true - }, - "printable-characters": { - "version": "1.0.42", - "resolved": "https://registry.npmjs.org/printable-characters/-/printable-characters-1.0.42.tgz", - "integrity": "sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==", + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true }, "process-nextick-args": { @@ -23304,31 +23194,31 @@ } }, "rollup": { - "version": "4.44.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.44.1.tgz", - "integrity": "sha512-x8H8aPvD+xbl0Do8oez5f5o8eMS3trfCghc4HhLAnCkj7Vl0d1JWGs0UF/D886zLW2rOj2QymV/JcSSsw+XDNg==", - "dev": true, - "requires": { - "@rollup/rollup-android-arm-eabi": "4.44.1", - "@rollup/rollup-android-arm64": "4.44.1", - "@rollup/rollup-darwin-arm64": "4.44.1", - "@rollup/rollup-darwin-x64": "4.44.1", - "@rollup/rollup-freebsd-arm64": "4.44.1", - "@rollup/rollup-freebsd-x64": "4.44.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.44.1", - "@rollup/rollup-linux-arm-musleabihf": "4.44.1", - "@rollup/rollup-linux-arm64-gnu": "4.44.1", - "@rollup/rollup-linux-arm64-musl": "4.44.1", - "@rollup/rollup-linux-loongarch64-gnu": "4.44.1", - "@rollup/rollup-linux-powerpc64le-gnu": "4.44.1", - "@rollup/rollup-linux-riscv64-gnu": "4.44.1", - "@rollup/rollup-linux-riscv64-musl": "4.44.1", - "@rollup/rollup-linux-s390x-gnu": "4.44.1", - "@rollup/rollup-linux-x64-gnu": "4.44.1", - "@rollup/rollup-linux-x64-musl": "4.44.1", - "@rollup/rollup-win32-arm64-msvc": "4.44.1", - "@rollup/rollup-win32-ia32-msvc": "4.44.1", - "@rollup/rollup-win32-x64-msvc": "4.44.1", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.45.1.tgz", + "integrity": "sha512-4iya7Jb76fVpQyLoiVpzUrsjQ12r3dM7fIVz+4NwoYvZOShknRmiv+iu9CClZml5ZLGb0XMcYLutK6w9tgxHDw==", + "dev": true, + "requires": { + "@rollup/rollup-android-arm-eabi": "4.45.1", + "@rollup/rollup-android-arm64": "4.45.1", + "@rollup/rollup-darwin-arm64": "4.45.1", + "@rollup/rollup-darwin-x64": "4.45.1", + "@rollup/rollup-freebsd-arm64": "4.45.1", + "@rollup/rollup-freebsd-x64": "4.45.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.45.1", + "@rollup/rollup-linux-arm-musleabihf": "4.45.1", + "@rollup/rollup-linux-arm64-gnu": "4.45.1", + "@rollup/rollup-linux-arm64-musl": "4.45.1", + "@rollup/rollup-linux-loongarch64-gnu": "4.45.1", + "@rollup/rollup-linux-powerpc64le-gnu": "4.45.1", + "@rollup/rollup-linux-riscv64-gnu": "4.45.1", + "@rollup/rollup-linux-riscv64-musl": "4.45.1", + "@rollup/rollup-linux-s390x-gnu": "4.45.1", + "@rollup/rollup-linux-x64-gnu": "4.45.1", + "@rollup/rollup-linux-x64-musl": "4.45.1", + "@rollup/rollup-win32-arm64-msvc": "4.45.1", + "@rollup/rollup-win32-ia32-msvc": "4.45.1", + "@rollup/rollup-win32-x64-msvc": "4.45.1", "@types/estree": "1.0.8", "fsevents": "~2.3.2" }, @@ -23882,16 +23772,6 @@ "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==", "dev": true }, - "stacktracey": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/stacktracey/-/stacktracey-2.1.8.tgz", - "integrity": "sha512-Kpij9riA+UNg7TnphqjH7/CzctQ/owJGNbFkfEeve4Z4uxT5+JapVLFXcsurIfN34gnTWZNJ/f7NMG0E8JDzTw==", - "dev": true, - "requires": { - "as-table": "^1.0.36", - "get-source": "^2.0.12" - } - }, "statuses": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", @@ -24418,13 +24298,10 @@ "dev": true }, "undici": { - "version": "5.29.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", - "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", - "dev": true, - "requires": { - "@fastify/busboy": "^2.0.0" - } + "version": "7.11.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.11.0.tgz", + "integrity": "sha512-heTSIac3iLhsmZhUCjyS3JQEkZELateufzZuBaVM5RHXdSBMb1LPMQf5x+FH7qjsZYDP0ttAc3nnVpUB+wYbOg==", + "dev": true }, "undici-types": { "version": "6.20.0", @@ -24432,19 +24309,6 @@ "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", "dev": true }, - "unenv": { - "version": "2.0.0-rc.17", - "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.17.tgz", - "integrity": "sha512-B06u0wXkEd+o5gOCMl/ZHl5cfpYbDZKAT+HWTL+Hws6jWu7dCiqBBXXXzMFcFVJb8D4ytAnYmxJA83uwOQRSsg==", - "dev": true, - "requires": { - "defu": "^6.1.4", - "exsolve": "^1.0.4", - "ohash": "^2.0.11", - "pathe": "^2.0.3", - "ufo": "^1.6.1" - } - }, "unicode-canonical-property-names-ecmascript": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", @@ -24927,16 +24791,16 @@ "dev": true }, "workerd": { - "version": "1.20250617.0", - "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20250617.0.tgz", - "integrity": "sha512-Uv6p0PYUHp/W/aWfUPLkZVAoAjapisM27JJlwcX9wCPTfCfnuegGOxFMvvlYpmNaX4YCwEdLCwuNn3xkpSkuZw==", + "version": "1.20250726.0", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20250726.0.tgz", + "integrity": "sha512-wDZqSKfIfQ2eVTUL6UawXdXEKPPyzRTnVdbhoKGq3NFrMxd+7v1cNH92u8775Qo1zO5S+GyWonQmZPFakXLvGw==", "dev": true, "requires": { - "@cloudflare/workerd-darwin-64": "1.20250617.0", - "@cloudflare/workerd-darwin-arm64": "1.20250617.0", - "@cloudflare/workerd-linux-64": "1.20250617.0", - "@cloudflare/workerd-linux-arm64": "1.20250617.0", - "@cloudflare/workerd-windows-64": "1.20250617.0" + "@cloudflare/workerd-darwin-64": "1.20250726.0", + "@cloudflare/workerd-darwin-arm64": "1.20250726.0", + "@cloudflare/workerd-linux-64": "1.20250726.0", + "@cloudflare/workerd-linux-arm64": "1.20250726.0", + "@cloudflare/workerd-windows-64": "1.20250726.0" } }, "workerpool": { @@ -24946,20 +24810,42 @@ "dev": true }, "wrangler": { - "version": "4.22.0", - "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.22.0.tgz", - "integrity": "sha512-m8qVO3YxhUTII+4U889G/f5UuLSvMkUkCNatupV2f/SJ+iqaWtP1QbuQII8bs2J/O4rqxsz46Wu2S50u7tKB5Q==", + "version": "4.26.1", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.26.1.tgz", + "integrity": "sha512-zGFEtHrjTAWOngm+zwEvYCxFwMSIBrzHa3Yu6rAxYMEzsT8PPvo2rdswyUJiUkpE9s2Depr37opceaY7JxEYFw==", "dev": true, "requires": { "@cloudflare/kv-asset-handler": "0.4.0", - "@cloudflare/unenv-preset": "2.3.3", + "@cloudflare/unenv-preset": "2.5.0", "blake3-wasm": "2.1.5", "esbuild": "0.25.4", "fsevents": "~2.3.2", - "miniflare": "4.20250617.4", + "miniflare": "4.20250726.0", "path-to-regexp": "6.3.0", - "unenv": "2.0.0-rc.17", - "workerd": "1.20250617.0" + "unenv": "2.0.0-rc.19", + "workerd": "1.20250726.0" + }, + "dependencies": { + "@cloudflare/unenv-preset": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.5.0.tgz", + "integrity": "sha512-CZe9B2VbjIQjBTyc+KoZcN1oUcm4T6GgCXoel9O7647djHuSRAa6sM6G+NdxWArATZgeMMbsvn9C50GCcnIatA==", + "dev": true, + "requires": {} + }, + "unenv": { + "version": "2.0.0-rc.19", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.19.tgz", + "integrity": "sha512-t/OMHBNAkknVCI7bVB9OWjUUAwhVv9vsPIAGnNUxnu3FxPQN11rjh0sksLMzc3g7IlTgvHmOTl4JM7JHpcv5wA==", + "dev": true, + "requires": { + "defu": "^6.1.4", + "exsolve": "^1.0.7", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "ufo": "^1.6.1" + } + } } }, "wrap-ansi": { @@ -25202,24 +25088,36 @@ "dev": true }, "youch": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/youch/-/youch-3.3.4.tgz", - "integrity": "sha512-UeVBXie8cA35DS6+nBkls68xaBBXCye0CNznrhszZjTbRVnJKQuNsyLKBTTL4ln1o1rh2PKtv35twV7irj5SEg==", + "version": "4.1.0-beta.10", + "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", + "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", "dev": true, "requires": { - "cookie": "^0.7.1", - "mustache": "^4.2.0", - "stacktracey": "^2.1.8" + "@poppinss/colors": "^4.1.5", + "@poppinss/dumper": "^0.6.4", + "@speed-highlight/core": "^1.2.7", + "cookie": "^1.0.2", + "youch-core": "^0.3.3" }, "dependencies": { "cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", "dev": true } } }, + "youch-core": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", + "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", + "dev": true, + "requires": { + "@poppinss/exception": "^1.2.2", + "error-stack-parser-es": "^1.0.5" + } + }, "zod": { "version": "3.22.3", "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.3.tgz", diff --git a/package.json b/package.json index f9434a0f215..fc0cb5d562e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hls.js", - "version": "1.6.8", + "version": "1.6.12", "license": "Apache-2.0", "description": "JavaScript HLS client using MediaSourceExtension", "homepage": "https://github.com/video-dev/hls.js", @@ -68,10 +68,10 @@ "devDependencies": { "@babel/core": "7.28.0", "@babel/helper-module-imports": "7.27.1", - "@babel/plugin-proposal-class-properties": "7.18.6", - "@babel/plugin-proposal-object-rest-spread": "7.20.7", - "@babel/plugin-proposal-optional-chaining": "7.21.0", + "@babel/plugin-transform-class-properties": "7.27.1", "@babel/plugin-transform-object-assign": "7.27.1", + "@babel/plugin-transform-object-rest-spread": "7.28.0", + "@babel/plugin-transform-optional-chaining": "7.27.1", "@babel/preset-env": "7.28.0", "@babel/preset-typescript": "7.27.1", "@babel/register": "7.27.1", @@ -84,25 +84,25 @@ "@rollup/plugin-replace": "6.0.2", "@rollup/plugin-terser": "0.4.4", "@rollup/plugin-typescript": "12.1.4", - "@svta/common-media-library": "0.15.1", + "@svta/common-media-library": "0.17.1", "@types/chai": "4.3.20", "@types/chart.js": "2.9.41", "@types/mocha": "10.0.10", "@types/sinon-chai": "3.2.12", - "@typescript-eslint/eslint-plugin": "8.35.1", - "@typescript-eslint/parser": "8.35.1", + "@typescript-eslint/eslint-plugin": "8.38.0", + "@typescript-eslint/parser": "8.38.0", "babel-loader": "10.0.0", "babel-plugin-transform-remove-console": "6.9.4", "chai": "4.5.0", "chart.js": "2.9.4", - "chromedriver": "138.0.0", + "chromedriver": "138.0.3", "doctoc": "2.2.1", "es-check": "9.1.4", "eslint": "8.57.1", - "eslint-config-prettier": "10.1.5", + "eslint-config-prettier": "10.1.8", "eslint-plugin-import": "2.32.0", "eslint-plugin-mocha": "10.5.0", - "eslint-plugin-n": "17.20.0", + "eslint-plugin-n": "17.21.0", "eslint-plugin-no-for-of-loops": "1.0.1", "eslint-plugin-promise": "7.2.1", "eventemitter3": "5.0.1", @@ -123,9 +123,9 @@ "mocha": "11.7.1", "node-fetch": "3.3.2", "npm-run-all2": "8.0.4", - "prettier": "3.5.3", + "prettier": "3.6.2", "promise-polyfill": "8.3.0", - "rollup": "4.44.1", + "rollup": "4.45.1", "rollup-plugin-istanbul": "5.0.0", "sauce-connect-launcher": "1.3.2", "selenium-webdriver": "4.34.0", @@ -134,6 +134,6 @@ "sinon-chai": "3.7.0", "typescript": "5.8.3", "url-toolkit": "2.2.5", - "wrangler": "4.22.0" + "wrangler": "4.26.1" } } diff --git a/renovate.json b/renovate.json index 21919c957eb..69f0454f5f3 100644 --- a/renovate.json +++ b/renovate.json @@ -1,6 +1,7 @@ { - "extends": ["config:base"], + "extends": ["config:recommended"], "labels": ["dependencies", "skip-change-log"], + "schedule": ["* 0 28 * *"], "prHourlyLimit": 0, "prConcurrentLimit": 0, "prCreation": "immediate", @@ -12,10 +13,15 @@ "major": { "addLabels": ["semver-major"] }, + "ignoreDeps": ["FileSaver.js", "@types/chart.js"], "packageRules": [ { - "matchPackagePatterns": ["*"], - "rangeStrategy": "bump" + "matchDatasources": ["html"], + "enabled": false + }, + { + "rangeStrategy": "bump", + "matchPackageNames": ["*"] }, { "matchDepTypes": ["devDependencies"], diff --git a/src/config.ts b/src/config.ts index 4627baa5797..7ba0901fb2f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -119,8 +119,8 @@ export type EMEControllerConfig = { ) => ArrayBuffer; emeEnabled: boolean; widevineLicenseUrl?: string; - drmSystems: DRMSystemsConfiguration; - drmSystemOptions: DRMSystemOptions; + drmSystems: DRMSystemsConfiguration | undefined; + drmSystemOptions: DRMSystemOptions | undefined; requestMediaKeySystemAccessFunc: MediaKeyFunc | null; requireKeySystemAccessOnStart: boolean; }; diff --git a/src/controller/abr-controller.ts b/src/controller/abr-controller.ts index ae1662f3c4d..afefdea4a44 100644 --- a/src/controller/abr-controller.ts +++ b/src/controller/abr-controller.ts @@ -845,43 +845,49 @@ class AbrController extends Logger implements AbrComponentAPI { mediaCapabilities, this.supportedCache, ); - levelInfo.supportedPromise.then((decodingInfo) => { - if (!this.hls) { - return; - } - levelInfo.supportedResult = decodingInfo; - const levels = this.hls.levels; - const index = levels.indexOf(levelInfo); - if (decodingInfo.error) { - this.warn( - `MediaCapabilities decodingInfo error: "${ - decodingInfo.error - }" for level ${index} ${stringify(decodingInfo)}`, - ); - } else if (!decodingInfo.supported) { - this.warn( - `Unsupported MediaCapabilities decodingInfo result for level ${index} ${stringify( - decodingInfo, - )}`, - ); - if (index > -1 && levels.length > 1) { - this.log(`Removing unsupported level ${index}`); - this.hls.removeLevel(index); - if (this.hls.loadLevel === -1) { - this.hls.nextLoadLevel = 0; + levelInfo.supportedPromise + .then((decodingInfo) => { + if (!this.hls) { + return; + } + levelInfo.supportedResult = decodingInfo; + const levels = this.hls.levels; + const index = levels.indexOf(levelInfo); + if (decodingInfo.error) { + this.warn( + `MediaCapabilities decodingInfo error: "${ + decodingInfo.error + }" for level ${index} ${stringify(decodingInfo)}`, + ); + } else if (!decodingInfo.supported) { + this.warn( + `Unsupported MediaCapabilities decodingInfo result for level ${index} ${stringify( + decodingInfo, + )}`, + ); + if (index > -1 && levels.length > 1) { + this.log(`Removing unsupported level ${index}`); + this.hls.removeLevel(index); + if (this.hls.loadLevel === -1) { + this.hls.nextLoadLevel = 0; + } } + } else if ( + decodingInfo.decodingInfoResults.some( + (info) => + info.smooth === false || info.powerEfficient === false, + ) + ) { + this.log( + `MediaCapabilities decodingInfo for level ${index} not smooth or powerEfficient: ${stringify(decodingInfo)}`, + ); } - } else if ( - decodingInfo.decodingInfoResults.some( - (info) => - info.smooth === false || info.powerEfficient === false, - ) - ) { - this.log( - `MediaCapabilities decodingInfo for level ${index} not smooth or powerEfficient: ${stringify(decodingInfo)}`, + }) + .catch((error) => { + this.warn( + `Error handling MediaCapabilities decodingInfo: ${error}`, ); - } - }); + }); } else { levelInfo.supportedResult = SUPPORTED_INFO_DEFAULT; } diff --git a/src/controller/audio-stream-controller.ts b/src/controller/audio-stream-controller.ts index f3517c041c9..d4cc58c5c70 100644 --- a/src/controller/audio-stream-controller.ts +++ b/src/controller/audio-stream-controller.ts @@ -142,16 +142,16 @@ class AudioStreamController // INIT_PTS_FOUND is triggered when the video track parsed in the stream-controller has a new PTS value onInitPtsFound( event: Events.INIT_PTS_FOUND, - { frag, id, initPTS, timescale }: InitPTSFoundData, + { frag, id, initPTS, timescale, trackId }: InitPTSFoundData, ) { // Always update the new INIT PTS // Can change due level switch if (id === PlaylistLevelType.MAIN) { const cc = frag.cc; const inFlightFrag = this.fragCurrent; - this.initPTS[cc] = { baseTime: initPTS, timescale }; + this.initPTS[cc] = { baseTime: initPTS, timescale, trackId }; this.log( - `InitPTS for cc: ${cc} found from main: ${initPTS}/${timescale}`, + `InitPTS for cc: ${cc} found from main: ${initPTS / timescale} (${initPTS}/${timescale}) trackId: ${trackId}`, ); this.mainAnchor = frag; // If we are waiting, tick immediately to unblock audio fragment transmuxing @@ -255,15 +255,7 @@ class AudioStreamController break; } case State.FRAG_LOADING_WAITING_RETRY: { - const now = performance.now(); - const retryDate = this.retryDate; - // if current time is gt than retryDate, or if media seeking let's switch to IDLE state to retry loading - if (!retryDate || now >= retryDate || this.media?.seeking) { - const { levels, trackId } = this; - this.log('RetryDate reached, switch back to IDLE state'); - this.resetStartWhenNotLoaded(levels?.[trackId] || null); - this.state = State.IDLE; - } + this.checkRetryDate(); break; } case State.WAITING_INIT_PTS: { @@ -884,6 +876,9 @@ class AudioStreamController if (initSegment?.tracks) { const mapFragment = frag.initSegment || frag; + if (this.unhandledEncryptionError(initSegment, frag)) { + return; + } this._bufferInitSegment( level, initSegment.tracks, diff --git a/src/controller/base-playlist-controller.ts b/src/controller/base-playlist-controller.ts index 6ff6a58cd63..e12ff9d9412 100644 --- a/src/controller/base-playlist-controller.ts +++ b/src/controller/base-playlist-controller.ts @@ -87,8 +87,8 @@ export default class BasePlaylistController } if (foundIndex !== -1) { const attr = renditionReports[foundIndex]; - const msn = parseInt(attr['LAST-MSN']) || previous?.lastPartSn; - let part = parseInt(attr['LAST-PART']) || previous?.lastPartIndex; + const msn = parseInt(attr['LAST-MSN']) || previous.lastPartSn; + let part = parseInt(attr['LAST-PART']) || previous.lastPartIndex; if (this.hls.config.lowLatencyMode) { const currentGoal = Math.min( previous.age - previous.partTarget, @@ -164,7 +164,7 @@ export default class BasePlaylistController const offset = Math.max(timelineOffset || 0, 0); details.appliedTimelineOffset = offset; details.fragments.forEach((frag) => { - frag.start = frag.playlistOffset + offset; + frag.setStart(frag.playlistOffset + offset); }); } @@ -174,7 +174,7 @@ export default class BasePlaylistController details.reloaded(previousDetails); // Merge live playlists to adjust fragment starts and fill in delta playlist skipped segments if (previousDetails && details.fragments.length > 0) { - mergeDetails(previousDetails, details); + mergeDetails(previousDetails, details, this); const error = details.playlistParsingError; if (error) { this.warn(error); diff --git a/src/controller/base-stream-controller.ts b/src/controller/base-stream-controller.ts index 67a43d88ece..a26c14380be 100644 --- a/src/controller/base-stream-controller.ts +++ b/src/controller/base-stream-controller.ts @@ -24,7 +24,11 @@ import { getAesModeFromFullSegmentMethod, isFullSegmentEncryption, } from '../utils/encryption-methods-util'; -import { getRetryDelay } from '../utils/error-helper'; +import { getRetryDelay, offlineHttpStatus } from '../utils/error-helper'; +import { + addEventListener, + removeEventListener, +} from '../utils/event-listener-helper'; import { findPart, getFragmentWithSN, @@ -57,9 +61,9 @@ import type { PartsLoadedData, } from '../types/events'; import type { Level } from '../types/level'; -import type { RemuxedTrack } from '../types/remuxer'; +import type { InitSegmentData, RemuxedTrack } from '../types/remuxer'; import type { Bufferable, BufferInfo } from '../utils/buffer-helper'; -import type { RationalTimestamp } from '../utils/timescale-conversion'; +import type { TimestampOffset } from '../utils/timescale-conversion'; type ResolveFragLoaded = (FragLoadedEndData) => void; type RejectFragLoaded = (LoadError) => void; @@ -111,7 +115,7 @@ export default class BaseStreamController protected levelLastLoaded: Level | null = null; protected startFragRequested: boolean = false; protected decrypter: Decrypter; - protected initPTS: RationalTimestamp[] = []; + protected initPTS: TimestampOffset[] = []; protected buffering: boolean = true; protected loadingParts: boolean = false; private loopSn?: string | number; @@ -264,7 +268,7 @@ export default class BaseStreamController public getLevelDetails(): LevelDetails | undefined { if (this.levels && this.levelLastLoaded !== null) { - return this.levelLastLoaded?.details; + return this.levelLastLoaded.details; } } @@ -284,10 +288,8 @@ export default class BaseStreamController data: MediaAttachedData, ) { const media = (this.media = this.mediaBuffer = data.media); - media.removeEventListener('seeking', this.onMediaSeeking); - media.removeEventListener('ended', this.onMediaEnded); - media.addEventListener('seeking', this.onMediaSeeking); - media.addEventListener('ended', this.onMediaEnded); + addEventListener(media, 'seeking', this.onMediaSeeking); + addEventListener(media, 'ended', this.onMediaEnded); const config = this.config; if (this.levels && config.autoStartLoad && this.state === State.STOPPED) { this.startLoad(config.startPosition); @@ -309,8 +311,8 @@ export default class BaseStreamController } // remove video listeners - media.removeEventListener('seeking', this.onMediaSeeking); - media.removeEventListener('ended', this.onMediaEnded); + removeEventListener(media, 'seeking', this.onMediaSeeking); + removeEventListener(media, 'ended', this.onMediaEnded); if (this.keyLoader && !transferringMedia) { this.keyLoader.detach(); @@ -424,8 +426,10 @@ export default class BaseStreamController } } - // Async tick to speed up processing - this.tickImmediate(); + if (noFowardBuffer && this.state === State.IDLE) { + // Async tick to speed up processing + this.tickImmediate(); + } }; protected onMediaEnded = () => { @@ -710,6 +714,39 @@ export default class BaseStreamController this.tick(); } + protected unhandledEncryptionError( + initSegment: InitSegmentData, + frag: Fragment, + ): boolean { + const tracks = initSegment.tracks; + if ( + tracks && + !frag.encrypted && + (tracks.audio?.encrypted || tracks.video?.encrypted) && + (!this.config.emeEnabled || !this.keyLoader.emeController) + ) { + const media = this.media; + const error = new Error( + `Encrypted track with no key in ${this.fragInfo(frag)} (media ${media ? 'attached mediaKeys: ' + media.mediaKeys : 'detached'})`, + ); + this.warn(error.message); + // Ignore if media is detached or mediaKeys are set + if (!media || media.mediaKeys) { + return false; + } + this.hls.trigger(Events.ERROR, { + type: ErrorTypes.KEY_SYSTEM_ERROR, + details: ErrorDetails.KEY_SYSTEM_NO_KEYS, + fatal: false, + error, + frag, + }); + this.resetTransmuxer(); + return true; + } + return false; + } + protected fragContextChanged(frag: Fragment | null) { const { fragCurrent } = this; return ( @@ -784,7 +821,7 @@ export default class BaseStreamController progressCallback?: FragmentLoadProgressCallback, ): Promise { this.fragCurrent = frag; - const details = level?.details; + const details = level.details; if (!this.levels || !details) { throw new Error( `frag load aborted, missing level${details ? '' : ' detail'}s`, @@ -808,10 +845,9 @@ export default class BaseStreamController } }); this.hls.trigger(Events.KEY_LOADING, { frag }); - if (this.fragCurrent === null) { - keyLoadingPromise = Promise.reject( - new Error(`frag load aborted, context changed in KEY_LOADING`), - ); + if ((this.fragCurrent as Fragment | null) === null) { + this.log(`context changed in KEY_LOADING`); + return Promise.resolve(null); } } else if (!frag.encrypted) { keyLoadingPromise = this.keyLoader.loadClear( @@ -843,7 +879,7 @@ export default class BaseStreamController if (this.loadingParts && isMediaFragment(frag)) { const partList = details.partList; if (partList && progressCallback) { - if (targetBufferTime > frag.end && details.fragmentHint) { + if (targetBufferTime > details.fragmentEnd && details.fragmentHint) { frag = details.fragmentHint; } const partIndex = this.getNextPart(partList, frag, targetBufferTime); @@ -912,7 +948,7 @@ export default class BaseStreamController this.log( `LL-Part loading OFF after next part miss @${targetBufferTime.toFixed( 2, - )}`, + )} Check buffer at sn: ${frag.sn} loaded parts: ${details.partList?.filter((p) => p.loaded).map((p) => `[${p.start}-${p.end}]`)}`, ); this.loadingParts = false; } else if (!frag.url) { @@ -922,7 +958,7 @@ export default class BaseStreamController this.log( `Loading ${frag.type} sn: ${frag.sn} of ${this.fragInfo(frag, false)}) cc: ${frag.cc} ${ - details ? '[' + details.startSN + '-' + details.endSN + ']' : '' + '[' + details.startSN + '-' + details.endSN + ']' }, target: ${parseFloat(targetBufferTime.toFixed(3))}`, ); // Don't update nextLoadPosition for fragments which are not buffered @@ -937,7 +973,7 @@ export default class BaseStreamController if (dataOnProgress && keyLoadingPromise) { result = keyLoadingPromise .then((keyLoadedData) => { - if (!keyLoadedData || this.fragContextChanged(keyLoadedData?.frag)) { + if (!keyLoadedData || this.fragContextChanged(keyLoadedData.frag)) { return null; } return this.fragmentLoader.load(frag, progressCallback); @@ -954,7 +990,7 @@ export default class BaseStreamController keyLoadingPromise, ]) .then(([fragLoadedData]) => { - if (!dataOnProgress && fragLoadedData && progressCallback) { + if (!dataOnProgress && progressCallback) { progressCallback(fragLoadedData); } return fragLoadedData; @@ -1007,11 +1043,16 @@ export default class BaseStreamController ); } - private handleFragLoadError(error: LoadError | Error) { + private handleFragLoadError( + error: LoadError | Error | (Error & { data: ErrorData }), + ) { if ('data' in error) { const data = error.data; - if (error.data && data.details === ErrorDetails.INTERNAL_ABORTED) { + if (data.frag && data.details === ErrorDetails.INTERNAL_ABORTED) { this.handleFragLoadAborted(data.frag, data.part); + } else if (data.frag && data.type === ErrorTypes.KEY_SYSTEM_ERROR) { + data.frag.abortRequests(); + this.resetFragmentLoading(data.frag); } else { this.hls.trigger(Events.ERROR, data as ErrorData); } @@ -1069,10 +1110,14 @@ export default class BaseStreamController if (!details) { return this.loadingParts; } - if (details?.partList) { + if (details.partList) { // Buffer must be ahead of first part + duration of parts after last segment // and playback must be at or past segment adjacent to part list const firstPart = details.partList[0]; + // Loading of VTT subtitle parts is not implemented in subtitle-stream-controller (#7460) + if (firstPart.fragment.type === PlaylistLevelType.SUBTITLE) { + return false; + } const safePartStart = firstPart.end + (details.fragmentHint?.duration || 0); if (bufferEnd >= safePartStart) { @@ -1123,21 +1168,23 @@ export default class BaseStreamController chunkMeta: ChunkMetadata, noBacktracking?: boolean, ) { - if (!data || this.state !== State.PARSING) { + if (this.state !== State.PARSING) { return; } const { data1, data2 } = data; let buffer = data1; - if (data1 && data2) { + if (data2) { // Combine the moof + mdat so that we buffer with a single append buffer = appendUint8Array(data1, data2); } - if (!buffer?.length) { + if (!buffer.length) { return; } - const offsetTimestamp = this.initPTS[frag.cc]; + const offsetTimestamp = this.initPTS[frag.cc] as + | TimestampOffset + | undefined; const offset = offsetTimestamp ? -offsetTimestamp.baseTime / offsetTimestamp.timescale : undefined; @@ -1264,10 +1311,9 @@ export default class BaseStreamController position: number, playlistType: PlaylistLevelType = PlaylistLevelType.MAIN, ): Fragment | null { - const fragOrPart = this.fragmentTracker?.getAppendedFrag( - position, - playlistType, - ); + const fragOrPart = (this.fragmentTracker as any) + ? this.fragmentTracker.getAppendedFrag(position, playlistType) + : null; if (fragOrPart && 'fragment' in fragOrPart) { return fragOrPart.fragment; } @@ -1397,7 +1443,7 @@ export default class BaseStreamController } protected get primaryPrefetch(): boolean { - if (interstitialsEnabled(this.hls.config)) { + if (interstitialsEnabled(this.config)) { const playingInterstitial = this.hls.interstitialsManager?.playingItem?.event; if (playingInterstitial) { @@ -1415,7 +1461,7 @@ export default class BaseStreamController return frag; } if ( - interstitialsEnabled(this.hls.config) && + interstitialsEnabled(this.config) && frag.type !== PlaylistLevelType.SUBTITLE ) { // Do not load fragments outside the buffering schedule segment @@ -1471,7 +1517,7 @@ export default class BaseStreamController mapToInitFragWhenRequired(frag: Fragment | null): typeof frag { // If an initSegment is present, it must be buffered first - if (frag?.initSegment && !frag?.initSegment.data && !this.bitrateTest) { + if (frag?.initSegment && !frag.initSegment.data && !this.bitrateTest) { return frag.initSegment; } @@ -1496,9 +1542,14 @@ export default class BaseStreamController if (loaded) { nextPart = -1; } else if ( - (contiguous || part.independent || independentAttrOmitted) && - part.fragment === frag + contiguous || + ((part.independent || independentAttrOmitted) && part.fragment === frag) ) { + if (part.fragment !== frag) { + this.warn( + `Need buffer at ${targetBufferTime} but next unloaded part starts at ${part.start}`, + ); + } nextPart = i; } contiguous = loaded; @@ -1510,8 +1561,17 @@ export default class BaseStreamController partList: Part[], targetBufferTime: number, ): boolean { - const lastPart = partList[partList.length - 1]; - return lastPart && targetBufferTime > lastPart.start && lastPart.loaded; + let part: Part; + for (let i = partList.length; i--; ) { + part = partList[i]; + if (!part.loaded) { + return false; + } + if (targetBufferTime > part.start) { + return true; + } + } + return false; } /* @@ -1607,7 +1667,6 @@ export default class BaseStreamController ); if ( loadingParts && - fragmentHint && !this.bitrateTest && partList[partList.length - 1].fragment.sn === fragmentHint.sn ) { @@ -1655,11 +1714,11 @@ export default class BaseStreamController frag.sn === fragPrevious.sn && (!loadingParts || partList[0].fragment.sn > frag.sn || - (!levelDetails.live && !loadingParts)) + !levelDetails.live) ) { // Force the next fragment to load if the previous one was already selected. This can occasionally happen with // non-uniform fragment durations - const sameLevel = fragPrevious && frag.level === fragPrevious.level; + const sameLevel = frag.level === fragPrevious.level; if (sameLevel) { const nextFrag = fragments[curSNIdx + 1]; if ( @@ -1775,7 +1834,7 @@ export default class BaseStreamController return pos; } - private handleFragLoadAborted(frag: Fragment, part: Part | undefined) { + private handleFragLoadAborted(frag: Fragment, part: Part | null | undefined) { if ( this.transmuxer && frag.type === this.playlistType && @@ -1828,20 +1887,41 @@ export default class BaseStreamController } // keep retrying until the limit will be reached const errorAction = data.errorAction; - const { action, flags, retryCount = 0, retryConfig } = errorAction || {}; - const couldRetry = !!errorAction && !!retryConfig; + if (!errorAction) { + this.state = State.ERROR; + return; + } + const { action, flags, retryCount = 0, retryConfig } = errorAction; + const couldRetry = !!retryConfig; const retry = couldRetry && action === NetworkErrorAction.RetryRequest; const noAlternate = couldRetry && !errorAction.resolved && flags === ErrorActionFlags.MoveAllAlternatesMatchingHost; - if (!retry && noAlternate && isMediaFragment(frag) && !frag.endList) { + const live = this.hls.latestLevelDetails?.live; + if ( + !retry && + noAlternate && + isMediaFragment(frag) && + !frag.endList && + live + ) { this.resetFragmentErrors(filterType); this.treatAsGap(frag); errorAction.resolved = true; } else if ((retry || noAlternate) && retryCount < retryConfig.maxNumRetry) { - this.resetStartWhenNotLoaded(this.levelLastLoaded); + const offlineStatus = offlineHttpStatus(data.response?.code); const delay = getRetryDelay(retryConfig, retryCount); + this.resetStartWhenNotLoaded(); + this.retryDate = self.performance.now() + delay; + this.state = State.FRAG_LOADING_WAITING_RETRY; + errorAction.resolved = true; + if (offlineStatus) { + this.log(`Waiting for connection (offline)`); + this.retryDate = Infinity; + data.reason = 'offline'; + return; + } this.warn( `Fragment ${frag.sn} of ${filterType} ${frag.level} errored with ${ data.details @@ -1849,10 +1929,7 @@ export default class BaseStreamController retryConfig.maxNumRetry } in ${delay}ms`, ); - errorAction.resolved = true; - this.retryDate = self.performance.now() + delay; - this.state = State.FRAG_LOADING_WAITING_RETRY; - } else if (retryConfig && errorAction) { + } else if (retryConfig) { this.resetFragmentErrors(filterType); if (retryCount < retryConfig.maxNumRetry) { // Network retry is skipped when level switch is preferred @@ -1877,6 +1954,24 @@ export default class BaseStreamController this.tickImmediate(); } + protected checkRetryDate() { + const now = self.performance.now(); + const retryDate = this.retryDate; + // if current time is gt than retryDate, or if media seeking let's switch to IDLE state to retry loading + const waitingForConnection = retryDate === Infinity; + if ( + !retryDate || + now >= retryDate || + (waitingForConnection && !offlineHttpStatus(0)) + ) { + if (waitingForConnection) { + this.log(`Connection restored (online)`); + } + this.resetStartWhenNotLoaded(); + this.state = State.IDLE; + } + } + protected reduceLengthAndFlushBuffer(data: ErrorData): boolean { // if in appending state if (this.state === State.PARSING || this.state === State.PARSED) { @@ -1898,7 +1993,7 @@ export default class BaseStreamController // this happens on IE/Edge, refer to https://github.com/video-dev/hls.js/pull/708 // in that case flush the whole audio buffer to recover this.warn( - `Buffer full error while media.currentTime is not buffered, flush ${playlistType} buffer`, + `Buffer full error while media.currentTime (${this.getLoadPosition()}) is not buffered, flush ${playlistType} buffer`, ); } if (frag) { @@ -1956,11 +2051,12 @@ export default class BaseStreamController } } - protected resetStartWhenNotLoaded(level: Level | null): void { + private resetStartWhenNotLoaded() { // if loadedmetadata is not set, it means that first frag request failed // in that case, reset startFragRequested flag if (!this.hls.hasEnoughToStart) { this.startFragRequested = false; + const level = this.levelLastLoaded; const details = level ? level.details : null; if (details?.live) { // Update the start position and return to IDLE to recover live start @@ -1975,11 +2071,11 @@ export default class BaseStreamController } protected resetWhenMissingContext(chunkMeta: ChunkMetadata | Fragment) { - this.warn( - `The loading context changed while buffering fragment ${chunkMeta.sn} of ${this.playlistLabel()} ${chunkMeta.level}. This chunk will not be buffered.`, + this.log( + `Loading context changed while buffering sn ${chunkMeta.sn} of ${this.playlistLabel()} ${chunkMeta.level === -1 ? '' : chunkMeta.level}. This chunk will not be buffered.`, ); this.removeUnbufferedFrags(); - this.resetStartWhenNotLoaded(this.levelLastLoaded); + this.resetStartWhenNotLoaded(); this.resetLoadingState(); } @@ -2027,6 +2123,7 @@ export default class BaseStreamController info.endPTS, info.startDTS, info.endDTS, + this, ); this.hls.trigger(Events.LEVEL_PTS_UPDATED, { details, @@ -2113,7 +2210,7 @@ export default class BaseStreamController this.transmuxer.destroy(); this.transmuxer = null; } - this.resetStartWhenNotLoaded(this.levelLastLoaded); + this.resetStartWhenNotLoaded(); this.resetLoadingState(); } } diff --git a/src/controller/buffer-controller.ts b/src/controller/buffer-controller.ts index 9011a9032f4..b13ec5edca5 100755 --- a/src/controller/buffer-controller.ts +++ b/src/controller/buffer-controller.ts @@ -278,9 +278,9 @@ export default class BufferController extends Logger implements ComponentAPI { data: MediaAttachingData, ) { const media = (this.media = data.media); - const MediaSource = getMediaSource(this.appendSource); this.transferData = this.overrides = undefined; - if (media && MediaSource) { + const MediaSource = getMediaSource(this.appendSource); + if (MediaSource) { const transferringMedia = !!data.mediaSource; if (transferringMedia || data.overrides) { this.transferData = data; @@ -318,7 +318,7 @@ export default class BufferController extends Logger implements ComponentAPI { private assignMediaSource(ms: MediaSource) { this.log( - `${this.transferData?.mediaSource === ms ? 'transferred' : 'created'} media source: ${ms.constructor?.name}`, + `${this.transferData?.mediaSource === ms ? 'transferred' : 'created'} media source: ${(ms.constructor as any)?.name}`, ); // MediaSource listeners are arrow functions with a lexical scope, and do not need to be bound ms.addEventListener('sourceopen', this._onMediaSourceOpen); @@ -343,9 +343,12 @@ export default class BufferController extends Logger implements ComponentAPI { : null; const trackCount = trackNames ? trackNames.length : 0; const mediaSourceOpenCallback = () => { - if (this.media && this.mediaSourceOpenOrEnded) { - this._onMediaSourceOpen(); - } + // eslint-disable-next-line @typescript-eslint/no-floating-promises + Promise.resolve().then(() => { + if (this.media && this.mediaSourceOpenOrEnded) { + this._onMediaSourceOpen(); + } + }); }; if (transferredTracks && trackNames && trackCount) { if (!this.tracksReady) { @@ -415,6 +418,7 @@ transfer tracks: ${stringify(transferredTracks, (key, value) => (key === 'initSe >; this.sourceBuffers[sbIndex] = sbTuple as any; if (sb.updating && this.operationQueue) { + // eslint-disable-next-line @typescript-eslint/no-floating-promises this.operationQueue.prependBlocker(type); } this.trackSourceBuffer(type, track); @@ -435,7 +439,7 @@ transfer tracks: ${stringify(transferredTracks, (key, value) => (key === 'initSe } private _onEndStreaming = (event) => { - if (!this.hls) { + if (!this.hls as any) { return; } if (this.mediaSource?.readyState !== 'open') { @@ -445,7 +449,7 @@ transfer tracks: ${stringify(transferredTracks, (key, value) => (key === 'initSe }; private _onStartStreaming = (event) => { - if (!this.hls) { + if (!this.hls as any) { return; } this.hls.resumeBuffering(); @@ -790,7 +794,12 @@ transfer tracks: ${stringify(transferredTracks, (key, value) => (key === 'initSe if (videoSb && sn !== 'initSegment') { const partOrFrag = part || (frag as MediaFragment); const blockedAudioAppend = this.blockedAudioAppend; - if (type === 'audio' && parent !== 'main' && !this.blockedAudioAppend) { + if ( + type === 'audio' && + parent !== 'main' && + !this.blockedAudioAppend && + !(videoTrack.ending || videoTrack.ended) + ) { const pStart = partOrFrag.start; const pTime = pStart + partOrFrag.duration * 0.05; const vbuffered = videoSb.buffered; @@ -1025,10 +1034,15 @@ transfer tracks: ${stringify(transferredTracks, (key, value) => (key === 'initSe public get bufferedToEnd(): boolean { return ( this.sourceBufferCount > 0 && - !this.sourceBuffers.some( - ([type]) => - type && (!this.tracks[type]?.ended || this.tracks[type]?.ending), - ) + !this.sourceBuffers.some(([type]) => { + if (type) { + const track = this.tracks[type]; + if (track) { + return !track.ended || track.ending; + } + } + return false; + }) ); } @@ -1077,6 +1091,9 @@ transfer tracks: ${stringify(transferredTracks, (key, value) => (key === 'initSe this.tracksEnded(); this.hls.trigger(Events.BUFFERED_TO_END, undefined); } + } else if (data.type === 'video') { + // Make sure pending audio appends are unblocked when video reaches end + this.unblockAudio(); } } @@ -1162,13 +1179,14 @@ transfer tracks: ${stringify(transferredTracks, (key, value) => (key === 'initSe ); } + const frontBufferFlushThreshold = config.frontBufferFlushThreshold; if ( - Number.isFinite(config.frontBufferFlushThreshold) && - config.frontBufferFlushThreshold > 0 + Number.isFinite(frontBufferFlushThreshold) && + frontBufferFlushThreshold > 0 ) { const frontBufferLength = Math.max( config.maxBufferLength, - config.frontBufferFlushThreshold, + frontBufferFlushThreshold, ); const maxFrontBufferLength = Math.max(frontBufferLength, targetDuration); @@ -1273,7 +1291,7 @@ transfer tracks: ${stringify(transferredTracks, (key, value) => (key === 'initSe const playlistEnd = details.edge; if (details.live && this.hls.config.liveDurationInfinity) { const len = details.fragments.length; - if (len && details.live && !!mediaSource.setLiveSeekableRange) { + if (len && !!(mediaSource as any).setLiveSeekableRange) { const start = Math.max(0, details.fragmentStart); const end = Math.max(start, playlistEnd); @@ -1547,7 +1565,7 @@ transfer tracks: ${stringify(transferredTracks, (key, value) => (key === 'initSe }; private get mediaSrc(): string | undefined { - const media = this.media?.querySelector?.('source') || this.media; + const media = (this.media?.querySelector as any)?.('source') || this.media; return media?.src; } @@ -1647,7 +1665,10 @@ transfer tracks: ${stringify(transferredTracks, (key, value) => (key === 'initSe } // This method must result in an updateend event; if append is not called, onSBUpdateEnd must be called manually - private appendExecutor(data: Uint8Array, type: SourceBufferName) { + private appendExecutor( + data: Uint8Array, + type: SourceBufferName, + ) { const track = this.tracks[type]; const sb = track?.buffer; if (!sb) { diff --git a/src/controller/eme-controller.ts b/src/controller/eme-controller.ts index b4ad16794c9..b7e8488f787 100644 --- a/src/controller/eme-controller.ts +++ b/src/controller/eme-controller.ts @@ -11,7 +11,7 @@ import { addEventListener, removeEventListener, } from '../utils/event-listener-helper'; -import Hex from '../utils/hex'; +import { arrayToHex } from '../utils/hex'; import { Logger } from '../utils/logger'; import { getKeySystemsForConfig, @@ -22,6 +22,8 @@ import { KeySystems, requestMediaKeySystemAccess, } from '../utils/mediakeys-helper'; +import { bin2str, parseSinf } from '../utils/mp4-tools'; +import { base64Decode } from '../utils/numeric-encoding-utils'; import { stringify } from '../utils/safe-json-stringify'; import { strToUtf8array } from '../utils/utf8-utils'; import type { EMEControllerConfig, HlsConfig, LoadPolicy } from '../config'; @@ -42,6 +44,7 @@ import type { LoaderContext, } from '../types/loader'; import type { KeySystemFormats } from '../utils/mediakeys-helper'; + interface KeySystemAccessPromises { keySystemAccess: Promise; mediaKeys?: Promise; @@ -52,9 +55,9 @@ interface KeySystemAccessPromises { export interface MediaKeySessionContext { keySystem: KeySystems; mediaKeys: MediaKeys; - decryptdata: LevelKey; + decryptdata: LevelKey; // FIXME: LevelKey has a URI which should be bound to the session, but is dependent one KeyId specifically. Session context should be allowed to adopt multiple level keys. mediaKeysSession: MediaKeySession; - keyStatus: MediaKeyStatus; + keyStatus: MediaKeyStatus; // FIXME: MediaKeySession can manage multiple keys with each with its own status licenseXhr?: XMLHttpRequest; _onmessage?: (this: MediaKeySession, ev: MediaKeyMessageEvent) => any; _onkeystatuseschange?: (this: MediaKeySession, ev: Event) => any; @@ -79,17 +82,18 @@ class EMEController extends Logger implements ComponentAPI { private media: HTMLMediaElement | null = null; private keyFormatPromise: Promise | null = null; private keySystemAccessPromises: { - [keysystem: string]: KeySystemAccessPromises; + [keysystem: string]: KeySystemAccessPromises | undefined; } = {}; private _requestLicenseFailureCount: number = 0; private mediaKeySessions: MediaKeySessionContext[] = []; private keyIdToKeySessionPromise: { - [keyId: string]: Promise; + [keyId: string]: Promise | undefined; } = {}; private mediaKeys: MediaKeys | null = null; private setMediaKeysQueue: Promise[] = EMEController.CDMCleanupPromise ? [EMEController.CDMCleanupPromise] : []; + private bannedKeyIds: { [keyId: string]: MediaKeyStatus | undefined } = {}; constructor(hls: Hls) { super('eme', hls.logger); @@ -109,7 +113,7 @@ class EMEController extends Logger implements ComponentAPI { // @ts-ignore this.hls = this.config = this.keyIdToKeySessionPromise = null; // @ts-ignore - this.onWaitingForKey = null; + this.onMediaEncrypted = this.onWaitingForKey = null; } private registerListeners() { @@ -130,7 +134,7 @@ class EMEController extends Logger implements ComponentAPI { private getLicenseServerUrl(keySystem: KeySystems): string | undefined { const { drmSystems, widevineLicenseUrl } = this.config; - const keySystemConfiguration = drmSystems[keySystem]; + const keySystemConfiguration = drmSystems?.[keySystem]; if (keySystemConfiguration) { return keySystemConfiguration.licenseUrl; @@ -154,7 +158,7 @@ class EMEController extends Logger implements ComponentAPI { private getServerCertificateUrl(keySystem: KeySystems): string | void { const { drmSystems } = this.config; - const keySystemConfiguration = drmSystems[keySystem]; + const keySystemConfiguration = drmSystems?.[keySystem]; if (keySystemConfiguration) { return keySystemConfiguration.serverCertificateUrl; @@ -245,10 +249,9 @@ class EMEController extends Logger implements ComponentAPI { keySystem, audioCodecs, videoCodecs, - this.config.drmSystemOptions, + this.config.drmSystemOptions || {}, ); - const keySystemAccessPromises: KeySystemAccessPromises = - this.keySystemAccessPromises[keySystem]; + let keySystemAccessPromises = this.keySystemAccessPromises[keySystem]; let keySystemAccess = keySystemAccessPromises?.keySystemAccess; if (!keySystemAccess) { this.log( @@ -260,10 +263,11 @@ class EMEController extends Logger implements ComponentAPI { keySystem, mediaKeySystemConfigs, ); - const keySystemAccessPromises: KeySystemAccessPromises = - (this.keySystemAccessPromises[keySystem] = { - keySystemAccess, - }); + const keySystemAccessPromisesNew = (keySystemAccessPromises = + this.keySystemAccessPromises[keySystem] = + { + keySystemAccess, + }) as KeySystemAccessPromises; keySystemAccess.catch((error) => { this.log( `Failed to obtain access to key-system "${keySystem}": ${error}`, @@ -277,11 +281,10 @@ class EMEController extends Logger implements ComponentAPI { const certificateRequest = this.fetchServerCertificate(keySystem); this.log(`Create media-keys for "${keySystem}"`); - keySystemAccessPromises.mediaKeys = mediaKeySystemAccess - .createMediaKeys() - .then((mediaKeys) => { + const mediaKeys = (keySystemAccessPromisesNew.mediaKeys = + mediaKeySystemAccess.createMediaKeys().then((mediaKeys) => { this.log(`Media-keys created for "${keySystem}"`); - keySystemAccessPromises.hasMediaKeys = true; + keySystemAccessPromisesNew.hasMediaKeys = true; return certificateRequest.then((certificate) => { if (certificate) { return this.setMediaKeysServerCertificate( @@ -292,18 +295,18 @@ class EMEController extends Logger implements ComponentAPI { } return mediaKeys; }); - }); + })); - keySystemAccessPromises.mediaKeys.catch((error) => { + mediaKeys.catch((error) => { this.error( `Failed to create media-keys for "${keySystem}"}: ${error}`, ); }); - return keySystemAccessPromises.mediaKeys; + return mediaKeys; }); } - return keySystemAccess.then(() => keySystemAccessPromises.mediaKeys!); + return keySystemAccess.then(() => keySystemAccessPromises!.mediaKeys!); } private createMediaKeySessionContext({ @@ -316,8 +319,8 @@ class EMEController extends Logger implements ComponentAPI { mediaKeys: MediaKeys; }): MediaKeySessionContext { this.log( - `Creating key-system session "${keySystem}" keyId: ${Hex.hexDump( - decryptdata.keyId! || [], + `Creating key-system session "${keySystem}" keyId: ${arrayToHex( + decryptdata.keyId || ([] as number[]), )}`, ); @@ -354,6 +357,7 @@ class EMEController extends Logger implements ComponentAPI { } else { this.warn(`Could not renew expired session. Missing pssh initData.`); } + // eslint-disable-next-line @typescript-eslint/no-floating-promises this.removeSession(mediaKeySessionContext); } @@ -364,7 +368,7 @@ class EMEController extends Logger implements ComponentAPI { if (decryptdata.keyId === null) { throw new Error('keyId is null'); } - return Hex.hexDump(decryptdata.keyId); + return arrayToHex(decryptdata.keyId); } private updateKeySession( @@ -373,10 +377,10 @@ class EMEController extends Logger implements ComponentAPI { ): Promise { const keySession = mediaKeySessionContext.mediaKeysSession; this.log( - `Updating key-session "${keySession.sessionId}" for keyID ${Hex.hexDump( - mediaKeySessionContext.decryptdata?.keyId! || [], + `Updating key-session "${keySession.sessionId}" for keyId ${arrayToHex( + mediaKeySessionContext.decryptdata.keyId || [], )} - } (data length: ${data ? data.byteLength : data})`, + } (data length: ${data.byteLength})`, ); return keySession.update(data); } @@ -385,7 +389,7 @@ class EMEController extends Logger implements ComponentAPI { return (Object.keys(this.keySystemAccessPromises) as KeySystems[]) .map((keySystem) => ({ keySystem, - hasMediaKeys: this.keySystemAccessPromises[keySystem].hasMediaKeys, + hasMediaKeys: this.keySystemAccessPromises[keySystem]!.hasMediaKeys, })) .filter(({ hasMediaKeys }) => !!hasMediaKeys) .map(({ keySystem }) => keySystemDomainToKeySystemFormat(keySystem)) @@ -404,7 +408,7 @@ class EMEController extends Logger implements ComponentAPI { keySystemsToAttempt: KeySystems[], ): Promise { return new Promise((resolve, reject) => { - return this.getKeySystemSelectionPromise(keySystemsToAttempt) + this.getKeySystemSelectionPromise(keySystemsToAttempt) .then(({ keySystem }) => { const keySystemFormat = keySystemDomainToKeySystemFormat(keySystem); if (keySystemFormat) { @@ -449,14 +453,22 @@ class EMEController extends Logger implements ComponentAPI { const decryptdata = data.keyInfo.decryptdata; const keyId = this.getKeyIdString(decryptdata); + const badStatus = this.bannedKeyIds[keyId]; + if (badStatus) { + const error = getKeyStatusError(badStatus, decryptdata); + this.handleError(error, data.frag); + return Promise.reject(error); + } const keyDetails = `(keyId: ${keyId} format: "${decryptdata.keyFormat}" method: ${decryptdata.method} uri: ${decryptdata.uri})`; this.log(`Starting session for key ${keyDetails}`); - let keyContextPromise = this.keyIdToKeySessionPromise[keyId]; + const keyContextPromise = this.keyIdToKeySessionPromise[keyId]; if (!keyContextPromise) { - keyContextPromise = this.getKeySystemForKeyPromise(decryptdata).then( - ({ keySystem, mediaKeys }) => { + const keySessionContextPromise = this.getKeySystemForKeyPromise( + decryptdata, + ) + .then(({ keySystem, mediaKeys }) => { this.throwIfDestroyed(); this.log( `Handle encrypted media sn: ${data.frag.sn} ${data.frag.type}: ${data.frag.level} using key ${keyDetails}`, @@ -470,11 +482,8 @@ class EMEController extends Logger implements ComponentAPI { decryptdata, }); }); - }, - ); - - const keySessionContextPromise = (this.keyIdToKeySessionPromise[keyId] = - keyContextPromise.then((keySessionContext) => { + }) + .then((keySessionContext) => { const scheme = 'cenc'; const initData = decryptdata.pssh ? decryptdata.pssh.buffer : null; return this.generateRequestWithPreferredKeySession( @@ -483,26 +492,34 @@ class EMEController extends Logger implements ComponentAPI { initData, 'playlist-key', ); - })); + }); - keySessionContextPromise.catch((error) => this.handleError(error)); + keySessionContextPromise.catch((error) => + this.handleError(error, data.frag), + ); + this.keyIdToKeySessionPromise[keyId] = keySessionContextPromise; + + return keySessionContextPromise; } return keyContextPromise; } private throwIfDestroyed(message = 'Invalid state'): void | never { - if (!this.hls) { + if (!this.hls as any) { throw new Error('invalid state'); } } - private handleError(error: EMEKeyError | Error) { - if (!this.hls) { + private handleError(error: EMEKeyError | Error, frag?: Fragment) { + if (!this.hls as any) { return; } this.error(error.message); if (error instanceof EMEKeyError) { + if (frag) { + error.data.frag = frag; + } this.hls.trigger(Events.ERROR, error.data); } else { this.hls.trigger(Events.ERROR, { @@ -552,6 +569,107 @@ class EMEController extends Logger implements ComponentAPI { return this.attemptKeySystemAccess(keySystemsToAttempt); } + private onMediaEncrypted = (event: MediaEncryptedEvent) => { + const { initDataType, initData } = event; + const logMessage = `"${event.type}" event: init data type: "${initDataType}"`; + this.debug(logMessage); + + // Ignore event when initData is null + if (initData === null) { + return; + } + + if (!this.keyFormatPromise) { + let keySystems = Object.keys( + this.keySystemAccessPromises, + ) as KeySystems[]; + if (!keySystems.length) { + keySystems = getKeySystemsForConfig(this.config); + } + const keyFormats = keySystems + .map(keySystemDomainToKeySystemFormat) + .filter((k) => !!k) as KeySystemFormats[]; + this.keyFormatPromise = this.getKeyFormatPromise(keyFormats); + } + + this.keyFormatPromise + .then((keySystemFormat) => { + const keySystem = keySystemFormatToKeySystemDomain(keySystemFormat); + if (initDataType !== 'sinf' || keySystem !== KeySystems.FAIRPLAY) { + this.log( + `Ignoring "${event.type}" event with init data type: "${initDataType}" for selected key-system ${keySystem}`, + ); + return; + } + + // Match sinf keyId to playlist skd://keyId= + let keyId: Uint8Array | undefined; + try { + const json = bin2str(new Uint8Array(initData)); + const sinf = base64Decode(JSON.parse(json).sinf); + const tenc = parseSinf(sinf); + if (!tenc) { + throw new Error( + `'schm' box missing or not cbcs/cenc with schi > tenc`, + ); + } + keyId = new Uint8Array(tenc.subarray(8, 24)); + } catch (error) { + this.warn(`${logMessage} Failed to parse sinf: ${error}`); + return; + } + + const keyIdHex = arrayToHex(keyId); + const { keyIdToKeySessionPromise, mediaKeySessions } = this; + let keySessionContextPromise = keyIdToKeySessionPromise[keyIdHex]; + + for (let i = 0; i < mediaKeySessions.length; i++) { + // Match playlist key + const keyContext = mediaKeySessions[i]; + const decryptdata = keyContext.decryptdata; + if (!decryptdata.keyId) { + continue; + } + const oldKeyIdHex = arrayToHex(decryptdata.keyId); + if ( + keyIdHex === oldKeyIdHex || + decryptdata.uri.replace(/-/g, '').indexOf(keyIdHex) !== -1 + ) { + keySessionContextPromise = keyIdToKeySessionPromise[oldKeyIdHex]; + if (!keySessionContextPromise) { + continue; + } + if (decryptdata.pssh) { + break; + } + delete keyIdToKeySessionPromise[oldKeyIdHex]; + decryptdata.pssh = new Uint8Array(initData); + decryptdata.keyId = keyId; + keySessionContextPromise = keyIdToKeySessionPromise[keyIdHex] = + keySessionContextPromise.then(() => { + return this.generateRequestWithPreferredKeySession( + keyContext, + initDataType, + initData, + 'encrypted-event-key-match', + ); + }); + keySessionContextPromise.catch((error) => this.handleError(error)); + break; + } + } + + if (!keySessionContextPromise) { + this.handleError( + new Error( + `Key ID ${keyIdHex} not encountered in playlist. Key-system sessions ${mediaKeySessions.length}.`, + ), + ); + } + }) + .catch((error) => this.handleError(error)); + }; + private onWaitingForKey = (event: Event) => { this.log(`"${event.type}" event`); }; @@ -614,7 +732,7 @@ class EMEController extends Logger implements ComponentAPI { context.decryptdata.pssh = initData ? new Uint8Array(initData) : null; } catch (error) { this.warn(error.message); - if (this.hls?.config.debug) { + if ((this.hls as any) && this.hls.config.debug) { throw error; } } @@ -628,7 +746,7 @@ class EMEController extends Logger implements ComponentAPI { const keyId = this.getKeyIdString(context.decryptdata); this.log( `Generating key-session request for "${reason}": ${keyId} (init data type: ${initDataType} length: ${ - initData ? initData.byteLength : null + initData.byteLength })`, ); @@ -636,7 +754,7 @@ class EMEController extends Logger implements ComponentAPI { const onmessage = (context._onmessage = (event: MediaKeyMessageEvent) => { const keySession = context.mediaKeysSession; - if (!keySession) { + if (!keySession as any) { licenseStatus.emit('error', new Error('invalid state')); return; } @@ -657,7 +775,9 @@ class EMEController extends Logger implements ComponentAPI { }); } else if (messageType === 'license-release') { if (context.keySystem === KeySystems.FAIRPLAY) { + // eslint-disable-next-line @typescript-eslint/no-floating-promises this.updateKeySession(context, strToUtf8array('acknowledged')); + // eslint-disable-next-line @typescript-eslint/no-floating-promises this.removeSession(context); } } else { @@ -669,22 +789,20 @@ class EMEController extends Logger implements ComponentAPI { event: Event, ) => { const keySession = context.mediaKeysSession; - if (!keySession) { + if (!keySession as any) { licenseStatus.emit('error', new Error('invalid state')); return; } + const initialStatus = context.keyStatus; this.onKeyStatusChange(context); - const keyStatus = context.keyStatus; - licenseStatus.emit('keyStatus', keyStatus); - if (keyStatus === 'expired') { - this.warn(`${context.keySystem} expired for key ${keyId}`); - this.renewKeySession(context); + const status = context.keyStatus; + if (status !== initialStatus) { + licenseStatus.emit('keyStatus', status, context); + if (status === 'expired') { + this.log(`${context.keySystem} expired for key ${keyId}`); + this.renewKeySession(context); + } } - - this.hls.trigger(Events.KEY_STATUSES_CHANGED, { - keySystem: context.decryptdata.keyFormat, - keyStatuses: keySession.keyStatuses, - }); }); addEventListener(context.mediaKeysSession, 'message', onmessage); @@ -698,30 +816,32 @@ class EMEController extends Logger implements ComponentAPI { (resolve: (value?: void) => void, reject) => { licenseStatus.on('error', reject); - licenseStatus.on('keyStatus', (keyStatus) => { - if (keyStatus.startsWith('usable')) { - resolve(); - } else if (keyStatus === 'output-restricted') { - reject( - new EMEKeyError( - { - type: ErrorTypes.KEY_SYSTEM_ERROR, - details: ErrorDetails.KEY_SYSTEM_STATUS_OUTPUT_RESTRICTED, - fatal: false, - }, - 'HDCP level output restricted', - ), - ); - } else if (keyStatus === 'internal-error') { - // resolve for Hardware DRM - this.warn('keyStatus: internal-error'); - resolve(); - } else if (keyStatus === 'expired') { - reject(new Error('key expired while generating request')); - } else { - this.warn(`unhandled key status change "${keyStatus}"`); - } - }); + licenseStatus.on( + 'keyStatus', + ( + keyStatus: MediaKeyStatus, + { decryptdata }: MediaKeySessionContext, + ) => { + if (keyStatus.startsWith('usable')) { + resolve(); + } else if ( + keyStatus === 'internal-error' || + keyStatus === 'output-restricted' + ) { + reject(getKeyStatusError(keyStatus, decryptdata)); + } else if (keyStatus === 'expired') { + reject( + new Error( + `key expired while generating request (keyId: ${keyId})`, + ), + ); + } else { + this.warn( + `unhandled key status change "${keyStatus}" (keyId: ${keyId})`, + ); + } + }, + ); }, ); @@ -729,7 +849,7 @@ class EMEController extends Logger implements ComponentAPI { .generateRequest(initDataType, initData) .then(() => { this.log( - `Request generated for key-session "${context.mediaKeysSession?.sessionId}" keyId: ${keyId}`, + `Request generated for key-session "${context.mediaKeysSession.sessionId}" keyId: ${keyId}`, ); }) .catch((error) => { @@ -738,6 +858,7 @@ class EMEController extends Logger implements ComponentAPI { type: ErrorTypes.KEY_SYSTEM_ERROR, details: ErrorDetails.KEY_SYSTEM_NO_SESSION, error, + decryptdata: context.decryptdata, fatal: false, }, `Error generating key-session request: ${error}`, @@ -746,6 +867,7 @@ class EMEController extends Logger implements ComponentAPI { .then(() => keyUsablePromise) .catch((error) => { licenseStatus.removeAllListeners(); + // eslint-disable-next-line @typescript-eslint/no-floating-promises this.removeSession(context); throw error; }) @@ -756,6 +878,9 @@ class EMEController extends Logger implements ComponentAPI { } private onKeyStatusChange(mediaKeySessionContext: MediaKeySessionContext) { + const sessionLevelKeyId = arrayToHex( + new Uint8Array(mediaKeySessionContext.decryptdata.keyId || []), + ); mediaKeySessionContext.mediaKeysSession.keyStatuses.forEach( (status: MediaKeyStatus, keyId: BufferSource) => { // keyStatuses.forEach is not standard API so the callback value looks weird on xboxone @@ -765,16 +890,25 @@ class EMEController extends Logger implements ComponentAPI { keyId = status; status = temp; } + const keyIdWithStatusChange = arrayToHex( + 'buffer' in keyId + ? new Uint8Array(keyId.buffer, keyId.byteOffset, keyId.byteLength) + : new Uint8Array(keyId), + ); + + // Error immediately when encountering a key ID with this status again + if (status === 'internal-error') { + this.bannedKeyIds[keyIdWithStatusChange] = status; + } + + // Only acknowledge status changes for level-key ID + const matched = keyIdWithStatusChange === sessionLevelKeyId; this.log( - `key status change "${status}" for keyStatuses keyId: ${Hex.hexDump( - 'buffer' in keyId - ? new Uint8Array(keyId.buffer, keyId.byteOffset, keyId.byteLength) - : new Uint8Array(keyId), - )} session keyId: ${Hex.hexDump( - new Uint8Array(mediaKeySessionContext.decryptdata.keyId || []), - )} uri: ${mediaKeySessionContext.decryptdata.uri}`, + `${matched ? '' : 'un'}matched key status change "${status}" for keyStatuses keyId: ${keyIdWithStatusChange} session keyId: ${sessionLevelKeyId} uri: ${mediaKeySessionContext.decryptdata.uri}`, ); - mediaKeySessionContext.keyStatus = status; + if (matched) { + mediaKeySessionContext.keyStatus = status; + } }, ); } @@ -864,7 +998,7 @@ class EMEController extends Logger implements ComponentAPI { this.log( `setServerCertificate ${ success ? 'success' : 'not supported by CDM' - } (${cert?.byteLength}) on "${keySystem}"`, + } (${cert.byteLength}) on "${keySystem}"`, ); resolve(mediaKeys); }) @@ -897,8 +1031,9 @@ class EMEController extends Logger implements ComponentAPI { { type: ErrorTypes.KEY_SYSTEM_ERROR, details: ErrorDetails.KEY_SYSTEM_SESSION_UPDATE_FAILED, + decryptdata: context.decryptdata, error, - fatal: true, + fatal: false, }, error.message, ); @@ -973,7 +1108,7 @@ class EMEController extends Logger implements ComponentAPI { return Promise.resolve() .then(() => { - if (!keysListItem.decryptdata) { + if (!keysListItem.decryptdata as any) { throw new Error('Key removed'); } return licenseXhrSetup.call( @@ -985,7 +1120,7 @@ class EMEController extends Logger implements ComponentAPI { ); }) .catch((error: Error) => { - if (!keysListItem.decryptdata) { + if (!keysListItem.decryptdata as any) { // Key session removed. Cancel license request. throw error; } @@ -1023,7 +1158,10 @@ class EMEController extends Logger implements ComponentAPI { const xhr = new XMLHttpRequest(); xhr.responseType = 'arraybuffer'; xhr.onreadystatechange = () => { - if (!this.hls || !keySessionContext.mediaKeysSession) { + if ( + (!this.hls as any) || + (!keySessionContext.mediaKeysSession as any) + ) { return reject(new Error('invalid state')); } if (xhr.readyState === 4) { @@ -1062,6 +1200,7 @@ class EMEController extends Logger implements ComponentAPI { { type: ErrorTypes.KEY_SYSTEM_ERROR, details: ErrorDetails.KEY_SYSTEM_LICENSE_REQUEST_FAILED, + decryptdata: keySessionContext.decryptdata, fatal: true, networkDetails: xhr, response: { @@ -1096,8 +1235,8 @@ class EMEController extends Logger implements ComponentAPI { } keySessionContext.licenseXhr = xhr; - this.setupLicenseXHR(xhr, url, keySessionContext, licenseChallenge).then( - ({ xhr, licenseChallenge }) => { + this.setupLicenseXHR(xhr, url, keySessionContext, licenseChallenge) + .then(({ xhr, licenseChallenge }) => { if (keySessionContext.keySystem == KeySystems.PLAYREADY) { licenseChallenge = this.unpackPlayReadyKeyMessage( xhr, @@ -1105,8 +1244,8 @@ class EMEController extends Logger implements ComponentAPI { ); } xhr.send(licenseChallenge); - }, - ); + }) + .catch(reject); }); } @@ -1128,6 +1267,7 @@ class EMEController extends Logger implements ComponentAPI { // keep reference of media this.media = media; + addEventListener(media, 'encrypted', this.onMediaEncrypted); addEventListener(media, 'waitingforkey', this.onWaitingForKey); } @@ -1135,6 +1275,7 @@ class EMEController extends Logger implements ComponentAPI { const media = this.media; if (media) { + removeEventListener(media, 'encrypted', this.onMediaEncrypted); removeEventListener(media, 'waitingforkey', this.onWaitingForKey); this.media = null; this.mediaKeys = null; @@ -1144,6 +1285,7 @@ class EMEController extends Logger implements ComponentAPI { private _clear() { this._requestLicenseFailureCount = 0; this.keyIdToKeySessionPromise = {}; + this.bannedKeyIds = {}; if (!this.mediaKeys && !this.mediaKeySessions.length) { return; } @@ -1162,20 +1304,24 @@ class EMEController extends Logger implements ComponentAPI { this.removeSession(mediaKeySessionContext), ) .concat( - media?.setMediaKeys(null)?.catch((error) => { - this.log(`Could not clear media keys: ${error}`); - this.hls?.trigger(Events.ERROR, { - type: ErrorTypes.OTHER_ERROR, - details: ErrorDetails.KEY_SYSTEM_DESTROY_MEDIA_KEYS_ERROR, - fatal: false, - error: new Error(`Could not clear media keys: ${error}`), - }); - }), + (media?.setMediaKeys(null) as Promise | null)?.catch( + (error) => { + this.log(`Could not clear media keys: ${error}`); + if (!this.hls as any) return; + this.hls.trigger(Events.ERROR, { + type: ErrorTypes.OTHER_ERROR, + details: ErrorDetails.KEY_SYSTEM_DESTROY_MEDIA_KEYS_ERROR, + fatal: false, + error: new Error(`Could not clear media keys: ${error}`), + }); + }, + ), ), ) .catch((error) => { this.log(`Could not close sessions and clear media keys: ${error}`); - this.hls?.trigger(Events.ERROR, { + if (!this.hls as any) return; + this.hls.trigger(Events.ERROR, { type: ErrorTypes.OTHER_ERROR, details: ErrorDetails.KEY_SYSTEM_DESTROY_CLOSE_SESSION_ERROR, fatal: false, @@ -1194,6 +1340,7 @@ class EMEController extends Logger implements ComponentAPI { private onManifestLoading() { this.keyFormatPromise = null; + this.bannedKeyIds = {}; } private onManifestLoaded( @@ -1225,10 +1372,11 @@ class EMEController extends Logger implements ComponentAPI { private removeSession( mediaKeySessionContext: MediaKeySessionContext, ): Promise | void { - const { mediaKeysSession, licenseXhr } = mediaKeySessionContext; - if (mediaKeysSession) { + const { mediaKeysSession, licenseXhr, decryptdata } = + mediaKeySessionContext; + if (mediaKeysSession as MediaKeySession | undefined) { this.log( - `Remove licenses and keys and close session ${mediaKeysSession.sessionId}`, + `Remove licenses and keys and close session "${mediaKeysSession.sessionId}" keyId: ${arrayToHex((decryptdata as LevelKey | undefined)?.keyId || [])}`, ); if (mediaKeySessionContext._onmessage) { mediaKeysSession.removeEventListener( @@ -1263,13 +1411,14 @@ class EMEController extends Logger implements ComponentAPI { () => reject(new Error(`MediaKeySession.remove() timeout`)), 8000, ); - mediaKeysSession.remove().then(resolve); + mediaKeysSession.remove().then(resolve).catch(reject); }) : Promise.resolve(); return removePromise .catch((error) => { this.log(`Could not remove session: ${error}`); - this.hls?.trigger(Events.ERROR, { + if (!this.hls as any) return; + this.hls.trigger(Events.ERROR, { type: ErrorTypes.OTHER_ERROR, details: ErrorDetails.KEY_SYSTEM_DESTROY_REMOVE_SESSION_ERROR, fatal: false, @@ -1281,7 +1430,8 @@ class EMEController extends Logger implements ComponentAPI { }) .catch((error) => { this.log(`Could not close session: ${error}`); - this.hls?.trigger(Events.ERROR, { + if (!this.hls as any) return; + this.hls.trigger(Events.ERROR, { type: ErrorTypes.OTHER_ERROR, details: ErrorDetails.KEY_SYSTEM_DESTROY_CLOSE_SESSION_ERROR, fatal: false, @@ -1305,4 +1455,25 @@ class EMEKeyError extends Error { } } +function getKeyStatusError( + keyStatus: MediaKeyStatus, + decryptdata: LevelKey, +): EMEKeyError { + const outputRestricted = keyStatus === 'output-restricted'; + const details = outputRestricted + ? ErrorDetails.KEY_SYSTEM_STATUS_OUTPUT_RESTRICTED + : ErrorDetails.KEY_SYSTEM_STATUS_INTERNAL_ERROR; + return new EMEKeyError( + { + type: ErrorTypes.KEY_SYSTEM_ERROR, + details, + fatal: false, + decryptdata, + }, + outputRestricted + ? 'HDCP level output restricted' + : `key status changed to "${keyStatus}"`, + ); +} + export default EMEController; diff --git a/src/controller/error-controller.ts b/src/controller/error-controller.ts index 39af1810c0b..171a3fe1ad6 100644 --- a/src/controller/error-controller.ts +++ b/src/controller/error-controller.ts @@ -9,14 +9,15 @@ import { isTimeoutError, shouldRetry, } from '../utils/error-helper'; +import { arrayToHex } from '../utils/hex'; import { Logger } from '../utils/logger'; import type { RetryConfig } from '../config'; +import type { LevelKey } from '../hls'; import type Hls from '../hls'; import type { Fragment, MediaFragment } from '../loader/fragment'; -import type { LevelDetails } from '../loader/level-details'; import type { NetworkComponentAPI } from '../types/component-api'; import type { ErrorData } from '../types/events'; -import type { HdcpLevel } from '../types/level'; +import type { HdcpLevel, Level } from '../types/level'; export const enum NetworkErrorAction { DoNothing = 0, @@ -30,8 +31,9 @@ export const enum NetworkErrorAction { export const enum ErrorActionFlags { None = 0, MoveAllAlternatesMatchingHost = 1, - MoveAllAlternatesMatchingHDCP = 1 << 1, - SwitchToSDR = 1 << 2, // Reserved for future use + MoveAllAlternatesMatchingHDCP = 2, + MoveAllAlternatesMatchingKey = 4, + SwitchToSDR = 8, } export type IErrorAction = { @@ -43,22 +45,12 @@ export type IErrorAction = { nextAutoLevel?: number; resolved?: boolean; }; - -type PenalizedRendition = { - lastErrorPerfMs: number; - errors: ErrorData[]; - details?: LevelDetails; -}; - -type PenalizedRenditions = { [key: number]: PenalizedRendition }; - export default class ErrorController extends Logger implements NetworkComponentAPI { private readonly hls: Hls; private playlistError: number = 0; - private penalizedRenditions: PenalizedRenditions = {}; constructor(hls: Hls) { super('error-controller', hls.logger); @@ -88,7 +80,6 @@ export default class ErrorController this.unregisterListeners(); // @ts-ignore this.hls = null; - this.penalizedRenditions = {}; } startLoad(startPosition: number): void {} @@ -98,14 +89,42 @@ export default class ErrorController } private getVariantLevelIndex(frag: Fragment | undefined): number { - return frag?.type === PlaylistLevelType.MAIN - ? frag.level - : this.hls.loadLevel; + if (frag?.type === PlaylistLevelType.MAIN) { + return frag.level; + } + return this.getVariantIndex(); + } + + private getVariantIndex(): number { + const hls = this.hls; + const currentLevel = hls.currentLevel; + if (hls.loadLevelObj?.details || currentLevel === -1) { + return hls.loadLevel; + } + return currentLevel; + } + + private variantHasKey( + level: Level | undefined, + keyInError: LevelKey, + ): boolean { + if (level) { + if (level.details?.hasKey(keyInError)) { + return true; + } + const audioGroupsIds = level.audioGroups; + if (audioGroupsIds) { + const audioTracks = this.hls.allAudioTracks.filter( + (track) => audioGroupsIds.indexOf(track.groupId) >= 0, + ); + return audioTracks.some((track) => track.details?.hasKey(keyInError)); + } + } + return false; } private onManifestLoading() { this.playlistError = 0; - this.penalizedRenditions = {}; } private onLevelUpdated() { @@ -201,17 +220,20 @@ export default class ErrorController return; case ErrorDetails.KEY_SYSTEM_STATUS_OUTPUT_RESTRICTED: { - const level = hls.loadLevelObj; - const restrictedHdcpLevel = level?.attrs['HDCP-LEVEL']; - if (restrictedHdcpLevel) { - data.errorAction = { - action: NetworkErrorAction.SendAlternateToPenaltyBox, - flags: ErrorActionFlags.MoveAllAlternatesMatchingHDCP, - hdcpLevel: restrictedHdcpLevel, - }; - } else { - this.keySystemError(data); - } + data.errorAction = { + action: NetworkErrorAction.SendAlternateToPenaltyBox, + flags: ErrorActionFlags.MoveAllAlternatesMatchingHDCP, + }; + } + return; + case ErrorDetails.KEY_SYSTEM_SESSION_UPDATE_FAILED: + case ErrorDetails.KEY_SYSTEM_STATUS_INTERNAL_ERROR: + case ErrorDetails.KEY_SYSTEM_NO_SESSION: + { + data.errorAction = { + action: NetworkErrorAction.SendAlternateToPenaltyBox, + flags: ErrorActionFlags.MoveAllAlternatesMatchingKey, + }; } return; case ErrorDetails.BUFFER_ADD_CODEC_ERROR: @@ -237,17 +259,12 @@ export default class ErrorController } if (data.type === ErrorTypes.KEY_SYSTEM_ERROR) { - this.keySystemError(data); + // Do not retry level. Should be fatal if ErrorDetails.KEY_SYSTEM_ not handled with early return above. + data.levelRetry = false; + data.errorAction = createDoNothingErrorAction(); } } - private keySystemError(data: ErrorData) { - const levelIndex = this.getVariantLevelIndex(data.frag); - // Do not retry level. Escalate to fatal if switching levels fails. - data.levelRetry = false; - data.errorAction = this.getLevelSwitchAction(data, levelIndex); - } - private getPlaylistRetryOrSwitchAction( data: ErrorData, levelIndex: number | null | undefined, @@ -413,10 +430,10 @@ export default class ErrorController )) || (findAudioCodecAlternate && level.audioCodec === levelCandidate.audioCodec) || - (!findAudioCodecAlternate && - level.audioCodec !== levelCandidate.audioCodec) || (findVideoCodecAlternate && - level.codecSet === levelCandidate.codecSet) + level.codecSet === levelCandidate.codecSet) || + (!findAudioCodecAlternate && + level.codecSet !== levelCandidate.codecSet) ) { // For video/audio/subs frag errors find another group ID or fallthrough to redundant fail-over continue; @@ -478,21 +495,54 @@ export default class ErrorController if (!errorAction) { return; } - const { flags, hdcpLevel, nextAutoLevel } = errorAction; + const { flags } = errorAction; + const nextAutoLevel = errorAction.nextAutoLevel; switch (flags) { case ErrorActionFlags.None: this.switchLevel(data, nextAutoLevel); break; - case ErrorActionFlags.MoveAllAlternatesMatchingHDCP: - if (hdcpLevel) { - hls.maxHdcpLevel = HdcpLevels[HdcpLevels.indexOf(hdcpLevel) - 1]; + case ErrorActionFlags.MoveAllAlternatesMatchingHDCP: { + const levelIndex = this.getVariantLevelIndex(data.frag); + const level = hls.levels[levelIndex]; + const restrictedHdcpLevel = (level as Level | undefined)?.attrs[ + 'HDCP-LEVEL' + ]; + errorAction.hdcpLevel = restrictedHdcpLevel; + if (restrictedHdcpLevel) { + hls.maxHdcpLevel = + HdcpLevels[HdcpLevels.indexOf(restrictedHdcpLevel) - 1]; errorAction.resolved = true; + this.warn( + `Restricting playback to HDCP-LEVEL of "${hls.maxHdcpLevel}" or lower`, + ); + break; + } + // Fallthrough when no HDCP-LEVEL attribute is found + } + // eslint-disable-next-line no-fallthrough + case ErrorActionFlags.MoveAllAlternatesMatchingKey: { + const levelKey = data.decryptdata; + if (levelKey) { + // Penalize all levels with key + const levels = this.hls.levels; + for (let i = levels.length; i--; ) { + if (this.variantHasKey(levels[i], levelKey)) { + this.log( + `Banned key found in level ${i} (${levels[i].bitrate}bps) or audio group "${levels[i].audioGroups?.join(',')}" (${data.frag?.type} fragment) ${arrayToHex(levelKey.keyId || [])}`, + ); + levels[i].fragmentError++; + levels[i].loadError++; + this.log(`Removing level ${i} with key error (${data.error})`); + this.hls.removeLevel(i); + } + } + if (levels.length) { + errorAction.resolved = true; + } } - this.warn( - `Restricting playback to HDCP-LEVEL of "${hls.maxHdcpLevel}" or lower`, - ); break; + } } // If not resolved by previous actions try to switch to next level if (!errorAction.resolved) { @@ -516,6 +566,9 @@ export default class ErrorController const levels = this.hls.levels; for (let i = levels.length; i--; ) { if (levels[i][`${data.sourceBufferName}Codec`] === codec) { + this.log( + `Removing level ${i} for ${data.details} ("${codec}" not supported)`, + ); this.hls.removeLevel(i); } } diff --git a/src/controller/fragment-tracker.ts b/src/controller/fragment-tracker.ts index 6ffb3b7f87b..af3b0583751 100644 --- a/src/controller/fragment-tracker.ts +++ b/src/controller/fragment-tracker.ts @@ -36,7 +36,7 @@ export class FragmentTracker implements ComponentAPI { | null = Object.create(null); private bufferPadding: number = 0.2; - private hls: Hls; + private hls: Hls | null; private hasGaps: boolean = false; constructor(hls: Hls) { @@ -47,24 +47,30 @@ export class FragmentTracker implements ComponentAPI { private _registerListeners() { const { hls } = this; - hls.on(Events.MANIFEST_LOADING, this.onManifestLoading, this); - hls.on(Events.BUFFER_APPENDED, this.onBufferAppended, this); - hls.on(Events.FRAG_BUFFERED, this.onFragBuffered, this); - hls.on(Events.FRAG_LOADED, this.onFragLoaded, this); + if (hls) { + hls.on(Events.MANIFEST_LOADING, this.onManifestLoading, this); + hls.on(Events.BUFFER_APPENDED, this.onBufferAppended, this); + hls.on(Events.FRAG_BUFFERED, this.onFragBuffered, this); + hls.on(Events.FRAG_LOADED, this.onFragLoaded, this); + } } private _unregisterListeners() { const { hls } = this; - hls.off(Events.MANIFEST_LOADING, this.onManifestLoading, this); - hls.off(Events.BUFFER_APPENDED, this.onBufferAppended, this); - hls.off(Events.FRAG_BUFFERED, this.onFragBuffered, this); - hls.off(Events.FRAG_LOADED, this.onFragLoaded, this); + if (hls) { + hls.off(Events.MANIFEST_LOADING, this.onManifestLoading, this); + hls.off(Events.BUFFER_APPENDED, this.onBufferAppended, this); + hls.off(Events.FRAG_BUFFERED, this.onFragBuffered, this); + hls.off(Events.FRAG_LOADED, this.onFragLoaded, this); + } } public destroy() { this._unregisterListeners(); // @ts-ignore - this.fragments = + this.hls = + // @ts-ignore + this.fragments = // @ts-ignore this.activePartLists = // @ts-ignore @@ -80,19 +86,18 @@ export class FragmentTracker implements ComponentAPI { public getAppendedFrag( position: number, levelType: PlaylistLevelType, - ): Fragment | Part | null { + ): MediaFragment | Part | null { const activeParts = this.activePartLists[levelType]; if (activeParts) { for (let i = activeParts.length; i--; ) { const activePart = activeParts[i]; - if (!activePart) { + if (!activePart as any) { break; } - const appendedPTS = activePart.end; if ( activePart.start <= position && - appendedPTS !== null && - position <= appendedPTS + position <= activePart.end && + activePart.loaded ) { return activePart; } @@ -534,10 +539,12 @@ export class FragmentTracker implements ComponentAPI { function isPartial(fragmentEntity: FragmentEntity): boolean { return ( fragmentEntity.buffered && - (fragmentEntity.body.gap || + !!( + fragmentEntity.body.gap || fragmentEntity.range.video?.partial || fragmentEntity.range.audio?.partial || - fragmentEntity.range.audiovideo?.partial) + fragmentEntity.range.audiovideo?.partial + ) ); } diff --git a/src/controller/gap-controller.ts b/src/controller/gap-controller.ts index 76b4d8dd527..5ef6869ce37 100644 --- a/src/controller/gap-controller.ts +++ b/src/controller/gap-controller.ts @@ -13,13 +13,14 @@ import type { InFlightData } from './base-stream-controller'; import type { InFlightFragments } from '../hls'; import type Hls from '../hls'; import type { FragmentTracker } from './fragment-tracker'; -import type { Fragment, MediaFragment } from '../loader/fragment'; +import type { Fragment, MediaFragment, Part } from '../loader/fragment'; import type { SourceBufferName } from '../types/buffer'; import type { BufferAppendedData, MediaAttachedData, MediaDetachingData, } from '../types/events'; +import type { ErrorData } from '../types/events'; import type { BufferInfo } from '../utils/buffer-helper'; export const MAX_START_GAP_JUMP = 2.0; @@ -28,8 +29,8 @@ export const SKIP_BUFFER_RANGE_START = 0.05; const TICK_INTERVAL = 100; export default class GapController extends TaskLoop { - private hls: Hls | null = null; - private fragmentTracker: FragmentTracker | null = null; + private hls: Hls | null; + private fragmentTracker: FragmentTracker | null; private media: HTMLMediaElement | null = null; private mediaSource?: MediaSource; @@ -267,10 +268,10 @@ export default class GapController extends TaskLoop { const maxStartGapJump = isLive ? levelDetails!.targetduration * 2 : MAX_START_GAP_JUMP; - const partialOrGap = fragmentTracker.getPartialFragment(currentTime); - if (startJump > 0 && (startJump <= maxStartGapJump || partialOrGap)) { + const appended = appendedFragAtPosition(currentTime, fragmentTracker); + if (startJump > 0 && (startJump <= maxStartGapJump || appended)) { if (!media.paused) { - this._trySkipBufferHole(partialOrGap); + this._trySkipBufferHole(appended); } return; } @@ -314,7 +315,7 @@ export default class GapController extends TaskLoop { } // Report stalling after trying to fix this._reportStall(bufferInfo); - if (!this.media || !this.hls) { + if (!this.media || (!this.hls as any)) { return; } } @@ -398,8 +399,13 @@ export default class GapController extends TaskLoop { this.warn(error.message); // Magic number to flush the pipeline without interuption to audio playback: this.media.currentTime += 0.000001; - const frag = - this.fragmentTracker.getPartialFragment(currentTime) || undefined; + let frag: MediaFragment | Part | null | undefined = + appendedFragAtPosition(currentTime, this.fragmentTracker); + if (frag && 'fragment' in frag) { + frag = frag.fragment; + } else if (!frag) { + frag = undefined; + } const bufferInfo = BufferHelper.bufferInfo( this.media, currentTime, @@ -439,14 +445,14 @@ export default class GapController extends TaskLoop { } const levelDetails = this.hls?.latestLevelDetails; - const partial = fragmentTracker.getPartialFragment(currentTime); + const appended = appendedFragAtPosition(currentTime, fragmentTracker); if ( - partial || + appended || (levelDetails?.live && currentTime < levelDetails.fragmentStart) ) { // Try to skip over the buffer hole caused by a partial fragment // This method isn't limited by the size of the gap between buffered ranges - const targetTime = this._trySkipBufferHole(partial); + const targetTime = this._trySkipBufferHole(appended); // we return here in this case, meaning // the branch below only executes when we haven't seeked to a new position if (targetTime || !this.media) { @@ -526,10 +532,10 @@ export default class GapController extends TaskLoop { /** * Attempts to fix buffer stalls by jumping over known gaps caused by partial fragments - * @param partial - The partial fragment found at the current time (where playback is stalling). + * @param appended - The fragment or part found at the current time (where playback is stalling). * @private */ - private _trySkipBufferHole(partial: MediaFragment | null): number { + private _trySkipBufferHole(appended: MediaFragment | Part | null): number { const { fragmentTracker, media } = this; const config = this.hls?.config; if (!media || !fragmentTracker || !config) { @@ -559,46 +565,34 @@ export default class GapController extends TaskLoop { startGap = true; } } - if (!startGap) { - const startProvisioned = - partial || - fragmentTracker.getAppendedFrag( - currentTime, - PlaylistLevelType.MAIN, - ); - if (startProvisioned) { - // Do not seek when selected variant playlist is unloaded - if (!this.hls.loadLevelObj?.details) { - return 0; - } - // Do not seek when required fragments are inflight or appending - const inFlightDependency = getInFlightDependency( - this.hls.inFlightFragments, - startTime, - ); - if (inFlightDependency) { - return 0; - } - // Do not seek if we can't walk tracked fragments to end of gap - let moreToLoad = false; - let pos = startProvisioned.end; - while (pos < startTime) { - const provisioned = - fragmentTracker.getAppendedFrag( - pos, - PlaylistLevelType.MAIN, - ) || fragmentTracker.getPartialFragment(pos); - if (provisioned) { - pos += provisioned.duration; - } else { - moreToLoad = true; - break; - } - } - if (moreToLoad) { - return 0; + if (!startGap && appended) { + // Do not seek when selected variant playlist is unloaded + if (!this.hls.loadLevelObj?.details) { + return 0; + } + // Do not seek when required fragments are inflight or appending + const inFlightDependency = getInFlightDependency( + this.hls.inFlightFragments, + startTime, + ); + if (inFlightDependency) { + return 0; + } + // Do not seek if we can't walk tracked fragments to end of gap + let moreToLoad = false; + let pos = appended.end; + while (pos < startTime) { + const provisioned = appendedFragAtPosition(pos, fragmentTracker); + if (provisioned) { + pos += provisioned.duration; + } else { + moreToLoad = true; + break; } } + if (moreToLoad) { + return 0; + } } } const targetTime = Math.max( @@ -610,20 +604,27 @@ export default class GapController extends TaskLoop { ); this.moved = true; media.currentTime = targetTime; - if (!partial?.gap) { + if (!appended?.gap) { const error = new Error( `fragment loaded with buffer holes, seeking from ${currentTime} to ${targetTime}`, ); - this.hls.trigger(Events.ERROR, { + const errorData: ErrorData = { type: ErrorTypes.MEDIA_ERROR, details: ErrorDetails.BUFFER_SEEK_OVER_HOLE, fatal: false, error, reason: error.message, - frag: partial || undefined, buffer: bufferInfo.len, bufferInfo, - }); + }; + if (appended) { + if ('fragment' in appended) { + errorData.part = appended; + } else { + errorData.frag = appended; + } + } + this.hls.trigger(Events.ERROR, errorData); } return targetTime; } @@ -705,3 +706,10 @@ function inFlight(inFlightData: InFlightData | undefined): Fragment | null { } return inFlightData.frag; } + +function appendedFragAtPosition(pos: number, fragmentTracker: FragmentTracker) { + return ( + fragmentTracker.getAppendedFrag(pos, PlaylistLevelType.MAIN) || + fragmentTracker.getPartialFragment(pos) + ); +} diff --git a/src/controller/id3-track-controller.ts b/src/controller/id3-track-controller.ts index e3c022b239f..24a8d0d1e43 100644 --- a/src/controller/id3-track-controller.ts +++ b/src/controller/id3-track-controller.ts @@ -13,6 +13,7 @@ import { removeCuesInRange, sendAddTrackEvent, } from '../utils/texttrack-utils'; +import type { MediaFragment } from '../hls'; import type Hls from '../hls'; import type { DateRange } from '../loader/date-range'; import type { LevelDetails } from '../loader/level-details'; @@ -36,7 +37,7 @@ const MIN_CUE_DURATION = 0.25; function getCueClass(): typeof VTTCue | typeof TextTrackCue | undefined { if (typeof self === 'undefined') return undefined; - return self.VTTCue || self.TextTrackCue; + return (self.VTTCue as typeof VTTCue | undefined) || self.TextTrackCue; } function createCueWithDataFields( @@ -75,18 +76,20 @@ const MAX_CUE_ENDTIME = (() => { })(); class ID3TrackController implements ComponentAPI { - private hls: Hls; + private hls: Hls | null; private id3Track: TextTrack | null = null; private media: HTMLMediaElement | null = null; private dateRangeCuesAppended: Record< string, - { - cues: Record; - dateRange: DateRange; - durationKnown: boolean; - } + | { + cues: Record; + dateRange: DateRange; + durationKnown: boolean; + } + | undefined > = {}; private removeCues: boolean = true; + private assetCue?: VTTCue | TextTrackCue; constructor(hls) { this.hls = hls; @@ -104,26 +107,30 @@ class ID3TrackController implements ComponentAPI { private _registerListeners() { const { hls } = this; - hls.on(Events.MEDIA_ATTACHING, this.onMediaAttaching, this); - hls.on(Events.MEDIA_ATTACHED, this.onMediaAttached, this); - hls.on(Events.MEDIA_DETACHING, this.onMediaDetaching, this); - hls.on(Events.MANIFEST_LOADING, this.onManifestLoading, this); - hls.on(Events.FRAG_PARSING_METADATA, this.onFragParsingMetadata, this); - hls.on(Events.BUFFER_FLUSHING, this.onBufferFlushing, this); - hls.on(Events.LEVEL_UPDATED, this.onLevelUpdated, this); - hls.on(Events.LEVEL_PTS_UPDATED, this.onLevelPtsUpdated, this); + if (hls) { + hls.on(Events.MEDIA_ATTACHING, this.onMediaAttaching, this); + hls.on(Events.MEDIA_ATTACHED, this.onMediaAttached, this); + hls.on(Events.MEDIA_DETACHING, this.onMediaDetaching, this); + hls.on(Events.MANIFEST_LOADING, this.onManifestLoading, this); + hls.on(Events.FRAG_PARSING_METADATA, this.onFragParsingMetadata, this); + hls.on(Events.BUFFER_FLUSHING, this.onBufferFlushing, this); + hls.on(Events.LEVEL_UPDATED, this.onLevelUpdated, this); + hls.on(Events.LEVEL_PTS_UPDATED, this.onLevelPtsUpdated, this); + } } private _unregisterListeners() { const { hls } = this; - hls.off(Events.MEDIA_ATTACHING, this.onMediaAttaching, this); - hls.off(Events.MEDIA_ATTACHED, this.onMediaAttached, this); - hls.off(Events.MEDIA_DETACHING, this.onMediaDetaching, this); - hls.off(Events.MANIFEST_LOADING, this.onManifestLoading, this); - hls.off(Events.FRAG_PARSING_METADATA, this.onFragParsingMetadata, this); - hls.off(Events.BUFFER_FLUSHING, this.onBufferFlushing, this); - hls.off(Events.LEVEL_UPDATED, this.onLevelUpdated, this); - hls.off(Events.LEVEL_PTS_UPDATED, this.onLevelPtsUpdated, this); + if (hls) { + hls.off(Events.MEDIA_ATTACHING, this.onMediaAttaching, this); + hls.off(Events.MEDIA_ATTACHED, this.onMediaAttached, this); + hls.off(Events.MEDIA_DETACHING, this.onMediaDetaching, this); + hls.off(Events.MANIFEST_LOADING, this.onManifestLoading, this); + hls.off(Events.FRAG_PARSING_METADATA, this.onFragParsingMetadata, this); + hls.off(Events.BUFFER_FLUSHING, this.onBufferFlushing, this); + hls.off(Events.LEVEL_UPDATED, this.onLevelUpdated, this); + hls.off(Events.LEVEL_PTS_UPDATED, this.onLevelPtsUpdated, this); + } } private onEventCueEnter = () => { @@ -145,7 +152,7 @@ class ID3TrackController implements ComponentAPI { } private onMediaAttached() { - const details = this.hls.latestLevelDetails; + const details = this.hls?.latestLevelDetails; if (details) { this.updateDateRangeCues(details); } @@ -200,15 +207,11 @@ class ID3TrackController implements ComponentAPI { event: Events.FRAG_PARSING_METADATA, data: FragParsingMetadataData, ) { - if (!this.media) { + if (!this.media || !this.hls) { return; } - const { - hls: { - config: { enableEmsgMetadataCues, enableID3MetadataCues }, - }, - } = this; + const { enableEmsgMetadataCues, enableID3MetadataCues } = this.hls.config; if (!enableEmsgMetadataCues && !enableID3MetadataCues) { return; } @@ -235,35 +238,33 @@ class ID3TrackController implements ComponentAPI { } const frames = getId3Frames(samples[i].data); - if (frames) { - const startTime = samples[i].pts; - let endTime: number = startTime + samples[i].duration; + const startTime = samples[i].pts; + let endTime: number = startTime + samples[i].duration; - if (endTime > MAX_CUE_ENDTIME) { - endTime = MAX_CUE_ENDTIME; - } + if (endTime > MAX_CUE_ENDTIME) { + endTime = MAX_CUE_ENDTIME; + } - const timeDiff = endTime - startTime; - if (timeDiff <= 0) { - endTime = startTime + MIN_CUE_DURATION; - } + const timeDiff = endTime - startTime; + if (timeDiff <= 0) { + endTime = startTime + MIN_CUE_DURATION; + } - for (let j = 0; j < frames.length; j++) { - const frame = frames[j]; - // Safari doesn't put the timestamp frame in the TextTrack - if (!isId3TimestampFrame(frame)) { - // add a bounds to any unbounded cues - this.updateId3CueEnds(startTime, type); - const cue = createCueWithDataFields( - Cue, - startTime, - endTime, - frame, - type, - ); - if (cue) { - this.id3Track.addCue(cue); - } + for (let j = 0; j < frames.length; j++) { + const frame = frames[j]; + // Safari doesn't put the timestamp frame in the TextTrack + if (!isId3TimestampFrame(frame)) { + // add a bounds to any unbounded cues + this.updateId3CueEnds(startTime, type); + const cue = createCueWithDataFields( + Cue, + startTime, + endTime, + frame, + type, + ); + if (cue) { + this.id3Track.addCue(cue); } } } @@ -335,11 +336,49 @@ class ID3TrackController implements ComponentAPI { } private updateDateRangeCues(details: LevelDetails, removeOldCues?: true) { + if (!this.hls || !this.media) { + return; + } + const { + assetPlayerId, + timelineOffset, + enableDateRangeMetadataCues, + interstitialsController, + } = this.hls.config; + if (!enableDateRangeMetadataCues) { + return; + } + + const Cue = getCueClass(); if ( - !this.media || - !details.hasProgramDateTime || - !this.hls.config.enableDateRangeMetadataCues + __USE_INTERSTITIALS__ && + assetPlayerId && + timelineOffset && + !interstitialsController ) { + const { fragmentStart, fragmentEnd } = details; + let cue = this.assetCue; + if (cue) { + cue.startTime = fragmentStart; + cue.endTime = fragmentEnd; + } else if (Cue) { + cue = this.assetCue = createCueWithDataFields( + Cue, + fragmentStart, + fragmentEnd, + { assetPlayerId: this.hls.config.assetPlayerId }, + 'hlsjs.interstitial.asset', + ); + if (cue) { + cue.id = assetPlayerId; + this.id3Track ||= this.createTrack(this.media); + this.id3Track.addCue(cue); + cue.addEventListener('enter', this.onEventCueEnter); + } + } + } + + if (!details.hasProgramDateTime) { return; } const { id3Track } = this; @@ -354,36 +393,39 @@ class ID3TrackController implements ComponentAPI { ); for (let i = idsToRemove.length; i--; ) { const id = idsToRemove[i]; - const cues = dateRangeCuesAppended[id].cues; + const cues = dateRangeCuesAppended[id]?.cues; delete dateRangeCuesAppended[id]; - Object.keys(cues).forEach((key) => { - try { + if (cues) { + Object.keys(cues).forEach((key) => { const cue = cues[key]; - cue.removeEventListener('enter', this.onEventCueEnter); - id3Track.removeCue(cue); - } catch (e) { - /* no-op */ - } - }); + if (cue) { + cue.removeEventListener('enter', this.onEventCueEnter); + try { + id3Track.removeCue(cue); + } catch (e) { + /* no-op */ + } + } + }); + } } } else { dateRangeCuesAppended = this.dateRangeCuesAppended = {}; } } // Exit if the playlist does not have Date Ranges or does not have Program Date Time - const lastFragment = details.fragments[details.fragments.length - 1]; + const lastFragment = details.fragments[details.fragments.length - 1] as + | MediaFragment + | undefined; if (ids.length === 0 || !Number.isFinite(lastFragment?.programDateTime)) { return; } - if (!this.id3Track) { - this.id3Track = this.createTrack(this.media); - } + this.id3Track ||= this.createTrack(this.media); - const Cue = getCueClass(); for (let i = 0; i < ids.length; i++) { const id = ids[i]; - const dateRange = dateRanges[id]; + const dateRange = dateRanges[id]!; const startTime = dateRange.startTime; // Process DateRanges to determine end-time (known DURATION, END-DATE, or END-ON-NEXT) @@ -399,7 +441,7 @@ class ID3TrackController implements ComponentAPI { const nextDateRangeWithSameClass = ids.reduce( (candidateDateRange: DateRange | null, id) => { if (id !== dateRange.id) { - const otherDateRange = dateRanges[id]; + const otherDateRange = dateRanges[id]!; if ( otherDateRange.class === dateRange.class && otherDateRange.startDate > dateRange.startDate && @@ -429,7 +471,7 @@ class ID3TrackController implements ComponentAPI { } const cue = cues[key]; if (cue) { - if (durationKnown && !appendedDateRangeCues.durationKnown) { + if (durationKnown && !appendedDateRangeCues?.durationKnown) { cue.endTime = endTime; } else if (Math.abs(cue.startTime - startTime) > 0.01) { cue.startTime = startTime; @@ -452,10 +494,7 @@ class ID3TrackController implements ComponentAPI { cue.id = id; this.id3Track.addCue(cue); cues[key] = cue; - if ( - __USE_INTERSTITIALS__ && - this.hls.config.interstitialsController - ) { + if (__USE_INTERSTITIALS__ && interstitialsController) { if (key === 'X-ASSET-LIST' || key === 'X-ASSET-URL') { cue.addEventListener('enter', this.onEventCueEnter); } diff --git a/src/controller/interstitial-player.ts b/src/controller/interstitial-player.ts index 4f5af35bcc3..7a89633bb38 100644 --- a/src/controller/interstitial-player.ts +++ b/src/controller/interstitial-player.ts @@ -14,6 +14,7 @@ import type Hls from '../hls'; import type { BufferCodecsData, MediaAttachingData } from '../types/events'; export interface InterstitialPlayer { + bufferedEnd: number; currentTime: number; duration: number; assetPlayers: (HlsAssetPlayer | null)[]; @@ -25,8 +26,8 @@ export type HlsAssetPlayerConfig = Partial & Required>; export class HlsAssetPlayer { - public readonly hls: Hls; - public readonly interstitial: InterstitialEvent; + public hls: Hls | null; + public interstitial: InterstitialEvent; public readonly assetItem: InterstitialAssetItem; public tracks: Partial | null = null; private hasDetails: boolean = false; @@ -43,14 +44,6 @@ export class HlsAssetPlayer { const hls = (this.hls = new HlsPlayerClass(userConfig)); this.interstitial = interstitial; this.assetItem = assetItem; - let uri: string = assetItem.uri; - try { - uri = getInterstitialUrl(uri, userConfig.primarySessionId).href; - } catch (error) { - // Ignore error parsing ASSET_URI or adding _HLS_primary_id to it. The - // issue should surface as an INTERSTITIAL_ASSET_ERROR loading the asset. - } - hls.loadSource(uri); const detailsLoaded = () => { this.hasDetails = true; }; @@ -77,7 +70,26 @@ export class HlsAssetPlayer { } get appendInPlace(): boolean { - return this.interstitial?.appendInPlace || false; + return this.interstitial.appendInPlace; + } + + loadSource() { + const hls = this.hls; + if (!hls) { + return; + } + if (!hls.url) { + let uri: string = this.assetItem.uri; + try { + uri = getInterstitialUrl(uri, hls.config.primarySessionId || '').href; + } catch (error) { + // Ignore error parsing ASSET_URI or adding _HLS_primary_id to it. The + // issue should surface as an INTERSTITIAL_ASSET_ERROR loading the asset. + } + hls.loadSource(uri); + } else if (hls.levels.length && !(hls as any).started) { + hls.startLoad(-1, true); + } } bufferedInPlaceToEnd(media?: HTMLMediaElement | null) { @@ -87,17 +99,18 @@ export class HlsAssetPlayer { if (this.hls?.bufferedToEnd) { return true; } - if (!media || !this._bufferedEosTime) { + if (!media) { return false; } + const duration = Math.min(this._bufferedEosTime || Infinity, this.duration); const start = this.timelineOffset; const bufferInfo = BufferHelper.bufferInfo(media, start, 0); const bufferedEnd = this.getAssetTime(bufferInfo.end); - return bufferedEnd >= this._bufferedEosTime - 0.02; + return bufferedEnd >= duration - 0.02; } private checkPlayout = () => { - if (this.reachedPlayout(this.currentTime)) { + if (this.reachedPlayout(this.currentTime) && this.hls) { this.hls.trigger(Events.PLAYOUT_LIMIT_REACHED, {}); } }; @@ -149,6 +162,13 @@ export class HlsAssetPlayer { if (!duration) { return 0; } + const playoutLimit = this.interstitial.playoutLimit; + if (playoutLimit) { + const assetPlayout = playoutLimit - this.startOffset; + if (assetPlayout > 0 && assetPlayout < duration) { + return assetPlayout; + } + } return duration; } @@ -172,7 +192,7 @@ export class HlsAssetPlayer { const timelineOffset = this.timelineOffset; if (value !== timelineOffset) { const diff = value - timelineOffset; - if (Math.abs(diff) > 1 / 90000) { + if (Math.abs(diff) > 1 / 90000 && this.hls) { if (this.hasDetails) { throw new Error( `Cannot set timelineOffset after playlists are loaded`, @@ -208,39 +228,41 @@ export class HlsAssetPlayer { destroy() { this.removeMediaListeners(); - this.hls.destroy(); - // @ts-ignore - this.hls = this.interstitial = null; + if (this.hls) { + this.hls.destroy(); + } + this.hls = null; // @ts-ignore this.tracks = this.mediaAttached = this.checkPlayout = null; } attachMedia(data: HTMLMediaElement | MediaAttachingData) { - this.hls.attachMedia(data); + this.loadSource(); + this.hls?.attachMedia(data); } detachMedia() { this.removeMediaListeners(); this.mediaAttached = null; - this.hls.detachMedia(); + this.hls?.detachMedia(); } resumeBuffering() { - this.hls.resumeBuffering(); + this.hls?.resumeBuffering(); } pauseBuffering() { - this.hls.pauseBuffering(); + this.hls?.pauseBuffering(); } transferMedia() { this.bufferSnapShot(); - return this.hls.transferMedia(); + return this.hls?.transferMedia() || null; } resetDetails() { const hls = this.hls; - if (this.hasDetails) { + if (hls && this.hasDetails) { hls.stopLoad(); const deleteDetails = (obj) => delete obj.details; hls.levels.forEach(deleteDetails); @@ -255,7 +277,7 @@ export class HlsAssetPlayer { listener: HlsListeners[E], context?: Context, ) { - this.hls.on(event, listener); + this.hls?.on(event, listener); } once( @@ -263,7 +285,7 @@ export class HlsAssetPlayer { listener: HlsListeners[E], context?: Context, ) { - this.hls.once(event, listener); + this.hls?.once(event, listener); } off( @@ -271,7 +293,7 @@ export class HlsAssetPlayer { listener: HlsListeners[E], context?: Context, ) { - this.hls.off(event, listener); + this.hls?.off(event, listener); } toString(): string { diff --git a/src/controller/interstitials-controller.ts b/src/controller/interstitials-controller.ts index 23df59f7858..abe29884b49 100644 --- a/src/controller/interstitials-controller.ts +++ b/src/controller/interstitials-controller.ts @@ -86,6 +86,10 @@ function playWithCatch(media: HTMLMediaElement | null) { }); } +function timelineMessage(label: string, time: number) { + return `[${label}] Advancing timeline position to ${time}`; +} + export default class InterstitialsController extends Logger implements NetworkComponentAPI @@ -117,7 +121,7 @@ export default class InterstitialsController private timelinePos: number = -1; // Schedule - private schedule: InterstitialsSchedule; + private schedule: InterstitialsSchedule | null; // Schedule playback and buffering state private playingItem: InterstitialScheduleItem | null = null; @@ -143,48 +147,51 @@ export default class InterstitialsController private registerListeners() { const hls = this.hls; - hls.on(Events.MEDIA_ATTACHING, this.onMediaAttaching, this); - hls.on(Events.MEDIA_ATTACHED, this.onMediaAttached, this); - hls.on(Events.MEDIA_DETACHING, this.onMediaDetaching, this); - hls.on(Events.MANIFEST_LOADING, this.onManifestLoading, this); - hls.on(Events.LEVEL_UPDATED, this.onLevelUpdated, this); - hls.on(Events.AUDIO_TRACK_SWITCHING, this.onAudioTrackSwitching, this); - hls.on(Events.AUDIO_TRACK_UPDATED, this.onAudioTrackUpdated, this); - hls.on(Events.SUBTITLE_TRACK_SWITCH, this.onSubtitleTrackSwitch, this); - hls.on(Events.SUBTITLE_TRACK_UPDATED, this.onSubtitleTrackUpdated, this); - hls.on(Events.EVENT_CUE_ENTER, this.onInterstitialCueEnter, this); - hls.on(Events.ASSET_LIST_LOADED, this.onAssetListLoaded, this); - hls.on(Events.BUFFER_APPENDED, this.onBufferAppended, this); - hls.on(Events.BUFFER_FLUSHED, this.onBufferFlushed, this); - hls.on(Events.BUFFERED_TO_END, this.onBufferedToEnd, this); - hls.on(Events.MEDIA_ENDED, this.onMediaEnded, this); - hls.on(Events.ERROR, this.onError, this); - hls.on(Events.DESTROYING, this.onDestroying, this); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (hls) { + hls.on(Events.MEDIA_ATTACHING, this.onMediaAttaching, this); + hls.on(Events.MEDIA_ATTACHED, this.onMediaAttached, this); + hls.on(Events.MEDIA_DETACHING, this.onMediaDetaching, this); + hls.on(Events.MANIFEST_LOADING, this.onManifestLoading, this); + hls.on(Events.LEVEL_UPDATED, this.onLevelUpdated, this); + hls.on(Events.AUDIO_TRACK_SWITCHING, this.onAudioTrackSwitching, this); + hls.on(Events.AUDIO_TRACK_UPDATED, this.onAudioTrackUpdated, this); + hls.on(Events.SUBTITLE_TRACK_SWITCH, this.onSubtitleTrackSwitch, this); + hls.on(Events.SUBTITLE_TRACK_UPDATED, this.onSubtitleTrackUpdated, this); + hls.on(Events.EVENT_CUE_ENTER, this.onInterstitialCueEnter, this); + hls.on(Events.ASSET_LIST_LOADED, this.onAssetListLoaded, this); + hls.on(Events.BUFFER_APPENDED, this.onBufferAppended, this); + hls.on(Events.BUFFER_FLUSHED, this.onBufferFlushed, this); + hls.on(Events.BUFFERED_TO_END, this.onBufferedToEnd, this); + hls.on(Events.MEDIA_ENDED, this.onMediaEnded, this); + hls.on(Events.ERROR, this.onError, this); + hls.on(Events.DESTROYING, this.onDestroying, this); + } } private unregisterListeners() { const hls = this.hls; - if (!hls) { - return; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (hls) { + hls.off(Events.MEDIA_ATTACHING, this.onMediaAttaching, this); + hls.off(Events.MEDIA_ATTACHED, this.onMediaAttached, this); + hls.off(Events.MEDIA_DETACHING, this.onMediaDetaching, this); + hls.off(Events.MANIFEST_LOADING, this.onManifestLoading, this); + hls.off(Events.LEVEL_UPDATED, this.onLevelUpdated, this); + hls.off(Events.AUDIO_TRACK_SWITCHING, this.onAudioTrackSwitching, this); + hls.off(Events.AUDIO_TRACK_UPDATED, this.onAudioTrackUpdated, this); + hls.off(Events.SUBTITLE_TRACK_SWITCH, this.onSubtitleTrackSwitch, this); + hls.off(Events.SUBTITLE_TRACK_UPDATED, this.onSubtitleTrackUpdated, this); + hls.off(Events.EVENT_CUE_ENTER, this.onInterstitialCueEnter, this); + hls.off(Events.ASSET_LIST_LOADED, this.onAssetListLoaded, this); + hls.off(Events.BUFFER_CODECS, this.onBufferCodecs, this); + hls.off(Events.BUFFER_APPENDED, this.onBufferAppended, this); + hls.off(Events.BUFFER_FLUSHED, this.onBufferFlushed, this); + hls.off(Events.BUFFERED_TO_END, this.onBufferedToEnd, this); + hls.off(Events.MEDIA_ENDED, this.onMediaEnded, this); + hls.off(Events.ERROR, this.onError, this); + hls.off(Events.DESTROYING, this.onDestroying, this); } - hls.off(Events.MEDIA_ATTACHING, this.onMediaAttaching, this); - hls.off(Events.MEDIA_ATTACHED, this.onMediaAttached, this); - hls.off(Events.MEDIA_DETACHING, this.onMediaDetaching, this); - hls.off(Events.MANIFEST_LOADING, this.onManifestLoading, this); - hls.off(Events.LEVEL_UPDATED, this.onLevelUpdated, this); - hls.off(Events.AUDIO_TRACK_SWITCHING, this.onAudioTrackSwitching, this); - hls.off(Events.AUDIO_TRACK_UPDATED, this.onAudioTrackUpdated, this); - hls.off(Events.SUBTITLE_TRACK_SWITCH, this.onSubtitleTrackSwitch, this); - hls.off(Events.SUBTITLE_TRACK_UPDATED, this.onSubtitleTrackUpdated, this); - hls.off(Events.EVENT_CUE_ENTER, this.onInterstitialCueEnter, this); - hls.off(Events.ASSET_LIST_LOADED, this.onAssetListLoaded, this); - hls.off(Events.BUFFER_CODECS, this.onBufferCodecs, this); - hls.off(Events.BUFFER_APPENDED, this.onBufferAppended, this); - hls.off(Events.BUFFER_FLUSHED, this.onBufferFlushed, this); - hls.off(Events.BUFFERED_TO_END, this.onBufferedToEnd, this); - hls.off(Events.MEDIA_ENDED, this.onMediaEnded, this); - hls.off(Events.ERROR, this.onError, this); - hls.off(Events.DESTROYING, this.onDestroying, this); } startLoad() { @@ -208,6 +215,7 @@ export default class InterstitialsController destroy() { this.unregisterListeners(); this.stopLoad(); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (this.assetListLoader) { this.assetListLoader.destroy(); } @@ -221,10 +229,11 @@ export default class InterstitialsController this.mediaSelection = this.requiredTracks = this.altSelection = + this.schedule = this.manager = null; // @ts-ignore - this.hls = this.HlsPlayerClass = this.schedule = this.log = null; + this.hls = this.HlsPlayerClass = this.log = null; // @ts-ignore this.assetListLoader = null; // @ts-ignore @@ -276,6 +285,7 @@ export default class InterstitialsController } private clearScheduleState() { + this.log(`clear schedule state`); this.playingItem = this.bufferingItem = this.waitingItem = @@ -303,6 +313,7 @@ export default class InterstitialsController if (this.detachedData) { const player = this.getBufferingPlayer(); if (player) { + this.log(`Removing schedule state for detachedData and ${player}`); this.playingAsset = this.endedAsset = this.bufferingAsset = @@ -317,345 +328,366 @@ export default class InterstitialsController } public get interstitialsManager(): InterstitialsManager | null { - if (!this.manager) { - if (!this.hls) { - return null; - } - const c = this; - const effectiveBufferingItem = () => c.bufferingItem || c.waitingItem; - const getAssetPlayer = (asset: InterstitialAssetItem | null) => - asset ? c.getAssetPlayer(asset.identifier) : asset; - const getMappedTime = ( - item: InterstitialScheduleItem | null, - timelineType: TimelineType, - asset: InterstitialAssetItem | null, - controllerField: 'bufferedPos' | 'timelinePos', - assetPlayerField: 'bufferedEnd' | 'currentTime', - ) => { - if (item) { - let time = item[timelineType].start; - const interstitial = item.event; - if (interstitial) { - if ( - timelineType === 'playout' || - interstitial.timelineOccupancy !== TimelineOccupancy.Point - ) { - const assetPlayer = getAssetPlayer(asset); - if (assetPlayer?.interstitial === interstitial) { - time += - assetPlayer.assetItem.startOffset + - assetPlayer[assetPlayerField]; - } - } - } else { - const value = - controllerField === 'bufferedPos' - ? getBufferedEnd() - : c[controllerField]; - time += value - item.start; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (!this.hls) { + return null; + } + if (this.manager) { + return this.manager; + } + const c = this; + const effectiveBufferingItem = () => c.bufferingItem || c.waitingItem; + const getAssetPlayer = (asset: InterstitialAssetItem | null) => + asset ? c.getAssetPlayer(asset.identifier) : asset; + const getMappedTime = ( + item: InterstitialScheduleItem | null, + timelineType: TimelineType, + asset: InterstitialAssetItem | null, + controllerField: 'bufferedPos' | 'timelinePos', + assetPlayerField: 'bufferedEnd' | 'currentTime', + ): number => { + if (item) { + let time = ( + item[timelineType] as { + start: number; + end: number; } - return time; - } - return 0; - }; - const findMappedTime = ( - primaryTime: number, - timelineType: TimelineType, - ): number => { - if ( - primaryTime !== 0 && - timelineType !== 'primary' && - c.schedule.length - ) { - const index = c.schedule.findItemIndexAtTime(primaryTime); - const item = c.schedule.items?.[index]; - if (item) { - const diff = item[timelineType].start - item.start; - return primaryTime + diff; + ).start; + const interstitial = item.event; + if (interstitial) { + if ( + timelineType === 'playout' || + interstitial.timelineOccupancy !== TimelineOccupancy.Point + ) { + const assetPlayer = getAssetPlayer(asset); + if (assetPlayer?.interstitial === interstitial) { + time += + assetPlayer.assetItem.startOffset + + assetPlayer[assetPlayerField]; + } } + } else { + const value = + controllerField === 'bufferedPos' + ? getBufferedEnd() + : c[controllerField]; + time += value - item.start; } - return primaryTime; - }; - const getBufferedEnd = (): number => { - const value = c.bufferedPos; - if (value === Number.MAX_VALUE) { - return getMappedDuration('primary'); + return time; + } + return 0; + }; + const findMappedTime = ( + primaryTime: number, + timelineType: TimelineType, + ): number => { + if ( + primaryTime !== 0 && + timelineType !== 'primary' && + c.schedule?.length + ) { + const index = c.schedule.findItemIndexAtTime(primaryTime); + const item = c.schedule.items?.[index]; + if (item) { + const diff = item[timelineType].start - item.start; + return primaryTime + diff; } - return Math.max(value, 0); - }; - const getMappedDuration = (timelineType: TimelineType): number => { - if (c.primaryDetails?.live) { - // return end of last event item or playlist - return c.primaryDetails.edge; + } + return primaryTime; + }; + const getBufferedEnd = (): number => { + const value = c.bufferedPos; + if (value === Number.MAX_VALUE) { + return getMappedDuration('primary'); + } + return Math.max(value, 0); + }; + const getMappedDuration = (timelineType: TimelineType): number => { + if (c.primaryDetails?.live) { + // return end of last event item or playlist + return c.primaryDetails.edge; + } + return c.schedule?.durations[timelineType] || 0; + }; + const seekTo = (time: number, timelineType: TimelineType) => { + const item = c.effectivePlayingItem; + if (item?.event?.restrictions.skip || !c.schedule) { + return; + } + c.log(`seek to ${time} "${timelineType}"`); + const playingItem = c.effectivePlayingItem; + const targetIndex = c.schedule.findItemIndexAtTime(time, timelineType); + const targetItem = c.schedule.items?.[targetIndex]; + const bufferingPlayer = c.getBufferingPlayer(); + const bufferingInterstitial = bufferingPlayer?.interstitial; + const appendInPlace = bufferingInterstitial?.appendInPlace; + const seekInItem = playingItem && c.itemsMatch(playingItem, targetItem); + if (playingItem && (appendInPlace || seekInItem)) { + // seek in asset player or primary media (appendInPlace) + const assetPlayer = getAssetPlayer(c.playingAsset); + const media = assetPlayer?.media || c.primaryMedia; + if (media) { + const currentTime = + timelineType === 'primary' + ? media.currentTime + : getMappedTime( + playingItem, + timelineType, + c.playingAsset, + 'timelinePos', + 'currentTime', + ); + + const diff = time - currentTime; + const seekToTime = + (appendInPlace ? currentTime : media.currentTime) + diff; + if ( + seekToTime >= 0 && + (!assetPlayer || + appendInPlace || + seekToTime <= assetPlayer.duration) + ) { + media.currentTime = seekToTime; + return; + } } - return c.schedule.durations[timelineType]; - }; - const seekTo = (time: number, timelineType: TimelineType) => { - const item = c.effectivePlayingItem; - if (item?.event?.restrictions.skip) { - return; + } + // seek out of item or asset + if (targetItem) { + let seekToTime = time; + if (timelineType !== 'primary') { + const primarySegmentStart = targetItem[timelineType].start; + const diff = time - primarySegmentStart; + seekToTime = targetItem.start + diff; } - c.log(`seek to ${time} "${timelineType}"`); - const playingItem = c.effectivePlayingItem; - const targetIndex = c.schedule.findItemIndexAtTime(time, timelineType); - const targetItem = c.schedule.items?.[targetIndex]; - const bufferingPlayer = c.getBufferingPlayer(); - const bufferingInterstitial = bufferingPlayer?.interstitial; - const appendInPlace = bufferingInterstitial?.appendInPlace; - const seekInItem = playingItem && c.itemsMatch(playingItem, targetItem); - if (playingItem && (appendInPlace || seekInItem)) { - // seek in asset player or primary media (appendInPlace) - const assetPlayer = getAssetPlayer(c.playingAsset); - const media = assetPlayer?.media || c.primaryMedia; + const targetIsPrimary = !c.isInterstitial(targetItem); + if ( + (!c.isInterstitial(playingItem) || playingItem.event.appendInPlace) && + (targetIsPrimary || targetItem.event.appendInPlace) + ) { + const media = + c.media || (appendInPlace ? bufferingPlayer?.media : null); if (media) { - const currentTime = - timelineType === 'primary' - ? media.currentTime - : getMappedTime( - playingItem, - timelineType, - c.playingAsset, - 'timelinePos', - 'currentTime', - ); - - const diff = time - currentTime; - const seekToTime = - (appendInPlace ? currentTime : media.currentTime) + diff; - if ( - seekToTime >= 0 && - (!assetPlayer || - appendInPlace || - seekToTime <= assetPlayer.duration) - ) { - media.currentTime = seekToTime; + media.currentTime = seekToTime; + } + } else if (playingItem) { + // check if an Interstitial between the current item and target item has an X-RESTRICT JUMP restriction + const playingIndex = c.findItemIndex(playingItem); + if (targetIndex > playingIndex) { + const jumpIndex = c.schedule.findJumpRestrictedIndex( + playingIndex + 1, + targetIndex, + ); + if (jumpIndex > playingIndex) { + c.setSchedulePosition(jumpIndex); return; } } - } - // seek out of item or asset - if (targetItem) { - let seekToTime = time; - if (timelineType !== 'primary') { - const primarySegmentStart = targetItem[timelineType].start; - const diff = time - primarySegmentStart; - seekToTime = targetItem.start + diff; - } - const targetIsPrimary = !c.isInterstitial(targetItem); - if ( - (!c.isInterstitial(playingItem) || - playingItem.event.appendInPlace) && - (targetIsPrimary || targetItem.event.appendInPlace) - ) { - const media = - c.media || (appendInPlace ? bufferingPlayer?.media : null); - if (media) { - media.currentTime = seekToTime; - } - } else if (playingItem) { - // check if an Interstitial between the current item and target item has an X-RESTRICT JUMP restriction - const playingIndex = c.findItemIndex(playingItem); - if (targetIndex > playingIndex) { - const jumpIndex = c.schedule.findJumpRestrictedIndex( - playingIndex + 1, - targetIndex, - ); - if (jumpIndex > playingIndex) { - c.setSchedulePosition(jumpIndex); - return; - } - } - let assetIndex = 0; - if (targetIsPrimary) { - c.timelinePos = seekToTime; - c.checkBuffer(); - } else { - const assetList = targetItem?.event?.assetList; - if (assetList) { - const eventTime = - time - (targetItem[timelineType] || targetItem).start; - for (let i = assetList.length; i--; ) { - const asset = assetList[i]; - if ( - asset.duration && - eventTime >= asset.startOffset && - eventTime < asset.startOffset + asset.duration - ) { - assetIndex = i; - break; - } - } + let assetIndex = 0; + if (targetIsPrimary) { + c.timelinePos = seekToTime; + c.checkBuffer(); + } else { + const assetList = targetItem.event.assetList; + const eventTime = + time - (targetItem[timelineType] || targetItem).start; + for (let i = assetList.length; i--; ) { + const asset = assetList[i]; + if ( + asset.duration && + eventTime >= asset.startOffset && + eventTime < asset.startOffset + asset.duration + ) { + assetIndex = i; + break; } } - c.setSchedulePosition(targetIndex, assetIndex); } + c.setSchedulePosition(targetIndex, assetIndex); } - }; - const getActiveInterstitial = () => { + } + }; + const getActiveInterstitial = () => { + const playingItem = c.effectivePlayingItem; + if (c.isInterstitial(playingItem)) { + return playingItem; + } + const bufferingItem = effectiveBufferingItem(); + if (c.isInterstitial(bufferingItem)) { + return bufferingItem; + } + return null; + }; + const interstitialPlayer: InterstitialPlayer = { + get bufferedEnd() { + const interstitialItem = effectiveBufferingItem(); + const bufferingItem = c.bufferingItem; + if (bufferingItem && bufferingItem === interstitialItem) { + return ( + getMappedTime( + bufferingItem, + 'playout', + c.bufferingAsset, + 'bufferedPos', + 'bufferedEnd', + ) - bufferingItem.playout.start || + c.bufferingAsset?.startOffset || + 0 + ); + } + return 0; + }, + get currentTime() { + const interstitialItem = getActiveInterstitial(); + const playingItem = c.effectivePlayingItem; + if (playingItem && playingItem === interstitialItem) { + return ( + getMappedTime( + playingItem, + 'playout', + c.effectivePlayingAsset, + 'timelinePos', + 'currentTime', + ) - playingItem.playout.start + ); + } + return 0; + }, + set currentTime(time: number) { + const interstitialItem = getActiveInterstitial(); const playingItem = c.effectivePlayingItem; - if (c.isInterstitial(playingItem)) { - return playingItem; + if (playingItem && playingItem === interstitialItem) { + seekTo(time + playingItem.playout.start, 'playout'); } - const bufferingItem = effectiveBufferingItem(); - if (c.isInterstitial(bufferingItem)) { - return bufferingItem; + }, + get duration() { + const interstitialItem = getActiveInterstitial(); + if (interstitialItem) { + return interstitialItem.playout.end - interstitialItem.playout.start; + } + return 0; + }, + get assetPlayers() { + const assetList = getActiveInterstitial()?.event.assetList; + if (assetList) { + return assetList.map((asset) => c.getAssetPlayer(asset.identifier)); + } + return []; + }, + get playingIndex() { + const interstitial = getActiveInterstitial()?.event; + if (interstitial && c.effectivePlayingAsset) { + return interstitial.findAssetIndex(c.effectivePlayingAsset); + } + return -1; + }, + get scheduleItem() { + return getActiveInterstitial(); + }, + }; + return (this.manager = { + get events() { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + return c.schedule?.events?.slice(0) || []; + }, + get schedule() { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + return c.schedule?.items?.slice(0) || []; + }, + get interstitialPlayer() { + if (getActiveInterstitial()) { + return interstitialPlayer; } return null; - }; - const interstitialPlayer: InterstitialPlayer = { + }, + get playerQueue() { + return c.playerQueue.slice(0); + }, + get bufferingAsset() { + return c.bufferingAsset; + }, + get bufferingItem() { + return effectiveBufferingItem(); + }, + get bufferingIndex() { + const item = effectiveBufferingItem(); + return c.findItemIndex(item); + }, + get playingAsset() { + return c.effectivePlayingAsset; + }, + get playingItem() { + return c.effectivePlayingItem; + }, + get playingIndex() { + const item = c.effectivePlayingItem; + return c.findItemIndex(item); + }, + primary: { + get bufferedEnd() { + return getBufferedEnd(); + }, get currentTime() { - const interstitialItem = getActiveInterstitial(); - const playingItem = c.effectivePlayingItem; - if (playingItem && playingItem === interstitialItem) { - return ( - getMappedTime( - playingItem, - 'playout', - c.effectivePlayingAsset, - 'timelinePos', - 'currentTime', - ) - playingItem.playout.start - ); - } - return 0; + const timelinePos = c.timelinePos; + return timelinePos > 0 ? timelinePos : 0; }, set currentTime(time: number) { - const interstitialItem = getActiveInterstitial(); - const playingItem = c.effectivePlayingItem; - if (playingItem && playingItem === interstitialItem) { - seekTo(time + playingItem.playout.start, 'playout'); - } + seekTo(time, 'primary'); }, get duration() { - const interstitialItem = getActiveInterstitial(); - if (interstitialItem) { - return ( - interstitialItem.playout.end - interstitialItem.playout.start - ); - } - return 0; - }, - get assetPlayers() { - const assetList = getActiveInterstitial()?.event.assetList; - if (assetList) { - return assetList.map((asset) => c.getAssetPlayer(asset.identifier)); - } - return []; - }, - get playingIndex() { - const interstitial = getActiveInterstitial()?.event; - if (interstitial && c.effectivePlayingAsset) { - return interstitial.findAssetIndex(c.effectivePlayingAsset); - } - return -1; - }, - get scheduleItem() { - return getActiveInterstitial(); - }, - }; - this.manager = { - get events() { - return c.schedule?.events?.slice(0) || []; - }, - get schedule() { - return c.schedule?.items?.slice(0) || []; - }, - get interstitialPlayer() { - if (getActiveInterstitial()) { - return interstitialPlayer; - } - return null; - }, - get playerQueue() { - return c.playerQueue.slice(0); - }, - get bufferingAsset() { - return c.bufferingAsset; - }, - get bufferingItem() { - return effectiveBufferingItem(); + return getMappedDuration('primary'); }, - get bufferingIndex() { - const item = effectiveBufferingItem(); - return c.findItemIndex(item); + get seekableStart() { + return c.primaryDetails?.fragmentStart || 0; }, - get playingAsset() { - return c.effectivePlayingAsset; + }, + integrated: { + get bufferedEnd() { + return getMappedTime( + effectiveBufferingItem(), + 'integrated', + c.bufferingAsset, + 'bufferedPos', + 'bufferedEnd', + ); }, - get playingItem() { - return c.effectivePlayingItem; + get currentTime() { + return getMappedTime( + c.effectivePlayingItem, + 'integrated', + c.effectivePlayingAsset, + 'timelinePos', + 'currentTime', + ); }, - get playingIndex() { - const item = c.effectivePlayingItem; - return c.findItemIndex(item); + set currentTime(time: number) { + seekTo(time, 'integrated'); }, - primary: { - get bufferedEnd() { - return getBufferedEnd(); - }, - get currentTime() { - const timelinePos = c.timelinePos; - return timelinePos > 0 ? timelinePos : 0; - }, - set currentTime(time: number) { - seekTo(time, 'primary'); - }, - get duration() { - return getMappedDuration('primary'); - }, - get seekableStart() { - return c.primaryDetails?.fragmentStart || 0; - }, + get duration() { + return getMappedDuration('integrated'); }, - integrated: { - get bufferedEnd() { - return getMappedTime( - effectiveBufferingItem(), - 'integrated', - c.bufferingAsset, - 'bufferedPos', - 'bufferedEnd', - ); - }, - get currentTime() { - return getMappedTime( - c.effectivePlayingItem, - 'integrated', - c.effectivePlayingAsset, - 'timelinePos', - 'currentTime', - ); - }, - set currentTime(time: number) { - seekTo(time, 'integrated'); - }, - get duration() { - return getMappedDuration('integrated'); - }, - get seekableStart() { - return findMappedTime( - c.primaryDetails?.fragmentStart || 0, - 'integrated', - ); - }, + get seekableStart() { + return findMappedTime( + c.primaryDetails?.fragmentStart || 0, + 'integrated', + ); }, - skip: () => { - const item = c.effectivePlayingItem; - const event = item?.event; - if (event && !event.restrictions.skip) { - const index = c.findItemIndex(item); - if (event.appendInPlace) { - const time = item.playout.start + item.event.duration; - seekTo(time + 0.001, 'playout'); - } else { - c.advanceAfterAssetEnded(event, index, Infinity); - } + }, + skip: () => { + const item = c.effectivePlayingItem; + const event = item?.event; + if (event && !event.restrictions.skip) { + const index = c.findItemIndex(item); + if (event.appendInPlace) { + const time = item.playout.start + item.event.duration; + seekTo(time + 0.001, 'playout'); + } else { + c.advanceAfterAssetEnded(event, index, Infinity); } - }, - }; - } - return this.manager; + } + }, + }); } // Schedule getters @@ -805,8 +837,9 @@ export default class InterstitialsController ) { const interstitial = queuedPlayer.interstitial; this.clearInterstitial(queuedPlayer.interstitial, null); - interstitial.appendInPlace = false; - if (interstitial.appendInPlace) { + interstitial.appendInPlace = false; // setter may be a no-op; + // `appendInPlace` getter may still return `true` after insterstitial streaming has begun in that mode. + if (interstitial.appendInPlace as boolean) { this.warn( `Could not change append strategy for queued assets ${interstitial}`, ); @@ -827,15 +860,16 @@ export default class InterstitialsController this.log( `${transferring ? 'transfering MediaSource' : 'attaching media'} to ${ isAssetPlayer ? player : 'Primary' - } from ${logFromSource}`, + } from ${logFromSource} (media.currentTime: ${media.currentTime})`, ); - if (dataToAttach === attachMediaSourceData) { + const schedule = this.schedule; + if (dataToAttach === attachMediaSourceData && schedule) { const isAssetAtEndOfSchedule = isAssetPlayer && - (player as HlsAssetPlayer).assetId === this.schedule.assetIdAtEnd; + (player as HlsAssetPlayer).assetId === schedule.assetIdAtEnd; // Prevent asset players from marking EoS on transferred MediaSource dataToAttach.overrides = { - duration: this.schedule.duration, + duration: schedule.duration, endOfStream: !isAssetPlayer || isAssetAtEndOfSchedule, cueRemoval: !isAssetPlayer, }; @@ -853,7 +887,7 @@ export default class InterstitialsController private onSeeking = () => { const currentTime = this.currentTime; - if (currentTime === undefined || this.playbackDisabled) { + if (currentTime === undefined || this.playbackDisabled || !this.schedule) { return; } const diff = currentTime - this.timelinePos; @@ -877,7 +911,7 @@ export default class InterstitialsController currentTime - diff, ); if (resetCount) { - this.updateSchedule(); + this.updateSchedule(true); } } this.checkBuffer(); @@ -885,13 +919,19 @@ export default class InterstitialsController (backwardSeek && currentTime < playingItem.start) || currentTime >= playingItem.end ) { - const scheduleIndex = this.schedule.findItemIndexAtTime(this.timelinePos); + const playingIndex = this.findItemIndex(playingItem); + let scheduleIndex = this.schedule.findItemIndexAtTime(currentTime); + if (scheduleIndex === -1) { + scheduleIndex = playingIndex + (backwardSeek ? -1 : 1); + this.log( + `seeked ${backwardSeek ? 'back ' : ''}to position not covered by schedule ${currentTime} (resolving from ${playingIndex} to ${scheduleIndex})`, + ); + } if (!this.isInterstitial(playingItem) && this.media?.paused) { this.shouldPlay = false; } if (!backwardSeek) { // check if an Interstitial between the current item and target item has an X-RESTRICT JUMP restriction - const playingIndex = this.findItemIndex(playingItem); if (scheduleIndex > playingIndex) { const jumpIndex = this.schedule.findJumpRestrictedIndex( playingIndex + 1, @@ -912,6 +952,7 @@ export default class InterstitialsController // restart Interstitial at end if (this.playingLastItem && this.isInterstitial(playingItem)) { const restartAsset = playingItem.event.assetList[0]; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (restartAsset) { this.endedItem = this.playingItem; this.playingItem = null; @@ -926,6 +967,10 @@ export default class InterstitialsController (backwardSeek && currentTime < start) || currentTime >= start + duration ) { + if (playingItem.event?.appendInPlace) { + this.clearInterstitial(playingItem.event, playingItem); + this.flushFrontBuffer(currentTime); + } this.setScheduleToAssetAtTime(currentTime, playingAsset); } }; @@ -975,7 +1020,7 @@ export default class InterstitialsController // Scheduling methods private checkStart() { const schedule = this.schedule; - const interstitialEvents = schedule.events; + const interstitialEvents = schedule?.events; if (!interstitialEvents || this.playbackDisabled || !this.media) { return; } @@ -988,6 +1033,7 @@ export default class InterstitialsController const effectivePlayingItem = this.effectivePlayingItem; if (timelinePos === -1) { const startPosition = this.hls.startPosition; + this.log(timelineMessage('checkStart', startPosition)); this.timelinePos = startPosition; if (interstitialEvents.length && interstitialEvents[0].cue.pre) { const index = schedule.findEventIndex(interstitialEvents[0].identifier); @@ -1004,6 +1050,23 @@ export default class InterstitialsController } } + private advanceAssetBuffering( + item: InterstitialScheduleEventItem, + assetItem: InterstitialAssetItem, + ) { + const interstitial = item.event; + const assetListIndex = interstitial.findAssetIndex(assetItem); + const nextAssetIndex = getNextAssetIndex(interstitial, assetListIndex); + if (!interstitial.isAssetPastPlayoutLimit(nextAssetIndex)) { + this.bufferedToEvent(item, nextAssetIndex); + } else if (this.schedule) { + const nextItem = this.schedule.items?.[this.findItemIndex(item) + 1]; + if (nextItem) { + this.bufferedToItem(nextItem); + } + } + } + private advanceAfterAssetEnded( interstitial: InterstitialEvent, index: number, @@ -1012,8 +1075,16 @@ export default class InterstitialsController const nextAssetIndex = getNextAssetIndex(interstitial, assetListIndex); if (!interstitial.isAssetPastPlayoutLimit(nextAssetIndex)) { // Advance to next asset list item + if (interstitial.appendInPlace) { + const assetItem = interstitial.assetList[nextAssetIndex] as + | InterstitialAssetItem + | undefined; + if (assetItem) { + this.advanceInPlace(assetItem.timelineStart); + } + } this.setSchedulePosition(index, nextAssetIndex); - } else { + } else if (this.schedule) { // Advance to next schedule segment // check if we've reached the end of the program const scheduleItems = this.schedule.items; @@ -1026,8 +1097,12 @@ export default class InterstitialsController } const resumptionTime = interstitial.resumeTime; if (this.timelinePos < resumptionTime) { + this.log(timelineMessage('advanceAfterAssetEnded', resumptionTime)); this.timelinePos = resumptionTime; - this.checkBuffer(); + if (interstitial.appendInPlace) { + this.advanceInPlace(resumptionTime); + } + this.checkBuffer(this.bufferedPos < resumptionTime); } this.setSchedulePosition(nextIndex); } @@ -1039,6 +1114,9 @@ export default class InterstitialsController playingAsset: InterstitialAssetItem, ) { const schedule = this.schedule; + if (!schedule) { + return; + } const parentIdentifier = playingAsset.parentIdentifier; const interstitial = schedule.getEvent(parentIdentifier); if (interstitial) { @@ -1049,14 +1127,16 @@ export default class InterstitialsController } private setSchedulePosition(index: number, assetListIndex?: number) { - const scheduleItems = this.schedule.items; + const scheduleItems = this.schedule?.items; if (!scheduleItems || this.playbackDisabled) { return; } - this.log(`setSchedulePosition ${index}, ${assetListIndex}`); const scheduledItem = index >= 0 ? scheduleItems[index] : null; + this.log( + `setSchedulePosition ${index}, ${assetListIndex} (${scheduledItem ? segmentToString(scheduledItem) : scheduledItem}) pos: ${this.timelinePos}`, + ); // Cleanup current item / asset - const currentItem = this.playingItem; + const currentItem = this.waitingItem || this.playingItem; const playingLastItem = this.playingLastItem; if (this.isInterstitial(currentItem)) { const interstitial = currentItem.event; @@ -1068,7 +1148,7 @@ export default class InterstitialsController assetId && (!this.eventItemsMatch(currentItem, scheduledItem) || (assetListIndex !== undefined && - assetId !== interstitial.assetList?.[assetListIndex].identifier)) + assetId !== interstitial.assetList[assetListIndex].identifier)) ) { const playingAssetListIndex = interstitial.findAssetIndex(playingAsset); this.log( @@ -1088,7 +1168,8 @@ export default class InterstitialsController // Schedule change occured on INTERSTITIAL_ASSET_ENDED if ( this.itemsMatch(currentItem, this.playingItem) && - !this.playingAsset + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + !this.playingAsset // INTERSTITIAL_ASSET_ENDED side-effect ) { this.advanceAfterAssetEnded( interstitial, @@ -1120,12 +1201,12 @@ export default class InterstitialsController if (interstitial.cue.once) { // Remove interstitial with CUE attribute value of ONCE after it has played this.updateSchedule(); - const items = this.schedule.items; - if (scheduledItem && items) { + const updatedScheduleItems = this.schedule?.items; + if (scheduledItem && updatedScheduleItems) { const updatedIndex = this.findItemIndex(scheduledItem); this.advanceSchedule( updatedIndex, - items, + updatedScheduleItems, assetListIndex, currentItem, playingLastItem, @@ -1150,16 +1231,18 @@ export default class InterstitialsController currentItem: InterstitialScheduleItem | null, playedLastItem: boolean, ) { - const scheduledItem = index >= 0 ? scheduleItems[index] : null; + const schedule = this.schedule; + if (!schedule) { + return; + } + const scheduledItem = scheduleItems[index] || null; const media = this.primaryMedia; // Cleanup out of range Interstitials const playerQueue = this.playerQueue; if (playerQueue.length) { playerQueue.forEach((player) => { const interstitial = player.interstitial; - const queuedIndex = this.schedule.findEventIndex( - interstitial.identifier, - ); + const queuedIndex = schedule.findEventIndex(interstitial.identifier); if (queuedIndex < index || queuedIndex > index + 1) { this.clearInterstitial(interstitial, scheduledItem); } @@ -1175,7 +1258,7 @@ export default class InterstitialsController const interstitial = scheduledItem.event; // find asset index if (assetListIndex === undefined) { - assetListIndex = this.schedule.findAssetIndex( + assetListIndex = schedule.findAssetIndex( interstitial, this.timelinePos, ); @@ -1183,7 +1266,10 @@ export default class InterstitialsController interstitial, assetListIndex - 1, ); - if (interstitial.isAssetPastPlayoutLimit(assetIndexCandidate)) { + if ( + interstitial.isAssetPastPlayoutLimit(assetIndexCandidate) || + (interstitial.appendInPlace && this.timelinePos === scheduledItem.end) + ) { this.advanceAfterAssetEnded(interstitial, index, assetListIndex); return; } @@ -1227,18 +1313,10 @@ export default class InterstitialsController this.playingItem = scheduledItem; // If asset-list is empty or missing asset index, advance to next item - const assetItem = interstitial.assetList[assetListIndex]; + const assetItem = interstitial.assetList[assetListIndex] as + | InterstitialAssetItem + | undefined; if (!assetItem) { - const nextItem = scheduleItems[index + 1]; - const media = this.media; - if ( - nextItem && - media && - !this.isInterstitial(nextItem) && - media.currentTime < nextItem.start - ) { - media.currentTime = this.timelinePos = nextItem.start; - } this.advanceAfterAssetEnded(interstitial, index, assetListIndex || 0); return; } @@ -1259,6 +1337,7 @@ export default class InterstitialsController assetItem, assetListIndex, ); + player.loadSource(); } if (!this.eventItemsMatch(scheduledItem, this.bufferingItem)) { if (interstitial.appendInPlace && this.isAssetBuffered(assetItem)) { @@ -1275,7 +1354,7 @@ export default class InterstitialsController if (this.shouldPlay) { playWithCatch(player.media); } - } else if (scheduledItem !== null) { + } else if (scheduledItem) { this.resumePrimary(scheduledItem, index, currentItem); if (this.shouldPlay) { playWithCatch(this.hls.media); @@ -1287,7 +1366,7 @@ export default class InterstitialsController this.playingItem = currentItem; if (!currentItem.event.appendInPlace) { // Media must be re-attached to resume primary schedule if not sharing source - this.attachPrimary(this.schedule.durations.primary, null); + this.attachPrimary(schedule.durations.primary, null); } } } @@ -1297,7 +1376,7 @@ export default class InterstitialsController } private get primaryDetails(): LevelDetails | undefined { - return this.mediaSelection?.main?.details; + return this.mediaSelection?.main.details; } private get primaryLive(): boolean { @@ -1324,6 +1403,7 @@ export default class InterstitialsController timelinePos >= scheduledItem.end ) { timelinePos = this.getPrimaryResumption(scheduledItem, index); + this.log(timelineMessage('resumePrimary', timelinePos)); this.timelinePos = timelinePos; } this.attachPrimary(timelinePos, scheduledItem); @@ -1333,7 +1413,7 @@ export default class InterstitialsController return; } - const scheduleItems = this.schedule.items; + const scheduleItems = this.schedule?.items; if (!scheduleItems) { return; } @@ -1404,6 +1484,7 @@ export default class InterstitialsController } if (!skipSeekToStartPosition) { // Set primary position to resume time + this.log(timelineMessage('attachPrimary', timelinePos)); this.timelinePos = timelinePos; this.startLoadingPrimaryAt(timelinePos, skipSeekToStartPosition); } @@ -1431,7 +1512,7 @@ export default class InterstitialsController // HLS.js event callbacks private onManifestLoading() { this.stopLoad(); - this.schedule.reset(); + this.schedule?.reset(); this.emptyPlayerQueue(); this.clearScheduleState(); this.shouldPlay = false; @@ -1447,7 +1528,7 @@ export default class InterstitialsController } private onLevelUpdated(event: Events.LEVEL_UPDATED, data: LevelUpdatedData) { - if (data.level === -1) { + if (data.level === -1 || !this.schedule) { // level was removed return; } @@ -1501,9 +1582,8 @@ export default class InterstitialsController ) { const audioOption = getBasicSelectionOption(data); this.playerQueue.forEach( - (player) => - player.hls.setAudioOption(data) || - player.hls.setAudioOption(audioOption), + ({ hls }) => + hls && (hls.setAudioOption(data) || hls.setAudioOption(audioOption)), ); } @@ -1513,9 +1593,10 @@ export default class InterstitialsController ) { const subtitleOption = getBasicSelectionOption(data); this.playerQueue.forEach( - (player) => - player.hls.setSubtitleOption(data) || - (data.id !== -1 && player.hls.setSubtitleOption(subtitleOption)), + ({ hls }) => + hls && + (hls.setSubtitleOption(data) || + (data.id !== -1 && hls.setSubtitleOption(subtitleOption))), ); } @@ -1550,6 +1631,9 @@ export default class InterstitialsController } private onBufferedToEnd(event: Events.BUFFERED_TO_END) { + if (!this.schedule) { + return; + } // Buffered to post-roll const interstitialEvents = this.schedule.events; if (this.bufferedPos < Number.MAX_VALUE && interstitialEvents) { @@ -1589,6 +1673,9 @@ export default class InterstitialsController previousItems: InterstitialScheduleItem[] | null, ) => { const schedule = this.schedule; + if (!schedule) { + return; + } const playingItem = this.playingItem; const interstitialEvents = schedule.events || []; const scheduleItems = schedule.items || []; @@ -1611,51 +1698,27 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli this.log(`Removed events ${removedIds}`); } - this.playerQueue.forEach((player) => { - if (player.interstitial.appendInPlace) { - const timelineStart = player.assetItem.timelineStart; - const diff = player.timelineOffset - timelineStart; - if (diff) { - try { - player.timelineOffset = timelineStart; - } catch (e) { - if (Math.abs(diff) > ALIGNED_END_THRESHOLD_SECONDS) { - this.warn( - `${e} ("${player.assetId}" ${player.timelineOffset}->${timelineStart})`, - ); - } - } - } - } - }); - // Update schedule item references // Do not replace Interstitial playingItem without a match - used for INTERSTITIAL_ASSET_ENDED and INTERSTITIAL_ENDED - let trimInPlaceForPlayout: null | (() => void) = null; + let updatedPlayingItem: InterstitialScheduleItem | null = null; + let updatedBufferingItem: InterstitialScheduleItem | null = null; if (playingItem) { - const updatedPlayingItem = this.updateItem(playingItem, this.timelinePos); + updatedPlayingItem = this.updateItem(playingItem, this.timelinePos); if (this.itemsMatch(playingItem, updatedPlayingItem)) { this.playingItem = updatedPlayingItem; + } else { this.waitingItem = this.endedItem = null; - trimInPlaceForPlayout = () => - this.trimInPlace(updatedPlayingItem, playingItem); } - } else { - // Clear waitingItem if it has been removed from the schedule - this.waitingItem = this.updateItem(this.waitingItem); - this.endedItem = this.updateItem(this.endedItem); } + // Clear waitingItem if it has been removed from the schedule + this.waitingItem = this.updateItem(this.waitingItem); + this.endedItem = this.updateItem(this.endedItem); // Do not replace Interstitial bufferingItem without a match - used for transfering media element or source const bufferingItem = this.bufferingItem; if (bufferingItem) { - const updatedBufferingItem = this.updateItem( - bufferingItem, - this.bufferedPos, - ); + updatedBufferingItem = this.updateItem(bufferingItem, this.bufferedPos); if (this.itemsMatch(bufferingItem, updatedBufferingItem)) { this.bufferingItem = updatedBufferingItem; - trimInPlaceForPlayout ||= () => - this.trimInPlace(updatedBufferingItem, bufferingItem); } else if (bufferingItem.event) { // Interstitial removed from schedule (Live -> VOD or other scenario where Start Date is outside the range of VOD Playlist) this.bufferingItem = this.playingItem; @@ -1669,6 +1732,24 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli }); }); + this.playerQueue.forEach((player) => { + if (player.interstitial.appendInPlace) { + const timelineStart = player.assetItem.timelineStart; + const diff = player.timelineOffset - timelineStart; + if (diff) { + try { + player.timelineOffset = timelineStart; + } catch (e) { + if (Math.abs(diff) > ALIGNED_END_THRESHOLD_SECONDS) { + this.warn( + `${e} ("${player.assetId}" ${player.timelineOffset}->${timelineStart})`, + ); + } + } + } + } + }); + if (interstitialsUpdated || previousItems) { this.hls.trigger(Events.INTERSTITIALS_UPDATED, { events: interstitialEvents.slice(0), @@ -1688,11 +1769,14 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli return; } - if (trimInPlaceForPlayout) { - trimInPlaceForPlayout(); + if (playingItem) { + this.trimInPlace(updatedPlayingItem, playingItem); + } + if (bufferingItem && updatedBufferingItem !== updatedPlayingItem) { + this.trimInPlace(updatedBufferingItem, bufferingItem); } - // Check is buffered to new Interstitial event boundary + // Check if buffered to new Interstitial event boundary // (Live update publishes Interstitial with new segment) this.checkBuffer(); } @@ -1703,10 +1787,10 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli time?: number, ): T | null { // find item in this.schedule.items; - const items = this.schedule.items; + const items = this.schedule?.items; if (previousItem && items) { const index = this.findItemIndex(previousItem, time); - return (items[index] as T) || null; + return (items[index] as T | undefined) || null; } return null; } @@ -1735,7 +1819,11 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli bufferInfo.end > flushStart || (bufferInfo.nextStart || 0) > flushStart ) { - this.attachPrimary(flushStart, null); + this.log( + `trim buffered interstitial ${segmentToString(updatedItem)} (was ${segmentToString(itemBeforeUpdate)})`, + ); + const skipSeekToStartPosition = true; + this.attachPrimary(flushStart, null, skipSeekToStartPosition); this.flushFrontBuffer(flushStart); } } @@ -1766,20 +1854,20 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli item: InterstitialScheduleItem | null, time?: number, ): number { - return item ? this.schedule.findItemIndex(item, time) : -1; + return item && this.schedule ? this.schedule.findItemIndex(item, time) : -1; } - private updateSchedule() { + private updateSchedule(forceUpdate: boolean = false) { const mediaSelection = this.mediaSelection; if (!mediaSelection) { return; } - this.schedule.updateSchedule(mediaSelection, []); + this.schedule?.updateSchedule(mediaSelection, [], forceUpdate); } // Schedule buffer control private checkBuffer(starved?: boolean) { - const items = this.schedule.items; + const items = this.schedule?.items; if (!items) { return; } @@ -1803,7 +1891,7 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli ) { const schedule = this.schedule; const bufferingItem = this.bufferingItem; - if (this.bufferedPos > bufferEnd) { + if (this.bufferedPos > bufferEnd || !schedule) { return; } if (items.length === 1 && this.itemsMatch(items[0], bufferingItem)) { @@ -1827,12 +1915,22 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli ) { bufferEndIndex = nextToBufferIndex; } - if ( - nextToBufferIndex - playingIndex > 1 && - bufferingItem?.event?.appendInPlace === false - ) { - // do not advance buffering item past Interstitial that requires source reset - return; + if (this.isInterstitial(bufferingItem)) { + const interstitial = bufferingItem.event; + if ( + nextToBufferIndex - playingIndex > 1 && + interstitial.appendInPlace === false + ) { + // do not advance buffering item past Interstitial that requires source reset + return; + } + if ( + interstitial.assetList.length === 0 && + interstitial.assetListLoader + ) { + // do not advance buffering item past Interstitial loading asset-list + return; + } } this.bufferedPos = bufferEnd; if (bufferEndIndex > bufferingIndex && bufferEndIndex > playingIndex) { @@ -1884,7 +1982,7 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli const bufferingLast = this.bufferingItem; const schedule = this.schedule; - if (!this.itemsMatch(item, bufferingLast)) { + if (!this.itemsMatch(item, bufferingLast) && schedule) { const { items, events } = schedule; if (!items || !events) { return bufferingLast; @@ -1907,10 +2005,17 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli ); if (!this.playbackDisabled) { if (isInterstitial) { + const bufferIndex = schedule.findAssetIndex( + item.event, + this.bufferedPos, + ); // primary fragment loading will exit early in base-stream-controller while `bufferingItem` is set to an Interstitial block - item.event.assetList.forEach((asset) => { + item.event.assetList.forEach((asset, i) => { const player = this.getAssetPlayer(asset.identifier); if (player) { + if (i === bufferIndex) { + player.loadSource(); + } player.resumeBuffering(); } }); @@ -1978,10 +2083,8 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli // Buffered to Interstitial boundary const player = this.preloadAssets(interstitial, assetListIndex); if (player?.interstitial.appendInPlace) { - // If we have a player and asset list info, start buffering - const assetItem = interstitial.assetList[assetListIndex]; const media = this.primaryMedia; - if (assetItem && media) { + if (media) { this.bufferAssetPlayer(player, media); } } @@ -2060,9 +2163,15 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli this.createAssetPlayer(interstitial, asset, i); } } - return this.getAssetPlayer( - interstitial.assetList[assetListIndex].identifier, - ); + const asset = interstitial.assetList[assetListIndex]; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (asset) { + const player = this.getAssetPlayer(asset.identifier); + if (player) { + player.loadSource(); + } + return player; + } } return null; } @@ -2141,11 +2250,13 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli let videoPreference = userConfig.videoPreference; const currentLevel = primary.loadLevelObj || primary.levels[primary.currentLevel]; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (videoPreference || currentLevel) { videoPreference = Object.assign({}, videoPreference); if (currentLevel.videoCodec) { videoPreference.videoCodec = currentLevel.videoCodec; } + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (currentLevel.videoRange) { videoPreference.allowedVideoRanges = [currentLevel.videoRange]; } @@ -2165,6 +2276,7 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli const assetId = assetItem.identifier; const playerConfig: HlsAssetPlayerConfig = { ...userConfig, + maxMaxBufferLength: Math.min(180, primary.config.maxMaxBufferLength), autoStartLoad: true, startFragPrefetch: true, primarySessionId: primary.sessionId, @@ -2175,9 +2287,14 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli liveDurationInfinity: false, testBandwidth: false, videoPreference, - audioPreference: selectedAudio || userConfig.audioPreference, - subtitlePreference: selectedSubtitle || userConfig.subtitlePreference, + audioPreference: + (selectedAudio as MediaPlaylist | undefined) || + userConfig.audioPreference, + subtitlePreference: + (selectedSubtitle as MediaPlaylist | undefined) || + userConfig.subtitlePreference, }; + // TODO: limit maxMaxBufferLength in asset players to prevent QEE if (interstitial.appendInPlace) { interstitial.appendInPlaceStarted = true; if (assetItem.timelineStart) { @@ -2204,6 +2321,7 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli this.playerQueue.push(player); interstitial.assetList[assetListIndex] = assetItem; // Listen for LevelDetails and PTS change to update duration + let initialDuration = true; const updateAssetPlayerDetails = (details: LevelDetails) => { if (details.live) { const error = new Error( @@ -2215,10 +2333,12 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli details: ErrorDetails.INTERSTITIAL_ASSET_ITEM_ERROR, error, }; + const scheduleIndex = + this.schedule?.findEventIndex(interstitial.identifier) || -1; this.handleAssetItemError( errorData, interstitial, - this.schedule.findEventIndex(interstitial.identifier), + scheduleIndex, assetListIndex, error.message, ); @@ -2227,7 +2347,12 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli // Get time at end of last fragment const duration = details.edge - details.fragmentStart; const currentAssetDuration = assetItem.duration; - if (currentAssetDuration === null || duration > currentAssetDuration) { + if ( + initialDuration || + currentAssetDuration === null || + duration > currentAssetDuration + ) { + initialDuration = false; this.log( `Interstitial asset "${assetId}" duration change ${currentAssetDuration} > ${duration}`, ); @@ -2242,6 +2367,7 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli player.on(Events.LEVEL_PTS_UPDATED, (event, { details }) => updateAssetPlayerDetails(details), ); + player.on(Events.EVENT_CUE_ENTER, () => this.onInterstitialCueEnter()); const onBufferCodecs = ( event: Events.BUFFER_CODECS, data: BufferCodecsData, @@ -2264,7 +2390,7 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli const bufferedToEnd = () => { const inQueuPlayer = this.getAssetPlayer(assetId); this.log(`buffered to end of asset ${inQueuPlayer}`); - if (!inQueuPlayer) { + if (!inQueuPlayer || !this.schedule) { return; } // Preload at end of asset @@ -2273,23 +2399,14 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli ); const item = this.schedule.items?.[scheduleIndex]; if (this.isInterstitial(item)) { - const assetListIndex = interstitial.findAssetIndex(assetItem); - const nextAssetIndex = getNextAssetIndex(interstitial, assetListIndex); - if (!interstitial.isAssetPastPlayoutLimit(nextAssetIndex)) { - this.bufferedToItem(item, nextAssetIndex); - } else { - const nextItem = this.schedule.items?.[scheduleIndex + 1]; - if (nextItem) { - this.bufferedToItem(nextItem); - } - } + this.advanceAssetBuffering(item, assetItem); } }; player.on(Events.BUFFERED_TO_END, bufferedToEnd); const endedWithAssetIndex = (assetIndex) => { return () => { const inQueuPlayer = this.getAssetPlayer(assetId); - if (!inQueuPlayer) { + if (!inQueuPlayer || !this.schedule) { return; } this.shouldPlay = true; @@ -2302,28 +2419,17 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli player.once(Events.MEDIA_ENDED, endedWithAssetIndex(assetListIndex)); player.once(Events.PLAYOUT_LIMIT_REACHED, endedWithAssetIndex(Infinity)); player.on(Events.ERROR, (event: Events.ERROR, data: ErrorData) => { + if (!this.schedule) { + return; + } const inQueuPlayer = this.getAssetPlayer(assetId); if (data.details === ErrorDetails.BUFFER_STALLED_ERROR) { - if (inQueuPlayer?.media) { - const assetCurrentTime = inQueuPlayer.currentTime; - const distanceFromEnd = inQueuPlayer.duration - assetCurrentTime; - if ( - assetCurrentTime && - interstitial.appendInPlace && - distanceFromEnd / inQueuPlayer.media.playbackRate < 0.5 - ) { - this.log( - `Advancing buffer past end of asset ${assetId} ${interstitial} at ${inQueuPlayer.media.currentTime}`, - ); - bufferedToEnd(); - } else { - this.warn( - `Stalled at ${assetCurrentTime} of ${assetCurrentTime + distanceFromEnd} in asset ${assetId} ${interstitial}`, - ); - this.onTimeupdate(); - this.checkBuffer(true); - } + if (inQueuPlayer?.appendInPlace) { + this.handleInPlaceStall(interstitial); + return; } + this.onTimeupdate(); + this.checkBuffer(true); return; } this.handleAssetItemError( @@ -2336,7 +2442,7 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli }); player.on(Events.DESTROYING, () => { const inQueuPlayer = this.getAssetPlayer(assetId); - if (!inQueuPlayer) { + if (!inQueuPlayer || !this.schedule) { return; } const error = new Error(`Asset player destroyed unexpectedly ${assetId}`); @@ -2394,10 +2500,10 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli ) { const playerIndex = this.getAssetPlayerQueueIndex(assetId); if (playerIndex !== -1) { + const player = this.playerQueue[playerIndex]; this.log( - `clear asset player "${assetId}" toSegment: ${toSegment ? segmentToString(toSegment) : toSegment}`, + `clear ${player} toSegment: ${toSegment ? segmentToString(toSegment) : toSegment}`, ); - const player = this.playerQueue[playerIndex]; this.transferMediaFromPlayer(player, toSegment); this.playerQueue.splice(playerIndex, 1); player.destroy(); @@ -2452,12 +2558,16 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli } private bufferAssetPlayer(player: HlsAssetPlayer, media: HTMLMediaElement) { + if (!this.schedule) { + return; + } const { interstitial, assetItem } = player; const scheduleIndex = this.schedule.findEventIndex(interstitial.identifier); const item = this.schedule.items?.[scheduleIndex]; if (!item) { return; } + player.loadSource(); this.setBufferingItem(item); this.bufferingAsset = assetItem; const bufferingPlayer = this.getBufferingPlayer(); @@ -2479,6 +2589,7 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli if (appendInPlaceNext && assetItem !== this.playingAsset) { // Do not buffer another item if tracks are unknown or incompatible if (!player.tracks) { + this.log(`Waiting for track info before buffering ${player}`); return; } if ( @@ -2509,6 +2620,52 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli this.transferMediaTo(player, media); } + private handleInPlaceStall(interstitial: InterstitialEvent) { + const schedule = this.schedule; + const media = this.primaryMedia; + if (!schedule || !media) { + return; + } + const currentTime = media.currentTime; + const foundAssetIndex = schedule.findAssetIndex(interstitial, currentTime); + const stallingAsset = interstitial.assetList[foundAssetIndex] as + | InterstitialAssetItem + | undefined; + if (stallingAsset) { + const player = this.getAssetPlayer(stallingAsset.identifier); + if (player) { + const assetCurrentTime = + player.currentTime || currentTime - stallingAsset.timelineStart; + const distanceFromEnd = player.duration - assetCurrentTime; + this.warn( + `Stalled at ${assetCurrentTime} of ${assetCurrentTime + distanceFromEnd} in ${player} ${interstitial} (media.currentTime: ${currentTime})`, + ); + if ( + assetCurrentTime && + (distanceFromEnd / media.playbackRate < 0.5 || + player.bufferedInPlaceToEnd(media)) && + player.hls + ) { + const scheduleIndex = schedule.findEventIndex( + interstitial.identifier, + ); + this.advanceAfterAssetEnded( + interstitial, + scheduleIndex, + foundAssetIndex, + ); + } + } + } + } + + private advanceInPlace(time: number) { + const media = this.primaryMedia; + if (media && media.currentTime < time) { + media.currentTime = time; + } + } + private handleAssetItemError( data: ErrorData, interstitial: InterstitialEvent, @@ -2519,11 +2676,15 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli if (data.details === ErrorDetails.BUFFER_STALLED_ERROR) { return; } - const assetItem = interstitial.assetList[assetListIndex]; + const assetItem = (interstitial.assetList[assetListIndex] || + null) as InterstitialAssetItem | null; this.warn( `INTERSTITIAL_ASSET_ERROR ${assetItem ? eventAssetToString(assetItem) : assetItem} ${data.error}`, ); - const assetId = assetItem?.identifier; + if (!this.schedule) { + return; + } + const assetId = assetItem?.identifier || ''; const playerIndex = this.getAssetPlayerQueueIndex(assetId); const player = this.playerQueue[playerIndex] || null; const items = this.schedule.items; @@ -2543,6 +2704,7 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli } const playingAsset = this.playingAsset; + const bufferingAsset = this.bufferingAsset; const error = new Error(errorMessage); if (assetItem) { this.clearAssetPlayer(assetId, null); @@ -2557,12 +2719,18 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli for (let i = assetListIndex; i < interstitial.assetList.length; i++) { this.resetAssetPlayer(interstitial.assetList[i].identifier); } - this.updateSchedule(); } + this.updateSchedule(true); if (interstitial.error) { this.primaryFallback(interstitial); } else if (playingAsset && playingAsset.identifier === assetId) { this.advanceAfterAssetEnded(interstitial, scheduleIndex, assetListIndex); + } else if ( + bufferingAsset && + bufferingAsset.identifier === assetId && + this.isInterstitial(this.bufferingItem) + ) { + this.advanceAssetBuffering(this.bufferingItem, bufferingAsset); } } @@ -2571,14 +2739,13 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli const flushStart = interstitial.timelineStart; const playingItem = this.effectivePlayingItem; // Update schedule now that interstitial/assets are flagged with `error` for fallback - this.updateSchedule(); if (playingItem) { this.log( `Fallback to primary from event "${interstitial.identifier}" start: ${ flushStart - } pos: ${this.timelinePos} playing: ${ - playingItem ? segmentToString(playingItem) : '' - } error: ${interstitial.error}`, + } pos: ${this.timelinePos} playing: ${segmentToString( + playingItem, + )} error: ${interstitial.error}`, ); let timelinePos = this.timelinePos; if (timelinePos === -1) { @@ -2592,6 +2759,9 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli this.attachPrimary(flushStart, null); this.flushFrontBuffer(flushStart); } + if (!this.schedule) { + return; + } const scheduleIndex = this.schedule.findItemIndexAtTime(timelinePos); this.setSchedulePosition(scheduleIndex); } else { @@ -2607,7 +2777,7 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli const interstitial = data.event; const interstitialId = interstitial.identifier; const assets = data.assetListResponse.ASSETS; - if (!this.schedule.hasEvent(interstitialId)) { + if (!this.schedule?.hasEvent(interstitialId)) { // Interstitial with id was removed return; } @@ -2651,6 +2821,7 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli interstitial.error = new Error( `Interstitial no longer within playback range ${this.timelinePos} ${interstitial}`, ); + this.updateSchedule(true); this.primaryFallback(interstitial); return; } @@ -2658,32 +2829,52 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli this.setBufferingItem(item); } this.setSchedulePosition(scheduleIndex); - } else if ( - bufferingEvent?.identifier === interstitialId && - bufferingEvent.appendInPlace - ) { - // If buffering (but not playback) has reached this item transfer media-source + } else if (bufferingEvent?.identifier === interstitialId) { const assetItem = interstitial.assetList[0]; - const player = this.getAssetPlayer(assetItem.identifier); - const media = this.primaryMedia; - if (assetItem && player && media) { - this.bufferAssetPlayer(player, media); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (assetItem) { + const player = this.getAssetPlayer(assetItem.identifier); + if (bufferingEvent.appendInPlace) { + // If buffering (but not playback) has reached this item transfer media-source + const media = this.primaryMedia; + if (player && media) { + this.bufferAssetPlayer(player, media); + } + } else if (player) { + player.loadSource(); + } } } } private onError(event: Events.ERROR, data: ErrorData) { + if (!this.schedule) { + return; + } switch (data.details) { case ErrorDetails.ASSET_LIST_PARSING_ERROR: case ErrorDetails.ASSET_LIST_LOAD_ERROR: case ErrorDetails.ASSET_LIST_LOAD_TIMEOUT: { const interstitial = data.interstitial; if (interstitial) { + this.updateSchedule(true); this.primaryFallback(interstitial); } break; } case ErrorDetails.BUFFER_STALLED_ERROR: { + const stallingItem = + this.endedItem || this.waitingItem || this.playingItem; + if ( + this.isInterstitial(stallingItem) && + stallingItem.event.appendInPlace + ) { + this.handleInPlaceStall(stallingItem.event); + return; + } + this.log( + `Primary player stall @${this.timelinePos} bufferedPos: ${this.bufferedPos}`, + ); this.onTimeupdate(); this.checkBuffer(true); break; diff --git a/src/controller/interstitials-schedule.ts b/src/controller/interstitials-schedule.ts index 4193355327d..5e6e41e9680 100644 --- a/src/controller/interstitials-schedule.ts +++ b/src/controller/interstitials-schedule.ts @@ -62,7 +62,7 @@ type ScheduleUpdateCallback = ( export class InterstitialsSchedule extends Logger { private onScheduleUpdate: ScheduleUpdateCallback; - private eventMap: Record = {}; + private eventMap: Record = {}; public events: InterstitialEvent[] | null = null; public items: InterstitialScheduleItem[] | null = null; public durations: InterstitialScheduleDurations = { @@ -217,7 +217,8 @@ export class InterstitialsSchedule extends Logger { if ( timelinePos === timelineStart || (timelinePos > timelineStart && - timelinePos < timelineStart + (asset.duration || 0)) + (timelinePos < timelineStart + (asset.duration || 0) || + i === length - 1)) ) { return i; } @@ -302,12 +303,14 @@ export class InterstitialsSchedule extends Logger { public updateSchedule( mediaSelection: MediaSelection, removedInterstitials: InterstitialEvent[] = [], + forceUpdate: boolean = false, ) { const events = this.events || []; if (events.length || removedInterstitials.length || this.length < 2) { const currentItems = this.items; const updatedItems = this.parseSchedule(events, mediaSelection); const updated = + forceUpdate || removedInterstitials.length || currentItems?.length !== updatedItems.length || updatedItems.some((item, i) => { @@ -326,7 +329,7 @@ export class InterstitialsSchedule extends Logger { } private parseDateRanges( - dateRanges: Record, + dateRanges: Record, baseData: BaseData, enableAppendInPlace: boolean, ): InterstitialEvent[] { @@ -334,7 +337,7 @@ export class InterstitialsSchedule extends Logger { const ids = Object.keys(dateRanges); for (let i = 0; i < ids.length; i++) { const id = ids[i]; - const dateRange = dateRanges[id]; + const dateRange = dateRanges[id]!; if (dateRange.isInterstitial) { let interstitial = this.eventMap[id]; if (interstitial) { @@ -377,7 +380,8 @@ export class InterstitialsSchedule extends Logger { interstitialEvents.forEach((interstitial, i) => { const preroll = interstitial.cue.pre; const postroll = interstitial.cue.post; - const previousEvent = interstitialEvents[i - 1] || null; + const previousEvent = + (interstitialEvents[i - 1] as InterstitialEvent | undefined) || null; const appendInPlace = interstitial.appendInPlace; const eventStart = postroll ? primaryDuration @@ -618,12 +622,6 @@ export class InterstitialsSchedule extends Logger { ); return false; } - if (!mediaSelection) { - this.log( - `"${interstitial.identifier}" resumption ${resumeTime} can not be aligned with media (none selected)`, - ); - return false; - } const playlists = Object.keys(mediaSelection); return !playlists.some((playlistType) => { const details = mediaSelection[playlistType].details; @@ -671,7 +669,8 @@ export class InterstitialsSchedule extends Logger { let sumDuration = 0; let hasUnknownDuration = false; let hasErrors = false; - interstitial.assetList.forEach((asset, i) => { + for (let i = 0; i < interstitial.assetList.length; i++) { + const asset = interstitial.assetList[i]; const timelineStart = eventStart + sumDuration; asset.startOffset = sumDuration; asset.timelineStart = timelineStart; @@ -679,7 +678,7 @@ export class InterstitialsSchedule extends Logger { hasErrors ||= !!asset.error; const duration = asset.error ? 0 : (asset.duration as number) || 0; sumDuration += duration; - }); + } // Use the sum of known durations when it is greater than the stated duration if (hasUnknownDuration && !hasErrors) { interstitial.duration = Math.max(sumDuration, interstitial.duration); diff --git a/src/controller/level-controller.ts b/src/controller/level-controller.ts index 9b33b71b561..c2999e7eaf4 100644 --- a/src/controller/level-controller.ts +++ b/src/controller/level-controller.ts @@ -263,6 +263,7 @@ export default class LevelController extends BasePlaylistController { if (levels.length === 0) { // Dispatch error after MANIFEST_LOADED is done propagating + // eslint-disable-next-line @typescript-eslint/no-floating-promises Promise.resolve().then(() => { if (this.hls) { let message = 'no level with compatible codecs found in manifest'; diff --git a/src/controller/stream-controller.ts b/src/controller/stream-controller.ts index 93e692be61b..2e3402bc5cf 100644 --- a/src/controller/stream-controller.ts +++ b/src/controller/stream-controller.ts @@ -215,17 +215,7 @@ export default class StreamController break; } case State.FRAG_LOADING_WAITING_RETRY: - { - const now = self.performance.now(); - const retryDate = this.retryDate; - // if current time is gt than retryDate, or if media seeking let's switch to IDLE state to retry loading - if (!retryDate || now >= retryDate || this.media?.seeking) { - const { levels, level } = this; - const currentLevel = levels?.[level]; - this.resetStartWhenNotLoaded(currentLevel || null); - this.state = State.IDLE; - } - } + this.checkRetryDate(); break; default: break; @@ -345,7 +335,7 @@ export default class StreamController const backtrackSn = (this.backtrackFragment ?? frag).sn as number; const fragIdx = backtrackSn - levelDetails.startSN; const backtrackFrag = levelDetails.fragments[fragIdx - 1]; - if (backtrackFrag && frag.cc === backtrackFrag.cc) { + if ((backtrackFrag as any) && frag.cc === backtrackFrag.cc) { frag = backtrackFrag; this.fragmentTracker.removeFragment(backtrackFrag); } @@ -624,13 +614,13 @@ export default class StreamController // detect if we have different kind of audio codecs used amongst playlists let aac = false; let heaac = false; - data.levels.forEach((level) => { - const codec = level.audioCodec; + for (let i = 0; i < data.levels.length; i++) { + const codec = data.levels[i].audioCodec; if (codec) { aac = aac || codec.indexOf('mp4a.40.2') !== -1; heaac = heaac || codec.indexOf('mp4a.40.5') !== -1; } - }); + } this.audioCodecSwitch = aac && heaac && !changeTypeSupported(); if (this.audioCodecSwitch) { this.log( @@ -777,7 +767,7 @@ export default class StreamController 0, ); - if (!bufferInfo?.buffered?.length) { + if (!bufferInfo.buffered?.length) { media.currentTime = liveSyncPosition; return; } @@ -816,7 +806,7 @@ export default class StreamController return; } const currentLevel = levels[frag.level]; - if (!currentLevel) { + if (!currentLevel as any) { this.warn(`Level ${frag.level} not found on progress`); return; } @@ -907,7 +897,10 @@ export default class StreamController if (fromAltAudio) { this.fragmentTracker.removeAllFragments(); hls.once(Events.BUFFER_FLUSHED, () => { - this.hls?.trigger(Events.AUDIO_TRACK_SWITCHED, data); + if (!this.hls as any) { + return; + } + this.hls.trigger(Events.AUDIO_TRACK_SWITCHED, data); }); hls.trigger(Events.BUFFER_FLUSHING, { startOffset: 0, @@ -1062,7 +1055,14 @@ export default class StreamController return; } if (this.reduceLengthAndFlushBuffer(data)) { - this.flushMainBuffer(0, Number.POSITIVE_INFINITY); + const isAssetPlayer = + !this.config.interstitialsController && this.config.assetPlayerId; + if (isAssetPlayer) { + // Use currentTime in buffer estimate to prevent loading more until playback advances + this._hasEnoughToStart = true; + } else { + this.flushMainBuffer(0, Number.POSITIVE_INFINITY); + } } break; case ErrorDetails.INTERNAL_EXCEPTION: @@ -1185,26 +1185,34 @@ export default class StreamController private _loadBitrateTestFrag(fragment: Fragment, level: Level) { fragment.bitrateTest = true; - this._doFragLoad(fragment, level).then((data) => { - const { hls } = this; - const frag = data?.frag; - if (!frag || this.fragContextChanged(frag)) { - return; - } - level.fragmentError = 0; - this.state = State.IDLE; - this.startFragRequested = false; - this.bitrateTest = false; - const stats = frag.stats; - // Bitrate tests fragments are neither parsed nor buffered - stats.parsing.start = - stats.parsing.end = - stats.buffering.start = - stats.buffering.end = - self.performance.now(); - hls.trigger(Events.FRAG_LOADED, data as FragLoadedData); - frag.bitrateTest = false; - }); + this._doFragLoad(fragment, level) + .then((data) => { + const { hls } = this; + const frag = data?.frag; + if (!frag || this.fragContextChanged(frag)) { + return; + } + level.fragmentError = 0; + this.state = State.IDLE; + this.startFragRequested = false; + this.bitrateTest = false; + const stats = frag.stats; + // Bitrate tests fragments are neither parsed nor buffered + stats.parsing.start = + stats.parsing.end = + stats.buffering.start = + stats.buffering.end = + self.performance.now(); + hls.trigger(Events.FRAG_LOADED, data as FragLoadedData); + frag.bitrateTest = false; + }) + .catch((reason) => { + if (this.state === State.STOPPED || this.state === State.ERROR) { + return; + } + this.warn(reason); + this.resetFragmentLoading(fragment); + }); } private _handleTransmuxComplete(transmuxResult: TransmuxerResult) { @@ -1233,37 +1241,41 @@ export default class StreamController this.state = State.PARSING; if (initSegment) { - if (initSegment?.tracks) { + const tracks = initSegment.tracks; + if (tracks) { const mapFragment = frag.initSegment || frag; - this._bufferInitSegment( - level, - initSegment.tracks, - mapFragment, - chunkMeta, - ); + if (this.unhandledEncryptionError(initSegment, frag)) { + return; + } + this._bufferInitSegment(level, tracks, mapFragment, chunkMeta); hls.trigger(Events.FRAG_PARSING_INIT_SEGMENT, { frag: mapFragment, id, - tracks: initSegment.tracks, + tracks, }); } - // This would be nice if Number.isFinite acted as a typeguard, but it doesn't. See: https://github.com/Microsoft/TypeScript/issues/10038 const baseTime = initSegment.initPTS as number; const timescale = initSegment.timescale as number; const initPTS = this.initPTS[frag.cc]; if ( Number.isFinite(baseTime) && - (!initPTS || + ((!initPTS as any) || initPTS.baseTime !== baseTime || initPTS.timescale !== timescale) ) { - this.initPTS[frag.cc] = { baseTime, timescale }; + const trackId = initSegment.trackId as number; + this.initPTS[frag.cc] = { + baseTime, + timescale, + trackId, + }; hls.trigger(Events.INIT_PTS_FOUND, { frag, id, initPTS: baseTime, timescale, + trackId, }); } } @@ -1275,7 +1287,8 @@ export default class StreamController } const prevFrag = details.fragments[frag.sn - 1 - details.startSN]; const isFirstFragment = frag.sn === details.startSN; - const isFirstInDiscontinuity = !prevFrag || frag.cc > prevFrag.cc; + const isFirstInDiscontinuity = + (!prevFrag as any) || frag.cc > prevFrag.cc; if (remuxResult.independent !== false) { const { startPTS, endPTS, startDTS, endDTS } = video; if (part) { @@ -1378,7 +1391,7 @@ export default class StreamController this.bufferFragmentData(audio, frag, part, chunkMeta); } - if (details && id3?.samples?.length) { + if (details && id3?.samples.length) { const emittedID3: FragParsingMetadataData = { id, frag, @@ -1522,7 +1535,7 @@ export default class StreamController const trackTypes = Object.keys(tracks); if (trackTypes.length) { this.hls.trigger(Events.BUFFER_CODECS, tracks as BufferCodecsData); - if (!this.hls) { + if (!this.hls as any) { // Exit after fatal tracks error return; } diff --git a/src/controller/timeline-controller.ts b/src/controller/timeline-controller.ts index 00942d277e4..7f8e38240fd 100644 --- a/src/controller/timeline-controller.ts +++ b/src/controller/timeline-controller.ts @@ -36,7 +36,7 @@ import type { MediaPlaylist } from '../types/media-playlist'; import type { VTTCCs } from '../types/vtt'; import type { CaptionScreen } from '../utils/cea-608-parser'; import type { CuesInterface } from '../utils/cues'; -import type { RationalTimestamp } from '../utils/timescale-conversion'; +import type { TimestampOffset } from '../utils/timescale-conversion'; type TrackProperties = { label: string; @@ -61,7 +61,7 @@ export class TimelineController implements ComponentAPI { private Cues: CuesInterface; private textTracks: Array = []; private tracks: Array = []; - private initPTS: RationalTimestamp[] = []; + private initPTS: TimestampOffset[] = []; private unparsedVttFrags: Array = []; private captionsTracks: Record = {}; private nonNativeCaptionsTracks: Record = {}; @@ -191,11 +191,11 @@ export class TimelineController implements ComponentAPI { // Triggered when an initial PTS is found; used for synchronisation of WebVTT. private onInitPtsFound( event: Events.INIT_PTS_FOUND, - { frag, id, initPTS, timescale }: InitPTSFoundData, + { frag, id, initPTS, timescale, trackId }: InitPTSFoundData, ) { const { unparsedVttFrags } = this; if (id === PlaylistLevelType.MAIN) { - this.initPTS[frag.cc] = { baseTime: initPTS, timescale }; + this.initPTS[frag.cc] = { baseTime: initPTS, timescale, trackId }; } // Due to asynchronous processing, initial PTS may arrive later than the first VTT fragments are loaded. diff --git a/src/demux/audio/base-audio-demuxer.ts b/src/demux/audio/base-audio-demuxer.ts index 5d9a382d281..4a980e11023 100644 --- a/src/demux/audio/base-audio-demuxer.ts +++ b/src/demux/audio/base-audio-demuxer.ts @@ -14,7 +14,10 @@ import { } from '../../types/demuxer'; import { appendUint8Array } from '../../utils/mp4-tools'; import { dummyTrack } from '../dummy-demuxed-track'; -import type { RationalTimestamp } from '../../utils/timescale-conversion'; +import type { + RationalTimestamp, + TimestampOffset, +} from '../../utils/timescale-conversion'; class BaseAudioDemuxer implements Demuxer { protected _audioTrack?: DemuxedAudioTrack; @@ -22,7 +25,7 @@ class BaseAudioDemuxer implements Demuxer { protected frameIndex: number = 0; protected cachedData: Uint8Array | null = null; protected basePTS: number | null = null; - protected initPTS: RationalTimestamp | null = null; + protected initPTS: TimestampOffset | null = null; protected lastPTS: number | null = null; resetInitSegment( @@ -42,7 +45,7 @@ class BaseAudioDemuxer implements Demuxer { }; } - resetTimeStamp(deaultTimestamp: RationalTimestamp | null) { + resetTimeStamp(deaultTimestamp: TimestampOffset | null) { this.initPTS = deaultTimestamp; this.resetContiguity(); } diff --git a/src/demux/sample-aes.ts b/src/demux/sample-aes.ts index 5267bbccc97..b1ead44700b 100644 --- a/src/demux/sample-aes.ts +++ b/src/demux/sample-aes.ts @@ -56,14 +56,16 @@ class SampleAesDecrypter { encryptedData.byteOffset + encryptedData.length, ); - this.decryptBuffer(encryptedBuffer).then((decryptedBuffer: ArrayBuffer) => { - const decryptedData = new Uint8Array(decryptedBuffer); - curUnit.set(decryptedData, 16); + this.decryptBuffer(encryptedBuffer) + .then((decryptedBuffer: ArrayBuffer) => { + const decryptedData = new Uint8Array(decryptedBuffer); + curUnit.set(decryptedData, 16); - if (!this.decrypter.isSync()) { - this.decryptAacSamples(samples, sampleIndex + 1, callback); - } - }); + if (!this.decrypter.isSync()) { + this.decryptAacSamples(samples, sampleIndex + 1, callback); + } + }) + .catch(callback); } decryptAacSamples( @@ -136,13 +138,15 @@ class SampleAesDecrypter { const decodedData = discardEPB(curUnit.data); const encryptedData = this.getAvcEncryptedData(decodedData); - this.decryptBuffer(encryptedData.buffer).then((decryptedBuffer) => { - curUnit.data = this.getAvcDecryptedUnit(decodedData, decryptedBuffer); + this.decryptBuffer(encryptedData.buffer) + .then((decryptedBuffer) => { + curUnit.data = this.getAvcDecryptedUnit(decodedData, decryptedBuffer); - if (!this.decrypter.isSync()) { - this.decryptAvcSamples(samples, sampleIndex, unitIndex + 1, callback); - } - }); + if (!this.decrypter.isSync()) { + this.decryptAvcSamples(samples, sampleIndex, unitIndex + 1, callback); + } + }) + .catch(callback); } decryptAvcSamples( diff --git a/src/demux/transmuxer-interface.ts b/src/demux/transmuxer-interface.ts index 3ae429dacce..50f79aa527b 100644 --- a/src/demux/transmuxer-interface.ts +++ b/src/demux/transmuxer-interface.ts @@ -21,7 +21,7 @@ import type Hls from '../hls'; import type { MediaFragment, Part } from '../loader/fragment'; import type { ErrorData, FragDecryptedData } from '../types/events'; import type { ChunkMetadata, TransmuxerResult } from '../types/transmuxer'; -import type { RationalTimestamp } from '../utils/timescale-conversion'; +import type { TimestampOffset } from '../utils/timescale-conversion'; let transmuxerInstanceCount: number = 0; @@ -193,7 +193,7 @@ export default class TransmuxerInterface { duration: number, accurateTimeOffset: boolean, chunkMeta: ChunkMetadata, - defaultInitPTS?: RationalTimestamp, + defaultInitPTS?: TimestampOffset, ) { chunkMeta.transmuxing.start = self.performance.now(); const { instanceNo, transmuxer } = this; diff --git a/src/demux/transmuxer.ts b/src/demux/transmuxer.ts index 45d8d8db863..136a5443482 100644 --- a/src/demux/transmuxer.ts +++ b/src/demux/transmuxer.ts @@ -21,7 +21,7 @@ import type { Remuxer } from '../types/remuxer'; import type { ChunkMetadata, TransmuxerResult } from '../types/transmuxer'; import type { TypeSupported } from '../utils/codecs'; import type { ILogger } from '../utils/logger'; -import type { RationalTimestamp } from '../utils/timescale-conversion'; +import type { TimestampOffset } from '../utils/timescale-conversion'; let now: () => number; // performance.now() not available on WebWorker, at least on Safari Desktop @@ -312,7 +312,7 @@ export default class Transmuxer { chunkMeta.transmuxing.executeEnd = now(); } - resetInitialTimestamp(defaultInitPts: RationalTimestamp | null) { + resetInitialTimestamp(defaultInitPts: TimestampOffset | null) { const { demuxer, remuxer } = this; if (!demuxer || !remuxer) { return; @@ -517,14 +517,14 @@ export class TransmuxConfig { public videoCodec?: string; public initSegmentData?: Uint8Array; public duration: number; - public defaultInitPts: RationalTimestamp | null; + public defaultInitPts: TimestampOffset | null; constructor( audioCodec: string | undefined, videoCodec: string | undefined, initSegmentData: Uint8Array | undefined, duration: number, - defaultInitPts?: RationalTimestamp, + defaultInitPts?: TimestampOffset, ) { this.audioCodec = audioCodec; this.videoCodec = videoCodec; diff --git a/src/demux/tsdemuxer.ts b/src/demux/tsdemuxer.ts index aa52b4881a9..863e0baadc1 100644 --- a/src/demux/tsdemuxer.ts +++ b/src/demux/tsdemuxer.ts @@ -191,6 +191,7 @@ class TSDemuxer implements Demuxer { this._audioTrack.segmentCodec = 'aac'; // flush any partial content + this.videoParser = null; this.aacOverFlow = null; this.remainderData = null; this.audioCodec = audioCodec; @@ -291,18 +292,7 @@ class TSDemuxer implements Demuxer { case videoPid: if (stt) { if (videoData && (pes = parsePES(videoData, this.logger))) { - if (this.videoParser === null) { - switch (videoTrack.segmentCodec) { - case 'avc': - this.videoParser = new AvcVideoParser(); - break; - case 'hevc': - if (__USE_M2TS_ADVANCED_CODECS__) { - this.videoParser = new HevcVideoParser(); - } - break; - } - } + this.readyVideoParser(videoTrack.segmentCodec); if (this.videoParser !== null) { this.videoParser.parsePES(videoTrack, textTrack, pes, false); } @@ -477,18 +467,7 @@ class TSDemuxer implements Demuxer { // try to parse last PES packets let pes: PES | null; if (videoData && (pes = parsePES(videoData, this.logger))) { - if (this.videoParser === null) { - switch (videoTrack.segmentCodec) { - case 'avc': - this.videoParser = new AvcVideoParser(); - break; - case 'hevc': - if (__USE_M2TS_ADVANCED_CODECS__) { - this.videoParser = new HevcVideoParser(); - } - break; - } - } + this.readyVideoParser(videoTrack.segmentCodec); if (this.videoParser !== null) { this.videoParser.parsePES( videoTrack as DemuxedVideoTrack, @@ -557,6 +536,16 @@ class TSDemuxer implements Demuxer { return this.decrypt(demuxResult, sampleAes); } + private readyVideoParser(codec: string | undefined) { + if (this.videoParser === null) { + if (codec === 'avc') { + this.videoParser = new AvcVideoParser(); + } else if (__USE_M2TS_ADVANCED_CODECS__ && codec === 'hevc') { + this.videoParser = new HevcVideoParser(); + } + } + } + private decrypt( demuxResult: DemuxerResult, sampleAes: SampleAesDecrypter, diff --git a/src/demux/video/hevc-video-parser.ts b/src/demux/video/hevc-video-parser.ts index 485ee85c6bd..61d00640a4c 100644 --- a/src/demux/video/hevc-video-parser.ts +++ b/src/demux/video/hevc-video-parser.ts @@ -516,10 +516,10 @@ class HevcVideoParser extends BaseVideoParser { eg.readBoolean(); // frame_field_info_present_flag default_display_window_flag = eg.readBoolean(); if (default_display_window_flag) { - pic_left_offset += eg.readUEG(); - pic_right_offset += eg.readUEG(); - pic_top_offset += eg.readUEG(); - pic_bottom_offset += eg.readUEG(); + eg.skipUEG(); + eg.skipUEG(); + eg.skipUEG(); + eg.skipUEG(); } const vui_timing_info_present_flag = eg.readBoolean(); if (vui_timing_info_present_flag) { @@ -604,7 +604,7 @@ class HevcVideoParser extends BaseVideoParser { let width = pic_width_in_luma_samples, height = pic_height_in_luma_samples; - if (conformance_window_flag || default_display_window_flag) { + if (conformance_window_flag) { let chroma_scale_w = 1, chroma_scale_h = 1; if (chroma_format_idc === 1) { diff --git a/src/events.ts b/src/events.ts index 722749ad054..4a13a65adb9 100644 --- a/src/events.ts +++ b/src/events.ts @@ -40,7 +40,6 @@ import type { InterstitialsUpdatedData, KeyLoadedData, KeyLoadingData, - KeyStatusesChangedData, LevelLoadedData, LevelLoadingData, LevelPTSUpdatedData, @@ -221,8 +220,6 @@ export enum Events { PLAYOUT_LIMIT_REACHED = 'hlsPlayoutLimitReached', // Event DateRange cue "enter" event dispatched EVENT_CUE_ENTER = 'hlsEventCueEnter', - // HD DRM - KEY_STATUSES_CHANGED = 'hlsKeyStatusesChanged', } /** @@ -499,10 +496,6 @@ export interface HlsListeners { data: {}, ) => void; [Events.EVENT_CUE_ENTER]: (event: Events.EVENT_CUE_ENTER, data: {}) => void; - [Events.KEY_STATUSES_CHANGED]: ( - event: Events.KEY_STATUSES_CHANGED, - data: KeyStatusesChangedData, - ) => void; } export interface HlsEventEmitter { on( diff --git a/src/hls.ts b/src/hls.ts index 59f1ecd34de..b0df771dac2 100644 --- a/src/hls.ts +++ b/src/hls.ts @@ -233,7 +233,7 @@ export default class Hls implements HlsEventEmitter { )); const id3TrackController = new ID3TrackController(this); - const keyLoader = new KeyLoader(this.config); + const keyLoader = new KeyLoader(this.config, this.logger); const streamController = (this.streamController = new StreamController( this, fragmentTracker, @@ -1417,7 +1417,6 @@ export type { BufferFlushingData, CuesParsedData, ErrorData, - KeyStatusesChangedData, FPSDropData, FPSDropLevelCappingData, FragBufferedData, @@ -1534,4 +1533,7 @@ export type { KeySystems, KeySystemFormats, } from './utils/mediakeys-helper'; -export type { RationalTimestamp } from './utils/timescale-conversion'; +export type { + RationalTimestamp, + TimestampOffset, +} from './utils/timescale-conversion'; diff --git a/src/loader/fragment.ts b/src/loader/fragment.ts index 9431b039a7a..97e5e08cd95 100644 --- a/src/loader/fragment.ts +++ b/src/loader/fragment.ts @@ -177,7 +177,7 @@ export class Fragment extends BaseSegment { // levelkeys are the EXT-X-KEY tags that apply to this segment for decryption // core difference from the private field _decryptdata is the lack of the initialized IV // _decryptdata will set the IV for this segment based on the segment number in the fragment - public levelkeys?: { [key: string]: LevelKey }; + public levelkeys?: { [key: string]: LevelKey | undefined }; // A string representing the fragment type public readonly type: PlaylistLevelType; // A reference to the loader. Set while the fragment is loading, and removed afterwards. Used to abort fragment loading @@ -233,7 +233,7 @@ export class Fragment extends BaseSegment { return total; } } - if (this.byteRange) { + if (this.byteRange.length) { const start = this.byteRange[0]; const end = this.byteRange[1]; if (Number.isFinite(start) && Number.isFinite(end)) { @@ -270,9 +270,11 @@ export class Fragment extends BaseSegment { } else { const keyFormats = Object.keys(this.levelkeys); if (keyFormats.length === 1) { - return (this._decryptdata = this.levelkeys[ - keyFormats[0] - ].getDecryptData(this.sn)); + const levelKey = (this._decryptdata = + this.levelkeys[keyFormats[0]] || null); + if (levelKey) { + return levelKey.getDecryptData(this.sn); + } } else { // Multiple keys. key-loader to call Fragment.setKeyFormat based on selected key-system. } @@ -305,7 +307,7 @@ export class Fragment extends BaseSegment { } else if (this.levelkeys) { const keyFormats = Object.keys(this.levelkeys); const len = keyFormats.length; - if (len > 1 || (len === 1 && this.levelkeys[keyFormats[0]].encrypted)) { + if (len > 1 || (len === 1 && this.levelkeys[keyFormats[0]]?.encrypted)) { return true; } } diff --git a/src/loader/key-loader.ts b/src/loader/key-loader.ts index 124e07bb20d..80f4e72b8c5 100644 --- a/src/loader/key-loader.ts +++ b/src/loader/key-loader.ts @@ -1,6 +1,8 @@ import { LoadError } from './fragment-loader'; import { ErrorDetails, ErrorTypes } from '../errors'; import { type Fragment, isMediaFragment } from '../loader/fragment'; +import { arrayToHex } from '../utils/hex'; +import { Logger } from '../utils/logger'; import { getKeySystemsForConfig, keySystemFormatToKeySystemDomain, @@ -20,6 +22,7 @@ import type { LoaderStats, PlaylistLevelType, } from '../types/loader'; +import type { ILogger } from '../utils/logger'; import type { KeySystemFormats } from '../utils/mediakeys-helper'; export interface KeyLoaderInfo { @@ -28,18 +31,19 @@ export interface KeyLoaderInfo { loader: Loader | null; mediaKeySessionContext: MediaKeySessionContext | null; } -export default class KeyLoader implements ComponentAPI { +export default class KeyLoader extends Logger implements ComponentAPI { private readonly config: HlsConfig; - public keyUriToKeyInfo: { [keyuri: string]: KeyLoaderInfo } = {}; + private keyIdToKeyInfo: { [keyId: string]: KeyLoaderInfo | undefined } = {}; public emeController: EMEController | null = null; - constructor(config: HlsConfig) { + constructor(config: HlsConfig, logger: ILogger) { + super('key-loader', logger); this.config = config; } abort(type?: PlaylistLevelType) { - for (const uri in this.keyUriToKeyInfo) { - const loader = this.keyUriToKeyInfo[uri].loader; + for (const id in this.keyIdToKeyInfo) { + const loader = this.keyIdToKeyInfo[id]!.loader; if (loader) { if (type && type !== loader.context?.frag.type) { return; @@ -50,27 +54,27 @@ export default class KeyLoader implements ComponentAPI { } detach() { - for (const uri in this.keyUriToKeyInfo) { - const keyInfo = this.keyUriToKeyInfo[uri]; + for (const id in this.keyIdToKeyInfo) { + const keyInfo = this.keyIdToKeyInfo[id]!; // Remove cached EME keys on detach if ( keyInfo.mediaKeySessionContext || keyInfo.decryptdata.isCommonEncryption ) { - delete this.keyUriToKeyInfo[uri]; + delete this.keyIdToKeyInfo[id]; } } } destroy() { this.detach(); - for (const uri in this.keyUriToKeyInfo) { - const loader = this.keyUriToKeyInfo[uri].loader; + for (const id in this.keyIdToKeyInfo) { + const loader = this.keyIdToKeyInfo[id]!.loader; if (loader) { loader.destroy(); } } - this.keyUriToKeyInfo = {}; + this.keyIdToKeyInfo = {}; } createKeyLoadError( @@ -185,22 +189,23 @@ export default class KeyLoader implements ComponentAPI { ), ); } - let keyInfo = this.keyUriToKeyInfo[uri]; + const id = getKeyId(decryptdata); + let keyInfo = this.keyIdToKeyInfo[id]; if (keyInfo?.decryptdata.key) { decryptdata.key = keyInfo.decryptdata.key; return Promise.resolve({ frag, keyInfo }); } - // Return key load promise as long as it does not have a mediakey session with an unusable key status + // Return key load promise once it has a mediakey session with an usable key status if (keyInfo?.keyLoadPromise) { - switch (keyInfo.mediaKeySessionContext?.keyStatus) { - case undefined: - case 'status-pending': + const keyStatus = keyInfo.mediaKeySessionContext?.keyStatus; + switch (keyStatus) { case 'usable': case 'usable-in-future': return keyInfo.keyLoadPromise.then((keyLoadedData) => { // Return the correct fragment with updated decryptdata key and loaded keyInfo - decryptdata.key = keyLoadedData.keyInfo.decryptdata.key; + const { keyInfo } = keyLoadedData; + decryptdata.key = keyInfo.decryptdata.key; return { frag, keyInfo }; }); } @@ -209,7 +214,11 @@ export default class KeyLoader implements ComponentAPI { } // Load the key or return the loading promise - keyInfo = this.keyUriToKeyInfo[uri] = { + this.log( + `Loading key ${arrayToHex(decryptdata.keyId || [])} from ${frag.type} ${frag.level}`, + ); + + keyInfo = this.keyIdToKeyInfo[id] = { decryptdata, keyLoadPromise: null, loader: null, @@ -217,7 +226,6 @@ export default class KeyLoader implements ComponentAPI { }; switch (decryptdata.method) { - case 'ISO-23001-7': case 'SAMPLE-AES': case 'SAMPLE-AES-CENC': case 'SAMPLE-AES-CTR': @@ -248,18 +256,19 @@ export default class KeyLoader implements ComponentAPI { if (this.emeController && this.config.emeEnabled) { const keySessionContextPromise = this.emeController.loadKey(keyLoadedData); - if (keySessionContextPromise) { - return (keyInfo.keyLoadPromise = keySessionContextPromise.then( - (keySessionContext) => { - keyInfo.mediaKeySessionContext = keySessionContext; - return keyLoadedData; - }, - )).catch((error) => { - // Remove promise for license renewal or retry - keyInfo.keyLoadPromise = null; - throw error; - }); - } + return (keyInfo.keyLoadPromise = keySessionContextPromise.then( + (keySessionContext) => { + keyInfo.mediaKeySessionContext = keySessionContext; + return keyLoadedData; + }, + )).catch((error) => { + // Remove promise for license renewal or retry + keyInfo.keyLoadPromise = null; + if (error.data) { + error.data.frag = frag; + } + throw error; + }); } return Promise.resolve(keyLoadedData); } @@ -298,7 +307,8 @@ export default class KeyLoader implements ComponentAPI { networkDetails: any, ) => { const { frag, keyInfo, url: uri } = context; - if (!frag.decryptdata || keyInfo !== this.keyUriToKeyInfo[uri]) { + const id = getKeyId(keyInfo.decryptdata) || uri; + if (!frag.decryptdata || keyInfo !== this.keyIdToKeyInfo[id]) { return reject( this.createKeyLoadError( frag, @@ -383,9 +393,18 @@ export default class KeyLoader implements ComponentAPI { frag.keyLoader = null; keyInfo.loader = null; } - delete this.keyUriToKeyInfo[uri]; + const id = getKeyId(keyInfo.decryptdata) || uri; + delete this.keyIdToKeyInfo[id]; if (loader) { loader.destroy(); } } } + +function getKeyId(decryptdata: LevelKey) { + const keyId = decryptdata.keyId; + if (keyId) { + return arrayToHex(keyId); + } + return decryptdata.uri; +} diff --git a/src/loader/level-details.ts b/src/loader/level-details.ts index 43ccef6bd40..3c954fe00c2 100644 --- a/src/loader/level-details.ts +++ b/src/loader/level-details.ts @@ -1,7 +1,9 @@ import type { DateRange } from './date-range'; import type { Fragment, MediaFragment, Part } from './fragment'; +import type { LevelKey } from './level-key'; import type { VariableMap } from '../types/level'; import type { AttrList } from '../utils/attr-list'; +import type { KeySystemFormats } from '../utils/mediakeys-helper'; const DEFAULT_TARGET_DURATION = 10; @@ -17,7 +19,7 @@ export class LevelDetails { public fragments: MediaFragment[]; public fragmentHint?: MediaFragment; public partList: Part[] | null = null; - public dateRanges: Record; + public dateRanges: Record; public dateRangeTagCount: number = 0; public live: boolean = true; public requestScheduled: number = -1; @@ -88,10 +90,21 @@ export class LevelDetails { } } + hasKey(levelKey: LevelKey): boolean { + return this.encryptedFragments.some((frag) => { + let decryptdata = frag.decryptdata; + if (!decryptdata) { + frag.setKeyFormat(levelKey.keyFormat as KeySystemFormats); + decryptdata = frag.decryptdata; + } + return !!decryptdata && levelKey.matches(decryptdata); + }); + } + get hasProgramDateTime(): boolean { if (this.fragments.length) { return Number.isFinite( - this.fragments[this.fragments.length - 1].programDateTime as number, + this.fragments[this.fragments.length - 1].programDateTime, ); } return false; @@ -126,14 +139,14 @@ export class LevelDetails { } get fragmentEnd(): number { - if (this.fragments?.length) { + if (this.fragments.length) { return this.fragments[this.fragments.length - 1].end; } return 0; } get fragmentStart(): number { - if (this.fragments?.length) { + if (this.fragments.length) { return this.fragments[0].start; } return 0; diff --git a/src/loader/level-key.ts b/src/loader/level-key.ts index 49d4dd925f7..0da3ae32ce0 100644 --- a/src/loader/level-key.ts +++ b/src/loader/level-key.ts @@ -1,3 +1,4 @@ +import { arrayValuesMatch, optionalArrayValuesMatch } from '../utils/arrays'; import { isFullSegmentEncryption } from '../utils/encryption-methods-util'; import { hexToArrayBuffer } from '../utils/hex'; import { convertDataUriToArrayBytes } from '../utils/keysystem-util'; @@ -63,8 +64,9 @@ export class LevelKey implements DecryptData { key.method === this.method && key.encrypted === this.encrypted && key.keyFormat === this.keyFormat && - key.keyFormatVersions.join(',') === this.keyFormatVersions.join(',') && - key.iv?.join(',') === this.iv?.join(',') + arrayValuesMatch(key.keyFormatVersions, this.keyFormatVersions) && + optionalArrayValuesMatch(key.iv, this.iv) && + optionalArrayValuesMatch(key.keyId, this.keyId) ); } @@ -84,12 +86,9 @@ export class LevelKey implements DecryptData { case KeySystemFormats.PLAYREADY: case KeySystemFormats.CLEARKEY: return ( - [ - 'ISO-23001-7', - 'SAMPLE-AES', - 'SAMPLE-AES-CENC', - 'SAMPLE-AES-CTR', - ].indexOf(this.method) !== -1 + ['SAMPLE-AES', 'SAMPLE-AES-CENC', 'SAMPLE-AES-CTR'].indexOf( + this.method, + ) !== -1 ); } } diff --git a/src/loader/m3u8-parser.ts b/src/loader/m3u8-parser.ts index 36d42a5246c..a7765fdfc76 100644 --- a/src/loader/m3u8-parser.ts +++ b/src/loader/m3u8-parser.ts @@ -43,6 +43,8 @@ type ParsedMultivariantMediaOptions = { 'CLOSED-CAPTIONS'?: MediaPlaylist[]; }; +type LevelKeys = { [key: string]: LevelKey | undefined }; + const MASTER_PLAYLIST_REGEX = /#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; @@ -254,8 +256,9 @@ export default class M3U8Parser { 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 groups: + | (typeof groupsByType)[keyof typeof groupsByType] + | undefined = groupsByType[type]; const medias: MediaPlaylist[] = results[type] || []; results[type] = medias; const lang = attrs.LANGUAGE; @@ -328,7 +331,7 @@ export default class M3U8Parser { let frag: Fragment = new Fragment(type, base); let result: RegExpExecArray | RegExpMatchArray | null; let i: number; - let levelkeys: { [key: string]: LevelKey } | undefined; + let levelkeys: LevelKeys | undefined; let firstPdtIndex = -1; let createNextFrag = false; let nextByteRange: string | null = null; @@ -351,7 +354,7 @@ export default class M3U8Parser { frag = new Fragment(type, base); // setup the next fragment for part loading frag.playlistOffset = totalduration; - frag.start = totalduration; + frag.setStart(totalduration); frag.sn = currentSN; frag.cc = discontinuityCounter; if (currentBitrate) { @@ -383,7 +386,7 @@ export default class M3U8Parser { // url if (Number.isFinite(frag.duration)) { frag.playlistOffset = totalduration; - frag.start = totalduration; + frag.setStart(totalduration); if (levelkeys) { setFragLevelKeys(frag, levelkeys, level); } @@ -414,7 +417,7 @@ export default class M3U8Parser { continue; } for (i = 1; i < result.length; i++) { - if (result[i] !== undefined) { + if ((result[i] as any) !== undefined) { break; } } @@ -720,9 +723,6 @@ export default class M3U8Parser { if (!level.live) { lastFragment.endList = true; } - if (firstFragment && level.startCC === undefined) { - level.startCC = firstFragment.cc; - } /** * Backfill any missing PDT values * "If the first EXT-X-PROGRAM-DATE-TIME tag in a Playlist appears after @@ -738,9 +738,6 @@ export default class M3U8Parser { programDateTimes.unshift(firstFragment as MediaFragment); } } - } else { - level.endSN = 0; - level.startCC = 0; } if (level.fragmentHint) { totalduration += level.fragmentHint.duration; @@ -761,18 +758,28 @@ export function mapDateRanges( details: LevelDetails, ) { // Make sure DateRanges are mapped to a ProgramDateTime tag that applies a date to a segment that overlaps with its start date - const programDateTimeCount = programDateTimes.length; + let programDateTimeCount = programDateTimes.length; if (!programDateTimeCount) { - return; + if (details.hasProgramDateTime) { + const lastFragment = details.fragments[details.fragments.length - 1]; + programDateTimes.push(lastFragment); + programDateTimeCount++; + } else { + // no segments with EXT-X-PROGRAM-DATE-TIME references in playlist history + return; + } } const lastProgramDateTime = programDateTimes[programDateTimeCount - 1]; const playlistEnd = details.live ? Infinity : details.totalduration; const dateRangeIds = Object.keys(details.dateRanges); for (let i = dateRangeIds.length; i--; ) { - const dateRange = details.dateRanges[dateRangeIds[i]]; + const dateRange = details.dateRanges[dateRangeIds[i]]!; const startDateTime = dateRange.startDate.getTime(); dateRange.tagAnchor = lastProgramDateTime.ref; for (let j = programDateTimeCount; j--; ) { + if (programDateTimes[j]?.sn < details.startSN) { + break; + } const fragIndex = findFragmentWithStartDate( details, startDateTime, @@ -795,7 +802,7 @@ function findFragmentWithStartDate( index: number, endTime: number, ): number { - const pdtFragment = programDateTimes[index]; + const pdtFragment = programDateTimes[index] as MediaFragment | undefined; if (pdtFragment) { // find matching range between PDT tags const pdtStart = pdtFragment.programDateTime as number; @@ -805,6 +812,9 @@ function findFragmentWithStartDate( if (startDateTime <= pdtStart + durationBetweenPdt * 1000) { // map to fragment with date-time range const startIndex = programDateTimes[index].sn - details.startSN; + if (startIndex < 0) { + return -1; + } const fragments = details.fragments; if (fragments.length > programDateTimes.length) { const endSegment = @@ -934,7 +944,7 @@ function setInitSegment( frag: Fragment, mapAttrs: AttrList, id: number, - levelkeys: { [key: string]: LevelKey } | undefined, + levelkeys: LevelKeys | undefined, ) { frag.relurl = mapAttrs.URI; if (mapAttrs.BYTERANGE) { @@ -950,7 +960,7 @@ function setInitSegment( function setFragLevelKeys( frag: Fragment, - levelkeys: { [key: string]: LevelKey }, + levelkeys: LevelKeys, level: LevelDetails, ) { frag.levelkeys = levelkeys; @@ -960,7 +970,7 @@ function setFragLevelKeys( encryptedFragments[encryptedFragments.length - 1].levelkeys !== levelkeys) && Object.keys(levelkeys).some( - (format) => levelkeys![format].isCommonEncryption, + (format) => levelkeys[format]!.isCommonEncryption, ) ) { encryptedFragments.push(frag); diff --git a/src/loader/playlist-loader.ts b/src/loader/playlist-loader.ts index 168db972e9e..b589ad1f8fc 100644 --- a/src/loader/playlist-loader.ts +++ b/src/loader/playlist-loader.ts @@ -711,7 +711,7 @@ class PlaylistLoader implements NetworkComponentAPI { } const error = levelDetails.playlistParsingError; if (error) { - this.hls.logger.warn(error); + this.hls.logger.warn(`${error} ${levelDetails.url}`); if (!hls.config.ignorePlaylistParsingErrors) { hls.trigger(Events.ERROR, { type: ErrorTypes.NETWORK_ERROR, diff --git a/src/remux/mp4-remuxer.ts b/src/remux/mp4-remuxer.ts index f97bfabd6af..9bd59545f98 100644 --- a/src/remux/mp4-remuxer.ts +++ b/src/remux/mp4-remuxer.ts @@ -27,7 +27,10 @@ import type { } from '../types/remuxer'; import type { TrackSet } from '../types/track'; import type { TypeSupported } from '../utils/codecs'; -import type { RationalTimestamp } from '../utils/timescale-conversion'; +import type { + RationalTimestamp, + TimestampOffset, +} from '../utils/timescale-conversion'; const MAX_SILENT_FRAME_DURATION = 10 * 1000; // 10 seconds const AAC_SAMPLES_PER_FRAME = 1024; @@ -62,8 +65,8 @@ export default class MP4Remuxer extends Logger implements Remuxer { private readonly config: HlsConfig; private readonly typeSupported: TypeSupported; private ISGenerated: boolean = false; - private _initPTS: RationalTimestamp | null = null; - private _initDTS: RationalTimestamp | null = null; + private _initPTS: TimestampOffset | null = null; + private _initDTS: TimestampOffset | null = null; private nextVideoTs: number | null = null; private nextAudioTs: number | null = null; private videoSampleDuration: number | null = null; @@ -103,7 +106,7 @@ export default class MP4Remuxer extends Logger implements Remuxer { this.config = this.videoTrackConfig = this._initPTS = this._initDTS = null; } - resetTimeStamp(defaultTimeStamp: RationalTimestamp | null) { + resetTimeStamp(defaultTimeStamp: TimestampOffset | null) { this.log('initPTS & initDTS reset'); this._initPTS = this._initDTS = defaultTimeStamp; } @@ -347,7 +350,7 @@ export default class MP4Remuxer extends Logger implements Remuxer { let initPTS: number | undefined; let initDTS: number | undefined; let timescale: number | undefined; - let trackId: number | undefined; + let trackId: number = -1; if (computePTSDTS) { initPTS = initDTS = Infinity; @@ -439,13 +442,23 @@ export default class MP4Remuxer extends Logger implements Remuxer { if (Object.keys(tracks).length) { this.ISGenerated = true; if (computePTSDTS) { + if (_initPTS) { + this.warn( + `Timestamps at playlist time: ${accurateTimeOffset ? '' : '~'}${timeOffset} ${initPTS! / timescale!} != initPTS: ${_initPTS.baseTime / _initPTS.timescale} (${_initPTS.baseTime}/${_initPTS.timescale}) trackId: ${_initPTS.trackId}`, + ); + } + this.log( + `Found initPTS at playlist time: ${timeOffset} offset: ${initPTS! / timescale!} (${initPTS}/${timescale}) trackId: ${trackId}`, + ); this._initPTS = { baseTime: initPTS as number, timescale: timescale as number, + trackId: trackId as number, }; this._initDTS = { baseTime: initDTS as number, timescale: timescale as number, + trackId: trackId as number, }; } else { initPTS = timescale = undefined; @@ -1134,8 +1147,8 @@ function findKeyframeIndex(samples: Array): number { export function flushTextTrackMetadataCueSamples( track: DemuxedMetadataTrack, timeOffset: number, - initPTS: RationalTimestamp, - initDTS: RationalTimestamp, + initPTS: TimestampOffset, + initDTS: TimestampOffset, ): RemuxedMetadata | undefined { const length = track.samples.length; if (!length) { diff --git a/src/remux/passthrough-remuxer.ts b/src/remux/passthrough-remuxer.ts index 22faa8a4503..488b504254c 100644 --- a/src/remux/passthrough-remuxer.ts +++ b/src/remux/passthrough-remuxer.ts @@ -25,14 +25,14 @@ import type { import type { TrackSet } from '../types/track'; import type { TypeSupported } from '../utils/codecs'; import type { InitData, InitDataTrack, TrackTimes } from '../utils/mp4-tools'; -import type { RationalTimestamp } from '../utils/timescale-conversion'; +import type { TimestampOffset } from '../utils/timescale-conversion'; class PassThroughRemuxer extends Logger implements Remuxer { private emitInitSegment: boolean = false; private audioCodec?: string; private videoCodec?: string; private initData?: InitData; - private initPTS: (RationalTimestamp & { trackId?: number }) | null = null; + private initPTS: TimestampOffset | null = null; private initTracks?: TrackSet; private lastEndTime: number | null = null; private isVideoContiguous: boolean = false; @@ -48,7 +48,7 @@ class PassThroughRemuxer extends Logger implements Remuxer { public destroy() {} - public resetTimeStamp(defaultInitPTS: RationalTimestamp | null) { + public resetTimeStamp(defaultInitPTS: TimestampOffset | null) { this.lastEndTime = null; const initPTS = this.initPTS; if (initPTS && defaultInitPTS) { @@ -68,64 +68,81 @@ class PassThroughRemuxer extends Logger implements Remuxer { } public resetInitSegment( - initSegment: Uint8Array | undefined, + initSegment: Uint8Array | undefined, audioCodec: string | undefined, videoCodec: string | undefined, decryptdata: DecryptData | null, ) { this.audioCodec = audioCodec; this.videoCodec = videoCodec; - this.generateInitSegment(patchEncyptionData(initSegment, decryptdata)); + this.generateInitSegment(initSegment, decryptdata); this.emitInitSegment = true; } - private generateInitSegment(initSegment: Uint8Array | undefined): void { + private generateInitSegment( + initSegment: Uint8Array | undefined, + decryptdata?: DecryptData | null, + ) { let { audioCodec, videoCodec } = this; if (!initSegment?.byteLength) { this.initTracks = undefined; this.initData = undefined; return; } - const initData = (this.initData = parseInitSegment(initSegment)); + const { audio, video } = (this.initData = parseInitSegment(initSegment)); + + if (decryptdata) { + patchEncyptionData(initSegment, decryptdata); + } else { + const eitherTrack = audio || video; + if (eitherTrack?.encrypted) { + this.warn( + `Init segment with encrypted track with has no key ("${eitherTrack.codec}")!`, + ); + } + } // Get codec from initSegment - if (initData.audio) { + if (audio) { audioCodec = getParsedTrackCodec( - initData.audio, + audio, ElementaryStreamTypes.AUDIO, this, ); } - if (initData.video) { + if (video) { videoCodec = getParsedTrackCodec( - initData.video, + video, ElementaryStreamTypes.VIDEO, this, ); } const tracks: TrackSet = {}; - if (initData.audio && initData.video) { + if (audio && video) { tracks.audiovideo = { container: 'video/mp4', codec: audioCodec + ',' + videoCodec, - supplemental: initData.video.supplemental, + supplemental: video.supplemental, + encrypted: video.encrypted, initSegment, id: 'main', }; - } else if (initData.audio) { + } else if (audio) { tracks.audio = { container: 'audio/mp4', codec: audioCodec, + encrypted: audio.encrypted, initSegment, id: 'audio', }; - } else if (initData.video) { + } else if (video) { tracks.video = { container: 'video/mp4', codec: videoCodec, - supplemental: initData.video.supplemental, + supplemental: video.supplemental, + encrypted: video.encrypted, initSegment, id: 'main', }; @@ -161,8 +178,8 @@ class PassThroughRemuxer extends Logger implements Remuxer { // The binary segment data is added to the videoTrack in the mp4demuxer. We don't check to see if the data is only // audio or video (or both); adding it to video was an arbitrary choice. - const data = videoTrack.samples as Uint8Array; - if (!data?.length) { + const data = videoTrack.samples; + if (!data.length) { return result; } @@ -182,7 +199,7 @@ class PassThroughRemuxer extends Logger implements Remuxer { return result; } if (this.emitInitSegment) { - initSegment.tracks = this.initTracks as TrackSet; + initSegment.tracks = this.initTracks; this.emitInitSegment = false; } @@ -199,58 +216,74 @@ class PassThroughRemuxer extends Logger implements Remuxer { const videoEndTime = toStartEndOrDefault(videoSampleTimestamps, 0, true); const audioEndTime = toStartEndOrDefault(audioSampleTimestamps, 0, true); - let baseOffsetSamples: TrackTimes | undefined; let decodeTime = timeOffset; - let duration: number = 0; - if ( + let duration = 0; + + const syncOnAudio = audioSampleTimestamps && (!videoSampleTimestamps || (!initPTS && audioStartTime < videoStartTime) || - (initPTS && initPTS.trackId === initData.audio!.id)) - ) { - initSegment.trackId = initData.audio!.id; - baseOffsetSamples = audioSampleTimestamps; - duration = audioEndTime - audioStartTime; - } else if (videoSampleTimestamps) { - initSegment.trackId = initData.video!.id; - baseOffsetSamples = videoSampleTimestamps; - duration = videoEndTime - videoStartTime; - } + (initPTS && initPTS.trackId === initData.audio!.id)); + const baseOffsetSamples = syncOnAudio + ? audioSampleTimestamps + : videoSampleTimestamps; + if (baseOffsetSamples) { const timescale = baseOffsetSamples.timescale; + const baseTime = baseOffsetSamples.start - timeOffset * timescale; + const trackId = syncOnAudio ? initData.audio!.id : initData.video!.id; + decodeTime = baseOffsetSamples.start / timescale; - initSegment.initPTS = baseOffsetSamples.start - timeOffset * timescale; - initSegment.timescale = timescale; - if (!initPTS) { - this.initPTS = initPTS = { - baseTime: initSegment.initPTS, - timescale, - trackId: initSegment.trackId, - }; - } - } + duration = syncOnAudio + ? audioEndTime - audioStartTime + : videoEndTime - videoStartTime; - if ( - (accurateTimeOffset || !initPTS) && - (isInvalidInitPts(initPTS, decodeTime, timeOffset, duration) || - initSegment.timescale !== initPTS.timescale) - ) { - initSegment.initPTS = decodeTime - timeOffset; - initSegment.timescale = 1; - if (initPTS && initPTS.timescale === 1) { - this.warn( - `Adjusting initPTS @${timeOffset} from ${initPTS.baseTime / initPTS.timescale} to ${initSegment.initPTS}`, + if ( + (accurateTimeOffset || !initPTS) && + (isInvalidInitPts(initPTS, decodeTime, timeOffset, duration) || + timescale !== initPTS.timescale) + ) { + if (initPTS) { + this.warn( + `Timestamps at playlist time: ${accurateTimeOffset ? '' : '~'}${timeOffset} ${baseTime / timescale} != initPTS: ${initPTS.baseTime / initPTS.timescale} (${initPTS.baseTime}/${initPTS.timescale}) trackId: ${initPTS.trackId}`, + ); + } + this.log( + `Found initPTS at playlist time: ${timeOffset} offset: ${decodeTime - timeOffset} (${baseTime}/${timescale}) trackId: ${trackId}`, ); + initPTS = null; + initSegment.initPTS = baseTime; + initSegment.timescale = timescale; + initSegment.trackId = trackId; + } + } else { + this.warn( + `No audio or video samples found for initPTS at playlist time: ${timeOffset}`, + ); + } + if (!initPTS) { + if ( + !initSegment.timescale || + initSegment.trackId === undefined || + initSegment.initPTS === undefined + ) { + this.warn('Could not set initPTS'); + initSegment.initPTS = decodeTime; + initSegment.timescale = 1; + initSegment.trackId = -1; } this.initPTS = initPTS = { baseTime: initSegment.initPTS, - timescale: 1, + timescale: initSegment.timescale, + trackId: initSegment.trackId, }; + } else { + initSegment.initPTS = initPTS.baseTime; + initSegment.timescale = initPTS.timescale; + initSegment.trackId = initPTS.trackId; } - const startTime = audioTrack - ? decodeTime - initPTS.baseTime / initPTS.timescale - : (lastEndTime as number); + const startTime = decodeTime - initPTS.baseTime / initPTS.timescale; const endTime = startTime + duration; if (duration > 0) { @@ -272,6 +305,10 @@ class PassThroughRemuxer extends Logger implements Remuxer { type += 'video'; } + const encrypted = + (initData.audio ? initData.audio.encrypted : false) || + (initData.video ? initData.video.encrypted : false); + const track: RemuxedTrack = { data1: data, startPTS: startTime, @@ -283,6 +320,7 @@ class PassThroughRemuxer extends Logger implements Remuxer { hasVideo, nb: 1, dropped: 0, + encrypted, }; result.audio = hasAudio && !hasVideo ? track : undefined; @@ -348,7 +386,7 @@ function toStartEndOrDefault( } function isInvalidInitPts( - initPTS: RationalTimestamp | null, + initPTS: TimestampOffset | null, startDTS: number, timeOffset: number, duration: number, @@ -367,7 +405,7 @@ function getParsedTrackCodec( type: ElementaryStreamTypes.AUDIO | ElementaryStreamTypes.VIDEO, logger: ILogger, ): string { - const parsedCodec = track?.codec; + const parsedCodec = track.codec; if (parsedCodec && parsedCodec.length > 4) { return parsedCodec; } diff --git a/src/types/buffer.ts b/src/types/buffer.ts index 396c739daf9..a5eb5848884 100644 --- a/src/types/buffer.ts +++ b/src/types/buffer.ts @@ -21,6 +21,7 @@ export interface BaseTrack { container: string; codec?: string; supplemental?: string; + encrypted?: boolean; levelCodec?: string; pendingCodec?: string; metadata?: { diff --git a/src/types/events.ts b/src/types/events.ts index 7a28b21084b..d3f5943dd93 100644 --- a/src/types/events.ts +++ b/src/types/events.ts @@ -91,7 +91,7 @@ export interface BufferAppendingData { chunkMeta: ChunkMetadata; offset?: number | undefined; parent: PlaylistLevelType; - data: Uint8Array; + data: Uint8Array; } export interface BufferAppendedData { @@ -252,11 +252,6 @@ export interface LevelPTSUpdatedData { end: number; } -export interface KeyStatusesChangedData { - keySystem: string; - keyStatuses: MediaKeyStatusMap; -} - export interface AudioTrackSwitchingData extends MediaPlaylist {} export interface AudioTrackSwitchedData extends MediaPlaylist {} @@ -324,6 +319,7 @@ export interface ErrorData { bytes?: number; chunkMeta?: ChunkMetadata; context?: PlaylistLoaderContext; + decryptdata?: LevelKey; event?: keyof HlsListeners | 'demuxerWorker'; frag?: Fragment; part?: Part | null; @@ -379,6 +375,7 @@ export interface InitPTSFoundData { frag: MediaFragment; initPTS: number; timescale: number; + trackId: number; } export interface FragLoadingData { diff --git a/src/types/remuxer.ts b/src/types/remuxer.ts index b6f2b6019b6..c1e0a0259de 100644 --- a/src/types/remuxer.ts +++ b/src/types/remuxer.ts @@ -11,7 +11,7 @@ import type { import type { PlaylistLevelType } from './loader'; import type { TrackSet } from './track'; import type { DecryptData } from '../loader/level-key'; -import type { RationalTimestamp } from '../utils/timescale-conversion'; +import type { TimestampOffset } from '../utils/timescale-conversion'; export interface Remuxer { remux( @@ -30,7 +30,7 @@ export interface Remuxer { videoCodec: string | undefined, decryptdata: DecryptData | null, ): void; - resetTimeStamp(defaultInitPTS: RationalTimestamp | null): void; + resetTimeStamp(defaultInitPTS: TimestampOffset | null): void; resetNextTimestamp(): void; destroy(): void; } @@ -52,6 +52,7 @@ export interface RemuxedTrack { transferredData1?: ArrayBuffer; transferredData2?: ArrayBuffer; dropped?: number; + encrypted?: boolean; } export interface RemuxedMetadata { diff --git a/src/utils/arrays.ts b/src/utils/arrays.ts new file mode 100644 index 00000000000..d2272794127 --- /dev/null +++ b/src/utils/arrays.ts @@ -0,0 +1,22 @@ +export function arrayValuesMatch( + a: (string | number)[] | Uint8Array, + b: (string | number)[] | Uint8Array, +): boolean { + if (a.length === b.length) { + return !a.some((value: string | number, i: number) => value !== b[i]); + } + return false; +} + +export function optionalArrayValuesMatch( + a: (string | number)[] | Uint8Array | null | undefined, + b: (string | number)[] | Uint8Array | null | undefined, +): boolean { + if (!a && !b) { + return true; + } + if (!a || !b) { + return false; + } + return arrayValuesMatch(a, b); +} diff --git a/src/utils/discontinuities.ts b/src/utils/discontinuities.ts index 795365e05b9..a6abea9ef71 100644 --- a/src/utils/discontinuities.ts +++ b/src/utils/discontinuities.ts @@ -31,11 +31,10 @@ export function shouldAlignOnDiscontinuities( } function adjustFragmentStart(frag: Fragment, sliding: number) { - if (frag) { - const start = frag.start + sliding; - frag.start = frag.startPTS = start; - frag.endPTS = start + frag.duration; - } + const start = frag.start + sliding; + frag.startPTS = start; + frag.setStart(start); + frag.endPTS = start + frag.duration; } export function adjustSlidingStart(sliding: number, details: LevelDetails) { @@ -68,13 +67,13 @@ export function alignStream( return; } alignDiscontinuities(details, switchDetails); - if (!details.alignedSliding && switchDetails) { + if (!details.alignedSliding) { // If the PTS wasn't figured out via discontinuity sequence that means there was no CC increase within the level. // Aligning via Program Date Time should therefore be reliable, since PDT should be the same within the same // discontinuity sequence. alignMediaPlaylistByPDT(details, switchDetails); } - if (!details.alignedSliding && switchDetails && !details.skippedSegments) { + if (!details.alignedSliding && !details.skippedSegments) { // Try to align on sn so that we pick a better start fragment. // Do not perform this on playlists with delta updates as this is only to align levels on switch // and adjustSliding only adjusts fragments after skippedSegments. diff --git a/src/utils/error-helper.ts b/src/utils/error-helper.ts index dc1eb18aa5d..b42ac693f83 100644 --- a/src/utils/error-helper.ts +++ b/src/utils/error-helper.ts @@ -71,10 +71,14 @@ export function shouldRetry( : retry; } -export function retryForHttpStatus(httpStatus: number | undefined) { +export function retryForHttpStatus(httpStatus: number | undefined): boolean { // Do not retry on status 4xx, status 0 (CORS error), or undefined (decrypt/gap/parse error) return ( - (httpStatus === 0 && navigator.onLine === false) || + offlineHttpStatus(httpStatus) || (!!httpStatus && (httpStatus < 400 || httpStatus > 499)) ); } + +export function offlineHttpStatus(httpStatus: number | undefined): boolean { + return httpStatus === 0 && navigator.onLine === false; +} diff --git a/src/utils/hex.ts b/src/utils/hex.ts index a7db9633fea..2bc66b55132 100644 --- a/src/utils/hex.ts +++ b/src/utils/hex.ts @@ -2,20 +2,18 @@ * hex dump helper class */ -const Hex = { - hexDump: function (array: Uint8Array) { - let str = ''; - for (let i = 0; i < array.length; i++) { - let h = array[i].toString(16); - if (h.length < 2) { - h = '0' + h; - } - - str += h; +export function arrayToHex(array: Uint8Array | number[]) { + let str = ''; + for (let i = 0; i < array.length; i++) { + let h = array[i].toString(16); + if (h.length < 2) { + h = '0' + h; } - return str; - }, -}; + + str += h; + } + return str; +} export function hexToArrayBuffer(str: string): ArrayBuffer { return Uint8Array.from( @@ -27,4 +25,8 @@ export function hexToArrayBuffer(str: string): ArrayBuffer { ).buffer; } +const Hex = { + hexDump: arrayToHex, +}; + export default Hex; diff --git a/src/utils/imsc1-ttml-parser.ts b/src/utils/imsc1-ttml-parser.ts index 764176fc10e..2a6dc5b6d9c 100644 --- a/src/utils/imsc1-ttml-parser.ts +++ b/src/utils/imsc1-ttml-parser.ts @@ -4,7 +4,7 @@ import { toTimescaleFromScale } from './timescale-conversion'; import VTTCue from './vttcue'; import { parseTimeStamp } from './vttparser'; import { generateCueId } from './webvtt-parser'; -import type { RationalTimestamp } from './timescale-conversion'; +import type { TimestampOffset } from './timescale-conversion'; export const IMSC1_CODEC = 'stpp.ttml.im1t'; @@ -24,7 +24,7 @@ const textAlignToLineAlign: Partial> = { export function parseIMSC1( payload: ArrayBuffer, - initPTS: RationalTimestamp, + initPTS: TimestampOffset, callBack: (cues: Array) => any, errorCallBack: (error: Error) => any, ) { diff --git a/src/utils/level-helper.ts b/src/utils/level-helper.ts index 0968f3d4d65..467dac78a7a 100644 --- a/src/utils/level-helper.ts +++ b/src/utils/level-helper.ts @@ -2,10 +2,10 @@ * Provides methods dealing with playlist sliding and drift */ -import { logger } from './logger'; import { stringify } from './safe-json-stringify'; import { DateRange } from '../loader/date-range'; import { assignProgramDateTime, mapDateRanges } from '../loader/m3u8-parser'; +import type { ILogger } from './logger'; import type { Fragment, MediaFragment, Part } from '../loader/fragment'; import type { LevelDetails } from '../loader/level-details'; import type { Level } from '../types/level'; @@ -67,6 +67,7 @@ export function updateFragPTSDTS( endPTS: number, startDTS: number, endDTS: number, + logger: ILogger, ): number { const parsedMediaDuration = endPTS - startPTS; if (parsedMediaDuration <= 0) { @@ -81,7 +82,11 @@ export function updateFragPTSDTS( if (Number.isFinite(fragStartPts)) { // delta PTS between audio and video const deltaPTS = Math.abs(fragStartPts - startPTS); - if (!Number.isFinite(frag.deltaPTS as number)) { + if (details && deltaPTS > details.totalduration) { + logger.warn( + `media timestamps and playlist times differ by ${deltaPTS}s for level ${frag.level} ${details.url}`, + ); + } else if (!Number.isFinite(frag.deltaPTS as number)) { frag.deltaPTS = deltaPTS; } else { frag.deltaPTS = Math.max(deltaPTS, frag.deltaPTS as number); @@ -89,11 +94,14 @@ export function updateFragPTSDTS( maxStartPTS = Math.max(startPTS, fragStartPts); startPTS = Math.min(startPTS, fragStartPts); - startDTS = Math.min(startDTS, frag.startDTS as number); + startDTS = + frag.startDTS !== undefined + ? Math.min(startDTS, frag.startDTS) + : startDTS; minEndPTS = Math.min(endPTS, fragEndPts); endPTS = Math.max(endPTS, fragEndPts); - endDTS = Math.max(endDTS, frag.endDTS as number); + endDTS = frag.endDTS !== undefined ? Math.max(endDTS, frag.endDTS) : endDTS; } const drift = startPTS - frag.start; @@ -142,6 +150,7 @@ export function updateFragPTSDTS( export function mergeDetails( oldDetails: LevelDetails, newDetails: LevelDetails, + logger: ILogger, ) { if (oldDetails === newDetails) { return; @@ -223,7 +232,7 @@ export function mergeDetails( if (currentInitSegment) { fragmentsToCheck.forEach((frag) => { if ( - frag && + (frag as any) && (!frag.initSegment || frag.initSegment.relurl === currentInitSegment?.relurl) ) { @@ -233,7 +242,7 @@ export function mergeDetails( } if (newDetails.skippedSegments) { - newDetails.deltaUpdateFailed = newFragments.some((frag) => !frag); + newDetails.deltaUpdateFailed = newFragments.some((frag) => !frag as any); if (newDetails.deltaUpdateFailed) { logger.warn( '[level-helper] Previous playlist missing segments skipped in delta playlist', @@ -247,6 +256,7 @@ export function mergeDetails( newDetails.dateRanges = mergeDateRanges( oldDetails.dateRanges, newDetails, + logger, ); } const programDateTimes = oldDetails.fragments.filter( @@ -294,6 +304,7 @@ export function mergeDetails( PTSFrag.endPTS as number, PTSFrag.startDTS as number, PTSFrag.endDTS as number, + logger, ); } else { // ensure that delta is within oldFragments range @@ -328,9 +339,10 @@ export function mergeDetails( } function mergeDateRanges( - oldDateRanges: Record, + oldDateRanges: Record, newDetails: LevelDetails, -): Record { + logger: ILogger, +): Record { const { dateRanges: deltaDateRanges, recentlyRemovedDateranges } = newDetails; const dateRanges = Object.assign({}, oldDateRanges); if (recentlyRemovedDateranges) { @@ -340,27 +352,25 @@ function mergeDateRanges( } const mergeIds = Object.keys(dateRanges); const mergeCount = mergeIds.length; - if (mergeCount) { - Object.keys(deltaDateRanges).forEach((id) => { - const mergedDateRange = dateRanges[id]; - const dateRange = new DateRange( - deltaDateRanges[id].attr, - mergedDateRange, - ); - if (dateRange.isValid) { - dateRanges[id] = dateRange; - if (!mergedDateRange) { - dateRange.tagOrder += mergeCount; - } - } else { - logger.warn( - `Ignoring invalid Playlist Delta Update DATERANGE tag: "${stringify( - deltaDateRanges[id].attr, - )}"`, - ); - } - }); + if (!mergeCount) { + return deltaDateRanges; } + Object.keys(deltaDateRanges).forEach((id) => { + const mergedDateRange = dateRanges[id]; + const dateRange = new DateRange(deltaDateRanges[id]!.attr, mergedDateRange); + if (dateRange.isValid) { + dateRanges[id] = dateRange; + if (!mergedDateRange) { + dateRange.tagOrder += mergeCount; + } + } else { + logger.warn( + `Ignoring invalid Playlist Delta Update DATERANGE tag: "${stringify( + deltaDateRanges[id]!.attr, + )}"`, + ); + } + }); return dateRanges; } @@ -375,8 +385,8 @@ export function mapPartIntersection( const oldPart = oldParts[i]; const newPart = newParts[i + delta]; if ( - oldPart && - newPart && + (oldPart as any) && + (newPart as any) && oldPart.index === newPart.index && oldPart.fragment.sn === newPart.fragment.sn ) { @@ -413,13 +423,15 @@ export function mapFragmentIntersection( for (let i = start; i <= end; i++) { const oldFrag = oldFrags[delta + i]; let newFrag = newFrags[i]; - if (skippedSegments && !newFrag && oldFrag) { + if (skippedSegments && (!newFrag as any) && (oldFrag as any)) { // Fill in skipped segments in delta playlist newFrag = newDetails.fragments[i] = oldFrag; } - if (oldFrag && newFrag) { + if ((oldFrag as any) && (newFrag as any)) { intersectionFn(oldFrag, newFrag, i, newFrags); - if (oldFrag.url && oldFrag.url !== newFrag.url) { + const uriBefore = oldFrag.relurl; + const uriAfter = newFrag.relurl; + if (uriBefore && notEqualAfterStrippingQueries(uriBefore, uriAfter)) { newDetails.playlistParsingError = getSequenceError( `media sequence mismatch ${newFrag.sn}:`, oldDetails, @@ -537,8 +549,9 @@ export function getFragmentWithSN( if (!details) { return null; } - let fragment: MediaFragment | undefined = - details.fragments[sn - details.startSN]; + let fragment = details.fragments[sn - details.startSN] as + | MediaFragment + | undefined; if (fragment) { return fragment; } @@ -589,3 +602,17 @@ export function reassignFragmentLevelIndexes(levels: Level[]) { }); }); } + +function notEqualAfterStrippingQueries( + uriBefore: string, + uriAfter: string | undefined, +): boolean { + if (uriBefore !== uriAfter && uriAfter) { + return stripQuery(uriBefore) !== stripQuery(uriAfter); + } + return false; +} + +function stripQuery(uri: string): string { + return uri.replace(/\?[^?]*$/, ''); +} diff --git a/src/utils/mediacapabilities-helper.ts b/src/utils/mediacapabilities-helper.ts index a61181c25e8..280a30c9561 100644 --- a/src/utils/mediacapabilities-helper.ts +++ b/src/utils/mediacapabilities-helper.ts @@ -16,16 +16,26 @@ export type MediaDecodingInfo = { error?: Error; }; +// @ts-ignore +const supportedResult: MediaCapabilitiesDecodingInfo = { + supported: true, + powerEfficient: true, + smooth: true, + // keySystemAccess: null, +}; + +// @ts-ignore +const unsupportedResult: MediaCapabilitiesDecodingInfo = { + supported: false, + smooth: false, + powerEfficient: false, + // keySystemAccess: null, +}; + export const SUPPORTED_INFO_DEFAULT: MediaDecodingInfo = { supported: true, configurations: [] as MediaDecodingConfiguration[], - decodingInfoResults: [ - { - supported: true, - powerEfficient: true, - smooth: true, - }, - ], + decodingInfoResults: [supportedResult], } as const; export function getUnsupportedResult( @@ -35,13 +45,7 @@ export function getUnsupportedResult( return { supported: false, configurations, - decodingInfoResults: [ - { - supported: false, - smooth: false, - powerEfficient: false, - }, - ], + decodingInfoResults: [unsupportedResult], error, }; } @@ -113,7 +117,10 @@ export function getMediaDecodingInfoPromise( level: Level, audioTracksByGroup: AudioTracksByGroup, mediaCapabilities: MediaCapabilities | undefined, - cache: Record> = {}, + cache: Record< + string, + Promise | undefined + > = {}, ): Promise { const videoCodecs = level.videoCodec; if ((!videoCodecs && !level.audioCodec) || !mediaCapabilities) { diff --git a/src/utils/mediakeys-helper.ts b/src/utils/mediakeys-helper.ts index c32f2e9bce0..5d9f5e2f64e 100755 --- a/src/utils/mediakeys-helper.ts +++ b/src/utils/mediakeys-helper.ts @@ -167,13 +167,14 @@ function createMediaKeySystemConfigurations( } export function isPersistentSessionType( - drmSystemOptions: DRMSystemOptions, + drmSystemOptions: DRMSystemOptions | undefined, ): boolean { return ( - drmSystemOptions.sessionType === 'persistent-license' || - !!drmSystemOptions.sessionTypes?.some( - (type) => type === 'persistent-license', - ) + !!drmSystemOptions && + (drmSystemOptions.sessionType === 'persistent-license' || + !!drmSystemOptions.sessionTypes?.some( + (type) => type === 'persistent-license', + )) ); } diff --git a/src/utils/mp4-tools.ts b/src/utils/mp4-tools.ts index faf306c638f..86d536f7047 100644 --- a/src/utils/mp4-tools.ts +++ b/src/utils/mp4-tools.ts @@ -1,5 +1,5 @@ import { utf8ArrayToStr } from '@svta/common-media-library/utils/utf8ArrayToStr'; -import Hex from './hex'; +import { arrayToHex } from './hex'; import { ElementaryStreamTypes } from '../loader/fragment'; import { logger } from '../utils/logger'; import type { KeySystemIds } from './mediakeys-helper'; @@ -224,6 +224,7 @@ export interface InitDataTrack { timescale: number; id: number; codec: string; + encrypted: boolean; supplemental: string | undefined; } @@ -262,15 +263,15 @@ export function parseInitSegment(initSegment: Uint8Array): InitData { for (let i = 0; i < traks.length; i++) { const trak = traks[i]; const tkhd = findBox(trak, ['tkhd'])[0]; - if (tkhd) { + if (tkhd as any) { let version = tkhd[0]; const trackId = readUint32(tkhd, version === 0 ? 12 : 20); const mdhd = findBox(trak, ['mdia', 'mdhd'])[0]; - if (mdhd) { + if (mdhd as any) { version = mdhd[0]; const timescale = readUint32(mdhd, version === 0 ? 12 : 20); const hdlr = findBox(trak, ['mdia', 'hdlr'])[0]; - if (hdlr) { + if (hdlr as any) { const hdlrType = bin2str(hdlr.subarray(8, 12)); const type: HdlrType | undefined = { soun: ElementaryStreamTypes.AUDIO as const, @@ -324,11 +325,11 @@ function parseStsd(stsd: Uint8Array): StsdData { const sinfs = findBox(encBoxChildren, ['sinf']); sinfs.forEach((sinf) => { const schm = findBox(sinf, ['schm'])[0]; - if (schm) { + if (schm as any) { const scheme = bin2str(schm.subarray(4, 8)); if (scheme === 'cbcs' || scheme === 'cenc') { const frma = findBox(sinf, ['frma'])[0]; - if (frma) { + if (frma as any) { // for encrypted content codec fourCC will be in frma codec = bin2str(frma); } @@ -344,7 +345,7 @@ function parseStsd(stsd: Uint8Array): StsdData { case 'avc4': { // extract profile + compatibility + level out of avcC box const avcCBox = findBox(sampleEntriesEnd, ['avcC'])[0]; - if (avcCBox && avcCBox.length > 3) { + if ((avcCBox as any) && avcCBox.length > 3) { codec += '.' + toHex(avcCBox[1]) + toHex(avcCBox[2]) + toHex(avcCBox[3]); supplemental = parseSupplementalDoViCodec( @@ -357,7 +358,7 @@ function parseStsd(stsd: Uint8Array): StsdData { case 'mp4a': { const codecBox = findBox(sampleEntries, [fourCC])[0]; const esdsBox = findBox(codecBox.subarray(28), ['esds'])[0]; - if (esdsBox && esdsBox.length > 7) { + if ((esdsBox as any) && esdsBox.length > 7) { let i = 4; // ES Descriptor tag if (esdsBox[i++] !== 0x03) { @@ -402,7 +403,7 @@ function parseStsd(stsd: Uint8Array): StsdData { case 'hvc1': case 'hev1': { const hvcCBox = findBox(sampleEntriesEnd, ['hvcC'])[0]; - if (hvcCBox && hvcCBox.length > 12) { + if ((hvcCBox as any) && hvcCBox.length > 12) { const profileByte = hvcCBox[1]; const profileSpace = ['', 'A', 'B', 'C'][profileByte >> 6]; const generalProfileIdc = profileByte & 0x1f; @@ -440,7 +441,7 @@ function parseStsd(stsd: Uint8Array): StsdData { } case 'vp09': { const vpcCBox = findBox(sampleEntriesEnd, ['vpcC'])[0]; - if (vpcCBox && vpcCBox.length > 6) { + if ((vpcCBox as any) && vpcCBox.length > 6) { const profile = vpcCBox[4]; const level = vpcCBox[5]; const bitDepth = (vpcCBox[6] >> 4) & 0x0f; @@ -456,7 +457,7 @@ function parseStsd(stsd: Uint8Array): StsdData { } case 'av01': { const av1CBox = findBox(sampleEntriesEnd, ['av1C'])[0]; - if (av1CBox && av1CBox.length > 2) { + if ((av1CBox as any) && av1CBox.length > 2) { const profile = av1CBox[1] >>> 5; const level = av1CBox[1] & 0x1f; const tierFlag = av1CBox[2] >>> 7 ? 'H' : 'M'; @@ -526,7 +527,7 @@ function parseSupplementalDoViCodec( const dvXCBox = dvvCResult.length ? dvvCResult[0] : findBox(sampleEntriesEnd, ['dvcC'])[0]; // used by DoVi Profiles up to 7 and 20 - if (dvXCBox) { + if (dvXCBox as any) { const doViProfile = (dvXCBox[2] >> 1) & 0x7f; const doViLevel = ((dvXCBox[2] << 5) & 0x20) | ((dvXCBox[3] >> 3) & 0x1f); return ( @@ -564,11 +565,11 @@ function addLeadingZero(num: number): string { } export function patchEncyptionData( - initSegment: Uint8Array | undefined, + initSegment: Uint8Array | undefined, decryptdata: DecryptData | null, -): Uint8Array | undefined { +) { if (!initSegment || !decryptdata) { - return initSegment; + return; } const keyId = decryptdata.keyId; if (keyId && decryptdata.isCommonEncryption) { @@ -595,7 +596,7 @@ export function patchEncyptionData( logger.log( `[eme] Patching keyId in 'enc${ isAudio ? 'a' : 'v' - }>sinf>>tenc' box: ${Hex.hexDump(tencKeyId)} -> ${Hex.hexDump( + }>sinf>>tenc' box: ${arrayToHex(tencKeyId)} -> ${arrayToHex( keyId, )}`, ); @@ -606,13 +607,11 @@ export function patchEncyptionData( }); }); } - - return initSegment; } export function parseSinf(sinf: Uint8Array): Uint8Array | null { const schm = findBox(sinf, ['schm'])[0]; - if (schm) { + if (schm as any) { const scheme = bin2str(schm.subarray(4, 8)); if (scheme === 'cbcs' || scheme === 'cenc') { return findBox(sinf, ['schi', 'tenc'])[0]; @@ -664,19 +663,18 @@ export function getSampleData( if (!track) { continue; } - const trackTimes: TrackTimes = - tracks[id] || - (tracks[id] = { - start: NaN, - duration: 0, - sampleCount: 0, - timescale: track.timescale, - type: track.type, - }); + (tracks[id] as any) ||= { + start: NaN, + duration: 0, + sampleCount: 0, + timescale: track.timescale, + type: track.type, + }; + const trackTimes: TrackTimes = tracks[id]; // get start DTS const tfdt = findBox(traf, ['tfdt'])[0]; - if (tfdt) { + if (tfdt as any) { const version = tfdt[0]; let baseTime = readUint32(tfdt, 4); if (version === 1) { @@ -825,7 +823,9 @@ export function getSampleData( } // TODO: Check if the last moof+mdat pair is part of the valid range -export function segmentValidRange(data: Uint8Array): SegmentedRange { +export function segmentValidRange( + data: Uint8Array, +): SegmentedRange { const segmentedRange: SegmentedRange = { valid: null, remainder: null, @@ -844,8 +844,8 @@ export function segmentValidRange(data: Uint8Array): SegmentedRange { } export interface SegmentedRange { - valid: Uint8Array | null; - remainder: Uint8Array | null; + valid: Uint8Array | null; + remainder: Uint8Array | null; } export function appendUint8Array(data1: Uint8Array, data2: Uint8Array) { @@ -892,7 +892,7 @@ export function parseSamples( return result / timescale; })[0]; - if (baseTime !== undefined) { + if ((baseTime as any) !== undefined) { timeOffset = baseTime; } @@ -1331,7 +1331,7 @@ export function mp4pssh( kidCount = new Uint8Array(); } const dataSize = new Uint8Array(4); - if (data && data.byteLength > 0) { + if (data.byteLength > 0) { new DataView(dataSize.buffer).setUint32(0, data.byteLength, false); } return mp4Box( @@ -1346,7 +1346,7 @@ export function mp4pssh( kidCount, kids, dataSize, - data || new Uint8Array(), + data, ); } @@ -1401,7 +1401,7 @@ function parsePssh(view: DataView): PsshData | PsshInvalidResult { return { offset, size }; } const buffer = view.buffer; - const systemId = Hex.hexDump( + const systemId = arrayToHex( new Uint8Array(buffer, offset + 12, 16), ) as KeySystemIds; @@ -1411,7 +1411,7 @@ function parsePssh(view: DataView): PsshData | PsshInvalidResult { if (version === 0) { dataSizeOffset = 28; - } else if (version === 1) { + } else { const kidCounts = view.getUint32(28); if (!kidCounts || length < 32 + kidCounts * 16) { return { offset, size }; diff --git a/src/utils/texttrack-utils.ts b/src/utils/texttrack-utils.ts index 7e9b2297a74..39c223554ad 100644 --- a/src/utils/texttrack-utils.ts +++ b/src/utils/texttrack-utils.ts @@ -69,7 +69,10 @@ export function addCueToTrack(track: TextTrack, cue: VTTCue) { } } -export function clearCurrentCues(track: TextTrack, enterHandler?: () => void) { +export function clearCurrentCues( + track: TextTrack, + enterHandler?: (e?: Event) => void, +) { // When track.mode is disabled, track.cues will be null. // To guarantee the removal of cues, we need to temporarily // change the mode to hidden diff --git a/src/utils/timescale-conversion.ts b/src/utils/timescale-conversion.ts index f46aa531871..c2762a6ba32 100644 --- a/src/utils/timescale-conversion.ts +++ b/src/utils/timescale-conversion.ts @@ -5,6 +5,8 @@ export type RationalTimestamp = { timescale: number; // ticks per second }; +export type TimestampOffset = RationalTimestamp & { trackId: number }; + export function toTimescaleFromBase( baseTime: number, destScale: number, diff --git a/src/utils/webvtt-parser.ts b/src/utils/webvtt-parser.ts index 7e44e3da932..57a683ee518 100644 --- a/src/utils/webvtt-parser.ts +++ b/src/utils/webvtt-parser.ts @@ -3,7 +3,7 @@ import { hash } from './hash'; import { toMpegTsClockFromTimescale } from './timescale-conversion'; import { VTTParser } from './vttparser'; import { normalizePts } from '../remux/mp4-remuxer'; -import type { RationalTimestamp } from './timescale-conversion'; +import type { TimestampOffset } from './timescale-conversion'; import type { VTTCCs } from '../types/vtt'; const LINEBREAKS = /\r\n|\n\r|\n|\r/g; @@ -80,7 +80,7 @@ const calculateOffset = function (vttCCs: VTTCCs, cc, presentationTime) { export function parseWebVTT( vttByteArray: ArrayBuffer, - initPTS: RationalTimestamp | undefined, + initPTS: TimestampOffset | undefined, vttCCs: VTTCCs, cc: number, timeOffset: number, diff --git a/tests/functional/auto/setup.js b/tests/functional/auto/setup.js index 0180fe2aa59..b0244d62a7f 100644 --- a/tests/functional/auto/setup.js +++ b/tests/functional/auto/setup.js @@ -492,7 +492,7 @@ function getPageURLComponents() { }; } -describe(`testing hls.js playback in the browser on "${browserDescription}"`, function () { +describe(`Testing hls.js playback in ${browserConfig.name} ${browserConfig.version} ${browserConfig.platform ? browserConfig.platform : ''}`, function () { const failedUrls = {}; before(async function () { @@ -696,46 +696,49 @@ ${data.logs} ) { return; } - it( - `should receive video loadeddata event for ${stream.description}`, - testLoadedData.bind(null, url, config) - ); - if (stream.startSeek && !HlsjsLightBuild) { + describe(`${index + 1}. [${name}]: ${stream.description} (${stream.url})`, function () { it( - `seek back to start and play for ${stream.description}`, - testSeekBackToStart.bind(null, url, config) + `should receive video loadeddata event`, + testLoadedData.bind(null, url, config) ); - } - if (stream.abr) { - it( - `should "smooth switch" to highest level and still play after 2s for ${stream.description}`, - testSmoothSwitch.bind(null, url, config) - ); - } + if (stream.startSeek && !HlsjsLightBuild) { + it( + `seek back to start and play`, + testSeekBackToStart.bind(null, url, config) + ); + } - if (stream.live) { - it( - `should seek near the end and receive video seeked event for ${stream.description}`, - testSeekOnLive.bind(null, url, config) - ); - } else if (!HlsjsLightBuild) { - it( - `should buffer up to maxBufferLength or video.duration for ${stream.description}`, - testIdleBufferLength.bind(null, url, config) - ); - it( - `should play ${stream.description}`, - testIsPlayingVOD.bind(null, url, config) - ); + if (stream.abr) { + it( + `should "smooth switch" to highest level and still play after 2s`, + testSmoothSwitch.bind(null, url, config) + ); + } - it( - `should seek 3s from end and receive video ended event for ${stream.description} with 2 or less buffered ranges`, - testSeekOnVOD.bind(null, url, config) - ); - // TODO: Seeking to or past VOD duration should result in the video ending - // it(`should seek on end and receive video ended event for ${stream.description}`, testSeekEndVOD.bind(null, url)); - } + if (stream.live) { + it( + `should seek near the end and receive video seeked event`, + testSeekOnLive.bind(null, url, config) + ); + } else if (!HlsjsLightBuild) { + it( + `should buffer up to maxBufferLength or video.duration`, + testIdleBufferLength.bind(null, url, config) + ); + it( + `should play ${stream.description}`, + testIsPlayingVOD.bind(null, url, config) + ); + + it( + `should seek 3s from end and receive video ended event with 2 or less buffered ranges`, + testSeekOnVOD.bind(null, url, config) + ); + // TODO: Seeking to or past VOD duration should result in the video ending + // it(`should seek on end and receive video ended event`, testSeekEndVOD.bind(null, url)); + } + }); }); }); diff --git a/tests/unit/controller/audio-stream-controller.ts b/tests/unit/controller/audio-stream-controller.ts index 7dd91954a18..d1c24b81d68 100644 --- a/tests/unit/controller/audio-stream-controller.ts +++ b/tests/unit/controller/audio-stream-controller.ts @@ -7,12 +7,13 @@ import { State } from '../../../src/controller/base-stream-controller'; import { FragmentTracker } from '../../../src/controller/fragment-tracker'; import { Events } from '../../../src/events'; import Hls from '../../../src/hls'; +import { Fragment } from '../../../src/loader/fragment'; import KeyLoader from '../../../src/loader/key-loader'; import { LoadStats } from '../../../src/loader/load-stats'; import { Level } from '../../../src/types/level'; +import { PlaylistLevelType } from '../../../src/types/loader'; import { AttrList } from '../../../src/utils/attr-list'; import { adjustSlidingStart } from '../../../src/utils/discontinuities'; -import type { Fragment } from '../../../src/loader/fragment'; import type { LevelDetails } from '../../../src/loader/level-details'; import type { AudioTrackLoadedData, @@ -138,7 +139,7 @@ describe('AudioStreamController', function () { sandbox = sinon.createSandbox(); hls = new Hls(); fragmentTracker = new FragmentTracker(hls); - keyLoader = new KeyLoader(hlsDefaultConfig); + keyLoader = new KeyLoader(hlsDefaultConfig, hls.logger); audioStreamController = new AudioStreamController( hls, fragmentTracker, @@ -180,19 +181,19 @@ describe('AudioStreamController', function () { const getPlaylistData = function ( startSN: number, endSN: number, - type: 'audio' | 'main', + type: PlaylistLevelType, live: boolean, ) { const targetduration = 10; const fragments: Fragment[] = Array.from(new Array(endSN - startSN)).map( - (u, i) => - ({ - sn: i + startSN, - cc: Math.floor((i + startSN) / 10), - start: i * targetduration, - duration: targetduration, - type, - }) as unknown as Fragment, + (u, i) => { + const frag = new Fragment(type, ''); + frag.sn = i + startSN; + frag.cc = Math.floor((i + startSN) / 10); + frag.setStart(i * targetduration); + frag.duration = targetduration; + return frag; + }, ); return { details: { @@ -221,7 +222,12 @@ describe('AudioStreamController', function () { endSN: number, live: boolean = false, ): LevelLoadedData { - const data = getPlaylistData(startSN, endSN, 'main', live); + const data = getPlaylistData( + startSN, + endSN, + PlaylistLevelType.MAIN, + live, + ); const levelData: LevelLoadedData = { ...data, level: 0, @@ -234,7 +240,12 @@ describe('AudioStreamController', function () { endSN: number, live: boolean = false, ): AudioTrackLoadedData { - const data = getPlaylistData(startSN, endSN, 'audio', live); + const data = getPlaylistData( + startSN, + endSN, + PlaylistLevelType.AUDIO, + live, + ); const audioTrackData: AudioTrackLoadedData = { ...data, groupId: 'audio', diff --git a/tests/unit/controller/base-stream-controller.ts b/tests/unit/controller/base-stream-controller.ts index a7ca4754086..52aaef8e679 100644 --- a/tests/unit/controller/base-stream-controller.ts +++ b/tests/unit/controller/base-stream-controller.ts @@ -42,7 +42,7 @@ describe('BaseStreamController', function () { baseStreamController = new BaseStreamController( hls, fragmentTracker, - new KeyLoader(hlsDefaultConfig), + new KeyLoader(hlsDefaultConfig, hls.logger), ) as unknown as BaseStreamControllerTestable; bufferInfo = { len: 1, @@ -68,7 +68,7 @@ describe('BaseStreamController', function () { const frag = new Fragment(PlaylistLevelType.MAIN, '') as MediaFragment; frag.duration = 5; frag.sn = i; - frag.start = i * 5; + frag.setStart(i * 5); details.fragments.push(frag); } details.live = live; diff --git a/tests/unit/controller/buffer-controller.ts b/tests/unit/controller/buffer-controller.ts index 3d89bd8524e..86994802693 100644 --- a/tests/unit/controller/buffer-controller.ts +++ b/tests/unit/controller/buffer-controller.ts @@ -137,7 +137,6 @@ describe('BufferController', function () { .stub(bufferController, 'createSourceBuffers') .callsFake(() => { Object.keys(bufferController.tracks).forEach((type) => { - bufferController.tracks ||= {}; bufferController.tracks[type] = { appendBuffer: () => {}, remove: () => {}, diff --git a/tests/unit/controller/cap-level-controller.js b/tests/unit/controller/cap-level-controller.ts similarity index 90% rename from tests/unit/controller/cap-level-controller.js rename to tests/unit/controller/cap-level-controller.ts index b478d41ee05..401a51eee42 100644 --- a/tests/unit/controller/cap-level-controller.js +++ b/tests/unit/controller/cap-level-controller.ts @@ -1,30 +1,38 @@ +import chai from 'chai'; import sinon from 'sinon'; -import Hls from '../../../src/hls'; +import sinonChai from 'sinon-chai'; import CapLevelController from '../../../src/controller/cap-level-controller'; import { Events } from '../../../src/events'; +import Hls from '../../../src/hls'; +import { Level } from '../../../src/types/level'; +import { parsedLevel } from '../utils/mock-level'; + +chai.use(sinonChai); +const expect = chai.expect; -const levels = [ - { +const parsedLevels = [ + parsedLevel({ width: 360, height: 360, - bandwidth: 1000, - }, - { + bitrate: 1000, + }), + parsedLevel({ width: 540, height: 540, - bandwidth: 2000, - }, - { + bitrate: 2000, + }), + parsedLevel({ width: 540, height: 540, - bandwidth: 3000, - }, - { + bitrate: 3000, + }), + parsedLevel({ width: 720, height: 720, - bandwidth: 4000, - }, + bitrate: 4000, + }), ]; +const levels = parsedLevels.map((parsedLevel) => new Level(parsedLevel)); describe('CapLevelController', function () { describe('getMaxLevelByMediaSize', function () { @@ -63,16 +71,6 @@ describe('CapLevelController', function () { const actual = CapLevelController.getMaxLevelByMediaSize([], 5000, 5000); expect(expected).to.equal(actual); }); - - it('Should return -1 if there levels is undefined', function () { - const expected = -1; - const actual = CapLevelController.getMaxLevelByMediaSize( - undefined, - 5000, - 5000, - ); - expect(expected).to.equal(actual); - }); }); describe('getDimensions', function () { @@ -100,7 +98,10 @@ describe('CapLevelController', function () { if (media.parentNode) { media.parentNode.removeChild(media); } - document.body.removeChild(document.querySelector('#test-fixture')); + const fixture = document.querySelector('#test-fixture'); + if (fixture) { + document.body.removeChild(fixture); + } hls.destroy(); }); @@ -126,7 +127,14 @@ describe('CapLevelController', function () { it('gets client bounds width and height when media element is in the DOM', function () { media.style.width = '1280px'; media.style.height = '720px'; - document.querySelector('#test-fixture').appendChild(media); + + const fixture = document.querySelector('#test-fixture'); + if (!fixture) { + expect(fixture).is.not.null; + return; + } + fixture.appendChild(media); + const pixelRatio = capLevelController.contentScaleFactor; const bounds = capLevelController.getDimensions(); expect(bounds.width).to.equal(1280); @@ -150,11 +158,17 @@ describe('CapLevelController', function () { media.style.width = '1280px'; media.style.height = '720px'; - document.querySelector('#test-fixture').appendChild(media); + + const fixture = document.querySelector('#test-fixture'); + if (!fixture) { + expect(fixture).is.not.null; + return; + } + fixture.appendChild(media); + capLevelController.onMediaAttaching(Events.MEDIA_ATTACHING, { media, }); - const pixelRatio = capLevelController.contentScaleFactor; bounds = capLevelController.getDimensions(); expect(bounds.width).to.equal(1280); diff --git a/tests/unit/controller/eme-controller.ts b/tests/unit/controller/eme-controller.ts index 2564181b2db..52aa6688a7f 100644 --- a/tests/unit/controller/eme-controller.ts +++ b/tests/unit/controller/eme-controller.ts @@ -61,7 +61,7 @@ class MediaKeySessionMock extends EventEmitter { messageType: 'license-request', message: new Uint8Array(0), }); - this.keyStatuses.set(new Uint8Array(0), 'usable'); + this.keyStatuses.set(new Uint8Array(16), 'usable'); this.emit('keystatuseschange', {}); }); } @@ -158,9 +158,6 @@ describe('EMEController', function () { } as any); expect(emePromise).to.be.a('Promise'); - if (!emePromise) { - return; - } return emePromise.finally(() => { expect(media.setMediaKeys).callCount(1); expect(reqMediaKsAccessSpy).callCount(1); @@ -226,9 +223,6 @@ describe('EMEController', function () { } as any); expect(emePromise).to.be.a('Promise'); - if (!emePromise) { - return; - } return emePromise.finally(() => { expect(reqMediaKsAccessSpy).callCount(1); const args = reqMediaKsAccessSpy.getCall(0) @@ -379,58 +373,51 @@ describe('EMEController', function () { drmSystems: { 'com.apple.fps': { serverCertificateUrl: 'https://example.com/certificate.cer', + licenseUrl: 'https://example.com/license', }, }, }); - let xhrInstance; sinonFakeXMLHttpRequestStatic.onCreate = ( xhr: sinon.SinonFakeXMLHttpRequest, ) => { - xhrInstance = xhr; - Promise.resolve().then(() => { - (xhr as any).response = new Uint8Array(); - xhr.respond(200, {}, ''); - }); + self.setTimeout(() => { + xhr.respond(200, {}, 'abcdef'); + }, 0); }; emeController.onMediaAttached(Events.MEDIA_ATTACHED, { media: media as any as HTMLMediaElement, }); - emeController.loadKey({ - frag: {}, - keyInfo: { - decryptdata: { - encrypted: true, - method: 'SAMPLE-AES', - uri: 'data://key-uri', - keyFormatVersions: [1], - keyId: new Uint8Array(16), - pssh: new Uint8Array(16), + return emeController + .loadKey({ + frag: {}, + keyInfo: { + decryptdata: { + encrypted: true, + method: 'SAMPLE-AES', + uri: 'data://key-uri', + keyFormatVersions: [1], + keyId: new Uint8Array(16), + pssh: new Uint8Array(16), + }, }, - }, - } as any); - - expect( - emeController.keyIdToKeySessionPromise[ - '00000000000000000000000000000000' - ], - ).to.be.a('Promise'); - if ( - !emeController.keyIdToKeySessionPromise[ - '00000000000000000000000000000000' - ] - ) { - return; - } - return emeController.keyIdToKeySessionPromise[ - '00000000000000000000000000000000' - ].finally(() => { - expect(mediaKeysSetServerCertificateSpy).to.have.been.calledOnce; - expect(mediaKeysSetServerCertificateSpy).to.have.been.calledWith( - xhrInstance.response, - ); - }); + } as any) + .then(() => { + expect( + emeController.keyIdToKeySessionPromise[ + '00000000000000000000000000000000' + ], + ).to.be.a('Promise'); + return emeController.keyIdToKeySessionPromise[ + '00000000000000000000000000000000' + ].finally(() => { + expect(mediaKeysSetServerCertificateSpy).to.have.been.calledOnce; + expect(mediaKeysSetServerCertificateSpy).to.have.been.calledWith( + sinon.match({ byteLength: 6 }), + ); + }); + }); }); it('should fetch the server certificate and trigger update failed error', function () { @@ -482,30 +469,27 @@ describe('EMEController', function () { emeController.onMediaAttached(Events.MEDIA_ATTACHED, { media: media as any as HTMLMediaElement, }); - emeController.loadKey({ - frag: {}, - keyInfo: { - decryptdata: { - encrypted: true, - method: 'SAMPLE-AES', - uri: 'data://key-uri', - keyId: new Uint8Array(16), + emeController + .loadKey({ + frag: {}, + keyInfo: { + decryptdata: { + encrypted: true, + method: 'SAMPLE-AES', + uri: 'data://key-uri', + keyId: new Uint8Array(16), + }, }, - }, - } as any); + } as any) + .catch((error) => { + // expected? + }); expect( emeController.keyIdToKeySessionPromise[ '00000000000000000000000000000000' ], ).to.be.a('Promise'); - if ( - !emeController.keyIdToKeySessionPromise[ - '00000000000000000000000000000000' - ] - ) { - return; - } return emeController.keyIdToKeySessionPromise[ '00000000000000000000000000000000' ] @@ -565,30 +549,27 @@ describe('EMEController', function () { emeController.onMediaAttached(Events.MEDIA_ATTACHED, { media: media as any as HTMLMediaElement, }); - emeController.loadKey({ - frag: {}, - keyInfo: { - decryptdata: { - encrypted: true, - method: 'SAMPLE-AES', - uri: 'data://key-uri', - keyId: new Uint8Array(16), + emeController + .loadKey({ + frag: {}, + keyInfo: { + decryptdata: { + encrypted: true, + method: 'SAMPLE-AES', + uri: 'data://key-uri', + keyId: new Uint8Array(16), + }, }, - }, - } as any); + } as any) + .catch((error) => { + // expected? + }); expect( emeController.keyIdToKeySessionPromise[ '00000000000000000000000000000000' ], ).to.be.a('Promise'); - if ( - !emeController.keyIdToKeySessionPromise[ - '00000000000000000000000000000000' - ] - ) { - return; - } return emeController.keyIdToKeySessionPromise[ '00000000000000000000000000000000' ] diff --git a/tests/unit/controller/error-controller.ts b/tests/unit/controller/error-controller.ts index 9df0432a1cc..7ddef8323ac 100644 --- a/tests/unit/controller/error-controller.ts +++ b/tests/unit/controller/error-controller.ts @@ -266,9 +266,10 @@ describe('ErrorController Integration Tests', function () { hls.loadSource('noSegmentsVod.m3u8'); hls.stopLoad.should.have.been.calledOnce; return new Promise((resolve, reject) => { - hls.on(Events.ERROR, (event, data) => - Promise.resolve().then(() => resolve(data)), - ); + hls.on(Events.ERROR, (event, data) => { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + Promise.resolve().then(() => resolve(data)); + }); hls.on(Events.LEVEL_LOADED, () => reject( new Error( @@ -713,6 +714,7 @@ segment.mp4 hls.on(Events.FRAG_LOADING, loadingEventCallback(server, timers)); hls.on(Events.ERROR, (event, data) => { errors.push(data); + // eslint-disable-next-line @typescript-eslint/no-floating-promises Promise.resolve().then(() => timers.tick(2000)); }); return new Promise((resolve, reject) => { @@ -823,6 +825,7 @@ segment.mp4 hls.on(Events.FRAG_LOADING, loadingEventCallback(server, timers)); hls.on(Events.ERROR, (event, data) => { errors.push(data); + // eslint-disable-next-line @typescript-eslint/no-floating-promises Promise.resolve().then(() => timers.tick(2000)); }); return new Promise((resolve, reject) => { @@ -968,6 +971,7 @@ function setupMockServerResponses(server: sinon.SinonFakeServer) { function loadingEventCallback(server, timers) { return (event, data) => { + // eslint-disable-next-line @typescript-eslint/no-floating-promises Promise.resolve().then(() => { server.respond(); }); diff --git a/tests/unit/controller/fragment-tracker.ts b/tests/unit/controller/fragment-tracker.ts index d245ef6fb6c..8c0fdf28e7c 100644 --- a/tests/unit/controller/fragment-tracker.ts +++ b/tests/unit/controller/fragment-tracker.ts @@ -608,7 +608,7 @@ function createBufferAppendedData( frag: new Fragment(PlaylistLevelType.MAIN, ''), part: null, parent: PlaylistLevelType.MAIN, - type: audio && video ? 'audiovideo' : video ? 'video' : 'audio', + type: audio ? 'audiovideo' : 'video', timeRanges: { video: createMockBuffer(video), audio: createMockBuffer(audio || video), @@ -655,7 +655,7 @@ function createMockFragment( ): Fragment { const frag = new Fragment(data.type, ''); Object.assign(frag, data); - frag.start = data.startPTS; + frag.setStart(data.startPTS); frag.duration = data.endPTS - data.startPTS; types.forEach((t) => { frag.setElementaryStreamInfo( diff --git a/tests/unit/controller/interstitials-controller.ts b/tests/unit/controller/interstitials-controller.ts index d0ee1b86bbd..2db36842684 100644 --- a/tests/unit/controller/interstitials-controller.ts +++ b/tests/unit/controller/interstitials-controller.ts @@ -36,10 +36,7 @@ class HLSTestPlayer extends Hls { hlsTestable.coreComponents.forEach((component) => component.destroy()); hlsTestable.coreComponents.length = 0; hlsTestable.on(Events.MEDIA_ATTACHING, (t, data) => { - const media = data.media; - if (media) { - media.src = ''; - } + data.media.src = ''; }); hlsTestable.on(Events.MEDIA_DETACHING, () => { const media = hlsTestable.media; @@ -140,7 +137,7 @@ describe('InterstitialsController', function () { 0, null, ); - expect(details?.playlistParsingError).to.equal(null); + expect(details.playlistParsingError).to.equal(null); const attrs = new AttrList({}); const level = new Level({ name: '', @@ -210,9 +207,6 @@ fileSequence4.ts expect(insterstitials.playingIndex).to.equal(0, 'playingIndex'); expect(insterstitials.events).is.an('array').which.has.lengthOf(1); expect(schedule).is.an('array').which.has.lengthOf(2); - if (!insterstitials.events || !schedule) { - return; - } const interstitialEvent = insterstitials.events[0]; expect(interstitialEvent.identifier).to.equal('0'); expect(interstitialEvent.restrictions.jump).to.equal(true); @@ -296,11 +290,8 @@ fileSequence4.ts .is.an('array') .which.has.lengthOf( 7, - `Schedule items: ${schedule?.map((item) => `${item.start}-${item.end}`).join(', ')}`, + `Schedule items: ${schedule.map((item) => `${item.start}-${item.end}`).join(', ')}`, ); - if (!events || !schedule) { - return; - } const eventAssertions = [ { startTime: 0, @@ -478,13 +469,10 @@ fileSequence3.ts const events = insterstitials.events; const schedule = insterstitials.schedule; expect(events).is.an('array').which.has.lengthOf(5); - const scheduleDebugString = `Schedule items: ${schedule?.map((item) => `[${item.event ? 'I' : 'P'}:${item.start}-${item.end}]`).join(', ')}`; + const scheduleDebugString = `Schedule items: ${schedule.map((item) => `[${item.event ? 'I' : 'P'}:${item.start}-${item.end}]`).join(', ')}`; expect(schedule) .is.an('array') .which.has.lengthOf(9, scheduleDebugString); - if (!events || !schedule) { - return; - } [ 'primary', '1', @@ -498,12 +486,12 @@ fileSequence3.ts ].forEach((typeOrIdentifier, i) => { if (typeOrIdentifier === 'primary') { expect( - schedule?.[i], + schedule[i], `Expected to find a primary segment at index ${i}: ${scheduleDebugString}`, ).to.have.property('nextEvent'); } else { expect( - schedule?.[i], + schedule[i], `Expected to find an Interstitial at index ${i}: ${scheduleDebugString}`, ) .to.have.property('event') @@ -725,9 +713,6 @@ fileSequence3.mp4 expect(insterstitials.playingIndex).to.equal(0, 'playingIndex'); expect(insterstitials.events).is.an('array').which.has.lengthOf(2); expect(schedule).is.an('array').which.has.lengthOf(4); - if (!insterstitials.events || !schedule) { - return; - } expect(insterstitials.events[0].identifier).to.equal('ad1'); expect(insterstitials.events[1].identifier).to.equal('ad2'); expect(insterstitials.events[0]).to.equal(schedule[0].event); @@ -827,9 +812,6 @@ fileSequence3.mp4 const schedule = insterstitials.schedule; expect(insterstitials.events).is.an('array').which.has.lengthOf(2); expect(schedule).is.an('array').which.has.lengthOf(2); - if (!insterstitials.events || !schedule) { - return; - } expect(insterstitials.events[0].identifier).to.equal('ad1'); expect(insterstitials.events[1].identifier).to.equal('ad2'); expect(insterstitials.events[0]).to.equal(schedule[0].event); @@ -891,9 +873,6 @@ fileSequence4.ts const events = insterstitials.events; expect(events).is.an('array').which.has.lengthOf(1); expect(schedule).is.an('array').which.has.lengthOf(2); - if (!events || !schedule) { - return; - } expect(events[0]).to.deep.include({ identifier: '0', timelineOccupancy: TimelineOccupancy.Point, @@ -986,9 +965,6 @@ fileSequence4.ts const events = insterstitials.events; expect(events).is.an('array').which.has.lengthOf(4); expect(schedule).is.an('array').which.has.lengthOf(5); - if (!events || !schedule) { - return; - } eventAssertions.forEach((assertions, i) => { expect(events[i].identifier).to.equal('' + i); expect(events[i], `Interstitial Event "${i}"`).to.deep.include( @@ -1118,9 +1094,6 @@ fileSequence5.mp4`; } expect(insterstitials.events).is.an('array').which.has.lengthOf(2); expect(insterstitials.schedule).is.an('array').which.has.lengthOf(4); - if (!insterstitials.events || !insterstitials.schedule) { - return; - } const callsWithPrerollBeforeAttach = getTriggerCalls(); expect(callsWithPrerollBeforeAttach).to.deep.equal( [Events.LEVEL_UPDATED, Events.INTERSTITIALS_UPDATED], @@ -1185,9 +1158,6 @@ fileSequence3.mp4 } expect(insterstitials.events).is.an('array').which.has.lengthOf(1); expect(insterstitials.schedule).is.an('array').which.has.lengthOf(2); - if (!insterstitials.events || !insterstitials.schedule) { - return; - } const callsBeforeAttach = getTriggerCalls(); expect(callsBeforeAttach).to.deep.equal( [ @@ -1255,9 +1225,6 @@ fileSequence3.mp4 } expect(insterstitials.events).is.an('array').which.has.lengthOf(1); expect(insterstitials.schedule).is.an('array').which.has.lengthOf(2); - if (!insterstitials.events || !insterstitials.schedule) { - return; - } const callsBeforeAttach = getTriggerCalls(); expect(callsBeforeAttach).to.deep.equal( [ @@ -1333,9 +1300,6 @@ fileSequence6.mp4`; } expect(insterstitials.events).is.an('array').which.has.lengthOf(1); expect(insterstitials.schedule).is.an('array').which.has.lengthOf(3); - if (!insterstitials.events || !insterstitials.schedule) { - return; - } const eventsBeforeAttach = getTriggerCalls(); expect(eventsBeforeAttach).to.deep.equal( [Events.LEVEL_UPDATED, Events.INTERSTITIALS_UPDATED], @@ -1463,9 +1427,6 @@ fileSequence6.mp4`; } expect(insterstitials.events).is.an('array').which.has.lengthOf(1); expect(insterstitials.schedule).is.an('array').which.has.lengthOf(3); - if (!insterstitials.events || !insterstitials.schedule) { - return; - } const eventsAfterPlaylist = getTriggerCalls(); expect(eventsAfterPlaylist).to.deep.equal( [ @@ -1508,10 +1469,10 @@ fileSequence6.mp4`; insterstitials.skip(); const eventsAfterSkip = getTriggerCalls(); const expectedSkipEvents = [ - Events.INTERSTITIALS_BUFFERED_TO_BOUNDARY, Events.INTERSTITIAL_ASSET_ENDED, Events.INTERSTITIAL_ENDED, Events.INTERSTITIALS_UPDATED, // removed Interstitial with CUE="ONCE" + Events.INTERSTITIALS_BUFFERED_TO_BOUNDARY, Events.MEDIA_ATTACHING, Events.INTERSTITIALS_PRIMARY_RESUMED, ]; @@ -1607,17 +1568,13 @@ fileSequence6.mp4 duration: 15, }); } else if (bufferingIndex === 2) { - // buffered to primary (end of interstitial) + // buffered to primary (interstitial ended) expectIm(primary, e).to.include({ currentTime: 40, bufferedEnd: 40 }); expectIm(integrated, e).to.include({ currentTime: 45, bufferedEnd: 45, }); - expectIm(interstitialPlayer, e).to.include({ - playingIndex: 2, - currentTime: 15, - duration: 15, - }); + expectIm(interstitialPlayer, e).to.be.null; } }); hls.on(Events.INTERSTITIAL_STARTED, (t) => { @@ -1799,9 +1756,6 @@ fileSequence6.mp4 expect(im.events).is.an('array').which.has.lengthOf(1); expect(im.schedule).is.an('array').which.has.lengthOf(3); - if (!im.events || !im.schedule) { - return; - } const eventsBeforeAttach = getTriggerCalls(); expect(eventsBeforeAttach).to.deep.equal( [Events.LEVEL_UPDATED, Events.INTERSTITIALS_UPDATED], @@ -1956,9 +1910,9 @@ fileSequence6.mp4 }); const eventsAfterLastAsset = getTriggerCalls(); const expectedEndLastAssetEvents = [ - Events.INTERSTITIALS_BUFFERED_TO_BOUNDARY, Events.INTERSTITIAL_ASSET_ENDED, Events.INTERSTITIAL_ENDED, + Events.INTERSTITIALS_BUFFERED_TO_BOUNDARY, Events.MEDIA_ATTACHING, Events.INTERSTITIALS_PRIMARY_RESUMED, ]; @@ -2008,9 +1962,6 @@ fileSequence6.mp4`; } expect(insterstitials.events).is.an('array').which.has.lengthOf(1); expect(insterstitials.schedule).is.an('array').which.has.lengthOf(3); - if (!insterstitials.events || !insterstitials.schedule) { - return; - } // Capture asset-list request const loadSpy = sandbox.spy(hls.config.loader.prototype, 'load'); diff --git a/tests/unit/controller/level-controller.ts b/tests/unit/controller/level-controller.ts index 232f1928bc6..0a2b1e660b2 100755 --- a/tests/unit/controller/level-controller.ts +++ b/tests/unit/controller/level-controller.ts @@ -10,6 +10,7 @@ import { PlaylistLevelType } from '../../../src/types/loader'; import { AttrList } from '../../../src/utils/attr-list'; import { getMediaSource } from '../../../src/utils/mediasource-helper'; import HlsMock from '../../mocks/hls.mock'; +import { parsedLevel } from '../utils/mock-level'; import type { Fragment } from '../../../src/loader/fragment'; import type { LevelDetails } from '../../../src/loader/level-details'; import type { ParsedMultivariantPlaylist } from '../../../src/loader/m3u8-parser'; @@ -17,7 +18,6 @@ import type { ManifestLoadedData, ManifestParsedData, } from '../../../src/types/events'; -import type { LevelParsed } from '../../../src/types/level'; import type { PlaylistLoaderContext } from '../../../src/types/loader'; import type { MediaAttributes, @@ -49,18 +49,6 @@ type LevelControllerTestable = Omit & { redundantFailover: (levelIndex: number) => void; }; -function parsedLevel( - options: Partial & { bitrate: number }, -): LevelParsed { - const level: LevelParsed = { - attrs: new AttrList({ BANDWIDTH: options.bitrate }), - bitrate: options.bitrate, - name: '', - url: `${options.bitrate}.m3u8`, - }; - return Object.assign(level, options); -} - function mediaPlaylist(options: Partial): MediaPlaylist { const track: MediaPlaylist = { attrs: new AttrList({}) as MediaAttributes, diff --git a/tests/unit/controller/level-helper.ts b/tests/unit/controller/level-helper.ts index da2973f48ad..d8f932f8d89 100644 --- a/tests/unit/controller/level-helper.ts +++ b/tests/unit/controller/level-helper.ts @@ -19,6 +19,7 @@ import { mapPartIntersection, mergeDetails, } from '../../../src/utils/level-helper'; +import { logger } from '../../../src/utils/logger'; import type { MediaFragment } from '../../../src/loader/fragment'; import type { ComponentAPI, @@ -48,7 +49,7 @@ const generatePlaylist = (sequenceNumbers, offset = 0, duration = 5) => { playlist.fragments = sequenceNumbers.map((n, i) => { const frag = new Fragment(PlaylistLevelType.MAIN, ''); frag.sn = n; - frag.start = i * 5 + offset; + frag.setStart(i * 5 + offset); frag.duration = duration; return frag; }); @@ -67,7 +68,9 @@ const getIteratedSequence = (oldPlaylist, newPlaylist) => { }; const getFragmentSequenceNumbers = (details: LevelDetails) => - details.fragments.map((f) => `${f?.sn}-${f?.cc}`).join(','); + details.fragments + .map((f: MediaFragment | null) => `${f?.sn}-${f?.cc}`) + .join(','); describe('LevelHelper Tests', function () { let sandbox; @@ -186,7 +189,7 @@ describe('LevelHelper Tests', function () { it('transfers start times where segments overlap, and extrapolates the start of any new segment', function () { const oldPlaylist = generatePlaylist([1, 2, 3, 4]); // start times: 0, 5, 10, 15 const newPlaylist = generatePlaylist([2, 3, 4, 5]); - mergeDetails(oldPlaylist, newPlaylist); + mergeDetails(oldPlaylist, newPlaylist, logger); const actual = newPlaylist.fragments.map((f) => f.start); expect(actual).to.deep.equal([5, 10, 15, 20]); expect(newPlaylist.playlistParsingError).to.be.null; @@ -195,7 +198,7 @@ describe('LevelHelper Tests', function () { it('applies expected sliding when there is no segment overlap', function () { const oldPlaylist = generatePlaylist([1, 2, 3]); const newPlaylist = generatePlaylist([5, 6, 7]); - mergeDetails(oldPlaylist, newPlaylist); + mergeDetails(oldPlaylist, newPlaylist, logger); const actual = newPlaylist.fragments.map((f) => f.start); expect(actual).to.deep.equal([20, 25, 30]); expect(newPlaylist.playlistParsingError).to.be.null; @@ -204,10 +207,10 @@ describe('LevelHelper Tests', function () { it('matches start when the new playlist starts before the old', function () { const oldPlaylist = generatePlaylist([3, 4, 5]); oldPlaylist.fragments.forEach((f) => { - f.start += 10; + f.addStart(10); }); const newPlaylist = generatePlaylist([1, 2, 3]); - mergeDetails(oldPlaylist, newPlaylist); + mergeDetails(oldPlaylist, newPlaylist, logger); const actual = newPlaylist.fragments.map((f) => f.start); expect(actual).to.deep.equal([10, 15, 20]); expect(newPlaylist.playlistParsingError).to.be.null; @@ -221,7 +224,7 @@ describe('LevelHelper Tests', function () { // @ts-ignore newPlaylist.fragments.unshift(null, null, null, null, null, null, null); const merged = generatePlaylist([3, 4, 5, 6, 7, 8, 9, 10, 11, 12], 10); - mergeDetails(oldPlaylist, newPlaylist); + mergeDetails(oldPlaylist, newPlaylist, logger); expect(newPlaylist.deltaUpdateFailed).to.equal(false); expect(newPlaylist.fragments.length).to.equal(merged.fragments.length); newPlaylist.fragments.forEach((frag, i) => { @@ -244,7 +247,7 @@ expect: ${JSON.stringify(merged.fragments[i])}`, newPlaylist.fragments.unshift(null, null, null, null, null); // FIXME: An expected offset of 50 would be preferred, but there is nothing to sync playlist start with const merged = generatePlaylist([10, 11, 12], 0); - mergeDetails(oldPlaylist, newPlaylist); + mergeDetails(oldPlaylist, newPlaylist, logger); expect(newPlaylist.deltaUpdateFailed).to.equal(true); expect(newPlaylist.fragments.length).to.equal(3); newPlaylist.fragments.forEach((frag, i) => { @@ -287,7 +290,7 @@ expect: ${JSON.stringify(merged.fragments[i])}`, newPlaylist.fragmentHint.sn = 5; newPlaylist.fragmentHint.initSegment = newInitSegment; - mergeDetails(oldPlaylist, newPlaylist); + mergeDetails(oldPlaylist, newPlaylist, logger); newPlaylist.fragments.forEach((frag, i) => { expect( @@ -353,7 +356,7 @@ fileSequence11.ts #EXT-X-DATERANGE:ID="four",START-DATE="2024-02-29T12:02:04.000Z"`; const details = parseLevelPlaylist(playlist); const detailsUpdated = parseLevelPlaylist(playlistUpdate); - mergeDetails(details, detailsUpdated); + mergeDetails(details, detailsUpdated, logger); expect(details.hasProgramDateTime, 'details.hasProgramDateTime').to.be .true; expect(details.dateRanges, 'one') @@ -374,12 +377,12 @@ fileSequence11.ts .which.equals(details.fragments[2].ref) .which.has.property('sn') .which.equals(5); - expect(details.dateRanges.one.startTime).to.equal(4); - expect(details.dateRanges.two.startTime).to.equal(10); - expect(details.dateRanges.three.startTime).to.equal(16); - expect(details.dateRanges.one.tagOrder, 'one.tagOrder').to.equal(0); - expect(details.dateRanges.two.tagOrder, 'two.tagOrder').to.equal(1); - expect(details.dateRanges.three.tagOrder, 'three.tagOrder').to.equal(2); + expect(details.dateRanges.one!.startTime).to.equal(4); + expect(details.dateRanges.two!.startTime).to.equal(10); + expect(details.dateRanges.three!.startTime).to.equal(16); + expect(details.dateRanges.one!.tagOrder, 'one.tagOrder').to.equal(0); + expect(details.dateRanges.two!.tagOrder, 'two.tagOrder').to.equal(1); + expect(details.dateRanges.three!.tagOrder, 'three.tagOrder').to.equal(2); expect( detailsUpdated.hasProgramDateTime, 'detailsUpdated.hasProgramDateTime', @@ -407,25 +410,26 @@ fileSequence11.ts .which.has.property('tagAnchor') .which.has.property('sn') .which.equals(5); - expect(detailsUpdated.dateRanges.one.startTime).to.equal(4); - expect(detailsUpdated.dateRanges.two.startTime).to.equal(10); - expect(detailsUpdated.dateRanges.three.startTime).to.equal(16); - expect(detailsUpdated.dateRanges.four.startTime).to.equal(76); + expect(detailsUpdated.dateRanges.one!.startTime).to.equal(4); + expect(detailsUpdated.dateRanges.two!.startTime).to.equal(10); + expect(detailsUpdated.dateRanges.three!.startTime).to.equal(16); + expect(detailsUpdated.dateRanges.four!.startTime).to.equal(76); expect( - detailsUpdated.dateRanges.one.tagOrder, + detailsUpdated.dateRanges.one!.tagOrder, 'one.tagOrder updated', ).to.equal(0); expect( - detailsUpdated.dateRanges.two.tagOrder, + detailsUpdated.dateRanges.two!.tagOrder, 'two.tagOrder updated', ).to.equal(1); expect( - detailsUpdated.dateRanges.three.tagOrder, + detailsUpdated.dateRanges.three!.tagOrder, 'three.tagOrder updated', ).to.equal(2); - expect(detailsUpdated.dateRanges.four.tagOrder, 'four.tagOrder').to.equal( - 3, - ); + expect( + detailsUpdated.dateRanges.four!.tagOrder, + 'four.tagOrder', + ).to.equal(3); expect(detailsUpdated.playlistParsingError).to.be.null; }); @@ -597,7 +601,7 @@ fileSequence12.ts`; updated: false, }); // discontinuity sequence numbers (frag.cc) should be carried over - mergeDetails(details1, details2); + mergeDetails(details1, details2, logger); const mergedSequence1 = getFragmentSequenceNumbers(details2); expect( details2, @@ -624,7 +628,7 @@ fileSequence12.ts`; }); // discontinuity sequence numbers (frag.cc) should be carried over - mergeDetails(details2, details3); + mergeDetails(details2, details3, logger); const mergedSequence2 = getFragmentSequenceNumbers(details3); expect( details3, @@ -651,7 +655,7 @@ fileSequence12.ts`; }); // discontinuity sequence numbers (frag.cc) should be carried over - mergeDetails(details3, details4); + mergeDetails(details3, details4, logger); const mergedSequence3 = getFragmentSequenceNumbers(details4); expect( details4, @@ -805,7 +809,7 @@ fileSequence8.m4s updated: true, }); // discontinuity sequence numbers (frag.cc) should be carried over - mergeDetails(details1, details2); + mergeDetails(details1, details2, logger); const mergedSequence1 = getFragmentSequenceNumbers(details2); expect( details2, @@ -833,7 +837,7 @@ fileSequence8.m4s updated: true, }); // discontinuity sequence numbers (frag.cc) should be carried over - mergeDetails(details1, details3); + mergeDetails(details1, details3, logger); const mergedSequence2 = getFragmentSequenceNumbers(details3); expect( details3, @@ -991,7 +995,7 @@ fileSequence8.m4s updated: true, }); // discontinuity sequence numbers (frag.cc) should be carried over - mergeDetails(details1, details2); + mergeDetails(details1, details2, logger); const mergedSequence1 = getFragmentSequenceNumbers(details2); expect( details2, @@ -1089,15 +1093,15 @@ fileSequence17.ts fileSequence18.ts`; const details = parseLevelPlaylist(playlist); const detailsUpdated = parseLevelPlaylist(playlistUpdate); - mergeDetails(details, detailsUpdated); + mergeDetails(details, detailsUpdated, logger); expect(details.hasProgramDateTime, 'details.hasProgramDateTime').to.be .true; expect( Object.keys(details.dateRanges), 'first playlist daterange ids', ).to.have.deep.equal(['d0', 'd1', 'd2', 'd3', 'd4']); - expect(details.dateRanges.d2.startTime).to.equal(2.94); - expect(details.dateRanges.d3.startTime).to.equal(3.94); + expect(details.dateRanges.d2!.startTime).to.equal(2.94); + expect(details.dateRanges.d3!.startTime).to.equal(3.94); expect( detailsUpdated.hasProgramDateTime, 'detailsUpdated.hasProgramDateTime', @@ -1116,8 +1120,8 @@ fileSequence18.ts`; .which.equals(detailsUpdated.fragments[0].ref) .which.has.property('sn') .which.equals(3); - expect(detailsUpdated.dateRanges.d2.startTime).to.equal(2.94); - expect(detailsUpdated.dateRanges.d3.startTime).to.equal(3.94); + expect(detailsUpdated.dateRanges.d2!.startTime).to.equal(2.94); + expect(detailsUpdated.dateRanges.d3!.startTime).to.equal(3.94); // Multiple #EXT-X-SKIP tags are not allowed expect(detailsUpdated.playlistParsingError).to.include({ message: `#EXT-X-SKIP must not appear more than once (#EXT-X-SKIP:SKIPPED-SEGMENTS=2,RECENTLY-REMOVED-DATERANGES="d0")`, @@ -1136,7 +1140,7 @@ fileSequence6.ts`; const details = parseLevelPlaylist(playlist); addSliding(details, 10); expect(details.fragmentStart).to.equal(10); - mergeDetails(details, details); + mergeDetails(details, details, logger); expect(details.fragmentStart).to.equal(10); }); @@ -1194,7 +1198,7 @@ video_32.m4s`; endCC: 3, }); - mergeDetails(oldPlaylist, newPlaylist); + mergeDetails(oldPlaylist, newPlaylist, logger); expect(newPlaylist.playlistParsingError).to.be.null; expect(newPlaylist).to.include({ @@ -1272,7 +1276,7 @@ getMP4MediaFragment.mp4?FragmentNumber=91343852333398821516816632930230891347979 endCC: 5, }); - mergeDetails(oldPlaylist, newPlaylist); + mergeDetails(oldPlaylist, newPlaylist, logger); expect(newPlaylist.playlistParsingError).to.be.null; expect(newPlaylist).to.include({ @@ -1542,8 +1546,8 @@ audio_5441.m4s`; expect(audioDetails1.totalduration).to.equal(16.019); // Seconds main and audio playlist responses - mergeDetails(mainDetails1, mainDetails2); - mergeDetails(audioDetails1, audioDetails2); + mergeDetails(mainDetails1, mainDetails2, logger); + mergeDetails(audioDetails1, audioDetails2, logger); expect(audioDetails2.alignedSliding).to.be.false; expect(mainDetails2.fragmentStart).to.equal(20.0825); expect(audioDetails2.fragmentStart).to.equal(16.0325); @@ -1575,8 +1579,8 @@ audio_5441.m4s`; expect(audioDetails1.totalduration).to.equal(16.019); // Seconds main and audio playlist responses - mergeDetails(mainDetails1, mainDetails2); - mergeDetails(audioDetails1, audioDetails2); + mergeDetails(mainDetails1, mainDetails2, logger); + mergeDetails(audioDetails1, audioDetails2, logger); expect(audioDetails2.alignedSliding).to.be.false; expect(mainDetails2.fragmentStart).to.equal(20.0825); expect(audioDetails2.fragmentStart).to.equal(16.0325); @@ -1626,7 +1630,7 @@ video_5431.m4s video_5432.m4s`; const details1 = parseLevelPlaylist(playlist1); const details2 = parseLevelPlaylist(playlist2); - mergeDetails(details1, details2); + mergeDetails(details1, details2, logger); expectPlaylistParsingError( details2, 'discontinuity sequence mismatch (31!=32)', @@ -1665,13 +1669,382 @@ video_5431.m4s video_5432.m4s`; const details1 = parseLevelPlaylist(playlist1); const details2 = parseLevelPlaylist(playlist2); - mergeDetails(details1, details2); + mergeDetails(details1, details2, logger); expectPlaylistParsingError( details2, 'media sequence mismatch 5429: http://example.com/video_5430.m4s', ); }); }); + + it('does not error between updates when only the query part of the URI changes', function () { + const playlist1 = `#EXTM3U +#EXT-X-VERSION:6 +#EXT-X-TARGETDURATION:3 +#EXT-X-MEDIA-SEQUENCE:5428 +#EXT-X-DISCONTINUITY-SEQUENCE:31 +#EXT-X-MAP:URI="video_init.mp4" +#EXTINF:2.000, +video_5428.m4s?t=1 +#EXTINF:2.000, +video_5429.m4s?t=1 +#EXTINF:2.000, +video_5430.m4s?t=1 +#EXTINF:2.000, +video_5431.m4s?t=1`; + // Media sequence increased by one but two segments removed. + const playlist2 = `#EXTM3U +#EXT-X-VERSION:6 +#EXT-X-TARGETDURATION:3 +#EXT-X-MEDIA-SEQUENCE:5429 +#EXT-X-DISCONTINUITY-SEQUENCE:31 +#EXT-X-MAP:URI="video_init.mp4" +#EXTINF:2.000, +video_5429.m4s?t=2 +#EXTINF:2.000, +video_5430.m4s?t=2 +#EXTINF:2.000, +video_5431.m4s?t=2 +#EXTINF:2.000, +video_5432.m4s?t=2`; + const details1 = parseLevelPlaylist(playlist1); + const details2 = parseLevelPlaylist(playlist2); + details2.fragments[0].base.url += '?base=changed'; + mergeDetails(details1, details2, logger); + expect(details2.playlistParsingError).to.be.null; + }); + + it('maps dateranges based on latest EXT-X-PROGRAM-DATE-TIME', function () { + const playlist1 = `#EXTM3U +#EXT-X-VERSION:6 +#EXT-X-MEDIA-SEQUENCE:1 +#EXT-X-TARGETDURATION:6 +#EXT-X-SERVER-CONTROL:CAN-SKIP-UNTIL=9,CAN-SKIP-DATERANGES=YES +#EXT-X-DATERANGE:ID="D0",START-DATE="2025-08-06T15:59:59Z",DURATION=1 +#EXT-X-DATERANGE:ID="D1",START-DATE="2025-08-06T16:00:00Z",DURATION=12,SCTE35-CMD=0x00000000 +#EXT-X-MAP:URI="hls/20821722-video=2499968.m4s" +#EXT-X-PROGRAM-DATE-TIME:2025-08-06T16:00:00Z +#EXTINF:4.0, no desc +1.m4s +#EXTINF:4.0, no desc +2.m4s +#EXT-X-PROGRAM-DATE-TIME:2025-08-06T16:08:00Z +#EXTINF:4.0, no desc +3.m4s +#EXT-X-DATERANGE:ID="D2",START-DATE="2025-08-06T16:00:15Z",PLANNED-DURATION=12,SCTE35-OUT=0x00000000 +#EXTINF:4.0, no desc +4.m4s +#EXTINF:4.0, no desc +5.m4s +#EXT-X-PROGRAM-DATE-TIME:2025-08-06T16:00:20Z +#EXTINF:4.0, no desc +6.m4s +#EXTINF:4.0, no desc +7.m4s +#EXTINF:4.0, no desc +8.m4s +#EXTINF:4.0, no desc +9.m4s`; + // Media sequence increased by one but two segments removed. + const playlist2 = `#EXTM3U +#EXT-X-VERSION:10 +#EXT-X-MEDIA-SEQUENCE:5 +#EXT-X-INDEPENDENT-SEGMENTS +#EXT-X-TARGETDURATION:6 +#EXT-X-SERVER-CONTROL:CAN-SKIP-UNTIL=9,CAN-SKIP-DATERANGES=YES +#EXT-X-SKIP:SKIPPED-SEGMENTS=4,RECENTLY-REMOVED-DATERANGES="D0" +#EXTINF:4, no desc +9.m4s +#EXTINF:4, no desc +10.m4s +#EXT-X-DATERANGE:ID="D3",START-DATE="2025-08-06T16:00:59.100Z",DURATION=12,SCTE35-CMD=0x00000000`; + const details1 = parseLevelPlaylist(playlist1); + const details2 = parseLevelPlaylist(playlist2); + + expect(details1.playlistParsingError).to.be.null; + expect(details2.playlistParsingError).to.be.null; + + // First playilst details + expect(details1).to.include({ + startSN: 1, + endSN: 9, + totalduration: 36, + dateRangeTagCount: 3, + }); + expect(details1.dateRanges).to.have.keys(['D0', 'D1', 'D2']); + expect(details1.dateRanges.D0).to.include({ + startTime: -1, + tagOrder: 0, + }); + expect(details1.dateRanges.D0?.tagAnchor).to.include({ + sn: 1, + }); + expect(details1.dateRanges.D1).to.include({ + startTime: 0, + tagOrder: 1, + }); + expect(details1.dateRanges.D1?.tagAnchor).to.include({ + sn: 1, // expected to change in details2 after `mergeDetails` when sn: 1 is removed + }); + expect(details1.dateRanges.D2).to.include({ + startTime: 15, + tagOrder: 2, + }); + expect(details1.dateRanges.D2?.tagAnchor).to.include({ + sn: 6, + }); + + // Merged delta playlist with EXT-X-PROGRAM-DATE-TIME removed and DateRange tagAnchors updated + mergeDetails(details1, details2, logger); + + expect(details2).to.include({ + startSN: 5, + endSN: 10, + totalduration: 24, + dateRangeTagCount: 1, + }); + expect(details2.dateRanges).to.have.keys(['D1', 'D2', 'D3']); + expect(details2.dateRanges.D1).to.include({ + startTime: 0, + tagOrder: 1, + }); + expect(details2.dateRanges.D1?.tagAnchor).to.include({ + sn: 6, + }); + expect(details2.dateRanges.D2).to.include({ + startTime: 15, + tagOrder: 2, + }); + expect(details2.dateRanges.D2?.tagAnchor).to.include({ + sn: 6, + }); + expect(details2.dateRanges.D3).to.include({ + startTime: 59.1, + tagOrder: 2, // Note: tagOrder: 3 is expected (following previous/skipped date-ranges), but not critical. + }); + expect(details2.dateRanges.D3?.tagAnchor).to.include({ + sn: 6, + }); + }); + + it('merges new dateranges in delta updates with previous details containing no dateranges', function () { + const playlist1 = `#EXTM3U +#EXT-X-VERSION:6 +#EXT-X-MEDIA-SEQUENCE:1 +#EXT-X-TARGETDURATION:6 +#EXT-X-SERVER-CONTROL:CAN-SKIP-UNTIL=9,CAN-SKIP-DATERANGES=YES +#EXT-X-MAP:URI="hls/20821722-video=2499968.m4s" +#EXT-X-PROGRAM-DATE-TIME:2025-08-06T16:00:00Z +#EXTINF:4.0, no desc +1.m4s +#EXTINF:4.0, no desc +2.m4s +#EXT-X-PROGRAM-DATE-TIME:2025-08-06T16:08:00Z +#EXTINF:4.0, no desc +3.m4s +#EXTINF:4.0, no desc +4.m4s +#EXTINF:4.0, no desc +5.m4s +#EXT-X-PROGRAM-DATE-TIME:2025-08-06T16:00:20Z +#EXTINF:4.0, no desc +6.m4s +#EXTINF:4.0, no desc +7.m4s +#EXTINF:4.0, no desc +8.m4s +#EXTINF:4.0, no desc +9.m4s`; + // Media sequence increased by one but two segments removed. + const playlist2 = `#EXTM3U +#EXT-X-VERSION:10 +#EXT-X-MEDIA-SEQUENCE:5 +#EXT-X-INDEPENDENT-SEGMENTS +#EXT-X-TARGETDURATION:6 +#EXT-X-SERVER-CONTROL:CAN-SKIP-UNTIL=9,CAN-SKIP-DATERANGES=YES +#EXT-X-SKIP:SKIPPED-SEGMENTS=4,RECENTLY-REMOVED-DATERANGES="D0" +#EXTINF:4, no desc +9.m4s +#EXTINF:4, no desc +10.m4s +#EXT-X-DATERANGE:ID="D3",START-DATE="2025-08-06T16:00:59.100Z",DURATION=12,SCTE35-CMD=0x00000000`; + + const details1 = parseLevelPlaylist(playlist1); + const details2 = parseLevelPlaylist(playlist2); + + expect(details1.playlistParsingError).to.be.null; + expect(details2.playlistParsingError).to.be.null; + + // First playilst details + expect(details1).to.include({ + startSN: 1, + endSN: 9, + totalduration: 36, + dateRangeTagCount: 0, + }); + expect(details1.dateRanges).to.be.empty; + + // Merged delta playlist + mergeDetails(details1, details2, logger); + + expect(details2).to.include({ + startSN: 5, + endSN: 10, + totalduration: 24, + dateRangeTagCount: 1, + }); + expect(details2.dateRanges).to.have.keys(['D3']); + expect(details2.dateRanges.D3).to.include({ + startTime: 59.1, + tagOrder: 0, + }); + expect(details2.dateRanges.D3?.tagAnchor, 'D3?.tagAnchor').to.include({ + sn: 6, + }); + }); + + it('adds and removed dateranges in delta updates with previous details when all previous dateranges are removed', function () { + const playlist1 = `#EXTM3U +#EXT-X-VERSION:6 +#EXT-X-MEDIA-SEQUENCE:1 +#EXT-X-TARGETDURATION:6 +#EXT-X-SERVER-CONTROL:CAN-SKIP-UNTIL=9,CAN-SKIP-DATERANGES=YES +#EXT-X-MAP:URI="hls/20821722-video=2499968.m4s" +#EXT-X-PROGRAM-DATE-TIME:2025-08-06T16:00:00Z +#EXTINF:4.0, no desc +1.m4s +#EXTINF:4.0, no desc +2.m4s +#EXTINF:4.0, no desc +3.m4s +#EXTINF:4.0, no desc +4.m4s +#EXTINF:4.0, no desc +5.m4s +#EXTINF:4.0, no desc +6.m4s +#EXTINF:4.0, no desc +7.m4s +#EXTINF:4.0, no desc +8.m4s +#EXTINF:4.0, no desc +9.m4s +#EXT-X-DATERANGE:ID="D2",START-DATE="2025-08-06T16:00:15Z",PLANNED-DURATION=12,SCTE35-OUT=0x00000000`; + // Media sequence increased by one but two segments removed. + const playlist2 = `#EXTM3U +#EXT-X-VERSION:10 +#EXT-X-MEDIA-SEQUENCE:5 +#EXT-X-INDEPENDENT-SEGMENTS +#EXT-X-TARGETDURATION:6 +#EXT-X-SERVER-CONTROL:CAN-SKIP-UNTIL=9,CAN-SKIP-DATERANGES=YES +#EXT-X-SKIP:SKIPPED-SEGMENTS=4,RECENTLY-REMOVED-DATERANGES="D2" +#EXTINF:4, no desc +9.m4s +#EXTINF:4, no desc +10.m4s +#EXT-X-DATERANGE:ID="D3",START-DATE="2025-08-06T16:00:59.100Z",DURATION=12,SCTE35-CMD=0x00000000`; + + const playlist3 = `#EXTM3U +#EXT-X-VERSION:10 +#EXT-X-MEDIA-SEQUENCE:6 +#EXT-X-INDEPENDENT-SEGMENTS +#EXT-X-TARGETDURATION:6 +#EXT-X-SERVER-CONTROL:CAN-SKIP-UNTIL=9,CAN-SKIP-DATERANGES=YES +#EXT-X-SKIP:SKIPPED-SEGMENTS=4,RECENTLY-REMOVED-DATERANGES="D2" +#EXTINF:4, no desc +10.m4s +#EXTINF:4, no desc +11.m4s +#EXT-X-DATERANGE:ID="D3",START-DATE="2025-08-06T16:00:59.100Z",DURATION=12,SCTE35-CMD=0x00000000`; + + const playlist4 = `#EXTM3U +#EXT-X-VERSION:10 +#EXT-X-MEDIA-SEQUENCE:7 +#EXT-X-INDEPENDENT-SEGMENTS +#EXT-X-TARGETDURATION:6 +#EXT-X-SERVER-CONTROL:CAN-SKIP-UNTIL=9,CAN-SKIP-DATERANGES=YES +#EXT-X-SKIP:SKIPPED-SEGMENTS=4,RECENTLY-REMOVED-DATERANGES="D2 D3" +#EXTINF:4, no desc +11.m4s +#EXTINF:4, no desc +12.m4s +#EXT-X-DATERANGE:ID="D4",START-DATE="2025-08-06T16:01:09.100Z",DURATION=12,SCTE35-CMD=0x00000000`; + + const details1 = parseLevelPlaylist(playlist1); + const details2 = parseLevelPlaylist(playlist2); + const details3 = parseLevelPlaylist(playlist3); + const details4 = parseLevelPlaylist(playlist4); + + expect(details1.playlistParsingError).to.be.null; + expect(details2.playlistParsingError).to.be.null; + expect(details3.playlistParsingError).to.be.null; + expect(details4.playlistParsingError).to.be.null; + + // First playilst details + expect(details1).to.include({ + startSN: 1, + endSN: 9, + totalduration: 36, + dateRangeTagCount: 1, + }); + expect(details1.dateRanges).to.have.keys(['D2']); + + // Merge delta playlist + mergeDetails(details1, details2, logger); + + expect(details2).to.include({ + startSN: 5, + endSN: 10, + totalduration: 24, + dateRangeTagCount: 1, + }); + expect(details2.dateRanges).to.have.keys(['D3']); + expect(details2.dateRanges.D3).to.include({ + startTime: 59.1, + tagOrder: 0, + }); + expect(details2.dateRanges.D3?.tagAnchor, 'D3?.tagAnchor').to.include({ + sn: 1, + }); + + // Merge next delta playlist + mergeDetails(details2, details3, logger); + + expect(details3).to.include({ + startSN: 6, + endSN: 11, + totalduration: 24, + dateRangeTagCount: 1, + }); + expect(details3.dateRanges).to.have.keys(['D3']); + expect(details3.dateRanges.D3).to.include({ + startTime: 59.1, + tagOrder: 0, + }); + // `tagAnchor` moved to last segment when no segments with `rawProgramDateTime` (#EXT-X-PROGRAM-DATE-TIME) remain + expect(details3.dateRanges.D3?.tagAnchor, 'D3?.tagAnchor').to.include({ + sn: 11, + }); + + // Merge next delta playlist + mergeDetails(details3, details4, logger); + + expect(details4).to.include({ + startSN: 7, + endSN: 12, + totalduration: 24, + dateRangeTagCount: 1, + }); + expect(details4.dateRanges).to.have.keys(['D4']); + // `tagAnchor` not inherited in new daterange when no segments with `rawProgramDateTime` (#EXT-X-PROGRAM-DATE-TIME) remain + expect(details4.dateRanges.D4?.tagAnchor, 'D4?.tagAnchor').to.include({ + sn: 12, + }); + expect(details4.dateRanges.D4).to.include({ + startTime: 69.1, + tagOrder: 0, + }); + }); }); function parseLevelPlaylist( diff --git a/tests/unit/controller/stream-controller.ts b/tests/unit/controller/stream-controller.ts index d645a0648aa..8bee6813a4c 100644 --- a/tests/unit/controller/stream-controller.ts +++ b/tests/unit/controller/stream-controller.ts @@ -210,7 +210,7 @@ describe('StreamController', function () { fragPrevious.programDateTime = 1505502671523; fragPrevious.duration = 5.0; fragPrevious.level = 1; - fragPrevious.start = 10.0; + fragPrevious.setStart(10.0); fragPrevious.sn = 2; // Fragment with PDT 1505502671523 in level 1 does not have the same sn as in level 2 where cc is 1 fragPrevious.cc = 0; @@ -274,7 +274,7 @@ describe('StreamController', function () { fragPrevious.programDateTime = 1505502681523; fragPrevious.duration = 5.0; fragPrevious.level = 1; - fragPrevious.start = 15.0; + fragPrevious.setStart(15.0); fragPrevious.sn = 3; streamController['fragPrevious'] = fragPrevious; @@ -475,7 +475,7 @@ describe('StreamController', function () { ) as MediaFragment; firstFrag.duration = 5.0; firstFrag.level = 1; - firstFrag.start = 0; + firstFrag.setStart(0); firstFrag.sn = 1; firstFrag.cc = 0; firstFrag.elementaryStreams.video = { diff --git a/tests/unit/controller/subtitle-stream-controller.ts b/tests/unit/controller/subtitle-stream-controller.ts index d0489781814..b70ae6371c8 100644 --- a/tests/unit/controller/subtitle-stream-controller.ts +++ b/tests/unit/controller/subtitle-stream-controller.ts @@ -42,7 +42,7 @@ describe('SubtitleStreamController', function () { hls = new Hls({}); mediaMock.currentTime = 0; fragmentTracker = new FragmentTracker(hls); - keyLoader = new KeyLoader(hls.config); + keyLoader = new KeyLoader(hls.config, hls.logger); subtitleStreamController = new SubtitleStreamController( hls, diff --git a/tests/unit/hls.ts b/tests/unit/hls.ts index ae64e5af31b..cf9779e5091 100644 --- a/tests/unit/hls.ts +++ b/tests/unit/hls.ts @@ -57,7 +57,7 @@ describe('Hls', function () { const hls = new Hls({ capLevelOnFPSDrop: true }); expect(hls.media).to.equal(null); const media = document.createElement('video'); - expect(media || null).to.not.equal(null); + expect(media).to.be.an('HTMLVideoElement'); hls.attachMedia(media); expect(hls.media).to.equal(media); detachTest(hls, media, 6); @@ -72,7 +72,7 @@ describe('Hls', function () { }); expect(hls.media).to.equal(null); const media = document.createElement('video'); - expect(media || null).to.not.equal(null); + expect(media).to.be.an('HTMLVideoElement'); hls.attachMedia(media); expect(hls.media).to.equal(media); hls.trigger(Events.MEDIA_ATTACHED, { media }); diff --git a/tests/unit/loader/m3u8-parser.ts b/tests/unit/loader/m3u8-parser.ts index e41bfc7d0ee..1549f308ce7 100644 --- a/tests/unit/loader/m3u8-parser.ts +++ b/tests/unit/loader/m3u8-parser.ts @@ -1996,10 +1996,10 @@ segment.m4s ); expect(details.playlistParsingError).to.be.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.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, }); @@ -2012,15 +2012,15 @@ segment.m4s 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; + 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); + 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('ensures DateRanges are mapped to a segment whose TimeRange covers the start date of the DATERANGE tag', function () { @@ -2047,14 +2047,14 @@ segment.m4s null, ); expect(details.playlistParsingError).to.be.null; - expect(details.dateRanges.sooner.isValid).to.equal( + expect(details.dateRanges.sooner!.isValid).to.equal( true, 'is valid DateRange', ); - expect(details.dateRanges.sooner.tagAnchor) + expect(details.dateRanges.sooner!.tagAnchor) .to.have.property('sn') .which.equals(2); - expect(details.dateRanges.sooner.startTime).to.equal(10); + expect(details.dateRanges.sooner!.startTime).to.equal(10); }); it('ensures DateRanges that start before the program are mapped to the first PDT tag', function () { @@ -2081,14 +2081,14 @@ segment.m4s null, ); expect(details.playlistParsingError).to.be.null; - expect(details.dateRanges.earlier.isValid).to.equal( + expect(details.dateRanges.earlier!.isValid).to.equal( true, 'is valid DateRange', ); - expect(details.dateRanges.earlier.tagAnchor) + expect(details.dateRanges.earlier!.tagAnchor) .to.have.property('sn') .which.equals(1); - expect(details.dateRanges.earlier.startTime).to.equal(-10); + expect(details.dateRanges.earlier!.startTime).to.equal(-10); }); it('adds PROGRAM-DATE-TIME and DATERANGE tag text to fragment[].tagList for backwards compatibility', function () { @@ -2758,10 +2758,6 @@ a{$mvpVariable}.mp4 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; @@ -2913,7 +2909,7 @@ a{$bar}.mp4 details, 'Missing preceding EXT-X-DEFINE tag for Variable Reference: "bar"', ); - expect(details.fragments?.[0].relurl).to.equal('a{$bar}.mp4'); + expect(details.fragments[0].relurl).to.equal('a{$bar}.mp4'); }); it('fails to parse Media Playlist when variable reference precedes definition', function () { diff --git a/tests/unit/utils/mediacapabilities-helper.ts b/tests/unit/utils/mediacapabilities-helper.ts index 445c554a59e..c7518b24201 100644 --- a/tests/unit/utils/mediacapabilities-helper.ts +++ b/tests/unit/utils/mediacapabilities-helper.ts @@ -7,6 +7,11 @@ import type { MediaPlaylist, } from '../../../src/types/media-playlist'; +declare const navigator: { + prototype: Navigator; + mediaCapabilities?: MediaCapabilities; +}; + describe('getMediaDecodingInfoPromise', function () { it('adds queries to cache', function () { if (!navigator.mediaCapabilities) { diff --git a/tests/unit/utils/mock-level.ts b/tests/unit/utils/mock-level.ts new file mode 100644 index 00000000000..2b2fd421ef7 --- /dev/null +++ b/tests/unit/utils/mock-level.ts @@ -0,0 +1,15 @@ +import { AttrList } from '../../../src/utils/attr-list'; +import type { LevelParsed } from '../../../src/types/level'; + +export function parsedLevel( + options: Partial & { bitrate: number }, +): LevelParsed { + const { bitrate, height } = options; + const level: LevelParsed = { + attrs: new AttrList({ BANDWIDTH: bitrate }), + bitrate, + name: `${height}-${bitrate}`, + url: `${bitrate}.m3u8`, + }; + return Object.assign(level, options); +} diff --git a/tools/mp4-inspect.js b/tools/mp4-inspect.js index 4dad9167e6f..9672cc8654f 100644 --- a/tools/mp4-inspect.js +++ b/tools/mp4-inspect.js @@ -663,7 +663,8 @@ var mp4toJSON = function (data) { while (i < data.byteLength) { // parse box data - (size = view.getUint32(i)), (type = parseType(data.subarray(i + 4, i + 8))); + ((size = view.getUint32(i)), + (type = parseType(data.subarray(i + 4, i + 8)))); end = size > 1 ? i + size : data.byteLength; // parse type-specific data