lbry-desktop/extras/recsys/recsys.js

311 lines
11 KiB
JavaScript

// @flow
import { RECSYS_ENDPOINT } from 'config';
import { selectUser } from 'redux/selectors/user';
import { makeSelectRecommendedRecsysIdForClaimId } from 'redux/selectors/search';
import { v4 as Uuidv4 } from 'uuid';
import { parseURI } from 'util/lbryURI';
import { getAuthToken } from 'util/saved-passwords';
import * as ACTIONS from 'constants/action_types';
import * as SETTINGS from 'constants/settings';
import { X_LBRY_AUTH_TOKEN } from 'constants/token';
import { makeSelectClaimForUri } from 'redux/selectors/claims';
import { selectPlayingUri, selectPrimaryUri } from 'redux/selectors/content';
import { selectClientSetting, selectDaemonSettings } from 'redux/selectors/settings';
import { selectIsSubscribedForClaimId } from 'redux/selectors/subscriptions';
// $FlowFixMe: cannot resolve..
import { history } from 'ui/store';
const recsysEndpoint = RECSYS_ENDPOINT;
const recsysId = 'lighthouse-v0';
const getClaimIdsFromUris = (uris) => {
return uris
? uris.map((uri) => {
try {
const { claimId } = parseURI(uri);
return claimId;
} catch (e) {
return [];
}
})
: [];
};
const recsys: Recsys = {
entries: {},
debug: false,
/**
* Provides for creating, updating, and sending Clickstream data object Entries.
* Entries are Created either when recommendedContent loads, or when recommendedContent is clicked.
* If recommended content is clicked, An Entry with parentUuid is created.
* On page load, find an empty entry with your claimId, or create a new entry and record to it.
*/
/**
* Saves existing entries to persistence storage (in this case, Redux).
*/
saveEntries: function () {
if (window && window.store) {
window.store.dispatch({
type: ACTIONS.SET_RECSYS_ENTRIES,
data: recsys.entries,
});
}
},
/**
* Called when RecommendedContent was clicked.
* Adds index of clicked recommendation to parent entry
* Adds new Entry with parentUuid for destination page
* @param parentClaimId: string,
* @param newClaimId: string,
*/
onClickedRecommended: function (parentClaimId, newClaimId) {
const parentEntry = recsys.entries[parentClaimId] ? recsys.entries[parentClaimId] : null;
const parentUuid = parentEntry ? parentEntry['uuid'] : '';
const parentRecommendedClaims = parentEntry ? parentEntry['recClaimIds'] : [];
const parentClickedIndexes = parentEntry ? parentEntry['recClickedVideoIdx'] : [];
const indexClicked = parentRecommendedClaims.indexOf(newClaimId);
if (parentUuid) {
recsys.createRecsysEntry(newClaimId, parentUuid);
}
parentClickedIndexes.push(indexClicked);
// recsys.log('onClickedRecommended', { parentClaimId, newClaimId });
recsys.log('onClickedRecommended', newClaimId);
},
/**
* Page was loaded. Get or Create entry and populate it with default data,
* plus recommended content, recsysId, etc.
* Called from recommendedContent component
*/
onRecsLoaded: function (claimId, uris, uuid = '') {
if (window && window.store) {
const state = window.store.getState();
if (!recsys.entries[claimId]) {
recsys.createRecsysEntry(claimId, null, uuid);
}
const claimIds = getClaimIdsFromUris(uris);
recsys.entries[claimId]['recsysId'] = makeSelectRecommendedRecsysIdForClaimId(claimId)(state) || recsysId;
recsys.entries[claimId]['pageLoadedAt'] = Date.now();
// It is possible that `claimIds` include `null | undefined` entries
// instead of all being strings. I don't know if we should filter it,
// or change the `recClaimIds` definition. Leaving as is for now since
// any changes could affect existing recsys data set.
// -----------
// $FlowFixMe:
recsys.entries[claimId]['recClaimIds'] = claimIds;
}
recsys.log('onRecsLoaded', claimId);
},
/**
* Creates an Entry with optional parentUuid
* @param: claimId: string
* @param: parentUuid: string (optional)
* @param: uuid: string Specific uuid to use.
*/
createRecsysEntry: function (claimId, parentUuid, uuid = '') {
if (window && window.store && claimId) {
const state = window.store.getState();
const user = selectUser(state);
const userId = user ? user.id : null;
// Make a stub entry that will be filled out on page load
// $FlowIgnore: not everything is defined since this is a stub
recsys.entries[claimId] = {
uuid: uuid || Uuidv4(),
claimId: claimId,
recClickedVideoIdx: [],
pageLoadedAt: Date.now(),
events: [],
incognito: !(user && user.has_verified_email),
isFollowing: selectIsSubscribedForClaimId(state, claimId),
};
if (parentUuid) {
// $FlowFixMe: 'uid' should be a number, not null.
recsys.entries[claimId].uid = userId || null;
recsys.entries[claimId].parentUuid = parentUuid;
} else {
// $FlowFixMe: 'uid' should be a number, not null.
recsys.entries[claimId].uid = userId;
// $FlowFixMe: 'recsysId' should be a number, not null.
recsys.entries[claimId].recsysId = null;
recsys.entries[claimId].recClaimIds = [];
}
recsys.saveEntries();
}
recsys.log('createRecsysEntry', claimId);
},
/**
* Send event for claimId
* @param claimId
* @param isTentative Visibility change rather than tab closed.
*/
sendRecsysEntry: function (claimId, isTentative) {
const shareTelemetry =
IS_WEB || (window && window.store && selectDaemonSettings(window.store.getState()).share_usage_data);
if (recsys.entries[claimId] && shareTelemetry) {
const { events, ...entryData } = recsys.entries[claimId];
const data = JSON.stringify(entryData);
return fetch(recsysEndpoint, {
method: 'POST',
headers: {
[X_LBRY_AUTH_TOKEN]: getAuthToken(),
'Content-Type': 'application/json',
},
body: data,
})
.then(() => {
if (!isTentative) {
delete recsys.entries[claimId];
recsys.saveEntries();
}
})
.catch((err) => {
console.log('RECSYS: failed to send entry', err);
});
}
recsys.log('sendRecsysEntry', claimId);
},
sendEntries: function (entries, isResumedSend) {
if (entries) {
if (Object.keys(recsys.entries).length !== 0) {
// Typically called on startup only.
console.warn('RECSYS: sendEntries() called on non-empty state. Data will be overwritten.');
}
recsys.entries = entries;
}
Object.keys(recsys.entries).forEach((claimId) => {
recsys.entries[claimId].isResumedSend = isResumedSend;
recsys.sendRecsysEntry(claimId, false); // Send and delete.
});
},
/**
* A player event fired. Get the Entry for the claimId, and add the events
* @param claimId
* @param event
*/
onRecsysPlayerEvent: function (claimId, event, isEmbedded) {
const state = window.store.getState();
const autoPlayNext = state && selectClientSetting(state, SETTINGS.AUTOPLAY_NEXT);
// Check if played through (4 = onEnded) and handle multiple events at end
if (recsys.entries[claimId] && !recsys.entries[claimId]['autoplay'] === true) {
if (autoPlayNext && event.event === 4) {
recsys.entries[claimId]['autoplay'] = true;
} else {
recsys.entries[claimId]['autoplay'] = false;
}
}
if (!recsys.entries[claimId]) {
recsys.createRecsysEntry(claimId);
// do something to show it's floating or autoplay
}
if (isEmbedded) {
recsys.entries[claimId]['isEmbed'] = true;
}
recsys.entries[claimId].events.push(event);
recsys.log('onRecsysPlayerEvent', claimId);
},
log: function (callName, claimId) {
if (recsys.debug) {
console.log(`Call: ***${callName}***, ClaimId: ${claimId}, Recsys Entries`, Object.assign({}, recsys.entries));
}
},
/**
* Player closed. Check to see if primaryUri = playingUri
* if so, send the Entry.
*/
onPlayerDispose: function (claimId, isEmbedded, totalPlayingTime) {
if (window && window.store) {
const state = window.store.getState();
const playingUri = selectPlayingUri(state);
const primaryUri = selectPrimaryUri(state);
const onFilePage = playingUri === primaryUri;
if (!onFilePage || isEmbedded) {
if (isEmbedded) {
recsys.entries[claimId]['isEmbed'] = true;
}
recsys.entries[claimId]['totalPlayTime'] = totalPlayingTime;
recsys.sendRecsysEntry(claimId);
}
}
recsys.log('PlayerDispose', claimId);
},
// /**
// * File page unmount or change event
// * Check to see if playingUri, floatingEnabled, primaryUri === playingUri
// * If not, send the Entry.
// * If floating enabled, leaving file page will pop out player, leading to
// * more events until player is disposed. Don't send unless floatingPlayer playingUri
// */
// onLeaveFilePage: function (primaryUri) {
// if (window && window.store) {
// const state = window.store.getState();
// const claim = makeSelectClaimForUri(primaryUri)(state);
// const claimId = claim ? claim.claim_id : null;
// const playingUri = selectPlayingUri(state);
// const actualPlayingUri = playingUri && playingUri.uri;
// // const primaryUri = selectPrimaryUri(state);
// const floatingPlayer = makeSelectClientSetting(SETTINGS.FLOATING_PLAYER)(state);
// // When leaving page, if floating player is enabled, play will continue.
// if (claimId) {
// recsys.entries[claimId]['pageExitedAt'] = Date.now();
// }
// const shouldSend =
// (claimId && floatingPlayer && actualPlayingUri && actualPlayingUri !== primaryUri) || !floatingPlayer || !actualPlayingUri;
// if (shouldSend) {
// recsys.sendRecsysEntry(claimId);
// }
// recsys.log('LeaveFile', claimId);
// }
// },
/**
* Navigate event
* Send all claimIds that aren't currently playing.
*/
onNavigate: function () {
if (window && window.store) {
const state = window.store.getState();
const playingUri = selectPlayingUri(state);
const actualPlayingUri = playingUri && playingUri.uri;
const claim = makeSelectClaimForUri(actualPlayingUri || '')(state);
const playingClaimId = claim ? claim.claim_id : null;
// const primaryUri = selectPrimaryUri(state);
const floatingPlayer = selectClientSetting(state, SETTINGS.FLOATING_PLAYER);
// When leaving page, if floating player is enabled, play will continue.
Object.keys(recsys.entries).forEach((claimId) => {
const shouldSkip = recsys.entries[claimId].parentUuid && !recsys.entries[claimId].recClaimIds;
if (!shouldSkip && ((claimId !== playingClaimId && floatingPlayer) || !floatingPlayer)) {
recsys.entries[claimId]['pageExitedAt'] = Date.now();
recsys.saveEntries();
// recsys.sendRecsysEntry(claimId); breaks pop out = off, not helping with browser close.
}
recsys.log('OnNavigate', claimId);
});
}
},
};
history.listen(() => {
recsys.onNavigate();
});
export default recsys;