From 47c764ee417f64166a4ae264b0e3a7d16113f208 Mon Sep 17 00:00:00 2001 From: Christopher Pruijsen Date: Sun, 13 Sep 2026 09:36:11 +0000 Subject: [PATCH 1/2] fix: prevent exception when playlist has no segments in updateSequenceNumbers Playlists whose segments come from a SegmentBase with an indexRange have an empty segments list until the referenced SIDX is resolved. When such a playlist is merged with a previous manifest (e.g. subtitle playlists, for which the SIDX is never resolved), updateSequenceNumbers would attempt to mark a discontinuity on a nonexistent first segment, throwing "Cannot set properties of undefined (setting 'discontinuity')". Skip sequence merging for playlists without segments, and fall back to an empty list when reading segments from the previous manifest's playlists. Fixes #180 --- src/playlist-merge.js | 22 ++++--- test/playlist-merge.test.js | 112 ++++++++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+), 8 deletions(-) diff --git a/src/playlist-merge.js b/src/playlist-merge.js index ab87ba0f..1754a929 100644 --- a/src/playlist-merge.js +++ b/src/playlist-merge.js @@ -115,9 +115,15 @@ export const updateSequenceNumbers = ({ oldPlaylists, newPlaylists, timelineStar } // Since we don't yet support early available timelines, we don't need to support - // playlists with no segments. + // playlists with no segments. Playlists with an unresolved SIDX will have an empty + // segments list until the SIDX is parsed and segment references are generated. + if (!playlist.segments || !playlist.segments.length) { + return; + } + const firstNewSegment = playlist.segments[0]; - const oldMatchingSegmentIndex = oldPlaylist.segments.findIndex(function(oldSegment) { + const oldPlaylistSegments = oldPlaylist.segments || []; + const oldMatchingSegmentIndex = oldPlaylistSegments.findIndex(function(oldSegment) { return ( Math.abs(oldSegment.presentationTime - firstNewSegment.presentationTime) < TIME_FUDGE ); @@ -130,7 +136,7 @@ export const updateSequenceNumbers = ({ oldPlaylists, newPlaylists, timelineStar if (oldMatchingSegmentIndex === -1) { updateMediaSequenceForPlaylist({ playlist, - mediaSequence: oldPlaylist.mediaSequence + oldPlaylist.segments.length + mediaSequence: oldPlaylist.mediaSequence + oldPlaylistSegments.length }); playlist.segments[0].discontinuity = true; playlist.discontinuityStarts.unshift(0); @@ -154,9 +160,9 @@ export const updateSequenceNumbers = ({ oldPlaylists, newPlaylists, timelineStar // // A period may also have a duration of zero, so the case of no segments is handled // here even though we don't yet support early available periods. - if ((!oldPlaylist.segments.length && playlist.timeline > oldPlaylist.timeline) || - (oldPlaylist.segments.length && playlist.timeline > - oldPlaylist.segments[oldPlaylist.segments.length - 1].timeline)) { + if ((!oldPlaylistSegments.length && playlist.timeline > oldPlaylist.timeline) || + (oldPlaylistSegments.length && playlist.timeline > + oldPlaylistSegments[oldPlaylistSegments.length - 1].timeline)) { playlist.discontinuitySequence--; } return; @@ -173,7 +179,7 @@ export const updateSequenceNumbers = ({ oldPlaylists, newPlaylists, timelineStar // Period while the "timeline" exists, HLS keeps track of it via the discontinuity // sequence, and that first segment is an indicator, but can be removed before that // timeline is gone. - const oldMatchingSegment = oldPlaylist.segments[oldMatchingSegmentIndex]; + const oldMatchingSegment = oldPlaylistSegments[oldMatchingSegmentIndex]; if (oldMatchingSegment.discontinuity && !firstNewSegment.discontinuity) { firstNewSegment.discontinuity = true; @@ -183,7 +189,7 @@ export const updateSequenceNumbers = ({ oldPlaylists, newPlaylists, timelineStar updateMediaSequenceForPlaylist({ playlist, - mediaSequence: oldPlaylist.segments[oldMatchingSegmentIndex].number + mediaSequence: oldPlaylistSegments[oldMatchingSegmentIndex].number }); }); }; diff --git a/test/playlist-merge.test.js b/test/playlist-merge.test.js index fc98e2d0..63a48a1a 100644 --- a/test/playlist-merge.test.js +++ b/test/playlist-merge.test.js @@ -333,6 +333,118 @@ QUnit.test('complete refresh of matching playlist', function(assert) { ); }); +QUnit.test('new playlists without segments only update discontinuity sequence', function(assert) { + const oldPlaylists = [{ + discontinuitySequence: 1, + discontinuityStarts: [], + mediaSequence: 5, + timeline: 5, + attributes: { NAME: 'A' }, + segments: [] + }, { + discontinuitySequence: 2, + discontinuityStarts: [], + mediaSequence: 3, + timeline: 10, + attributes: { NAME: 'B' } + }]; + const newPlaylists = [{ + discontinuitySequence: 0, + discontinuityStarts: [], + mediaSequence: 0, + timeline: 5, + attributes: { NAME: 'A' }, + segments: [] + }, { + discontinuitySequence: 0, + discontinuityStarts: [], + mediaSequence: 0, + timeline: 10, + attributes: { NAME: 'B' } + }]; + const timelineStarts = [ + { start: 0, timeline: 0 }, + { start: 5, timeline: 5 }, + { start: 10, timeline: 10 } + ]; + + updateSequenceNumbers({ oldPlaylists, newPlaylists, timelineStarts }); + + assert.deepEqual( + newPlaylists, + [{ + discontinuitySequence: 1, + discontinuityStarts: [], + mediaSequence: 0, + timeline: 5, + attributes: { NAME: 'A' }, + segments: [] + }, { + discontinuitySequence: 2, + discontinuityStarts: [], + mediaSequence: 0, + timeline: 10, + attributes: { NAME: 'B' } + }], + 'new playlists unchanged apart from discontinuity sequence' + ); +}); + +QUnit.test('matching old playlist without segments is treated as a refresh', function(assert) { + const oldPlaylists = [{ + discontinuitySequence: 1, + discontinuityStarts: [], + mediaSequence: 3, + timeline: 5, + attributes: { NAME: 'A' } + }]; + const newPlaylists = [{ + discontinuitySequence: 0, + discontinuityStarts: [], + mediaSequence: 0, + timeline: 10, + attributes: { NAME: 'A' }, + segments: [{ + presentationTime: 10, + timeline: 10, + number: 0 + }, { + presentationTime: 12, + timeline: 10, + number: 1 + }] + }]; + const timelineStarts = [ + { start: 0, timeline: 0 }, + { start: 5, timeline: 5 }, + { start: 10, timeline: 10 } + ]; + + updateSequenceNumbers({ oldPlaylists, newPlaylists, timelineStarts }); + + assert.deepEqual( + newPlaylists, + [{ + discontinuitySequence: 1, + discontinuityStarts: [0], + mediaSequence: 3, + timeline: 10, + attributes: { NAME: 'A' }, + segments: [{ + discontinuity: true, + presentationTime: 10, + timeline: 10, + number: 3 + }, { + presentationTime: 12, + timeline: 10, + number: 4 + }] + }], + 'new playlist updated as a complete refresh' + ); +}); + QUnit.module('positionManifestOnTimeline'); QUnit.test('handles multiple playlists, including added and removed', function(assert) { From b9370d2d0a335349a7f6612d6aeccde4cf54b2a2 Mon Sep 17 00:00:00 2001 From: Christopher Pruijsen Date: Sun, 13 Sep 2026 09:41:21 +0000 Subject: [PATCH 2/2] fix: retain sidx on vtt playlists so their segments can be resolved formatVttPlaylist dropped the sidx property carried by SegmentBase playlists, so text tracks could never resolve segment references from the sidxMapping (addSidxSegmentsToPlaylist was a no-op for them) and they bypassed the sidx early-return in updateSequenceNumbers, which assumed every remaining playlist has at least one segment. Keep sidx on the formatted vtt playlist like the audio and video formatters do. Refs #180 --- src/toM3u8.js | 5 +++ test/toM3u8.test.js | 91 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+) diff --git a/src/toM3u8.js b/src/toM3u8.js index 6d058a79..b4f3b72e 100644 --- a/src/toM3u8.js +++ b/src/toM3u8.js @@ -147,6 +147,7 @@ export const formatAudioPlaylist = ({ export const formatVttPlaylist = ({ attributes, segments, + sidx, mediaSequence, discontinuityStarts, discontinuitySequence @@ -192,6 +193,10 @@ export const formatVttPlaylist = ({ vttPlaylist.attributes.serviceLocation = attributes.serviceLocation; } + if (sidx) { + vttPlaylist.sidx = sidx; + } + return vttPlaylist; }; diff --git a/test/toM3u8.test.js b/test/toM3u8.test.js index 9d765a81..994e4039 100644 --- a/test/toM3u8.test.js +++ b/test/toM3u8.test.js @@ -1342,6 +1342,97 @@ QUnit.test('playlists with sidx and sidxMapping', function(assert) { assert.deepEqual(toM3u8({ dashPlaylists, sidxMapping }).playlists, expected); }); +QUnit.test('vtt playlists with sidx and sidxMapping', function(assert) { + const dashPlaylists = [{ + attributes: { + sourceDuration: 100, + id: 'en', + lang: 'en', + codecs: 'wvtt', + duration: 0, + bandwidth: 10000, + periodStart: 0, + mimeType: 'text/vtt', + type: 'static' + }, + segments: [], + sidx: { + byterange: { + offset: 10, + length: 10 + }, + uri: 'sidx.mp4', + resolvedUri: 'http://example.com/sidx.mp4', + timeline: 0, + duration: 10 + }, + uri: 'http://example.com/fmp4.mp4' + }]; + + const sidxMapping = { + 'sidx.mp4-10-19': { + sidx: { + timescale: 1, + firstOffset: 0, + references: [{ + referenceType: 0, + referencedSize: 5, + subsegmentDuration: 2 + }] + } + } + }; + + const expected = [{ + attributes: { + BANDWIDTH: 10000, + CODECS: 'wvtt', + NAME: 'en', + ['PROGRAM-ID']: 1 + }, + sidx: { + byterange: { + offset: 10, + length: 10 + }, + uri: 'sidx.mp4', + resolvedUri: 'http://example.com/sidx.mp4', + timeline: 0, + duration: 10 + }, + targetDuration: 0, + timeline: 0, + timelineStarts: [{ start: 0, timeline: 0 }], + uri: '', + segments: [{ + map: { + resolvedUri: 'http://example.com/sidx.mp4', + uri: '' + }, + byterange: { + offset: 20, + length: 5 + }, + uri: 'http://example.com/sidx.mp4', + resolvedUri: 'http://example.com/sidx.mp4', + duration: 2, + number: 0, + presentationTime: 0, + timeline: 0 + }], + endList: true, + mediaSequence: 0, + discontinuitySequence: 0, + discontinuityStarts: [], + resolvedUri: '' + }]; + + assert.deepEqual( + toM3u8({ dashPlaylists, sidxMapping }).mediaGroups.SUBTITLES.subs.en.playlists, + expected + ); +}); + QUnit.test('playlists without minimumUpdatePeriod dont assign default value', function(assert) { const dashPlaylists = [{ attributes: {