diff --git a/README.md b/README.md index 5708942b9f7..e1239c143a7 100644 --- a/README.md +++ b/README.md @@ -110,11 +110,11 @@ The following properties are added to their respective variants' attribute list - `#EXT-X-RENDITION-REPORT:` - `#EXT-X-DATERANGE:` Metadata - `#EXT-X-DEFINE:` Variable Import and Substitution +- `#EXT-X-GAP` (Skips loading GAP segments and parts. Skips playback of unbuffered program containing only GAP content and no suitable alternates (up to two segments). See [#2940](https://github.com/video-dev/hls.js/issues/2940)) The following tags are added to their respective fragment's attribute list but are not implemented in streaming and playback. - `#EXT-X-BITRATE` (Not used in ABR controller) -- `#EXT-X-GAP` (Not implemented. See [#2940](https://github.com/video-dev/hls.js/issues/2940)) Parsed but missing feature support diff --git a/api-extractor/report/hls.js.api.md b/api-extractor/report/hls.js.api.md index c02b5fe0eb2..42b581f285d 100644 --- a/api-extractor/report/hls.js.api.md +++ b/api-extractor/report/hls.js.api.md @@ -989,6 +989,8 @@ export enum ErrorDetails { // (undocumented) FRAG_DECRYPT_ERROR = "fragDecryptError", // (undocumented) + FRAG_GAP = "fragGap", + // (undocumented) FRAG_LOAD_ERROR = "fragLoadError", // (undocumented) FRAG_LOAD_TIMEOUT = "fragLoadTimeOut", @@ -1341,6 +1343,8 @@ export class Fragment extends BaseSegment { // (undocumented) endPTS?: number; // (undocumented) + gap?: boolean; + // (undocumented) initSegment: Fragment | null; // Warning: (ae-forgotten-export) The symbol "KeyLoaderContext" needs to be exported by the entry point hls.d.ts // diff --git a/docs/API.md b/docs/API.md index e4d704e4842..edf7139a07e 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1720,14 +1720,14 @@ Full list of errors is described below: - data: { type : `NETWORK_ERROR`, details : `Hls.ErrorDetails.MANIFEST_LOAD_TIMEOUT`, fatal : `true`, url : manifest URL, loader : URL loader } - `Hls.ErrorDetails.MANIFEST_PARSING_ERROR` - raised when manifest parsing failed to find proper content - data: { type : `NETWORK_ERROR`, details : `Hls.ErrorDetails.MANIFEST_PARSING_ERROR`, fatal : `true`, url : manifest URL, reason : parsing error reason } -- `Hls.ErrorDetails.LEVEL_EMPTY_ERROR` - raised when loaded level contains no fragments - - data: { type : `NETWORK_ERROR`, details : `Hls.ErrorDetails.LEVEL_EMPTY_ERROR`, url: playlist URL, reason: error reason, level: index of the bad level } +- `Hls.ErrorDetails.LEVEL_EMPTY_ERROR` - raised when loaded level contains no fragments (applies to levels and audio and subtitle tracks) + - data: { type : `NETWORK_ERROR`, details : `Hls.ErrorDetails.LEVEL_EMPTY_ERROR`, url: playlist URL, reason: error reason, level: index of the bad level or undefined, parent: PlaylistLevelType } - `Hls.ErrorDetails.LEVEL_LOAD_ERROR` - raised when level loading fails because of a network error - data: { type : `NETWORK_ERROR`, details : `Hls.ErrorDetails.LEVEL_LOAD_ERROR`, fatal : `true`, url : level URL, response : { code: error code, text: error text }, loader : URL loader } - `Hls.ErrorDetails.LEVEL_LOAD_TIMEOUT` - raised when level loading fails because of a timeout - data: { type : `NETWORK_ERROR`, details : `Hls.ErrorDetails.LEVEL_LOAD_TIMEOUT`, fatal : `false`, url : level URL, loader : URL loader } -- `Hls.ErrorDetails.LEVEL_PARSING_ERROR` - raised when level parsing failed or found invalid content - - data: { type : `NETWORK_ERROR`, details : `Hls.ErrorDetails.LEVEL_PARSING_ERROR`, fatal : `false`, url : level URL, error: Error } +- `Hls.ErrorDetails.LEVEL_PARSING_ERROR` - raised when playlist parsing failed or found invalid content (applies to levels and audio and subtitle tracks) + - data: { type : `NETWORK_ERROR`, details : `Hls.ErrorDetails.LEVEL_PARSING_ERROR`, fatal : `false`, url : level URL, error: Error, parent: PlaylistLevelType } - `Hls.ErrorDetails.AUDIO_TRACK_LOAD_ERROR` - raised when audio playlist loading fails because of a network error - data: { type : `NETWORK_ERROR`, details : `Hls.ErrorDetails.AUDIO_TRACK_LOAD_ERROR`, fatal : `false`, url : audio URL, response : { code: error code, text: error text }, loader : URL loader } - `Hls.ErrorDetails.AUDIO_TRACK_LOAD_TIMEOUT` - raised when audio playlist loading fails because of a timeout @@ -1753,6 +1753,8 @@ Full list of errors is described below: - data: { type : `MEDIA_ERROR`, details : `Hls.ErrorDetails.FRAG_DECRYPT_ERROR`, fatal : `true`, reason : failure reason } - `Hls.ErrorDetails.FRAG_PARSING_ERROR` - raised when fragment parsing fails - data: { type : `MEDIA_ERROR`, details : `Hls.ErrorDetails.FRAG_PARSING_ERROR`, fatal : `true` or `false`, reason : failure reason } +- `Hls.ErrorDetails.FRAG_GAP` - raised when segment loading is skipped because a fragment with a GAP tag or part with GAP=YES attribute was encountered + - data: { type : `MEDIA_ERROR`, details : `Hls.ErrorDetails.FRAG_GAP`, fatal : `false`, frag : fragment object, part? : part object (if any) } - `Hls.ErrorDetails.BUFFER_ADD_CODEC_ERROR` - raised when MediaSource fails to add new sourceBuffer - data: { type : `MEDIA_ERROR`, details : `Hls.ErrorDetails.BUFFER_ADD_CODEC_ERROR`, fatal : `false`, error : error raised by MediaSource, mimeType: mimeType on which the failure happened } - `Hls.ErrorDetails.BUFFER_INCOMPATIBLE_CODECS_ERROR` - raised when no MediaSource(s) could be created based on track codec(s) diff --git a/src/controller/audio-stream-controller.ts b/src/controller/audio-stream-controller.ts index 14f3ba60e08..9d946b022d8 100644 --- a/src/controller/audio-stream-controller.ts +++ b/src/controller/audio-stream-controller.ts @@ -356,7 +356,13 @@ class AudioStreamController } // wait for main buffer after buffing some audio if (!mainBufferInfo?.len && bufferInfo.len) { - return; + if ( + !mainBufferInfo?.nextStart || + mainBufferInfo.nextStart > + bufferInfo.end + trackDetails.targetduration * 2 + ) { + return; + } } const frag = this.getNextFragment(targetBufferTime, trackDetails); @@ -643,6 +649,7 @@ class AudioStreamController return; } switch (data.details) { + case ErrorDetails.FRAG_GAP: case ErrorDetails.FRAG_PARSING_ERROR: case ErrorDetails.FRAG_DECRYPT_ERROR: case ErrorDetails.FRAG_LOAD_ERROR: diff --git a/src/controller/base-stream-controller.ts b/src/controller/base-stream-controller.ts index 59de94bdb28..e9b2b5db983 100644 --- a/src/controller/base-stream-controller.ts +++ b/src/controller/base-stream-controller.ts @@ -1160,7 +1160,10 @@ export default class BaseStreamController const curSNIdx = frag.sn - levelDetails.startSN; // Move fragPrevious forward to support forcing the next fragment to load // when the buffer catches up to a previously buffered range. - if (this.fragmentTracker.getState(frag) === FragmentState.OK) { + if ( + this.fragmentTracker.getState(frag) === FragmentState.OK || + (frag.gap && frag.stats.aborted) + ) { fragPrevious = frag; } if (fragPrevious && frag.sn === fragPrevious.sn && !loadingParts) { @@ -1371,6 +1374,9 @@ export default class BaseStreamController ); return; } + if (data.details === ErrorDetails.FRAG_GAP) { + this.fragmentTracker.fragBuffered(frag, true); + } // keep retrying until the limit will be reached const errorAction = data.errorAction; const { action, retryCount = 0, retryConfig } = errorAction || {}; diff --git a/src/controller/eme-controller.ts b/src/controller/eme-controller.ts index 61b1ba34cf7..15d46daa088 100644 --- a/src/controller/eme-controller.ts +++ b/src/controller/eme-controller.ts @@ -24,7 +24,7 @@ import { base64Decode } from '../utils/numeric-encoding-utils'; import { DecryptData, LevelKey } from '../loader/level-key'; import Hex from '../utils/hex'; import { bin2str, parsePssh, parseSinf } from '../utils/mp4-tools'; -import EventEmitter from 'eventemitter3'; +import { EventEmitter } from 'eventemitter3'; import type Hls from '../hls'; import type { ComponentAPI } from '../types/component-api'; import type { diff --git a/src/controller/error-controller.ts b/src/controller/error-controller.ts index 0bdf1a0528d..51c644c7a44 100644 --- a/src/controller/error-controller.ts +++ b/src/controller/error-controller.ts @@ -75,6 +75,7 @@ export default class ErrorController implements NetworkComponentAPI { case ErrorDetails.KEY_LOAD_TIMEOUT: data.errorAction = this.getFragRetryOrSwitchAction(data); return; + case ErrorDetails.FRAG_GAP: case ErrorDetails.FRAG_PARSING_ERROR: case ErrorDetails.FRAG_DECRYPT_ERROR: { // Switch level if possible, otherwise allow retry count to reach max error retries @@ -243,7 +244,9 @@ export default class ErrorController implements NetworkComponentAPI { ); // Switch levels when out of retried or level index out of bounds if (level) { - level.fragmentError++; + if (data.details !== ErrorDetails.FRAG_GAP) { + level.fragmentError++; + } const httpStatus = data.response?.code; const retry = shouldRetry( retryConfig, diff --git a/src/controller/fragment-tracker.ts b/src/controller/fragment-tracker.ts index f4904e51567..d522517a491 100644 --- a/src/controller/fragment-tracker.ts +++ b/src/controller/fragment-tracker.ts @@ -220,9 +220,18 @@ export class FragmentTracker implements ComponentAPI { } } - public fragBuffered(frag: Fragment) { + public fragBuffered(frag: Fragment, force?: boolean) { const fragKey = getFragmentKey(frag); - const fragmentEntity = this.fragments[fragKey]; + let fragmentEntity = this.fragments[fragKey]; + if (!fragmentEntity && force) { + fragmentEntity = this.fragments[fragKey] = { + body: frag, + appendedPTS: null, + loaded: null, + buffered: false, + range: Object.create(null), + }; + } if (fragmentEntity) { fragmentEntity.loaded = null; fragmentEntity.buffered = true; @@ -482,7 +491,9 @@ export class FragmentTracker implements ComponentAPI { function isPartial(fragmentEntity: FragmentEntity): boolean { return ( fragmentEntity.buffered && - (fragmentEntity.range.video?.partial || fragmentEntity.range.audio?.partial) + (fragmentEntity.body.gap || + fragmentEntity.range.video?.partial || + fragmentEntity.range.audio?.partial) ); } diff --git a/src/controller/gap-controller.ts b/src/controller/gap-controller.ts index 2591c77b983..ff3bf04efe5 100644 --- a/src/controller/gap-controller.ts +++ b/src/controller/gap-controller.ts @@ -131,7 +131,11 @@ export default class GapController { const maxStartGapJump = isLive ? level!.details!.targetduration * 2 : MAX_START_GAP_JUMP; - if (startJump > 0 && startJump <= maxStartGapJump) { + if ( + startJump > 0 && + (startJump <= maxStartGapJump || + this.fragmentTracker.getPartialFragment(0)) + ) { this._trySkipBufferHole(null); return; } @@ -194,7 +198,9 @@ export default class GapController { // needs to cross some sort of threshold covering all source-buffers content // to start playing properly. if ( - bufferInfo.len > config.maxBufferHole && + (bufferInfo.len > config.maxBufferHole || + (bufferInfo.nextStart && + bufferInfo.nextStart - currentTime < config.maxBufferHole)) && stalledDurationMs > config.highBufferWatchdogPeriod * 1000 ) { logger.warn('Trying to nudge playhead over buffer-hole'); diff --git a/src/controller/latency-controller.ts b/src/controller/latency-controller.ts index bf60895bb9e..707079f4b66 100644 --- a/src/controller/latency-controller.ts +++ b/src/controller/latency-controller.ts @@ -184,9 +184,11 @@ export default class LatencyController implements ComponentAPI { return; } this.stallCount++; - logger.warn( - '[playback-rate-controller]: Stall detected, adjusting target latency' - ); + if (this.levelDetails?.live) { + logger.warn( + '[playback-rate-controller]: Stall detected, adjusting target latency' + ); + } } private timeupdate() { diff --git a/src/controller/stream-controller.ts b/src/controller/stream-controller.ts index f427cf317d3..00b1b4476c7 100644 --- a/src/controller/stream-controller.ts +++ b/src/controller/stream-controller.ts @@ -855,6 +855,7 @@ export default class StreamController return; } switch (data.details) { + case ErrorDetails.FRAG_GAP: case ErrorDetails.FRAG_PARSING_ERROR: case ErrorDetails.FRAG_DECRYPT_ERROR: case ErrorDetails.FRAG_LOAD_ERROR: diff --git a/src/errors.ts b/src/errors.ts index b230d4b456f..c910d4f9a70 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -60,6 +60,8 @@ export enum ErrorDetails { // Identifier for a fragment parsing error event - data: { id : demuxer Id, reason : parsing error description } // will be renamed DEMUX_PARSING_ERROR and switched to MUX_ERROR in the next major release FRAG_PARSING_ERROR = 'fragParsingError', + // Identifier for a fragment or part load skipped because of a GAP tag or attribute + FRAG_GAP = 'fragGap', // Identifier for a remux alloc error event - data: { id : demuxer Id, frag : fragment object, bytes : nb of bytes on which allocation failed , reason : error text } REMUX_ALLOC_ERROR = 'remuxAllocError', // Identifier for decrypt key load error - data: { frag : fragment object, response : { code: error code, text: error text }} diff --git a/src/loader/fragment-loader.ts b/src/loader/fragment-loader.ts index 136d7ed4abb..27884939923 100644 --- a/src/loader/fragment-loader.ts +++ b/src/loader/fragment-loader.ts @@ -68,6 +68,21 @@ export default class FragmentLoader { if (this.loader) { this.loader.destroy(); } + if (frag.gap) { + frag.stats.aborted = true; + frag.stats.retry++; + reject( + new LoadError({ + type: ErrorTypes.MEDIA_ERROR, + details: ErrorDetails.FRAG_GAP, + fatal: false, + frag, + error: new Error('GAP tag found'), + networkDetails: null, + }) + ); + return; + } const loader = (this.loader = frag.loader = @@ -175,6 +190,22 @@ export default class FragmentLoader { if (this.loader) { this.loader.destroy(); } + if (frag.gap || part.gap) { + frag.stats.aborted = true; + frag.stats.retry++; + reject( + new LoadError({ + type: ErrorTypes.MEDIA_ERROR, + details: ErrorDetails.FRAG_GAP, + fatal: false, + frag, + part, + error: new Error(`GAP ${frag.gap ? 'tag' : 'attribute'} found`), + networkDetails: null, + }) + ); + return; + } const loader = (this.loader = frag.loader = diff --git a/src/loader/fragment.ts b/src/loader/fragment.ts index 2dd4aaa5f26..6d770c027e8 100644 --- a/src/loader/fragment.ts +++ b/src/loader/fragment.ts @@ -147,6 +147,8 @@ export class Fragment extends BaseSegment { public initSegment: Fragment | null = null; // Fragment is the last fragment in the media playlist public endList?: boolean; + // Fragment is marked by an EXT-X-GAP tag indicating that it does not contain media data and should not be loaded + public gap?: boolean; constructor(type: PlaylistLevelType, baseurl: string) { super(baseurl); diff --git a/src/loader/m3u8-parser.ts b/src/loader/m3u8-parser.ts index 22e52156e70..830404f3065 100644 --- a/src/loader/m3u8-parser.ts +++ b/src/loader/m3u8-parser.ts @@ -512,6 +512,7 @@ export default class M3U8Parser { frag.tagList.push(['DIS']); break; case 'GAP': + frag.gap = true; frag.tagList.push([tag]); break; case 'BITRATE': diff --git a/tests/unit/loader/playlist-loader.ts b/tests/unit/loader/playlist-loader.ts index 3f7190a60cb..054095c6166 100644 --- a/tests/unit/loader/playlist-loader.ts +++ b/tests/unit/loader/playlist-loader.ts @@ -1582,7 +1582,7 @@ fileSequence2.ts ]); }); - it('adds GAP to fragment.tagList', function () { + it('adds GAP to fragment.tagList and sets fragment.gap', function () { const playlist = `#EXTM3U #EXT-X-TARGETDURATION:5 #EXT-X-VERSION:3 @@ -1613,6 +1613,9 @@ fileSequence2.ts ['GAP'], ]); expectWithJSONMessage(fragments[2].tagList).to.deep.equal([['INF', '5']]); + expect(fragments[0].gap).to.equal(undefined); + expect(fragments[1].gap).to.equal(true); + expect(fragments[2].gap).to.equal(undefined); }); it('adds unhandled tags (DATERANGE) and comments to fragment.tagList', function () {