486a557d75
* Recsys/FYP: add documentation. * Recsys: capture and use `x-uuid` from search results Ticket: 1717
321 lines
12 KiB
JavaScript
321 lines
12 KiB
JavaScript
// @flow
|
|
import { RECSYS_ENDPOINT } from 'config';
|
|
import { selectUser } from 'redux/selectors/user';
|
|
import { selectRecommendedMetaForClaimId } from 'redux/selectors/search';
|
|
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 DEFAULT_RECSYS_ID = '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
|
|
*
|
|
* @param claimId The ID of the content the recommendations are for.
|
|
* @param uris The recommended uris for `claimId`.
|
|
* @param uuid Specific uuid to use (e.g. for FYP); uses the recommendation's
|
|
* uuid otherwise.
|
|
*/
|
|
onRecsLoaded: function (claimId, uris, uuid = '') {
|
|
if (window && window.store) {
|
|
const state = window.store.getState();
|
|
const recommendedMeta = selectRecommendedMetaForClaimId(state, claimId);
|
|
|
|
if (!recsys.entries[claimId]) {
|
|
recsys.createRecsysEntry(claimId, null, uuid || recommendedMeta.uuid);
|
|
}
|
|
|
|
const claimIds = getClaimIdsFromUris(uris);
|
|
recsys.entries[claimId]['recsysId'] = recommendedMeta.poweredBy || DEFAULT_RECSYS_ID;
|
|
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 Specific uuid to use (e.g. for FYP); uses the recommendation's
|
|
* uuid otherwise.
|
|
*/
|
|
createRecsysEntry: function (claimId, parentUuid, uuid = '') {
|
|
if (window && window.store && claimId) {
|
|
const state = window.store.getState();
|
|
const recommendedMeta = selectRecommendedMetaForClaimId(state, claimId);
|
|
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 || recommendedMeta.uuid,
|
|
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) {
|
|
// Exclude `events` in the submission https://github.com/OdyseeTeam/odysee-frontend/issues/1317
|
|
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;
|