PLAYLIST_UNCHANGED_ERROR should not be fatal so that playback is allowed to reach the end of the playlist

This commit is contained in:
Rob Walch
2026-01-28 18:11:54 -08:00
parent c7ccc9a3c2
commit 0d2b39677d
7 changed files with 25 additions and 23 deletions
+10 -8
View File
@@ -150,6 +150,7 @@ export default class BasePlaylistController
previousDetails?: LevelDetails,
) {
const { details, stats } = data;
const fragments = details.fragments;
// Set last updated date-time
const now = self.performance.now();
@@ -163,8 +164,8 @@ export default class BasePlaylistController
if (timelineOffset !== details.appliedTimelineOffset) {
const offset = Math.max(timelineOffset || 0, 0);
details.appliedTimelineOffset = offset;
details.fragments.forEach((frag) => {
frag.setStart(frag.playlistOffset + offset);
fragments.forEach((frag) => {
frag?.setStart(frag.playlistOffset + offset);
});
}
@@ -172,11 +173,12 @@ export default class BasePlaylistController
if (details.live || previousDetails?.live) {
const levelOrTrack = 'levelInfo' in data ? data.levelInfo : data.track;
details.reloaded(previousDetails);
const parent = fragments[fragments.length - 1]?.type;
// TODO: consider a separate flow for low-latency blocking reload requests with delivery directives
if (details.misses >= this.hls.config.liveMaxUnchangedPlaylistRefresh) {
const error = new Error(
`levelOrTrack (${levelOrTrack.id}) hits max allowed unchanged reloads.`,
`${parent} playlist ${levelOrTrack.id} hit max allowed unchanged reloads.`,
);
this.warn(error);
const { networkDetails, context } = data;
@@ -187,8 +189,8 @@ export default class BasePlaylistController
url: details.url,
error,
reason: error.message,
level: (data as LevelLoadedData).level ?? undefined,
parent: details.fragments[0]?.type,
level: (data as LevelLoadedData).level,
parent,
context,
networkDetails,
stats,
@@ -197,7 +199,7 @@ export default class BasePlaylistController
}
// Merge live playlists to adjust fragment starts and fill in delta playlist skipped segments
if (previousDetails && details.fragments.length > 0) {
if (previousDetails) {
mergeDetails(previousDetails, details, this);
const error = details.playlistParsingError;
if (error) {
@@ -212,8 +214,8 @@ export default class BasePlaylistController
url: details.url,
error,
reason: error.message,
level: (data as any).level || undefined,
parent: details.fragments[0]?.type,
level: (data as LevelLoadedData).level,
parent,
networkDetails,
stats,
});
+1 -1
View File
@@ -2341,7 +2341,7 @@ export default class BaseStreamController
}
private playlistLabel() {
return this.playlistType === PlaylistLevelType.MAIN ? 'level' : 'track';
return `${this.playlistType} playlist`;
}
private fragInfo(
+2 -2
View File
@@ -1013,8 +1013,8 @@ transfer tracks: ${stringify(transferredTracks, (key, value) => (key === 'initSe
};
this.log(
`queuing "${type}" append sn: ${sn}${part ? ' p: ' + part.index : ''} of ${
parent === PlaylistLevelType.MAIN ? 'level' : 'track'
} ${frag.level} cc: ${cc} offset: ${offset} bytes: ${data.byteLength}`,
parent
} playlist ${frag.level} cc: ${cc} offset: ${offset} bytes: ${data.byteLength}`,
);
this.append(operation, type, this.isPending(this.tracks[type]));
}
+2 -1
View File
@@ -481,7 +481,8 @@ export default class ErrorController
this.sendAlternateToPenaltyBox(data);
if (
!data.errorAction.resolved &&
data.details !== ErrorDetails.FRAG_GAP
data.details !== ErrorDetails.FRAG_GAP &&
data.details !== ErrorDetails.PLAYLIST_UNCHANGED_ERROR
) {
data.fatal = true;
}
+2 -2
View File
@@ -12,7 +12,6 @@ import Transmuxer, {
} from '../demux/transmuxer';
import { ErrorDetails, ErrorTypes } from '../errors';
import { Events } from '../events';
import { PlaylistLevelType } from '../types/loader';
import { getM2TSSupportedAudioTypes } from '../utils/codecs';
import { stringify } from '../utils/safe-json-stringify';
import type { WorkerContext } from './inject-worker';
@@ -20,6 +19,7 @@ import type { HlsEventEmitter, HlsListeners } from '../events';
import type Hls from '../hls';
import type { MediaFragment, Part } from '../loader/fragment';
import type { ErrorData, FragDecryptedData } from '../types/events';
import type { PlaylistLevelType } from '../types/loader';
import type { ChunkMetadata, TransmuxerResult } from '../types/transmuxer';
import type { TimestampOffset } from '../utils/timescale-conversion';
@@ -237,7 +237,7 @@ export default class TransmuxerInterface {
this.hls.logger
.log(`[transmuxer-interface]: Starting new transmux session for ${frag.type} sn: ${chunkMeta.sn}${
chunkMeta.part > -1 ? ' part: ' + chunkMeta.part : ''
} ${this.id === PlaylistLevelType.MAIN ? 'level' : 'track'}: ${chunkMeta.level} id: ${chunkMeta.id}
} ${this.id} playlist: ${chunkMeta.level} id: ${chunkMeta.id}
discontinuity: ${discontinuity}
trackSwitch: ${trackSwitch}
contiguous: ${contiguous}
+2 -2
View File
@@ -8,7 +8,6 @@ import { ErrorDetails, ErrorTypes } from '../errors';
import { Events } from '../events';
import MP4Remuxer from '../remux/mp4-remuxer';
import PassThroughRemuxer from '../remux/passthrough-remuxer';
import { PlaylistLevelType } from '../types/loader';
import {
getAesModeFromFullSegmentMethod,
isFullSegmentEncryption,
@@ -17,6 +16,7 @@ import type { HlsConfig } from '../config';
import type { HlsEventEmitter } from '../events';
import type { DecryptData } from '../loader/level-key';
import type { Demuxer, DemuxerResult, KeyData } from '../types/demuxer';
import type { PlaylistLevelType } from '../types/loader';
import type { Remuxer } from '../types/remuxer';
import type { ChunkMetadata, TransmuxerResult } from '../types/transmuxer';
import type { TypeSupported } from '../utils/codecs';
@@ -287,7 +287,7 @@ export default class Transmuxer {
this.logger.log(
`[transmuxer.ts]: Flushed ${this.id} sn: ${chunkMeta.sn}${
chunkMeta.part > -1 ? ' part: ' + chunkMeta.part : ''
} of ${this.id === PlaylistLevelType.MAIN ? 'level' : 'track'} ${chunkMeta.level}`,
} of ${this.id} playlist ${chunkMeta.level}`,
);
const remuxResult = this.remuxer!.remux(
audioTrack,
+6 -7
View File
@@ -457,12 +457,12 @@ segment101.ts`;
expect(data.details).to.equal(ErrorDetails.PLAYLIST_UNCHANGED_ERROR);
expect(data.fatal).to.equal(false, 'Error should not be fatal');
expect(data.error.message).to.include(
'hits max allowed unchanged reloads',
'hit max allowed unchanged reloads',
);
});
});
it('PLAYLIST_UNCHANGED_ERROR becomes fatal when no alternate levels are available', function () {
it('PLAYLIST_UNCHANGED_ERROR is not fatal when no alternate levels are available', function () {
(hls.config as any).liveMaxUnchangedPlaylistRefresh = 2;
server.respondWith('singleLevelMultivariant.m3u8', [
@@ -502,13 +502,12 @@ segment101.ts`;
}).then((data: ErrorData) => {
expect(data.details).to.equal(ErrorDetails.PLAYLIST_UNCHANGED_ERROR);
expect(data.fatal).to.equal(
true,
'Error should be fatal with no alternates',
false,
'Error should not be fatal with no alternates',
);
expect(data.error.message).to.include(
'hits max allowed unchanged reloads',
'hit max allowed unchanged reloads',
);
expect(hls.stopLoad).to.have.been.called;
});
});
@@ -572,7 +571,7 @@ audio101.ts`;
expect(data.details).to.equal(ErrorDetails.PLAYLIST_UNCHANGED_ERROR);
expect(data.fatal).to.equal(false, 'Error should not be fatal');
expect(data.error.message).to.include(
'hits max allowed unchanged reloads',
'hit max allowed unchanged reloads',
);
});
});