mirror of
https://github.com/video-dev/hls.js.git
synced 2026-05-17 13:30:38 +00:00
Prevent loop-loading of segments with dropped appends (#7797)
* Prevent loop-loading of segments that do not change SourceBuffer buffered time ranges Moves `detectEvictedFragments` to `detectPartialFragments` (all work performed after async yield to sb "updated" instead of some sync and some async) Refactor `isEndListAppended` workaround added in #7754 to resolve #7770 on playlist update - #7774 Seeking broken with fMP4 passthrough for h264 open-GOP content - #7546 Playlist which plays well in Firefox fails in Chrome by requesting same segment repeatedly - #6711 There is a segment in the hls video that loads many times (QuotaExceededError) - #7770 Fix FragmentTracker.isEndListAppended after Live to VOD transition * Address hongjun-bae's feedback
This commit is contained in:
@@ -1785,7 +1785,7 @@ export interface FragBufferedData {
|
||||
// (undocumented)
|
||||
frag: Fragment;
|
||||
// (undocumented)
|
||||
id: string;
|
||||
id: PlaylistLevelType;
|
||||
// (undocumented)
|
||||
part: Part | null;
|
||||
// (undocumented)
|
||||
@@ -2035,6 +2035,8 @@ export const enum FragmentState {
|
||||
export class FragmentTracker implements ComponentAPI {
|
||||
constructor(hls: Hls);
|
||||
// (undocumented)
|
||||
addAsGap(frag: MediaFragment): void;
|
||||
// (undocumented)
|
||||
destroy(): void;
|
||||
detectEvictedFragments(elementaryStream: SourceBufferName, timeRange: TimeRanges, playlistType: PlaylistLevelType, appendedPart?: Part | null, removeAppending?: boolean): void;
|
||||
detectPartialFragments(data: FragBufferedData): void;
|
||||
|
||||
@@ -299,20 +299,7 @@ export default class BaseStreamController
|
||||
|
||||
const lastFragment =
|
||||
levelDetails.fragments[levelDetails.fragments.length - 1];
|
||||
const playlistType = lastFragment.type;
|
||||
if (this.fragmentTracker.isEndListAppended(playlistType)) {
|
||||
return true;
|
||||
}
|
||||
// When a live playlist transitions to VOD (ENDLIST added), the last
|
||||
// fragment in the updated playlist has endList=true but the fragment
|
||||
// tracker entity was created before ENDLIST was known. Check if the
|
||||
// last fragment is actually buffered even though endListFragments
|
||||
// was never populated for it.
|
||||
if (lastFragment.endList) {
|
||||
const state = this.fragmentTracker.getState(lastFragment);
|
||||
return state === FragmentState.OK || state === FragmentState.PARTIAL;
|
||||
}
|
||||
return false;
|
||||
return this.fragmentTracker.isEndListAppended(lastFragment.type);
|
||||
}
|
||||
|
||||
public getLevelDetails(): LevelDetails | undefined {
|
||||
@@ -654,8 +641,26 @@ export default class BaseStreamController
|
||||
protected checkLiveUpdate(details: LevelDetails) {
|
||||
if (details.updated && !details.live) {
|
||||
// Live stream ended, update fragment tracker
|
||||
const fragmentTracker = this.fragmentTracker;
|
||||
const lastFragment = details.fragments[details.fragments.length - 1];
|
||||
this.fragmentTracker.detectPartialFragments({
|
||||
if (
|
||||
lastFragment.endList &&
|
||||
!fragmentTracker.isEndListAppended(this.playlistType)
|
||||
) {
|
||||
// When a live playlist transitions to VOD (ENDLIST added), the last
|
||||
// fragment in the updated playlist has endList=true but the fragment
|
||||
// tracker entity was created before ENDLIST was known. Check if the
|
||||
// last fragment is actually buffered even though endListFragments
|
||||
// was never populated for it. (#7770)
|
||||
const fragmentAtEnd = fragmentTracker.getPartialFragment(
|
||||
lastFragment.end,
|
||||
);
|
||||
if (fragmentAtEnd) {
|
||||
fragmentTracker.removeFragment(fragmentAtEnd);
|
||||
fragmentTracker.fragBuffered(lastFragment, true);
|
||||
}
|
||||
}
|
||||
fragmentTracker.detectPartialFragments({
|
||||
frag: lastFragment,
|
||||
part: null,
|
||||
stats: lastFragment.stats,
|
||||
@@ -839,11 +844,15 @@ export default class BaseStreamController
|
||||
}
|
||||
}
|
||||
const level = this.levels?.[frag.level];
|
||||
if (level?.fragmentError) {
|
||||
if (
|
||||
level?.fragmentError &&
|
||||
(part || this.fragmentTracker.getState(frag) !== FragmentState.PARTIAL)
|
||||
) {
|
||||
this.log(
|
||||
`Resetting level fragment error count of ${level.fragmentError} on frag buffered`,
|
||||
);
|
||||
level.fragmentError = 0;
|
||||
frag.stats.retry = 0;
|
||||
}
|
||||
}
|
||||
this.state = State.IDLE;
|
||||
@@ -1506,10 +1515,19 @@ export default class BaseStreamController
|
||||
return false;
|
||||
}
|
||||
const trackerState = this.fragmentTracker.getState(frag);
|
||||
return (
|
||||
trackerState === FragmentState.OK ||
|
||||
(trackerState === FragmentState.PARTIAL && !!frag.gap)
|
||||
);
|
||||
if (trackerState === FragmentState.OK) {
|
||||
return true;
|
||||
}
|
||||
if (trackerState === FragmentState.PARTIAL) {
|
||||
if (frag.gap) {
|
||||
return true;
|
||||
}
|
||||
if (frag.stats.retry > 1) {
|
||||
return true;
|
||||
}
|
||||
frag.stats.retry++;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
protected getNextFragmentLoopLoading(
|
||||
@@ -2011,7 +2029,7 @@ export default class BaseStreamController
|
||||
}
|
||||
const gapTagEncountered = data.details === ErrorDetails.FRAG_GAP;
|
||||
if (gapTagEncountered) {
|
||||
this.fragmentTracker.fragBuffered(frag as MediaFragment, true);
|
||||
this.fragmentTracker.addAsGap(frag as MediaFragment);
|
||||
}
|
||||
// keep retrying until the limit will be reached
|
||||
const errorAction = data.errorAction;
|
||||
@@ -2340,9 +2358,7 @@ export default class BaseStreamController
|
||||
if (level) {
|
||||
level.fragmentError++;
|
||||
}
|
||||
frag.gap = true;
|
||||
this.fragmentTracker.removeFragment(frag);
|
||||
this.fragmentTracker.fragBuffered(frag, true);
|
||||
this.fragmentTracker.addAsGap(frag);
|
||||
}
|
||||
|
||||
protected resetTransmuxer() {
|
||||
@@ -2621,7 +2637,7 @@ export default class BaseStreamController
|
||||
}
|
||||
}
|
||||
|
||||
function interstitialsEnabled(config: HlsConfig): boolean {
|
||||
function interstitialsEnabled(config: Readonly<HlsConfig>): boolean {
|
||||
return (
|
||||
__USE_INTERSTITIALS__ &&
|
||||
!!config.interstitialsController &&
|
||||
|
||||
@@ -1018,11 +1018,12 @@ transfer tracks: ${stringify(transferredTracks, (key, value) => (key === 'initSe
|
||||
this.append(operation, type, this.isPending(this.tracks[type]));
|
||||
}
|
||||
|
||||
private getClearEvictionPendingOp(type: string): BufferOperation {
|
||||
private getClearEvictionPendingOp(type: SourceBufferName): BufferOperation {
|
||||
return {
|
||||
label: 'clear',
|
||||
execute: () => {
|
||||
this._quotaEvictionPending[type] = false;
|
||||
this.shiftAndExecuteNext(type);
|
||||
},
|
||||
onStart: () => {},
|
||||
onComplete: () => {},
|
||||
@@ -1114,6 +1115,11 @@ transfer tracks: ${stringify(transferredTracks, (key, value) => (key === 'initSe
|
||||
stats,
|
||||
id: frag.type,
|
||||
});
|
||||
if (!part && frag.gap && stats.retry) {
|
||||
this.log(
|
||||
`Nothing buffered for ${frag.type} level: ${frag.level} sn: ${frag.sn} retries ${stats.retry}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (buffersAppendedTo.length === 0) {
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { Events } from '../events';
|
||||
import {
|
||||
type Fragment,
|
||||
isMediaFragment,
|
||||
type MediaFragment,
|
||||
type Part,
|
||||
} from '../loader/fragment';
|
||||
import type Hls from '../hls';
|
||||
import type { Fragment, MediaFragment, Part } from '../loader/fragment';
|
||||
import type { SourceBufferName } from '../types/buffer';
|
||||
import type { ComponentAPI } from '../types/component-api';
|
||||
import type {
|
||||
@@ -204,11 +209,10 @@ export class FragmentTracker implements ComponentAPI {
|
||||
*/
|
||||
public detectPartialFragments(data: FragBufferedData) {
|
||||
const timeRanges = this.timeRanges;
|
||||
if (!timeRanges || data.frag.sn === 'initSegment') {
|
||||
const { frag, part, id: playlistType } = data;
|
||||
if (!timeRanges || !isMediaFragment(frag)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const frag = data.frag as MediaFragment;
|
||||
const fragKey = getFragmentKey(frag);
|
||||
const fragmentEntity = this.fragments[fragKey];
|
||||
if (!fragmentEntity || (fragmentEntity.buffered && frag.gap)) {
|
||||
@@ -228,20 +232,49 @@ export class FragmentTracker implements ComponentAPI {
|
||||
partial,
|
||||
timeRange,
|
||||
);
|
||||
this.detectEvictedFragments(
|
||||
elementaryStream,
|
||||
timeRange,
|
||||
playlistType,
|
||||
part,
|
||||
);
|
||||
});
|
||||
fragmentEntity.loaded = null;
|
||||
if (Object.keys(fragmentEntity.range).length) {
|
||||
const trackNames = Object.keys(fragmentEntity.range) as SourceBufferName[];
|
||||
if (trackNames.length) {
|
||||
this.bufferedEnd(fragmentEntity, frag);
|
||||
if (!isPartial(fragmentEntity)) {
|
||||
// Remove older fragment parts from lookup after frag is tracked as buffered
|
||||
this.removeParts(frag.sn - 1, frag.type);
|
||||
}
|
||||
// Detect nothing buffered for segment append (open-GOP issue #7774)
|
||||
if (!part) {
|
||||
const minPartialAppend = Math.min(0.004, frag.duration);
|
||||
trackNames.some((elementaryStream) => {
|
||||
const times = fragmentEntity.range[elementaryStream]?.time;
|
||||
const bufferedGap =
|
||||
times.length === 0 ||
|
||||
(times.length === 1 &&
|
||||
times[0].endPTS - times[0].startPTS < minPartialAppend);
|
||||
if (bufferedGap) {
|
||||
// Segment was appended but it did not change buffer. Mark as gap to prevent loop loading.
|
||||
this.addAsGap(frag);
|
||||
}
|
||||
return bufferedGap;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// remove fragment if nothing was appended
|
||||
this.removeFragment(fragmentEntity.body);
|
||||
this.removeFragment(frag);
|
||||
}
|
||||
}
|
||||
|
||||
public addAsGap(frag: MediaFragment) {
|
||||
frag.gap = true;
|
||||
this.removeFragment(frag);
|
||||
this.fragBuffered(frag, true);
|
||||
}
|
||||
|
||||
private bufferedEnd(fragmentEntity: FragmentEntity, frag: MediaFragment) {
|
||||
fragmentEntity.buffered = true;
|
||||
const endList = (fragmentEntity.body.endList =
|
||||
@@ -414,7 +447,7 @@ export class FragmentTracker implements ComponentAPI {
|
||||
private onFragLoaded(event: Events.FRAG_LOADED, data: FragLoadedData) {
|
||||
// don't track initsegment (for which sn is not a number)
|
||||
// don't track frags used for bitrateTest, they're irrelevant.
|
||||
if (data.frag.sn === 'initSegment' || data.frag.bitrateTest) {
|
||||
if (!isMediaFragment(data.frag) || data.frag.bitrateTest) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -436,8 +469,8 @@ export class FragmentTracker implements ComponentAPI {
|
||||
event: Events.BUFFER_APPENDED,
|
||||
data: BufferAppendedData,
|
||||
) {
|
||||
const { frag, part, timeRanges, type } = data;
|
||||
if (frag.sn === 'initSegment') {
|
||||
const { frag, part, timeRanges } = data;
|
||||
if (!isMediaFragment(frag)) {
|
||||
return;
|
||||
}
|
||||
const playlistType = frag.type;
|
||||
@@ -450,8 +483,6 @@ export class FragmentTracker implements ComponentAPI {
|
||||
}
|
||||
// Store the latest timeRanges loaded in the buffer
|
||||
this.timeRanges = timeRanges;
|
||||
const timeRange = timeRanges[type] as TimeRanges;
|
||||
this.detectEvictedFragments(type, timeRange, playlistType, part);
|
||||
}
|
||||
|
||||
private onFragBuffered(event: Events.FRAG_BUFFERED, data: FragBufferedData) {
|
||||
@@ -505,6 +536,10 @@ export class FragmentTracker implements ComponentAPI {
|
||||
continue;
|
||||
}
|
||||
const frag = entity.body;
|
||||
if (frag.gap) {
|
||||
// do not evict media when gaps are detected
|
||||
return 0;
|
||||
}
|
||||
// Use stats.loaded (always set after load) with byteLength as fallback
|
||||
const bytes =
|
||||
(frag.hasStats && frag.stats.loaded) ||
|
||||
|
||||
@@ -960,10 +960,20 @@ export default class StreamController
|
||||
}
|
||||
return;
|
||||
}
|
||||
let fragError = false;
|
||||
if (isMediaFragment(frag)) {
|
||||
this.fragPrevious = frag;
|
||||
fragError =
|
||||
!!frag.gap && !frag.tagList.some((tags) => tags[0] === 'GAP');
|
||||
}
|
||||
this.fragBufferedComplete(frag, part);
|
||||
if (fragError) {
|
||||
frag.stats.retry++;
|
||||
const level = this.levels?.[frag.level];
|
||||
if (level) {
|
||||
level.fragmentError++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const media = this.media;
|
||||
|
||||
@@ -207,7 +207,7 @@ export class SubtitleStreamController
|
||||
|
||||
if (frag?.type === PlaylistLevelType.SUBTITLE) {
|
||||
if (data.details === ErrorDetails.FRAG_GAP) {
|
||||
this.fragmentTracker.fragBuffered(frag as MediaFragment, true);
|
||||
this.fragmentTracker.addAsGap(frag as MediaFragment);
|
||||
}
|
||||
if (this.fragCurrent) {
|
||||
this.fragCurrent.abortRequests();
|
||||
|
||||
@@ -97,6 +97,7 @@ export default class FragmentLoader {
|
||||
highWaterMark: frag.sn === 'initSegment' ? Infinity : MIN_CHUNK_SIZE,
|
||||
};
|
||||
// Assign frag stats to the loader's stats reference
|
||||
loader.stats.retry = frag.stats.retry;
|
||||
frag.stats = loader.stats;
|
||||
const callbacks: LoaderCallbacks<FragmentLoaderContext> = {
|
||||
onSuccess: (response, stats, context, networkDetails) => {
|
||||
|
||||
+1
-1
@@ -451,7 +451,7 @@ export interface FragBufferedData {
|
||||
stats: LoadStats;
|
||||
frag: Fragment;
|
||||
part: Part | null;
|
||||
id: string;
|
||||
id: PlaylistLevelType;
|
||||
}
|
||||
|
||||
export interface LevelsUpdatedData {
|
||||
|
||||
@@ -246,7 +246,7 @@ function loadAndBufferFragment(
|
||||
frag,
|
||||
part: null,
|
||||
stats: frag.stats,
|
||||
id: 'video',
|
||||
id: frag.type,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -149,12 +149,20 @@ describe('FragmentTracker', function () {
|
||||
Events.BUFFER_APPENDED,
|
||||
createBufferAppendedData([
|
||||
{
|
||||
startPTS: 0.75,
|
||||
startPTS: 1,
|
||||
endPTS: 2,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const fragment2 = addFragment();
|
||||
hls.trigger(
|
||||
Events.FRAG_BUFFERED,
|
||||
createFragBufferedData(fragment2, true),
|
||||
);
|
||||
|
||||
expect(fragment.gap).to.equal(undefined);
|
||||
|
||||
expect(fragmentTracker.getState(fragment)).to.equal(
|
||||
FragmentState.NOT_LOADED,
|
||||
);
|
||||
|
||||
@@ -495,7 +495,7 @@ describe('StreamController', function () {
|
||||
stats: new LoadStats(),
|
||||
frag: firstFrag,
|
||||
part: null,
|
||||
id: 'main',
|
||||
id: firstFrag.type,
|
||||
});
|
||||
expect(seekStub).to.have.been.calledOnce;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user