diff --git a/sandbox/public/index_extension.html b/sandbox/public/index_extension.html index 7a254da4b..6b8c38a7c 100755 --- a/sandbox/public/index_extension.html +++ b/sandbox/public/index_extension.html @@ -19,81 +19,85 @@ AEP Web SDK - Tags Extension - +
- +
- - - - + + + +
diff --git a/sandbox/public/media-collection/clickbaby.mp4 b/sandbox/public/media-collection/clickbaby.mp4 new file mode 100644 index 000000000..13acb9849 Binary files /dev/null and b/sandbox/public/media-collection/clickbaby.mp4 differ diff --git a/sandbox/public/media-collection/streaming-media-launch-extension.html b/sandbox/public/media-collection/streaming-media-launch-extension.html new file mode 100644 index 000000000..66a838f38 --- /dev/null +++ b/sandbox/public/media-collection/streaming-media-launch-extension.html @@ -0,0 +1,44 @@ + + + + + + + + + + + Mock website hosting Alloy using launch + + + + + +
+

+ Collect streaming media events using automatic session handling option. +

+ +

+ Collect streaming media events using manual session handling option. +

+ +
+ + diff --git a/sandbox/public/media-collection/streaming-media.html b/sandbox/public/media-collection/streaming-media.html new file mode 100644 index 000000000..b06d19c23 --- /dev/null +++ b/sandbox/public/media-collection/streaming-media.html @@ -0,0 +1,129 @@ + + + + + + + + + + + Mock website hosting Alloy + + + + + + + + + + + + + + +
+

+ Collect streaming media events using automatic session handling option. +

+ +

+ Collect streaming media events using manual session handling option. +

+ +

Collect streaming media events using legacy Media Analytics API.

