Anthony watchman integration #6799
4 changed files with 186 additions and 38 deletions
|
@ -109,3 +109,5 @@ ENABLE_UI_NOTIFICATIONS=false
|
|||
#IMAGES_ENABLED=true
|
||||
#FILES_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 { generateInitialUrl } from 'util/url';
|
||||
// @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 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';
|
||||
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 (isProduction) {
|
||||
ElectronCookies.enable({
|
||||
|
@ -37,7 +40,8 @@ type Analytics = {
|
|||
tagFollowEvent: (string, boolean, ?string) => void,
|
||||
playerLoadedEvent: (?boolean) => void,
|
||||
playerStartedEvent: (?boolean) => void,
|
||||
videoStartEvent: (string, number) => void,
|
||||
videoStartEvent: (string, number, string, number, string, any) => void,
|
||||
videoIsPlaying: (boolean, any) => void,
|
||||
videoBufferEvent: (
|
||||
StreamClaim,
|
||||
{
|
||||
|
@ -49,7 +53,7 @@ type Analytics = {
|
|||
playerPoweredBy: string,
|
||||
readyState: number,
|
||||
}
|
||||
) => void,
|
||||
) => Promise<any>,
|
||||
adsFetchedEvent: () => void,
|
||||
adsReceivedEvent: (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;
|
||||
// @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 = {
|
||||
// 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) => {
|
||||
return new Promise((resolve) => {
|
||||
if (internalAnalyticsEnabled && isProduction) {
|
||||
|
@ -195,39 +359,6 @@ const analytics: Analytics = {
|
|||
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: () => {
|
||||
sendMatomoEvent('Media', 'AdsFetched');
|
||||
},
|
||||
|
|
|
@ -15,6 +15,7 @@ const select = (state, props) => {
|
|||
const { search } = props.location;
|
||||
const urlParams = new URLSearchParams(search);
|
||||
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 userId = selectUser(state) && selectUser(state).id;
|
||||
|
||||
|
|
|
@ -127,7 +127,7 @@ function VideoViewer(props: Props) {
|
|||
}, [embedded, videoPlaybackRate]);
|
||||
|
||||
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');
|
||||
doAnalyticsBuffer(uri, data);
|
||||
});
|
||||
|
@ -141,13 +141,20 @@ function VideoViewer(props: Props) {
|
|||
timeToStart += differenceToAdd;
|
||||
}
|
||||
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(() => {
|
||||
claimRewards();
|
||||
});
|
||||
}
|
||||
|
||||
const onEnded = React.useCallback(() => {
|
||||
analytics.videoIsPlaying(false);
|
||||
|
||||
if (adUrl) {
|
||||
setAdUrl(null);
|
||||
return;
|
||||
|
@ -167,15 +174,18 @@ function VideoViewer(props: Props) {
|
|||
setIsPlaying(true);
|
||||
setShowAutoplayCountdown(false);
|
||||
setIsEndededEmbed(false);
|
||||
analytics.videoIsPlaying(true, player);
|
||||
}
|
||||
|
||||
function onPause(event, player) {
|
||||
setIsPlaying(false);
|
||||
handlePosition(player);
|
||||
analytics.videoIsPlaying(false, player);
|
||||
}
|
||||
|
||||
function onDispose(event, player) {
|
||||
handlePosition(player);
|
||||
analytics.videoIsPlaying(false, player);
|
||||
}
|
||||
|
||||
function handlePosition(player) {
|
||||
|
@ -229,12 +239,16 @@ function VideoViewer(props: Props) {
|
|||
// re-factoring.
|
||||
player.on('loadedmetadata', () => restorePlaybackRate(player));
|
||||
|
||||
// used for tracking buffering for watchman
|
||||
player.on('tracking:buffered', doTrackingBuffered);
|
||||
|
||||
// first play tracking, used for initializing the watchman api
|
||||
player.on('tracking:firstplay', doTrackingFirstPlay);
|
||||
player.on('ended', onEnded);
|
||||
player.on('play', onPlay);
|
||||
player.on('pause', (event) => onPause(event, player));
|
||||
player.on('dispose', (event) => onDispose(event, player));
|
||||
|
||||
player.on('error', () => {
|
||||
const error = player.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.