From 5766dc34b5bc725a8a7336d440b59381e7dd6c8b Mon Sep 17 00:00:00 2001 From: infinite-persistence Date: Tue, 29 Jun 2021 09:51:04 +0800 Subject: [PATCH] Port recsys to master; enabled for SIMPLE_SITE only --- ui/component/viewers/videoViewer/index.js | 4 +- .../internal/plugins/videojs-recsys/plugin.js | 201 ++++++++++++++++++ .../viewers/videoViewer/internal/videojs.jsx | 18 ++ ui/component/viewers/videoViewer/view.jsx | 39 ++-- 4 files changed, 243 insertions(+), 19 deletions(-) create mode 100644 ui/component/viewers/videoViewer/internal/plugins/videojs-recsys/plugin.js diff --git a/ui/component/viewers/videoViewer/index.js b/ui/component/viewers/videoViewer/index.js index 52fe1db17..f7d8925a8 100644 --- a/ui/component/viewers/videoViewer/index.js +++ b/ui/component/viewers/videoViewer/index.js @@ -9,13 +9,14 @@ import { withRouter } from 'react-router'; import { doClaimEligiblePurchaseRewards } from 'redux/actions/rewards'; import { makeSelectClientSetting, selectHomepageData } from 'redux/selectors/settings'; import { toggleVideoTheaterMode, doSetClientSetting } from 'redux/actions/settings'; -import { selectUserVerifiedEmail } from 'redux/selectors/user'; +import { selectUserVerifiedEmail, selectUser } from 'redux/selectors/user'; const select = (state, props) => { const { search } = props.location; const urlParams = new URLSearchParams(search); const autoplay = urlParams.get('autoplay'); const position = urlParams.get('t') !== null ? urlParams.get('t') : makeSelectContentPositionForUri(props.uri)(state); + const userId = selectUser(state) && selectUser(state).id; return { autoplayIfEmbedded: Boolean(autoplay), @@ -29,6 +30,7 @@ const select = (state, props) => { claim: makeSelectClaimForUri(props.uri)(state), homepageData: selectHomepageData(state), authenticated: selectUserVerifiedEmail(state), + userId: userId, }; }; diff --git a/ui/component/viewers/videoViewer/internal/plugins/videojs-recsys/plugin.js b/ui/component/viewers/videoViewer/internal/plugins/videojs-recsys/plugin.js new file mode 100644 index 000000000..5eeda11d3 --- /dev/null +++ b/ui/component/viewers/videoViewer/internal/plugins/videojs-recsys/plugin.js @@ -0,0 +1,201 @@ +// Created by xander on 6/21/2021 +import videojs from 'video.js/dist/video.min.js'; +import { v4 as uuidV4 } from 'uuid'; +const VERSION = '0.0.1'; + +const recsysEndpoint = 'https://clickstream.odysee.com/log/video/view'; +const recsysId = 'lighthouse-v0'; + +/* RecSys */ +const RecsysData = { + event: { + start: 0, + stop: 1, + scrub: 2, + speed: 3, + }, +}; + +function createRecsys(claimId, userId, events, loadedAt, isEmbed) { + // TODO: use a UUID generator + const uuid = uuidV4(); + const pageLoadedAt = loadedAt; + const pageExitedAt = Date.now(); + return { + uuid: uuid, + parentUuid: null, + uid: userId, + claimId: claimId, + pageLoadedAt: pageLoadedAt, + pageExitedAt: pageExitedAt, + recsysId: recsysId, + recClaimIds: null, + recClickedVideoIdx: null, + events: events, + isEmbed: isEmbed, + }; +} + +function newRecsysEvent(eventType, offset, arg) { + if (arg) { + return { + event: eventType, + offset: offset, + arg: arg, + }; + } else { + return { + event: eventType, + offset: offset, + }; + } +} + +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 = { + endpoint: recsysEndpoint, + recsysId: recsysId, + videoId: null, + userId: 0, + debug: false, +}; + +const Component = videojs.getComponent('Component'); +const registerPlugin = videojs.registerPlugin || videojs.plugin; + +class RecsysPlugin extends Component { + constructor(player, options) { + super(player, options); + + // Plugin started + if (options.debug) { + this.log(`Created recsys plugin for: videoId:${options.videoId}, userId:${options.userId}`); + } + + // To help with debugging, we'll add a global vjs object with the video js player + window.vjs = player; + + this.player = player; + + this.recsysEvents = []; + this.lastTimeUpdate = null; + this.currentTimeUpdate = null; + this.loadedAt = Date.now(); + + // Plugin event listeners + player.on('playing', (event) => this.onPlay(event)); + player.on('pause', (event) => this.onPause(event)); + player.on('ended', (event) => this.onEnded(event)); + player.on('ratechange', (event) => this.onRateChange(event)); + player.on('timeupdate', (event) => this.onTimeUpdate(event)); + player.on('seeked', (event) => this.onSeeked(event)); + + // Event trigger to send recsys event + player.on('dispose', (event) => this.onDispose(event)); + } + + addRecsysEvent(recsysEvent) { + this.recsysEvents.push(recsysEvent); + } + + getRecsysEvents() { + return this.recsysEvents; + } + + sendRecsysEvents() { + const event = createRecsys( + this.options_.videoId, + this.options_.userId, + this.getRecsysEvents(), + this.loadedAt, + false + ); + sendRecsysEvents(event); + } + + onPlay(event) { + const recsysEvent = newRecsysEvent(RecsysData.event.start, this.player.currentTime()); + this.log('onPlay', recsysEvent); + this.addRecsysEvent(recsysEvent); + } + + onPause(event) { + const recsysEvent = newRecsysEvent(RecsysData.event.stop, this.player.currentTime()); + this.log('onPause', recsysEvent); + this.addRecsysEvent(recsysEvent); + } + + onEnded(event) { + const recsysEvent = newRecsysEvent(RecsysData.event.stop, this.player.currentTime()); + this.log('onEnded', recsysEvent); + this.addRecsysEvent(recsysEvent); + } + + onRateChange(event) { + const recsysEvent = newRecsysEvent(RecsysData.event.speed, this.player.currentTime()); + this.log('onRateChange', recsysEvent); + this.addRecsysEvent(recsysEvent); + } + + onTimeUpdate(event) { + this.lastTimeUpdate = this.currentTimeUpdate; + this.currentTimeUpdate = this.player.currentTime(); + } + + onSeeked(event) { + const recsysEvent = newRecsysEvent(RecsysData.event.scrub, this.lastTimeUpdate, this.player.currentTime()); + this.log('onSeeked', recsysEvent); + this.addRecsysEvent(recsysEvent); + } + + onDispose(event) { + this.sendRecsysEvents(); + } + + log(...args) { + if (this.options_.debug) { + // console.log(`Recsys Debug:`, JSON.stringify(args)); + } + } +} + +videojs.registerComponent('recsys', RecsysPlugin); + +const onPlayerReady = (player, options) => { + player.recsys = new RecsysPlugin(player, options); +}; + +/** + * Initialize the plugin. + * + * @function plugin + * @param {Object} [options={}] + */ +const plugin = function (options) { + this.ready(() => { + onPlayerReady(this, videojs.mergeOptions(defaults, options)); + }); +}; + +plugin.VERSION = VERSION; + +registerPlugin('recsys', plugin); + +export default plugin; diff --git a/ui/component/viewers/videoViewer/internal/videojs.jsx b/ui/component/viewers/videoViewer/internal/videojs.jsx index 0d5489540..7a1a3110f 100644 --- a/ui/component/viewers/videoViewer/internal/videojs.jsx +++ b/ui/component/viewers/videoViewer/internal/videojs.jsx @@ -1,5 +1,6 @@ // @flow import React, { useEffect, useRef, useState } from 'react'; +import { SIMPLE_SITE } from 'config'; import Button from 'component/button'; import * as ICONS from 'constants/icons'; import classnames from 'classnames'; @@ -9,6 +10,7 @@ import eventTracking from 'videojs-event-tracking'; import * as OVERLAY from './overlays'; import './plugins/videojs-mobile-ui/plugin'; import hlsQualitySelector from './plugins/videojs-hls-quality-selector/plugin'; +import recsys from './plugins/videojs-recsys/plugin'; import qualityLevels from 'videojs-contrib-quality-levels'; import isUserTyping from 'util/detect-typing'; @@ -50,6 +52,8 @@ type Props = { autoplay: boolean, toggleVideoTheaterMode: () => void, adUrl: ?string, + claimId: ?string, + userId: ?number, }; type VideoJSOptions = { @@ -121,6 +125,10 @@ if (!Object.keys(videojs.getPlugins()).includes('qualityLevels')) { videojs.registerPlugin('qualityLevels', qualityLevels); } +if (!Object.keys(videojs.getPlugins()).includes('recsys')) { + videojs.registerPlugin('recsys', recsys); +} + // **************************************************************************** // LbryVolumeBarClass // **************************************************************************** @@ -180,6 +188,8 @@ export default React.memo(function VideoJs(props: Props) { onPlayerReady, toggleVideoTheaterMode, adUrl, + claimId, + userId, } = props; const [reload, setReload] = useState('initial'); @@ -583,6 +593,14 @@ export default React.memo(function VideoJs(props: Props) { displayCurrentQuality: true, }); + // Add recsys plugin + if (SIMPLE_SITE) { + player.recsys({ + videoId: claimId, + userId: userId, + }); + } + // Update player source player.src({ src: finalSource, diff --git a/ui/component/viewers/videoViewer/view.jsx b/ui/component/viewers/videoViewer/view.jsx index 53637b2f9..a8ab89b39 100644 --- a/ui/component/viewers/videoViewer/view.jsx +++ b/ui/component/viewers/videoViewer/view.jsx @@ -48,6 +48,7 @@ type Props = { toggleVideoTheaterMode: () => void, setVideoPlaybackRate: (number) => void, authenticated: boolean, + userId: number, homepageData: { PRIMARY_CONTENT_CHANNEL_IDS?: Array, ENLIGHTENMENT_CHANNEL_IDS?: Array, @@ -89,6 +90,7 @@ function VideoViewer(props: Props) { setVideoPlaybackRate, homepageData, authenticated, + userId, } = props; const { PRIMARY_CONTENT_CHANNEL_IDS = [], @@ -179,13 +181,22 @@ function VideoViewer(props: Props) { } }, [embedded, setIsEndededEmbed, autoplaySetting, setShowAutoplayCountdown, adUrl, setAdUrl]); - function onPlay() { + function onPlay(player) { setIsLoading(false); setIsPlaying(true); setShowAutoplayCountdown(false); setIsEndededEmbed(false); } + function onPause(player) { + setIsPlaying(false); + handlePosition(player); + } + + function onDispose(player) { + handlePosition(player); + } + function handlePosition(player) { if (player.ended()) { clearPosition(uri); @@ -223,17 +234,11 @@ function VideoViewer(props: Props) { Promise.race([playPromise, timeoutPromise]).catch((error) => { if (typeof error === 'object' && error.name && error.name === 'NotAllowedError') { - if (player.autoplay() && !player.muted()) { - player.muted(true); - } + // Autoplay disallowed by browser } if (PLAY_TIMEOUT_ERROR) { - const retryPlayPromise = player.play(); - Promise.race([retryPlayPromise, timeoutPromise]).catch((error) => { - setIsLoading(false); - setIsPlaying(false); - }); + // Autoplay failed } else { setIsLoading(false); setIsPlaying(false); @@ -252,11 +257,10 @@ function VideoViewer(props: Props) { player.on('tracking:buffered', doTrackingBuffered); player.on('tracking:firstplay', doTrackingFirstPlay); player.on('ended', onEnded); - player.on('play', onPlay); - player.on('pause', () => { - setIsPlaying(false); - handlePosition(player); - }); + player.on('play', () => onPlay(player)); + player.on('pause', () => onPause(player)); + player.on('dispose', () => onDispose(player)); + player.on('error', () => { const error = player.error(); if (error) { @@ -283,10 +287,7 @@ function VideoViewer(props: Props) { if (position) { player.currentTime(position); } - player.on('dispose', () => { - handlePosition(player); - }); - }, playerReadyDependencyList); + }, playerReadyDependencyList); // eslint-disable-line return (
)}