mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
601 lines
17 KiB
JavaScript
601 lines
17 KiB
JavaScript
import { isDevMode } from "../config.js";
|
|
import { pointerArrayToKey } from "../utils/pointer.js";
|
|
import { userLogger } from "../utils/logger.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) {
|
|
userLogger.warn(
|
|
"[watchHistoryTelemetry] Failed to clear cached metadata after toggle off:",
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
},
|
|
);
|
|
if (typeof unsubscribe === "function") {
|
|
this.watchHistoryPreferenceUnsubscribe = unsubscribe;
|
|
}
|
|
} catch (error) {
|
|
if (isDevMode) {
|
|
userLogger.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) {
|
|
userLogger.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) {
|
|
userLogger.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) {
|
|
userLogger.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) {
|
|
userLogger.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) {
|
|
userLogger.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) {
|
|
userLogger.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) {
|
|
userLogger.warn(
|
|
"[watchHistoryTelemetry] Failed to ingest local view event:",
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
} else if (isDevMode && viewResult) {
|
|
userLogger.warn(
|
|
"[watchHistoryTelemetry] View event rejected by relays:",
|
|
viewResult,
|
|
);
|
|
}
|
|
})().catch((error) => {
|
|
if (isDevMode) {
|
|
userLogger.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) {
|
|
userLogger.warn(
|
|
"[watchHistoryTelemetry] Failed to unsubscribe watch history preference:",
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
this.watchHistoryPreferenceUnsubscribe = null;
|
|
}
|
|
|
|
_getTimerHost() {
|
|
if (typeof window !== "undefined" && window) {
|
|
return window;
|
|
}
|
|
return globalThis;
|
|
}
|
|
}
|