mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-31 09:52:24 +00:00
Refactor watch history telemetry into helper
This commit is contained in:
1 parent
0cf50ee7e9
commit
0b0d3628ee
3 files changed
+867
-386
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
Reference in new issue
Block a user