lbry-desktop/ui/component/viewers/videoViewer/internal/plugins/videojs-recsys/plugin.js
2021-06-30 19:12:21 -07:00

285 lines
8.5 KiB
JavaScript

// Created by xander on 6/21/2021
import videojs from 'video.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();
this.playInitiated = false;
// 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) {
if (!this.playInitiated) {
switch (recsysEvent.event) {
case RecsysData.event.start:
this.playInitiated = true;
break;
case RecsysData.event.scrub:
// If playback hasn't started, swallow scrub events. They offer some
// information, but if there isn't a subsequent play event, it's
// mostly nonsensical.
return undefined;
case RecsysData.event.stop:
// If playback hasn't started, swallow stop events. This means
// you're going to start from an offset but the start event
// captures that information. (With the ambiguity that you can't
// tell if they scrubbed, landed at the offset, or restarted. But
// I don't think that matters much.)
return undefined;
case RecsysData.event.speed:
if (this.recsysEvents.length > 0 && this.recsysEvents[0].event === RecsysData.event.speed) {
// video.js will sometimes fire the default play speed followed by the
// user preference. This is not useful information so we can keep the latter.
this.recsysEvents.pop();
}
}
} else {
const lastEvent = this.recsysEvents[this.recsysEvents.length - 1];
switch (recsysEvent.event) {
case RecsysData.event.scrub:
if (lastEvent.event === RecsysData.event.stop) {
// Video.js fires a stop before the seek. This extra information isn't
// useful to log though, so this code prunes the stop event if it was
// within 0.25 seconds.
if (Math.abs(lastEvent.offset - recsysEvent.offset) < 0.25) {
this.recsysEvents.pop();
recsysEvent.offset = lastEvent.arg;
}
} else if (lastEvent.event === RecsysData.event.start) {
// If the last event was a play and this event is a scrub close to
// that play position, I think it's just a weird emit order for
// video.js and we don't need to log the scrub.
if (Math.abs(lastEvent.offset - recsysEvent.arg) < 0.25) {
return undefined;
}
}
break;
case RecsysData.event.start:
if (lastEvent.event === RecsysData.event.scrub) {
// If the last event was a seek and this is a play,
// it's reasonable to just implicitly assume the play occurred,
// no need to create the play event.
return undefined;
} else if (lastEvent.event === RecsysData.event.start) {
// A start followed by a start is a buffering event.
// It may make sense to keep these. A user may abandon
// a page *not because it's bad content but because
// there are network troubles right now*.
}
break;
}
}
this.recsysEvents.push(recsysEvent);
}
getRecsysEvents() {
return this.recsysEvents.map((event) => {
if (event !== RecsysData.event.stop) {
return event;
}
// I used the arg in stop events to smuggle the seek time into
// the scrub events. But the backend doesn't expect it.
const dup = { ...event };
delete dup.arg;
return dup;
});
}
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) {
// The API doesn't want an `arg` for `STOP` events. However, video.js
// emits these before the seek events, and that seems to be the easiest
// way to lift time you are seeking from into the scrub record (via lastTimeUpdate).
// Hacky, but works.
const recsysEvent = newRecsysEvent(RecsysData.event.stop, this.player.currentTime(), this.lastTimeUpdate);
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) {
// This is actually a bug. The offset should be the offset. The change speed should be change speed.
// Otherise, you don't know where it changed and the time calc is wrong.
const recsysEvent = newRecsysEvent(RecsysData.event.speed, this.player.currentTime(), this.player.playbackRate());
this.log('onRateChange', recsysEvent);
this.addRecsysEvent(recsysEvent);
}
onTimeUpdate(event) {
this.lastTimeUpdate = this.currentTimeUpdate;
this.currentTimeUpdate = this.player.currentTime();
}
onSeeked(event) {
// The problem? `lastTimeUpdate` is wrong.
// So every seeks l
// If the immediately prior event is a pause?
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;