+ +
+
+ + diff --git a/sandbox/public/media-collection/video-player-1.js b/sandbox/public/media-collection/video-player-1.js new file mode 100644 index 000000000..c81416866 --- /dev/null +++ b/sandbox/public/media-collection/video-player-1.js @@ -0,0 +1,273 @@ +const createVideoPlayer = videoPlayerId => { + const videoPlayer = document.getElementById(videoPlayerId); + const playerSettings = { + playerName: "samplePlayerName", + videoId: "123", + videoName: "", + videoLoaded: false, + clock: null + }; + + return { playerSettings, videoPlayer }; +}; + +const getVideoPlayedPlayhead = videoPlayer => { + return videoPlayer.currentTime; +}; +const createAddSampleEventsBasedOnPlayhead = videoPlayer => { + return () => { + const playhead = getVideoPlayedPlayhead(videoPlayer); + if (playhead > 1 && playhead < 2) { + console.log("chapter start"); + window.alloy("sendMediaEvent", { + playerId: "episode-1", + xdm: { + eventType: "media.chapterStart", + mediaCollection: { + chapterDetails: { + friendlyName: "Chapter 1", + length: 20, + index: 1, + offset: 0 + } + } + } + }); + } + + if (playhead > 20 && playhead < 21) { + window.alloy("sendMediaEvent", { + playerId: "episode-1", + xdm: { + eventType: "media.chapterComplete" + } + }); + } + + if (playhead > 21 && playhead < 22) { + window.alloy("sendMediaEvent", { + playerId: "episode-1", + xdm: { + eventType: "media.adBreakStart", + mediaCollection: { + advertisingPodDetails: { + friendlyName: "Mid-roll", + offset: 0, + index: 1 + } + } + } + }); + window.alloy("sendMediaEvent", { + playerId: "episode-1", + xdm: { + eventType: "media.adStart", + mediaCollection: { + advertisingDetails: { + friendlyName: "Ad 1", + name: "/uri-reference/001", + length: 10, + advertiser: "Adobe Marketing", + campaignID: "Adobe Analytics", + creativeID: "creativeID", + creativeURL: "https://creativeurl.com", + placementID: "placementID", + siteID: "siteID", + podPosition: 11, + playerName: "HTML5 player" // ?? why do we have it here as well? same as the one from session start event? + } + } + } + }); + } + + if (playhead > 25 && playhead < 26) { + window.alloy("sendMediaEvent", { + playerId: "episode-1", + xdm: { + eventType: "media.adComplete" + } + }); + } + + if (playhead > 26 && playhead < 27) { + window.alloy("sendMediaEvent", { + playerId: "episode-1", + xdm: { + eventType: "media.adStart", + mediaCollection: { + advertisingDetails: { + friendlyName: "Ad 2", + name: "/uri-reference/002", + length: 10, + advertiser: "Adobe Marketing 2", + campaignID: "Adobe Analytics 2", + creativeID: "creativeID2", + creativeURL: "https://creativeurl.com", + placementID: "placementID", + siteID: "siteID", + podPosition: 17, + playerName: "HTML5 player" // ?? why do we have it here as well? same as the one from session start event? + } + } + } + }); + } + + if (playhead > 29 && playhead < 30) { + window.alloy("sendMediaEvent", { + playerId: "episode-1", + xdm: { + eventType: "media.adSkip" + } + }); + window.alloy("sendMediaEvent", { + playerId: "episode-1", + xdm: { + eventType: "media.adBreakComplete" + } + }); + } + + if (playhead > 30 && playhead < 31) { + window.alloy("sendMediaEvent", { + playerId: "episode-1", + xdm: { + eventType: "media.chapterStart", + mediaCollection: { + chapterDetails: { + friendlyName: "Chapter 2", + length: 30, + index: 2, + offset: 0 + } + } + } + }); + } + + if (playhead > 59 && playhead < 60) { + window.alloy("sendMediaEvent", { + playerId: "episode-1", + xdm: { + eventType: "media.chapterComplete" + } + }); + } + }; +}; +document.addEventListener("DOMContentLoaded", async function(event) { + const { playerSettings, videoPlayer } = createVideoPlayer("media-movie"); + + let sessionPromise; + videoPlayer.addEventListener("playing", function() { + if (!playerSettings.videoLoaded) { + sessionPromise = window + .alloy("createMediaSession", { + // start media session + playerId: "episode-1", // unique identifier + xdm: { + eventType: "media.sessionStart", + mediaCollection: { + sessionDetails: { + dayPart: "dayPart", + mvpd: "test-mvpd", + authorized: "true", + label: "test-label", + station: "test-station", + publisher: "test-media-publisher", + author: "test-author", + name: "Friends", + friendlyName: "FriendlyName", + assetID: "/uri-reference", + originator: "David Crane and Marta Kauffman", + episode: "4933", + genre: "Comedy", + rating: "4.8/5", + season: "1521", + show: "Friends Series", + length: 60, + firstDigitalDate: "releaseDate", + artist: "test-artist", + hasResume: false, + album: "test-album", + firstAirDate: "firstAirDate", + showType: "sitcom", + streamFormat: "streamFormat", + streamType: "video", + adLoad: "adLoadType", + channel: "broadcastChannel", + contentType: "VOD", + feed: "sourceFeed", + network: "test-network" + } + } + }, + getPlayerDetails: () => { + const getPlayhead = getVideoPlayedPlayhead(videoPlayer); + return { + playhead: getPlayhead + }; + } + }) + .then(sessionId => { + const sampleDemoEventTriggerer = createAddSampleEventsBasedOnPlayhead( + videoPlayer + ); + playerSettings.clock = setInterval(sampleDemoEventTriggerer, 1000); + + return sessionId; + }); + + sessionPromise + .then(result => { + console.log("sessionPromise result", result); + }) + .catch(error => { + console.log("error", error); + }); + playerSettings.videoLoaded = true; + } + + window.alloy("sendMediaEvent", { + playerId: "episode-1", + xdm: { + eventType: "media.play" + } + }); + }); + videoPlayer.addEventListener("seeking", function() { + console.log("seeking", videoPlayer); + }); + videoPlayer.addEventListener("seeked", function() { + console.log("seeked", videoPlayer); + }); + videoPlayer.addEventListener("pause", function() { + // console.log("pause", videoPlayer, session); + // session.then(result => { + window.alloy("sendMediaEvent", { + playerId: "episode-1", + xdm: { + eventType: "media.pauseStart" + } + }); + // }); + }); + videoPlayer.addEventListener("ended", function() { + // session.then(result => { + window + .alloy("sendMediaEvent", { + playerId: "episode-1", + xdm: { + eventType: "media.sessionComplete" + } + }) + .then(result => { + console.log("sessionComplete result", result); + }); + + // reset player state + clearInterval(playerSettings.clock); + playerSettings.videoLoaded = false; + }); +}); diff --git a/sandbox/public/media-collection/video-player-2.js b/sandbox/public/media-collection/video-player-2.js new file mode 100644 index 000000000..bd3bf7bf6 --- /dev/null +++ b/sandbox/public/media-collection/video-player-2.js @@ -0,0 +1,300 @@ +const createSecondVideoPlayer = secondVideoPlayerId => { + const secondVideoPlayer = document.getElementById(secondVideoPlayerId); + + const secondPlayerSettings = { + playerName: "samplePlayerName", + videoId: "123", + videoName: "", + videoLoaded: false, + clock: null + }; + + return { secondPlayerSettings, secondVideoPlayer }; +}; +const getDemoVideoPlayedPlayhead = secondVideoPlayer => { + return secondVideoPlayer.currentTime; +}; +const createSecondSampleEventsBasedOnPlayhead = ({ + secondVideoPlayer, + sessionId +}) => { + return () => { + const playhead = getDemoVideoPlayedPlayhead(secondVideoPlayer); + if (playhead > 1 && playhead < 2) { + window.alloy("sendMediaEvent", { + xdm: { + eventType: "media.chapterStart", + mediaCollection: { + chapterDetails: { + friendlyName: "Chapter 1", + length: 20, + index: 1, + offset: 0 + }, + sessionID: sessionId, + playhead: parseInt(playhead, 10) + } + } + }); + } + + if (playhead > 20 && playhead < 21) { + window.alloy("sendMediaEvent", { + xdm: { + eventType: "media.chapterComplete", + mediaCollection: { + sessionID: sessionId, + playhead: parseInt(playhead, 10) + } + } + }); + } + + if (playhead > 21 && playhead < 22) { + window.alloy("sendMediaEvent", { + xdm: { + eventType: "media.adBreakStart", + mediaCollection: { + advertisingPodDetails: { + friendlyName: "Mid-roll", + offset: 0, + index: 1 + }, + sessionID: sessionId, + playhead: parseInt(playhead, 10) + } + } + }); + window.alloy("sendMediaEvent", { + xdm: { + eventType: "media.adStart", + mediaCollection: { + advertisingDetails: { + friendlyName: "Ad 1", + name: "/uri-reference/001", + length: 10, + advertiser: "Adobe Marketing", + campaignID: "Adobe Analytics", + creativeID: "creativeID", + creativeURL: "https://creativeurl.com", + placementID: "placementID", + siteID: "siteID", + podPosition: 11, + playerName: "HTML5 player" // ?? why do we have it here as well? same as the one from session start event? + }, + sessionID: sessionId, + playhead: parseInt(playhead, 10) + } + } + }); + } + + if (playhead > 25 && playhead < 26) { + window.alloy("sendMediaEvent", { + xdm: { + eventType: "media.adComplete", + mediaCollection: { + sessionID: sessionId, + playhead: parseInt(playhead, 10) + } + } + }); + } + + if (playhead > 26 && playhead < 27) { + window.alloy("sendMediaEvent", { + xdm: { + eventType: "media.adStart", + mediaCollection: { + advertisingDetails: { + friendlyName: "Ad 2", + name: "/uri-reference/002", + length: 10, + advertiser: "Adobe Marketing 2", + campaignID: "Adobe Analytics 2", + creativeID: "creativeID2", + creativeURL: "https://creativeurl.com", + placementID: "placementID", + siteID: "siteID", + podPosition: 17, + playerName: "HTML5 player" // ?? why do we have it here as well? same as the one from session start event? + }, + sessionID: sessionId, + playhead: parseInt(playhead, 10) + } + } + }); + } + + if (playhead > 29 && playhead < 30) { + window.alloy("sendMediaEvent", { + xdm: { + eventType: "media.adSkip", + mediaCollection: { + sessionID: sessionId, + playhead: parseInt(playhead, 10) + } + } + }); + window.alloy("sendMediaEvent", { + xdm: { + eventType: "media.adBreakComplete", + mediaCollection: { + sessionID: sessionId, + playhead: parseInt(playhead, 10) + } + } + }); + } + + if (playhead > 30 && playhead < 31) { + window.alloy("sendMediaEvent", { + xdm: { + eventType: "media.chapterStart", + mediaCollection: { + chapterDetails: { + friendlyName: "Chapter 2", + length: 30, + index: 2, + offset: 0 + }, + sessionID: sessionId, + playhead: parseInt(playhead, 10) + } + } + }); + } + + if (playhead > 59 && playhead < 60) { + window.alloy("sendMediaEvent", { + xdm: { + eventType: "media.chapterComplete", + mediaCollection: { + sessionID: sessionId, + playhead: parseInt(playhead, 10) + } + } + }); + } + }; +}; +document.addEventListener("DOMContentLoaded", async function(event) { + const { secondPlayerSettings, secondVideoPlayer } = createSecondVideoPlayer( + "media-second-movie" + ); + + let sessionPromise; + secondVideoPlayer.addEventListener("playing", function() { + if (!secondPlayerSettings.videoLoaded) { + sessionPromise = window + .alloy("createMediaSession", { + xdm: { + eventType: "media.sessionStart", + mediaCollection: { + sessionDetails: { + dayPart: "dayPart", + mvpd: "test-mvpd", + authorized: "true", + label: "test-label", + station: "test-station", + publisher: "test-media-publisher", + author: "test-author", + name: "Friends", + friendlyName: "FriendlyName", + assetID: "/uri-reference", + originator: "David Crane and Marta Kauffman", + episode: "4933", + genre: "Comedy", + rating: "4.8/5", + season: "1521", + show: "Friends Series", + length: 60, + firstDigitalDate: "releaseDate", + artist: "test-artist", + hasResume: false, + album: "test-album", + firstAirDate: "firstAirDate", + showType: "sitcom", + streamFormat: "streamFormat", + streamType: "video", + adLoad: "adLoadType", + channel: "broadcastChannel", + contentType: "VOD", + feed: "sourceFeed", + network: "test-network" + }, + playhead: 0 + } + } + }) + .then(result => { + const { sessionId } = result; + const sampleDemoEventTriggerer = createSecondSampleEventsBasedOnPlayhead( + { secondVideoPlayer, sessionId } + ); + secondPlayerSettings.clock = setInterval( + sampleDemoEventTriggerer, + 1000 + ); + return sessionId; + }) + .catch(error => { + console.log("error", error); + }); + + secondPlayerSettings.videoLoaded = true; + } + + sessionPromise.then(sessionId => { + window.alloy("sendMediaEvent", { + xdm: { + eventType: "media.play", + mediaCollection: { + playhead: parseInt(getDemoVideoPlayedPlayhead(this), 10), + sessionID: sessionId + } + } + }); + }); + }); + + secondVideoPlayer.addEventListener("seeking", function() { + console.log("seeking", secondVideoPlayer); + }); + secondVideoPlayer.addEventListener("seeked", function() { + console.log("seeked", secondVideoPlayer); + }); + secondVideoPlayer.addEventListener("pause", function() { + sessionPromise.then(sessionId => { + window.alloy("sendMediaEvent", { + xdm: { + eventType: "media.pauseStart", + mediaCollection: { + playhead: parseInt(getDemoVideoPlayedPlayhead(this), 10), + sessionID: sessionId + } + } + }); + }); + }); + secondVideoPlayer.addEventListener("ended", function() { + sessionPromise.then(sessionId => { + window + .alloy("sendMediaEvent", { + xdm: { + eventType: "media.sessionComplete", + mediaCollection: { + playhead: parseInt(getDemoVideoPlayedPlayhead(this), 10), + sessionID: sessionId + } + } + }) + .then(result => { + console.log("sessionComplete result", result); + }); + }); + // reset player state + clearInterval(secondPlayerSettings.clock); + secondPlayerSettings.videoLoaded = false; + }); +}); diff --git a/sandbox/public/media-collection/video-player-3.js b/sandbox/public/media-collection/video-player-3.js new file mode 100644 index 000000000..67d3126e7 --- /dev/null +++ b/sandbox/public/media-collection/video-player-3.js @@ -0,0 +1,157 @@ +const createThirdVideoPlayer = thirdVideoPlayerId => { + const thirdVideoPlayer = document.getElementById(thirdVideoPlayerId); + + const thirdPlayerSettings = { + playerName: "samplePlayerName", + videoId: "123", + videoName: "", + videoLoaded: false, + clock: null + }; + + return { thirdPlayerSettings, thirdVideoPlayer }; +}; + +const createAddSampleEventsBasedOnVideoPlayhead = ({ + trackerInstance, + Media, + videoPlayer +}) => { + const playhead = videoPlayer.currentTime; + if (playhead > 1 && playhead < 2) { + const chapterContextData = { + segmentType: "Sample segment type" + }; + const chapterInfo = Media.createChapterObject("chapterNumber1", 2, 18, 1); + trackerInstance.trackEvent( + Media.Event.ChapterStart, + chapterInfo, + chapterContextData + ); + } + + if (playhead > 20 && playhead < 21) { + trackerInstance.trackEvent(Media.Event.ChapterComplete); + } + + if (playhead > 21 && playhead < 22) { + const adBreakInfo = Media.createAdBreakObject("addBreakName", 12, 12); + const adInfo = Media.createAdObject("firstAdd", "123", 10, 10); + + const adContextData = { + affiliate: "Sample affiliate", + campaign: "Sample ad campaign" + }; + + // Set standard Ad Metadata + adContextData[Media.AdMetadataKeys.Advertiser] = "Sample Advertiser"; + adContextData[Media.AdMetadataKeys.CampaignId] = "Sample Campaign"; + + trackerInstance.trackEvent(Media.Event.AdBreakStart, adBreakInfo); + trackerInstance.trackEvent(Media.Event.AdStart, adInfo, adContextData); + } + + if (playhead > 25 && playhead < 26) { + trackerInstance.trackEvent(Media.Event.AdComplete); + } + + if (playhead > 26 && playhead < 27) { + const secondAdInfo = Media.createAdObject("secondAdd", "hjui", 10, 10); + + const adContextData = { + affiliate: "Sample affiliate 2", + campaign: "Sample ad campaign 2" + }; + + // Set standard Ad Metadata + adContextData[Media.AdMetadataKeys.Advertiser] = "Sample Advertiser 2"; + adContextData[Media.AdMetadataKeys.CampaignId] = "Sample Campaign 2"; + trackerInstance.trackEvent( + Media.Event.AdStart, + secondAdInfo, + adContextData + ); + } + + if (playhead > 29 && playhead < 30) { + trackerInstance.trackEvent(Media.Event.AdSkip); + trackerInstance.trackEvent(Media.Event.AdBreakComplete); + } +}; + +document.addEventListener("DOMContentLoaded", async function(event) { + const { thirdPlayerSettings, thirdVideoPlayer } = createThirdVideoPlayer( + "media-third-movie" + ); + const Media = await window.alloy("getMediaAnalyticsTracker", {}); + console.log("Media", Media); + const trackerInstance = Media.getInstance(); + const trackerInstance2 = Media.getInstance(); + console.log("trackerInstance2", trackerInstance2); + thirdVideoPlayer.addEventListener("playing", function() { + const mediaInfo = Media.createMediaObject( + "NinasVideoName", + "Ninas player video", + 60, + Media.StreamType.VOD, + Media.MediaType.Video + ); + if (!thirdPlayerSettings.videoLoaded) { + const contextData = { + isUserLoggedIn: "false", + tvStation: "Sample TV station", + programmer: "Sample programmer", + assetID: "/uri-reference" + }; + + // Set standard Video Metadata + contextData[Media.VideoMetadataKeys.Episode] = "Sample Episode"; + contextData[Media.VideoMetadataKeys.Show] = "Sample Show"; + + trackerInstance.trackSessionStart(mediaInfo, contextData); + trackerInstance2.trackSessionStart(mediaInfo, contextData); + trackerInstance.trackEvent(Media.Event.BufferStart); + trackerInstance.trackEvent(Media.Event.BufferComplete); + // StateStart (ex: Mute is switched on) + const stateObject = Media.createStateObject(Media.PlayerState.Mute); + console.log("stateObject", stateObject); + trackerInstance.trackEvent(Media.Event.StateStart, stateObject); + + // StateEnd (ex: Mute is switched off) + trackerInstance.trackEvent(Media.Event.StateEnd, stateObject); + + const qoeObject = Media.createQoEObject(1000000, 24, 25, 10); + trackerInstance.updateQoEObject(qoeObject); + thirdPlayerSettings.videoLoaded = true; + trackerInstance.trackEvent(window.Media.Event.BitrateChange); + + thirdPlayerSettings.clock = setInterval(() => { + trackerInstance.updatePlayhead(thirdVideoPlayer.currentTime); + createAddSampleEventsBasedOnVideoPlayhead({ + trackerInstance, + Media, + videoPlayer: thirdVideoPlayer + }); + }, 1000); + } else { + trackerInstance.trackPlay(); + } + }); + + thirdVideoPlayer.addEventListener("seeking", function() { + console.log("seeking", thirdVideoPlayer); + }); + thirdVideoPlayer.addEventListener("seeked", function() { + console.log("seeked", thirdVideoPlayer); + }); + thirdVideoPlayer.addEventListener("pause", function() { + trackerInstance.trackPause(); + }); + + thirdVideoPlayer.addEventListener("ended", function() { + // reset player state + trackerInstance.trackSessionEnd(); + clearInterval(thirdPlayerSettings.clock); + thirdPlayerSettings.videoLoaded = false; + }); +}); diff --git a/sandbox/src/useSendPageViewEvent.js b/sandbox/src/useSendPageViewEvent.js index 13fbb40fe..20e45f270 100644 --- a/sandbox/src/useSendPageViewEvent.js +++ b/sandbox/src/useSendPageViewEvent.js @@ -28,7 +28,8 @@ export default ({ if (viewName) { xdm.web = { webPageDetails: { - viewName + viewName, + pageName: viewName } }; } diff --git a/src/components/LegacyMediaAnalytics/constants/constants.js b/src/components/LegacyMediaAnalytics/constants/constants.js new file mode 100644 index 000000000..16566a29d --- /dev/null +++ b/src/components/LegacyMediaAnalytics/constants/constants.js @@ -0,0 +1,146 @@ +/* +Copyright 2023 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +export const MEDIA_TYPE = { + Video: "video", + Audio: "audio" +}; +export const STREAM_TYPE = { + VOD: "vod", + Live: "live", + Linear: "linear", + Podcast: "podcast", + Audiobook: "audiobook", + AOD: "aod" +}; +export const PLAYER_STATE = { + FullScreen: "fullScreen", + ClosedCaption: "closedCaptioning", + Mute: "mute", + PictureInPicture: "pictureInPicture", + InFocus: "inFocus" +}; + +export const EVENT = { + /** + * Constant defining event type for AdBreak start + */ + AdBreakStart: "adBreakStart", + /** + * Constant defining event type for AdBreak complete + */ + AdBreakComplete: "adBreakComplete", + /** + * Constant defining event type for Ad start + */ + AdStart: "adStart", + /** + * Constant defining event type for Ad complete + */ + AdComplete: "adComplete", + /** + * Constant defining event type for Ad skip + */ + AdSkip: "adSkip", + /** + * Constant defining event type for Chapter start + */ + ChapterStart: "chapterStart", + /** + * Constant defining event type for Chapter complete + */ + ChapterComplete: "chapterComplete", + /** + * Constant defining event type for Chapter skip + */ + ChapterSkip: "chapterSkip", + /** + * Constant defining event type for Seek start + */ + SeekStart: "seekStart", + /** + * Constant defining event type for Seek complete + */ + SeekComplete: "seekComplete", + /** + * Constant defining event type for Buffer start + */ + BufferStart: "bufferStart", + /** + * Constant defining event type for Buffer complete + */ + BufferComplete: "bufferComplete", + /** + * Constant defining event type for change in Bitrate + */ + BitrateChange: "bitrateChange", + /** + * Constant defining event type for Custom State Start + */ + StateStart: "stateStart", + /** + * Constant defining event type for Custom State End + */ + StateEnd: "stateEnd" +}; +export const MEDIA_EVENTS_INTERNAL = { + SessionStart: "sessionStart", + SessionEnd: "sessionEnd", + SessionComplete: "sessionComplete", + Play: "play", + Pause: "pauseStart", + Error: "error", + StateUpdate: "statesUpdate" +}; + +export const MEDIA_OBJECT_KEYS = { + MediaResumed: "media.resumed", + GranularAdTracking: "media.granularadtracking" +}; + +export const VIDEO_METADATA_KEYS = { + Show: "a.media.show", + Season: "a.media.season", + Episode: "a.media.episode", + AssetId: "a.media.asset", + Genre: "a.media.genre", + FirstAirDate: "a.media.airDate", + FirstDigitalDate: "a.media.digitalDate", + Rating: "a.media.rating", + Originator: "a.media.originator", + Network: "a.media.network", + ShowType: "a.media.type", + AdLoad: "a.media.adLoad", + MVPD: "a.media.pass.mvpd", + Authorized: "a.media.pass.auth", + DayPart: "a.media.dayPart", + Feed: "a.media.feed", + StreamFormat: "a.media.format" +}; + +export const AUDIO_METADATA_KEYS = { + Artist: "a.media.artist", + Album: "a.media.album", + Label: "a.media.label", + Author: "a.media.author", + Station: "a.media.station", + Publisher: "a.media.publisher" +}; + +export const AD_METADATA_KEYS = { + Advertiser: "a.media.ad.advertiser", + CampaignId: "a.media.ad.campaign", + CreativeId: "a.media.ad.creative", + PlacementId: "a.media.ad.placement", + SiteId: "a.media.ad.site", + CreativeUrl: "a.media.ad.creativeURL" +}; diff --git a/src/components/LegacyMediaAnalytics/constants/mediaKeysToXdmConverter.js b/src/components/LegacyMediaAnalytics/constants/mediaKeysToXdmConverter.js new file mode 100644 index 000000000..a6e214fbf --- /dev/null +++ b/src/components/LegacyMediaAnalytics/constants/mediaKeysToXdmConverter.js @@ -0,0 +1,47 @@ +/* +Copyright 2023 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +export const mediaToXdmKeys = { + "a.media.show": "show", + "a.media.season": "season", + "a.media.episode": "episode", + "a.media.asset": "assetID", + "a.media.genre": "genre", + "a.media.airDate": "firstAirDate", + "a.media.digitalDate": "firstDigitalDate", + "a.media.rating": "rating", + "a.media.originator": "originator", + "a.media.network": "network", + "a.media.type": "showType", + "a.media.adLoad": "adLoad", + "a.media.pass.mvpd": "mvpd", + "a.media.pass.auth": "authorized", + "a.media.dayPart": "dayPart", + "a.media.feed": "feed", + "a.media.format": "streamFormat", + "a.media.artist": "artist", + "a.media.album": "album", + "a.media.label": "label", + "a.media.author": "author", + "a.media.station": "station", + "a.media.publisher": "publisher", + "media.resumed": "hasResume" +}; + +export const adsToXdmKeys = { + "a.media.ad.advertiser": "advertiser", + "a.media.ad.campaign": "campaignID", + "a.media.ad.creative": "creativeID", + "a.media.ad.placement": "placementID", + "a.media.ad.site": "siteID", + "a.media.ad.creativeURL": "creativeURL" +}; diff --git a/src/components/LegacyMediaAnalytics/createGetInstance.js b/src/components/LegacyMediaAnalytics/createGetInstance.js new file mode 100644 index 000000000..c08e4195e --- /dev/null +++ b/src/components/LegacyMediaAnalytics/createGetInstance.js @@ -0,0 +1,241 @@ +/* +Copyright 2024 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ +import { EVENT, MEDIA_EVENTS_INTERNAL } from "./constants/constants"; +import { + includes, + isEmptyObject, + isNonEmptyArray, + isNumber +} from "../../utils"; +import { + adsToXdmKeys, + mediaToXdmKeys +} from "./constants/mediaKeysToXdmConverter"; + +export default ({ logger, trackMediaSession, trackMediaEvent, uuid }) => { + let trackerState = { + qoe: null, + lastPlayhead: 0, + playerId: uuid() + }; + const getEventType = ({ eventType }) => { + if ( + eventType === EVENT.BufferComplete || + eventType === EVENT.SeekComplete + ) { + return MEDIA_EVENTS_INTERNAL.Play; + } + if (eventType === EVENT.StateStart || eventType === EVENT.StateEnd) { + return MEDIA_EVENTS_INTERNAL.StateUpdate; + } + if (eventType === EVENT.SeekStart) { + return MEDIA_EVENTS_INTERNAL.Pause; + } + return eventType; + }; + const createXdmObject = ({ + eventType, + mediaDetails = {}, + contextData = [] + }) => { + const action = getEventType({ eventType }); + + if (eventType === EVENT.StateStart) { + const xdm = { + eventType: `media.${action}`, + mediaCollection: { + statesStart: [mediaDetails] + } + }; + return xdm; + } + if (eventType === EVENT.StateEnd) { + const xdm = { + eventType: `media.${action}`, + mediaCollection: { + statesEnd: [mediaDetails] + } + }; + return xdm; + } + const xdm = { + eventType: `media.${action}`, + mediaCollection: { + ...mediaDetails + } + }; + + const customMetadata = []; + Object.keys(contextData).forEach(key => { + if (mediaToXdmKeys[key]) { + xdm.mediaCollection.sessionDetails[mediaToXdmKeys[key]] = + contextData[key]; + } else if (adsToXdmKeys[key]) { + xdm.mediaCollection.advertisingDetails[adsToXdmKeys[key]] = + contextData[key]; + } else { + customMetadata.push({ + name: key, + value: contextData[key] + }); + } + }); + if (isNonEmptyArray(customMetadata)) { + xdm.mediaCollection.customMetadata = customMetadata; + } + + return xdm; + }; + + return { + trackSessionStart: (mediaObject, contextData = {}) => { + if (isEmptyObject(mediaObject)) { + logger.warn("Invalid media object"); + return {}; + } + if (trackerState === null) { + logger.warn( + "The Media Session was completed. Restarting a new session." + ); + trackerState = { + qoe: null, + lastPlayhead: 0, + playerId: uuid() + }; + } + const xdm = createXdmObject({ + eventType: MEDIA_EVENTS_INTERNAL.SessionStart, + mediaDetails: mediaObject, + contextData + }); + + return trackMediaSession({ + playerId: trackerState.playerId, + getPlayerDetails: () => { + return { + playhead: trackerState.lastPlayhead, + qoeDataDetails: trackerState.qoe + }; + }, + xdm + }); + }, + trackPlay: () => { + if (trackerState === null) { + logger.warn("The Media Session was completed."); + return {}; + } + + const xdm = createXdmObject({ eventType: MEDIA_EVENTS_INTERNAL.Play }); + + return trackMediaEvent({ playerId: trackerState.playerId, xdm }); + }, + trackPause: () => { + if (trackerState === null) { + logger.warn("The Media Session was completed."); + return {}; + } + + const xdm = createXdmObject({ eventType: MEDIA_EVENTS_INTERNAL.Pause }); + + return trackMediaEvent({ playerId: trackerState.playerId, xdm }); + }, + trackSessionEnd: () => { + if (trackerState === null) { + logger.warn("The Media Session was completed."); + return {}; + } + + const xdm = createXdmObject({ + eventType: MEDIA_EVENTS_INTERNAL.SessionEnd + }); + + return trackMediaEvent({ playerId: trackerState.playerId, xdm }); + }, + trackComplete: () => { + if (trackerState === null) { + logger.warn("The Media Session was completed."); + return {}; + } + + const xdm = createXdmObject({ + eventType: MEDIA_EVENTS_INTERNAL.SessionComplete + }); + + return trackMediaEvent({ playerId: trackerState.playerId, xdm }); + }, + trackError: errorId => { + logger.warn(`trackError(${errorId})`); + if (trackerState === null) { + logger.warn("The Media Session was completed."); + return {}; + } + + const errorDetails = { + name: errorId, + source: "player" + }; + + const xdm = createXdmObject({ + eventType: MEDIA_EVENTS_INTERNAL.Error, + mediaDetails: { errorDetails } + }); + return trackMediaEvent({ playerId: trackerState.playerId, xdm }); + }, + trackEvent: (eventType, info, context) => { + if (isEmptyObject(info)) { + logger.warn("Invalid media object."); + return {}; + } + if (trackerState === null) { + logger.warn("The Media Session was completed."); + return {}; + } + if (!includes(Object.values(EVENT), eventType)) { + logger.warn("Invalid event type"); + return {}; + } + const xdm = createXdmObject({ + eventType, + mediaDetails: info, + contextData: context + }); + + return trackMediaEvent({ playerId: trackerState.playerId, xdm }); + }, + updatePlayhead: time => { + if (trackerState === null) { + logger.warn("The Media Session was completed."); + return; + } + + if (isNumber(time)) { + trackerState.lastPlayhead = parseInt(time, 10); + } + }, + updateQoEObject: qoeObject => { + if (trackerState === null) { + logger.warn("The Media Session was completed."); + return; + } + + if (!qoeObject) { + return; + } + trackerState.qoe = qoeObject; + }, + destroy: () => { + logger.warn("Destroy called, destroying the tracker."); + trackerState = null; + } + }; +}; diff --git a/src/components/LegacyMediaAnalytics/createLegacyMediaComponent.js b/src/components/LegacyMediaAnalytics/createLegacyMediaComponent.js new file mode 100644 index 000000000..3759d7149 --- /dev/null +++ b/src/components/LegacyMediaAnalytics/createLegacyMediaComponent.js @@ -0,0 +1,84 @@ +/* +Copyright 2024 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +import { noop, uuid } from "../../utils"; +import { + AD_METADATA_KEYS as AdMetadataKeys, + AUDIO_METADATA_KEYS as AudioMetadataKeys, + EVENT as Event, + MEDIA_OBJECT_KEYS as MediaObjectKey, + MEDIA_TYPE as MediaType, + PLAYER_STATE as PlayerState, + STREAM_TYPE as StreamType, + VIDEO_METADATA_KEYS as VideoMetadataKeys +} from "./constants/constants"; + +export default ({ + trackMediaEvent, + trackMediaSession, + mediaResponseHandler, + logger, + createMediaHelper, + createGetInstance, + config +}) => { + return { + lifecycle: { + onBeforeEvent({ mediaOptions, onResponse = noop }) { + if (!mediaOptions) { + return; + } + const { legacy, playerId, getPlayerDetails } = mediaOptions; + + if (!legacy) { + return; + } + onResponse(({ response }) => { + return mediaResponseHandler({ playerId, getPlayerDetails, response }); + }); + } + }, + commands: { + getMediaAnalyticsTracker: { + run: () => { + if (!config.streamingMedia) { + return Promise.reject( + new Error("Streaming media is not configured.") + ); + } + logger.info("Streaming media is configured in legacy mode."); + const mediaAnalyticsHelper = createMediaHelper({ logger }); + + return Promise.resolve({ + getInstance: () => { + return createGetInstance({ + logger, + trackMediaEvent, + trackMediaSession, + uuid + }); + }, + Event, + MediaType, + PlayerState, + StreamType, + MediaObjectKey, + VideoMetadataKeys, + AudioMetadataKeys, + AdMetadataKeys, + ...mediaAnalyticsHelper + }); + } + } + } + }; +}; diff --git a/src/components/LegacyMediaAnalytics/createMediaHelper.js b/src/components/LegacyMediaAnalytics/createMediaHelper.js new file mode 100644 index 000000000..c28462e68 --- /dev/null +++ b/src/components/LegacyMediaAnalytics/createMediaHelper.js @@ -0,0 +1,207 @@ +/* +Copyright 2023 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +import { number, objectOf, string } from "../../utils/validation"; + +export default ({ logger }) => { + const createMediaObject = ( + friendlyName, + name, + length, + contentType, + streamType + ) => { + const mediaObject = { + friendlyName, + name, + length, + streamType, + contentType + }; + + const validate = objectOf({ + friendlyName: string().nonEmpty(), + name: string().nonEmpty(), + length: number().required(), + streamType: string().nonEmpty(), + contentType: string().nonEmpty() + }); + + try { + const result = validate(mediaObject); + const sessionDetails = { + name: result.name, + friendlyName: result.friendlyName, + length: result.length, + streamType: result.streamType, + contentType: result.contentType + }; + return { sessionDetails }; + } catch (error) { + logger.warn(`An error occurred while creating the Media Object.`, error); + return {}; + } + }; + + const createAdBreakObject = (name, position, startTime) => { + const adBreakObject = { + friendlyName: name, + offset: position, + index: startTime + }; + const validator = objectOf({ + friendlyName: string().nonEmpty(), + offset: number(), + index: number() + }); + + try { + const result = validator(adBreakObject); + const advertisingPodDetails = { + friendlyName: result.friendlyName, + offset: result.offset, + index: result.index + }; + + return { advertisingPodDetails }; + } catch (error) { + logger.warn( + `An error occurred while creating the Ad Break Object.`, + error + ); + return {}; + } + }; + const createAdObject = (name, id, position, length) => { + const adObject = { + friendlyName: name, + name: id, + podPosition: position, + length + }; + + const validator = objectOf({ + friendlyName: string().nonEmpty(), + name: string().nonEmpty(), + podPosition: number(), + length: number() + }); + + try { + const result = validator(adObject); + const advertisingDetails = { + friendlyName: result.friendlyName, + name: result.name, + podPosition: result.podPosition, + length: result.length + }; + + return { advertisingDetails }; + } catch (error) { + logger.warn( + `An error occurred while creating the Advertising Object.`, + error + ); + return {}; + } + }; + const createChapterObject = (name, position, length, startTime) => { + const chapterDetailsObject = { + friendlyName: name, + offset: position, + length, + index: startTime + }; + + const validator = objectOf({ + friendlyName: string().nonEmpty(), + offset: number(), + length: number(), + index: number() + }); + + try { + const result = validator(chapterDetailsObject); + const chapterDetails = { + friendlyName: result.friendlyName, + offset: result.offset, + index: result.index, + length: result.length + }; + + return { chapterDetails }; + } catch (error) { + logger.warn( + `An error occurred while creating the Chapter Object.`, + error + ); + return {}; + } + }; + const createStateObject = stateName => { + const STATE_NAME_REGEX = new RegExp("^[a-zA-Z0-9_]{1,64}$"); + + const validator = string().matches( + STATE_NAME_REGEX, + "This is not a valid state name." + ); + + try { + const result = validator(stateName); + + return { + name: result + }; + } catch (error) { + logger.warn(`An error occurred while creating the State Object.`, error); + return {}; + } + }; + const createQoEObject = (bitrate, droppedFrames, fps, startupTime) => { + const qoeObject = { + bitrate, + droppedFrames, + fps, + startupTime + }; + + const validator = objectOf({ + bitrate: number(), + droppedFrames: number(), + fps: number(), + startupTime: number() + }); + + try { + const result = validator(qoeObject); + + return { + bitrate: result.bitrate, + droppedFrames: result.droppedFrames, + framesPerSecond: result.fps, + timeToStart: result.startupTime + }; + } catch (error) { + logger.warn(`An error occurred while creating the QOE Object.`, error); + return {}; + } + }; + + return { + createMediaObject, + createAdBreakObject, + createAdObject, + createChapterObject, + createStateObject, + createQoEObject + }; +}; diff --git a/src/components/LegacyMediaAnalytics/index.js b/src/components/LegacyMediaAnalytics/index.js new file mode 100644 index 000000000..82659892b --- /dev/null +++ b/src/components/LegacyMediaAnalytics/index.js @@ -0,0 +1,71 @@ +/* +Copyright 2024 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ +/* eslint-disable import/no-restricted-paths */ + +import createMediaEventManager from "../StreamingMedia/createMediaEventManager"; +import createMediaSessionCacheManager from "../StreamingMedia/createMediaSessionCacheManager"; +import createTrackMediaEvent from "../StreamingMedia/createTrackMediaEvent"; +import createTrackMediaSession from "../StreamingMedia/createTrackMediaSession"; +import createMediaResponseHandler from "../StreamingMedia/createMediaResponseHandler"; +import createLegacyMediaComponent from "./createLegacyMediaComponent"; +import createMediaHelper from "./createMediaHelper"; +import createGetInstance from "./createGetInstance"; +import injectTimestamp from "../Context/injectTimestamp"; + +const createLegacyMediaAnalytics = ({ + eventManager, + sendEdgeNetworkRequest, + config, + logger, + consent +}) => { + const mediaSessionCacheManager = createMediaSessionCacheManager({ config }); + + const mediaEventManager = createMediaEventManager({ + sendEdgeNetworkRequest, + config, + consent, + eventManager, + setTimestamp: injectTimestamp(() => new Date()) + }); + + const trackMediaEvent = createTrackMediaEvent({ + mediaSessionCacheManager, + mediaEventManager, + config + }); + const trackMediaSession = createTrackMediaSession({ + config, + mediaEventManager, + mediaSessionCacheManager, + legacy: true + }); + const mediaResponseHandler = createMediaResponseHandler({ + mediaSessionCacheManager, + config, + trackMediaEvent + }); + + return createLegacyMediaComponent({ + mediaResponseHandler, + trackMediaSession, + trackMediaEvent, + createMediaHelper, + createGetInstance, + logger, + config + }); +}; + +createLegacyMediaAnalytics.namespace = "Legacy Media Analytics"; + +export default createLegacyMediaAnalytics; diff --git a/src/components/StreamingMedia/configValidators.js b/src/components/StreamingMedia/configValidators.js new file mode 100644 index 000000000..d292e8efa --- /dev/null +++ b/src/components/StreamingMedia/configValidators.js @@ -0,0 +1,33 @@ +/* +Copyright 2024 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +import { number, objectOf, string } from "../../utils/validation"; + +export default objectOf({ + streamingMedia: objectOf({ + channel: string() + .nonEmpty() + .required(), + playerName: string() + .nonEmpty() + .required(), + appVersion: string(), + mainPingInterval: number() + .minimum(10) + .maximum(50) + .default(10), + adPingInterval: number() + .minimum(1) + .maximum(10) + .default(10) + }).noUnknownFields() +}); diff --git a/src/components/StreamingMedia/constants/eventTypes.js b/src/components/StreamingMedia/constants/eventTypes.js new file mode 100644 index 000000000..3ca69b57a --- /dev/null +++ b/src/components/StreamingMedia/constants/eventTypes.js @@ -0,0 +1,31 @@ +/* +Copyright 2023 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ +export default { + PAUSE: "media.pauseStart", + PLAY: "media.play", + BUFFER_START: "media.bufferStart", + AD_START: "media.adStart", + Ad_BREAK_START: "media.adBreakStart", + SESSION_END: "media.sessionEnd", + SESSION_START: "media.sessionStart", + SESSION_COMPLETE: "media.sessionComplete", + PING: "media.ping", + AD_BREAK_COMPLETE: "media.adBreakComplete", + AD_COMPLETE: "media.adComplete", + AD_SKIP: "media.adSkip", + BITRATE_CHANGE: "media.bitrateChange", + CHAPTER_COMPLETE: "media.chapterComplete", + CHAPTER_SKIP: "media.chapterSkip", + CHAPTER_START: "media.chapterStart", + ERROR: "media.error", + STATES_UPDATE: "media.statesUpdate" +}; diff --git a/src/components/StreamingMedia/constants/playbackState.js b/src/components/StreamingMedia/constants/playbackState.js new file mode 100644 index 000000000..9f6894d10 --- /dev/null +++ b/src/components/StreamingMedia/constants/playbackState.js @@ -0,0 +1,16 @@ +/* +Copyright 2024 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ +export default { + MAIN: "main", + AD: "ad", + COMPLETED: "completed" +}; diff --git a/src/components/StreamingMedia/createMediaEventManager.js b/src/components/StreamingMedia/createMediaEventManager.js new file mode 100644 index 000000000..ce5e5ff27 --- /dev/null +++ b/src/components/StreamingMedia/createMediaEventManager.js @@ -0,0 +1,98 @@ +/* +Copyright 2023 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ +import MediaEvents from "./constants/eventTypes"; +import createMediaRequest from "./createMediaRequest"; +import { toInteger } from "../../utils"; +import { createDataCollectionRequestPayload } from "../../utils/request"; + +export default ({ + config, + eventManager, + consent, + sendEdgeNetworkRequest, + setTimestamp +}) => { + return { + createMediaEvent({ options }) { + const event = eventManager.createEvent(); + const { xdm } = options; + setTimestamp(xdm); + event.setUserXdm(xdm); + + if (xdm.eventType === MediaEvents.AD_START) { + const { advertisingDetails } = options.xdm.mediaCollection; + + event.mergeXdm({ + mediaCollection: { + advertisingDetails: { + playerName: + advertisingDetails.playerName || + config.streamingMedia.playerName + } + } + }); + } + return event; + }, + createMediaSession(options) { + const { playerName, channel, appVersion } = config.streamingMedia; + const event = eventManager.createEvent(); + const { sessionDetails } = options.xdm.mediaCollection; + event.setUserXdm(options.xdm); + event.mergeXdm({ + eventType: MediaEvents.SESSION_START, + mediaCollection: { + sessionDetails: { + playerName: sessionDetails.playerName || playerName, + channel: sessionDetails.channel || channel, + appVersion: sessionDetails.appVersion || appVersion + } + } + }); + + return event; + }, + augmentMediaEvent({ event, playerId, getPlayerDetails, sessionID }) { + if (!playerId || !getPlayerDetails) { + return event; + } + const { playhead, qoeDataDetails } = getPlayerDetails({ playerId }); + + event.mergeXdm({ + mediaCollection: { + playhead: toInteger(playhead), + qoeDataDetails, + sessionID + } + }); + return event; + }, + trackMediaSession({ event, mediaOptions }) { + return eventManager.sendEvent(event, { mediaOptions }); + }, + trackMediaEvent({ event, action }) { + const mediaRequestPayload = createDataCollectionRequestPayload(); + const request = createMediaRequest({ + mediaRequestPayload, + action + }); + mediaRequestPayload.addEvent(event); + event.finalize(); + + return consent.awaitConsent().then(() => { + return sendEdgeNetworkRequest({ request }).then(() => { + return {}; + }); + }); + } + }; +}; diff --git a/src/components/StreamingMedia/createMediaRequest.js b/src/components/StreamingMedia/createMediaRequest.js new file mode 100644 index 000000000..980c8449c --- /dev/null +++ b/src/components/StreamingMedia/createMediaRequest.js @@ -0,0 +1,26 @@ +/* +Copyright 2023 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +import { createRequest } from "../../utils/request"; + +export default ({ mediaRequestPayload, action }) => { + return createRequest({ + payload: mediaRequestPayload, + edgeSubPath: "/va", + getAction() { + return action; + }, + getUseSendBeacon() { + return false; + } + }); +}; diff --git a/src/components/StreamingMedia/createMediaResponseHandler.js b/src/components/StreamingMedia/createMediaResponseHandler.js new file mode 100644 index 000000000..4c5da46f1 --- /dev/null +++ b/src/components/StreamingMedia/createMediaResponseHandler.js @@ -0,0 +1,51 @@ +/* +Copyright 2024 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ +import isBlankString from "../../utils/isBlankString"; +import MediaEvents from "./constants/eventTypes"; +import { isNonEmptyArray } from "../../utils"; +import PlaybackState from "./constants/playbackState"; + +export default ({ mediaSessionCacheManager, config, trackMediaEvent }) => { + return ({ response, playerId, getPlayerDetails }) => { + const mediaPayload = response.getPayloadsByType( + "media-analytics:new-session" + ); + if (isNonEmptyArray(mediaPayload)) { + const { sessionId } = mediaPayload[0]; + if (isBlankString(sessionId)) { + return {}; + } + + if (!playerId || !getPlayerDetails) { + return { sessionId }; + } + + const pingId = setTimeout(() => { + trackMediaEvent({ + playerId, + xdm: { + eventType: MediaEvents.PING + } + }); + }, config.streamingMedia.mainPingInterval * 1000); + + mediaSessionCacheManager.savePing({ + playerId, + pingId, + playbackState: PlaybackState.MAIN + }); + + return { sessionId }; + } + return {}; + }; +}; diff --git a/src/components/StreamingMedia/createMediaSessionCacheManager.js b/src/components/StreamingMedia/createMediaSessionCacheManager.js new file mode 100644 index 000000000..57c8e2a08 --- /dev/null +++ b/src/components/StreamingMedia/createMediaSessionCacheManager.js @@ -0,0 +1,60 @@ +/* +Copyright 2023 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +import PlaybackState from "./constants/playbackState"; + +export default () => { + let mediaSessionCache; + + const getSession = playerId => { + return mediaSessionCache[playerId] || {}; + }; + + const savePing = ({ playerId, pingId, playbackState }) => { + if (!mediaSessionCache[playerId]) { + return; + } + if (mediaSessionCache[playerId].pingId) { + clearTimeout(mediaSessionCache[playerId].pingId); + } + + mediaSessionCache[playerId].pingId = pingId; + mediaSessionCache[playerId].playbackState = playbackState; + }; + + const stopPing = ({ playerId }) => { + const sessionDetails = mediaSessionCache[playerId]; + + if (!sessionDetails) { + return; + } + + clearTimeout(sessionDetails.pingId); + + sessionDetails.pingId = null; + sessionDetails.playbackState = PlaybackState.COMPLETED; + }; + const storeSession = ({ playerId, sessionDetails }) => { + if (mediaSessionCache === undefined) { + mediaSessionCache = {}; + } + + mediaSessionCache[playerId] = sessionDetails; + }; + + return { + getSession, + storeSession, + stopPing, + savePing + }; +}; diff --git a/src/components/StreamingMedia/createStreamingMediaComponent.js b/src/components/StreamingMedia/createStreamingMediaComponent.js new file mode 100644 index 000000000..df6492ff8 --- /dev/null +++ b/src/components/StreamingMedia/createStreamingMediaComponent.js @@ -0,0 +1,59 @@ +/* +Copyright 2024 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ +import { noop } from "../../utils"; +import validateSessionOptions from "./validateMediaSessionOptions"; +import validateMediaEventOptions from "./validateMediaEventOptions"; + +export default ({ + config, + trackMediaEvent, + trackMediaSession, + mediaResponseHandler +}) => { + return { + lifecycle: { + onBeforeEvent({ mediaOptions, onResponse = noop }) { + if (!mediaOptions) { + return; + } + const { legacy, playerId, getPlayerDetails } = mediaOptions; + if (legacy) { + return; + } + onResponse(({ response }) => { + return mediaResponseHandler({ playerId, getPlayerDetails, response }); + }); + } + }, + commands: { + createMediaSession: { + optionsValidator: options => validateSessionOptions({ options }), + + run: trackMediaSession + }, + + sendMediaEvent: { + optionsValidator: options => validateMediaEventOptions({ options }), + + run: options => { + if (!config.streamingMedia) { + return Promise.reject( + new Error("Streaming media is not configured.") + ); + } + + return trackMediaEvent(options); + } + } + } + }; +}; diff --git a/src/components/StreamingMedia/createTrackMediaEvent.js b/src/components/StreamingMedia/createTrackMediaEvent.js new file mode 100644 index 000000000..0a22d32a4 --- /dev/null +++ b/src/components/StreamingMedia/createTrackMediaEvent.js @@ -0,0 +1,103 @@ +/* +Copyright 2024 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ +import MediaEvents from "./constants/eventTypes"; + +const getContentState = (eventType, sessionContentState) => { + if ( + eventType === MediaEvents.AD_START || + eventType === MediaEvents.Ad_BREAK_START || + eventType === MediaEvents.AD_SKIP || + eventType === MediaEvents.AD_COMPLETE + ) { + return "ad"; + } + if ( + eventType === MediaEvents.AD_BREAK_COMPLETE || + eventType === MediaEvents.CHAPTER_COMPLETE || + eventType === MediaEvents.CHAPTER_START || + eventType === MediaEvents.CHAPTER_SKIP || + eventType === MediaEvents.SESSION_START + ) { + return "main"; + } + if ( + eventType === MediaEvents.SESSION_END || + eventType === MediaEvents.SESSION_COMPLETE + ) { + return "completed"; + } + return sessionContentState; +}; + +export default ({ mediaEventManager, mediaSessionCacheManager, config }) => { + const sendMediaEvent = options => { + const event = mediaEventManager.createMediaEvent({ options }); + const { playerId, xdm } = options; + const eventType = xdm.eventType; + const action = eventType.split(".")[1]; + const { + getPlayerDetails, + sessionPromise, + playbackState + } = mediaSessionCacheManager.getSession(playerId); + return sessionPromise.then(result => { + mediaEventManager.augmentMediaEvent({ + event, + eventType, + playerId, + getPlayerDetails, + sessionID: result.sessionId + }); + + return mediaEventManager.trackMediaEvent({ event, action }).then(() => { + if (playerId) { + if ( + eventType === MediaEvents.SESSION_COMPLETE || + eventType === MediaEvents.SESSION_END + ) { + mediaSessionCacheManager.stopPing({ playerId }); + } else { + const sessionPlaybackState = getContentState( + eventType, + playbackState + ); + + if (sessionPlaybackState === "completed") { + return; + } + const interval = + sessionPlaybackState === "ad" + ? config.streamingMedia.adPingInterval + : config.streamingMedia.mainPingInterval; + + const pingId = setTimeout(() => { + const pingOptions = { + playerId, + xdm: { + eventType: MediaEvents.PING + } + }; + sendMediaEvent(pingOptions); + }, interval * 1000); + mediaSessionCacheManager.savePing({ + playerId, + pingId, + playbackState: sessionPlaybackState + }); + } + } + }); + }); + }; + + return options => sendMediaEvent(options); +}; diff --git a/src/components/StreamingMedia/createTrackMediaSession.js b/src/components/StreamingMedia/createTrackMediaSession.js new file mode 100644 index 000000000..39ca2c861 --- /dev/null +++ b/src/components/StreamingMedia/createTrackMediaSession.js @@ -0,0 +1,54 @@ +/* +Copyright 2024 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ +import PlaybackState from "./constants/playbackState"; + +export default ({ + config, + mediaEventManager, + mediaSessionCacheManager, + legacy = false +}) => { + return options => { + if (!config.streamingMedia) { + return Promise.reject(new Error("Streaming media is not configured.")); + } + + const { playerId, getPlayerDetails } = options; + const event = mediaEventManager.createMediaSession(options); + + mediaEventManager.augmentMediaEvent({ + event, + playerId, + getPlayerDetails + }); + + const sessionPromise = mediaEventManager.trackMediaSession({ + event, + mediaOptions: { + playerId, + getPlayerDetails, + legacy + } + }); + + mediaSessionCacheManager.storeSession({ + playerId, + sessionDetails: { + sessionPromise, + getPlayerDetails, + playbackState: PlaybackState.MAIN + } + }); + + return sessionPromise; + }; +}; diff --git a/src/components/StreamingMedia/index.js b/src/components/StreamingMedia/index.js new file mode 100644 index 000000000..ed1d189a0 --- /dev/null +++ b/src/components/StreamingMedia/index.js @@ -0,0 +1,68 @@ +/* +Copyright 2023 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ +/* eslint-disable import/no-restricted-paths */ +import createMediaSessionCacheManager from "./createMediaSessionCacheManager"; +import createMediaEventManager from "./createMediaEventManager"; +import createTrackMediaEvent from "./createTrackMediaEvent"; +import configValidators from "./configValidators"; +import createStreamingMediaComponent from "./createStreamingMediaComponent"; +import createTrackMediaSession from "./createTrackMediaSession"; +import createMediaResponseHandler from "./createMediaResponseHandler"; +import injectTimestamp from "../Context/injectTimestamp"; + +const createStreamingMedia = ({ + config, + logger, + eventManager, + sendEdgeNetworkRequest, + consent +}) => { + const mediaSessionCacheManager = createMediaSessionCacheManager({ config }); + const mediaEventManager = createMediaEventManager({ + config, + eventManager, + logger, + consent, + sendEdgeNetworkRequest, + setTimestamp: injectTimestamp(() => new Date()) + }); + + const trackMediaEvent = createTrackMediaEvent({ + mediaSessionCacheManager, + mediaEventManager, + config + }); + + const trackMediaSession = createTrackMediaSession({ + config, + mediaEventManager, + mediaSessionCacheManager + }); + + const mediaResponseHandler = createMediaResponseHandler({ + mediaSessionCacheManager, + config, + trackMediaEvent + }); + + return createStreamingMediaComponent({ + config, + trackMediaEvent, + mediaEventManager, + mediaResponseHandler, + trackMediaSession + }); +}; +createStreamingMedia.namespace = "Streaming media"; + +createStreamingMedia.configValidators = configValidators; +export default createStreamingMedia; diff --git a/src/components/StreamingMedia/validateMediaEventOptions.js b/src/components/StreamingMedia/validateMediaEventOptions.js new file mode 100644 index 000000000..d45b62a0f --- /dev/null +++ b/src/components/StreamingMedia/validateMediaEventOptions.js @@ -0,0 +1,50 @@ +/* +Copyright 2023 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ +import { + anyOf, + anything, + enumOf, + number, + objectOf, + string +} from "../../utils/validation"; +import EventTypes from "./constants/eventTypes"; + +export default ({ options }) => { + const validator = anyOf( + [ + objectOf({ + playerId: string().required(), + xdm: objectOf({ + eventType: enumOf(...Object.values(EventTypes)).required(), + mediaCollection: objectOf(anything()) + }).required() + }).required(), + + objectOf({ + xdm: objectOf({ + eventType: enumOf(...Object.values(EventTypes)).required(), + mediaCollection: objectOf({ + playhead: number() + .integer() + .required(), + sessionID: string().required() + }).required() + }).required() + }).required() + ], + + "Error validating the sendMediaEvent command options." + ); + + return validator(options); +}; diff --git a/src/components/StreamingMedia/validateMediaSessionOptions.js b/src/components/StreamingMedia/validateMediaSessionOptions.js new file mode 100644 index 000000000..9abf4be13 --- /dev/null +++ b/src/components/StreamingMedia/validateMediaSessionOptions.js @@ -0,0 +1,49 @@ +/* +Copyright 2023 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +import { + anyOf, + anything, + callback, + number, + objectOf, + string +} from "../../utils/validation"; + +export default ({ options }) => { + const sessionValidator = anyOf( + [ + objectOf({ + playerId: string().required(), + getPlayerDetails: callback().required(), + xdm: objectOf({ + mediaCollection: objectOf({ + sessionDetails: objectOf(anything()).required() + }) + }) + }).required(), + + objectOf({ + xdm: objectOf({ + mediaCollection: objectOf({ + playhead: number().required(), + sessionDetails: objectOf(anything()).required() + }) + }) + }).required() + ], + + "Error validating the createMediaSession command options." + ); + + return sessionValidator(options); +}; diff --git a/src/core/componentCreators.js b/src/core/componentCreators.js index 00ea305ad..4dd7ce4a7 100644 --- a/src/core/componentCreators.js +++ b/src/core/componentCreators.js @@ -41,6 +41,8 @@ import createDecisioningEngine from "../components/DecisioningEngine"; /* @skipwhen ENV.alloy_machinelearning === false */ import createMachineLearning from "../components/MachineLearning"; +import createStreamingMedia from "../components/StreamingMedia"; +import createLegacyMediaAnalytics from "../components/LegacyMediaAnalytics"; // TODO: Register the Components here statically for now. They might be registered differently. // TODO: Figure out how sub-components will be made available/registered @@ -56,5 +58,7 @@ export default [ createEventMerge, createLibraryInfo, createMachineLearning, - createDecisioningEngine + createDecisioningEngine, + createLegacyMediaAnalytics, + createStreamingMedia ]; diff --git a/src/core/edgeNetwork/injectSendEdgeNetworkRequest.js b/src/core/edgeNetwork/injectSendEdgeNetworkRequest.js index 21d537030..c66c5181c 100644 --- a/src/core/edgeNetwork/injectSendEdgeNetworkRequest.js +++ b/src/core/edgeNetwork/injectSendEdgeNetworkRequest.js @@ -57,8 +57,8 @@ export default ({ : edgeDomain; const locationHint = getLocationHint(); const edgeBasePathWithLocationHint = locationHint - ? `${edgeBasePath}/${locationHint}` - : edgeBasePath; + ? `${edgeBasePath}/${locationHint}${request.getEdgeSubPath()}` + : `${edgeBasePath}${request.getEdgeSubPath()}`; const configId = request.getDatastreamIdOverride() || datastreamId; const payload = request.getPayload(); if (configId !== datastreamId) { diff --git a/src/utils/request/createRequest.js b/src/utils/request/createRequest.js index 8e28fd04c..072da4caa 100644 --- a/src/utils/request/createRequest.js +++ b/src/utils/request/createRequest.js @@ -18,7 +18,8 @@ export default options => { payload, getAction, getUseSendBeacon, - datastreamIdOverride + datastreamIdOverride, + edgeSubPath } = options; const id = uuid(); let shouldUseThirdPartyDomain = false; @@ -40,6 +41,12 @@ export default options => { getUseSendBeacon() { return getUseSendBeacon({ isIdentityEstablished }); }, + getEdgeSubPath() { + if (edgeSubPath) { + return edgeSubPath; + } + return ""; + }, getUseIdThirdPartyDomain() { return shouldUseThirdPartyDomain; }, diff --git a/src/utils/validation/createMaximumValidator.js b/src/utils/validation/createMaximumValidator.js new file mode 100644 index 000000000..70c8be97b --- /dev/null +++ b/src/utils/validation/createMaximumValidator.js @@ -0,0 +1,22 @@ +/* +Copyright 2023 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ +import { assertValid } from "./utils"; + +export default (typeName, maximum) => (value, path) => { + assertValid( + value <= maximum, + value, + path, + `${typeName} less than or equal to ${maximum}` + ); + return value; +}; diff --git a/src/utils/validation/index.js b/src/utils/validation/index.js index 4ebe19065..c2d869712 100644 --- a/src/utils/validation/index.js +++ b/src/utils/validation/index.js @@ -78,6 +78,7 @@ import createDeprecatedValidator from "./createDeprecatedValidator"; import createLiteralValidator from "./createLiteralValidator"; import createMapOfValuesValidator from "./createMapOfValuesValidator"; import createMinimumValidator from "./createMinimumValidator"; +import createMaximumValidator from "./createMaximumValidator"; import createNoUnknownFieldsValidator from "./createNoUnknownFieldsValidator"; import createNonEmptyValidator from "./createNonEmptyValidator"; import createObjectOfValidator from "./createObjectOfValidator"; @@ -90,6 +91,7 @@ import numberValidator from "./numberValidator"; import regexpValidator from "./regexpValidator"; import requiredValidator from "./requiredValidator"; import stringValidator from "./stringValidator"; +import matchesRegexpValidator from "./matchesRegexpValidator"; // The base validator does no validation and just returns the value unchanged const base = value => value; @@ -114,6 +116,9 @@ const minimumInteger = function minimumInteger(minValue) { const minimumNumber = function minimumNumber(minValue) { return nullSafeChain(this, createMinimumValidator("a number", minValue)); }; +const maximumNumber = function maximumNumber(maxValue) { + return nullSafeChain(this, createMaximumValidator("a number", maxValue)); +}; const integer = function integer() { return nullSafeChain(this, integerValidator, { minimum: minimumInteger }); }; @@ -129,6 +134,9 @@ const nonEmptyObject = function nonEmptyObject() { const regexp = function regexp() { return nullSafeChain(this, regexpValidator); }; +const matches = function matches(regexpPattern) { + return nullSafeChain(this, matchesRegexpValidator(regexpPattern)); +}; const unique = function createUnique() { return nullSafeChain(this, createUniqueValidator()); }; @@ -163,6 +171,7 @@ const literal = function literal(literalValue) { const number = function number() { return nullSafeChain(this, numberValidator, { minimum: minimumNumber, + maximum: maximumNumber, integer, unique }); @@ -208,7 +217,8 @@ const string = function string() { regexp, domain, nonEmpty: nonEmptyString, - unique + unique, + matches }); }; diff --git a/src/utils/validation/matchesRegexpValidator.js b/src/utils/validation/matchesRegexpValidator.js new file mode 100644 index 000000000..74c98bcd8 --- /dev/null +++ b/src/utils/validation/matchesRegexpValidator.js @@ -0,0 +1,23 @@ +/* +Copyright 2023 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +import { assertValid } from "./utils"; + +export default regexp => (value, path) => { + assertValid( + regexp.test(value), + value, + path, + `does not match the ${regexp.toString()}` + ); + return value; +}; diff --git a/test/functional/helpers/constants/configParts/orgMediaConfig.js b/test/functional/helpers/constants/configParts/orgMediaConfig.js new file mode 100644 index 000000000..f8717ad7d --- /dev/null +++ b/test/functional/helpers/constants/configParts/orgMediaConfig.js @@ -0,0 +1,17 @@ +/* +Copyright 2023 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ +import getBaseConfig from "../../getBaseConfig"; + +export default getBaseConfig( + "97D1F3F459CE0AD80A495CBE@AdobeOrg", + "27dae196-8c75-4eed-82d1-3895616f85d6" +); diff --git a/test/functional/helpers/constants/configParts/streamingMedia.js b/test/functional/helpers/constants/configParts/streamingMedia.js new file mode 100644 index 000000000..8343eeb86 --- /dev/null +++ b/test/functional/helpers/constants/configParts/streamingMedia.js @@ -0,0 +1,17 @@ +/* +Copyright 2023 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ +export default { + streamingMedia: { + channel: "functional tests channel", + playerName: "functional test player" + } +}; diff --git a/test/functional/helpers/createAlloyProxy.js b/test/functional/helpers/createAlloyProxy.js index d1fbf154c..bb50a54fb 100644 --- a/test/functional/helpers/createAlloyProxy.js +++ b/test/functional/helpers/createAlloyProxy.js @@ -92,7 +92,10 @@ const commands = [ "appendIdentityToUrl", "applyPropositions", "subscribeRulesetItems", - "evaluateRulesets" + "evaluateRulesets", + "createMediaSession", + "sendMediaEvent", + "getMediaAnalyticsTracker" ]; export default (instanceName = "alloy") => { diff --git a/test/functional/helpers/networkLogger/index.js b/test/functional/helpers/networkLogger/index.js index 362827a59..5e00cce01 100644 --- a/test/functional/helpers/networkLogger/index.js +++ b/test/functional/helpers/networkLogger/index.js @@ -32,6 +32,24 @@ const createNetworkLogger = () => { const acquireEndpoint = /v1\/identity\/acquire\?configId=/; const targetDeliveryEndpoint = /rest\/v1\/delivery\?client=/; const targetMboxJsonEndpoint = /m2\/unifiedjsqeonly\/mbox\/json\?mbox=/; + // media endpoints + const playEndpoint = /va\/v1\/play/; + const pauseEndpoint = /va\/v1\/pauseStart/; + const pingEndpoint = /va\/v1\/ping/; + const adBreakCompleteEndpoint = /va\/v1\/adBreakComplete/; + const adBreakStartEndpoint = /va\/v1\/adBreakStart/; + const adCompleteEndpoint = /va\/v1\/adComplete/; + const adSkipEndpoint = /va\/v1\/adSkip/; + const adStartEndpoint = /va\/v1\/adStart/; + const bitrateChangeEndpoint = /va\/v1\/bitrateChange/; + const bufferStartEndpoint = /va\/v1\/bufferStart/; + const chapterCompleteEndpoint = /va\/v1\/chapterComplete/; + const chapterSkipEndpoint = /va\/v1\/chapterSkip/; + const chapterStartEndpoint = /va\/v1\/chapterStart/; + const errorEndpoint = /va\/v1\/error/; + const sessionCompleteEndpoint = /va\/v1\/sessionComplete/; + const sessionEndEndpoint = /va\/v1\/sessionEnd/; + const statesUpdateEndpoint = /va\/v1\/statesUpdate/; const edgeEndpointLogs = createRequestLogger(edgeEndpoint); const edgeCollectEndpointLogs = createRequestLogger(edgeCollectEndpoint); @@ -44,6 +62,30 @@ const createNetworkLogger = () => { const targetMboxJsonEndpointLogs = createRequestLogger( targetMboxJsonEndpoint ); + // media endpoint loggers + const mediaPlayEndpointLogs = createRequestLogger(playEndpoint); + const mediaPauseEndpointLogs = createRequestLogger(pauseEndpoint); + const pingEndpointLogs = createRequestLogger(pingEndpoint); + const adBreakCompleteEndpointLogs = createRequestLogger( + adBreakCompleteEndpoint + ); + const adBreakStartEndpointLogs = createRequestLogger(adBreakStartEndpoint); + const adCompleteEndpointLogs = createRequestLogger(adCompleteEndpoint); + const adSkipEndpointLogs = createRequestLogger(adSkipEndpoint); + const adStartEndpointLogs = createRequestLogger(adStartEndpoint); + const bitrateChangeEndpointLogs = createRequestLogger(bitrateChangeEndpoint); + const bufferStartEndpointLogs = createRequestLogger(bufferStartEndpoint); + const chapterCompleteEndpointLogs = createRequestLogger( + chapterCompleteEndpoint + ); + const chapterSkipEndpointLogs = createRequestLogger(chapterSkipEndpoint); + const chapterStartEndpointLogs = createRequestLogger(chapterStartEndpoint); + const errorEndpointLogs = createRequestLogger(errorEndpoint); + const sessionCompleteEndpointLogs = createRequestLogger( + sessionCompleteEndpoint + ); + const sessionEndEndpointLogs = createRequestLogger(sessionEndEndpoint); + const statesUpdateEndpointLogs = createRequestLogger(statesUpdateEndpoint); const clearLogs = async () => { await edgeEndpointLogs.clear(); @@ -53,6 +95,23 @@ const createNetworkLogger = () => { await acquireEndpointLogs.clear(); await targetDeliveryEndpointLogs.clear(); await targetMboxJsonEndpointLogs.clear(); + await mediaPlayEndpointLogs.clear(); + await mediaPauseEndpointLogs.clear(); + await pingEndpointLogs.clear(); + await adBreakCompleteEndpointLogs.clear(); + await adBreakStartEndpointLogs.clear(); + await adCompleteEndpointLogs.clear(); + await adSkipEndpointLogs.clear(); + await adStartEndpointLogs.clear(); + await bitrateChangeEndpointLogs.clear(); + await bufferStartEndpointLogs.clear(); + await chapterCompleteEndpointLogs.clear(); + await chapterSkipEndpointLogs.clear(); + await chapterStartEndpointLogs.clear(); + await errorEndpointLogs.clear(); + await sessionCompleteEndpointLogs.clear(); + await sessionEndEndpointLogs.clear(); + await statesUpdateEndpointLogs.clear(); }; return { @@ -65,6 +124,23 @@ const createNetworkLogger = () => { acquireEndpointLogs, targetDeliveryEndpointLogs, targetMboxJsonEndpointLogs, + mediaPlayEndpointLogs, + mediaPauseEndpointLogs, + pingEndpointLogs, + adBreakCompleteEndpointLogs, + adBreakStartEndpointLogs, + adCompleteEndpointLogs, + adSkipEndpointLogs, + adStartEndpointLogs, + bitrateChangeEndpointLogs, + bufferStartEndpointLogs, + chapterCompleteEndpointLogs, + chapterSkipEndpointLogs, + chapterStartEndpointLogs, + errorEndpointLogs, + sessionCompleteEndpointLogs, + sessionEndEndpointLogs, + statesUpdateEndpointLogs, clearLogs }; }; diff --git a/test/functional/specs/MediaCollection/MA1.js b/test/functional/specs/MediaCollection/MA1.js new file mode 100644 index 000000000..d6cf17e07 --- /dev/null +++ b/test/functional/specs/MediaCollection/MA1.js @@ -0,0 +1,302 @@ +/* +Copyright 2024 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ +import { t } from "testcafe"; +import createNetworkLogger from "../../helpers/networkLogger"; +import { responseStatus } from "../../helpers/assertions/index"; +import createFixture from "../../helpers/createFixture"; +import { compose } from "../../helpers/constants/configParts"; +import getResponseBody from "../../helpers/networkLogger/getResponseBody"; +import createResponse from "../../helpers/createResponse"; +import { TEST_PAGE as TEST_PAGE_URL } from "../../helpers/constants/url"; +import createAlloyProxy from "../../helpers/createAlloyProxy"; +import orgMediaConfig from "../../helpers/constants/configParts/orgMediaConfig"; +import streamingMedia from "../../helpers/constants/configParts/streamingMedia"; +import { sleep } from "../Migration/helper"; + +const networkLogger = createNetworkLogger(); +const config = compose(orgMediaConfig, streamingMedia); +createFixture({ + title: "Streaming media in automatic mode.", + url: TEST_PAGE_URL, + requestHooks: [ + networkLogger.edgeEndpointLogs, + networkLogger.mediaPauseEndpointLogs, + networkLogger.mediaPlayEndpointLogs, + networkLogger.chapterStartEndpointLogs, + networkLogger.pingEndpointLogs, + networkLogger.chapterCompleteEndpointLogs, + networkLogger.chapterSkipEndpointLogs, + networkLogger.adStartEndpointLogs, + networkLogger.adBreakStartEndpointLogs, + networkLogger.adBreakCompleteEndpointLogs, + networkLogger.adSkipEndpointLogs, + networkLogger.adCompleteEndpointLogs, + networkLogger.errorEndpointLogs, + networkLogger.sessionCompleteEndpointLogs, + networkLogger.sessionEndEndpointLogs, + networkLogger.statesUpdateEndpointLogs, + networkLogger.bitrateChangeEndpointLogs + ] +}); + +test.meta({ + ID: "MA1", + SEVERITY: "P0", + TEST_RUN: "Regression" +}); +const assertSessionStartedInAutoPingMode = async alloy => { + const sessionPromise = await alloy.createMediaSession({ + playerId: "player1", + xdm: { + mediaCollection: { + sessionDetails: { + length: 60, + contentType: "VOD", + name: "test name of the video" + } + } + }, + getPlayerDetails: () => { + return { + playhead: 3, + qoeDataDetails: { + bitrate: 1, + droppedFrames: 2, + framesPerSecond: 3, + timeToStart: 4 + } + }; + } + }); + await responseStatus(networkLogger.edgeEndpointLogs.requests, 200); + await t.expect(networkLogger.edgeEndpointLogs.requests.length).eql(1); + + const createSession = networkLogger.edgeEndpointLogs.requests[0]; + const requestBody = JSON.parse(createSession.request.body); + await t.expect(requestBody.events[0].xdm.eventType).eql("media.sessionStart"); + await t.expect(requestBody.events[0].xdm.mediaCollection.playhead).eql(3); + const response = JSON.parse( + getResponseBody(networkLogger.edgeEndpointLogs.requests[0]) + ); + const mediaCollectionPayload = createResponse({ + content: response + }).getPayloadsByType("media-analytics:new-session"); + + await t + .expect(mediaCollectionPayload[0].sessionId) + .eql(sessionPromise.sessionId); + + return sessionPromise; +}; +const assertPingsSent = async sessionId => { + const pingEventRequest = networkLogger.pingEndpointLogs.requests[0]; + const pingEvent = JSON.parse(pingEventRequest.request.body).events[0]; + await t.expect(pingEvent.xdm.mediaCollection.sessionID).eql(sessionId); + await t.expect(pingEvent.xdm.eventType).eql("media.ping"); +}; +const assertPingsNotSentWhenSessionClosed = async alloy => { + await alloy.sendMediaEvent({ + playerId: "player1", + xdm: { + eventType: "media.sessionComplete" + } + }); + await sleep(10000); + + const secondPingEventRequest = networkLogger.pingEndpointLogs.requests[2]; + await t.expect(secondPingEventRequest).eql(undefined); +}; +const sendMediaEvent = async ( + alloy, + eventType, + sessionId, + additionalData = {} +) => { + await alloy.sendMediaEvent({ + playerId: "player1", + xdm: { + eventType, + mediaCollection: { + ...additionalData + } + } + }); +}; + +const assertEventIsSent = async (endpointLogs, eventType, sessionId) => { + const eventRequest = endpointLogs.requests[0]; + await responseStatus(endpointLogs.requests, 204); + + const event = JSON.parse(eventRequest.request.body).events[0]; + await t.expect(event.xdm.mediaCollection.playhead).eql(3); + await t.expect(event.xdm.mediaCollection.sessionID).eql(sessionId); + await t.expect(event.xdm.eventType).eql(eventType); +}; + +test("Test that MC component sends pings, augment the events with playhead and session", async () => { + const alloy = createAlloyProxy(); + await alloy.configure(config); + const { sessionId } = await assertSessionStartedInAutoPingMode(alloy); + await sleep(11000); + await assertPingsSent(sessionId); + + // play event + await sendMediaEvent(alloy, "media.play", sessionId); + await assertEventIsSent( + networkLogger.mediaPlayEndpointLogs, + "media.play", + sessionId + ); + + // pause event + await sendMediaEvent(alloy, "media.pauseStart", sessionId); + await assertEventIsSent( + networkLogger.mediaPauseEndpointLogs, + "media.pauseStart", + sessionId + ); + + // chapter start event + await sendMediaEvent(alloy, "media.chapterStart", sessionId, { + chapterDetails: { + friendlyName: "Chapter 1", + length: 10, + index: 1, + offset: 0 + } + }); + await assertEventIsSent( + networkLogger.chapterStartEndpointLogs, + "media.chapterStart", + sessionId + ); + + await sendMediaEvent(alloy, "media.chapterComplete", sessionId); + await assertEventIsSent( + networkLogger.chapterCompleteEndpointLogs, + "media.chapterComplete", + sessionId + ); + + await sendMediaEvent(alloy, "media.chapterSkip", sessionId); + await assertEventIsSent( + networkLogger.chapterSkipEndpointLogs, + "media.chapterSkip", + sessionId + ); + + await sendMediaEvent(alloy, "media.adBreakStart", sessionId, { + advertisingPodDetails: { + friendlyName: "Mid-roll", + offset: 0, + index: 1 + } + }); + await assertEventIsSent( + networkLogger.adBreakStartEndpointLogs, + "media.adBreakStart", + sessionId + ); + + await sendMediaEvent(alloy, "media.adStart", sessionId, { + advertisingDetails: { + friendlyName: "Ad 1", + name: "/uri-reference/001", + length: 10, + advertiser: "Adobe Marketing", + campaignID: "Adobe Analytics", + creativeID: "creativeID", + creativeURL: "https://creativeurl.com", + placementID: "placementID", + siteID: "siteID", + podPosition: 11, + playerName: "HTML5 player" + } + }); + await assertEventIsSent( + networkLogger.adStartEndpointLogs, + "media.adStart", + sessionId + ); + + await sendMediaEvent(alloy, "media.adComplete", sessionId); + await assertEventIsSent( + networkLogger.adCompleteEndpointLogs, + "media.adComplete", + sessionId + ); + + await sendMediaEvent(alloy, "media.adBreakComplete", sessionId); + await assertEventIsSent( + networkLogger.adBreakCompleteEndpointLogs, + "media.adBreakComplete", + sessionId + ); + + await sendMediaEvent(alloy, "media.adSkip", sessionId); + await assertEventIsSent( + networkLogger.adSkipEndpointLogs, + "media.adSkip", + sessionId + ); + + await sendMediaEvent(alloy, "media.error", sessionId, { + errorDetails: { + name: "test-buffer-start", + source: "player" + } + }); + await assertEventIsSent( + networkLogger.errorEndpointLogs, + "media.error", + sessionId + ); + + await sendMediaEvent(alloy, "media.bitrateChange", sessionId, { + qoeDataDetails: { + framesPerSecond: 1, + bitrate: 35000, + droppedFrames: 30, + timeToStart: 1364 + } + }); + await assertEventIsSent( + networkLogger.bitrateChangeEndpointLogs, + "media.bitrateChange", + sessionId + ); + + await sendMediaEvent(alloy, "media.statesUpdate", sessionId, { + statesStart: [ + { + name: "mute" + }, + { + name: "pictureInPicture" + } + ], + statesEnd: [ + { + name: "fullScreen" + } + ] + }); + await assertEventIsSent( + networkLogger.statesUpdateEndpointLogs, + "media.statesUpdate", + sessionId + ); + + await sleep(11000); + await assertPingsSent(sessionId); + await assertPingsNotSentWhenSessionClosed(alloy); +}); diff --git a/test/functional/specs/MediaCollection/MA2.js b/test/functional/specs/MediaCollection/MA2.js new file mode 100644 index 000000000..85547d4a1 --- /dev/null +++ b/test/functional/specs/MediaCollection/MA2.js @@ -0,0 +1,384 @@ +/* +Copyright 2024 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ +import { ClientFunction, t } from "testcafe"; +import createNetworkLogger from "../../helpers/networkLogger"; +import { responseStatus } from "../../helpers/assertions/index"; +import createFixture from "../../helpers/createFixture"; +import { compose } from "../../helpers/constants/configParts"; +import getResponseBody from "../../helpers/networkLogger/getResponseBody"; +import createResponse from "../../helpers/createResponse"; +import { TEST_PAGE as TEST_PAGE_URL } from "../../helpers/constants/url"; +import createAlloyProxy from "../../helpers/createAlloyProxy"; +import orgMediaConfig from "../../helpers/constants/configParts/orgMediaConfig"; +import streamingMedia from "../../helpers/constants/configParts/streamingMedia"; +import { sleep } from "../Migration/helper"; + +const networkLogger = createNetworkLogger(); +const config = compose(orgMediaConfig, streamingMedia); +createFixture({ + title: "Streaming media for legacy migration use cases.", + url: TEST_PAGE_URL, + requestHooks: [ + networkLogger.edgeEndpointLogs, + networkLogger.mediaPauseEndpointLogs, + networkLogger.mediaPlayEndpointLogs, + networkLogger.chapterStartEndpointLogs, + networkLogger.pingEndpointLogs, + networkLogger.chapterCompleteEndpointLogs, + networkLogger.chapterSkipEndpointLogs, + networkLogger.adStartEndpointLogs, + networkLogger.adBreakStartEndpointLogs, + networkLogger.adBreakCompleteEndpointLogs, + networkLogger.adSkipEndpointLogs, + networkLogger.adCompleteEndpointLogs, + networkLogger.errorEndpointLogs, + networkLogger.sessionCompleteEndpointLogs, + networkLogger.sessionEndEndpointLogs, + networkLogger.statesUpdateEndpointLogs, + networkLogger.bitrateChangeEndpointLogs, + networkLogger.bufferStartEndpointLogs + ] +}); + +test.meta({ + ID: "MA3", + SEVERITY: "P0", + TEST_RUN: "Regression" +}); + +const assertSessionStarted = async () => { + await t.expect(networkLogger.edgeEndpointLogs.count(() => true)).gte(1); + await responseStatus(networkLogger.edgeEndpointLogs.requests, 200); + await t.expect(networkLogger.edgeEndpointLogs.requests.length).eql(1); + + const createSession = networkLogger.edgeEndpointLogs.requests[0]; + const requestBody = JSON.parse(createSession.request.body); + await t.expect(requestBody.events[0].xdm.eventType).eql("media.sessionStart"); + await t.expect(requestBody.events[0].xdm.mediaCollection.playhead).eql(0); + const response = JSON.parse( + getResponseBody(networkLogger.edgeEndpointLogs.requests[0]) + ); + const mediaCollectionPayload = createResponse({ + content: response + }).getPayloadsByType("media-analytics:new-session"); + + return mediaCollectionPayload[0].sessionId; +}; + +const assertPingsNotSent = async () => { + await sleep(10000); + const secondPingEventRequest = networkLogger.pingEndpointLogs.requests[2]; + await t.expect(secondPingEventRequest).eql(undefined); +}; +const assertPingsSent = async sessionId => { + await t.expect(networkLogger.pingEndpointLogs.count(() => true)).gte(1); + const pingEventRequest = networkLogger.pingEndpointLogs.requests[0]; + const pingEvent = JSON.parse(pingEventRequest.request.body).events[0]; + await t.expect(pingEvent.xdm.mediaCollection.sessionID).eql(sessionId); + await t.expect(pingEvent.xdm.eventType).eql("media.ping"); +}; +const assertEventIsSent = async ( + endpointLogs, + eventType, + sessionId, + playhead, + order = 0 +) => { + await t.expect(endpointLogs.count(() => true)).gte(order + 1); + const eventRequest = endpointLogs.requests[order]; + await responseStatus(endpointLogs.requests, 204); + + const event = JSON.parse(eventRequest.request.body).events[0]; + await t.expect(event.xdm.mediaCollection.sessionID).eql(sessionId); + await t.expect(event.xdm.mediaCollection.playhead).eql(playhead); + await t.expect(event.xdm.eventType).eql(eventType); +}; + +const initializeTracker = ClientFunction(() => { + return window.alloy("getMediaAnalyticsTracker").then(Media => { + window.Media = Media; + window.mediaTrackerInstance = Media.getInstance(); + return Media; + }); +}); + +const trackEvent = ClientFunction(eventType => { + const event = window.Media.Event[eventType]; + window.mediaTrackerInstance.trackEvent(event); +}); +const trackPlay = ClientFunction(() => { + window.mediaTrackerInstance.trackPlay(); +}); +const trackPause = ClientFunction(() => { + window.mediaTrackerInstance.trackPause(); +}); +const updatePlayhead = ClientFunction(playhead => { + window.mediaTrackerInstance.updatePlayhead(playhead); +}); + +const startChapter = ClientFunction(() => { + const chapterContextData = { + segmentType: "Sample segment type" + }; + const chapterInfo = window.Media.createChapterObject( + "chapterNumber1", + 2, + 18, + 1 + ); + window.mediaTrackerInstance.trackEvent( + window.Media.Event.ChapterStart, + chapterInfo, + chapterContextData + ); +}); + +const trackSessionStart = ClientFunction(() => { + const Media = window.Media; + const tracker = window.mediaTrackerInstance; + const mediaInfo = Media.createMediaObject( + "NinasVideoName", + "Ninas player video", + 60, + Media.StreamType.VOD, + Media.MediaType.Video + ); + const contextData = { + isUserLoggedIn: "false", + tvStation: "Sample TV station", + programmer: "Sample programmer", + assetID: "/uri-reference" + }; + + contextData[Media.VideoMetadataKeys.Episode] = "Sample Episode"; + contextData[Media.VideoMetadataKeys.Show] = "Sample Show"; + + tracker.trackSessionStart(mediaInfo, contextData); +}); + +const trackAds = ClientFunction(() => { + const tracker = window.mediaTrackerInstance; + + const adObject = window.Media.createAdObject("ad-name", "ad-id", 1, 15.0); + + const adMetadata = {}; + // Standard metadata keys provided by adobe. + adMetadata[window.Media.AdMetadataKeys.Advertiser] = "Sample Advertiser"; + adMetadata[window.Media.AdMetadataKeys.CampaignId] = "Sample Campaign"; + // Custom metadata keys + adMetadata.affiliate = "Sample affiliate"; + + tracker.trackEvent(window.Media.Event.AdStart, adObject, adMetadata); + + // AdComplete + tracker.trackEvent(window.Media.Event.AdComplete); + + // AdSkip + tracker.trackEvent(window.Media.Event.AdSkip); + + // AdBreakStart + const adBreakObject = window.Media.createAdBreakObject("preroll", 1, 0); + tracker.trackEvent(window.Media.Event.AdBreakStart, adBreakObject); + + // AdBreakComplete + tracker.trackEvent(window.Media.Event.AdBreakComplete); +}); + +const trackError = ClientFunction(errorId => { + window.mediaTrackerInstance.trackError(errorId); +}); + +const trackPlaybackEvents = ClientFunction(() => { + // BufferStart + window.mediaTrackerInstance.trackEvent(window.Media.Event.BufferStart); + + // BufferComplete + window.mediaTrackerInstance.trackEvent(window.Media.Event.BufferComplete); + + // SeekStart + window.mediaTrackerInstance.trackEvent(window.Media.Event.SeekStart); + + // SeekComplete + window.mediaTrackerInstance.trackEvent(window.Media.Event.SeekComplete); +}); + +const bitrateChange = ClientFunction(() => { + const qoeObject = window.Media.createQoEObject(1000000, 24, 25, 10); + window.mediaTrackerInstance.updateQoEObject(qoeObject); + + // Bitrate change + window.mediaTrackerInstance.trackEvent(window.Media.Event.BitrateChange); +}); + +const stateChanges = ClientFunction(() => { + // StateStart (ex: Mute is switched on) + const stateObject = window.Media.createStateObject( + window.Media.PlayerState.Mute + ); + window.mediaTrackerInstance.trackEvent( + window.Media.Event.StateStart, + stateObject + ); + + // StateEnd + window.mediaTrackerInstance.trackEvent( + window.Media.Event.StateEnd, + stateObject + ); +}); + +const sessionComplete = ClientFunction(() => { + window.mediaTrackerInstance.trackComplete(); +}); + +test("Test that legacy component send pings automatically and events are transformed correctly into XDM objects.", async () => { + const alloy = createAlloyProxy(); + await alloy.configure(config); + await initializeTracker(); + await trackSessionStart(); + const sessionId = await assertSessionStarted(); + + // play event + await trackPlay(); + await assertEventIsSent( + networkLogger.mediaPlayEndpointLogs, + "media.play", + sessionId, + 0 + ); + + // pause event + await trackPause(); + await assertEventIsSent( + networkLogger.mediaPauseEndpointLogs, + "media.pauseStart", + sessionId, + 0 + ); + + // chapter start event + await startChapter(); + await assertEventIsSent( + networkLogger.chapterStartEndpointLogs, + "media.chapterStart", + sessionId, + 0 + ); + + await updatePlayhead(10); + + await trackEvent("ChapterComplete"); + + // chapter skip event + await trackEvent("ChapterSkip"); + + await trackPlaybackEvents(); + // bitrate change event + await bitrateChange(); + await stateChanges(); + // error event + await trackError("test-buffer-start"); + // ad break start event + + await trackAds(); + await assertEventIsSent( + networkLogger.chapterCompleteEndpointLogs, + "media.chapterComplete", + sessionId, + 10 + ); + + await assertEventIsSent( + networkLogger.chapterSkipEndpointLogs, + "media.chapterSkip", + sessionId, + 10 + ); + + await assertEventIsSent( + networkLogger.adBreakStartEndpointLogs, + "media.adBreakStart", + sessionId, + 10 + ); + await assertEventIsSent( + networkLogger.adStartEndpointLogs, + "media.adStart", + sessionId, + 10 + ); + await assertEventIsSent( + networkLogger.adCompleteEndpointLogs, + "media.adComplete", + sessionId, + 10 + ); + await assertEventIsSent( + networkLogger.adBreakCompleteEndpointLogs, + "media.adBreakComplete", + sessionId, + 10 + ); + await assertEventIsSent( + networkLogger.adSkipEndpointLogs, + "media.adSkip", + sessionId, + 10 + ); + + await assertEventIsSent( + networkLogger.errorEndpointLogs, + "media.error", + sessionId, + 10 + ); + + await assertEventIsSent( + networkLogger.bufferStartEndpointLogs, + "media.bufferStart", + sessionId, + 10 + ); + await assertEventIsSent( + networkLogger.mediaPlayEndpointLogs, + "media.play", + sessionId, + 10, + 1 + ); + + await assertEventIsSent( + networkLogger.bitrateChangeEndpointLogs, + "media.bitrateChange", + sessionId, + 10 + ); + await assertEventIsSent( + networkLogger.statesUpdateEndpointLogs, + "media.statesUpdate", + sessionId, + 10, + 0 + ); + await assertEventIsSent( + networkLogger.statesUpdateEndpointLogs, + "media.statesUpdate", + sessionId, + 10, + 1 + ); + await sleep(10000); + await assertPingsSent(sessionId); + await sessionComplete(); + await sleep(10000); + await assertPingsNotSent(); +}); diff --git a/test/functional/specs/MediaCollection/MA3.js b/test/functional/specs/MediaCollection/MA3.js new file mode 100644 index 000000000..904d5c979 --- /dev/null +++ b/test/functional/specs/MediaCollection/MA3.js @@ -0,0 +1,286 @@ +/* +Copyright 2024 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ +import { t } from "testcafe"; +import createNetworkLogger from "../../helpers/networkLogger"; +import { responseStatus } from "../../helpers/assertions/index"; +import createFixture from "../../helpers/createFixture"; +import { compose } from "../../helpers/constants/configParts"; +import getResponseBody from "../../helpers/networkLogger/getResponseBody"; +import createResponse from "../../helpers/createResponse"; +import { TEST_PAGE as TEST_PAGE_URL } from "../../helpers/constants/url"; +import createAlloyProxy from "../../helpers/createAlloyProxy"; +import orgMediaConfig from "../../helpers/constants/configParts/orgMediaConfig"; +import streamingMedia from "../../helpers/constants/configParts/streamingMedia"; +import { sleep } from "../Migration/helper"; + +const networkLogger = createNetworkLogger(); +const config = compose(orgMediaConfig, streamingMedia); +createFixture({ + title: "Streaming media in non-automatic mode", + url: TEST_PAGE_URL, + requestHooks: [ + networkLogger.edgeEndpointLogs, + networkLogger.mediaPauseEndpointLogs, + networkLogger.mediaPlayEndpointLogs, + networkLogger.chapterStartEndpointLogs, + networkLogger.pingEndpointLogs, + networkLogger.chapterCompleteEndpointLogs, + networkLogger.chapterSkipEndpointLogs, + networkLogger.adStartEndpointLogs, + networkLogger.adBreakStartEndpointLogs, + networkLogger.adBreakCompleteEndpointLogs, + networkLogger.adSkipEndpointLogs, + networkLogger.adCompleteEndpointLogs, + networkLogger.errorEndpointLogs, + networkLogger.sessionCompleteEndpointLogs, + networkLogger.sessionEndEndpointLogs, + networkLogger.statesUpdateEndpointLogs, + networkLogger.bitrateChangeEndpointLogs, + networkLogger.bufferStartEndpointLogs + ] +}); + +test.meta({ + ID: "MA3", + SEVERITY: "P0", + TEST_RUN: "Regression" +}); +const assertSessionStarted = async alloy => { + const sessionPromise = await alloy.createMediaSession({ + xdm: { + mediaCollection: { + playhead: 0, + sessionDetails: { + length: 60, + contentType: "VOD", + name: "test name of the video" + } + } + } + }); + await responseStatus(networkLogger.edgeEndpointLogs.requests, 200); + await t.expect(networkLogger.edgeEndpointLogs.requests.length).eql(1); + + const createSession = networkLogger.edgeEndpointLogs.requests[0]; + const requestBody = JSON.parse(createSession.request.body); + await t.expect(requestBody.events[0].xdm.eventType).eql("media.sessionStart"); + await t.expect(requestBody.events[0].xdm.mediaCollection.playhead).eql(0); + const response = JSON.parse( + getResponseBody(networkLogger.edgeEndpointLogs.requests[0]) + ); + const mediaCollectionPayload = createResponse({ + content: response + }).getPayloadsByType("media-analytics:new-session"); + await t + .expect(mediaCollectionPayload[0].sessionId) + .eql(sessionPromise.sessionId); + + return sessionPromise; +}; + +const assertPingsNotSent = async () => { + await sleep(10000); + + const secondPingEventRequest = networkLogger.pingEndpointLogs.requests[0]; + await t.expect(secondPingEventRequest).eql(undefined); +}; +const sendMediaEvent = async ( + alloy, + eventType, + sessionId, + additionalData = {} +) => { + await alloy.sendMediaEvent({ + xdm: { + eventType, + mediaCollection: { + playhead: 1, + sessionID: sessionId, + ...additionalData + } + } + }); +}; + +const assertEventIsSent = async (endpointLogs, eventType, sessionId) => { + const eventRequest = endpointLogs.requests[0]; + await responseStatus(endpointLogs.requests, 204); + + const event = JSON.parse(eventRequest.request.body).events[0]; + await t.expect(event.xdm.mediaCollection.sessionID).eql(sessionId); + await t.expect(event.xdm.eventType).eql(eventType); +}; + +test("Test that MC component doesn't send pings automatically", async () => { + const alloy = createAlloyProxy(); + await alloy.configure(config); + const { sessionId } = await assertSessionStarted(alloy); + // play event + await sendMediaEvent(alloy, "media.play", sessionId); + await assertEventIsSent( + networkLogger.mediaPlayEndpointLogs, + "media.play", + sessionId + ); + + // pause event + await sendMediaEvent(alloy, "media.pauseStart", sessionId); + await assertEventIsSent( + networkLogger.mediaPauseEndpointLogs, + "media.pauseStart", + sessionId + ); + + // chapter start event + await sendMediaEvent(alloy, "media.chapterStart", sessionId, { + chapterDetails: { + friendlyName: "Chapter 1", + length: 10, + index: 1, + offset: 0 + } + }); + await assertEventIsSent( + networkLogger.chapterStartEndpointLogs, + "media.chapterStart", + sessionId + ); + + // chapter complete event + await sendMediaEvent(alloy, "media.chapterComplete", sessionId); + await assertEventIsSent( + networkLogger.chapterCompleteEndpointLogs, + "media.chapterComplete", + sessionId + ); + + // chapter skip event + await sendMediaEvent(alloy, "media.chapterSkip", sessionId); + await assertEventIsSent( + networkLogger.chapterSkipEndpointLogs, + "media.chapterSkip", + sessionId + ); + + // ad break start event + await sendMediaEvent(alloy, "media.adBreakStart", sessionId, { + advertisingPodDetails: { + friendlyName: "Mid-roll", + offset: 0, + index: 1 + } + }); + await assertEventIsSent( + networkLogger.adBreakStartEndpointLogs, + "media.adBreakStart", + sessionId + ); + // ad start event + await sendMediaEvent(alloy, "media.adStart", sessionId, { + advertisingDetails: { + friendlyName: "Ad 1", + name: "/uri-reference/001", + length: 10, + advertiser: "Adobe Marketing", + campaignID: "Adobe Analytics", + creativeID: "creativeID", + creativeURL: "https://creativeurl.com", + placementID: "placementID", + siteID: "siteID", + podPosition: 11, + playerName: "HTML5 player" + } + }); + await assertEventIsSent( + networkLogger.adStartEndpointLogs, + "media.adStart", + sessionId + ); + + // ad complete event + await sendMediaEvent(alloy, "media.adComplete", sessionId); + await assertEventIsSent( + networkLogger.adCompleteEndpointLogs, + "media.adComplete", + sessionId + ); + // ad break complete event + await sendMediaEvent(alloy, "media.adBreakComplete", sessionId); + await assertEventIsSent( + networkLogger.adBreakCompleteEndpointLogs, + "media.adBreakComplete", + sessionId + ); + // ad skip event + await sendMediaEvent(alloy, "media.adSkip", sessionId); + await assertEventIsSent( + networkLogger.adSkipEndpointLogs, + "media.adSkip", + sessionId + ); + // error event + await sendMediaEvent(alloy, "media.error", sessionId, { + errorDetails: { + name: "test-buffer-start", + source: "player" + } + }); + await assertEventIsSent( + networkLogger.errorEndpointLogs, + "media.error", + sessionId + ); + // bitrate change event + await sendMediaEvent(alloy, "media.bufferStart", sessionId); + await assertEventIsSent( + networkLogger.bufferStartEndpointLogs, + "media.bufferStart", + sessionId + ); + // bitrate change event + await sendMediaEvent(alloy, "media.bitrateChange", sessionId, { + qoeDataDetails: { + framesPerSecond: 1, + bitrate: 35000, + droppedFrames: 30, + timeToStart: 1364 + } + }); + await assertEventIsSent( + networkLogger.bitrateChangeEndpointLogs, + "media.bitrateChange", + sessionId + ); + // states update event + await sendMediaEvent(alloy, "media.statesUpdate", sessionId, { + statesStart: [ + { + name: "mute" + }, + { + name: "pictureInPicture" + } + ], + statesEnd: [ + { + name: "fullScreen" + } + ] + }); + await assertEventIsSent( + networkLogger.statesUpdateEndpointLogs, + "media.statesUpdate", + sessionId + ); + + await assertPingsNotSent(alloy); +}); diff --git a/test/unit/specs/components/LegacyMediaAnalytics/createGetInstance.spec.js b/test/unit/specs/components/LegacyMediaAnalytics/createGetInstance.spec.js new file mode 100644 index 000000000..05a76fa4b --- /dev/null +++ b/test/unit/specs/components/LegacyMediaAnalytics/createGetInstance.spec.js @@ -0,0 +1,257 @@ +const createGetInstance = require("../../../../../src/components/LegacyMediaAnalytics/createGetInstance"); + +describe("createGetInstance", () => { + const logger = { + warn: jasmine.createSpy() + }; + let trackMediaSession; + let trackMediaEvent; + let uuid; + + beforeEach(() => { + trackMediaSession = jasmine.createSpy(); + trackMediaEvent = jasmine.createSpy(); + uuid = jasmine.createSpy().and.returnValue("1234-5678-9101-1121"); + }); + + it("should return an object", () => { + const result = createGetInstance({ + logger, + trackMediaSession, + trackMediaEvent, + uuid + }); + expect(typeof result).toBe("object"); + expect(typeof result.trackSessionStart).toBe("function"); + expect(typeof result.trackPlay).toBe("function"); + expect(typeof result.trackComplete).toBe("function"); + expect(typeof result.trackPause).toBe("function"); + expect(typeof result.trackError).toBe("function"); + expect(typeof result.trackEvent).toBe("function"); + expect(typeof result.trackSessionEnd).toBe("function"); + expect(typeof result.updatePlayhead).toBe("function"); + expect(typeof result.updateQoEObject).toBe("function"); + expect(typeof result.destroy).toBe("function"); + }); + + it("when play is called", () => { + const result = createGetInstance({ + logger, + trackMediaSession, + trackMediaEvent, + uuid + }); + result.trackPlay(); + + expect(trackMediaEvent).toHaveBeenCalledWith({ + playerId: "1234-5678-9101-1121", + xdm: { eventType: "media.play", mediaCollection: {} } + }); + }); + it("when pause is called", () => { + const result = createGetInstance({ + logger, + trackMediaSession, + trackMediaEvent, + uuid + }); + result.trackPause(); + + expect(trackMediaEvent).toHaveBeenCalledWith({ + playerId: "1234-5678-9101-1121", + xdm: { eventType: "media.pauseStart", mediaCollection: {} } + }); + }); + + it("when sessionStart is called", () => { + const result = createGetInstance({ + logger, + trackMediaSession, + trackMediaEvent, + uuid + }); + const sessionDetails = { + name: "test", + friendlyName: "test1", + length: "test2", + streamType: "vod", + contentType: "video/mp4" + }; + + const meta = { + isUserLoggedIn: "false", + tvStation: "Sample TV station", + programmer: "Sample programmer", + assetID: "/uri-reference", + "a.media.episode": "episode1" + }; + result.trackSessionStart({ sessionDetails }, meta); + expect(trackMediaSession).toHaveBeenCalledWith({ + playerId: "1234-5678-9101-1121", + getPlayerDetails: jasmine.any(Function), + xdm: { + eventType: "media.sessionStart", + mediaCollection: { + sessionDetails: { + name: "test", + friendlyName: "test1", + length: "test2", + streamType: "vod", + contentType: "video/mp4", + episode: "episode1" + }, + customMetadata: [ + { name: "isUserLoggedIn", value: "false" }, + { name: "tvStation", value: "Sample TV station" }, + { name: "programmer", value: "Sample programmer" }, + { name: "assetID", value: "/uri-reference" } + ] + } + } + }); + }); + + it("when trackError is called", () => { + const result = createGetInstance({ + logger, + trackMediaSession, + trackMediaEvent, + uuid + }); + result.trackError("error"); + + expect(trackMediaEvent).toHaveBeenCalledWith({ + playerId: "1234-5678-9101-1121", + xdm: { + eventType: "media.error", + mediaCollection: { + errorDetails: { name: "error", source: "player" } + } + } + }); + expect(logger.warn).toHaveBeenCalled(); + }); + + it("when trackComplete is called", () => { + const result = createGetInstance({ + logger, + trackMediaSession, + trackMediaEvent, + uuid + }); + result.trackComplete(); + + expect(trackMediaEvent).toHaveBeenCalledWith({ + playerId: "1234-5678-9101-1121", + xdm: { + eventType: "media.sessionComplete", + mediaCollection: {} + } + }); + }); + it("when trackSessionEnd is called", () => { + const result = createGetInstance({ + logger, + trackMediaSession, + trackMediaEvent, + uuid + }); + result.trackSessionEnd(); + + expect(trackMediaEvent).toHaveBeenCalledWith({ + playerId: "1234-5678-9101-1121", + xdm: { + eventType: "media.sessionEnd", + mediaCollection: {} + } + }); + }); + it("when state update is called", () => { + const result = createGetInstance({ + logger, + trackMediaSession, + trackMediaEvent, + uuid + }); + const state = { + name: "muted" + }; + result.trackEvent("stateStart", state); + + expect(trackMediaEvent).toHaveBeenCalledWith({ + playerId: "1234-5678-9101-1121", + xdm: { + eventType: "media.statesUpdate", + mediaCollection: { + statesStart: [{ name: "muted" }] + } + } + }); + }); + it("when state update is called", () => { + const result = createGetInstance({ + logger, + trackMediaSession, + trackMediaEvent, + uuid + }); + const state = { + name: "muted" + }; + result.trackEvent("stateEnd", state); + + expect(trackMediaEvent).toHaveBeenCalledWith({ + playerId: "1234-5678-9101-1121", + xdm: { + eventType: "media.statesUpdate", + mediaCollection: { + statesEnd: [{ name: "muted" }] + } + } + }); + }); + it("when track adds is called add get's converted correctly", () => { + const result = createGetInstance({ + logger, + trackMediaSession, + trackMediaEvent, + uuid + }); + const advertisingDetails = { + friendlyName: "test", + name: "trst1", + podPosition: 2, + length: 100 + }; + + const adContextData = { + affiliate: "Sample affiliate 2", + campaign: "Sample ad campaign 2", + "a.media.ad.advertiser": "Sample Advertiser 2", + "a.media.ad.campaign": "csmpaign2" + }; + + result.trackEvent("adStart", { advertisingDetails }, adContextData); + + expect(trackMediaEvent).toHaveBeenCalledWith({ + playerId: "1234-5678-9101-1121", + xdm: { + eventType: "media.adStart", + mediaCollection: { + advertisingDetails: { + friendlyName: "test", + name: "trst1", + podPosition: 2, + length: 100, + advertiser: "Sample Advertiser 2", + campaignID: "csmpaign2" + }, + customMetadata: [ + { name: "affiliate", value: "Sample affiliate 2" }, + { name: "campaign", value: "Sample ad campaign 2" } + ] + } + } + }); + }); +}); diff --git a/test/unit/specs/components/LegacyMediaAnalytics/createLegacyMediaComponent.spec.js b/test/unit/specs/components/LegacyMediaAnalytics/createLegacyMediaComponent.spec.js new file mode 100644 index 000000000..4714c8aba --- /dev/null +++ b/test/unit/specs/components/LegacyMediaAnalytics/createLegacyMediaComponent.spec.js @@ -0,0 +1,105 @@ +/* +Copyright 2024 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +import createLegacyMediaComponent from "../../../../../src/components/LegacyMediaAnalytics/createLegacyMediaComponent"; + +describe("LegacyMediaAnalytics::createLegacyMediaComponent", () => { + const config = { + streamingMedia: { + channel: "testChannel", + playerName: "testPlayerName", + appVersion: "testAppVersion" + } + }; + let logger; + let legacyMediaComponent; + let trackMediaEvent; + let mediaResponseHandler; + let trackMediaSession; + let createMediaHelper; + let createGetInstance; + + const build = configs => { + legacyMediaComponent = createLegacyMediaComponent({ + config: configs, + logger, + trackMediaEvent, + mediaResponseHandler, + trackMediaSession, + createMediaHelper, + createGetInstance + }); + }; + + beforeEach(() => { + logger = jasmine.createSpyObj("logger", ["info"]); + mediaResponseHandler = jasmine.createSpy(); + trackMediaEvent = jasmine.createSpy(); + trackMediaSession = jasmine.createSpy(); + createMediaHelper = jasmine.createSpy(); + createGetInstance = jasmine.createSpy(); + build(config); + }); + + it("should reject promise when called with invalid config", async () => { + build({}); + const getMediaAnalyticsTracker = + legacyMediaComponent.commands.getMediaAnalyticsTracker; + + return expectAsync(getMediaAnalyticsTracker.run()).toBeRejected(); + }); + + it("should call createGetInstance when getInstance Media API is called", async () => { + build(config); + + const { getMediaAnalyticsTracker } = legacyMediaComponent.commands; + const mediaApi = await getMediaAnalyticsTracker.run(); + mediaApi.getInstance(); + expect(createGetInstance).toHaveBeenCalled(); + }); + + it("should call onBeforeMediaEvent when onBeforeEvent is called with legacy flag", async () => { + build(config); + const getPlayerDetails = () => {}; + const { onBeforeEvent } = legacyMediaComponent.lifecycle; + const mediaOptions = { + legacy: true, + playerId: "testPlayerId", + getPlayerDetails + }; + const onResponseHandler = onResponse => { + onResponse({ response: {} }); + }; + onBeforeEvent({ mediaOptions, onResponse: onResponseHandler }); + expect(mediaResponseHandler).toHaveBeenCalledWith({ + getPlayerDetails, + playerId: "testPlayerId", + response: {} + }); + }); + + it("should not call onBeforeMediaEvent when onBeforeEvent is called without legacy flag", async () => { + build(config); + const getPlayerDetails = () => {}; + const { onBeforeEvent } = legacyMediaComponent.lifecycle; + const mediaOptions = { + legacy: false, + playerId: "testPlayerId", + getPlayerDetails + }; + const onResponseHandler = onResponse => { + onResponse({ response: {} }); + }; + onBeforeEvent({ mediaOptions, onResponse: onResponseHandler }); + expect(mediaResponseHandler).not.toHaveBeenCalled(); + }); +}); diff --git a/test/unit/specs/components/LegacyMediaAnalytics/createMediaHelper.spec.js b/test/unit/specs/components/LegacyMediaAnalytics/createMediaHelper.spec.js new file mode 100644 index 000000000..a9f69a1c8 --- /dev/null +++ b/test/unit/specs/components/LegacyMediaAnalytics/createMediaHelper.spec.js @@ -0,0 +1,248 @@ +import createMediaHelper from "../../../../../src/components/LegacyMediaAnalytics/createMediaHelper"; + +describe("createMediaHelper", () => { + let logger; + let mediaHelper; + + beforeEach(() => { + logger = { + warn: jasmine.createSpy("warn") + }; + mediaHelper = createMediaHelper({ logger }); + }); + + describe("createMediaObject", () => { + it("should return a valid media object when called with valid arguments", () => { + const friendlyName = "testFriendlyName"; + const name = "testName"; + const length = 120; + const contentType = "video/mp4"; + const streamType = "VOD"; + + const expectedResult = { + sessionDetails: { + friendlyName, + name, + length, + contentType, + streamType + } + }; + + const result = mediaHelper.createMediaObject( + friendlyName, + name, + length, + contentType, + streamType + ); + + expect(result).toEqual(expectedResult); + }); + + it("should log a warning and return an empty object when validation fails", () => { + const friendlyName = ""; + const name = ""; + const length = "invalid"; + const contentType = ""; + const streamType = ""; + + const expectedResult = {}; + + const result = mediaHelper.createMediaObject( + friendlyName, + name, + length, + contentType, + streamType + ); + + expect(result).toEqual(expectedResult); + expect(logger.warn).toHaveBeenCalled(); + }); + }); + + describe("createAdBreakObject", () => { + it("should return a valid ad break object when called with valid arguments", () => { + const name = "testAdBreak"; + const position = 1; + const startTime = 120; + + const expectedResult = { + advertisingPodDetails: { + friendlyName: name, + offset: position, + index: startTime + } + }; + + const result = mediaHelper.createAdBreakObject(name, position, startTime); + + expect(result).toEqual(expectedResult); + }); + + it("should log a warning and return an empty object when validation fails", () => { + const name = ""; + const position = "invalid"; + const startTime = "invalid"; + + const expectedResult = {}; + + const result = mediaHelper.createAdBreakObject(name, position, startTime); + + expect(result).toEqual(expectedResult); + expect(logger.warn).toHaveBeenCalled(); + }); + }); + + describe("createAdObject", () => { + it("should return a valid ad object when called with valid arguments", () => { + const name = "testAd"; + const id = "testId"; + const position = 1; + const length = 30; + + const expectedResult = { + advertisingDetails: { + friendlyName: name, + name: id, + podPosition: position, + length + } + }; + + const result = mediaHelper.createAdObject(name, id, position, length); + + expect(result).toEqual(expectedResult); + }); + + it("should log a warning and return an empty object when validation fails", () => { + const name = ""; + const id = ""; + const position = "invalid"; + const length = "invalid"; + + const expectedResult = {}; + + const result = mediaHelper.createAdObject(name, id, position, length); + + expect(result).toEqual(expectedResult); + expect(logger.warn).toHaveBeenCalled(); + }); + }); + + describe("createChapterObject", () => { + it("should return a valid chapter object when called with valid arguments", () => { + const name = "testChapter"; + const position = 1; + const length = 30; + const startTime = 120; + + const expectedResult = { + chapterDetails: { + friendlyName: name, + offset: position, + length, + index: startTime + } + }; + + const result = mediaHelper.createChapterObject( + name, + position, + length, + startTime + ); + + expect(result).toEqual(expectedResult); + }); + + it("should log a warning and return an empty object when validation fails", () => { + const name = ""; + const position = "invalid"; + const length = "invalid"; + const startTime = "invalid"; + + const expectedResult = {}; + + const result = mediaHelper.createChapterObject( + name, + position, + length, + startTime + ); + + expect(result).toEqual(expectedResult); + expect(logger.warn).toHaveBeenCalled(); + }); + }); + + describe("createStateObject", () => { + it("should return a valid state object when called with valid arguments", () => { + const stateName = "testState"; + + const expectedResult = { + name: stateName + }; + + const result = mediaHelper.createStateObject(stateName); + + expect(result).toEqual(expectedResult); + }); + + it("should log a warning and return an empty object when validation fails", () => { + const stateName = "invalid state name"; + + const expectedResult = {}; + + const result = mediaHelper.createStateObject(stateName); + + expect(result).toEqual(expectedResult); + expect(logger.warn).toHaveBeenCalled(); + }); + }); + + describe("createQoEObject", () => { + it("should return a valid QOE object when called with valid arguments", () => { + const bitrate = 5000; + const droppedFrames = 10; + const framesPerSecond = 30; + const timeToStart = 2; + + const expectedResult = { + bitrate, + droppedFrames, + framesPerSecond, + timeToStart + }; + + const result = mediaHelper.createQoEObject( + bitrate, + droppedFrames, + framesPerSecond, + timeToStart + ); + + expect(result).toEqual(expectedResult); + }); + + it("should log a warning and return an empty object when validation fails", () => { + const bitrate = "invalid"; + const droppedFrames = "invalid"; + const fps = "invalid"; + const startupTime = "invalid"; + + const expectedResult = {}; + + const result = mediaHelper.createQoEObject( + bitrate, + droppedFrames, + fps, + startupTime + ); + + expect(result).toEqual(expectedResult); + expect(logger.warn).toHaveBeenCalled(); + }); + }); +}); diff --git a/test/unit/specs/components/StreamingMedia/configValidators.spec.js b/test/unit/specs/components/StreamingMedia/configValidators.spec.js new file mode 100644 index 000000000..dd42f5694 --- /dev/null +++ b/test/unit/specs/components/StreamingMedia/configValidators.spec.js @@ -0,0 +1,63 @@ +/* +Copyright 2024 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +import configValidators from "../../../../../src/components/StreamingMedia/configValidators"; +import testConfigValidators from "../../../helpers/testConfigValidators"; + +describe("Streaming Media config validators", () => { + testConfigValidators({ + configValidators, + validConfigurations: [ + {}, + { + streamingMedia: { + channel: "test-channel", + playerName: "test-player-name" + } + }, + { + streamingMedia: { + channel: "test-channel", + playerName: "test-player-name", + appVersion: "test-app-version" + } + }, + { + streamingMedia: { + channel: "test-channel", + playerName: "test-player-name", + appVersion: "test-app-version", + mainPingInterval: 10, + adPingInterval: 1 + } + } + ], + invalidConfigurations: [ + { streamingMedia: "" }, + { streamingMedia: {} }, + { streamingMedia: { channel: "test-channel" } }, + { streamingMedia: { playerName: "test-player-name" } } + ], + defaultValues: {} + }); + + it("provides default values when Streaming media configured", () => { + const config = configValidators({ + streamingMedia: { + channel: "test-channel", + playerName: "test-player-name" + } + }); + expect(config.streamingMedia.adPingInterval).toBe(10); + expect(config.streamingMedia.mainPingInterval).toBe(10); + }); +}); diff --git a/test/unit/specs/components/StreamingMedia/createMediaEventManager.spec.js b/test/unit/specs/components/StreamingMedia/createMediaEventManager.spec.js new file mode 100644 index 000000000..e13be4383 --- /dev/null +++ b/test/unit/specs/components/StreamingMedia/createMediaEventManager.spec.js @@ -0,0 +1,147 @@ +/* +Copyright 2024 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +// tests for createMediaEventManager.js + +import createMediaEventManager from "../../../../../src/components/StreamingMedia/createMediaEventManager"; + +describe("StreamingMedia::createMediaEventManager", () => { + let config; + let eventManager; + let consent; + let sendEdgeNetworkRequest; + let mediaEventManager; + let setTimestamp; + + beforeEach(() => { + config = { + streamingMedia: { + playerName: "player1", + channel: "channel1", + version: "1.0.0" + } + }; + eventManager = jasmine.createSpyObj("eventManager", [ + "createEvent", + "sendEvent" + ]); + consent = jasmine.createSpyObj("consent", ["awaitConsent"]); + sendEdgeNetworkRequest = jasmine + .createSpy("sendEdgeNetworkRequest") + .and.returnValue(Promise.resolve()); + setTimestamp = jasmine.createSpy("setTimestamp"); + mediaEventManager = createMediaEventManager({ + config, + eventManager, + consent, + sendEdgeNetworkRequest, + setTimestamp + }); + }); + + it("should create a media event with user xdm", () => { + const options = { xdm: {} }; + const event = { + setUserXdm: jasmine.createSpy("setUserXdm"), + toJSON: () => ({ a: 1 }) + }; + + eventManager.createEvent.and.returnValue(event); + + const result = mediaEventManager.createMediaEvent({ options }); + + expect(result.toJSON()).toEqual(event.toJSON()); + }); + + it("should create a media session with player name, channel, and version", () => { + const options = { + xdm: { + mediaCollection: { + playerName: "player1", + channel: "channel1", + version: "1.0.0", + sessionDetails: {} + } + } + }; + + const event = { + setUserXdm: jasmine.createSpy("setUserXdm"), + mergeXdm: jasmine.createSpy("mergeXdm"), + toJSON: () => ({ a: 1 }) + }; + + eventManager.createEvent.and.returnValue(event); + + const result = mediaEventManager.createMediaSession(options); + + expect(result.toJSON()).toEqual(event.toJSON()); + }); + + it("should augment media event with playhead, qoeDataDetails, and sessionID", () => { + const event = { + mergeXdm: jasmine.createSpy("mergeXdm") + }; + const playerId = "player1"; + const getPlayerDetails = jasmine + .createSpy("getPlayerDetails") + .and.returnValue({ + playhead: 10, + qoeDataDetails: { duration: 60 } + }); + const sessionID = "session1"; + + const result = mediaEventManager.augmentMediaEvent({ + event, + playerId, + getPlayerDetails, + sessionID + }); + + expect(result).toBe(event); + expect(getPlayerDetails).toHaveBeenCalledWith({ playerId }); + expect(event.mergeXdm).toHaveBeenCalledWith({ + mediaCollection: { + playhead: 10, + qoeDataDetails: { duration: 60 }, + sessionID: "session1" + } + }); + }); + + it("should track media session with event, playerId, and getPlayerDetails", () => { + const event = {}; + const playerId = "player1"; + const getPlayerDetails = () => {}; + const mediaOptions = { playerId, getPlayerDetails, legacy: false }; + + mediaEventManager.trackMediaSession({ + event, + mediaOptions + }); + expect(eventManager.sendEvent).toHaveBeenCalledWith(event, { + mediaOptions + }); + }); + + it("should track media event with action and send request to Edge Network", async () => { + const event = jasmine.createSpyObj("event", ["finalize"]); + const action = "play"; + + consent.awaitConsent.and.returnValue(Promise.resolve()); + + await mediaEventManager.trackMediaEvent({ event, action }); + + expect(event.finalize).toHaveBeenCalled(); + expect(sendEdgeNetworkRequest).toHaveBeenCalled(); + }); +}); diff --git a/test/unit/specs/components/StreamingMedia/createMediaRequest.spec.js b/test/unit/specs/components/StreamingMedia/createMediaRequest.spec.js new file mode 100644 index 000000000..4f9c11520 --- /dev/null +++ b/test/unit/specs/components/StreamingMedia/createMediaRequest.spec.js @@ -0,0 +1,26 @@ +/* +Copyright 2023 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +import createMediaRequest from "../../../../../src/components/StreamingMedia/createMediaRequest"; + +describe("StreamingMedia::createMediaRequest", () => { + it("should call createRequest with correct parameters", () => { + const mediaRequestPayload = {}; // replace with valid payload + const action = "testAction"; + const edgeSubPath = "/va"; + const result = createMediaRequest({ mediaRequestPayload, action }); + + expect(result.getAction()).toEqual(action); + expect(result.getEdgeSubPath()).toEqual(edgeSubPath); + expect(result.getUseSendBeacon()).toEqual(false); + }); +}); diff --git a/test/unit/specs/components/StreamingMedia/createMediaResponseHandler.spec.js b/test/unit/specs/components/StreamingMedia/createMediaResponseHandler.spec.js new file mode 100644 index 000000000..e5307a4e7 --- /dev/null +++ b/test/unit/specs/components/StreamingMedia/createMediaResponseHandler.spec.js @@ -0,0 +1,75 @@ +import createMediaResponseHandler from "../../../../../src/components/StreamingMedia/createMediaResponseHandler"; + +describe("createMediaResponseHandler", () => { + let trackMediaEvent; + let mediaSessionCacheManager; + let config; + let logger; + let mediaResponseHandler; + let response; + const getPlayerDetails = () => {}; + + beforeEach(() => { + response = { + getPayloadsByType: jasmine.createSpy() + }; + mediaSessionCacheManager = { + getSession: jasmine.createSpy().and.returnValue({ + getPlayerDetails: jasmine.createSpy(), + sessionPromise: Promise.resolve({ sessionId: "123" }) + }), + stopPing: jasmine.createSpy(), + savePing: jasmine.createSpy() + }; + config = { + streamingMedia: { + adPingInterval: 5, + mainPingInterval: 10 + } + }; + logger = { + info: jasmine.createSpy() + }; + trackMediaEvent = jasmine.createSpy(); + mediaResponseHandler = createMediaResponseHandler({ + mediaSessionCacheManager, + logger, + config, + trackMediaEvent + }); + }); + + it("should return empty object when no media payload", async () => { + response.getPayloadsByType.and.returnValue([]); + + const result = await mediaResponseHandler({ + response, + playerId: "player1", + getPlayerDetails + }); + await expect(result).toEqual({}); + await expect(mediaSessionCacheManager.savePing).not.toHaveBeenCalled(); + }); + + it("should return session id", async () => { + response.getPayloadsByType.and.returnValue([{ sessionId: "123" }]); + + const result = await mediaResponseHandler({ + response, + playerId: "player1", + getPlayerDetails + }); + await expect(result).toEqual({ sessionId: "123" }); + await expect(mediaSessionCacheManager.savePing).toHaveBeenCalled(); + }); + + it("should return sessionId when no player or getPlayerDetails function", async () => { + response.getPayloadsByType.and.returnValue([{ sessionId: "123" }]); + + const result = await mediaResponseHandler({ + response + }); + await expect(result).toEqual({ sessionId: "123" }); + await expect(mediaSessionCacheManager.savePing).not.toHaveBeenCalled(); + }); +}); diff --git a/test/unit/specs/components/StreamingMedia/createMediaSessionCacheManager.spec.js b/test/unit/specs/components/StreamingMedia/createMediaSessionCacheManager.spec.js new file mode 100644 index 000000000..9280e3c54 --- /dev/null +++ b/test/unit/specs/components/StreamingMedia/createMediaSessionCacheManager.spec.js @@ -0,0 +1,65 @@ +/* +Copyright 2023 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +import createMediaSessionCacheManager from "../../../../../src/components/StreamingMedia/createMediaSessionCacheManager"; + +describe("StreamingMedia::createMediaSessionCacheManager", () => { + let mediaSessionCacheManager; + + beforeEach(() => { + mediaSessionCacheManager = createMediaSessionCacheManager(); + }); + + it("getSession should return correct session", () => { + const playerId = "player1"; + const sessionDetails = { id: "session1" }; + mediaSessionCacheManager.storeSession({ playerId, sessionDetails }); + + const result = mediaSessionCacheManager.getSession(playerId); + + expect(result).toEqual(sessionDetails); + }); + + it("stopPing should stop the Ping", () => { + const playerId = "player1"; + const sessionDetails = { id: "session1", pingId: 1 }; + + mediaSessionCacheManager.storeSession({ playerId, sessionDetails }); + + const result = mediaSessionCacheManager.getSession(playerId); + + mediaSessionCacheManager.stopPing({ playerId }); + + expect(result.pingId).toEqual(null); + }); + + it("storeSession should store the session", () => { + const playerId = "player1"; + const sessionDetails = { id: "session1" }; + mediaSessionCacheManager.storeSession({ playerId, sessionDetails }); + + const session = mediaSessionCacheManager.getSession(playerId); + + expect(session).toEqual(sessionDetails); + }); + + it("savePing should save the Ping", () => { + const playerId = "player1"; + const sessionDetails = { id: "session1" }; + mediaSessionCacheManager.storeSession({ playerId, sessionDetails }); + mediaSessionCacheManager.savePing({ playerId, pingId: 1 }); + + const session = mediaSessionCacheManager.getSession(playerId); + + expect(session.pingId).toEqual(1); + }); +}); diff --git a/test/unit/specs/components/StreamingMedia/createStreamingMediaComponent.spec.js b/test/unit/specs/components/StreamingMedia/createStreamingMediaComponent.spec.js new file mode 100644 index 000000000..d0c4cb625 --- /dev/null +++ b/test/unit/specs/components/StreamingMedia/createStreamingMediaComponent.spec.js @@ -0,0 +1,78 @@ +/* +Copyright 2023 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +import createStreamingMediaComponent from "../../../../../src/components/StreamingMedia/createStreamingMediaComponent"; + +describe("StreamingMedia::createComponent", () => { + const config = { + streamingMedia: { + channel: "testChannel", + playerName: "testPlayerName", + appVersion: "testAppVersion" + } + }; + let logger; + let mediaComponent; + let trackMediaEvent; + let mediaResponseHandler; + let trackMediaSession; + + const build = configs => { + mediaComponent = createStreamingMediaComponent({ + config: configs, + logger, + trackMediaEvent, + mediaResponseHandler, + trackMediaSession + }); + }; + + beforeEach(() => { + logger = jasmine.createSpyObj("logger", ["warn"]); + mediaResponseHandler = jasmine.createSpy(); + trackMediaEvent = jasmine.createSpy(); + trackMediaSession = jasmine.createSpy(); + build(config); + }); + + it("should call trackSession when with invalid config", async () => { + build({}); + const options = { + playerId: "testPlayerId", + getPlayerDetails: () => {}, + xdm: { + mediaCollection: { + sessionDetails: { + playerName: "testPlayerName" + } + } + } + }; + + const createMediaSession = mediaComponent.commands.createMediaSession; + await createMediaSession.run(options); + expect(trackMediaSession).toHaveBeenCalled(); + }); + + it("should not send media event if no valid configs", async () => { + build({}); + const options = { + playerId: "testPlayerId", + xdm: { + mediaCollection: {} + } + }; + + const { sendMediaEvent } = mediaComponent.commands; + return expectAsync(sendMediaEvent.run(options)).toBeRejected(); + }); +}); diff --git a/test/unit/specs/components/StreamingMedia/createTrackMediaEvent.spec.js b/test/unit/specs/components/StreamingMedia/createTrackMediaEvent.spec.js new file mode 100644 index 000000000..362924709 --- /dev/null +++ b/test/unit/specs/components/StreamingMedia/createTrackMediaEvent.spec.js @@ -0,0 +1,84 @@ +import createTrackMediaEvent from "../../../../../src/components/StreamingMedia/createTrackMediaEvent"; +import MediaEvents from "../../../../../src/components/StreamingMedia/constants/eventTypes"; + +describe("createTrackMediaEvent", () => { + let trackMediaEvent; + let mediaEventManager; + let mediaSessionCacheManager; + let config; + + beforeEach(() => { + mediaEventManager = { + createMediaEvent: jasmine.createSpy(), + augmentMediaEvent: jasmine.createSpy(), + trackMediaEvent: jasmine.createSpy().and.returnValue(Promise.resolve()) + }; + mediaSessionCacheManager = { + getSession: jasmine.createSpy().and.returnValue({ + getPlayerDetails: jasmine.createSpy(), + sessionPromise: Promise.resolve({ sessionId: "123" }) + }), + stopPing: jasmine.createSpy(), + savePing: jasmine.createSpy() + }; + config = { + streamingMedia: { + adPingInterval: 5, + mainPingInterval: 10 + } + }; + trackMediaEvent = createTrackMediaEvent({ + mediaEventManager, + mediaSessionCacheManager, + config + }); + }); + + it("should send a media event", async () => { + const options = { + playerId: "player1", + xdm: { + eventType: "media.play" + } + }; + + await trackMediaEvent(options); + + expect(mediaEventManager.createMediaEvent).toHaveBeenCalledWith({ + options + }); + expect(mediaSessionCacheManager.getSession).toHaveBeenCalledWith( + options.playerId + ); + expect(mediaEventManager.augmentMediaEvent).toHaveBeenCalled(); + expect(mediaEventManager.trackMediaEvent).toHaveBeenCalled(); + }); + + it("should stop the Ping for session complete event", async () => { + const options = { + playerId: "player1", + xdm: { + eventType: MediaEvents.SESSION_COMPLETE + } + }; + + await trackMediaEvent(options); + + expect(mediaSessionCacheManager.stopPing).toHaveBeenCalledWith({ + playerId: options.playerId + }); + }); + + it("should save the Ping for non-session complete event", async () => { + const options = { + playerId: "player1", + xdm: { + eventType: "media.play" + } + }; + + await trackMediaEvent(options); + + expect(mediaSessionCacheManager.savePing).toHaveBeenCalled(); + }); +}); diff --git a/test/unit/specs/components/StreamingMedia/createTrackMediaSession.spec.js b/test/unit/specs/components/StreamingMedia/createTrackMediaSession.spec.js new file mode 100644 index 000000000..b76d363ab --- /dev/null +++ b/test/unit/specs/components/StreamingMedia/createTrackMediaSession.spec.js @@ -0,0 +1,115 @@ +import createTrackMediaSession from "../../../../../src/components/StreamingMedia/createTrackMediaSession"; +import PlaybackState from "../../../../../src/components/StreamingMedia/constants/playbackState"; + +describe("createTrackMediaEvent", () => { + let trackMediaSession; + let mediaEventManager; + let mediaSessionCacheManager; + let config; + let logger; + + beforeEach(() => { + logger = { + warn: jasmine.createSpy() + }; + mediaEventManager = { + createMediaSession: jasmine.createSpy(), + augmentMediaEvent: jasmine.createSpy(), + trackMediaSession: jasmine.createSpy().and.returnValue(Promise.resolve()) + }; + mediaSessionCacheManager = { + storeSession: jasmine.createSpy() + }; + config = { + streamingMedia: { + playerName: "testPlayerName", + channel: "testChannel", + adPingInterval: 5, + mainPingInterval: 10 + } + }; + trackMediaSession = createTrackMediaSession({ + config, + logger, + mediaEventManager, + mediaSessionCacheManager + }); + }); + it("should track a session", async () => { + const sessionPromise = Promise.resolve("123"); + const playerId = "testPlayerId"; + const playerName = "testPlayerName"; + const eventType = "media.sessionStart"; + const event = { eventType }; + const getPlayerDetails = () => {}; + + const options = { + playerId, + getPlayerDetails, + xdm: { + mediaCollection: { + sessionDetails: { + playerName + } + } + } + }; + mediaEventManager.createMediaSession.and.returnValue({ eventType }); + mediaEventManager.augmentMediaEvent.and.returnValue({ + eventType, + xdm: { + mediaCollection: { + sessionDetails: { + playerName + }, + playhead: 0 + } + } + }); + mediaEventManager.trackMediaSession.and.returnValue(sessionPromise); + + await trackMediaSession(options); + + expect(mediaEventManager.createMediaSession).toHaveBeenCalledWith(options); + + expect(mediaEventManager.augmentMediaEvent).toHaveBeenCalledWith({ + event, + playerId, + getPlayerDetails + }); + + expect(mediaEventManager.trackMediaSession).toHaveBeenCalledWith({ + event, + mediaOptions: { + playerId, + getPlayerDetails, + legacy: false + } + }); + + expect(mediaSessionCacheManager.storeSession).toHaveBeenCalledWith({ + playerId, + sessionDetails: { + sessionPromise, + getPlayerDetails, + playbackState: PlaybackState.MAIN + } + }); + }); + it("should not track session when no valid configs", async () => { + config = {}; + trackMediaSession = createTrackMediaSession({ + config, + mediaEventManager, + mediaSessionCacheManager + }); + const options = { + playerId: "player1", + xdm: { + eventType: "media.sessionStart" + }, + getPlayerDetails: "" + }; + return expectAsync(trackMediaSession(options)).toBeRejected(); + }); +}); diff --git a/test/unit/specs/components/StreamingMedia/validateMediaEventOptions.spec.js b/test/unit/specs/components/StreamingMedia/validateMediaEventOptions.spec.js new file mode 100644 index 000000000..de319e527 --- /dev/null +++ b/test/unit/specs/components/StreamingMedia/validateMediaEventOptions.spec.js @@ -0,0 +1,64 @@ +/* +Copyright 2023 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +import validateMediaEventOptions from "../../../../../src/components/StreamingMedia/validateMediaEventOptions"; + +describe("StreamingMedia::validateMediaEventOptions", () => { + it("should not fail when payerId and xdm are used", () => { + const options = { + playerId: "playerId", + xdm: { + eventType: "media.play", + mediaCollection: { + playhead: 0, + sessionID: "sessionID" + } + } + }; + + expect(() => { + validateMediaEventOptions({ options }); + }).not.toThrowError(); + }); + + it("should not fail when xdm with playhead is used", () => { + const options = { + xdm: { + eventType: "media.play", + mediaCollection: { + playhead: 0, + sessionID: "sessionID" + } + } + }; + + expect(() => { + validateMediaEventOptions({ options }); + }).not.toThrowError(); + }); + + it("should throw an error when invalid options are passed", () => { + const options = { + xdm: { + eventType: "media.play", + mediaCollection: { + playhead: "0", + sessionID: "sessionID" + } + } + }; + + expect(() => { + validateMediaEventOptions({ options }); + }).toThrowError(); + }); +}); diff --git a/test/unit/specs/components/StreamingMedia/validateMediaSessionOptions.spec.js b/test/unit/specs/components/StreamingMedia/validateMediaSessionOptions.spec.js new file mode 100644 index 000000000..58575c5f3 --- /dev/null +++ b/test/unit/specs/components/StreamingMedia/validateMediaSessionOptions.spec.js @@ -0,0 +1,64 @@ +/* +Copyright 2023 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +import validateMediaSessionOptions from "../../../../../src/components/StreamingMedia/validateMediaSessionOptions"; + +describe("StreamingMedia::validateMediaSessionOptions", () => { + it("should not fail when playerId, callback and xdm are used", () => { + const options = { + playerId: "playerId", + getPlayerDetails: () => {}, + xdm: { + eventType: "eventType", + mediaCollection: { + sessionDetails: {} + } + } + }; + + expect(() => { + validateMediaSessionOptions({ options }); + }).not.toThrowError(); + }); + + it("should not fail when playerId, callback and xdm are used", () => { + const options = { + xdm: { + eventType: "eventType", + mediaCollection: { + playhead: 0, + sessionDetails: {} + } + } + }; + + expect(() => { + validateMediaSessionOptions({ options }); + }).not.toThrowError(); + }); + + it("should throw an error when invalid options are passed", () => { + const options = { + xdm: { + eventType: "eventType", + mediaCollection: { + playhead: "0", + sessionID: "sessionID" + } + } + }; + + expect(() => { + validateMediaSessionOptions({ options }); + }).toThrowError(); + }); +}); diff --git a/test/unit/specs/core/edgeNetwork/injectSendEdgeNetworkRequest.spec.js b/test/unit/specs/core/edgeNetwork/injectSendEdgeNetworkRequest.spec.js index 39db7a815..671a19a67 100644 --- a/test/unit/specs/core/edgeNetwork/injectSendEdgeNetworkRequest.spec.js +++ b/test/unit/specs/core/edgeNetwork/injectSendEdgeNetworkRequest.spec.js @@ -116,7 +116,8 @@ describe("injectSendEdgeNetworkRequest", () => { getPayload: payload, getUseIdThirdPartyDomain: false, getUseSendBeacon: false, - getDatastreamIdOverride: "" + getDatastreamIdOverride: "", + getEdgeSubPath: "" }); logger = jasmine.createSpyObj("logger", ["info"]); lifecycle = jasmine.createSpyObj("lifecycle", { diff --git a/test/unit/specs/utils/validation/createMaximumValidator.spec.js b/test/unit/specs/utils/validation/createMaximumValidator.spec.js new file mode 100644 index 000000000..e6357101c --- /dev/null +++ b/test/unit/specs/utils/validation/createMaximumValidator.spec.js @@ -0,0 +1,56 @@ +/* +Copyright 2023 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +import { number } from "../../../../../src/utils/validation"; +import describeValidation from "../../../helpers/describeValidation"; + +describe("validation::maximum", () => { + describeValidation( + "optional maximum", + number() + .integer() + .maximum(4), + [ + { value: 3 }, + { value: 5, error: true }, + { value: null }, + { value: undefined } + ] + ); + + describeValidation( + "required maximum", + number() + .integer() + .maximum(2) + .required(), + [ + { value: null, error: true }, + { value: undefined, error: true }, + { value: 3, error: true }, + { value: 1 } + ] + ); + + describeValidation( + "default maximum", + number() + .integer() + .maximum(10) + .default(8), + [ + { value: null, expected: 8 }, + { value: undefined, expected: 8 }, + { value: 11, error: true } + ] + ); +}); diff --git a/test/unit/specs/utils/validation/matchesRegexpValidator.spec.js b/test/unit/specs/utils/validation/matchesRegexpValidator.spec.js new file mode 100644 index 000000000..f8bc21dd9 --- /dev/null +++ b/test/unit/specs/utils/validation/matchesRegexpValidator.spec.js @@ -0,0 +1,51 @@ +/* +Copyright 2023 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +import { string } from "../../../../../src/utils/validation"; +import describeValidation from "../../../helpers/describeValidation"; + +const regexp = new RegExp("^[A-z]+$"); + +describe("validation::matchesRegexp", () => { + describeValidation("optional matchesRegexp", string().matches(regexp), [ + { value: "abc" }, + { value: "ABCD" }, + { value: "*", error: true }, + { value: "123", error: true }, + { value: null }, + { value: undefined } + ]); + + describeValidation( + "required regexp", + string() + .regexp(regexp) + .required(), + [ + { value: null, error: true }, + { value: undefined, error: true }, + { value: "" } + ] + ); + + describeValidation( + "default regexp", + string() + .regexp(regexp) + .default("abc"), + [ + { value: null, expected: "abc" }, + { value: undefined, expected: "abc" }, + { value: "a" } + ] + ); +});