From 0b0d3628ee2a52f5e06c75db6a0a95a3c7a16a3d Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 9 Oct 2025 10:31:09 -0400 Subject: [PATCH] Refactor watch history telemetry into helper --- js/app.js | 485 ++++----------- js/services/watchHistoryTelemetry.js | 561 ++++++++++++++++++ .../watch-history-telemetry.test.mjs | 207 +++++++ 3 files changed, 867 insertions(+), 386 deletions(-) create mode 100644 js/services/watchHistoryTelemetry.js create mode 100644 tests/watchHistory/watch-history-telemetry.test.mjs diff --git a/js/app.js b/js/app.js index 3575fbaf..bc52e369 100644 --- a/js/app.js +++ b/js/app.js @@ -38,6 +38,7 @@ import nostrService from "./services/nostrService.js"; import { initQuickR2Upload } from "./r2-quick.js"; import { createWatchHistoryRenderer } from "./historyView.js"; import WatchHistoryController from "./ui/watchHistoryController.js"; +import WatchHistoryTelemetry from "./services/watchHistoryTelemetry.js"; import { getSidebarLoadingMarkup } from "./sidebarLoading.js"; import { subscriptions } from "./subscriptions.js"; import { @@ -163,8 +164,7 @@ class Application { this.videoDiscussionCountCache = new Map(); this.inFlightDiscussionCounts = new Map(); this.activeIntervals = []; - this.watchHistoryMetadataEnabled = null; - this.watchHistoryPreferenceUnsubscribe = null; + this.watchHistoryTelemetry = null; this.authEventUnsubscribes = []; this.unsubscribeFromNostrService = null; this.videoModalReadyPromise = null; @@ -241,6 +241,16 @@ class Application { typeof this.pubkey === "string" && this.pubkey ? this.pubkey : "", }); + this.watchHistoryTelemetry = new WatchHistoryTelemetry({ + watchHistoryService, + watchHistoryController: this.watchHistoryController, + nostrClient, + log: (message, ...args) => this.log(message, ...args), + normalizeHexPubkey: (value) => this.normalizeHexPubkey(value), + getActiveUserPubkey: () => this.pubkey, + ingestLocalViewEvent, + }); + this.playbackService = services.playbackService || new PlaybackService({ @@ -625,8 +635,6 @@ class Application { this.currentVideo = null; this.currentVideoPointer = null; this.currentVideoPointerKey = null; - this.playbackTelemetryState = null; - this.loggedViewPointerKeys = new Set(); this.videoSubscription = this.nostrService.getVideoSubscription() || null; this.videoList = null; this.modalViewCountUnsub = null; @@ -1108,16 +1116,15 @@ class Application { // 5. Setup general event listeners this.setupEventListeners(); - const watchHistoryInitPromise = this.initWatchHistoryMetadataSync().catch( - (error) => { + const watchHistoryInitPromise = + this.watchHistoryTelemetry?.initPreferenceSync?.().catch((error) => { if (isDevMode) { console.warn( "[app.init()] Failed to initialize watch history metadata sync:", error ); } - } - ); + }) || Promise.resolve(); // 6) Load the default view ONLY if there's no #view= already if (!window.location.hash || !window.location.hash.startsWith("#view=")) { @@ -3040,146 +3047,20 @@ class Application { } cancelPendingViewLogging() { - const state = this.playbackTelemetryState; - if (!state) { - return; - } - - if (state.viewTimerId) { - clearTimeout(state.viewTimerId); - } - - if (Array.isArray(state.handlers) && state.videoEl) { - for (const { eventName, handler } of state.handlers) { - try { - state.videoEl.removeEventListener(eventName, handler); - } catch (err) { - if (isDevMode) { - console.warn( - `[cancelPendingViewLogging] Failed to detach ${eventName} listener:`, - err - ); - } - } - } - } - - this.playbackTelemetryState = null; - - this.flushWatchHistory("session-end", "cancelPendingViewLogging").catch( - (error) => { - const message = - error && typeof error.message === "string" - ? error.message - : String(error ?? "unknown error"); - this.log( - `[cancelPendingViewLogging] Watch history flush failed: ${message}` - ); - } - ); + this.watchHistoryTelemetry?.cancelPlaybackLogging?.(); } resetViewLoggingState() { - this.cancelPendingViewLogging(); - if (this.loggedViewPointerKeys.size > 0) { - this.loggedViewPointerKeys.clear(); - } - } - - refreshWatchHistoryMetadataSettings() { - if (!watchHistoryService?.isEnabled?.()) { - this.watchHistoryMetadataEnabled = false; - return this.watchHistoryMetadataEnabled; - } - - const previous = this.watchHistoryMetadataEnabled; - let enabled = true; - - try { - if (typeof watchHistoryService.getSettings === "function") { - const settings = watchHistoryService.getSettings(); - enabled = settings?.metadata?.storeLocally !== false; - } else if (typeof watchHistoryService.shouldStoreMetadata === "function") { - enabled = watchHistoryService.shouldStoreMetadata() !== false; - } - } catch (error) { - if (isDevMode) { - console.warn( - "[watchHistory] Failed to read metadata settings:", - error, - ); - } - enabled = true; - } - - this.watchHistoryMetadataEnabled = enabled; - - if (enabled === false && previous !== false) { - try { - watchHistoryService?.clearLocalMetadata?.(); - } catch (error) { - if (isDevMode) { - console.warn( - "[watchHistory] Failed to purge metadata cache while preference disabled:", - error, - ); - } - } - } - - return this.watchHistoryMetadataEnabled; - } - - async initWatchHistoryMetadataSync() { - if (!watchHistoryService?.isEnabled?.()) { - this.watchHistoryMetadataEnabled = false; - return; - } - - this.refreshWatchHistoryMetadataSettings(); - - if ( - typeof watchHistoryService.subscribe === "function" && - !this.watchHistoryPreferenceUnsubscribe - ) { - try { - const unsubscribe = watchHistoryService.subscribe( - "metadata-preference", - (payload) => { - const previous = this.watchHistoryMetadataEnabled; - const enabled = payload?.enabled !== false; - this.watchHistoryMetadataEnabled = enabled; - if (previous === true && enabled === false) { - try { - watchHistoryService?.clearLocalMetadata?.(); - } catch (error) { - if (isDevMode) { - console.warn( - "[watchHistory] Failed to clear cached metadata after toggle off:", - error, - ); - } - } - } - }, - ); - if (typeof unsubscribe === "function") { - this.watchHistoryPreferenceUnsubscribe = unsubscribe; - } - } catch (error) { - if (isDevMode) { - console.warn( - "[watchHistory] Failed to subscribe to metadata preference changes:", - error, - ); - } - } - } + this.watchHistoryTelemetry?.resetPlaybackLoggingState?.(); } persistWatchHistoryMetadataForVideo(video, pointerInfo) { + if (this.watchHistoryTelemetry) { + this.watchHistoryTelemetry.persistMetadataForVideo(video, pointerInfo); + return; + } + if ( - !this.watchHistoryMetadataEnabled || !pointerInfo || !pointerInfo.key || typeof watchHistoryService?.setLocalMetadata !== "function" @@ -3214,6 +3095,11 @@ class Application { } dropWatchHistoryMetadata(pointerKey) { + if (this.watchHistoryTelemetry) { + this.watchHistoryTelemetry.dropMetadata(pointerKey); + return; + } + if (!pointerKey || typeof pointerKey !== "string") { return; } @@ -3254,277 +3140,104 @@ class Application { } async handleWatchHistoryRemoval(payload = {}) { - if (!this.watchHistoryController) { + if (!this.watchHistoryTelemetry) { this.showError("Watch history sync is not available right now."); const error = new Error("watch-history-disabled"); error.handled = true; throw error; } - return this.watchHistoryController.handleWatchHistoryRemoval(payload); + return this.watchHistoryTelemetry.handleRemoval(payload); } flushWatchHistory(reason = "session-end", context = "watch-history") { - if (!this.watchHistoryController) { + if (!this.watchHistoryTelemetry) { return Promise.resolve(); } - return this.watchHistoryController.flush(reason, context); + return this.watchHistoryTelemetry.flush(reason, context); } getActiveViewIdentityKey() { - const normalizedUser = this.normalizeHexPubkey(this.pubkey); - if (normalizedUser) { - return `actor:${normalizedUser}`; + if (!this.watchHistoryTelemetry) { + const normalizedUser = this.normalizeHexPubkey(this.pubkey); + if (normalizedUser) { + return `actor:${normalizedUser}`; + } + + const sessionActorPubkey = this.normalizeHexPubkey( + nostrClient?.sessionActor?.pubkey + ); + if (sessionActorPubkey) { + return `actor:${sessionActorPubkey}`; + } + + return "actor:anonymous"; } - const sessionActorPubkey = this.normalizeHexPubkey( - nostrClient?.sessionActor?.pubkey - ); - if (sessionActorPubkey) { - return `actor:${sessionActorPubkey}`; - } - - return "actor:anonymous"; + return this.watchHistoryTelemetry.getActiveViewIdentityKey(); } deriveViewIdentityKeyFromEvent(event) { - if (!event || typeof event !== "object") { - return ""; + if (!this.watchHistoryTelemetry) { + if (!event || typeof event !== "object") { + return ""; + } + + const normalizedPubkey = this.normalizeHexPubkey(event.pubkey); + if (!normalizedPubkey) { + return ""; + } + + return `actor:${normalizedPubkey}`; } - const normalizedPubkey = this.normalizeHexPubkey(event.pubkey); - if (!normalizedPubkey) { - return ""; - } - - return `actor:${normalizedPubkey}`; + return this.watchHistoryTelemetry.deriveViewIdentityKeyFromEvent(event); } buildViewCooldownKey(pointerKey, identityKey) { - const normalizedPointerKey = - typeof pointerKey === "string" && pointerKey.trim() - ? pointerKey.trim() - : ""; - if (!normalizedPointerKey) { - return ""; + if (!this.watchHistoryTelemetry) { + const normalizedPointerKey = + typeof pointerKey === "string" && pointerKey.trim() + ? pointerKey.trim() + : ""; + if (!normalizedPointerKey) { + return ""; + } + + const normalizedIdentity = + typeof identityKey === "string" && identityKey.trim() + ? identityKey.trim().toLowerCase() + : ""; + + return normalizedIdentity + ? `${normalizedPointerKey}::${normalizedIdentity}` + : normalizedPointerKey; } - const normalizedIdentity = - typeof identityKey === "string" && identityKey.trim() - ? identityKey.trim().toLowerCase() - : ""; - - return normalizedIdentity - ? `${normalizedPointerKey}::${normalizedIdentity}` - : normalizedPointerKey; + return this.watchHistoryTelemetry.buildViewCooldownKey( + pointerKey, + identityKey + ); } - preparePlaybackViewLogging(videoEl) { - this.cancelPendingViewLogging(); - - if (!videoEl || typeof videoEl.addEventListener !== "function") { + preparePlaybackLogging(videoEl) { + if (!this.watchHistoryTelemetry) { + this.cancelPendingViewLogging(); return; } const pointer = this.currentVideoPointer; const pointerKey = this.currentVideoPointerKey || pointerArrayToKey(pointer); + if (!pointer || !pointerKey) { + this.watchHistoryTelemetry.cancelPlaybackLogging(); return; } - const viewerIdentityKey = this.getActiveViewIdentityKey(); - const cooldownKey = this.buildViewCooldownKey(pointerKey, viewerIdentityKey); - - if (cooldownKey && this.loggedViewPointerKeys.has(cooldownKey)) { - return; - } - - const VIEW_THRESHOLD_SECONDS = 12; - const state = { - videoEl, + this.watchHistoryTelemetry.preparePlaybackLogging({ + videoElement: videoEl, pointer, pointerKey, - viewerIdentityKey, - handlers: [], - viewTimerId: null, - viewFired: false, - }; - - const clearTimer = (timerKey) => { - const property = `${timerKey}TimerId`; - if (state[property]) { - clearTimeout(state[property]); - state[property] = null; - } - }; - - const finalizeView = () => { - if (state.viewFired) { - return; - } - state.viewFired = true; - clearTimer("view"); - this.cancelPendingViewLogging(); - - const { pointer: thresholdPointer, pointerKey: thresholdPointerKey } = - state; - - (async () => { - let viewResult; - try { - const canUseWatchHistoryService = - typeof watchHistoryService?.publishView === "function"; - const resolveWatchActor = () => { - const normalizedUser = this.normalizeHexPubkey(this.pubkey); - if (normalizedUser) { - return normalizedUser; - } - - const normalizedClient = this.normalizeHexPubkey( - nostrClient?.pubkey - ); - if (normalizedClient) { - return normalizedClient; - } - - const normalizedSession = this.normalizeHexPubkey( - nostrClient?.sessionActor?.pubkey - ); - if (normalizedSession) { - return normalizedSession; - } - - if ( - typeof nostrClient?.pubkey === "string" && - nostrClient.pubkey.trim() - ) { - return nostrClient.pubkey.trim().toLowerCase(); - } - - if ( - typeof nostrClient?.sessionActor?.pubkey === "string" && - nostrClient.sessionActor.pubkey.trim() - ) { - return nostrClient.sessionActor.pubkey.trim().toLowerCase(); - } - - return ""; - }; - - const activeWatchActor = resolveWatchActor(); - const watchMetadata = activeWatchActor ? { actor: activeWatchActor } : undefined; - - if (canUseWatchHistoryService) { - viewResult = await watchHistoryService.publishView( - thresholdPointer, - undefined, - watchMetadata - ); - } else if (typeof nostrClient?.recordVideoView === "function") { - viewResult = await nostrClient.recordVideoView(thresholdPointer); - } else { - viewResult = { ok: false, error: "view-logging-unavailable" }; - } - } catch (error) { - if (isDevMode) { - console.warn( - "[playVideoWithFallback] 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) { - ingestLocalViewEvent({ - event: viewResult.event, - pointer: thresholdPointer, - }); - } - } else if (isDevMode && viewResult) { - console.warn( - "[playVideoWithFallback] View event rejected by relays:", - viewResult - ); - } - })().catch((error) => { - if (isDevMode) { - console.warn( - "[playVideoWithFallback] 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(videoEl.currentTime) - ? videoEl.currentTime - : 0; - const remainingMs = Math.max( - 0, - Math.ceil((thresholdSeconds - currentSeconds) * 1000) - ); - if (remainingMs <= 0) { - callback(); - return; - } - state[idKey] = window.setTimeout(callback, remainingMs); - }; - - const registerHandler = (eventName, handler) => { - videoEl.addEventListener(eventName, handler); - state.handlers.push({ eventName, handler }); - }; - - registerHandler("timeupdate", () => { - if (!state.viewFired && videoEl.currentTime >= VIEW_THRESHOLD_SECONDS) { - 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", VIEW_THRESHOLD_SECONDS, finalizeView); - }; - - ["play", "playing"].forEach((event) => - registerHandler(event, resumeIfNeeded) - ); - - this.playbackTelemetryState = state; - - if (!videoEl.paused && videoEl.currentTime > 0) { - resumeIfNeeded(); - } } teardownVideoElement(videoElement, { replaceNode = false } = {}) { @@ -6323,7 +6036,7 @@ class Application { subscribe("view-logging-request", ({ videoElement } = {}) => { if (videoElement) { - this.preparePlaybackViewLogging(videoElement); + this.preparePlaybackLogging(videoElement); } }); @@ -7060,6 +6773,18 @@ class Application { this.teardownModalViewCountSubscription(); this.videoModalReadyPromise = null; + if (this.watchHistoryTelemetry) { + try { + this.watchHistoryTelemetry.destroy(); + } catch (error) { + console.warn( + "[Application] Failed to destroy watch history telemetry:", + error + ); + } + this.watchHistoryTelemetry = null; + } + if (typeof this.unsubscribeFromPubkeyState === "function") { try { this.unsubscribeFromPubkeyState(); @@ -7100,18 +6825,6 @@ class Application { this.nwcSettingsService.clearCache(); } - if (typeof this.watchHistoryPreferenceUnsubscribe === "function") { - try { - this.watchHistoryPreferenceUnsubscribe(); - } catch (error) { - console.warn( - "[Application] Failed to unsubscribe watch history preference:", - error - ); - } - this.watchHistoryPreferenceUnsubscribe = null; - } - this.authEventUnsubscribes.forEach((unsubscribe) => { if (typeof unsubscribe === "function") { try { diff --git a/js/services/watchHistoryTelemetry.js b/js/services/watchHistoryTelemetry.js new file mode 100644 index 00000000..11d3cbb7 --- /dev/null +++ b/js/services/watchHistoryTelemetry.js @@ -0,0 +1,561 @@ +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() : ""; +} + +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: { + 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 : "", + }, + }; + + 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, + } = {}) { + 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, + }; + + 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 = activeWatchActor + ? { actor: activeWatchActor } + : undefined; + + if (canUseWatchHistoryService) { + viewResult = await this.watchHistoryService.publishView( + thresholdPointer, + undefined, + watchMetadata, + ); + } 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; + } +} diff --git a/tests/watchHistory/watch-history-telemetry.test.mjs b/tests/watchHistory/watch-history-telemetry.test.mjs new file mode 100644 index 00000000..dd1a8008 --- /dev/null +++ b/tests/watchHistory/watch-history-telemetry.test.mjs @@ -0,0 +1,207 @@ +import "../test-helpers/setup-localstorage.mjs"; +import assert from "node:assert/strict"; + +import WatchHistoryTelemetry from "../../js/services/watchHistoryTelemetry.js"; + +const hexPubkey = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"; + +function createTelemetry({ + serviceOverrides = {}, + controllerOverrides = {}, + options = {}, +} = {}) { + const watchHistoryService = { + isEnabled: () => true, + getSettings: () => ({ metadata: { storeLocally: true } }), + setLocalMetadata: () => {}, + removeLocalMetadata: () => {}, + clearLocalMetadata: () => {}, + publishView: async () => ({ ok: true }), + subscribe: () => () => {}, + ...serviceOverrides, + }; + + const watchHistoryController = { + handleWatchHistoryRemoval: async () => ({}), + flush: async () => {}, + ...controllerOverrides, + }; + + const telemetry = new WatchHistoryTelemetry({ + watchHistoryService, + watchHistoryController, + nostrClient: options.nostrClient || { pubkey: hexPubkey }, + log: options.log || (() => {}), + normalizeHexPubkey: + options.normalizeHexPubkey || ((value) => (typeof value === "string" ? value.toLowerCase() : null)), + getActiveUserPubkey: options.getActiveUserPubkey || (() => hexPubkey), + ingestLocalViewEvent: options.ingestLocalViewEvent || (() => {}), + viewThresholdSeconds: options.viewThresholdSeconds, + }); + + return { telemetry, watchHistoryService, watchHistoryController }; +} + +class FakeVideoElement { + constructor() { + this.listeners = new Map(); + this.paused = true; + this.currentTime = 0; + } + + addEventListener(eventName, handler) { + if (!this.listeners.has(eventName)) { + this.listeners.set(eventName, new Set()); + } + this.listeners.get(eventName).add(handler); + } + + removeEventListener(eventName, handler) { + const set = this.listeners.get(eventName); + if (!set) { + return; + } + set.delete(handler); + if (set.size === 0) { + this.listeners.delete(eventName); + } + } + + emit(eventName) { + const set = this.listeners.get(eventName); + if (!set) { + return; + } + for (const handler of set) { + handler({ type: eventName }); + } + } +} + +async function flushAsyncOperations() { + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +async function testMetadataPreferenceSync() { + let subscribeTopic = null; + let subscribeHandler = null; + let cleared = false; + + const { telemetry } = createTelemetry({ + serviceOverrides: { + subscribe: (topic, handler) => { + subscribeTopic = topic; + subscribeHandler = handler; + return () => {}; + }, + clearLocalMetadata: () => { + cleared = true; + }, + getSettings: () => ({ metadata: { storeLocally: true } }), + }, + options: { + getActiveUserPubkey: () => null, + }, + }); + + const unsubscribe = await telemetry.initPreferenceSync(); + assert.equal(subscribeTopic, "metadata-preference"); + assert.equal(typeof unsubscribe, "function"); + assert.equal(telemetry.isMetadataPreferenceEnabled(), true); + + subscribeHandler({ enabled: false }); + assert.equal(telemetry.isMetadataPreferenceEnabled(), false); + assert.equal(cleared, true); + + subscribeHandler({ enabled: true }); + assert.equal(telemetry.isMetadataPreferenceEnabled(), true); + +} + +async function testPreparePlaybackLoggingPublishesOnce() { + const ingestEvents = []; + const publishCalls = []; + + const pointer = { type: "e", value: "event-id" }; + const pointerKey = "event-id::relay"; + + const { telemetry } = createTelemetry({ + serviceOverrides: { + publishView: async (ptr, _relay, metadata) => { + publishCalls.push({ ptr, metadata }); + return { + ok: true, + event: { pubkey: hexPubkey }, + }; + }, + }, + options: { + ingestLocalViewEvent: (payload) => ingestEvents.push(payload), + }, + }); + + const videoA = new FakeVideoElement(); + videoA.paused = false; + videoA.currentTime = telemetry.viewThresholdSeconds; + + telemetry.preparePlaybackLogging({ + videoElement: videoA, + pointer, + pointerKey, + }); + await flushAsyncOperations(); + + assert.equal(publishCalls.length, 1, "publishView should be called once"); + assert.equal(ingestEvents.length, 1, "ingestLocalViewEvent should run once"); + assert.deepEqual(publishCalls[0].ptr, pointer); + assert.deepEqual(ingestEvents[0].pointer, pointer); + + const videoB = new FakeVideoElement(); + videoB.paused = false; + videoB.currentTime = telemetry.viewThresholdSeconds; + + telemetry.preparePlaybackLogging({ + videoElement: videoB, + pointer, + pointerKey, + }); + await flushAsyncOperations(); + + assert.equal( + publishCalls.length, + 1, + "Repeated preparePlaybackLogging should reuse cooldown cache" + ); +} + +async function testCancelPlaybackLoggingFlushes() { + let flushArgs = null; + const video = new FakeVideoElement(); + const { telemetry } = createTelemetry({ + controllerOverrides: { + flush: async (reason, context) => { + flushArgs = { reason, context }; + }, + }, + }); + + video.paused = true; + video.currentTime = 0; + + telemetry.preparePlaybackLogging({ + videoElement: video, + pointer: { type: "e", value: "event" }, + pointerKey: "event", + }); + + telemetry.cancelPlaybackLogging({ reason: "unit", context: "test" }); + await flushAsyncOperations(); + + assert.deepEqual(flushArgs, { reason: "unit", context: "test" }); +} + +await testMetadataPreferenceSync(); +await testPreparePlaybackLoggingPublishesOnce(); +await testCancelPlaybackLoggingFlushes(); + +console.log("watch-history-telemetry tests passed");