Support EXT-X-MEDIA-SEQUENCE declaration after EXT-X-SKIP (#7905)

* Support EXT-X-MEDIA-SEQUENCE declaration after EXT-X-SKIP

* Use prevFrag to detect a Media Segment preceding EXT-X-MEDIA-SEQUENCE

---------

Authored-by: 배홍준[Media Player Tech] <hongjun.bae@navercorp.com>
This commit is contained in:
brodi
2026-06-12 09:39:04 -07:00
committed by GitHub
parent df4158658e
commit d8b1b06254
3 changed files with 253 additions and 2 deletions
+6 -2
View File
@@ -487,10 +487,14 @@ export default class M3U8Parser {
case 'MEDIA-SEQUENCE':
if (level.startSN !== 0) {
assignMultipleMediaPlaylistTagOccuranceError(level, tag, result);
} else if (fragments.length > 0) {
} else if (prevFrag !== null) {
// a real Media Segment (not an EXT-X-SKIP placeholder) precedes this tag
assignMustAppearBeforeSegmentsError(level, tag, result);
}
currentSN = level.startSN = parseInt(value1);
level.startSN = parseInt(value1);
// include skipped segments so the first real fragment keeps its sequence
// number when EXT-X-SKIP is declared before EXT-X-MEDIA-SEQUENCE
currentSN = level.startSN + level.skippedSegments;
break;
case 'SKIP': {
if (level.skippedSegments) {
+163
View File
@@ -1149,6 +1149,169 @@ fileSequence18.ts`;
});
});
it('handles delta Playlist updates when EXT-X-SKIP is declared before EXT-X-MEDIA-SEQUENCE', function () {
const playlist = `#EXTM3U
#EXT-X-TARGETDURATION:6
#EXT-X-VERSION:9
#EXT-X-SERVER-CONTROL:CAN-SKIP-UNTIL=36
#EXT-X-MEDIA-SEQUENCE:100
#EXTINF:6,
fileSequence100.ts
#EXTINF:6,
fileSequence101.ts
#EXTINF:6,
fileSequence102.ts
#EXTINF:6,
fileSequence103.ts
#EXTINF:6,
fileSequence104.ts
#EXTINF:6,
fileSequence105.ts
#EXTINF:6,
fileSequence106.ts
#EXTINF:6,
fileSequence107.ts
#EXTINF:6,
fileSequence108.ts
#EXTINF:6,
fileSequence109.ts`;
const deltaUpdate1 = `#EXTM3U
#EXT-X-TARGETDURATION:6
#EXT-X-VERSION:9
#EXT-X-SERVER-CONTROL:CAN-SKIP-UNTIL=36
#EXT-X-SKIP:SKIPPED-SEGMENTS=3
#EXT-X-MEDIA-SEQUENCE:100
#EXTINF:6,
fileSequence103.ts
#EXTINF:6,
fileSequence104.ts
#EXTINF:6,
fileSequence105.ts
#EXTINF:6,
fileSequence106.ts
#EXTINF:6,
fileSequence107.ts
#EXTINF:6,
fileSequence108.ts
#EXTINF:6,
fileSequence109.ts
#EXTINF:6,
fileSequence110.ts`;
const deltaUpdate2 = `#EXTM3U
#EXT-X-TARGETDURATION:6
#EXT-X-VERSION:9
#EXT-X-SERVER-CONTROL:CAN-SKIP-UNTIL=36
#EXT-X-SKIP:SKIPPED-SEGMENTS=3
#EXT-X-MEDIA-SEQUENCE:102
#EXTINF:6,
fileSequence105.ts
#EXTINF:6,
fileSequence106.ts
#EXTINF:6,
fileSequence107.ts
#EXTINF:6,
fileSequence108.ts
#EXTINF:6,
fileSequence109.ts
#EXTINF:6,
fileSequence110.ts
#EXTINF:6,
fileSequence111.ts
#EXTINF:6,
fileSequence112.ts`;
const details1 = parseLevelPlaylist(playlist);
const details2 = parseLevelPlaylist(deltaUpdate1);
const details3 = parseLevelPlaylist(deltaUpdate2);
expect(details2.playlistParsingError, 'details2 parsing error').to.be
.null;
expect(details2, 'details2 before merging').to.include({
live: true,
skippedSegments: 3,
startSN: 100,
endSN: 110,
});
expect(details2.fragments, 'details2 parsed fragments').to.have.lengthOf(
11,
);
expect(details2.fragments[0]).to.equal(null);
expect(details2.fragments[2]).to.equal(null);
expect(details2.fragments[3]?.sn).to.equal(103);
mergeDetails(details1, details2, logger);
expect(details2, 'details2 merged with details1').to.include({
deltaUpdateFailed: false,
startSN: 100,
endSN: 110,
});
expect(details2.playlistParsingError).to.be.null;
details2.fragments.forEach((frag, i) => {
expect(frag, `details2.fragments[${i}]`).to.not.equal(null);
expect(frag.sn, `details2.fragments[${i}].sn`).to.equal(100 + i);
expect(frag.relurl, `details2.fragments[${i}].relurl`).to.equal(
`fileSequence${100 + i}.ts`,
);
});
mergeDetails(details2, details3, logger);
expect(details3, 'details3 merged with details2').to.include({
deltaUpdateFailed: false,
startSN: 102,
endSN: 112,
});
expect(details3.playlistParsingError).to.be.null;
details3.fragments.forEach((frag, i) => {
expect(frag, `details3.fragments[${i}]`).to.not.equal(null);
expect(frag.sn, `details3.fragments[${i}].sn`).to.equal(102 + i);
expect(frag.relurl, `details3.fragments[${i}].relurl`).to.equal(
`fileSequence${102 + i}.ts`,
);
});
});
it('detects media sequence mismatch on delta updates when EXT-X-SKIP is declared before EXT-X-MEDIA-SEQUENCE', function () {
const playlist = `#EXTM3U
#EXT-X-TARGETDURATION:6
#EXT-X-VERSION:9
#EXT-X-SERVER-CONTROL:CAN-SKIP-UNTIL=36
#EXT-X-MEDIA-SEQUENCE:100
#EXTINF:6,
fileSequence100.ts
#EXTINF:6,
fileSequence101.ts
#EXTINF:6,
fileSequence102.ts
#EXTINF:6,
fileSequence103.ts
#EXTINF:6,
fileSequence104.ts
#EXTINF:6,
fileSequence105.ts`;
// The sequence numbering of this delta update does not match the
// previous playlist (fileSequence104 was at 104, now at 105)
const deltaUpdate = `#EXTM3U
#EXT-X-TARGETDURATION:6
#EXT-X-VERSION:9
#EXT-X-SERVER-CONTROL:CAN-SKIP-UNTIL=36
#EXT-X-SKIP:SKIPPED-SEGMENTS=3
#EXT-X-MEDIA-SEQUENCE:102
#EXTINF:6,
fileSequence104.ts
#EXTINF:6,
fileSequence105.ts
#EXTINF:6,
fileSequence106.ts`;
const details1 = parseLevelPlaylist(playlist);
const details2 = parseLevelPlaylist(deltaUpdate);
expect(details2.playlistParsingError, 'details2 parsing error').to.be
.null;
mergeDetails(details1, details2, logger);
expect(details2.playlistParsingError?.message).to.match(
/^media sequence mismatch 105:/,
);
});
it('does not add more sliding when LevelDetails arguments are the same object', function () {
const playlist = `#EXTM3U
#EXT-X-TARGETDURATION:6
+84
View File
@@ -3367,6 +3367,90 @@ a{$bar}.mp4
'#EXT-X-MEDIA-SEQUENCE must appear before the first Media Segment (#EXT-X-MEDIA-SEQUENCE:2)',
);
});
it('does not error when EXT-X-MEDIA-SEQUENCE is declared after EXT-X-SKIP', function () {
const level = `#EXTM3U
#EXT-X-VERSION:10
#EXT-X-TARGETDURATION:3
#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK=3.008,CAN-SKIP-UNTIL=35.4
#EXT-X-PART-INF:PART-TARGET=1.001
#EXT-X-SKIP:SKIPPED-SEGMENTS=191
#EXT-X-MEDIA-SEQUENCE:7126
#EXT-X-MAP:URI="https://sample-host/init.m4s"
#EXTINF:2.953,
https://sample-host/1080p_7317.m4v`;
const details = M3U8Parser.parseLevelPlaylist(
level,
'http://example.com/hls/index.m3u8',
0,
PlaylistLevelType.MAIN,
0,
null,
);
expect(details.playlistParsingError).to.be.null;
expect(details.startSN).to.equal(7126);
expect(details.skippedSegments).to.equal(191);
// 191 skipped placeholders (sn 7126..7316), then the first real segment at sn 7317
expect(details.fragments.length).to.equal(192);
expect(details.fragments[0]).to.equal(null);
expect(details.fragments[190]).to.equal(null);
expect(details.fragments[191]?.sn).to.equal(7317);
});
it('errors when a Media Segment precedes EXT-X-SKIP and EXT-X-MEDIA-SEQUENCE', function () {
const level = `#EXTM3U
#EXT-X-TARGETDURATION:6
#EXT-X-VERSION:9
#EXT-X-SERVER-CONTROL:CAN-SKIP-UNTIL=36
#EXTINF:6,
fileSequence102.ts
#EXT-X-SKIP:SKIPPED-SEGMENTS=3
#EXT-X-MEDIA-SEQUENCE:102
#EXTINF:6,
fileSequence106.ts`;
const details = M3U8Parser.parseLevelPlaylist(
level,
'http://example.com/hls/index.m3u8',
0,
PlaylistLevelType.MAIN,
0,
null,
);
expectPlaylistParsingError(
details,
'#EXT-X-MEDIA-SEQUENCE must appear before the first Media Segment (#EXT-X-MEDIA-SEQUENCE:102)',
);
});
it('does not error when EXT-X-SKIP replaces segments after the first Media Segment', function () {
const level = `#EXTM3U
#EXT-X-TARGETDURATION:6
#EXT-X-VERSION:9
#EXT-X-SERVER-CONTROL:CAN-SKIP-UNTIL=36
#EXT-X-MEDIA-SEQUENCE:102
#EXTINF:6,
fileSequence102.ts
#EXT-X-SKIP:SKIPPED-SEGMENTS=3
#EXTINF:6,
fileSequence106.ts`;
const details = M3U8Parser.parseLevelPlaylist(
level,
'http://example.com/hls/index.m3u8',
0,
PlaylistLevelType.MAIN,
0,
null,
);
expect(details.playlistParsingError).to.be.null;
expect(details.startSN).to.equal(102);
expect(details.skippedSegments).to.equal(3);
// sn 102, then 3 skipped placeholders (sn 103..105), then sn 106
expect(details.fragments.length).to.equal(5);
expect(details.fragments[0]?.sn).to.equal(102);
expect(details.fragments[1]).to.equal(null);
expect(details.fragments[3]).to.equal(null);
expect(details.fragments[4]?.sn).to.equal(106);
});
});
});