recsys v0.2 (#6977)

* recsys wip

better logging

fix floating player popout playing uri bug with recsys

lint

add empty entries to create

use beacon; fire on visibilitychange

cleanup, not record recs if not seen

ifweb recsys beacon

recsys handle embeds, cleanup

use history.listen to trigger events

fix recsys embed bug

bugfix

more default data

cleaner

cleaner

* remove tentative

* disable recsys debug logging
This commit is contained in:
jessopb 2021-09-02 18:39:40 -04:00 committed by GitHub
parent 64cbd4ae8d
commit 049fb2878e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 340 additions and 238 deletions

View file

@ -31,6 +31,7 @@ module.name_mapper='^modal\(.*\)$' -> '<PROJECT_ROOT>/ui/modal\1'
module.name_mapper='^app\(.*\)$' -> '<PROJECT_ROOT>/ui/app\1' module.name_mapper='^app\(.*\)$' -> '<PROJECT_ROOT>/ui/app\1'
module.name_mapper='^native\(.*\)$' -> '<PROJECT_ROOT>/ui/native\1' module.name_mapper='^native\(.*\)$' -> '<PROJECT_ROOT>/ui/native\1'
module.name_mapper='^analytics\(.*\)$' -> '<PROJECT_ROOT>/ui/analytics\1' module.name_mapper='^analytics\(.*\)$' -> '<PROJECT_ROOT>/ui/analytics\1'
module.name_mapper='^recsys\(.*\)$' -> '<PROJECT_ROOT>/ui/recsys\1'
module.name_mapper='^rewards\(.*\)$' -> '<PROJECT_ROOT>/ui/rewards\1' module.name_mapper='^rewards\(.*\)$' -> '<PROJECT_ROOT>/ui/rewards\1'
module.name_mapper='^i18n\(.*\)$' -> '<PROJECT_ROOT>/ui/i18n\1' module.name_mapper='^i18n\(.*\)$' -> '<PROJECT_ROOT>/ui/i18n\1'
module.name_mapper='^effects\(.*\)$' -> '<PROJECT_ROOT>/ui/effects\1' module.name_mapper='^effects\(.*\)$' -> '<PROJECT_ROOT>/ui/effects\1'

View file

@ -27,7 +27,6 @@ type Props = {
onScrollBottom?: (any) => void, onScrollBottom?: (any) => void,
page?: number, page?: number,
pageSize?: number, pageSize?: number,
id?: string,
// If using the default header, this is a unique ID needed to persist the state of the filter setting // If using the default header, this is a unique ID needed to persist the state of the filter setting
persistedStorageKey?: string, persistedStorageKey?: string,
showHiddenByUser: boolean, showHiddenByUser: boolean,
@ -47,7 +46,7 @@ type Props = {
searchOptions?: any, searchOptions?: any,
collectionId?: string, collectionId?: string,
showNoSourceClaims?: boolean, showNoSourceClaims?: boolean,
onClick?: (e: any, index?: number) => void, onClick?: (e: any, claim?: ?Claim, index?: number) => void,
}; };
export default function ClaimList(props: Props) { export default function ClaimList(props: Props) {
@ -56,7 +55,6 @@ export default function ClaimList(props: Props) {
uris, uris,
headerAltControls, headerAltControls,
loading, loading,
id,
persistedStorageKey, persistedStorageKey,
empty, empty,
defaultSort, defaultSort,
@ -110,9 +108,9 @@ export default function ClaimList(props: Props) {
setCurrentSort(currentSort === SORT_NEW ? SORT_OLD : SORT_NEW); setCurrentSort(currentSort === SORT_NEW ? SORT_OLD : SORT_NEW);
} }
function handleClaimClicked(e, index) { function handleClaimClicked(e, claim, index) {
if (onClick) { if (onClick) {
onClick(e, index); onClick(e, claim, index);
} }
} }
@ -200,7 +198,6 @@ export default function ClaimList(props: Props) {
{injectedItem && index === 4 && <li>{injectedItem}</li>} {injectedItem && index === 4 && <li>{injectedItem}</li>}
<ClaimPreview <ClaimPreview
uri={uri} uri={uri}
containerId={id}
indexInContainer={index} indexInContainer={index}
type={type} type={type}
active={activeUri && uri === activeUri} active={activeUri && uri === activeUri}
@ -220,7 +217,7 @@ export default function ClaimList(props: Props) {
return claim.name.length === 24 && !claim.name.includes(' ') && claim.value.author === 'Spee.ch'; return claim.name.length === 24 && !claim.name.includes(' ') && claim.value.author === 'Spee.ch';
}} }}
live={resolveLive(index)} live={resolveLive(index)}
onClick={handleClaimClicked} onClick={(e, claim, index) => handleClaimClicked(e, claim, index)}
/> />
</React.Fragment> </React.Fragment>
))} ))}

View file

@ -583,7 +583,6 @@ function ClaimListDiscover(props: Props) {
)} )}
<ClaimList <ClaimList
tileLayout tileLayout
id={mainSearchKey}
loading={loading} loading={loading}
uris={finalUris} uris={finalUris}
onScrollBottom={handleScrollBottom} onScrollBottom={handleScrollBottom}
@ -617,7 +616,6 @@ function ClaimListDiscover(props: Props) {
</div> </div>
)} )}
<ClaimList <ClaimList
id={mainSearchKey}
type={type} type={type}
loading={loading} loading={loading}
uris={finalUris} uris={finalUris}

