From 7c9db2eeaf841a678f5fdb0f0e4f34d92d278f99 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 20 Oct 2025 22:36:19 -0400 Subject: [PATCH] Fix watch history snapshot merging --- js/watchHistoryService.js | 64 +++++++++++++++++---- tests/watch-history.test.mjs | 104 ++++++++++++++++++++++++++++++++++- 2 files changed, 156 insertions(+), 12 deletions(-) diff --git a/js/watchHistoryService.js b/js/watchHistoryService.js index d8c02d2d..facbb247 100644 --- a/js/watchHistoryService.js +++ b/js/watchHistoryService.js @@ -843,6 +843,42 @@ function collectQueueItems(actorKey) { return items.slice(0, WATCH_HISTORY_MAX_ITEMS); } +function getCachedSnapshotItems(actorKey) { + if (!actorKey) { + return []; + } + + const fingerprintEntry = state.fingerprintCache.get(actorKey); + if (Array.isArray(fingerprintEntry?.items) && fingerprintEntry.items.length) { + return Array.from(fingerprintEntry.items); + } + + const clientCache = + nostrClient && + nostrClient.watchHistoryCache instanceof Map && + nostrClient.watchHistoryCache.get(actorKey); + if (Array.isArray(clientCache?.items) && clientCache.items.length) { + return Array.from(clientCache.items); + } + + if (typeof nostrClient?.getWatchHistoryStorage === "function") { + try { + const storage = nostrClient.getWatchHistoryStorage(); + const storedItems = storage?.actors?.[actorKey]?.items; + if (Array.isArray(storedItems) && storedItems.length) { + return Array.from(storedItems); + } + } catch (error) { + devLogger.warn( + "[watchHistoryService] Failed to read cached watch history items:", + error, + ); + } + } + + return []; +} + function notifyQueueChange(actorKey) { emit("queue-changed", { actor: actorKey, @@ -1215,10 +1251,17 @@ async function snapshot(items, options = {}) { const queue = ensureQueue(actorKey); + if (!items) { + const cachedItems = getCachedSnapshotItems(actorKey); + if (cachedItems.length) { + payloadItems = [...payloadItems, ...cachedItems]; + } + } + devLogger.info( "[watchHistoryService] Initiating watch list snapshot publish.", { - actor: actorKey, + actor: actorKey, reason, itemCount: payloadItems.length, queued: queue?.items?.size ?? 0, @@ -1303,16 +1346,15 @@ function scheduleWatchHistoryRefresh(actorKey, cacheEntry = {}) { return cacheEntry.promise; } - const promise = Promise.resolve() - .then(async () => { - const permissionResult = await ensureWatchHistoryExtensionPermissions( - actorKey, - ); - if (!permissionResult.ok) { - throw permissionResult.error; - } - return nostrClient.resolveWatchHistory(actorKey, { forceRefresh: true }); - }) + const promise = (async () => { + const permissionResult = await ensureWatchHistoryExtensionPermissions( + actorKey, + ); + if (!permissionResult.ok) { + throw permissionResult.error; + } + return nostrClient.resolveWatchHistory(actorKey, { forceRefresh: true }); + })() .then((resolvedItems) => updateFingerprintCache(actorKey, resolvedItems)) .then(() => { const latest = state.fingerprintCache.get(actorKey); diff --git a/tests/watch-history.test.mjs b/tests/watch-history.test.mjs index 4dcc54d5..6ef282d7 100644 --- a/tests/watch-history.test.mjs +++ b/tests/watch-history.test.mjs @@ -1282,7 +1282,7 @@ async function testWatchHistoryPartialRelayRetry() { nostrClient.sessionActor = originalSession; nostrClient.pubkey = originalPub; poolHarness.setResolver(() => ({ ok: true })); - watchHistoryService.resetProgress(actor); + watchHistoryService.resetProgress(); restoreCrypto.restore(); } } @@ -1533,6 +1533,107 @@ async function testWatchHistoryServiceIntegration() { } } +async function testWatchHistorySnapshotMergesQueuedWithCachedItems() { + console.log("Running watch history snapshot merge test..."); + + const actor = "npub-snapshot-merge"; + const originalPublishSnapshot = nostrClient.publishWatchHistorySnapshot; + const originalRecordView = nostrClient.recordVideoView; + const originalEnsure = nostrClient.ensureSessionActor; + const originalSession = nostrClient.sessionActor; + const originalPub = nostrClient.pubkey; + const originalFingerprint = nostrClient.getWatchHistoryFingerprint; + + try { + nostrClient.pubkey = actor; + nostrClient.sessionActor = { pubkey: actor, privateKey: "merge-priv" }; + nostrClient.ensureSessionActor = async () => actor; + nostrClient.recordVideoView = async (_pointer, options = {}) => ({ + ok: true, + event: { + id: `view-${options.created_at || Date.now()}`, + pubkey: actor, + created_at: options.created_at || Math.floor(Date.now() / 1000), + }, + }); + + const publishedPayloads = []; + nostrClient.publishWatchHistorySnapshot = async (items, options = {}) => { + const clonedItems = Array.isArray(items) + ? items.map((entry) => ({ ...entry })) + : []; + publishedPayloads.push({ + items: clonedItems, + options: { ...options }, + }); + return { + ok: true, + snapshotId: `snapshot-${publishedPayloads.length}`, + items: clonedItems, + publishResults: {}, + }; + }; + + nostrClient.getWatchHistoryFingerprint = async (_actor, items = []) => + `fingerprint-${Array.isArray(items) ? items.length : 0}-${Date.now()}`; + + watchHistoryService.resetProgress(actor); + + const seedItems = [ + { type: "e", value: "seed-one", watchedAt: 1_700_000_000 }, + { type: "e", value: "seed-two", watchedAt: 1_700_000_100 }, + ]; + + await watchHistoryService.snapshot(seedItems, { + actor, + reason: "seed-history", + }); + + assert.equal( + publishedPayloads.length, + 1, + "initial snapshot should publish seed items", + ); + + await watchHistoryService.publishView( + { type: "e", value: "fresh-entry" }, + 1_700_000_200, + { actor }, + ); + + const mergeResult = await watchHistoryService.snapshot(null, { + actor, + reason: "merge-queued-items", + }); + assert.ok(mergeResult.ok, "merge snapshot should succeed"); + + assert.equal( + publishedPayloads.length, + 2, + "merge snapshot should trigger a second publish", + ); + + const mergedCall = publishedPayloads[1]; + const mergedKeys = mergedCall.items.map( + (entry) => `${entry?.type}:${entry?.value}`, + ); + mergedKeys.sort(); + assert.deepEqual( + mergedKeys, + ["e:fresh-entry", "e:seed-one", "e:seed-two"], + "merged snapshot should include seed and queued pointers", + ); + } finally { + nostrClient.publishWatchHistorySnapshot = originalPublishSnapshot; + nostrClient.recordVideoView = originalRecordView; + nostrClient.ensureSessionActor = originalEnsure; + nostrClient.sessionActor = originalSession; + nostrClient.pubkey = originalPub; + nostrClient.getWatchHistoryFingerprint = originalFingerprint; + watchHistoryService.resetProgress(); + } +} + async function testHistoryCardsUseDecryptedPlaybackMetadata() { console.log("Running watch history card playback metadata test..."); @@ -2047,6 +2148,7 @@ await testPublishSnapshotFailureRetry(); await testWatchHistoryPartialRelayRetry(); await testResolveWatchHistoryBatchingWindow(); await testWatchHistoryServiceIntegration(); +await testWatchHistorySnapshotMergesQueuedWithCachedItems(); await testHistoryCardsUseDecryptedPlaybackMetadata(); await testWatchHistoryStaleCacheRefresh(); await testWatchHistoryLocalFallbackWhenDisabled();