diff --git a/index.js b/index.js index f99cbdb..1c75d52 100644 --- a/index.js +++ b/index.js @@ -1,7 +1,17 @@ const m3u8 = require("@eyevinn/m3u8"); const debug = require("debug")("hls-vodtolive"); const verbose = require("debug")("hls-vodtolive-verbose"); -const { findIndexReversed, fetchWithRetry, urlResolve, segToM3u8, findBottomSegItem, fixedNumber, inspectForVodTransition } = require("./utils.js"); +const { + findIndexReversed, + fetchWithRetry, + urlResolve, + segToM3u8, + findBottomSegItem, + fixedNumber, + inspectForVodTransition, + playlistItemWithInterstitialsMetadata, + appendHlsInterstitialLineWithCUE, +} = require("./utils.js"); class HLSVod { /** @@ -79,6 +89,7 @@ class HLSVod { this.subtitleSequencesCount = 0; this.mediaStartExcessTime = 0; this.audioCodecsMap = {}; + this.cuedHlsInterstitialTag = null; } toJSON() { @@ -123,7 +134,8 @@ class HLSVod { mediaStartExcessTime: this.mediaStartExcessTime, audioCodecsMap: this.audioCodecsMap, alwaysMapBandwidthByNearest: this.alwaysMapBandwidthByNearest, - skipSerializeMediaSequences: this.skipSerializeMediaSequences + skipSerializeMediaSequences: this.skipSerializeMediaSequences, + cuedHlsInterstitialTag: this.cuedHlsInterstitialTag, }; return JSON.stringify(serialized); } @@ -172,11 +184,12 @@ class HLSVod { this.subtitleSliceEndpoint = de.subtitleSliceEndpoint; this.videoSequencesCount = de.videoSequencesCount; this.audioSequencesCount = de.audioSequencesCount; - this.subtitleSequencesCount = de.subtitleSequencesCount + this.subtitleSequencesCount = de.subtitleSequencesCount; this.mediaStartExcessTime = de.mediaStartExcessTime; this.audioCodecsMap = de.audioCodecsMap; this.alwaysMapBandwidthByNearest = de.alwaysMapBandwidthByNearest; this.skipSerializeMediaSequences = de.skipSerializeMediaSequences; + this.cuedHlsInterstitialTag = de.cuedHlsInterstitialTag; } /** @@ -736,10 +749,11 @@ class HLSVod { if (newTimeOffsetAudio > 0) { let totalTimelinePos = 0; for (let x = 0; x < additionalAudioSegments[groupIdsInSegments[i]][langsInSegment[j]].length; x++) { - const segment =additionalAudioSegments[groupIdsInSegments[i]][langsInSegment[j]][x]; + const segment = additionalAudioSegments[groupIdsInSegments[i]][langsInSegment[j]][x]; if (segment.duration) { totalTimelinePos += segment.duration * 1000; - additionalAudioSegments[groupIdsInSegments[i]][langsInSegment[j]][x].timelinePosition = newTimeOffsetAudio + totalTimelinePos; + additionalAudioSegments[groupIdsInSegments[i]][langsInSegment[j]][x].timelinePosition = + newTimeOffsetAudio + totalTimelinePos; } } } @@ -903,7 +917,7 @@ class HLSVod { if (!this.audioCodecsMap[audioCodecs]) { return undefined; } - Object.keys(this.audioCodecsMap[audioCodecs]).map(channelsKey => { + Object.keys(this.audioCodecsMap[audioCodecs]).map((channelsKey) => { if (channelsKey === channels) { audioGroupId = this.audioCodecsMap[audioCodecs][channelsKey]; } @@ -914,8 +928,8 @@ class HLSVod { getAudioCodecsAndChannelsForGroupId(groupId) { let audioCodecs; let channels; - Object.keys(this.audioCodecsMap).map(codecKey => { - Object.keys(this.audioCodecsMap[codecKey]).map(channelsKey => { + Object.keys(this.audioCodecsMap).map((codecKey) => { + Object.keys(this.audioCodecsMap[codecKey]).map((channelsKey) => { if (this.audioCodecsMap[codecKey][channelsKey] === groupId) { audioCodecs = codecKey; channels = channelsKey; @@ -926,7 +940,7 @@ class HLSVod { } getSubtitleGroups(all = false) { - return Object.keys(this.subtitleSegments).filter(groupId => groupId !== this.DUMMY_DEFAULT_SUBTITLE_GROUP_ID || all); + return Object.keys(this.subtitleSegments).filter((groupId) => groupId !== this.DUMMY_DEFAULT_SUBTITLE_GROUP_ID || all); } getSubtitleLangsForSubtitleGroup(groupId) { @@ -1021,14 +1035,20 @@ class HLSVod { for (let i = 0; i < this.mediaSequences[seqIdx].segments[bw].length; i++) { const v = this.mediaSequences[seqIdx].segments[bw][i]; if (v) { - m3u8 += segToM3u8(v, i, + m3u8 += segToM3u8( + v, + i, this.mediaSequences[seqIdx].segments[bw].length, this.mediaSequences[seqIdx].segments[bw][i + 1], - previousSegment); + previousSegment + ); previousSegment = v; } } - + // FOR LIVE AND CUED HLS-INTERSTITIAL TAGS, Place them at the bottom of each window + if (this.cuedHlsInterstitialTag) { + m3u8 = appendHlsInterstitialLineWithCUE(m3u8, this.cuedHlsInterstitialTag); + } return m3u8; } @@ -1072,12 +1092,14 @@ class HLSVod { for (let i = 0; i < mediaSeqAudioSegments.length; i++) { const v = mediaSeqAudioSegments[i]; if (v) { - m3u8 += segToM3u8(v, i, mediaSeqAudioSegments.length, - mediaSeqAudioSegments[i + 1], previousSegment); + m3u8 += segToM3u8(v, i, mediaSeqAudioSegments.length, mediaSeqAudioSegments[i + 1], previousSegment); previousSegment = v; } } - + // FOR LIVE AND CUED HLS-INTERSTITIAL TAGS, Place them at the bottom of each window + if (this.cuedHlsInterstitialTag) { + m3u8 = appendHlsInterstitialLineWithCUE(m3u8, this.cuedHlsInterstitialTag); + } return m3u8; } @@ -1116,11 +1138,14 @@ class HLSVod { for (let i = 0; i < mediaSeqSubtitleSegments.length; i++) { const v = mediaSeqSubtitleSegments[i]; if (v) { - m3u8 += segToM3u8(v, i, mediaSeqSubtitleSegments.length, - mediaSeqSubtitleSegments[i + 1], previousSegment); + m3u8 += segToM3u8(v, i, mediaSeqSubtitleSegments.length, mediaSeqSubtitleSegments[i + 1], previousSegment); previousSegment = v; } } + // FOR LIVE AND CUED HLS-INTERSTITIAL TAGS, Place them at the bottom of each window + if (this.cuedHlsInterstitialTag) { + m3u8 = appendHlsInterstitialLineWithCUE(m3u8, this.cuedHlsInterstitialTag); + } return m3u8; } @@ -1336,7 +1361,7 @@ class HLSVod { let segOffset = 0; let segIdx = 0; let sequence = {}; - let video_sequence_list = [] + let video_sequence_list = []; while (this.segments[bw][segIdx] && segIdx != length) { if (this.segments[bw][segIdx].uri) { video_duration += this.segments[bw][segIdx].duration; @@ -1477,7 +1502,7 @@ class HLSVod { } sequence = {}; } - return sequenceList + return sequenceList; } generateSequencesTypeBVideo(bw, bandwidths) { let seqIndex = 0; @@ -1588,12 +1613,7 @@ class HLSVod { To avoid creating a sequence where we remove 2 segments to add 2 segments. Aim to add and remove as few segments as possible each sequence. */ - if ( - segIdxVideo < SIZE && - shiftedSegmentsCount === 1 && - newPushedSegmentsCount > 1 && - totalSeqDurVideo >= this.SEQUENCE_DURATION - ) { + if (segIdxVideo < SIZE && shiftedSegmentsCount === 1 && newPushedSegmentsCount > 1 && totalSeqDurVideo >= this.SEQUENCE_DURATION) { // pop video... bandwidths.forEach((_bw) => { let seg = _sequence[_bw].pop(); @@ -1643,11 +1663,7 @@ class HLSVod { try { totalSeqDur = 0; const _sequence = JSON.parse(JSON.stringify(sequence)); - if ( - _sequence[firstGroupId] && - _sequence[firstGroupId][firstLanguage] && - _sequence[firstGroupId][firstLanguage].length > 0 - ) { + if (_sequence[firstGroupId] && _sequence[firstGroupId][firstLanguage] && _sequence[firstGroupId][firstLanguage].length > 0) { let temp = 0; _sequence[firstGroupId][firstLanguage].forEach((seg) => { if (seg && seg.duration) { @@ -1791,12 +1807,7 @@ class HLSVod { To avoid creating a sequence where we remove 2 segments to add 2 segments. Aim to add and remove as few segments as possible each sequence. */ - if ( - segIdx < SIZE && - shiftedSegmentsCount === 1 && - newPushedSegmentsCount > 1 && - totalSeqDur >= this.SEQUENCE_DURATION - ) { + if (segIdx < SIZE && shiftedSegmentsCount === 1 && newPushedSegmentsCount > 1 && totalSeqDur >= this.SEQUENCE_DURATION) { // pop audio... if (firstGroupId) { const groupIds = Object.keys(segments); @@ -1884,10 +1895,10 @@ class HLSVod { debug(`Increasing discont sequence ${discSeqNo}`); } if (this.sequenceAlwaysContainNewSegments) { - type === "audio" ? this.discontinuitiesAudio[seqNo] += discSeqNo : this.discontinuitiesSubtitle[seqNo] += discSeqNo; + type === "audio" ? (this.discontinuitiesAudio[seqNo] += discSeqNo) : (this.discontinuitiesSubtitle[seqNo] += discSeqNo); discSeqNo = 0; } else { - type === "audio" ? this.discontinuitiesAudio[seqNo] = discSeqNo : this.discontinuitiesSubtitle[seqNo] = discSeqNo; + type === "audio" ? (this.discontinuitiesAudio[seqNo] = discSeqNo) : (this.discontinuitiesSubtitle[seqNo] = discSeqNo); } if (this.sequenceAlwaysContainNewSegments) { @@ -1920,12 +1931,12 @@ class HLSVod { if (type === "audio") { this.deltaTimesAudio.push({ interval: interval, - position: positionIncrement ? fixedNumber(lastPosition + tpi) : (lastPosition), + position: positionIncrement ? fixedNumber(lastPosition + tpi) : lastPosition, }); } else if (type === "subtitle") { this.deltaTimesSubtitle.push({ interval: interval, - position: positionIncrement ? fixedNumber(lastPosition + tpi) : (lastPosition), + position: positionIncrement ? fixedNumber(lastPosition + tpi) : lastPosition, }); } if (positionIncrement) { @@ -2028,7 +2039,7 @@ class HLSVod { q = { discontinuity: q.discontinuity, daterange: q.daterange, - } + }; } this.segments[destBw].push(q); } @@ -2085,7 +2096,7 @@ class HLSVod { q = { discontinuity: q.discontinuity, daterange: q.daterange, - } + }; } this.audioSegments[audioGroupId][audioLang].push(q); } @@ -2093,7 +2104,7 @@ class HLSVod { this.audioSegments[audioGroupId][audioLang].push({ discontinuity: true, daterange: this.rangeMetadata ? this.rangeMetadata : null, - vodTransition: true + vodTransition: true, }); } } @@ -2133,7 +2144,7 @@ class HLSVod { q = { discontinuity: q.discontinuity, daterange: q.daterange, - } + }; } this.subtitleSegments[subtitleGroupId][subtitleLang].push(q); } @@ -2141,7 +2152,7 @@ class HLSVod { this.subtitleSegments[subtitleGroupId][subtitleLang].push({ discontinuity: true, daterange: this.rangeMetadata ? this.rangeMetadata : null, - vodTransition: true + vodTransition: true, }); } } @@ -2217,11 +2228,11 @@ class HLSVod { } // Remove all double discontinuities (audio) if (audioGroupId) { - this._removeDoubleDiscontinuitiesFromExtraMedia(this.audioSegments) + this._removeDoubleDiscontinuitiesFromExtraMedia(this.audioSegments); } // Remove all double discontinuities (subtitle) if (subtitleGroupId) { - this._removeDoubleDiscontinuitiesFromExtraMedia(this.subtitleSegments) + this._removeDoubleDiscontinuitiesFromExtraMedia(this.subtitleSegments); } if (this.shouldContainSubtitles && !mode) { // we are doing all this to figure out the entire duration of the new vod so we can create a long subtitle segment that we can later chunk to smaller segments @@ -2231,7 +2242,7 @@ class HLSVod { const bw = this.getBandwidths()[0]; for (let index = 0; index < this.segments[bw].length; index++) { if (this.segments[bw][index].duration) { - tempDuration += this.segments[bw][index].duration + tempDuration += this.segments[bw][index].duration; } if (this.segments[bw][index].vodTransition) { duration -= tempDuration; @@ -2247,7 +2258,6 @@ class HLSVod { uri: this.dummySubtitleEndpoint, }; - const result = this.generateSmallerSubtitleSegments(fakeSubtileSegment, offset, 0, true, false, 0) this.subtitleSegments[this.DUMMY_DEFAULT_SUBTITLE_GROUP_ID][this.DUMMY_DEFAULT_SUBTITLE_LANGUAGE] = this.subtitleSegments[this.DUMMY_DEFAULT_SUBTITLE_GROUP_ID][this.DUMMY_DEFAULT_SUBTITLE_LANGUAGE].concat(result.newSegments); // If the expected tracks are set and are not filled with new source segments, then let them have dummy segments @@ -2354,7 +2364,7 @@ class HLSVod { this.mediaSequences.push({ segments: {}, audioSegments: audioSequences[i] ? audioSequences[i] : {}, - subtitleSegments: {} + subtitleSegments: {}, }); } } @@ -2371,7 +2381,7 @@ class HLSVod { } this.videoSequencesCount = videoSequences.length; this.audioSequencesCount = audioSequences.length; - this.subtitleSequencesCount = subtitleSequences.length + this.subtitleSequencesCount = subtitleSequences.length; } if (!this.mediaSequences) { @@ -2480,10 +2490,10 @@ class HLSVod { } // Audio Version if (this.mediaSequences[0].audioSegments) { - this.calculateDeltaAndPositionExtraMedia("audio") + this.calculateDeltaAndPositionExtraMedia("audio"); } if (this.mediaSequences[0].subtitleSegments) { - this.calculateDeltaAndPositionExtraMedia("subtitle") + this.calculateDeltaAndPositionExtraMedia("subtitle"); } resolve(); } @@ -2663,6 +2673,14 @@ class HLSVod { } const playlistItem = m3u.items.PlaylistItem[i]; + + if (!this.cuedHlsInterstitialTag && playlistItem.get("daterange")) { + let daterange = playlistItem.get("daterange"); + if (daterange["CUE"]) { + this.cuedHlsInterstitialTag = daterange; + } + } + let segmentUri; let baseUrl; let byteRange = undefined; @@ -2803,6 +2821,16 @@ class HLSVod { q["daterange"] = this.rangeMetadata; } } + + if (playlistItemWithInterstitialsMetadata(playlistItem)) { + const newDateRange = playlistItem.attributes.attributes["daterange"]; + const newStartDate = new Date(newDateRange["START-DATE"]); + const tempTimeOffset = new Date(this.timeOffset); + const newStartDateString = new Date(newStartDate.getTime() + tempTimeOffset.getTime()).toISOString(); + newDateRange["START-DATE"] = newStartDateString; + q["daterange"] = newDateRange; + } + this.segments[bw].push(q); position += q.duration; timelinePosition += q.duration * 1000; @@ -2843,12 +2871,12 @@ class HLSVod { _similarSegItemDuration(playlistItems, startOffset) { let totalSegmentDuration = 0; let segmentCount = 0; - playlistItems.map(seg => { + playlistItems.map((seg) => { if (seg.get("duration")) { segmentCount++; totalSegmentDuration += seg.get("duration"); } - }) + }); const avgSegmentDuration = totalSegmentDuration / segmentCount; const bandwidths = Object.keys(this.segments); @@ -2895,7 +2923,7 @@ class HLSVod { // Remove segments in the beginning if we have a start time offset if (this.startTimeOffset != null) { let offset = 0; - const bw = this.getBandwidths()[0] + const bw = this.getBandwidths()[0]; for (let index = 0; index < this.segments[bw].length; index++) { if (this.segments[bw][index].vodTransition) { offset = index; @@ -2903,9 +2931,7 @@ class HLSVod { } } const sameLength = this._similarSegItemDuration(m3u.items.PlaylistItem, offset); - let remain = sameLength - ? this.startTimeOffset - : this.startTimeOffset + this.mediaStartExcessTime; + let remain = sameLength ? this.startTimeOffset : this.startTimeOffset + this.mediaStartExcessTime; while (remain > 0) { let removed; @@ -3024,6 +3050,16 @@ class HLSVod { q["daterange"] = this.rangeMetadata; } } + + if (playlistItemWithInterstitialsMetadata(playlistItem)) { + const newDateRange = playlistItem.attributes.attributes["daterange"]; + const newStartDate = new Date(newDateRange["START-DATE"]); + const tempTimeOffset = new Date(this.timeOffset); + const newStartDateString = new Date(newStartDate.getTime() + tempTimeOffset.getTime()).toISOString(); + newDateRange["START-DATE"] = newStartDateString; + q["daterange"] = newDateRange; + } + this.audioSegments[groupId][language].push(q); timelinePosition += q.duration * 1000; } @@ -3070,7 +3106,7 @@ class HLSVod { parser.on("m3u", (m3u) => { let offset = 0; - const bw = this.getBandwidths()[0] + const bw = this.getBandwidths()[0]; for (let index = 0; index < this.segments[bw].length; index++) { if (this.segments[bw][index].vodTransition) { offset = index; @@ -3201,6 +3237,15 @@ class HLSVod { } } + if (playlistItemWithInterstitialsMetadata(playlistItem)) { + const newDateRange = playlistItem.attributes.attributes["daterange"]; + const newStartDate = new Date(newDateRange["START-DATE"]); + const tempTimeOffset = new Date(this.timeOffset); + const newStartDateString = new Date(newStartDate.getTime() + tempTimeOffset.getTime()).toISOString(); + newDateRange["START-DATE"] = newStartDateString; + q["daterange"] = newDateRange; + } + if (!similarSegItemDuration) { const result = this.generateSmallerSubtitleSegments(q, offset, leftover, false, firstSegment, elapsedTime); firstSegment = false; diff --git a/package-lock.json b/package-lock.json index 2da5a85..69c332a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "4.1.2", "license": "MIT", "dependencies": { - "@eyevinn/m3u8": "^0.5.6", + "@eyevinn/m3u8": "^0.5.8", "abort-controller": "^3.0.0", "debug": "^4.1.1", "node-fetch": "2.6.7" @@ -340,9 +340,9 @@ } }, "node_modules/@eyevinn/m3u8": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/@eyevinn/m3u8/-/m3u8-0.5.6.tgz", - "integrity": "sha512-aYWN9Rzofs3MCuoGrJww6vExnKg8bLHN2jAmzjK8t/akwT3bzwR3i2kqH895ZysR87mikgOzF3IaBp4OYYfwWg==", + "version": "0.5.8", + "resolved": "https://registry.npmjs.org/@eyevinn/m3u8/-/m3u8-0.5.8.tgz", + "integrity": "sha512-fmCfznP8XUPhJa5zRu+6H8Ys/ts/1J4wj0on1yIUSnkf+UTW2NciWxoB24EDe3s8eF2qjcyMNnwU/DeIM7+f6A==", "dependencies": { "chunked-stream": "~0.0.2" } @@ -3498,9 +3498,9 @@ } }, "@eyevinn/m3u8": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/@eyevinn/m3u8/-/m3u8-0.5.6.tgz", - "integrity": "sha512-aYWN9Rzofs3MCuoGrJww6vExnKg8bLHN2jAmzjK8t/akwT3bzwR3i2kqH895ZysR87mikgOzF3IaBp4OYYfwWg==", + "version": "0.5.8", + "resolved": "https://registry.npmjs.org/@eyevinn/m3u8/-/m3u8-0.5.8.tgz", + "integrity": "sha512-fmCfznP8XUPhJa5zRu+6H8Ys/ts/1J4wj0on1yIUSnkf+UTW2NciWxoB24EDe3s8eF2qjcyMNnwU/DeIM7+f6A==", "requires": { "chunked-stream": "~0.0.2" } diff --git a/package.json b/package.json index 5982dfc..db5e815 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "nyc": "^15.0.1" }, "dependencies": { - "@eyevinn/m3u8": "^0.5.6", + "@eyevinn/m3u8": "^0.5.8", "abort-controller": "^3.0.0", "debug": "^4.1.1", "node-fetch": "2.6.7" diff --git a/spec/hlsvod_spec.js b/spec/hlsvod_spec.js index 88f0887..f884fe5 100644 --- a/spec/hlsvod_spec.js +++ b/spec/hlsvod_spec.js @@ -59,7 +59,7 @@ describe("HLSVod standalone", () => { return fs.createReadStream("testvectors/hls17/" + bandwidth + ".m3u8"); }; }); - + it("return the correct vod URI", (done) => { mockVod = new HLSVod("http://mock.com/mock.m3u8"); mockVod.load(mockMasterManifest, mockMediaManifest).then(() => { @@ -244,15 +244,16 @@ describe("HLSVod standalone", () => { it("should fail loading a VOD with different lengths of video segments", (done) => { mockVod = new HLSVod("http://mock.com/mock.m3u8"); - mockVod.load(mockMasterManifest4, mockMediaManifest4) - .then(() => { - expect(mockVod.getVodUri()).toBe("http://mock.com/mock.m3u8"); - done(); - }) - .catch((err) => { - expect(err.toString()).toBe("Error: The VOD loading was rejected because it contains video variants with different segment counts"); - done(); - }) + mockVod + .load(mockMasterManifest4, mockMediaManifest4) + .then(() => { + expect(mockVod.getVodUri()).toBe("http://mock.com/mock.m3u8"); + done(); + }) + .catch((err) => { + expect(err.toString()).toBe("Error: The VOD loading was rejected because it contains video variants with different segment counts"); + done(); + }); }); }); @@ -666,8 +667,8 @@ describe("HLSVod with timeline", () => { mockVod.load(mockMasterManifest, mockMediaManifest).then(() => { let m3u8 = mockVod.getLiveMediaSequences(0, "2497000", 0); const lines = m3u8.split("\n"); - expect(lines[6]).toEqual(`#EXT-X-PROGRAM-DATE-TIME:${(new Date(now)).toISOString()}`); - expect(lines[9]).toEqual(`#EXT-X-PROGRAM-DATE-TIME:${(new Date(now + 9 * 1000)).toISOString()}`); + expect(lines[6]).toEqual(`#EXT-X-PROGRAM-DATE-TIME:${new Date(now).toISOString()}`); + expect(lines[9]).toEqual(`#EXT-X-PROGRAM-DATE-TIME:${new Date(now + 9 * 1000).toISOString()}`); done(); }); }); @@ -679,12 +680,13 @@ describe("HLSVod with timeline", () => { let m3u8 = mockVod.getLiveMediaSequences(0, "2497000", 0); const lines = m3u8.split("\n"); expect(lines[6]).toEqual(`#EXTINF:9.000,`); - expect(lines[9]).toEqual(`https://tv4play-i.akamaihd.net/i/mp4root/2018-01-26/pid200032972(3953564_,T3MP445,T3MP435,T3MP425,T3MP415,T3MP48,T3MP43,T3MP4130,).mp4.csmil/segment2_2_av.ts`); + expect(lines[9]).toEqual( + `https://tv4play-i.akamaihd.net/i/mp4root/2018-01-26/pid200032972(3953564_,T3MP445,T3MP435,T3MP425,T3MP415,T3MP48,T3MP43,T3MP4130,).mp4.csmil/segment2_2_av.ts` + ); done(); }); }); - it("can handle vod after another vod", (done) => { const VOD_LENGTH_MS = 2646000 + 6266; const now = Date.now(); @@ -706,7 +708,7 @@ describe("HLSVod with timeline", () => { it("can handle vod after another vod, with later vod disabling timeOffset", (done) => { const now = 1692110553608; mockVod = new HLSVod("http://mock.com/mock.m3u8", [], now); - mockVod2 = new HLSVod("http://mock.com/mock2.m3u8", [], timeOffet=null); + mockVod2 = new HLSVod("http://mock.com/mock2.m3u8", [], (timeOffet = null)); mockVod .load(mockMasterManifest, mockMediaManifest) .then(() => { @@ -912,7 +914,7 @@ describe("HLSVod with not equal usage profiles", () => { }); mockMasterManifest.push(function () { return fs.createReadStream("testvectors/hls_abr6/master.m3u8"); - }) + }); mockMediaManifest.push(function (bandwidth) { return fs.createReadStream("testvectors/hls1/" + bandwidth + ".m3u8"); }); @@ -1227,10 +1229,10 @@ describe("HLSVod with not equal usage profiles", () => { done(); }); }); - + it("can match by true nearest when options-> alwaysMapBandwidthByNearest is true", (done) => { - mockVod = new HLSVod("http://mock.com/mock.m3u8", null, 0,0, null, { alwaysMapBandwidthByNearest: 1 }); - mockVod2 = new HLSVod("http://mock.com/mock2.m3u8", null, 0,0, null, { alwaysMapBandwidthByNearest: 1 }); + mockVod = new HLSVod("http://mock.com/mock.m3u8", null, 0, 0, null, { alwaysMapBandwidthByNearest: 1 }); + mockVod2 = new HLSVod("http://mock.com/mock2.m3u8", null, 0, 0, null, { alwaysMapBandwidthByNearest: 1 }); mockVod .load(mockMasterManifest[0], mockMediaManifest[0]) .then(() => { @@ -1239,7 +1241,7 @@ describe("HLSVod with not equal usage profiles", () => { .then(() => { const seqSegments = mockVod2.getLiveMediaSequenceSegments(0); const lastIdx = seqSegments["1497000"].length - 1; - expect(seqSegments["1497000"][lastIdx].uri).toEqual("https://mock.vod.media/segment1_0_av.ts"); + expect(seqSegments["1497000"][lastIdx].uri).toEqual("https://mock.vod.media/segment1_0_av.ts"); expect(seqSegments["3496000"][lastIdx].uri).toEqual("https://mock.vod.media/segment1_1_av.ts"); expect(seqSegments["4497000"][lastIdx].uri).toEqual("https://mock.vod.media/segment1_2_av.ts"); expect(seqSegments["5544000"][lastIdx].uri).toEqual("https://mock.vod.media/segment1_3_av.ts"); @@ -1406,11 +1408,11 @@ describe("HLSVod with separate audio variants", () => { }); }); - it("can handle vod after another vod, loading same groupId & languages, but with later vod disabling timeOffset", (done) => { + it("can handle vod after another vod, loading same groupId & languages, but with later vod disabling timeOffset", (done) => { const now = Date.now(); // # Two demuxed vods with some different languages. mockVod = new HLSVod("http://mock.com/mock.m3u8", [], now); - mockVod2 = new HLSVod("http://mock.com/mock2.m3u8", [], timeOffest=null); + mockVod2 = new HLSVod("http://mock.com/mock2.m3u8", [], (timeOffest = null)); mockVod .load(mockMasterManifest, mockMediaManifest, mockAudioManifest) .then(() => { @@ -2266,7 +2268,7 @@ describe("HLSVod serializing", () => { const bytesToMB = (bytes) => { const megabytes = bytes / (1024 * 1024); return megabytes.toFixed(2); - } + }; const expectedJSONSizeInMBFull = "5.26"; const expectedJSONSizeInMBPartial = "0.38"; const expectedJSONSizeInMBFullAgain = expectedJSONSizeInMBFull; @@ -2350,7 +2352,7 @@ describe("HLSVod serializing", () => { const bytesToMB = (bytes) => { const megabytes = bytes / (1024 * 1024); return megabytes.toFixed(2); - } + }; const expectedJSONSizeInMBFull = "5.29"; const expectedJSONSizeInMBPartial = "0.38"; const expectedJSONSizeInMBFullAgain = expectedJSONSizeInMBFull; @@ -3109,7 +3111,6 @@ describe("HLSVod for demuxed audio, with set option-> sequenceAlwaysContainNewSe return fs.createReadStream(`testvectors/hls_multiaudiotracks4/${groupId}.m3u8`); } }; - }); xit("should fail loading a VOD with different lengths of audio segments", (done) => { @@ -3725,6 +3726,7 @@ describe("HLSVod delta time and positions", () => { let mock2_MasterManifest; let mock2_MediaManifest; let mock2_AudioManifest; + let mock_vod_3; beforeEach(() => { mock1_MasterManifest = function () { @@ -3757,6 +3759,17 @@ describe("HLSVod delta time and positions", () => { return fs.createReadStream(`testvectors/hls_always_4_demux/${groupId}.m3u8`); } }; + mock_vod_3 = { + master: () => { + return fs.createReadStream("testvectors/hls_cmaf_interstitial_1/master.m3u8"); + }, + media: (bandwidth) => { + return fs.createReadStream("testvectors/hls_cmaf_interstitial_1/test-video=" + bandwidth + ".m3u8"); + }, + audio: (groupId, lang) => { + return fs.createReadStream("testvectors/hls_cmaf_interstitial_1/test-audio=256000.m3u8"); + }, + }; }); it("and there is no matching group ID, then it sets default target group ID to load next VOD segments into", (done) => { @@ -3772,5 +3785,29 @@ describe("HLSVod delta time and positions", () => { }); }); }); + + it("with Program date time enabled and VODs have HLS Interstitial Tag", (done) => { + mockVod = new HLSVod("http://mock.com/mock.m3u8", null, Date.now(), 0, null, { + sequenceAlwaysContainNewSegments: 0, + forcedDemuxMode: true, + }); + mockVod2 = new HLSVod("http://mock.com/mock2.m3u8", null, Date.now() + 90000, 0, null, { + sequenceAlwaysContainNewSegments: 0, + forcedDemuxMode: true, + }); + mockVod.load(mock_vod_3.master, mock_vod_3.media, mock_vod_3.audio).then(() => { + mockVod2.loadAfter(mockVod, mock_vod_3.master, mock_vod_3.media, mock_vod_3.audio).then(() => { + let m3u8 = mockVod2.getLiveMediaAudioSequences(0, "audio-aacl-256", "sv", 4); + let lines = m3u8.split("\n"); + expect( + lines[16].includes('#EXT-X-DATERANGE:ID="ad1",CLASS="com.apple.hls.interstitial",START-DATE') && + lines[16].includes( + ',DURATION="15.0",X-ASSET-URI="http://example.com/ad1.m3u8",X-RESUME-OFFSET="0",X-RESTRICT="SKIP,JUMP",X-COM-EXAMPLE-BEACON="123"' + ) + ).toBe(true); + done(); + }); + }); + }); }); }); diff --git a/testvectors/hls_cmaf_interstitial_1/master.m3u8 b/testvectors/hls_cmaf_interstitial_1/master.m3u8 new file mode 100644 index 0000000..2d3f03d --- /dev/null +++ b/testvectors/hls_cmaf_interstitial_1/master.m3u8 @@ -0,0 +1,11 @@ +#EXTM3U +#EXT-X-VERSION:6 +## Created with Unified Streaming Platform (version=1.11.20-26889) + +# AUDIO groups +#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio-aacl-256",NAME="Swedish",LANGUAGE="sv",DEFAULT=YES,AUTOSELECT=YES,CHANNELS="2",URI="test-audio=256000.m3u8" + +# variants +#EXT-X-STREAM-INF:BANDWIDTH=2500000,CODECS="mp4a.40.2,avc1.4D401F",RESOLUTION=1024x576,FRAME-RATE=25,VIDEO-RANGE=SDR,AUDIO="audio-aacl-256",CLOSED-CAPTIONS=NONE +test-video=2500000.m3u8 + diff --git a/testvectors/hls_cmaf_interstitial_1/test-audio=256000.m3u8 b/testvectors/hls_cmaf_interstitial_1/test-audio=256000.m3u8 new file mode 100644 index 0000000..055e391 --- /dev/null +++ b/testvectors/hls_cmaf_interstitial_1/test-audio=256000.m3u8 @@ -0,0 +1,29 @@ +#EXTM3U +#EXT-X-VERSION:6 +## Created with Unified Streaming Platform (version=1.11.20-26889) +#EXT-X-PLAYLIST-TYPE:VOD +#EXT-X-MEDIA-SEQUENCE:1 +#EXT-X-INDEPENDENT-SEGMENTS +#EXT-X-TARGETDURATION:3 +#EXT-X-PROGRAM-DATE-TIME:1970-01-01T00:00:00.001Z +#EXT-X-MAP:URI="test-audio=256000.m4s" +#EXTINF:10, no desc +test-audio=2500000-1.m4s +#EXTINF:10, no desc +test-audio=2500000-2.m4s +#EXTINF:10, no desc +test-audio=2500000-3.m4s +#EXT-X-DATERANGE:ID="ad1",CLASS="com.apple.hls.interstitial",START-DATE="1970-01-01T00:00:35.001Z",DURATION=15.0,X-ASSET-URI="http://example.com/ad1.m3u8",X-RESUME-OFFSET=0, X-RESTRICT="SKIP,JUMP",X-COM-EXAMPLE-BEACON=123 +#EXTINF:10, no desc +test-audio=2500000-4.m4s +#EXTINF:10, no desc +test-audio=2500000-5.m4s +#EXTINF:10, no desc +test-audio=2500000-6.m4s +#EXTINF:10, no desc +test-audio=2500000-7.m4s +#EXTINF:10, no desc +test-audio=2500000-8.m4s +#EXTINF:10, no desc +test-audio=2500000-9.m4s +#EXT-X-ENDLIST diff --git a/testvectors/hls_cmaf_interstitial_1/test-video=2500000.m3u8 b/testvectors/hls_cmaf_interstitial_1/test-video=2500000.m3u8 new file mode 100644 index 0000000..8ccd5c9 --- /dev/null +++ b/testvectors/hls_cmaf_interstitial_1/test-video=2500000.m3u8 @@ -0,0 +1,29 @@ +#EXTM3U +#EXT-X-VERSION:6 +## Created with Unified Streaming Platform (version=1.11.20-26889) +#EXT-X-PLAYLIST-TYPE:VOD +#EXT-X-MEDIA-SEQUENCE:1 +#EXT-X-INDEPENDENT-SEGMENTS +#EXT-X-TARGETDURATION:3 +#EXT-X-PROGRAM-DATE-TIME:1970-01-01T00:00:00.001Z +#EXT-X-MAP:URI="test-video=256000.m4s" +#EXTINF:10, no desc +test-video=2500000-1.m4s +#EXTINF:10, no desc +test-video=2500000-2.m4s +#EXTINF:10, no desc +test-video=2500000-3.m4s +#EXT-X-DATERANGE:ID="ad1",CLASS="com.apple.hls.interstitial",START-DATE="1970-01-01T00:00:35.001Z",DURATION=15.0,X-ASSET-URI="http://example.com/ad1.m3u8",X-RESUME-OFFSET=0, X-RESTRICT="SKIP,JUMP",X-COM-EXAMPLE-BEACON=123 +#EXTINF:10, no desc +test-video=2500000-4.m4s +#EXTINF:10, no desc +test-video=2500000-5.m4s +#EXTINF:10, no desc +test-video=2500000-6.m4s +#EXTINF:10, no desc +test-video=2500000-7.m4s +#EXTINF:10, no desc +test-video=2500000-8.m4s +#EXTINF:10, no desc +test-video=2500000-9.m4s +#EXT-X-ENDLIST diff --git a/utils.js b/utils.js index 11502e0..666cd54 100644 --- a/utils.js +++ b/utils.js @@ -118,16 +118,17 @@ function segToM3u8(v, i, len, nextSegment, previousSegment) { m3u8 += "#EXT-X-CUE-IN" + "\n"; } } - if (v.daterange && i != len - 1) { + if (v.daterange && i != len - 1 && !(v.daterange["CLASS"] == "com.apple.hls.interstitial" && v.daterange["CUE"])) { const dateRangeAttributes = Object.keys(v.daterange) .map((key) => daterangeAttribute(key, v.daterange[key])) .join(","); - if ((nextSegment && !nextSegment.timelinePosition) && v.daterange["start-date"]) { + if (nextSegment && !nextSegment.timelinePosition && v.daterange["start-date"]) { m3u8 += "#EXT-X-PROGRAM-DATE-TIME:" + v.daterange["start-date"] + "\n"; } m3u8 += "#EXT-X-DATERANGE:" + dateRangeAttributes + "\n"; } } + return m3u8; } @@ -221,6 +222,25 @@ const inspectForVodTransition = (list) => { return [count, foundVodTransition]; }; +const playlistItemWithInterstitialsMetadata = (pli) => { + const daterange = pli.attributes.attributes.daterange; + if (daterange && daterange["CLASS"] == "com.apple.hls.interstitial") { + return true; + } + return false; +}; + +const appendHlsInterstitialLineWithCUE = (m3u8Str, data) => { + // FOR LIVE AND CUED HLS-INTERSTITIAL TAGS, Place them at the bottom of each window + if (data) { + const dateRangeAttributes = Object.keys(data) + .map((key) => daterangeAttribute(key, data[key])) + .join(","); + m3u8Str += "#EXT-X-DATERANGE:" + dateRangeAttributes + "\n"; + } + return m3u8Str; +} + module.exports = { daterangeAttribute, keysToM3u8, @@ -230,5 +250,7 @@ module.exports = { findIndexReversed, findBottomSegItem, fixedNumber, - inspectForVodTransition + inspectForVodTransition, + playlistItemWithInterstitialsMetadata, + appendHlsInterstitialLineWithCUE } \ No newline at end of file