View file

@ -29,7 +29,6 @@ import ClaimPreviewNoContent from './claim-preview-no-content';
import { ENABLE_NO_SOURCE_CLAIMS } from 'config'; import { ENABLE_NO_SOURCE_CLAIMS } from 'config';
import Button from 'component/button'; import Button from 'component/button';
import * as ICONS from 'constants/icons'; import * as ICONS from 'constants/icons';
import { CONTAINER_ID } from 'constants/navigation';
const AbandonedChannelPreview = lazyImport(() => const AbandonedChannelPreview = lazyImport(() =>
import('component/abandonedChannelPreview' /* webpackChunkName: "abandonedChannelPreview" */) import('component/abandonedChannelPreview' /* webpackChunkName: "abandonedChannelPreview" */)
@ -67,7 +66,7 @@ type Props = {
actions: boolean | Node | string | number, actions: boolean | Node | string | number,
properties: boolean | Node | string | number | ((Claim) => Node), properties: boolean | Node | string | number | ((Claim) => Node),
empty?: Node, empty?: Node,
onClick?: (e: any, index?: number) => any, onClick?: (e: any, claim?: ?Claim, index?: number) => any,
streamingUrl: ?string, streamingUrl: ?string,
getFile: (string) => void, getFile: (string) => void,
customShouldHide?: (Claim) => boolean, customShouldHide?: (Claim) => boolean,
@ -90,7 +89,6 @@ type Props = {
disableNavigation?: boolean, disableNavigation?: boolean,
mediaDuration?: string, mediaDuration?: string,
date?: any, date?: any,
containerId?: string, // ID or name of the container (e.g. ClaimList, HOC, etc.) that this is in.
indexInContainer?: number, // The index order of this component within 'containerId'. indexInContainer?: number, // The index order of this component within 'containerId'.
channelSubCount?: number, channelSubCount?: number,
}; };
@ -154,7 +152,6 @@ const ClaimPreview = forwardRef<any, {}>((props: Props, ref: any) => {
isCollectionMine, isCollectionMine,
collectionUris, collectionUris,
disableNavigation, disableNavigation,
containerId,
indexInContainer, indexInContainer,
channelSubCount, channelSubCount,
} = props; } = props;
@ -223,7 +220,7 @@ const ClaimPreview = forwardRef<any, {}>((props: Props, ref: any) => {
const handleNavLinkClick = (e) => { const handleNavLinkClick = (e) => {
if (onClick) { if (onClick) {
onClick(e, indexInContainer); onClick(e, claim, indexInContainer); // not sure indexInContainer is used for anything.
} }
e.stopPropagation(); e.stopPropagation();
}; };
@ -232,10 +229,9 @@ const ClaimPreview = forwardRef<any, {}>((props: Props, ref: any) => {
to: { to: {
pathname: navigateUrl, pathname: navigateUrl,
search: navigateSearch.toString() ? '?' + navigateSearch.toString() : '', search: navigateSearch.toString() ? '?' + navigateSearch.toString() : '',
state: containerId ? { [CONTAINER_ID]: containerId } : undefined,
}, },
onClick: (e) => handleNavLinkClick(e), onClick: handleNavLinkClick,
onAuxClick: (e) => handleNavLinkClick(e), onAuxClick: handleNavLinkClick,
}; };
// do not block abandoned and nsfw claims if showUserBlocked is passed // do not block abandoned and nsfw claims if showUserBlocked is passed
@ -281,14 +277,13 @@ const ClaimPreview = forwardRef<any, {}>((props: Props, ref: any) => {
function handleOnClick(e) { function handleOnClick(e) {
if (onClick) { if (onClick) {
onClick(e, indexInContainer); onClick(e, claim, indexInContainer);
} }
if (claim && !pending && !disableNavigation) { if (claim && !pending && !disableNavigation) {
history.push({ history.push({
pathname: navigateUrl, pathname: navigateUrl,
search: navigateSearch.toString() ? '?' + navigateSearch.toString() : '', search: navigateSearch.toString() ? '?' + navigateSearch.toString() : '',
state: containerId ? { [CONTAINER_ID]: containerId } : undefined,
}); });
} }
} }

View file

@ -7,14 +7,19 @@ import { selectUserVerifiedEmail } from 'redux/selectors/user';
import { makeSelectNextUnplayedRecommended } from 'redux/selectors/content'; import { makeSelectNextUnplayedRecommended } from 'redux/selectors/content';
import RecommendedContent from './view'; import RecommendedContent from './view';
const select = (state, props) => ({ const select = (state, props) => {
mature: makeSelectClaimIsNsfw(props.uri)(state), const claim = makeSelectClaimForUri(props.uri)(state);
recommendedContent: makeSelectRecommendedContentForUri(props.uri)(state), const { claim_id: claimId } = claim;
nextRecommendedUri: makeSelectNextUnplayedRecommended(props.uri)(state), return {
isSearching: selectIsSearching(state), mature: makeSelectClaimIsNsfw(props.uri)(state),
isAuthenticated: selectUserVerifiedEmail(state), recommendedContentUris: makeSelectRecommendedContentForUri(props.uri)(state),
claim: makeSelectClaimForUri(props.uri)(state), nextRecommendedUri: makeSelectNextUnplayedRecommended(props.uri)(state),
}); isSearching: selectIsSearching(state),
isAuthenticated: selectUserVerifiedEmail(state),
claim,
claimId,
};
};
const perform = (dispatch) => ({ const perform = (dispatch) => ({
doFetchRecommendedContent: (uri, mature) => dispatch(doFetchRecommendedContent(uri, mature)), doFetchRecommendedContent: (uri, mature) => dispatch(doFetchRecommendedContent(uri, mature)),

View file

@ -1,8 +1,6 @@
// @flow // @flow
import { SHOW_ADS } from 'config'; import { SHOW_ADS } from 'config';
import React from 'react'; import React from 'react';
import { useHistory } from 'react-router-dom';
import { v4 as uuidv4 } from 'uuid';
import ClaimList from 'component/claimList'; import ClaimList from 'component/claimList';
import ClaimListDiscover from 'component/claimListDiscover'; import ClaimListDiscover from 'component/claimListDiscover';
import Ads from 'web/component/ads'; import Ads from 'web/component/ads';
@ -10,14 +8,14 @@ import Card from 'component/common/card';
import { useIsMobile, useIsMediumScreen } from 'effects/use-screensize'; import { useIsMobile, useIsMediumScreen } from 'effects/use-screensize';
import Button from 'component/button'; import Button from 'component/button';
import classnames from 'classnames'; import classnames from 'classnames';
import { CONTAINER_ID } from 'constants/navigation'; import RecSys from 'recsys';
const VIEW_ALL_RELATED = 'view_all_related'; const VIEW_ALL_RELATED = 'view_all_related';
const VIEW_MORE_FROM = 'view_more_from'; const VIEW_MORE_FROM = 'view_more_from';
type Props = { type Props = {
uri: string, uri: string,
recommendedContent: Array<string>, recommendedContentUris: Array<string>,
nextRecommendedUri: string, nextRecommendedUri: string,
isSearching: boolean, isSearching: boolean,
doFetchRecommendedContent: (string, boolean) => void, doFetchRecommendedContent: (string, boolean) => void,
@ -25,7 +23,7 @@ type Props = {
isAuthenticated: boolean, isAuthenticated: boolean,
claim: ?StreamClaim, claim: ?StreamClaim,
doRecommendationUpdate: (claimId: string, urls: Array<string>, id: string, parentId: string) => void, doRecommendationUpdate: (claimId: string, urls: Array<string>, id: string, parentId: string) => void,
doRecommendationClicked: (claimId: string, index: number) => void, claimId: string,
}; };
export default React.memo<Props>(function RecommendedContent(props: Props) { export default React.memo<Props>(function RecommendedContent(props: Props) {
@ -33,23 +31,20 @@ export default React.memo<Props>(function RecommendedContent(props: Props) {
uri, uri,
doFetchRecommendedContent, doFetchRecommendedContent,
mature, mature,
recommendedContent, recommendedContentUris,
nextRecommendedUri, nextRecommendedUri,
isSearching, isSearching,
isAuthenticated, isAuthenticated,
claim, claim,
doRecommendationUpdate, claimId,
doRecommendationClicked,
} = props; } = props;
const [viewMode, setViewMode] = React.useState(VIEW_ALL_RELATED); const [viewMode, setViewMode] = React.useState(VIEW_ALL_RELATED);
const [recommendationId, setRecommendationId] = React.useState('');
const [recommendationUrls, setRecommendationUrls] = React.useState(); const [recommendationUrls, setRecommendationUrls] = React.useState();
const history = useHistory();
const signingChannel = claim && claim.signing_channel; const signingChannel = claim && claim.signing_channel;
const channelName = signingChannel ? signingChannel.name : null; const channelName = signingChannel ? signingChannel.name : null;
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const isMedium = useIsMediumScreen(); const isMedium = useIsMediumScreen();
const { onRecsLoaded: onRecommendationsLoaded, onClickedRecommended: onRecommendationClicked } = RecSys;
React.useEffect(() => { React.useEffect(() => {
function moveAutoplayNextItemToTop(recommendedContent) { function moveAutoplayNextItemToTop(recommendedContent) {
let newList = recommendedContent; let newList = recommendedContent;
@ -72,33 +67,27 @@ export default React.memo<Props>(function RecommendedContent(props: Props) {
} }
} }
const newRecommendationUrls = moveAutoplayNextItemToTop(recommendedContent); const newRecommendationUrls = moveAutoplayNextItemToTop(recommendedContentUris);
if (claim && !listEq(recommendationUrls, newRecommendationUrls)) { if (claim && !listEq(recommendationUrls, newRecommendationUrls)) {
const parentId = (history.location.state && history.location.state[CONTAINER_ID]) || '';
const id = uuidv4();
setRecommendationId(id);
setRecommendationUrls(newRecommendationUrls); setRecommendationUrls(newRecommendationUrls);
doRecommendationUpdate(claim.claim_id, newRecommendationUrls, id, parentId);
} }
}, [ }, [recommendedContentUris, nextRecommendedUri, recommendationUrls, setRecommendationUrls, claim]);
recommendedContent,
nextRecommendedUri,
recommendationUrls,
setRecommendationUrls,
claim,
doRecommendationUpdate,
history.location.state,
]);
React.useEffect(() => { React.useEffect(() => {
doFetchRecommendedContent(uri, mature); doFetchRecommendedContent(uri, mature);
}, [uri, mature, doFetchRecommendedContent]); }, [uri, mature, doFetchRecommendedContent]);
function handleRecommendationClicked(e: any, index: number) { React.useEffect(() => {
// Right now we only want to record the recs if they actually saw them.
if (recommendationUrls && recommendationUrls.length && nextRecommendedUri && viewMode === VIEW_ALL_RELATED) {
onRecommendationsLoaded(claimId, recommendationUrls);
}
}, [recommendationUrls, onRecommendationsLoaded, claimId, nextRecommendedUri, viewMode]);
function handleRecommendationClicked(e, clickedClaim, index: number) {
if (claim) { if (claim) {
doRecommendationClicked(claim.claim_id, index); onRecommendationClicked(claim.claim_id, clickedClaim.claim_id);
} }
} }
@ -133,7 +122,6 @@ export default React.memo<Props>(function RecommendedContent(props: Props) {
<div> <div>
{viewMode === VIEW_ALL_RELATED && ( {viewMode === VIEW_ALL_RELATED && (
<ClaimList <ClaimList
id={recommendationId}
type="small" type="small"
loading={isSearching} loading={isSearching}
uris={recommendationUrls} uris={recommendationUrls}
@ -177,8 +165,8 @@ function areEqual(prevProps: Props, nextProps: Props) {
a.isAuthenticated !== b.isAuthenticated || a.isAuthenticated !== b.isAuthenticated ||
a.isSearching !== b.isSearching || a.isSearching !== b.isSearching ||
a.mature !== b.mature || a.mature !== b.mature ||
(a.recommendedContent && !b.recommendedContent) || (a.recommendedContentUris && !b.recommendedContentUris) ||
(!a.recommendedContent && b.recommendedContent) || (!a.recommendedContentUris && b.recommendedContentUris) ||
(a.claim && !b.claim) || (a.claim && !b.claim) ||
(!a.claim && b.claim) (!a.claim && b.claim)
) { ) {
@ -189,14 +177,14 @@ function areEqual(prevProps: Props, nextProps: Props) {
return false; return false;
} }
if (a.recommendedContent && b.recommendedContent) { if (a.recommendedContentUris && b.recommendedContentUris) {
if (a.recommendedContent.length !== b.recommendedContent.length) { if (a.recommendedContentUris.length !== b.recommendedContentUris.length) {
return false; return false;
} }
let i = a.recommendedContent.length; let i = a.recommendedContentUris.length;
while (i--) { while (i--) {
if (a.recommendedContent[i] !== b.recommendedContent[i]) { if (a.recommendedContentUris[i] !== b.recommendedContentUris[i]) {
return false; return false;
} }
} }

View file

@ -1,54 +1,20 @@
// Created by xander on 6/21/2021 // Created by xander on 6/21/2021
import videojs from 'video.js'; import videojs from 'video.js';
import {
makeSelectRecommendationId,
makeSelectRecommendationParentId,
makeSelectRecommendedClaimIds,
makeSelectRecommendationClicks,
} from 'redux/selectors/content';
import { makeSelectRecommendedRecsysIdForClaimId } from 'redux/selectors/search';
import RecSys from 'recsys';
const VERSION = '0.0.1'; const VERSION = '0.0.1';
const recsysEndpoint = 'https://clickstream.odysee.com/log/video/view';
const recsysId = 'lighthouse-v0';
/* RecSys */ /* RecSys */
const RecsysData = { const PlayerEvent = {
event: { event: {
start: 0, start: 0, // event types
stop: 1, stop: 1,
scrub: 2, scrub: 2,
speed: 3, speed: 3,
}, },
}; };
function createRecsys(claimId, userId, events, loadedAt, isEmbed) { function newRecsysPlayerEvent(eventType, offset, arg) {
const pageLoadedAt = loadedAt;
const pageExitedAt = Date.now();
if (window.store) {
const state = window.store.getState();
return {
uuid: makeSelectRecommendationId(claimId)(state),
parentUuid: makeSelectRecommendationParentId(claimId)(state),
uid: userId,
claimId: claimId,
pageLoadedAt: pageLoadedAt,
pageExitedAt: pageExitedAt,
recsysId: makeSelectRecommendedRecsysIdForClaimId(claimId)(state) || recsysId,
recClaimIds: makeSelectRecommendedClaimIds(claimId)(state),
recClickedVideoIdx: makeSelectRecommendationClicks(claimId)(state),
events: events,
isEmbed: isEmbed,
};
}
return undefined;
}
function newRecsysEvent(eventType, offset, arg) {
if (arg) { if (arg) {
return { return {
event: eventType, event: eventType,
@ -63,30 +29,11 @@ function newRecsysEvent(eventType, offset, arg) {
} }
} }
function sendRecsysEvents(recsys) {
const requestOptions = {
method: 'POST',
headers: { 'Content-Type': 'text/plain' }, // application/json
body: JSON.stringify(recsys),
};
try {
fetch(recsysEndpoint, requestOptions)
.then((response) => response.json())
.then((data) => {
// console.log(`Recsys response data:`, data);
});
} catch (error) {
// console.error(`Recsys Error`, error);
}
}
const defaults = { const defaults = {
endpoint: recsysEndpoint,
recsysId: recsysId,
videoId: null, videoId: null,
userId: 0, userId: 0,
debug: false, debug: false,
embedded: false,
}; };
const Component = videojs.getComponent('Component'); const Component = videojs.getComponent('Component');
@ -98,16 +45,13 @@ class RecsysPlugin extends Component {
// Plugin started // Plugin started
if (options.debug) { if (options.debug) {
this.log(`Created recsys plugin for: videoId:${options.videoId}, userId:${options.userId}`); this.log(`Created recsys plugin for: videoId:${options.videoId}`);
} }
// To help with debugging, we'll add a global vjs object with the video js player // To help with debugging, we'll add a global vjs object with the video js player
window.vjs = player; window.vjs = player;
this.player = player; this.player = player;
this.recsysEvents = [];
this.loadedAt = Date.now();
this.lastTimeUpdate = null; this.lastTimeUpdate = null;
this.currentTimeUpdate = null; this.currentTimeUpdate = null;
this.inPause = false; this.inPause = false;
@ -124,57 +68,37 @@ class RecsysPlugin extends Component {
player.on('dispose', (event) => this.onDispose(event)); player.on('dispose', (event) => this.onDispose(event));
} }
addRecsysEvent(recsysEvent) {
// For now, don't do client-side preprocessing. I think there
// are browser inconsistencies and preprocessing loses too much info.
this.recsysEvents.push(recsysEvent);
}
getRecsysEvents() {
return this.recsysEvents;
}
sendRecsysEvents() {
const event = createRecsys(
this.options_.videoId,
this.options_.userId,
this.getRecsysEvents(),
this.loadedAt,
false
);
if (event) {
sendRecsysEvents(event);
}
}
onPlay(event) { onPlay(event) {
const recsysEvent = newRecsysEvent(RecsysData.event.start, this.player.currentTime()); const recsysEvent = newRecsysPlayerEvent(PlayerEvent.event.start, this.player.currentTime());
this.log('onPlay', recsysEvent); this.log('onPlay', recsysEvent);
this.addRecsysEvent(recsysEvent); RecSys.onRecsysPlayerEvent(this.options_.videoId, recsysEvent, this.options_.embedded);
this.inPause = false; this.inPause = false;
this.lastTimeUpdate = recsysEvent.offset; this.lastTimeUpdate = recsysEvent.offset;
} }
onPause(event) { onPause(event) {
const recsysEvent = newRecsysEvent(RecsysData.event.stop, this.player.currentTime()); const recsysEvent = newRecsysPlayerEvent(PlayerEvent.event.stop, this.player.currentTime());
this.log('onPause', recsysEvent); this.log('onPause', recsysEvent);
this.addRecsysEvent(recsysEvent); RecSys.onRecsysPlayerEvent(this.options_.videoId, recsysEvent);
this.inPause = true; this.inPause = true;
} }
onEnded(event) { onEnded(event) {
const recsysEvent = newRecsysEvent(RecsysData.event.stop, this.player.currentTime()); const recsysEvent = newRecsysPlayerEvent(PlayerEvent.event.stop, this.player.currentTime());
this.log('onEnded', recsysEvent); this.log('onEnded', recsysEvent);
this.addRecsysEvent(recsysEvent); RecSys.onRecsysPlayerEvent(this.options_.videoId, recsysEvent);
} }
onRateChange(event) { onRateChange(event) {
const recsysEvent = newRecsysEvent(RecsysData.event.speed, this.player.currentTime(), this.player.playbackRate()); const recsysEvent = newRecsysPlayerEvent(
PlayerEvent.event.speed,
this.player.currentTime(),
this.player.playbackRate()
);
this.log('onRateChange', recsysEvent); this.log('onRateChange', recsysEvent);
this.addRecsysEvent(recsysEvent); RecSys.onRecsysPlayerEvent(this.options_.videoId, recsysEvent);
} }
onTimeUpdate(event) { onTimeUpdate(event) {
@ -212,19 +136,19 @@ class RecsysPlugin extends Component {
if (fromTime !== curTime) { if (fromTime !== curTime) {
// This removes duplicates that aren't useful. // This removes duplicates that aren't useful.
const recsysEvent = newRecsysEvent(RecsysData.event.scrub, fromTime, curTime); const recsysEvent = newRecsysPlayerEvent(PlayerEvent.event.scrub, fromTime, curTime);
this.log('onSeeked', recsysEvent); this.log('onSeeked', recsysEvent);
this.addRecsysEvent(recsysEvent); RecSys.onRecsysPlayerEvent(this.options_.videoId, recsysEvent);
} }
} }
onDispose(event) { onDispose(event) {
this.sendRecsysEvents(); RecSys.onPlayerDispose(this.options_.videoId, this.options_.embedded);
} }
log(...args) { log(...args) {
if (this.options_.debug) { if (this.options_.debug) {
// console.log(`Recsys Debug:`, JSON.stringify(args)); console.log(`Recsys Player Debug:`, JSON.stringify(args));
} }
} }
} }

View file

@ -54,6 +54,7 @@ type Props = {
startMuted: boolean, startMuted: boolean,
autoplay: boolean, autoplay: boolean,
autoplaySetting: boolean, autoplaySetting: boolean,
embedded: boolean,
toggleVideoTheaterMode: () => void, toggleVideoTheaterMode: () => void,
adUrl: ?string, adUrl: ?string,
claimId: ?string, claimId: ?string,
@ -194,6 +195,7 @@ export default React.memo<Props>(function VideoJs(props: Props) {
const { const {
autoplay, autoplay,
autoplaySetting, autoplaySetting,
embedded,
startMuted, startMuted,
source, source,
sourceType, sourceType,
@ -590,6 +592,7 @@ export default React.memo<Props>(function VideoJs(props: Props) {
player.recsys({ player.recsys({
videoId: claimId, videoId: claimId,
userId: userId, userId: userId,
embedded: embedded,
}); });
} }

View file

@ -125,7 +125,7 @@ function VideoViewer(props: Props) {
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
const [ended, setEnded] = useState(false); const [ended, setEnded] = useState(false);
const [showAutoplayCountdown, setShowAutoplayCountdown] = useState(false); const [showAutoplayCountdown, setShowAutoplayCountdown] = useState(false);
const [isEndededEmbed, setIsEndededEmbed] = useState(false); const [isEndedEmbed, setIsEndedEmbed] = useState(false);
const vjsCallbackDataRef: any = React.useRef(); const vjsCallbackDataRef: any = React.useRef();
const previousUri = usePrevious(uri); const previousUri = usePrevious(uri);
const embedded = useContext(EmbedContext); const embedded = useContext(EmbedContext);
@ -142,7 +142,7 @@ function VideoViewer(props: Props) {
useEffect(() => { useEffect(() => {
if (uri && previousUri && uri !== previousUri) { if (uri && previousUri && uri !== previousUri) {
setShowAutoplayCountdown(false); setShowAutoplayCountdown(false);
setIsEndededEmbed(false); setIsEndedEmbed(false);
setIsLoading(false); setIsLoading(false);
} }
}, [uri, previousUri]); }, [uri, previousUri]);
@ -236,7 +236,7 @@ function VideoViewer(props: Props) {
} }
if (embedded) { if (embedded) {
setIsEndededEmbed(true); setIsEndedEmbed(true);
} else if (!collectionId && autoplayNext) { } else if (!collectionId && autoplayNext) {
setShowAutoplayCountdown(true); setShowAutoplayCountdown(true);
} else if (collectionId) { } else if (collectionId) {
@ -247,7 +247,7 @@ function VideoViewer(props: Props) {
} }
}, [ }, [
embedded, embedded,
setIsEndededEmbed, setIsEndedEmbed,
autoplayMedia, autoplayMedia,
setShowAutoplayCountdown, setShowAutoplayCountdown,
adUrl, adUrl,
@ -264,7 +264,7 @@ function VideoViewer(props: Props) {
setIsLoading(false); setIsLoading(false);
setIsPlaying(true); setIsPlaying(true);
setShowAutoplayCountdown(false); setShowAutoplayCountdown(false);
setIsEndededEmbed(false); setIsEndedEmbed(false);
setReplay(false); setReplay(false);
setDoNavigate(false); setDoNavigate(false);
analytics.videoIsPlaying(true, player); analytics.videoIsPlaying(true, player);
@ -391,7 +391,7 @@ function VideoViewer(props: Props) {
<div <div
className={classnames('file-viewer', { className={classnames('file-viewer', {
'file-viewer--is-playing': isPlaying, 'file-viewer--is-playing': isPlaying,
'file-viewer--ended-embed': isEndededEmbed, 'file-viewer--ended-embed': isEndedEmbed,
})} })}
onContextMenu={stopContextMenu} onContextMenu={stopContextMenu}
> >
@ -403,8 +403,8 @@ function VideoViewer(props: Props) {
doReplay={() => setReplay(true)} doReplay={() => setReplay(true)}
/> />
)} )}
{isEndededEmbed && <FileViewerEmbeddedEnded uri={uri} />} {isEndedEmbed && <FileViewerEmbeddedEnded uri={uri} />}
{embedded && !isEndededEmbed && <FileViewerEmbeddedTitle uri={uri} />} {embedded && !isEndedEmbed && <FileViewerEmbeddedTitle uri={uri} />}
{/* disable this loading behavior because it breaks when player.play() promise hangs */} {/* disable this loading behavior because it breaks when player.play() promise hangs */}
{isLoading && <LoadingScreen status={__('Loading')} />} {isLoading && <LoadingScreen status={__('Loading')} />}
@ -458,6 +458,7 @@ function VideoViewer(props: Props) {
videoTheaterMode={videoTheaterMode} videoTheaterMode={videoTheaterMode}
playNext={doPlayNext} playNext={doPlayNext}
playPrevious={doPlayPrevious} playPrevious={doPlayPrevious}
embedded={embedded}
/> />
)} )}
</div> </div>

254
ui/recsys.js Normal file
View file

@ -0,0 +1,254 @@
import { selectUser } from 'redux/selectors/user';
import { makeSelectRecommendedRecsysIdForClaimId } from 'redux/selectors/search';
import { v4 as Uuidv4 } from 'uuid';
import { parseURI, SETTINGS, makeSelectClaimForUri } from 'lbry-redux';
import { selectPlayingUri, selectPrimaryUri } from 'redux/selectors/content';
import { makeSelectClientSetting, selectDaemonSettings } from 'redux/selectors/settings';
import { history } from './store';
const recsysEndpoint = 'https://clickstream.odysee.com/log/video/view';
const recsysId = 'lighthouse-v0';
const getClaimIdsFromUris = (uris) => {
return uris
? uris.map((uri) => {
try {
const { claimId } = parseURI(uri);
return claimId;
} catch (e) {
return [];
}
})
: [];
};
const 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.
* The entry will be populated with the following:
* - parentUuid // optional
* - Uuid
* - claimId
* - recommendedClaims [] // optionally empty
* - playerEvents [] // optionally empty
* - recommendedClaimsIndexClicked [] // optionally empty
* - UserId
* - pageLoadedAt
* - isEmbed
* - pageExitedAt
* - recsysId // optional
*/
/**
* Function: onClickedRecommended()
* 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['uuid'];
const parentRecommendedClaims = parentEntry['recClaimIds'] || [];
const parentClickedIndexes = parentEntry['recClickedVideoIdx'] || [];
const indexClicked = parentRecommendedClaims.indexOf(newClaimId);
if (parentUuid) {
recsys.createRecsysEntry(newClaimId, parentUuid);
}
parentClickedIndexes.push(indexClicked);
recsys.log('onClickedRecommended', { parentClaimId, 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) {
if (window.store) {
const state = window.store.getState();
if (!recsys.entries[claimId]) {
recsys.createRecsysEntry(claimId);
}
const claimIds = getClaimIdsFromUris(uris);
recsys.entries[claimId]['recsysId'] = makeSelectRecommendedRecsysIdForClaimId(claimId)(state) || recsysId;
recsys.entries[claimId]['pageLoadedAt'] = Date.now();
recsys.entries[claimId]['recClaimIds'] = claimIds;
}
recsys.log('onRecsLoaded', claimId);
},
/**
* Creates an Entry with optional parentUuid
* @param: claimId: string
* @param: parentUuid: string (optional)
*/
createRecsysEntry: function (claimId, parentUuid) {
if (window.store && claimId) {
const state = window.store.getState();
const { id: userId } = selectUser(state);
if (parentUuid) {
// Make a stub entry that will be filled out on page load
recsys.entries[claimId] = {
uuid: Uuidv4(),
parentUuid: parentUuid,
uid: userId || null, // selectUser
claimId: claimId,
recClickedVideoIdx: [],
pageLoadedAt: Date.now(),
events: [],
};
} else {
recsys.entries[claimId] = {
uuid: Uuidv4(),
uid: userId, // selectUser
claimId: claimId,
pageLoadedAt: Date.now(),
recsysId: makeSelectRecommendedRecsysIdForClaimId(claimId)(state) || recsysId,
recClaimIds: [],
recClickedVideoIdx: [],
events: [],
};
}
}
recsys.log('createRecsysEntry', claimId);
},
/**
* Send event for claimId
* @param claimId
* @param isTentative
*/
sendRecsysEntry: function (claimId, isTentative) {
const shareTelemetry =
IS_WEB || (window && window.store && selectDaemonSettings(window.store.getState()).share_usage_data);
if (recsys.entries[claimId] && shareTelemetry) {
const data = JSON.stringify(recsys.entries[claimId]);
try {
navigator.sendBeacon(recsysEndpoint, data);
if (!isTentative) {
delete recsys.entries[claimId];
}
} catch (error) {
console.log('no beacon for you', error);
}
}
recsys.log('sendRecsysEntry', claimId);
},
/**
* A player event fired. Get the Entry for the claimId, and add the events
* @param claimId
* @param event
*/
onRecsysPlayerEvent: function (claimId, event, isEmbedded) {
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) {
if (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.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.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.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 = makeSelectClientSetting(SETTINGS.FLOATING_PLAYER)(state);
// 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.sendRecsysEntry(claimId);
}
recsys.log('OnNavigate', claimId);
});
}
},
};
// @if TARGET='web'
document.addEventListener('visibilitychange', function logData() {
if (document.visibilityState === 'hidden') {
Object.keys(recsys.entries).map((claimId) => recsys.sendRecsysEntry(claimId, true));
}
});
// @endif
history.listen(() => {
recsys.onNavigate();
});
export default recsys;

View file

@ -116,43 +116,6 @@ reducers[ACTIONS.CLEAR_CONTENT_HISTORY_URI] = (state, action) => {
reducers[ACTIONS.CLEAR_CONTENT_HISTORY_ALL] = (state) => ({ ...state, history: [] }); reducers[ACTIONS.CLEAR_CONTENT_HISTORY_ALL] = (state) => ({ ...state, history: [] });
reducers[ACTIONS.RECOMMENDATION_UPDATED] = (state, action) => {
const { claimId, urls, id, parentId } = action.data;
const recommendationId = Object.assign({}, state.recommendationId);
const recommendationParentId = Object.assign({}, state.recommendationParentId);
const recommendationUrls = Object.assign({}, state.recommendationUrls);
const recommendationClicks = Object.assign({}, state.recommendationClicks);
if (urls && urls.length > 0) {
recommendationId[claimId] = id;
recommendationParentId[claimId] = parentId;
recommendationUrls[claimId] = urls;
recommendationClicks[claimId] = [];
} else {
delete recommendationId[claimId];
delete recommendationParentId[claimId];
delete recommendationUrls[claimId];
delete recommendationClicks[claimId];
}
return { ...state, recommendationId, recommendationParentId, recommendationUrls, recommendationClicks };
};
reducers[ACTIONS.RECOMMENDATION_CLICKED] = (state, action) => {
const { claimId, index } = action.data;
const recommendationClicks = Object.assign({}, state.recommendationClicks);
if (state.recommendationUrls[claimId] && index >= 0 && index < state.recommendationUrls[claimId].length) {
if (recommendationClicks[claimId]) {
recommendationClicks[claimId].push(index);
} else {
recommendationClicks[claimId] = [index];
}
}
return { ...state, recommendationClicks };
};
// reducers[LBRY_REDUX_ACTIONS.PURCHASE_URI_FAILED] = (state, action) => { // reducers[LBRY_REDUX_ACTIONS.PURCHASE_URI_FAILED] = (state, action) => {
// return { // return {
// ...state, // ...state,

View file

@ -3,7 +3,6 @@ import { createSelector } from 'reselect';
import { import {
makeSelectClaimForUri, makeSelectClaimForUri,
selectClaimsByUri, selectClaimsByUri,
makeSelectClaimsInChannelForCurrentPageState,
makeSelectClaimIsNsfw, makeSelectClaimIsNsfw,
makeSelectClaimIsMine, makeSelectClaimIsMine,
makeSelectMediaTypeForUri, makeSelectMediaTypeForUri,
@ -11,7 +10,6 @@ import {
parseURI, parseURI,
makeSelectContentTypeForUri, makeSelectContentTypeForUri,
makeSelectFileNameForUri, makeSelectFileNameForUri,
selectClaimIdsByUri,
} from 'lbry-redux'; } from 'lbry-redux';
import { makeSelectRecommendedContentForUri } from 'redux/selectors/search'; import { makeSelectRecommendedContentForUri } from 'redux/selectors/search';
import { selectMutedChannels } from 'redux/selectors/blocked'; import { selectMutedChannels } from 'redux/selectors/blocked';
@ -155,18 +153,6 @@ export const selectRecentHistory = createSelector(selectHistory, (history) => {
return history.slice(0, RECENT_HISTORY_AMOUNT); return history.slice(0, RECENT_HISTORY_AMOUNT);
}); });
export const makeSelectCategoryListUris = (uris: ?Array<string>, channel: string) =>
createSelector(makeSelectClaimsInChannelForCurrentPageState(channel), (channelClaims) => {
if (uris) return uris;
if (channelClaims) {
const CATEGORY_LIST_SIZE = 10;
return channelClaims.slice(0, CATEGORY_LIST_SIZE).map(({ name, claim_id: claimId }) => `${name}#${claimId}`);
}
return null;
});
export const makeSelectShouldObscurePreview = (uri: string) => export const makeSelectShouldObscurePreview = (uri: string) =>
createSelector(selectShowMatureContent, makeSelectClaimIsNsfw(uri), (showMatureContent, isClaimMature) => { createSelector(selectShowMatureContent, makeSelectClaimIsNsfw(uri), (showMatureContent, isClaimMature) => {
return isClaimMature && !showMatureContent; return isClaimMature && !showMatureContent;
@ -247,21 +233,3 @@ export const makeSelectInsufficientCreditsForUri = (uri: string) =>
return !isMine && costInfo && costInfo.cost > 0 && costInfo.cost > balance; return !isMine && costInfo && costInfo.cost > 0 && costInfo.cost > balance;
} }
); );
export const makeSelectRecommendationId = (claimId: string) =>
createSelector(selectState, (state) => state.recommendationId[claimId]);
export const makeSelectRecommendationParentId = (claimId: string) =>
createSelector(selectState, (state) => state.recommendationParentId[claimId]);
export const makeSelectRecommendedClaimIds = (claimId: string) =>
createSelector(selectState, selectClaimIdsByUri, (state, claimIdsByUri) => {
const recommendationUrls = state.recommendationUrls[claimId];
if (recommendationUrls) {
return recommendationUrls.map((url) => claimIdsByUri[url]);
}
return undefined;
});
export const makeSelectRecommendationClicks = (claimId: string) =>
createSelector(selectState, (state) => state.recommendationClicks[claimId]);

View file

@ -98,7 +98,7 @@ export const makeSelectRecommendedRecsysIdForClaimId = (claimId: string) =>
createSelector(makeSelectClaimForClaimId(claimId), selectSearchResultByQuery, (claim, searchUrisByQuery) => { createSelector(makeSelectClaimForClaimId(claimId), selectSearchResultByQuery, (claim, searchUrisByQuery) => {
// TODO: DRY this out. // TODO: DRY this out.
let poweredBy; let poweredBy;
if (claim) { if (claim && claimId) {
const isMature = isClaimNsfw(claim); const isMature = isClaimNsfw(claim);
const { title } = claim.value; const { title } = claim.value;
@ -204,3 +204,8 @@ export const makeSelectIsResolvingWinningUri = (query: string = '') => {
} }
); );
}; };
export const makeSelectUrlForClaimId = (claimId: string) =>
createSelector(
makeSelectClaimForClaimId(claimId), (claim) => claim ? claim.canonical_url || claim.permanent_url : null
);