From 9399985315e5432168b09f9919e24d7cdc534377 Mon Sep 17 00:00:00 2001 From: Rob Walch Date: Tue, 21 Feb 2023 20:31:54 -0800 Subject: [PATCH] Handle fragments and parts with a GAP tag/attr #2940 --- api-extractor/report/hls.js.api.md | 4 +++ src/controller/audio-stream-controller.ts | 9 ++++++- src/controller/base-stream-controller.ts | 8 +++++- src/controller/eme-controller.ts | 2 +- src/controller/error-controller.ts | 5 +++- src/controller/fragment-tracker.ts | 17 ++++++++++--- src/controller/gap-controller.ts | 10 ++++++-- src/controller/latency-controller.ts | 8 +++--- src/controller/stream-controller.ts | 1 + src/errors.ts | 2 ++ src/loader/fragment-loader.ts | 31 +++++++++++++++++++++++ src/loader/fragment.ts | 2 ++ src/loader/m3u8-parser.ts | 1 + tests/unit/loader/playlist-loader.ts | 5 +++- 14 files changed, 92 insertions(+), 13 deletions(-) diff --git a/api-extractor/report/hls.js.api.md b/api-extractor/report/hls.js.api.md index e7af4df8f01..3fddf7c2ee7 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/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 92793d4462a..3a0d881b2cf 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 d6909071f42..034251d877f 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 @@ -239,7 +240,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..15994966340 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) { + 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 tag 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 () {