Files
bitvid/js/services/watchHistoryTelemetry.js
T

600 lines
17 KiB
JavaScript

import { isDevMode } from "../config.js";
import { pointerArrayToKey } from "../utils/pointer.js";
import {
ingestLocalViewEvent as defaultIngestLocalViewEvent,
} from "../viewCounter.js";
const DEFAULT_VIEW_THRESHOLD_SECONDS = 12;
function normalizeString(value) {
return typeof value === "string" ? value.trim() : "";
}
function sanitizeVideoMetadata(video) {
if (!video || typeof video !== "object") {
return null;
}
const createdAtCandidates = [
video.rootCreatedAt,
video.created_at,
video.createdAt,
video.publishedAt,
];
let createdAt = null;
for (const candidate of createdAtCandidates) {
if (Number.isFinite(candidate)) {
createdAt = Math.floor(candidate);
break;
}
}
return {
id: typeof video.id === "string" ? video.id : "",
title: typeof video.title === "string" ? video.title : "",
thumbnail: typeof video.thumbnail === "string" ? video.thumbnail : "",
pubkey: typeof video.pubkey === "string" ? video.pubkey : "",
created_at: createdAt,
url: typeof video.url === "string" ? video.url : "",
magnet: typeof video.magnet === "string" ? video.magnet : "",
infoHash: typeof video.infoHash === "string" ? video.infoHash : "",
legacyInfoHash:
typeof video.legacyInfoHash === "string" ? video.legacyInfoHash : "",
};
}
export default class WatchHistoryTelemetry {
constructor({
watchHistoryService = null,
watchHistoryController = null,
nostrClient = null,
log,
normalizeHexPubkey,
getActiveUserPubkey,
ingestLocalViewEvent = defaultIngestLocalViewEvent,
viewThresholdSeconds = DEFAULT_VIEW_THRESHOLD_SECONDS,
} = {}) {
this.watchHistoryService = watchHistoryService;
this.watchHistoryController = watchHistoryController;
this.nostrClient = nostrClient;
this.log = typeof log === "function" ? log : () => {};
this.normalizeHexPubkey =
typeof normalizeHexPubkey === "function"
? normalizeHexPubkey
: () => null;
this.getActiveUserPubkey =
typeof getActiveUserPubkey === "function" ? getActiveUserPubkey : () => null;
this.ingestLocalViewEvent =
typeof ingestLocalViewEvent === "function"
? ingestLocalViewEvent
: defaultIngestLocalViewEvent;
this.viewThresholdSeconds = Number.isFinite(viewThresholdSeconds)
? Math.max(0, viewThresholdSeconds)
: DEFAULT_VIEW_THRESHOLD_SECONDS;
this.watchHistoryMetadataEnabled = null;
this.watchHistoryPreferenceUnsubscribe = null;
this.playbackTelemetryState = null;
this.loggedViewPointerKeys = new Set();
}
destroy() {
this.resetPlaybackLoggingState();
this._clearPreferenceSubscription();
this.watchHistoryMetadataEnabled = null;
}
async initPreferenceSync() {
if (!this.watchHistoryService?.isEnabled?.()) {
this.watchHistoryMetadataEnabled = false;
return null;
}
this.refreshPreferenceSettings();
if (
typeof this.watchHistoryService.subscribe === "function" &&
!this.watchHistoryPreferenceUnsubscribe
) {
try {
const unsubscribe = this.watchHistoryService.subscribe(
"metadata-preference",
(payload) => {
const previous = this.watchHistoryMetadataEnabled;
const enabled = payload?.enabled !== false;
this.watchHistoryMetadataEnabled = enabled;
if (previous === true && enabled === false) {
try {
this.watchHistoryService?.clearLocalMetadata?.();
} catch (error) {
if (isDevMode) {
console.warn(
"[watchHistoryTelemetry] Failed to clear cached metadata after toggle off:",
error,
);
}
}
}
},
);
if (typeof unsubscribe === "function") {
this.watchHistoryPreferenceUnsubscribe = unsubscribe;
}
} catch (error) {
if (isDevMode) {
console.warn(
"[watchHistoryTelemetry] Failed to subscribe to metadata preference changes:",
error,
);
}
}
}
return this.watchHistoryPreferenceUnsubscribe || null;
}
refreshPreferenceSettings() {
if (!this.watchHistoryService?.isEnabled?.()) {
this.watchHistoryMetadataEnabled = false;
return this.watchHistoryMetadataEnabled;
}
const previous = this.watchHistoryMetadataEnabled;
let enabled = true;
try {
if (typeof this.watchHistoryService.getSettings === "function") {
const settings = this.watchHistoryService.getSettings();
enabled = settings?.metadata?.storeLocally !== false;
} else if (
typeof this.watchHistoryService.shouldStoreMetadata === "function"
) {
enabled = this.watchHistoryService.shouldStoreMetadata() !== false;
}
} catch (error) {
if (isDevMode) {
console.warn(
"[watchHistoryTelemetry] Failed to read metadata settings:",
error,
);
}
enabled = true;
}
this.watchHistoryMetadataEnabled = enabled;
if (enabled === false && previous !== false) {
try {
this.watchHistoryService?.clearLocalMetadata?.();
} catch (error) {
if (isDevMode) {
console.warn(
"[watchHistoryTelemetry] Failed to purge metadata cache while preference disabled:",
error,
);
}
}
}
return this.watchHistoryMetadataEnabled;
}
isMetadataPreferenceEnabled() {
return this.watchHistoryMetadataEnabled !== false;
}
persistMetadataForVideo(video, pointerInfo) {
if (
!this.watchHistoryMetadataEnabled ||
!pointerInfo ||
!pointerInfo.key ||
typeof this.watchHistoryService?.setLocalMetadata !== "function"
) {
return;
}
if (!video || typeof video !== "object") {
return;
}
const metadata = {
video: sanitizeVideoMetadata(video),
};
try {
this.watchHistoryService.setLocalMetadata(pointerInfo.key, metadata);
} catch (error) {
if (isDevMode) {
console.warn(
"[watchHistoryTelemetry] Failed to persist local metadata for pointer:",
pointerInfo.key,
error,
);
}
}
}
dropMetadata(pointerKey) {
if (!pointerKey || typeof pointerKey !== "string") {
return;
}
if (typeof this.watchHistoryService?.removeLocalMetadata !== "function") {
return;
}
try {
this.watchHistoryService.removeLocalMetadata(pointerKey);
} catch (error) {
if (isDevMode) {
console.warn(
"[watchHistoryTelemetry] Failed to remove cached metadata for pointer:",
pointerKey,
error,
);
}
}
}
async handleRemoval(payload = {}) {
if (!this.watchHistoryController) {
const error = new Error("watch-history-disabled");
error.handled = true;
throw error;
}
return this.watchHistoryController.handleWatchHistoryRemoval(payload);
}
flush(reason = "session-end", context = "watch-history") {
if (!this.watchHistoryController) {
return Promise.resolve();
}
try {
return this.watchHistoryController.flush(reason, context);
} catch (error) {
return Promise.reject(error);
}
}
cancelPlaybackLogging({
reason = "session-end",
context = "cancelPendingViewLogging",
} = {}) {
const state = this.playbackTelemetryState;
if (!state) {
return;
}
if (state.viewTimerId) {
this._getTimerHost().clearTimeout(state.viewTimerId);
}
if (Array.isArray(state.handlers) && state.videoEl) {
for (const { eventName, handler } of state.handlers) {
try {
state.videoEl.removeEventListener(eventName, handler);
} catch (error) {
if (isDevMode) {
console.warn(
`[watchHistoryTelemetry] Failed to detach ${eventName} listener:`,
error,
);
}
}
}
}
this.playbackTelemetryState = null;
this.flush(reason, context).catch((error) => {
const message = normalizeString(error?.message) || String(error ?? "unknown");
this.log(
`[watchHistoryTelemetry] Watch history flush failed: ${message}`,
);
});
}
resetPlaybackLoggingState() {
this.cancelPlaybackLogging();
if (this.loggedViewPointerKeys.size > 0) {
this.loggedViewPointerKeys.clear();
}
}
preparePlaybackLogging({
videoElement,
pointer,
pointerKey: explicitPointerKey,
video,
} = {}) {
this.cancelPlaybackLogging();
if (!videoElement || typeof videoElement.addEventListener !== "function") {
return;
}
const pointerKey = explicitPointerKey || pointerArrayToKey(pointer);
if (!pointer || !pointerKey) {
return;
}
const viewerIdentityKey = this.getActiveViewIdentityKey();
const cooldownKey = this.buildViewCooldownKey(pointerKey, viewerIdentityKey);
if (cooldownKey && this.loggedViewPointerKeys.has(cooldownKey)) {
return;
}
const state = {
videoEl: videoElement,
pointer,
pointerKey,
viewerIdentityKey,
handlers: [],
viewTimerId: null,
viewFired: false,
videoMetadata: sanitizeVideoMetadata(video),
};
const clearTimer = (timerKey) => {
const property = `${timerKey}TimerId`;
if (state[property]) {
this._getTimerHost().clearTimeout(state[property]);
state[property] = null;
}
};
const finalizeView = () => {
if (state.viewFired) {
return;
}
state.viewFired = true;
clearTimer("view");
this.cancelPlaybackLogging();
const { pointer: thresholdPointer, pointerKey: thresholdPointerKey } =
state;
(async () => {
let viewResult;
try {
const canUseWatchHistoryService =
typeof this.watchHistoryService?.publishView === "function";
const resolveWatchActor = () => {
const normalizedUser = this.normalizeHexPubkey(
this.getActiveUserPubkey(),
);
if (normalizedUser) {
return normalizedUser;
}
const normalizedClient = this.normalizeHexPubkey(
this.nostrClient?.pubkey,
);
if (normalizedClient) {
return normalizedClient;
}
const normalizedSession = this.normalizeHexPubkey(
this.nostrClient?.sessionActor?.pubkey,
);
if (normalizedSession) {
return normalizedSession;
}
if (normalizeString(this.nostrClient?.pubkey)) {
return normalizeString(this.nostrClient.pubkey).toLowerCase();
}
if (normalizeString(this.nostrClient?.sessionActor?.pubkey)) {
return normalizeString(
this.nostrClient.sessionActor.pubkey,
).toLowerCase();
}
return "";
};
const activeWatchActor = resolveWatchActor();
const watchMetadata = {};
if (activeWatchActor) {
watchMetadata.actor = activeWatchActor;
}
if (state.videoMetadata) {
watchMetadata.video = state.videoMetadata;
}
const metadataPayload = Object.keys(watchMetadata).length
? watchMetadata
: undefined;
if (canUseWatchHistoryService) {
viewResult = await this.watchHistoryService.publishView(
thresholdPointer,
undefined,
metadataPayload,
);
} else if (typeof this.nostrClient?.recordVideoView === "function") {
viewResult = await this.nostrClient.recordVideoView(
thresholdPointer,
);
} else {
viewResult = { ok: false, error: "view-logging-unavailable" };
}
} catch (error) {
if (isDevMode) {
console.warn(
"[watchHistoryTelemetry] Exception while recording video view:",
error,
);
}
}
const viewOk = !!viewResult?.ok;
if (viewOk) {
const eventIdentityKey =
this.deriveViewIdentityKeyFromEvent(viewResult?.event) ||
state.viewerIdentityKey ||
this.getActiveViewIdentityKey();
const keyToPersist = this.buildViewCooldownKey(
thresholdPointerKey,
eventIdentityKey,
);
if (keyToPersist) {
this.loggedViewPointerKeys.add(keyToPersist);
}
if (viewResult?.event && this.ingestLocalViewEvent) {
try {
this.ingestLocalViewEvent({
event: viewResult.event,
pointer: thresholdPointer,
});
} catch (error) {
if (isDevMode) {
console.warn(
"[watchHistoryTelemetry] Failed to ingest local view event:",
error,
);
}
}
}
} else if (isDevMode && viewResult) {
console.warn(
"[watchHistoryTelemetry] View event rejected by relays:",
viewResult,
);
}
})().catch((error) => {
if (isDevMode) {
console.warn(
"[watchHistoryTelemetry] Unexpected error while recording video view:",
error,
);
}
});
};
const scheduleTimer = (timerKey, thresholdSeconds, callback) => {
const firedKey = `${timerKey}Fired`;
const idKey = `${timerKey}TimerId`;
if (state[firedKey] || state[idKey]) {
return;
}
const currentSeconds = Number.isFinite(videoElement.currentTime)
? videoElement.currentTime
: 0;
const remainingMs = Math.max(
0,
Math.ceil((thresholdSeconds - currentSeconds) * 1000),
);
if (remainingMs <= 0) {
callback();
return;
}
state[idKey] = this._getTimerHost().setTimeout(callback, remainingMs);
};
const registerHandler = (eventName, handler) => {
videoElement.addEventListener(eventName, handler);
state.handlers.push({ eventName, handler });
};
registerHandler("timeupdate", () => {
if (!state.viewFired && videoElement.currentTime >= this.viewThresholdSeconds) {
finalizeView();
}
});
const cancelOnPause = () => {
if (!state.viewFired) {
clearTimer("view");
}
};
["pause", "waiting", "stalled", "ended", "emptied"].forEach((event) =>
registerHandler(event, cancelOnPause),
);
const resumeIfNeeded = () => {
if (state.viewFired) {
return;
}
scheduleTimer("view", this.viewThresholdSeconds, finalizeView);
};
["play", "playing"].forEach((event) =>
registerHandler(event, resumeIfNeeded),
);
this.playbackTelemetryState = state;
if (!videoElement.paused && videoElement.currentTime > 0) {
resumeIfNeeded();
}
}
getActiveViewIdentityKey() {
const normalizedUser = this.normalizeHexPubkey(this.getActiveUserPubkey());
if (normalizedUser) {
return `actor:${normalizedUser}`;
}
const sessionActorPubkey = this.normalizeHexPubkey(
this.nostrClient?.sessionActor?.pubkey,
);
if (sessionActorPubkey) {
return `actor:${sessionActorPubkey}`;
}
return "actor:anonymous";
}
deriveViewIdentityKeyFromEvent(event) {
if (!event || typeof event !== "object") {
return "";
}
const normalizedPubkey = this.normalizeHexPubkey(event.pubkey);
if (!normalizedPubkey) {
return "";
}
return `actor:${normalizedPubkey}`;
}
buildViewCooldownKey(pointerKey, identityKey) {
const normalizedPointerKey = normalizeString(pointerKey);
if (!normalizedPointerKey) {
return "";
}
const normalizedIdentity = normalizeString(identityKey).toLowerCase();
return normalizedIdentity
? `${normalizedPointerKey}::${normalizedIdentity}`
: normalizedPointerKey;
}
_clearPreferenceSubscription() {
if (typeof this.watchHistoryPreferenceUnsubscribe === "function") {
try {
this.watchHistoryPreferenceUnsubscribe();
} catch (error) {
console.warn(
"[watchHistoryTelemetry] Failed to unsubscribe watch history preference:",
error,
);
}
}
this.watchHistoryPreferenceUnsubscribe = null;
}
_getTimerHost() {
if (typeof window !== "undefined" && window) {
return window;
}
return globalThis;
}
}