mirror of
https://github.com/chrisbenincasa/tunarr.git
synced 2026-04-18 09:03:35 -04:00
fix: prevent playlist from referencing segments deleted by high-water mark
The segmentsToKeepBefore buffer could extend the playlist window below #highestDeletedBelow, causing the playlist to reference segment files that had already been deleted from disk. Clients requesting those segments would get 404s. Add a segmentFloor option to FilterBeforeSegmentNumber that acts as a hard lower bound for minSeg, and pass #highestDeletedBelow as the floor from HlsSession.trimPlaylist().
This commit is contained in:
@@ -921,18 +921,17 @@ describe('HlsPlaylistMutator', () => {
|
||||
});
|
||||
|
||||
describe('high-water mark floor protection', () => {
|
||||
// These tests verify the invariant that HlsSession's #highestDeletedBelow
|
||||
// relies on: the before_segment_number filter acts as a hard floor, so the
|
||||
// playlist never references segments below (segmentNumber - segmentsToKeepBefore).
|
||||
// These tests verify that the segmentFloor option prevents the playlist
|
||||
// from referencing segments that have been deleted from disk. Without it,
|
||||
// segmentsToKeepBefore can extend the window below the deletion threshold.
|
||||
|
||||
const start = dayjs('2024-10-18T14:00:00.000-0400');
|
||||
const largeOpts = { ...defaultOpts, maxSegmentsToKeep: 20 };
|
||||
|
||||
it('Scenario B: segmentNumber=0 (empty _minByIp) selects segments from the start', () => {
|
||||
// Without the high-water mark fix, stale cleanup empties _minByIp and
|
||||
// minSegmentRequested returns 0. This causes trimPlaylist to serve segments
|
||||
// from the very beginning of the playlist — all of which have been deleted.
|
||||
// This test documents the behavior that #highestDeletedBelow prevents.
|
||||
it('without segmentFloor, segmentNumber=0 selects segments from the start', () => {
|
||||
// Without segmentFloor, stale cleanup empties _minByIp and
|
||||
// minSegmentRequested returns 0, causing the playlist to serve
|
||||
// segments from the very beginning — all of which may be deleted.
|
||||
const lines = createPlaylist(100);
|
||||
|
||||
const result = mutator.trimPlaylist(
|
||||
@@ -946,17 +945,17 @@ describe('HlsPlaylistMutator', () => {
|
||||
largeOpts,
|
||||
);
|
||||
|
||||
// minSeg = max(0-10, 0) = 0 → all 100 segments pass the filter (≥20) → take first 20 = segs 0..19
|
||||
// minSeg = max(0-10, 0) = 0 → all 100 pass filter → take first 20
|
||||
expect(result.playlist).toContain('data000000.ts');
|
||||
expect(result.playlist).toContain('data000019.ts');
|
||||
expect(result.playlist).not.toContain('data000020.ts');
|
||||
});
|
||||
|
||||
it('Scenario B fix: high-water mark as segmentNumber floors the playlist above deleted range', () => {
|
||||
it('segmentFloor prevents playlist from referencing deleted segments', () => {
|
||||
// After deleteOldSegments(190), #highestDeletedBelow = 190.
|
||||
// Math.max(minSegmentRequested=0, highestDeletedBelow=190) = 190 is used
|
||||
// as segmentNumber, so the playlist starts at 180 (190 - keepBefore:10)
|
||||
// rather than 0, avoiding any reference to deleted segments.
|
||||
// segmentNumber = max(minSegmentRequested=0, 190) = 190
|
||||
// Without segmentFloor: minSeg = max(190-10, 0) = 180 → refs 180-189 which are deleted!
|
||||
// With segmentFloor=190: minSeg = max(180, 190) = 190 → starts at 190, all exist.
|
||||
const lines = createPlaylist(300);
|
||||
|
||||
const result = mutator.trimPlaylist(
|
||||
@@ -965,23 +964,23 @@ describe('HlsPlaylistMutator', () => {
|
||||
type: 'before_segment_number',
|
||||
segmentNumber: 190,
|
||||
segmentsToKeepBefore: 10,
|
||||
segmentFloor: 190,
|
||||
},
|
||||
lines,
|
||||
largeOpts,
|
||||
);
|
||||
|
||||
// minSeg = max(190-10, 0) = 180; segs 180..299 (120 ≥ 20) → take first 20 = segs 180..199
|
||||
expect(result.playlist).toContain('data000180.ts');
|
||||
expect(result.playlist).toContain('data000199.ts');
|
||||
expect(result.playlist).not.toContain('data000179.ts');
|
||||
expect(result.playlist).not.toContain('data000000.ts');
|
||||
// segs 190..299 (110 ≥ 20) → take first 20 = segs 190..209
|
||||
expect(result.playlist).toContain('data000190.ts');
|
||||
expect(result.playlist).toContain('data000209.ts');
|
||||
expect(result.playlist).not.toContain('data000189.ts');
|
||||
expect(result.playlist).not.toContain('data000180.ts');
|
||||
});
|
||||
|
||||
it('Scenario A: stale client removal jump still floors above deleted range', () => {
|
||||
// Client A was at seg 100, stale cleanup removes it, leaving Client B at seg 200.
|
||||
// deleteOldSegments(190) ran, so #highestDeletedBelow = 190.
|
||||
// Math.max(minSegmentRequested=200, highestDeletedBelow=190) = 200.
|
||||
// Playlist must not include segments below 190 (200 - keepBefore:10).
|
||||
it('segmentFloor does not affect segments above the floor', () => {
|
||||
// Client B at seg 200, #highestDeletedBelow = 190.
|
||||
// segmentNumber = max(200, 190) = 200, segmentFloor = 190
|
||||
// minSeg = max(200-10, 190) = 190 → keeps 190..209
|
||||
const lines = createPlaylist(300);
|
||||
|
||||
const result = mutator.trimPlaylist(
|
||||
@@ -990,21 +989,18 @@ describe('HlsPlaylistMutator', () => {
|
||||
type: 'before_segment_number',
|
||||
segmentNumber: 200,
|
||||
segmentsToKeepBefore: 10,
|
||||
segmentFloor: 190,
|
||||
},
|
||||
lines,
|
||||
largeOpts,
|
||||
);
|
||||
|
||||
// minSeg = max(200-10, 0) = 190; segs 190..299 (110 ≥ 20) → take first 20 = segs 190..209
|
||||
expect(result.playlist).toContain('data000190.ts');
|
||||
expect(result.playlist).toContain('data000209.ts');
|
||||
expect(result.playlist).not.toContain('data000189.ts');
|
||||
expect(result.playlist).not.toContain('data000100.ts');
|
||||
});
|
||||
|
||||
it('floor does not over-trim when segmentNumber equals the live edge', () => {
|
||||
// When #highestDeletedBelow and minSegmentRequested agree (normal single-client case),
|
||||
// the playlist should include the last keepBefore segments before the current position.
|
||||
it('segmentFloor=0 behaves the same as no floor', () => {
|
||||
const lines = createPlaylist(50);
|
||||
|
||||
const result = mutator.trimPlaylist(
|
||||
@@ -1013,12 +1009,13 @@ describe('HlsPlaylistMutator', () => {
|
||||
type: 'before_segment_number',
|
||||
segmentNumber: 30,
|
||||
segmentsToKeepBefore: 10,
|
||||
segmentFloor: 0,
|
||||
},
|
||||
lines,
|
||||
largeOpts,
|
||||
);
|
||||
|
||||
// minSeg = max(30-10, 0) = 20; segs 20..49 (30 ≥ 20) → take first 20 = segs 20..39
|
||||
// minSeg = max(30-10, 0) = 20; segs 20..49 (30 ≥ 20) → take first 20
|
||||
expect(result.playlist).toContain('data000020.ts');
|
||||
expect(result.playlist).toContain('data000039.ts');
|
||||
expect(result.playlist).not.toContain('data000019.ts');
|
||||
|
||||
@@ -33,6 +33,9 @@ type FilterBeforeSegmentNumber = {
|
||||
type: 'before_segment_number';
|
||||
segmentNumber: number;
|
||||
segmentsToKeepBefore: number;
|
||||
// Hard floor: never include segments below this number. Prevents the
|
||||
// playlist from referencing segments that have been deleted from disk.
|
||||
segmentFloor?: number;
|
||||
};
|
||||
|
||||
export type HlsPlaylistFilterOptions =
|
||||
@@ -155,7 +158,7 @@ export class HlsPlaylistMutator {
|
||||
(beforeSeg) => {
|
||||
const minSeg = Math.max(
|
||||
beforeSeg.segmentNumber - beforeSeg.segmentsToKeepBefore,
|
||||
0,
|
||||
beforeSeg.segmentFloor ?? 0,
|
||||
);
|
||||
return seq.collect(allSegments, (segment) => {
|
||||
const fileName = basename(segment.line);
|
||||
|
||||
@@ -113,6 +113,7 @@ export class HlsSession extends BaseHlsSession<HlsSessionOptions> {
|
||||
this.#highestDeletedBelow,
|
||||
),
|
||||
segmentsToKeepBefore: 10,
|
||||
segmentFloor: this.#highestDeletedBelow,
|
||||
};
|
||||
return Result.attemptAsync(async () => {
|
||||
return await this.lock.runExclusive(async () => {
|
||||
|
||||
Reference in New Issue
Block a user