diff --git a/src/loader/m3u8-parser.ts b/src/loader/m3u8-parser.ts index b0c0e2f8405..0a7615d7073 100644 --- a/src/loader/m3u8-parser.ts +++ b/src/loader/m3u8-parser.ts @@ -427,14 +427,19 @@ export default class M3U8Parser { currentSN = level.startSN = parseInt(value1); break; case 'SKIP': { + if (level.skippedSegments) { + level.playlistParsingError = new Error( + `#EXT-X-SKIP MUST NOT appear more than once in a Playlist`, + ); + } const skipAttrs = new AttrList(value1, level); const skippedSegments = skipAttrs.decimalInteger('SKIPPED-SEGMENTS'); if (Number.isFinite(skippedSegments)) { - level.skippedSegments = skippedSegments; + level.skippedSegments += skippedSegments; // This will result in fragments[] containing undefined values, which we will fill in with `mergeDetails` for (let i = skippedSegments; i--; ) { - fragments.unshift(null); + fragments.push(null); } currentSN += skippedSegments; } @@ -442,8 +447,9 @@ export default class M3U8Parser { 'RECENTLY-REMOVED-DATERANGES', ); if (recentlyRemovedDateranges) { - level.recentlyRemovedDateranges = - recentlyRemovedDateranges.split('\t'); + level.recentlyRemovedDateranges = ( + level.recentlyRemovedDateranges || [] + ).concat(recentlyRemovedDateranges.split('\t')); } break; } @@ -660,58 +666,73 @@ export default class M3U8Parser { if (level.fragmentHint) { totalduration += level.fragmentHint.duration; } - const programDateTimeCount = programDateTimes.length; - if (programDateTimeCount) { - /** - * Backfill any missing PDT values - * "If the first EXT-X-PROGRAM-DATE-TIME tag in a Playlist appears after - * one or more Media Segment URIs, the client SHOULD extrapolate - * backward from that tag (using EXTINF durations and/or media - * timestamps) to associate dates with those segments." - * We have already extrapolated forward, but all fragments up to the first instance of PDT do not have their PDTs - * computed. - */ - if (firstPdtIndex > 0) { - backfillProgramDateTimes(fragments, firstPdtIndex); - if (firstFragment) { - programDateTimes.unshift(firstFragment); - } + level.totalduration = totalduration; + if (programDateTimes.length && firstFragment) { + mapDateRanges(programDateTimes, dateRangeMapping, level); + } + /** + * Backfill any missing PDT values + * "If the first EXT-X-PROGRAM-DATE-TIME tag in a Playlist appears after + * one or more Media Segment URIs, the client SHOULD extrapolate + * backward from that tag (using EXTINF durations and/or media + * timestamps) to associate dates with those segments." + * We have already extrapolated forward, but all fragments up to the first instance of PDT do not have their PDTs + * computed. + */ + if (firstPdtIndex > 0) { + backfillProgramDateTimes(fragments, firstPdtIndex); + if (firstFragment) { + programDateTimes.unshift(firstFragment); } - // Make sure DateRanges are mapped to a ProgramDateTime tag that applies a date to a segment that overlaps with its start date - const lastProgramDateTime = programDateTimes[programDateTimeCount - 1]; - for (let i = dateRangeMapping.length; i--; ) { - const dateRange = dateRangeMapping[i][0]; - const pdtIndex = dateRange.tagAnchor - ? dateRangeMapping[i][1] - : programDateTimeCount - 1; - if (!dateRange.tagAnchor) { - dateRange.tagAnchor = lastProgramDateTime; - } - const startDateTime = dateRange.startDate.getTime(); + } + + level.endCC = discontinuityCounter; + + return level; + } +} + +export function mapDateRanges( + programDateTimes: Fragment[], + dateRangeMapping: [DateRange, number][], + details: LevelDetails, +) { + // Make sure DateRanges are mapped to a ProgramDateTime tag that applies a date to a segment that overlaps with its start date + const programDateTimeCount = programDateTimes.length; + const lastProgramDateTime = programDateTimes[programDateTimeCount - 1]; + const playlistEnd = details.live ? Infinity : details.totalduration; + for (let i = dateRangeMapping.length; i--; ) { + const dateRange = dateRangeMapping[i][0]; + const pdtIndex = dateRange.tagAnchor + ? dateRangeMapping[i][1] + : programDateTimeCount - 1; + if (!dateRange.tagAnchor) { + dateRange.tagAnchor = lastProgramDateTime; + } + const startDateTime = dateRange.startDate.getTime(); + if ( + !dateRangeMapsToProgramDateTime( + startDateTime, + programDateTimes, + pdtIndex, + playlistEnd, + ) + ) { + // DateRange not mappable to segments between surrounding ProgramDateTime tags, find alternate starting at anchor and looping backwards: + for (let j = programDateTimeCount; j--; ) { if ( - !dateRangeMapsToProgramDateTime( + dateRangeMapsToProgramDateTime( startDateTime, programDateTimes, - pdtIndex, + j, + playlistEnd, ) ) { - // DateRange not mappable to segments between surrounding ProgramDateTime tags, find alternate starting at anchor and looping backwards: - for (let j = programDateTimeCount; j--; ) { - const k = (j + pdtIndex) % programDateTimeCount; - if ( - dateRangeMapsToProgramDateTime(startDateTime, programDateTimes, k) - ) { - dateRange.tagAnchor = programDateTimes[k]; - break; - } - } + dateRange.tagAnchor = programDateTimes[j]; + break; } } } - level.totalduration = totalduration; - level.endCC = discontinuityCounter; - - return level; } } @@ -719,15 +740,16 @@ function dateRangeMapsToProgramDateTime( startDateTime: number, programDateTimes: Fragment[], index: number, + endTime: number, ): boolean { const pdtFragment = programDateTimes[index]; if (pdtFragment) { const durationBetweenPdt = - (programDateTimes[index + 1]?.start || Infinity) - pdtFragment.start; + (programDateTimes[index + 1]?.start || endTime) - pdtFragment.start; const pdtStart = pdtFragment.programDateTime as number; return ( - startDateTime >= pdtStart && - startDateTime <= pdtStart + durationBetweenPdt + (startDateTime >= pdtStart || index === 0) && + startDateTime <= pdtStart + durationBetweenPdt * 1000 ); } return false; @@ -823,22 +845,22 @@ function backfillProgramDateTimes( } } -function assignProgramDateTime( +export function assignProgramDateTime( frag: Fragment, prevFrag: Fragment | null, - programDateTimeMap: Fragment[], + programDateTimes: Fragment[], dateRangeMapping: [DateRange, number][], ) { if (frag.rawProgramDateTime) { frag.programDateTime = Date.parse(frag.rawProgramDateTime); if (frag.sn !== 'initSegment' && Number.isFinite(frag.programDateTime)) { - programDateTimeMap.push(frag); + programDateTimes.push(frag); // Anchor DateRanges to last ProgramDateTime initially. Mapping to segments is finalized at the end of Playlist parsing. for (let i = dateRangeMapping.length; i--; ) { const dateRange = dateRangeMapping[i][0]; if (!dateRange.tagAnchor) { dateRange.tagAnchor = frag; - dateRangeMapping[i][1] = programDateTimeMap.length - 1; + dateRangeMapping[i][1] = programDateTimes.length - 1; } } } diff --git a/src/utils/level-helper.ts b/src/utils/level-helper.ts index c407f591d59..92d2fb844dc 100644 --- a/src/utils/level-helper.ts +++ b/src/utils/level-helper.ts @@ -3,10 +3,11 @@ */ import { logger } from './logger'; -import { Fragment, Part } from '../loader/fragment'; -import { LevelDetails } from '../loader/level-details'; -import type { Level } from '../types/level'; import { DateRange } from '../loader/date-range'; +import { assignProgramDateTime, mapDateRanges } from '../loader/m3u8-parser'; +import type { Fragment, Part } from '../loader/fragment'; +import type { LevelDetails } from '../loader/level-details'; +import type { Level } from '../types/level'; type FragmentIntersection = (oldFrag: Fragment, newFrag: Fragment) => void; type PartIntersection = (oldPart: Part, newPart: Part) => void; @@ -196,10 +197,10 @@ export function mergeDetails( }, ); + const fragmentsToCheck = newDetails.fragmentHint + ? newDetails.fragments.concat(newDetails.fragmentHint) + : newDetails.fragments; if (currentInitSegment) { - const fragmentsToCheck = newDetails.fragmentHint - ? newDetails.fragments.concat(newDetails.fragmentHint) - : newDetails.fragments; fragmentsToCheck.forEach((frag) => { if ( frag && @@ -222,12 +223,32 @@ export function mergeDetails( } newDetails.startSN = newDetails.fragments[0].sn as number; newDetails.startCC = newDetails.fragments[0].cc; - } else if (newDetails.canSkipDateRanges) { - newDetails.dateRanges = mergeDateRanges( - oldDetails.dateRanges, - newDetails.dateRanges, - newDetails.recentlyRemovedDateranges, + } else { + if (newDetails.canSkipDateRanges) { + newDetails.dateRanges = mergeDateRanges( + oldDetails.dateRanges, + newDetails, + ); + } + const programDateTimes = oldDetails.fragments.filter( + (frag) => frag.rawProgramDateTime, ); + const dateRangeMapping: [DateRange, number][] = Object.keys( + newDetails.dateRanges, + ).map((id) => [newDetails.dateRanges[id], -1]); + if (oldDetails.hasProgramDateTime && !newDetails.hasProgramDateTime) { + for (let i = 1; i < fragmentsToCheck.length; i++) { + if (fragmentsToCheck[i].programDateTime === null) { + assignProgramDateTime( + fragmentsToCheck[i], + fragmentsToCheck[i - 1], + programDateTimes, + dateRangeMapping, + ); + } + } + } + mapDateRanges(programDateTimes, dateRangeMapping, newDetails); } } @@ -293,27 +314,38 @@ export function mergeDetails( function mergeDateRanges( oldDateRanges: Record, - deltaDateRanges: Record, - recentlyRemovedDateranges: string[] | undefined, + newDetails: LevelDetails, ): Record { + const { dateRanges: deltaDateRanges, recentlyRemovedDateranges } = newDetails; const dateRanges = Object.assign({}, oldDateRanges); if (recentlyRemovedDateranges) { recentlyRemovedDateranges.forEach((id) => { delete dateRanges[id]; }); } - Object.keys(deltaDateRanges).forEach((id) => { - const dateRange = new DateRange(deltaDateRanges[id].attr, dateRanges[id]); - if (dateRange.isValid) { - dateRanges[id] = dateRange; - } else { - logger.warn( - `Ignoring invalid Playlist Delta Update DATERANGE tag: "${JSON.stringify( - deltaDateRanges[id].attr, - )}"`, + const mergeIds = Object.keys(dateRanges); + const mergeCount = mergeIds.length; + if (mergeCount) { + Object.keys(deltaDateRanges).forEach((id) => { + const mergedDateRange = dateRanges[id]; + const dateRange = new DateRange( + deltaDateRanges[id].attr, + mergedDateRange, ); - } - }); + if (dateRange.isValid) { + dateRanges[id] = dateRange; + if (!mergedDateRange) { + dateRange.tagOrder += mergeCount; + } + } else { + logger.warn( + `Ignoring invalid Playlist Delta Update DATERANGE tag: "${JSON.stringify( + deltaDateRanges[id].attr, + )}"`, + ); + } + }); + } return dateRanges; } @@ -366,7 +398,7 @@ export function mapFragmentIntersection( for (let i = start; i <= end; i++) { const oldFrag = oldFrags[delta + i]; let newFrag = newFrags[i]; - if (skippedSegments && !newFrag && i < skippedSegments) { + if (skippedSegments && !newFrag && oldFrag) { // Fill in skipped segments in delta playlist newFrag = newDetails.fragments[i] = oldFrag; } diff --git a/tests/unit/controller/level-helper.ts b/tests/unit/controller/level-helper.ts index 352ec286b1f..750f5895c17 100644 --- a/tests/unit/controller/level-helper.ts +++ b/tests/unit/controller/level-helper.ts @@ -7,6 +7,7 @@ import { } from '../../../src/utils/level-helper'; import { LevelDetails } from '../../../src/loader/level-details'; import { Fragment, Part } from '../../../src/loader/fragment'; +import M3U8Parser from '../../../src/loader/m3u8-parser'; import { PlaylistLevelType } from '../../../src/types/loader'; import { AttrList } from '../../../src/utils/attr-list'; import sinon from 'sinon'; @@ -261,6 +262,269 @@ expect: ${JSON.stringify(merged.fragments[i])}`, 'fragmentHint does not have correct initSegment', ).to.equal(oldInitSegment); }); + + it('handles delta Playlist updates with merged program date time and skipped date ranges', function () { + const playlist = `#EXTM3U +#EXT-X-TARGETDURATION:6 +#EXT-X-VERSION:9 +#EXT-X-MEDIA-SEQUENCE:3 +#EXT-X-SERVER-CONTROL:CAN-SKIP-UNTIL=36.0,CAN-SKIP-DATERANGES=YES,CAN-BLOCK-RELOAD=YES,HOLDBACK=18,PART-HOLDBACK=3 +#EXTINF:6, +fileSequence3.ts +#EXT-X-DATERANGE:ID="one",START-DATE="2024-02-29T12:00:04.000Z" +#EXT-X-MAP:URI="map.ts\ +#EXT-X-KEY:METHOD=SAMPLE-AES,URI="key.bin" +#EXT-X-PROGRAM-DATE-TIME:2024-02-29T12:00:06.000Z +#EXTINF:6, +fileSequence4.ts +#EXT-X-BITRATE:2000 +#EXT-X-DISCONTINUITY +#EXT-X-PROGRAM-DATE-TIME:2024-02-29T12:01:00.000Z +#EXTINF:6, +fileSequence5.ts +#EXT-X-DATERANGE:ID="two",START-DATE="2024-02-29T12:00:10.000Z" +#EXTINF:6, +fileSequence6.ts +#EXTINF:6, +fileSequence7.ts +#EXTINF:6, +fileSequence8.ts +#EXTINF:6, +fileSequence9.ts +#EXTINF:6, +fileSequence10.ts +#EXT-X-DATERANGE:ID="three",START-DATE="2024-02-29T12:01:04.000Z"`; + const playlistUpdate = `#EXTM3U +#EXT-X-TARGETDURATION:6 +#EXT-X-VERSION:9 +#EXT-X-MEDIA-SEQUENCE:4 +#EXT-X-SERVER-CONTROL:CAN-SKIP-UNTIL=36.0,CAN-SKIP-DATERANGES=YES,CAN-BLOCK-RELOAD=YES,HOLDBACK=18,PART-HOLDBACK=3 +#EXT-X-SKIP:SKIPPED-SEGMENTS=3 +#EXTINF:6, +fileSequence7.ts +#EXTINF:6, +fileSequence8.ts +#EXTINF:6, +fileSequence9.ts +#EXTINF:6, +fileSequence10.ts +#EXT-X-DATERANGE:ID="three",START-DATE="2024-02-29T12:01:04.000Z" +#EXTINF:6, +fileSequence11.ts +#EXT-X-DATERANGE:ID="four",START-DATE="2024-02-29T12:02:04.000Z"`; + const details = M3U8Parser.parseLevelPlaylist( + playlist, + 'http://dummy.url.com/playlist.m3u8', + 0, + PlaylistLevelType.MAIN, + 0, + null, + ); + const detailsUpdated = M3U8Parser.parseLevelPlaylist( + playlistUpdate, + 'http://dummy.url.com/playlist.m3u8', + 0, + PlaylistLevelType.MAIN, + 0, + null, + ); + mergeDetails(details, detailsUpdated); + expect(details.hasProgramDateTime, 'details.hasProgramDateTime').to.be + .true; + expect(details.dateRanges, 'one') + .to.have.property('one') + .which.has.property('tagAnchor') + .which.equals(details.fragments[1]) + .which.has.property('sn') + .which.equals(4); + expect(details.dateRanges, 'two') + .to.have.property('two') + .which.has.property('tagAnchor') + .which.equals(details.fragments[1]) + .which.has.property('sn') + .which.equals(4); + expect(details.dateRanges, 'three') + .to.have.property('three') + .which.has.property('tagAnchor') + .which.equals(details.fragments[2]) + .which.has.property('sn') + .which.equals(5); + expect(details.dateRanges.one.startTime).to.equal(4); + expect(details.dateRanges.two.startTime).to.equal(10); + expect(details.dateRanges.three.startTime).to.equal(16); + expect(details.dateRanges.one.tagOrder, 'one.tagOrder').to.equal(0); + expect(details.dateRanges.two.tagOrder, 'two.tagOrder').to.equal(1); + expect(details.dateRanges.three.tagOrder, 'three.tagOrder').to.equal(2); + expect( + detailsUpdated.hasProgramDateTime, + 'detailsUpdated.hasProgramDateTime', + ).to.be.true; + expect(detailsUpdated.dateRanges, 'one updated') + .to.have.property('one') + .which.has.property('tagAnchor') + .which.equals(detailsUpdated.fragments[0]) + .which.has.property('sn') + .which.equals(4); + expect(detailsUpdated.dateRanges, 'two updated') + .to.have.property('two') + .which.has.property('tagAnchor') + .which.equals(detailsUpdated.fragments[0]) + .which.has.property('sn') + .which.equals(4); + expect(detailsUpdated.dateRanges, 'three updated') + .to.have.property('three') + .which.has.property('tagAnchor') + .which.equals(detailsUpdated.fragments[1]) + .which.has.property('sn') + .which.equals(5); + expect(detailsUpdated.dateRanges, 'four') + .to.have.property('four') + .which.has.property('tagAnchor') + .which.has.property('sn') + .which.equals(5); + expect(detailsUpdated.dateRanges.one.startTime).to.equal(4); + expect(detailsUpdated.dateRanges.two.startTime).to.equal(10); + expect(detailsUpdated.dateRanges.three.startTime).to.equal(16); + expect(detailsUpdated.dateRanges.four.startTime).to.equal(76); + expect( + detailsUpdated.dateRanges.one.tagOrder, + 'one.tagOrder updated', + ).to.equal(0); + expect( + detailsUpdated.dateRanges.two.tagOrder, + 'two.tagOrder updated', + ).to.equal(1); + expect( + detailsUpdated.dateRanges.three.tagOrder, + 'three.tagOrder updated', + ).to.equal(2); + expect(detailsUpdated.dateRanges.four.tagOrder, 'four.tagOrder').to.equal( + 3, + ); + }); + + it('handles delta Playlist updates with multiple skip tags and removed date ranges', function () { + const playlist = `#EXTM3U +#EXT-X-TARGETDURATION:6 +#EXT-X-VERSION:10 +#EXT-X-MEDIA-SEQUENCE:3 +#EXT-X-SERVER-CONTROL:CAN-SKIP-UNTIL=36.0,CAN-SKIP-DATERANGES=YES,CAN-BLOCK-RELOAD=YES,HOLDBACK=18,PART-HOLDBACK=3 +#EXT-X-PROGRAM-DATE-TIME:2019-04-29T19:52:19.060Z +#EXT-X-DATERANGE:ID="d0",START-DATE="2019-04-29T19:52:20.000Z",DURATION=0 +#EXT-X-DATERANGE:ID="d1",START-DATE="2019-04-29T19:52:21.000Z",DURATION=1 +#EXT-X-DATERANGE:ID="d2",START-DATE="2019-04-29T19:52:22.000Z",DURATION=2 +#EXT-X-DATERANGE:ID="d3",START-DATE="2019-04-29T19:52:23.000Z",DURATION=3 +#EXT-X-DATERANGE:ID="d4",START-DATE="2019-04-29T19:52:24.000Z",DURATION=4 +#EXTINF:6, +fileSequence3.ts +#EXTINF:6, +fileSequence4.ts +#EXTINF:6, +fileSequence5.ts +#EXTINF:6, +fileSequence6.ts +#EXTINF:6, +fileSequence7.ts +#EXTINF:6, +fileSequence8.ts +#EXTINF:6, +fileSequence9.ts +#EXTINF:6, +fileSequence10.ts +#EXTINF:6, +fileSequence11.ts +#EXTINF:6, +fileSequence12.ts +#EXTINF:6, +fileSequence13.ts +#EXTINF:6, +fileSequence14.ts +#EXTINF:6, +fileSequence15.ts +#EXTINF:6, +fileSequence16.ts +#EXTINF:6, +fileSequence17.ts`; + const playlistUpdate = `#EXTM3U +#EXT-X-TARGETDURATION:6 +#EXT-X-VERSION:10 +#EXT-X-MEDIA-SEQUENCE:3 +#EXT-X-SERVER-CONTROL:CAN-SKIP-UNTIL=36.0,CAN-SKIP-DATERANGES=YES,CAN-BLOCK-RELOAD=YES,HOLDBACK=18,PART-HOLDBACK=3 +#EXT-X-DATERANGE:ID="d3",START-DATE="2019-04-29T19:52:23.000Z",DURATION=3 +#EXT-X-DATERANGE:ID="d5",START-DATE="2019-04-29T19:52:25.000Z",DURATION=5 +#EXT-X-DATERANGE:ID="d6",START-DATE="2019-04-29T19:52:26.000Z",DURATION=6 +#EXT-X-SKIP:SKIPPED-SEGMENTS=2,RECENTLY-REMOVED-DATERANGES="d1 d4" +#EXTINF:6, +fileSequence5.ts +#EXTINF:6, +fileSequence6.ts +#EXT-X-SKIP:SKIPPED-SEGMENTS=2,RECENTLY-REMOVED-DATERANGES="d0" +#EXTINF:6, +fileSequence9.ts +#EXTINF:6, +fileSequence10.ts +#EXTINF:6, +fileSequence11.ts +#EXTINF:6, +fileSequence12.ts +#EXTINF:6, +fileSequence13.ts +#EXTINF:6, +fileSequence14.ts +#EXTINF:6, +fileSequence15.ts +#EXTINF:6, +fileSequence16.ts +#EXTINF:6, +fileSequence17.ts +#EXTINF:6, +fileSequence18.ts`; + const details = M3U8Parser.parseLevelPlaylist( + playlist, + 'http://dummy.url.com/playlist.m3u8', + 0, + PlaylistLevelType.MAIN, + 0, + null, + ); + const detailsUpdated = M3U8Parser.parseLevelPlaylist( + playlistUpdate, + 'http://dummy.url.com/playlist.m3u8', + 0, + PlaylistLevelType.MAIN, + 0, + null, + ); + mergeDetails(details, detailsUpdated); + expect(details.hasProgramDateTime, 'details.hasProgramDateTime').to.be + .true; + expect( + Object.keys(details.dateRanges), + 'first playlist daterange ids', + ).to.have.deep.equal(['d0', 'd1', 'd2', 'd3', 'd4']); + expect(details.dateRanges.d2.startTime).to.equal(2.94); + expect(details.dateRanges.d3.startTime).to.equal(3.94); + expect( + detailsUpdated.hasProgramDateTime, + 'detailsUpdated.hasProgramDateTime', + ).to.be.true; + expect( + detailsUpdated.recentlyRemovedDateranges, + 'removed daterange ids', + ).to.deep.equal(['d1', 'd4', 'd0']); + expect( + Object.keys(detailsUpdated.dateRanges), + 'delta playlist merged daterange ids', + ).to.have.deep.equal(['d2', 'd3', 'd5', 'd6']); + expect(detailsUpdated.dateRanges, 'd2 updated') + .to.have.property('d2') + .which.has.property('tagAnchor') + .which.equals(detailsUpdated.fragments[0]) + .which.has.property('sn') + .which.equals(3); + expect(detailsUpdated.dateRanges.d2.startTime).to.equal(2.94); + expect(detailsUpdated.dateRanges.d3.startTime).to.equal(3.94); + }); }); describe('computeReloadInterval', function () { diff --git a/tests/unit/loader/playlist-loader.ts b/tests/unit/loader/playlist-loader.ts index 2bd104107e6..b56b10ae620 100644 --- a/tests/unit/loader/playlist-loader.ts +++ b/tests/unit/loader/playlist-loader.ts @@ -1818,6 +1818,38 @@ segment.m4s expect(details.dateRanges.sooner.startTime).to.equal(10); }); + it('ensures DateRanges that start before the program are mapped to the first PDT tag', function () { + const playlist = `#EXTM3U +#EXT-X-VERSION:4 +#EXT-X-MEDIA-SEQUENCE:1 +#EXT-X-PROGRAM-DATE-TIME:2000-01-01T00:00:00.000Z +#EXT-X-DATERANGE:ID="earlier",START-DATE="1999-12-31T23:59:50.000Z" +#EXTINF:10 +1.mp4 +#EXT-X-DISCONTINUITY +#EXT-X-PROGRAM-DATE-TIME:2000-01-01T00:00:20.000Z +#EXTINF:10 +2.mp4 +#EXTINF:10 +3.mp4`; + const details = M3U8Parser.parseLevelPlaylist( + playlist, + 'http://dummy.url.com/playlist.m3u8', + 0, + PlaylistLevelType.MAIN, + 0, + null, + ); + expect(details.dateRanges.earlier.isValid).to.equal( + true, + 'is valid DateRange', + ); + expect(details.dateRanges.earlier.tagAnchor) + .to.have.property('sn') + .which.equals(1); + expect(details.dateRanges.earlier.startTime).to.equal(-10); + }); + it('adds PROGRAM-DATE-TIME and DATERANGE tag text to fragment[].tagList for backwards compatibility', function () { const playlist = `#EXTM3U #EXT-X-TARGETDURATION:10