Skip to content

Commit

Permalink
Fix: Extend HLS Interstitial Tag Insertion (#50)
Browse files Browse the repository at this point in the history
* interstitial: handle more attributes and set start-date more accurately

* fix if statement

* update unit tests
  • Loading branch information
Nfrederiksen authored Nov 25, 2024
1 parent c08e6fd commit 5073173
Show file tree
Hide file tree
Showing 2 changed files with 108 additions and 38 deletions.
120 changes: 92 additions & 28 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ const NOT_MULTIVARIANT_ERROR_MSG = "Error: Source is Not a Multivariant Manifest
let DUMMY_SUBTITLE_COUNT = 0;
const getDummySubtitleSegmentId = () => {
return DUMMY_SUBTITLE_COUNT++;
}
};

const findNearestGroupAndLang = (_group, _language, _playlist) => {
const groups = Object.keys(_playlist);
Expand Down Expand Up @@ -93,7 +93,6 @@ class HLSSpliceVod {
}

this.cmafMapUri = { video: {}, audio: {}, subtitle: {} };

}

loadMasterManifest(_injectMasterManifest, _injectMediaManifest, _injectAudioManifest, _injectSubtitleManifest) {
Expand Down Expand Up @@ -214,15 +213,22 @@ class HLSSpliceVod {
subtitleItemUri = subtitleItem.get("uri");
}
const subtitleItemGroupId = subtitleItem.get("group-id");
const subtitleItemLanguage = subtitleItem.get("language") ? subtitleItem.get("language") : subtitleItem.get("name");
const subtitleItemLanguage = subtitleItem.get("language")
? subtitleItem.get("language")
: subtitleItem.get("name");
if (loadedSubtitleGroupLangs.includes(`${subtitleItemGroupId}-${subtitleItemLanguage}`)) {
continue;
} else {
loadedSubtitleGroupLangs.push(`${subtitleItemGroupId}-${subtitleItemLanguage}`);
}
const subtitleManifestUrl = url.resolve(baseUrl, subtitleItemUri);
mediaManifestPromises.push(
this.loadSubtitleManifest(subtitleManifestUrl, subtitleItemGroupId, subtitleItemLanguage, _injectSubtitleManifest)
this.loadSubtitleManifest(
subtitleManifestUrl,
subtitleItemGroupId,
subtitleItemLanguage,
_injectSubtitleManifest
)
);
}
Promise.all(mediaManifestPromises).then(resolve).catch(reject);
Expand All @@ -243,16 +249,16 @@ class HLSSpliceVod {
_createFakeSubtitles(videoPlaylist) {
let bw = Object.keys(videoPlaylist)[0];

const [nearestGroup, nearestLang] = findNearestGroupAndLang("temp", "temp", this.playlistsSubtitle)
const [nearestGroup, nearestLang] = findNearestGroupAndLang("temp", "temp", this.playlistsSubtitle);
let subtitleItems = {};
subtitleItems[nearestGroup] = {};
subtitleItems[nearestGroup][nearestLang] = {};

const vp = videoPlaylist[bw]
const vp = videoPlaylist[bw];

let m3u = m3u8.M3U.create();

vp.items.PlaylistItem.forEach(element => m3u.addPlaylistItem(element.properties))
vp.items.PlaylistItem.forEach((element) => m3u.addPlaylistItem(element.properties));

subtitleItems[nearestGroup][nearestLang] = m3u;
const playlist = subtitleItems[nearestGroup][nearestLang];
Expand All @@ -263,14 +269,17 @@ class HLSSpliceVod {
for (let index = 0; index < playlist.items.PlaylistItem.length; index++) {
if (playlist.items.PlaylistItem[index].get("duration")) {
duration += playlist.items.PlaylistItem[index].get("duration");
playlist.items.PlaylistItem[index].set("uri", this.dummySubtitleEndpoint + `?id=${getDummySubtitleSegmentId()}`);
playlist.items.PlaylistItem[index].set(
"uri",
this.dummySubtitleEndpoint + `?id=${getDummySubtitleSegmentId()}`
);
}
}
return [subtitleItems, duration];
}

_insertAdAtExtraMedia(startOffset, offset, playlists, adPlaylists, targetDuration, adDuration, isPostRoll) {
let groups = Object.keys(playlists)
let groups = Object.keys(playlists);
if (isPostRoll) {
let duration = 0;
const langs = Object.keys(playlists[groups[0]]);
Expand Down Expand Up @@ -333,7 +342,14 @@ class HLSSpliceVod {
}
}

insertAdAt(offset, adMasterManifestUri, _injectAdMasterManifest, _injectAdMediaManifest, _injectAdAudioManifest, _injectAdSubtitleManifest) {
insertAdAt(
offset,
adMasterManifestUri,
_injectAdMasterManifest,
_injectAdMediaManifest,
_injectAdAudioManifest,
_injectAdSubtitleManifest
) {
this.ad = {};
return new Promise((resolve, reject) => {
this._parseAdMasterManifest(
Expand Down Expand Up @@ -416,19 +432,35 @@ class HLSSpliceVod {
const audioGroups = Object.keys(this.playlistsAudio);
const adAudioGroups = Object.keys(ad.playlistAudio);
if (audioGroups.length > 0 && adAudioGroups.length > 0) {
this._insertAdAtExtraMedia(startOffset, offset, this.playlistsAudio, ad.playlistAudio, this.targetDurationAudio, ad.durationAudio, isPostRoll)
this._insertAdAtExtraMedia(
startOffset,
offset,
this.playlistsAudio,
ad.playlistAudio,
this.targetDurationAudio,
ad.durationAudio,
isPostRoll
);
}

if (subtitleGroups.length > 0) {
this._insertAdAtExtraMedia(startOffset, offset, this.playlistsSubtitle, ad.playlistSubtitle, this.targetDurationSubtitle, ad.durationSubtile, isPostRoll)
this._insertAdAtExtraMedia(
startOffset,
offset,
this.playlistsSubtitle,
ad.playlistSubtitle,
this.targetDurationSubtitle,
ad.durationSubtile,
isPostRoll
);
}
resolve();
})
.catch(reject);
});
}

_insertInterstitialAtExtraMedia(offset, id, uri, isAssetList, extraAttrs, plannedDuration, playlists) {
_insertInterstitialAtExtraMedia(offset, id, uri, isAssetList, extraAttrs, startDate, playlists, opts) {
const groups = Object.keys(playlists);
for (let i = 0; i < groups.length; i++) {
const group = groups[i];
Expand All @@ -442,12 +474,13 @@ class HLSSpliceVod {
while (pos < offset && idx < playlist.items.PlaylistItem.length) {
const plItem = playlist.items.PlaylistItem[idx];
pos += plItem.get("duration") * 1000;
idx++;
if (pos <= offset) {
idx++;
}
}
let startDate = new Date(1 + offset).toISOString();
let durationTag = "";
if (plannedDuration) {
durationTag = `,DURATION=${plannedDuration / 1000}`;
if (opts && opts.plannedDuration) {
durationTag = `,DURATION=${opts.plannedDuration / 1000}`;
}
if (isAssetList) {
playlist.items.PlaylistItem[idx].set(
Expand All @@ -469,7 +502,7 @@ class HLSSpliceVod {
if (this.bumperDuration) {
offset = this.bumperDuration + offset;
}

let startDate;
let extraAttrs = "";
if (opts) {
if (opts.resumeOffset !== undefined) {
Expand All @@ -481,6 +514,31 @@ class HLSSpliceVod {
if (opts.snap === "IN" || opts.snap === "OUT") {
extraAttrs += `,X-SNAP="${opts.snap}"`;
}
if (opts.restrict !== undefined) {
if (opts.restrict.includes("SKIP") || opts.restrict.includes("JUMP")) {
extraAttrs += `,X-RESTRICT="${opts.restrict}"`;
}
}
if (opts.contentmayvary === "YES" || opts.contentmayvary === "NO") {
extraAttrs += `,X-CONTENT-MAY-VARY="${opts.contentmayvary}"`;
}
if (opts.timelineoccupies === "POINT" || opts.timelineoccupies === "RANGE") {
extraAttrs += `,X-TIMELINE-OCCUPIES="${opts.timelineoccupies}"`;
}
if (opts.timelinestyle === "HIGHLIGHT" || opts.timelinestyle === "PRIMARY") {
extraAttrs += `,X-TIMELINE-STYLE="${opts.timelinestyle}"`;
}
if (opts.custombeacon !== undefined) {
let custombeacon = "";
if (opts.custombeacon.includes("%")) {
custombeacon = decodeURIComponent(opts.custombeacon);
} else {
custombeacon = opts.custombeacon;
}
if (custombeacon.charAt(0) === "X") {
extraAttrs += `,${custombeacon}`;
}
}
}

const bandwidths = Object.keys(this.playlists);
Expand All @@ -492,9 +550,11 @@ class HLSSpliceVod {
while (pos < offset && i < this.playlists[bw].items.PlaylistItem.length) {
const plItem = this.playlists[bw].items.PlaylistItem[i];
pos += plItem.get("duration") * 1000;
i++;
if (pos <= offset) {
i++;
}
}
let startDate = new Date(1 + offset).toISOString();
startDate = new Date(1 + Number(offset)).toISOString();
let durationTag = "";
if (opts && opts.plannedDuration) {
durationTag = `,DURATION=${opts.plannedDuration / 1000}`;
Expand All @@ -512,11 +572,9 @@ class HLSSpliceVod {
}
}

let plannedDuration = (opts && opts.plannedDuration) ? opts.plannedDuration : 0;

this._insertInterstitialAtExtraMedia(offset, id, uri, isAssetList, extraAttrs, plannedDuration, this.playlistsAudio)
this._insertInterstitialAtExtraMedia(offset, id, uri, isAssetList, extraAttrs, startDate, this.playlistsAudio, opts);

this._insertInterstitialAtExtraMedia(offset, id, uri, isAssetList, extraAttrs, plannedDuration, this.playlistsSubtitle)
this._insertInterstitialAtExtraMedia(offset, id, uri, isAssetList, extraAttrs, startDate, this.playlistsSubtitle, opts);

resolve();
});
Expand Down Expand Up @@ -585,9 +643,9 @@ class HLSSpliceVod {
this.playlists[bw].set("targetDuration", this.targetDuration);
}

this._insertBumperExtraMedia(this.playlistsAudio, bumper.playlistAudio, this.targetDurationAudio)
this._insertBumperExtraMedia(this.playlistsAudio, bumper.playlistAudio, this.targetDurationAudio);

this._insertBumperExtraMedia(this.playlistsSubtitle, bumper.playlistSubtitle, this.targetDurationSubtitle)
this._insertBumperExtraMedia(this.playlistsSubtitle, bumper.playlistSubtitle, this.targetDurationSubtitle);

resolve();
})
Expand Down Expand Up @@ -901,7 +959,7 @@ class HLSSpliceVod {
this.targetDurationSubtitle = targetDuration;
}
this.playlistsSubtitle[group][lang].set("targetDuration", this.targetDurationSubtitle);

const initSegUri = this._getCmafMapUri(m3u, subtitleManifestUri, this.baseUrl);
if (initSegUri) {
if (!this.cmafMapUri.subtitle[group]) {
Expand All @@ -925,7 +983,13 @@ class HLSSpliceVod {
});
}

_parseAdMasterManifest(manifestUri, _injectAdMasterManifest, _injectAdMediaManifest, _injectAdAudioManifest, _injectAdSubtitleManifest) {
_parseAdMasterManifest(
manifestUri,
_injectAdMasterManifest,
_injectAdMediaManifest,
_injectAdAudioManifest,
_injectAdSubtitleManifest
) {
return new Promise((resolve, reject) => {
let ad = {};
const parser = m3u8.createStream();
Expand Down
26 changes: 16 additions & 10 deletions spec/hls_splice_spec.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
const HLSSpliceVod = require("../index.js");
const fs = require("fs");

const ll = (log_lines) => {
log_lines.map((line, idx) => console.log(line, idx));
}

describe("HLSSpliceVod", () => {
let mockMasterManifest;
let mockMediaManifest;
Expand Down Expand Up @@ -439,7 +443,7 @@ describe("HLSSpliceVod", () => {
.then(() => {
const m3u8 = mockVod.getMediaManifest(4497000);
const lines = m3u8.split("\n");
expect(lines[12]).toEqual(
expect(lines[10]).toEqual(
'#EXT-X-DATERANGE:ID="001",CLASS="com.apple.hls.interstitial",START-DATE="1970-01-01T00:00:16.001Z",X-ASSET-LIST="http://mock.com/assetlist"'
);
done();
Expand All @@ -456,7 +460,7 @@ describe("HLSSpliceVod", () => {
.then(() => {
const m3u8 = mockVod.getMediaManifest(4497000);
const lines = m3u8.split("\n");
expect(lines[12]).toEqual(
expect(lines[10]).toEqual(
'#EXT-X-DATERANGE:ID="001",CLASS="com.apple.hls.interstitial",START-DATE="1970-01-01T00:00:16.001Z",X-ASSET-LIST="/assetlist/sdfsdfjlsdfsdf"'
);
done();
Expand Down Expand Up @@ -1379,12 +1383,12 @@ describe("HLSSpliceVod with Demuxed Audio Tracks,", () => {
.then(() => {
const m3u8 = mockVod.getMediaManifest(4497000);
let lines = m3u8.split("\n");
expect(lines[12]).toEqual(
expect(lines[10]).toEqual(
'#EXT-X-DATERANGE:ID="001",CLASS="com.apple.hls.interstitial",START-DATE="1970-01-01T00:00:16.001Z",X-ASSET-LIST="http://mock.com/assetlist"'
);
const m3u8Audio = mockVod.getAudioManifest("stereo", "sv");
lines = m3u8Audio.split("\n");
expect(lines[12]).toEqual(
expect(lines[10]).toEqual(
'#EXT-X-DATERANGE:ID="001",CLASS="com.apple.hls.interstitial",START-DATE="1970-01-01T00:00:16.001Z",X-ASSET-LIST="http://mock.com/assetlist"'
);
done();
Expand All @@ -1401,12 +1405,12 @@ describe("HLSSpliceVod with Demuxed Audio Tracks,", () => {
.then(() => {
const m3u8 = mockVod.getMediaManifest(4497000);
let lines = m3u8.split("\n");
expect(lines[12]).toEqual(
expect(lines[10]).toEqual(
'#EXT-X-DATERANGE:ID="001",CLASS="com.apple.hls.interstitial",START-DATE="1970-01-01T00:00:16.001Z",X-ASSET-LIST="/assetlist/sdfsdfjlsdfsdf"'
);
const m3u8Audio = mockVod.getAudioManifest("stereo", "sv");
lines = m3u8Audio.split("\n");
expect(lines[12]).toEqual(
expect(lines[10]).toEqual(
'#EXT-X-DATERANGE:ID="001",CLASS="com.apple.hls.interstitial",START-DATE="1970-01-01T00:00:16.001Z",X-ASSET-LIST="/assetlist/sdfsdfjlsdfsdf"'
);
done();
Expand Down Expand Up @@ -1543,6 +1547,7 @@ describe("HLSSpliceVod with Demuxed Audio Tracks,", () => {
.then(() => {
const m3u8 = mockVod.getMediaManifest(4497000);
let lines = m3u8.split("\n");

expect(lines[12]).toEqual(
'#EXT-X-DATERANGE:ID="001",CLASS="com.apple.hls.interstitial",START-DATE="1970-01-01T00:00:18.001Z",X-ASSET-URI="http://mock.com/asseturi",X-SNAP="OUT"'
);
Expand Down Expand Up @@ -1980,13 +1985,12 @@ test-audio=256000-6.m4s`;
.then(() => {
const m3u8 = mockVod.getMediaManifest(4497000);
let lines = m3u8.split("\n");
expect(lines[22]).toEqual(
expect(lines[20]).toEqual(
'#EXT-X-DATERANGE:ID="001",CLASS="com.apple.hls.interstitial",START-DATE="1970-01-01T00:00:16.001Z",X-ASSET-LIST="http://mock.com/assetlist"'
);
const m3u8Audio = mockVod.getAudioManifest("stereo", "sv");
lines = m3u8Audio.split("\n");
//lines.map((l, i) => console.log(l, i));
expect(lines[28]).toEqual(
expect(lines[26]).toEqual(
'#EXT-X-DATERANGE:ID="001",CLASS="com.apple.hls.interstitial",START-DATE="1970-01-01T00:00:16.001Z",X-ASSET-LIST="http://mock.com/assetlist"'
);
done();
Expand All @@ -2005,12 +2009,14 @@ test-audio=256000-6.m4s`;
.then(() => {
const m3u8 = mockVod.getMediaManifest(4497000);
let lines = m3u8.split("\n");

expect(lines[22]).toEqual(
'#EXT-X-DATERANGE:ID="001",CLASS="com.apple.hls.interstitial",START-DATE="1970-01-01T00:00:18.001Z",DURATION=30,X-ASSET-LIST="http://mock.com/asseturi"'
);
const m3u8Audio = mockVod.getAudioManifest("stereo", "sv");
lines = m3u8Audio.split("\n");
expect(lines[30]).toEqual(

expect(lines[28]).toEqual(
'#EXT-X-DATERANGE:ID="001",CLASS="com.apple.hls.interstitial",START-DATE="1970-01-01T00:00:18.001Z",DURATION=30,X-ASSET-LIST="http://mock.com/asseturi"'
);
done();
Expand Down

0 comments on commit 5073173

Please sign in to comment.