1318: Persist recsys data for send after tab close

- Add ability to store `entries` into Redux.
- Sync to redux in the same interval as when playing position is saved (re-use timer).
- On startup, send any stashed entries and clear them.
This commit is contained in:
infinite-persistence 2022-05-24 20:24:04 +08:00 committed by Thomas Zarebczan
parent ac44b771ff
commit 6d6d95237a
10 changed files with 87 additions and 9 deletions

View file

@ -5,6 +5,7 @@ import { makeSelectRecommendedRecsysIdForClaimId } from 'redux/selectors/search'
import { v4 as Uuidv4 } from 'uuid'; import { v4 as Uuidv4 } from 'uuid';
import { parseURI } from 'util/lbryURI'; import { parseURI } from 'util/lbryURI';
import { getAuthToken } from 'util/saved-passwords'; import { getAuthToken } from 'util/saved-passwords';
import * as ACTIONS from 'constants/action_types';
import * as SETTINGS from 'constants/settings'; import * as SETTINGS from 'constants/settings';
import { X_LBRY_AUTH_TOKEN } from 'constants/token'; import { X_LBRY_AUTH_TOKEN } from 'constants/token';
import { makeSelectClaimForUri } from 'redux/selectors/claims'; import { makeSelectClaimForUri } from 'redux/selectors/claims';
@ -33,6 +34,7 @@ const getClaimIdsFromUris = (uris) => {
const recsys: Recsys = { const recsys: Recsys = {
entries: {}, entries: {},
debug: false, debug: false,
/** /**
* Provides for creating, updating, and sending Clickstream data object Entries. * Provides for creating, updating, and sending Clickstream data object Entries.
* Entries are Created either when recommendedContent loads, or when recommendedContent is clicked. * Entries are Created either when recommendedContent loads, or when recommendedContent is clicked.
@ -40,6 +42,18 @@ const recsys: Recsys = {
* On page load, find an empty entry with your claimId, or create a new entry and record to it. * 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. * Called when RecommendedContent was clicked.
* Adds index of clicked recommendation to parent entry * Adds index of clicked recommendation to parent entry
@ -124,6 +138,8 @@ const recsys: Recsys = {
recsys.entries[claimId].recsysId = null; recsys.entries[claimId].recsysId = null;
recsys.entries[claimId].recClaimIds = []; recsys.entries[claimId].recClaimIds = [];
} }
recsys.saveEntries();
} }
recsys.log('createRecsysEntry', claimId); recsys.log('createRecsysEntry', claimId);
}, },
@ -131,7 +147,7 @@ const recsys: Recsys = {
/** /**
* Send event for claimId * Send event for claimId
* @param claimId * @param claimId
* @param isTentative * @param isTentative Visibility change rather than tab closed.
*/ */
sendRecsysEntry: function (claimId, isTentative) { sendRecsysEntry: function (claimId, isTentative) {
const shareTelemetry = const shareTelemetry =
@ -141,10 +157,6 @@ const recsys: Recsys = {
const { events, ...entryData } = recsys.entries[claimId]; const { events, ...entryData } = recsys.entries[claimId];
const data = JSON.stringify(entryData); const data = JSON.stringify(entryData);
if (!isTentative) {
delete recsys.entries[claimId];
}
return fetch(recsysEndpoint, { return fetch(recsysEndpoint, {
method: 'POST', method: 'POST',
headers: { headers: {
@ -152,13 +164,36 @@ const recsys: Recsys = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: data, body: data,
}).catch((err) => { })
.then(() => {
if (!isTentative) {
delete recsys.entries[claimId];
recsys.saveEntries();
}
})
.catch((err) => {
console.log('RECSYS: failed to send entry', err); console.log('RECSYS: failed to send entry', err);
}); });
} }
recsys.log('sendRecsysEntry', claimId); 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 * A player event fired. Get the Entry for the claimId, and add the events
* @param claimId * @param claimId
@ -260,6 +295,7 @@ const recsys: Recsys = {
const shouldSkip = recsys.entries[claimId].parentUuid && !recsys.entries[claimId].recClaimIds; const shouldSkip = recsys.entries[claimId].parentUuid && !recsys.entries[claimId].recClaimIds;
if (!shouldSkip && ((claimId !== playingClaimId && floatingPlayer) || !floatingPlayer)) { if (!shouldSkip && ((claimId !== playingClaimId && floatingPlayer) || !floatingPlayer)) {
recsys.entries[claimId]['pageExitedAt'] = Date.now(); recsys.entries[claimId]['pageExitedAt'] = Date.now();
recsys.saveEntries();
// recsys.sendRecsysEntry(claimId); breaks pop out = off, not helping with browser close. // recsys.sendRecsysEntry(claimId); breaks pop out = off, not helping with browser close.
} }
recsys.log('OnNavigate', claimId); recsys.log('OnNavigate', claimId);

View file

@ -16,6 +16,7 @@ declare type ContentState = {
// can cast it to a boolean. That, or rename the variable to `shuffle` if you // can cast it to a boolean. That, or rename the variable to `shuffle` if you
// don't care about the URLs. // don't care about the URLs.
lastViewedAnnouncement: ?string, // undefined = not seen in wallet. lastViewedAnnouncement: ?string, // undefined = not seen in wallet.
recsysEntries: { [ClaimId]: RecsysEntry }, // Persistent shadow copy. The main one resides in RecSys.
}; };
declare type WatchHistory = { declare type WatchHistory = {

View file

@ -1,10 +1,13 @@
declare type Recsys = { declare type Recsys = {
entries: { [ClaimId]: RecsysEntry }, entries: { [ClaimId]: RecsysEntry },
debug: boolean, debug: boolean,
saveEntries: () => void,
onClickedRecommended: (parentClaimId: ClaimId, newClaimId: ClaimId) => void, onClickedRecommended: (parentClaimId: ClaimId, newClaimId: ClaimId) => void,
onRecsLoaded: (claimId: ClaimId, uris: Array<string>, uuid: string) => void, onRecsLoaded: (claimId: ClaimId, uris: Array<string>, uuid: string) => void,
createRecsysEntry: (claimId: ClaimId, parentUuid?: ?string, uuid?: string) => void, createRecsysEntry: (claimId: ClaimId, parentUuid?: ?string, uuid?: string) => void,
sendRecsysEntry: (claimId: ClaimId, isTentative?: boolean) => ?Promise<?Response>, sendRecsysEntry: (claimId: ClaimId, isTentative?: boolean) => ?Promise<?Response>,
sendEntries: (entries: ?{ [ClaimId]: RecsysEntry }, isResumedSend: boolean) => void,
onRecsysPlayerEvent: (claimId: ClaimId, event: RecsysPlaybackEvent, isEmbedded: boolean) => void, onRecsysPlayerEvent: (claimId: ClaimId, event: RecsysPlaybackEvent, isEmbedded: boolean) => void,
log: (callName: string, claimId: ClaimId) => void, log: (callName: string, claimId: ClaimId) => void,
onPlayerDispose: (claimId: ClaimId, isEmbedded: boolean, totalPlayingTime: number) => void, onPlayerDispose: (claimId: ClaimId, isEmbedded: boolean, totalPlayingTime: number) => void,

View file

@ -29,6 +29,7 @@ import debounce from 'util/debounce';
import { formatLbryUrlForWeb, generateListSearchUrlParams } from 'util/url'; import { formatLbryUrlForWeb, generateListSearchUrlParams } from 'util/url';
import useInterval from 'effects/use-interval'; import useInterval from 'effects/use-interval';
import { lastBandwidthSelector } from './internal/plugins/videojs-http-streaming--override/playlist-selectors'; import { lastBandwidthSelector } from './internal/plugins/videojs-http-streaming--override/playlist-selectors';
import RecSys from 'recsys';
// const PLAY_TIMEOUT_ERROR = 'play_timeout_error'; // const PLAY_TIMEOUT_ERROR = 'play_timeout_error';
// const PLAY_TIMEOUT_LIMIT = 2000; // const PLAY_TIMEOUT_LIMIT = 2000;
@ -173,6 +174,7 @@ function VideoViewer(props: Props) {
useInterval( useInterval(
() => { () => {
RecSys.saveEntries();
if (playerRef.current && isPlaying && !isLivestreamClaim) { if (playerRef.current && isPlaying && !isLivestreamClaim) {
handlePosition(playerRef.current); handlePosition(playerRef.current);
} }

View file

@ -144,6 +144,7 @@ export const SET_CONTENT_LAST_VIEWED = 'SET_CONTENT_LAST_VIEWED';
export const CLEAR_CONTENT_HISTORY_URI = 'CLEAR_CONTENT_HISTORY_URI'; export const CLEAR_CONTENT_HISTORY_URI = 'CLEAR_CONTENT_HISTORY_URI';
export const CLEAR_CONTENT_HISTORY_ALL = 'CLEAR_CONTENT_HISTORY_ALL'; export const CLEAR_CONTENT_HISTORY_ALL = 'CLEAR_CONTENT_HISTORY_ALL';
export const SET_LAST_VIEWED_ANNOUNCEMENT = 'SET_LAST_VIEWED_ANNOUNCEMENT'; export const SET_LAST_VIEWED_ANNOUNCEMENT = 'SET_LAST_VIEWED_ANNOUNCEMENT';
export const SET_RECSYS_ENTRIES = 'SET_RECSYS_ENTRIES';
export const RECOMMENDATION_UPDATED = 'RECOMMENDATION_UPDATED'; export const RECOMMENDATION_UPDATED = 'RECOMMENDATION_UPDATED';
export const RECOMMENDATION_CLICKED = 'RECOMMENDATION_CLICKED'; export const RECOMMENDATION_CLICKED = 'RECOMMENDATION_CLICKED';
export const TOGGLE_LOOP_LIST = 'TOGGLE_LOOP_LIST'; export const TOGGLE_LOOP_LIST = 'TOGGLE_LOOP_LIST';

View file

@ -44,6 +44,7 @@ import 'scss/all.scss';
// @if TARGET='web' // @if TARGET='web'
// These overrides can't live in web/ because they need to use the same instance of `Lbry` // These overrides can't live in web/ because they need to use the same instance of `Lbry`
import apiPublishCallViaWeb from 'web/setup/publish'; import apiPublishCallViaWeb from 'web/setup/publish';
import { doSendPastRecsysEntries } from 'redux/actions/content';
// Sentry error logging setup // Sentry error logging setup
// Will only work if you have a SENTRY_AUTH_TOKEN env // Will only work if you have a SENTRY_AUTH_TOKEN env
@ -232,6 +233,7 @@ function AppWrapper() {
useEffect(() => { useEffect(() => {
if (persistDone) { if (persistDone) {
app.store.dispatch(doToggle3PAnalytics(null, true)); app.store.dispatch(doToggle3PAnalytics(null, true));
app.store.dispatch(doSendPastRecsysEntries());
} }
}, [persistDone]); }, [persistDone]);

View file

@ -16,10 +16,12 @@ import { makeSelectUrlsForCollectionId } from 'redux/selectors/collections';
import { doToast } from 'redux/actions/notifications'; import { doToast } from 'redux/actions/notifications';
import { doPurchaseUri } from 'redux/actions/file'; import { doPurchaseUri } from 'redux/actions/file';
import Lbry from 'lbry'; import Lbry from 'lbry';
import RecSys from 'recsys';
import * as SETTINGS from 'constants/settings'; import * as SETTINGS from 'constants/settings';
import { selectCostInfoForUri, Lbryio } from 'lbryinc'; import { selectCostInfoForUri, Lbryio } from 'lbryinc';
import { selectClientSetting, selectosNotificationsEnabled, selectDaemonSettings } from 'redux/selectors/settings'; import { selectClientSetting, selectosNotificationsEnabled, selectDaemonSettings } from 'redux/selectors/settings';
import { selectIsActiveLivestreamForUri } from 'redux/selectors/livestream'; import { selectIsActiveLivestreamForUri } from 'redux/selectors/livestream';
import { selectRecsysEntries } from 'redux/selectors/content';
const DOWNLOAD_POLL_INTERVAL = 1000; const DOWNLOAD_POLL_INTERVAL = 1000;
@ -383,3 +385,30 @@ export function doSetLastViewedAnnouncement(hash: string) {
}); });
}; };
} }
export function doSetRecsysEntries(entries: { [ClaimId]: RecsysEntry }) {
return (dispatch: Dispatch) => {
dispatch({
type: ACTIONS.SET_RECSYS_ENTRIES,
data: entries,
});
};
}
/**
* Sends any lingering recsys entries from the previous session and deletes it.
*
* Should only be called on startup, before a new cycle of recsys data is
* collected.
*
* @returns {(function(Dispatch, GetState): void)|*}
*/
export function doSendPastRecsysEntries() {
return (dispatch: Dispatch, getState: GetState) => {
const state = getState();
const entries = selectRecsysEntries(state);
if (entries) {
RecSys.sendEntries(entries, true);
}
};
}

View file

@ -14,6 +14,7 @@ const defaultState: ContentState = {
recommendationClicks: {}, recommendationClicks: {},
loopList: undefined, loopList: undefined,
lastViewedAnnouncement: '', lastViewedAnnouncement: '',
recsysEntries: {},
}; };
reducers[ACTIONS.SET_PRIMARY_URI] = (state, action) => reducers[ACTIONS.SET_PRIMARY_URI] = (state, action) =>
@ -121,6 +122,8 @@ reducers[ACTIONS.CLEAR_CONTENT_HISTORY_ALL] = (state) => ({ ...state, history: [
reducers[ACTIONS.SET_LAST_VIEWED_ANNOUNCEMENT] = (state, action) => ({ ...state, lastViewedAnnouncement: action.data }); reducers[ACTIONS.SET_LAST_VIEWED_ANNOUNCEMENT] = (state, action) => ({ ...state, lastViewedAnnouncement: action.data });
reducers[ACTIONS.SET_RECSYS_ENTRIES] = (state, action) => ({ ...state, recsysEntries: action.data });
// reducers[LBRY_REDUX_ACTIONS.PURCHASE_URI_FAILED] = (state, action) => { // reducers[LBRY_REDUX_ACTIONS.PURCHASE_URI_FAILED] = (state, action) => {
// return { // return {
// ...state, // ...state,

View file

@ -27,6 +27,7 @@ export const selectPrimaryUri = (state: State) => selectState(state).primaryUri;
export const selectListLoop = (state: State) => selectState(state).loopList; export const selectListLoop = (state: State) => selectState(state).loopList;
export const selectListShuffle = (state: State) => selectState(state).shuffleList; export const selectListShuffle = (state: State) => selectState(state).shuffleList;
export const selectLastViewedAnnouncement = (state: State) => selectState(state).lastViewedAnnouncement; export const selectLastViewedAnnouncement = (state: State) => selectState(state).lastViewedAnnouncement;
export const selectRecsysEntries = (state: State) => selectState(state).recsysEntries;
export const makeSelectIsPlaying = (uri: string) => export const makeSelectIsPlaying = (uri: string) =>
createSelector(selectPrimaryUri, (primaryUri) => primaryUri === uri); createSelector(selectPrimaryUri, (primaryUri) => primaryUri === uri);

View file

@ -45,7 +45,7 @@ function enableBatching(reducer) {
}; };
} }
const contentFilter = createFilter('content', ['positions', 'history', 'lastViewedAnnouncement']); const contentFilter = createFilter('content', ['positions', 'history', 'lastViewedAnnouncement', 'recsysEntries']);
const fileInfoFilter = createFilter('fileInfo', [ const fileInfoFilter = createFilter('fileInfo', [
'fileListPublishedSort', 'fileListPublishedSort',
'fileListDownloadedSort', 'fileListDownloadedSort',