Skip to content

Commit

Permalink
Handle fragments and parts with a GAP tag/attr
Browse files Browse the repository at this point in the history
  • Loading branch information
robwalch committed Feb 28, 2023
1 parent 8063580 commit bbb4134
Show file tree
Hide file tree
Showing 16 changed files with 99 additions and 18 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,11 +110,11 @@ The following properties are added to their respective variants' attribute list
- `#EXT-X-RENDITION-REPORT:<attribute-list>`
- `#EXT-X-DATERANGE:<attribute-list>` Metadata
- `#EXT-X-DEFINE:<attribute-list>` 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

Expand Down
4 changes: 4 additions & 0 deletions api-extractor/report/hls.js.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
//
Expand Down
10 changes: 6 additions & 4 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
9 changes: 8 additions & 1 deletion src/controller/audio-stream-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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:
Expand Down
8 changes: 7 additions & 1 deletion src/controller/base-stream-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 || {};
Expand Down
2 changes: 1 addition & 1 deletion src/controller/eme-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
5 changes: 4 additions & 1 deletion src/controller/error-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
17 changes: 14 additions & 3 deletions src/controller/fragment-tracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)
);
}

Expand Down
10 changes: 8 additions & 2 deletions src/controller/gap-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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');
Expand Down
8 changes: 5 additions & 3 deletions src/controller/latency-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
1 change: 1 addition & 0 deletions src/controller/stream-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
31 changes: 31 additions & 0 deletions src/loader/fragment-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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 =
Expand Down
2 changes: 2 additions & 0 deletions src/loader/fragment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions src/loader/m3u8-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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':
Expand Down
5 changes: 4 additions & 1 deletion tests/unit/loader/playlist-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 () {
Expand Down

0 comments on commit bbb4134

Please sign in to comment.