From 97a8ff814efe39d4f3274630794a483e326c0275 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 2 Oct 2025 13:58:42 -0400 Subject: [PATCH] feat: add watch history service and integrate playback flush --- js/app.js | 66 +++- js/constants.js | 28 ++ js/nostr.js | 6 + js/watchHistoryService.js | 776 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 874 insertions(+), 2 deletions(-) create mode 100644 js/watchHistoryService.js diff --git a/js/app.js b/js/app.js index cdc147a9..1026ad4d 100644 --- a/js/app.js +++ b/js/app.js @@ -1,7 +1,7 @@ // js/app.js import { loadView } from "./viewManager.js"; -import { nostrClient, recordVideoView } from "./nostr.js"; +import { nostrClient } from "./nostr.js"; import { torrentClient } from "./webtorrent.js"; import { isDevMode, ADMIN_SUPER_NPUB } from "./config.js"; import { accessControl, normalizeNpub } from "./accessControl.js"; @@ -15,6 +15,7 @@ import { attachUrlHealthBadges } from "./urlHealthObserver.js"; import { ADMIN_INITIAL_EVENT_BLACKLIST } from "./lists.js"; import { userBlocks } from "./userBlocks.js"; import { relayManager } from "./relayManager.js"; +import watchHistoryService from "./watchHistoryService.js"; import { loadR2Settings, saveR2Settings, @@ -5968,11 +5969,31 @@ class bitvidApp { // 7) Cleanup on page unload window.addEventListener("beforeunload", () => { + this.flushWatchHistory("session-end", "beforeunload").catch((error) => { + if (isDevMode) { + console.warn("[beforeunload] Watch history flush failed:", error); + } + }); this.cleanup().catch((err) => { console.error("Cleanup before unload failed:", err); }); }); + document.addEventListener("visibilitychange", () => { + if (document.visibilityState === "hidden") { + this.flushWatchHistory("session-end", "visibilitychange").catch( + (error) => { + if (isDevMode) { + console.warn( + "[visibilitychange] Watch history flush failed:", + error + ); + } + } + ); + } + }); + // 8) Handle back/forward navigation => hide video modal window.addEventListener("popstate", async () => { console.log("[popstate] user navigated back/forward; cleaning modal..."); @@ -6846,6 +6867,15 @@ class bitvidApp { ); try { this.cancelPendingViewLogging(); + await this.flushWatchHistory("session-end", "cleanup").catch( + (error) => { + const message = + error && typeof error.message === "string" + ? error.message + : String(error ?? "unknown error"); + this.log(`[cleanup] Watch history flush failed: ${message}`); + } + ); this.clearActiveIntervals(); this.cleanupUrlPlaybackWatchdog(); this.teardownModalViewCountSubscription(); @@ -6976,6 +7006,18 @@ class bitvidApp { } 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}` + ); + } + ); } resetViewLoggingState() { @@ -6985,6 +7027,26 @@ class bitvidApp { } } + flushWatchHistory(reason = "session-end", context = "watch-history") { + if (!watchHistoryService?.isEnabled?.()) { + return Promise.resolve(); + } + try { + const result = watchHistoryService.snapshot(undefined, { reason }); + return Promise.resolve(result).catch((error) => { + if (isDevMode) { + console.warn(`[${context}] Watch history flush failed:`, error); + } + throw error; + }); + } catch (error) { + if (isDevMode) { + console.warn(`[${context}] Failed to queue watch history flush:`, error); + } + return Promise.reject(error); + } + } + getActiveViewIdentityKey() { const normalizedUser = this.normalizeHexPubkey(this.pubkey); if (normalizedUser) { @@ -7086,7 +7148,7 @@ class bitvidApp { (async () => { let viewResult; try { - viewResult = await recordVideoView(thresholdPointer); + viewResult = await watchHistoryService.publishView(thresholdPointer); } catch (error) { if (isDevMode) { console.warn( diff --git a/js/constants.js b/js/constants.js index 19780950..831d3e3d 100644 --- a/js/constants.js +++ b/js/constants.js @@ -2,6 +2,7 @@ const DEFAULT_FLAGS = Object.freeze({ URL_FIRST_ENABLED: true, // try URL before magnet in the player ACCEPT_LEGACY_V1: true, // accept v1 magnet-only notes VIEW_FILTER_INCLUDE_LEGACY_VIDEO: false, + FEATURE_WATCH_HISTORY_V2: false, WSS_TRACKERS: Object.freeze([ "wss://tracker.openwebtorrent.com", "wss://tracker.fastcast.nz", @@ -21,6 +22,7 @@ const runtimeFlags = (() => { ACCEPT_LEGACY_V1: DEFAULT_FLAGS.ACCEPT_LEGACY_V1, VIEW_FILTER_INCLUDE_LEGACY_VIDEO: DEFAULT_FLAGS.VIEW_FILTER_INCLUDE_LEGACY_VIDEO, + FEATURE_WATCH_HISTORY_V2: DEFAULT_FLAGS.FEATURE_WATCH_HISTORY_V2, WSS_TRACKERS: [...DEFAULT_FLAGS.WSS_TRACKERS], }; if (globalScope) { @@ -101,6 +103,11 @@ export let VIEW_FILTER_INCLUDE_LEGACY_VIDEO = coerceBoolean( DEFAULT_FLAGS.VIEW_FILTER_INCLUDE_LEGACY_VIDEO ); +export let FEATURE_WATCH_HISTORY_V2 = coerceBoolean( + runtimeFlags.FEATURE_WATCH_HISTORY_V2, + DEFAULT_FLAGS.FEATURE_WATCH_HISTORY_V2 +); + export let WSS_TRACKERS = freezeTrackers( sanitizeTrackerList(runtimeFlags.WSS_TRACKERS) ); @@ -141,6 +148,20 @@ Object.defineProperty(runtimeFlags, "VIEW_FILTER_INCLUDE_LEGACY_VIDEO", { }, }); +Object.defineProperty(runtimeFlags, "FEATURE_WATCH_HISTORY_V2", { + configurable: true, + enumerable: true, + get() { + return FEATURE_WATCH_HISTORY_V2; + }, + set(next) { + FEATURE_WATCH_HISTORY_V2 = coerceBoolean( + next, + DEFAULT_FLAGS.FEATURE_WATCH_HISTORY_V2 + ); + }, +}); + Object.defineProperty(runtimeFlags, "WSS_TRACKERS", { configurable: true, enumerable: true, @@ -156,6 +177,7 @@ Object.defineProperty(runtimeFlags, "WSS_TRACKERS", { runtimeFlags.URL_FIRST_ENABLED = URL_FIRST_ENABLED; runtimeFlags.ACCEPT_LEGACY_V1 = ACCEPT_LEGACY_V1; runtimeFlags.VIEW_FILTER_INCLUDE_LEGACY_VIDEO = VIEW_FILTER_INCLUDE_LEGACY_VIDEO; +runtimeFlags.FEATURE_WATCH_HISTORY_V2 = FEATURE_WATCH_HISTORY_V2; runtimeFlags.WSS_TRACKERS = WSS_TRACKERS; export function setUrlFirstEnabled(next) { @@ -184,8 +206,14 @@ export function resetRuntimeFlags() { setViewFilterIncludeLegacyVideo( DEFAULT_FLAGS.VIEW_FILTER_INCLUDE_LEGACY_VIDEO ); + setWatchHistoryV2Enabled(DEFAULT_FLAGS.FEATURE_WATCH_HISTORY_V2); setWssTrackers(DEFAULT_FLAGS.WSS_TRACKERS); } export const RUNTIME_FLAGS = runtimeFlags; +export function setWatchHistoryV2Enabled(next) { + runtimeFlags.FEATURE_WATCH_HISTORY_V2 = next; + return FEATURE_WATCH_HISTORY_V2; +} + diff --git a/js/nostr.js b/js/nostr.js index c211b5a8..0d46db7e 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -265,6 +265,10 @@ function clonePointerItem(pointer) { cloned.watchedAt = watchedAt; } + if (pointer.session === true) { + cloned.session = true; + } + return cloned; } @@ -5312,3 +5316,5 @@ export const countVideoViewEvents = (...args) => { } return nostrClient.countVideoViewEvents(...args); }; + +export { normalizePointerInput, pointerKey, chunkWatchHistoryPayloadItems, normalizeActorKey }; diff --git a/js/watchHistoryService.js b/js/watchHistoryService.js new file mode 100644 index 00000000..6b9605de --- /dev/null +++ b/js/watchHistoryService.js @@ -0,0 +1,776 @@ +// js/watchHistoryService.js + +import { + nostrClient, + normalizePointerInput, + pointerKey, + normalizeActorKey, +} from "./nostr.js"; +import { + WATCH_HISTORY_CACHE_TTL_MS, + WATCH_HISTORY_MAX_ITEMS, +} from "./config.js"; +import { FEATURE_WATCH_HISTORY_V2 } from "./constants.js"; +import { isDevMode } from "./config.js"; + +const SESSION_STORAGE_KEY = "bitvid:watch-history:session:v1"; +const SESSION_STORAGE_VERSION = 1; +const POINTER_THROTTLE_MS = 60 * 1000; + +const state = { + restored: false, + queues: new Map(), + inflightSnapshots: new Map(), + fingerprintCache: new Map(), + listeners: new Map(), +}; + +function isFeatureEnabled() { + return FEATURE_WATCH_HISTORY_V2 === true; +} + +function getSessionStorage() { + try { + if (typeof window !== "undefined" && window.sessionStorage) { + return window.sessionStorage; + } + } catch (error) { + if (isDevMode) { + console.warn("[watchHistoryService] sessionStorage unavailable:", error); + } + } + return null; +} + +function emit(eventName, payload) { + const listeners = state.listeners.get(eventName); + if (listeners && listeners.size) { + for (const callback of Array.from(listeners)) { + try { + callback(payload); + } catch (error) { + if (isDevMode) { + console.warn( + `[watchHistoryService] Listener for ${eventName} failed:`, + error + ); + } + } + } + } + + const wildcard = state.listeners.get("*"); + if (wildcard && wildcard.size) { + for (const callback of Array.from(wildcard)) { + try { + callback({ event: eventName, payload }); + } catch (error) { + if (isDevMode) { + console.warn( + `[watchHistoryService] Wildcard listener error for ${eventName}:`, + error + ); + } + } + } + } + + if (typeof window !== "undefined" && typeof window.dispatchEvent === "function") { + try { + const detail = { event: eventName, payload }; + const evt = new CustomEvent("bitvid:watchHistory", { detail }); + window.dispatchEvent(evt); + } catch (error) { + if (isDevMode) { + console.warn( + `[watchHistoryService] Failed to dispatch window event for ${eventName}:`, + error + ); + } + } + } +} + +function subscribe(eventName, callback) { + if (typeof callback !== "function") { + return () => {}; + } + if (!state.listeners.has(eventName)) { + state.listeners.set(eventName, new Set()); + } + const listeners = state.listeners.get(eventName); + listeners.add(callback); + return () => { + listeners.delete(callback); + if (!listeners.size) { + state.listeners.delete(eventName); + } + }; +} + +function getOrCreateQueue(actorKey) { + let queue = state.queues.get(actorKey); + if (!queue) { + queue = { + items: new Map(), + throttle: new Map(), + pendingSnapshotId: null, + lastSnapshotReason: null, + republishScheduled: false, + }; + state.queues.set(actorKey, queue); + } + return queue; +} + +function restoreQueueState() { + if (state.restored) { + return; + } + state.restored = true; + + const storage = getSessionStorage(); + if (!storage) { + return; + } + + let raw = null; + try { + raw = storage.getItem(SESSION_STORAGE_KEY); + } catch (error) { + if (isDevMode) { + console.warn("[watchHistoryService] Failed to read session cache:", error); + } + return; + } + + if (!raw || typeof raw !== "string") { + return; + } + + let parsed = null; + try { + parsed = JSON.parse(raw); + } catch (error) { + if (isDevMode) { + console.warn("[watchHistoryService] Failed to parse session cache:", error); + } + try { + storage.removeItem(SESSION_STORAGE_KEY); + } catch (cleanupError) { + if (isDevMode) { + console.warn( + "[watchHistoryService] Failed to clear corrupt session cache:", + cleanupError + ); + } + } + return; + } + + if (!parsed || parsed.version !== SESSION_STORAGE_VERSION) { + try { + storage.removeItem(SESSION_STORAGE_KEY); + } catch (cleanupError) { + if (isDevMode) { + console.warn( + "[watchHistoryService] Failed to clear outdated session cache:", + cleanupError + ); + } + } + return; + } + + const actors = parsed.actors; + if (!actors || typeof actors !== "object") { + return; + } + + const now = Date.now(); + for (const [actor, entry] of Object.entries(actors)) { + const actorKey = normalizeActorKey(actor); + if (!actorKey) { + continue; + } + const queue = getOrCreateQueue(actorKey); + queue.items.clear(); + + const items = Array.isArray(entry?.items) ? entry.items : []; + for (const candidate of items) { + const pointer = normalizePointerInput(candidate); + if (!pointer) { + continue; + } + const key = pointerKey(pointer); + if (!key) { + continue; + } + queue.items.set(key, { + pointer, + addedAt: now, + updatedAt: now, + }); + } + + if (typeof entry?.pendingSnapshotId === "string" && entry.pendingSnapshotId) { + queue.pendingSnapshotId = entry.pendingSnapshotId; + } else { + queue.pendingSnapshotId = null; + } + if ( + typeof entry?.lastSnapshotReason === "string" && + entry.lastSnapshotReason + ) { + queue.lastSnapshotReason = entry.lastSnapshotReason; + } else { + queue.lastSnapshotReason = null; + } + + if (queue.pendingSnapshotId && isFeatureEnabled()) { + scheduleRepublishForQueue(actorKey); + } + } +} + +function ensureQueue(actorKey) { + if (!actorKey) { + return null; + } + if (!state.restored) { + restoreQueueState(); + } + return getOrCreateQueue(actorKey); +} + +function persistQueueState() { + const storage = getSessionStorage(); + if (!storage) { + return; + } + + const payload = { version: SESSION_STORAGE_VERSION, actors: {} }; + let hasEntries = false; + + for (const [actorKey, queue] of state.queues.entries()) { + const items = collectQueueItems(actorKey); + if (!items.length && !queue.pendingSnapshotId) { + continue; + } + payload.actors[actorKey] = { + items, + pendingSnapshotId: queue.pendingSnapshotId, + lastSnapshotReason: queue.lastSnapshotReason, + }; + hasEntries = true; + } + + if (!hasEntries) { + try { + storage.removeItem(SESSION_STORAGE_KEY); + } catch (error) { + if (isDevMode) { + console.warn( + "[watchHistoryService] Failed to clear empty session cache:", + error + ); + } + } + return; + } + + try { + storage.setItem(SESSION_STORAGE_KEY, JSON.stringify(payload)); + } catch (error) { + if (isDevMode) { + console.warn("[watchHistoryService] Failed to persist session cache:", error); + } + } +} + +function resolveActorKey(actorInput) { + const supplied = + typeof actorInput === "string" && actorInput.trim() + ? normalizeActorKey(actorInput) + : ""; + if (supplied) { + return supplied; + } + const logged = normalizeActorKey(nostrClient?.pubkey); + if (logged) { + return logged; + } + const session = normalizeActorKey(nostrClient?.sessionActor?.pubkey); + if (session) { + return session; + } + return ""; +} + +function collectQueueItems(actorKey) { + const queue = state.queues.get(actorKey); + if (!queue) { + return []; + } + const items = []; + for (const { pointer } of queue.items.values()) { + const normalized = normalizePointerInput(pointer); + if (normalized) { + items.push(normalized); + } + } + items.sort((a, b) => { + const watchedA = Number.isFinite(a?.watchedAt) ? a.watchedAt : 0; + const watchedB = Number.isFinite(b?.watchedAt) ? b.watchedAt : 0; + if (watchedA !== watchedB) { + return watchedB - watchedA; + } + const keyA = pointerKey(a); + const keyB = pointerKey(b); + return keyA.localeCompare(keyB); + }); + return items.slice(0, WATCH_HISTORY_MAX_ITEMS); +} + +function notifyQueueChange(actorKey) { + emit("queue-changed", { + actor: actorKey, + items: collectQueueItems(actorKey), + }); +} + +function clearQueue(actorKey) { + const queue = state.queues.get(actorKey); + if (!queue) { + return; + } + queue.items.clear(); + queue.throttle.clear(); + queue.pendingSnapshotId = null; + queue.republishScheduled = false; + persistQueueState(); + notifyQueueChange(actorKey); +} + +function resolveWatchedAt(...candidates) { + for (const candidate of candidates) { + if (!Number.isFinite(candidate)) { + continue; + } + let value = Math.floor(Number(candidate)); + if (value <= 0) { + continue; + } + if (value > 10_000_000_000) { + value = Math.floor(value / 1000); + } + if (value > 0) { + return value; + } + } + return Math.floor(Date.now() / 1000); +} + +function scheduleRepublishForQueue(actorKey) { + const queue = state.queues.get(actorKey); + if (!queue || !queue.pendingSnapshotId || queue.republishScheduled) { + return; + } + const snapshotId = queue.pendingSnapshotId; + queue.republishScheduled = true; + nostrClient.scheduleWatchHistoryRepublish(snapshotId, async (attempt) => { + const latestItems = collectQueueItems(actorKey); + if (!latestItems.length) { + queue.pendingSnapshotId = null; + queue.republishScheduled = false; + persistQueueState(); + return { + ok: true, + skipped: true, + actor: actorKey, + snapshotId, + }; + } + const publishResult = await nostrClient.publishWatchHistorySnapshot( + latestItems, + { + actorPubkey: actorKey, + snapshotId, + attempt, + source: `${queue.lastSnapshotReason || "session"}-retry`, + } + ); + if (publishResult?.ok) { + queue.pendingSnapshotId = null; + queue.republishScheduled = false; + await updateFingerprintCache(actorKey, publishResult.items || latestItems); + persistQueueState(); + notifyQueueChange(actorKey); + } else if (!publishResult?.retryable) { + queue.pendingSnapshotId = publishResult?.snapshotId || queue.pendingSnapshotId; + queue.republishScheduled = false; + persistQueueState(); + } + return publishResult; + }); +} + +async function updateFingerprintCache(actorKey, items) { + const ttl = Math.max(0, Number(WATCH_HISTORY_CACHE_TTL_MS) || 0); + const fingerprint = await nostrClient.getWatchHistoryFingerprint( + actorKey, + items + ); + const entry = state.fingerprintCache.get(actorKey) || {}; + const nextEntry = { + ...entry, + items: Array.isArray(items) ? items : [], + fingerprint, + expiresAt: Date.now() + ttl, + }; + delete nextEntry.promise; + state.fingerprintCache.set(actorKey, nextEntry); + emit("fingerprint", { actor: actorKey, fingerprint, items: nextEntry.items }); + return fingerprint; +} + +async function publishView(pointerInput, createdAt, metadata = {}) { + const recordOptions = {}; + if (Number.isFinite(createdAt)) { + recordOptions.created_at = createdAt; + } + if (metadata && typeof metadata === "object") { + if (Array.isArray(metadata.additionalTags)) { + recordOptions.additionalTags = metadata.additionalTags; + } + if (Array.isArray(metadata.relays)) { + recordOptions.relays = metadata.relays; + } + if (metadata.content != null) { + recordOptions.content = metadata.content; + } + } + + const viewResult = await nostrClient.recordVideoView(pointerInput, recordOptions); + + if (!isFeatureEnabled()) { + return viewResult; + } + + const pointer = normalizePointerInput(pointerInput); + if (!pointer) { + return viewResult; + } + + const actorCandidate = + (typeof metadata.actor === "string" && metadata.actor.trim()) || + viewResult?.event?.pubkey || + nostrClient?.pubkey || + nostrClient?.sessionActor?.pubkey || + ""; + const actorKey = normalizeActorKey(actorCandidate); + if (!actorKey) { + return viewResult; + } + + const queue = ensureQueue(actorKey); + if (!queue) { + return viewResult; + } + + const normalizedPointer = normalizePointerInput(pointer); + if (!normalizedPointer) { + return viewResult; + } + + normalizedPointer.watchedAt = resolveWatchedAt( + createdAt, + viewResult?.event?.created_at + ); + + const normalizedLogged = normalizeActorKey(nostrClient?.pubkey); + const normalizedEventActor = normalizeActorKey( + viewResult?.event?.pubkey || actorCandidate + ); + if (normalizedEventActor && normalizedEventActor !== normalizedLogged) { + normalizedPointer.session = true; + } + + const key = pointerKey(normalizedPointer); + if (!key) { + return viewResult; + } + + const now = Date.now(); + const existing = queue.items.get(key); + const lastSeen = queue.throttle.get(key) || 0; + const throttled = !existing && now - lastSeen < POINTER_THROTTLE_MS; + + if (existing) { + const currentWatched = Number.isFinite(existing.pointer?.watchedAt) + ? existing.pointer.watchedAt + : 0; + if ( + Number.isFinite(normalizedPointer.watchedAt) && + normalizedPointer.watchedAt > currentWatched + ) { + existing.pointer.watchedAt = normalizedPointer.watchedAt; + } + if (normalizedPointer.session === true) { + existing.pointer.session = true; + } + existing.updatedAt = now; + queue.items.set(key, existing); + queue.throttle.set(key, now); + persistQueueState(); + notifyQueueChange(actorKey); + return viewResult; + } + + if (throttled) { + queue.throttle.set(key, now); + if (isDevMode) { + console.info( + `[watchHistoryService] Throttled pointer ${key} for actor ${actorKey}.` + ); + } + return viewResult; + } + + queue.items.set(key, { + pointer: normalizedPointer, + addedAt: now, + updatedAt: now, + }); + queue.throttle.set(key, now); + persistQueueState(); + notifyQueueChange(actorKey); + + return viewResult; +} + +async function snapshot(items, options = {}) { + if (!isFeatureEnabled()) { + return { ok: true, skipped: true, reason: "feature-disabled" }; + } + const reason = typeof options.reason === "string" ? options.reason : "manual"; + const actorKey = resolveActorKey(options.actor); + if (!actorKey) { + return { ok: false, error: "missing-actor" }; + } + + if (state.inflightSnapshots.has(actorKey)) { + return state.inflightSnapshots.get(actorKey); + } + + let payloadItems = []; + if (Array.isArray(items) && items.length) { + payloadItems = items + .map((item) => normalizePointerInput(item)) + .filter(Boolean); + } else { + payloadItems = collectQueueItems(actorKey); + } + + if (!payloadItems.length) { + const result = { ok: true, empty: true, actor: actorKey }; + emit("snapshot-empty", result); + return result; + } + + const queue = ensureQueue(actorKey); + + const run = (async () => { + emit("snapshot-start", { actor: actorKey, reason, items: payloadItems }); + const publishResult = await nostrClient.publishWatchHistorySnapshot( + payloadItems, + { + actorPubkey: actorKey, + source: reason, + } + ); + + if (!publishResult?.ok) { + if (publishResult?.retryable && publishResult?.snapshotId) { + if (queue) { + queue.pendingSnapshotId = publishResult.snapshotId; + queue.lastSnapshotReason = reason; + queue.republishScheduled = false; + persistQueueState(); + scheduleRepublishForQueue(actorKey); + } + } + const error = new Error("watch-history-snapshot-failed"); + error.result = publishResult; + emit("snapshot-error", { actor: actorKey, reason, error }); + throw error; + } + + if (!items) { + clearQueue(actorKey); + } else if (queue) { + queue.pendingSnapshotId = null; + queue.republishScheduled = false; + persistQueueState(); + } + + await updateFingerprintCache(actorKey, publishResult.items || payloadItems); + emit("snapshot-complete", { actor: actorKey, reason, result: publishResult }); + return publishResult; + })() + .catch((error) => { + if (isDevMode) { + console.warn("[watchHistoryService] Snapshot failed:", error); + } + throw error; + }) + .finally(() => { + state.inflightSnapshots.delete(actorKey); + }); + + state.inflightSnapshots.set(actorKey, run); + return run; +} + +async function loadLatest(actorInput) { + if (!isFeatureEnabled()) { + return []; + } + const actorKey = resolveActorKey(actorInput); + if (!actorKey) { + return []; + } + + const now = Date.now(); + const cacheEntry = state.fingerprintCache.get(actorKey); + if ( + cacheEntry?.items && + cacheEntry.expiresAt && + cacheEntry.expiresAt > now && + Array.isArray(cacheEntry.items) + ) { + return cacheEntry.items; + } + + if (cacheEntry?.promise) { + return cacheEntry.promise; + } + + const promise = nostrClient + .resolveWatchHistory(actorKey, { forceRefresh: true }) + .then((resolvedItems) => updateFingerprintCache(actorKey, resolvedItems)) + .then(() => { + const latest = state.fingerprintCache.get(actorKey); + return latest?.items || []; + }) + .catch((error) => { + if (isDevMode) { + console.warn("[watchHistoryService] Failed to load watch history:", error); + } + throw error; + }) + .finally(() => { + const entry = state.fingerprintCache.get(actorKey); + if (entry) { + delete entry.promise; + } + }); + + state.fingerprintCache.set(actorKey, { + ...(cacheEntry || {}), + promise, + }); + + return promise; +} + +async function getFingerprint(actorInput) { + if (!isFeatureEnabled()) { + return ""; + } + const actorKey = resolveActorKey(actorInput); + if (!actorKey) { + return ""; + } + const now = Date.now(); + const cacheEntry = state.fingerprintCache.get(actorKey); + if ( + cacheEntry?.fingerprint && + cacheEntry.expiresAt && + cacheEntry.expiresAt > now + ) { + return cacheEntry.fingerprint; + } + const fingerprint = await nostrClient.getWatchHistoryFingerprint(actorKey); + const ttl = Math.max(0, Number(WATCH_HISTORY_CACHE_TTL_MS) || 0); + const nextEntry = { + ...(cacheEntry || {}), + fingerprint, + expiresAt: now + ttl, + }; + state.fingerprintCache.set(actorKey, nextEntry); + emit("fingerprint", { actor: actorKey, fingerprint }); + return fingerprint; +} + +function resetProgress(actorInput) { + const actorKey = normalizeActorKey(actorInput); + if (actorKey) { + state.queues.delete(actorKey); + state.fingerprintCache.delete(actorKey); + state.inflightSnapshots.delete(actorKey); + persistQueueState(); + notifyQueueChange(actorKey); + return; + } + state.queues.clear(); + state.fingerprintCache.clear(); + state.inflightSnapshots.clear(); + persistQueueState(); + emit("queue-changed", { actor: null, items: [] }); +} + +function getQueuedPointers(actorInput) { + const actorKey = normalizeActorKey(actorInput); + if (!actorKey) { + return []; + } + if (!state.restored) { + restoreQueueState(); + } + return collectQueueItems(actorKey); +} + +function getAllQueues() { + if (!state.restored) { + restoreQueueState(); + } + const summary = {}; + for (const actorKey of state.queues.keys()) { + const items = collectQueueItems(actorKey); + if (items.length) { + summary[actorKey] = items; + } + } + return summary; +} + +const watchHistoryService = { + isEnabled: isFeatureEnabled, + publishView, + snapshot, + loadLatest, + getFingerprint, + resetProgress, + getQueuedPointers, + getAllQueues, + subscribe, +}; + +if (typeof window !== "undefined") { + window.bitvid = window.bitvid || {}; + window.bitvid.watchHistory = watchHistoryService; +} + +export { watchHistoryService }; +export default watchHistoryService;