Anthony watchman integration #6799
4 changed files with 186 additions and 38 deletions
|
@ -109,3 +109,5 @@ ENABLE_UI_NOTIFICATIONS=false
|
||||||
#IMAGES_ENABLED=true
|
#IMAGES_ENABLED=true
|
||||||
#FILES_ENABLED=true
|
#FILES_ENABLED=true
|
||||||
#MODELS_ENABLED=true
|
#MODELS_ENABLED=true
|
||||||
|
|
||||||
|
BRANDED_SITE=odysee
|
||||||
|
|
203
ui/analytics.js
203
ui/analytics.js
|
@ -9,7 +9,7 @@ import Native from 'native';
|
||||||
import ElectronCookies from '@exponent/electron-cookies';
|
import ElectronCookies from '@exponent/electron-cookies';
|
||||||
import { generateInitialUrl } from 'util/url';
|
import { generateInitialUrl } from 'util/url';
|
||||||
// @endif
|
// @endif
|
||||||
import { MATOMO_ID, MATOMO_URL, LBRY_WEB_BUFFER_API } from 'config';
|
import { MATOMO_ID, MATOMO_URL } from 'config';
|
||||||
|
|
||||||
const isProduction = process.env.NODE_ENV === 'production';
|
const isProduction = process.env.NODE_ENV === 'production';
|
||||||
const devInternalApis = process.env.LBRY_API_URL && process.env.LBRY_API_URL.includes('dev');
|
const devInternalApis = process.env.LBRY_API_URL && process.env.LBRY_API_URL.includes('dev');
|
||||||
|
@ -17,6 +17,9 @@ const devInternalApis = process.env.LBRY_API_URL && process.env.LBRY_API_URL.inc
|
||||||
export const SHARE_INTERNAL = 'shareInternal';
|
export const SHARE_INTERNAL = 'shareInternal';
|
||||||
const SHARE_THIRD_PARTY = 'shareThirdParty';
|
const SHARE_THIRD_PARTY = 'shareThirdParty';
|
||||||
|
|
||||||
|
const WATCHMAN_BACKEND_ENDPOINT = 'https://watchman.na-backend.odysee.com/reports/playback';
|
||||||
|
const SEND_DATA_TO_WATCHMAN_INTERVAL = 10; // in seconds
|
||||||
|
|
||||||
// @if TARGET='app'
|
// @if TARGET='app'
|
||||||
if (isProduction) {
|
if (isProduction) {
|
||||||
ElectronCookies.enable({
|
ElectronCookies.enable({
|
||||||
|
@ -37,7 +40,8 @@ type Analytics = {
|
||||||
tagFollowEvent: (string, boolean, ?string) => void,
|
tagFollowEvent: (string, boolean, ?string) => void,
|
||||||
playerLoadedEvent: (?boolean) => void,
|
playerLoadedEvent: (?boolean) => void,
|
||||||
playerStartedEvent: (?boolean) => void,
|
playerStartedEvent: (?boolean) => void,
|
||||||
videoStartEvent: (string, number) => void,
|
videoStartEvent: (string, number, string, number, string, any) => void,
|
||||||
|
videoIsPlaying: (boolean, any) => void,
|
||||||
videoBufferEvent: (
|
videoBufferEvent: (
|
||||||
StreamClaim,
|
StreamClaim,
|
||||||
{
|
{
|
||||||
|
@ -49,7 +53,7 @@ type Analytics = {
|
||||||
playerPoweredBy: string,
|
playerPoweredBy: string,
|
||||||
readyState: number,
|
readyState: number,
|
||||||
}
|
}
|
||||||
) => void,
|
) => Promise<any>,
|
||||||
adsFetchedEvent: () => void,
|
adsFetchedEvent: () => void,
|
||||||
adsReceivedEvent: (any) => void,
|
adsReceivedEvent: (any) => void,
|
||||||
adsErrorEvent: (any) => void,
|
adsErrorEvent: (any) => void,
|
||||||
|
@ -76,7 +80,167 @@ if (window.localStorage.getItem(SHARE_INTERNAL) === 'true') internalAnalyticsEna
|
||||||
// if (window.localStorage.getItem(SHARE_THIRD_PARTY) === 'true') thirdPartyAnalyticsEnabled = true;
|
// if (window.localStorage.getItem(SHARE_THIRD_PARTY) === 'true') thirdPartyAnalyticsEnabled = true;
|
||||||
// @endif
|
// @endif
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine the mobile device type viewing the data
|
||||||
|
* This function returns one of 'and' (Android), 'ios', or 'web'.
|
||||||
|
*
|
||||||
|
* @returns {String}
|
||||||
|
*/
|
||||||
|
function getDeviceType() {
|
||||||
|
var userAgent = navigator.userAgent || navigator.vendor || window.opera;
|
||||||
|
|
||||||
|
if (/android/i.test(userAgent)) {
|
||||||
|
return 'and';
|
||||||
|
}
|
||||||
|
|
||||||
|
// iOS detection from: http://stackoverflow.com/a/9039885/177710
|
||||||
|
if (/iPad|iPhone|iPod/.test(userAgent) && !window.MSStream) {
|
||||||
|
return 'ios';
|
||||||
|
}
|
||||||
|
|
||||||
|
// default as web, this can be optimized
|
||||||
|
return 'web';
|
||||||
|
}
|
||||||
|
// variables initialized for watchman
|
||||||
|
let amountOfBufferEvents = 0;
|
||||||
|
let amountOfBufferTimeInMS = 0;
|
||||||
|
let videoType, userId, claimUrl, playerPoweredBy, videoPlayer;
|
||||||
|
let lastSentTime;
|
||||||
|
|
||||||
|
// calculate data for backend, send them, and reset buffer data for next interval
|
||||||
|
async function sendAndResetWatchmanData() {
|
||||||
|
if (!userId) {
|
||||||
|
return 'Can only be used with a user id';
|
||||||
|
}
|
||||||
|
|
||||||
|
let timeSinceLastIntervalSend = new Date() - lastSentTime;
|
||||||
|
lastSentTime = new Date();
|
||||||
|
|
||||||
|
let protocol;
|
||||||
|
if (videoType === 'application/x-mpegURL') {
|
||||||
|
protocol = 'hls';
|
||||||
|
} else {
|
||||||
|
protocol = 'stb';
|
||||||
|
}
|
||||||
|
|
||||||
|
// current position in video in MS
|
||||||
|
const positionInVideo = Math.round(videoPlayer.currentTime()) * 1000;
|
||||||
|
|||||||
|
|
||||||
|
// get the duration marking the time in the video for relative position calculation
|
||||||
|
const totalDurationInSeconds = Math.round(videoPlayer.duration());
|
||||||
|
|
||||||
|
// build object for watchman backend
|
||||||
|
const objectToSend = {
|
||||||
|
rebuf_count: amountOfBufferEvents,
|
||||||
|
rebuf_duration: amountOfBufferTimeInMS,
|
||||||
|
url: claimUrl.replace('lbry://', ''),
|
||||||
|
device: getDeviceType(),
|
||||||
|
duration: timeSinceLastIntervalSend,
|
||||||
|
protocol,
|
||||||
|
player: playerPoweredBy,
|
||||||
|
user_id: userId.toString(),
|
||||||
|
position: Math.round(positionInVideo),
|
||||||
|
rel_position: Math.round((positionInVideo / (totalDurationInSeconds * 1000)) * 100),
|
||||||
|
};
|
||||||
|
|
||||||
|
// post to watchman
|
||||||
|
await sendWatchmanData(objectToSend);
|
||||||
|
|
||||||
|
// reset buffer data
|
||||||
|
amountOfBufferEvents = 0;
|
||||||
|
amountOfBufferTimeInMS = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let watchmanInterval;
|
||||||
|
// clear watchman interval and mark it as null (when video paused)
|
||||||
|
function stopWatchmanInterval() {
|
||||||
|
clearInterval(watchmanInterval);
|
||||||
|
watchmanInterval = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// creates the setInterval that will run send to watchman on recurring basis
|
||||||
|
function startWatchmanIntervalIfNotRunning() {
|
||||||
|
if (!watchmanInterval) {
|
||||||
|
// instantiate the first time to calculate duration from
|
||||||
|
lastSentTime = new Date();
|
||||||
|
|
||||||
|
// only set an interval if analytics are enabled and is prod
|
||||||
|
if (internalAnalyticsEnabled && isProduction) {
|
||||||
|
watchmanInterval = setInterval(sendAndResetWatchmanData, 1000 * SEND_DATA_TO_WATCHMAN_INTERVAL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// post data to the backend
|
||||||
|
async function sendWatchmanData(body) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(WATCHMAN_BACKEND_ENDPOINT, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (err) {
|
||||||
|
console.log('ERROR FROM WATCHMAN BACKEND');
|
||||||
|
console.log(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const analytics: Analytics = {
|
const analytics: Analytics = {
|
||||||
|
// receive buffer events from tracking plugin and jklj
|
||||||
|
videoBufferEvent: async (claim, data) => {
|
||||||
|
amountOfBufferEvents = amountOfBufferEvents + 1;
|
||||||
|
amountOfBufferTimeInMS = amountOfBufferTimeInMS + data.bufferDuration;
|
||||||
|
},
|
||||||
|
onDispose: () => {
|
||||||
|
stopWatchmanInterval();
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Is told whether video is being started or paused, and adjusts interval accordingly
|
||||||
|
* @param {boolean} isPlaying - Whether video was started or paused
|
||||||
|
* @param {object} passedPlayer - VideoJS Player object
|
||||||
|
*/
|
||||||
|
videoIsPlaying: (isPlaying, passedPlayer) => {
|
||||||
|
let playerIsSeeking = false;
|
||||||
|
// have to use this because videojs pauses/unpauses during seek
|
||||||
|
// sometimes the seeking function isn't populated yet so check for it as well
|
||||||
|
if (passedPlayer && passedPlayer.seeking) {
|
||||||
|
playerIsSeeking = passedPlayer.seeking();
|
||||||
|
}
|
||||||
|
|
||||||
|
// if being paused, and not seeking, send existing data and stop interval
|
||||||
|
if (!isPlaying && !playerIsSeeking) {
|
||||||
|
sendAndResetWatchmanData();
|
||||||
|
stopWatchmanInterval();
|
||||||
|
// if being told to pause, and seeking, send and restart interval
|
||||||
|
} else if (!isPlaying && playerIsSeeking) {
|
||||||
|
sendAndResetWatchmanData();
|
||||||
|
stopWatchmanInterval();
|
||||||
|
startWatchmanIntervalIfNotRunning();
|
||||||
|
// is being told to play, and seeking, don't do anything,
|
||||||
|
// assume it's been started already from pause
|
||||||
|
} else if (isPlaying && playerIsSeeking) {
|
||||||
|
// start but not a seek, assuming a start from paused content
|
||||||
|
} else if (isPlaying && !playerIsSeeking) {
|
||||||
|
startWatchmanIntervalIfNotRunning();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
videoStartEvent: (claimId, duration, poweredBy, passedUserId, canonicalUrl, passedPlayer) => {
|
||||||
|
// populate values for watchman when video starts
|
||||||
|
userId = passedUserId;
|
||||||
|
claimUrl = canonicalUrl;
|
||||||
|
playerPoweredBy = poweredBy;
|
||||||
|
|
||||||
|
videoType = passedPlayer.currentSource().type;
|
||||||
|
videoPlayer = passedPlayer;
|
||||||
|
|
||||||
|
sendPromMetric('time_to_start', duration);
|
||||||
|
sendMatomoEvent('Media', 'TimeToStart', claimId, duration);
|
||||||
|
},
|
||||||
error: (message) => {
|
error: (message) => {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
if (internalAnalyticsEnabled && isProduction) {
|
if (internalAnalyticsEnabled && isProduction) {
|
||||||
|
@ -195,39 +359,6 @@ const analytics: Analytics = {
|
||||||
Lbryio.call('content_tags', 'sync', params);
|
Lbryio.call('content_tags', 'sync', params);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
videoStartEvent: (claimId, duration) => {
|
|
||||||
sendPromMetric('time_to_start', duration);
|
|
||||||
sendMatomoEvent('Media', 'TimeToStart', claimId, duration);
|
|
||||||
},
|
|
||||||
|
|
||||||
videoBufferEvent: (claim, data) => {
|
|
||||||
sendMatomoEvent('Media', 'BufferTimestamp', claim.claim_id, data.timeAtBuffer);
|
|
||||||
|
|
||||||
if (LBRY_WEB_BUFFER_API) {
|
|
||||||
fetch(LBRY_WEB_BUFFER_API, {
|
|
||||||
method: 'post',
|
|
||||||
headers: {
|
|
||||||
Accept: 'application/json',
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
device: 'web',
|
|
||||||
type: 'buffering',
|
|
||||||
client: data.userId,
|
|
||||||
data: {
|
|
||||||
url: claim.canonical_url,
|
|
||||||
position: data.timeAtBuffer,
|
|
||||||
duration: data.bufferDuration,
|
|
||||||
player: data.playerPoweredBy,
|
|
||||||
readyState: data.readyState,
|
|
||||||
stream_duration: data.duration,
|
|
||||||
stream_bitrate: data.bitRate,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
adsFetchedEvent: () => {
|
adsFetchedEvent: () => {
|
||||||
sendMatomoEvent('Media', 'AdsFetched');
|
sendMatomoEvent('Media', 'AdsFetched');
|
||||||
},
|
},
|
||||||
|
|
|
@ -15,6 +15,7 @@ const select = (state, props) => {
|
||||||
const { search } = props.location;
|
const { search } = props.location;
|
||||||
const urlParams = new URLSearchParams(search);
|
const urlParams = new URLSearchParams(search);
|
||||||
const autoplay = urlParams.get('autoplay');
|
const autoplay = urlParams.get('autoplay');
|
||||||
|
// TODO: eventually this should be received from DB and not local state (https://github.com/lbryio/lbry-desktop/issues/6796)
|
||||||
const position = urlParams.get('t') !== null ? urlParams.get('t') : makeSelectContentPositionForUri(props.uri)(state);
|
const position = urlParams.get('t') !== null ? urlParams.get('t') : makeSelectContentPositionForUri(props.uri)(state);
|
||||||
const userId = selectUser(state) && selectUser(state).id;
|
const userId = selectUser(state) && selectUser(state).id;
|
||||||
|
|
||||||
|
|
|
@ -127,7 +127,7 @@ function VideoViewer(props: Props) {
|
||||||
}, [embedded, videoPlaybackRate]);
|
}, [embedded, videoPlaybackRate]);
|
||||||
|
|
||||||
function doTrackingBuffered(e: Event, data: any) {
|
function doTrackingBuffered(e: Event, data: any) {
|
||||||
fetch(source, { method: 'HEAD' }).then((response) => {
|
fetch(source, { method: 'HEAD', cache: 'no-store' }).then((response) => {
|
||||||
data.playerPoweredBy = response.headers.get('x-powered-by');
|
data.playerPoweredBy = response.headers.get('x-powered-by');
|
||||||
doAnalyticsBuffer(uri, data);
|
doAnalyticsBuffer(uri, data);
|
||||||
});
|
});
|
||||||
|
@ -141,13 +141,20 @@ function VideoViewer(props: Props) {
|
||||||
timeToStart += differenceToAdd;
|
timeToStart += differenceToAdd;
|
||||||
}
|
}
|
||||||
analytics.playerStartedEvent(embedded);
|
analytics.playerStartedEvent(embedded);
|
||||||
analytics.videoStartEvent(claimId, timeToStart);
|
|
||||||
|
fetch(source, { method: 'HEAD', cache: 'no-store' }).then((response) => {
|
||||||
|
let playerPoweredBy = response.headers.get('x-powered-by') || '';
|
||||||
|
analytics.videoStartEvent(claimId, timeToStart, playerPoweredBy, userId, claim.canonical_url, this);
|
||||||
|
});
|
||||||
|
|
||||||
doAnalyticsView(uri, timeToStart).then(() => {
|
doAnalyticsView(uri, timeToStart).then(() => {
|
||||||
claimRewards();
|
claimRewards();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const onEnded = React.useCallback(() => {
|
const onEnded = React.useCallback(() => {
|
||||||
|
analytics.videoIsPlaying(false);
|
||||||
|
|
||||||
if (adUrl) {
|
if (adUrl) {
|
||||||
setAdUrl(null);
|
setAdUrl(null);
|
||||||
return;
|
return;
|
||||||
|
@ -167,15 +174,18 @@ function VideoViewer(props: Props) {
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
setShowAutoplayCountdown(false);
|
setShowAutoplayCountdown(false);
|
||||||
setIsEndededEmbed(false);
|
setIsEndededEmbed(false);
|
||||||
|
analytics.videoIsPlaying(true, player);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPause(event, player) {
|
function onPause(event, player) {
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
handlePosition(player);
|
handlePosition(player);
|
||||||
|
analytics.videoIsPlaying(false, player);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onDispose(event, player) {
|
function onDispose(event, player) {
|
||||||
handlePosition(player);
|
handlePosition(player);
|
||||||
|
analytics.videoIsPlaying(false, player);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePosition(player) {
|
function handlePosition(player) {
|
||||||
|
@ -229,12 +239,16 @@ function VideoViewer(props: Props) {
|
||||||
// re-factoring.
|
// re-factoring.
|
||||||
player.on('loadedmetadata', () => restorePlaybackRate(player));
|
player.on('loadedmetadata', () => restorePlaybackRate(player));
|
||||||
|
|
||||||
|
// used for tracking buffering for watchman
|
||||||
player.on('tracking:buffered', doTrackingBuffered);
|
player.on('tracking:buffered', doTrackingBuffered);
|
||||||
|
|
||||||
|
// first play tracking, used for initializing the watchman api
|
||||||
player.on('tracking:firstplay', doTrackingFirstPlay);
|
player.on('tracking:firstplay', doTrackingFirstPlay);
|
||||||
player.on('ended', onEnded);
|
player.on('ended', onEnded);
|
||||||
player.on('play', onPlay);
|
player.on('play', onPlay);
|
||||||
player.on('pause', (event) => onPause(event, player));
|
player.on('pause', (event) => onPause(event, player));
|
||||||
player.on('dispose', (event) => onDispose(event, player));
|
player.on('dispose', (event) => onDispose(event, player));
|
||||||
|
|
||||||
player.on('error', () => {
|
player.on('error', () => {
|
||||||
const error = player.error();
|
const error = player.error();
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|
Loading…
Reference in a new issue
This sort of thing, if tested elsewhere should use a constant, make a new file in constants if necessary.