From 6039ad2fd7123a1e6ddf83c468c8d9dbd53bf614 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 30 Sep 2025 20:09:23 -0400 Subject: [PATCH] Improve watch history caching and reuse --- js/historyView.js | 4 + js/nostr.js | 158 ++++++++++++++++++++++++++--------- tests/watch-history.test.mjs | 43 +++++++++- 3 files changed, 163 insertions(+), 42 deletions(-) diff --git a/js/historyView.js b/js/historyView.js index 6484fb30..1c422cb8 100644 --- a/js/historyView.js +++ b/js/historyView.js @@ -950,6 +950,10 @@ export function createWatchHistoryRenderer(config = {}) { state.actor = beforeResult.actor.trim(); } + if (state.actor) { + nostrClient.resetWatchHistoryProgress(state.actor); + } + let fingerprint; if (typeof fingerprintOverride !== "undefined") { fingerprint = fingerprintOverride; diff --git a/js/nostr.js b/js/nostr.js index 02e1c9fe..18e66c04 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -1102,9 +1102,15 @@ class NostrClient { resolved: previousEntry?.resolved ? new Map(previousEntry.resolved) : new Map(), + resolvedVideos: previousEntry?.resolvedVideos + ? new Map(previousEntry.resolvedVideos) + : new Map(), resolving: previousEntry?.resolving ? new Set(previousEntry.resolving) : new Set(), + delivered: previousEntry?.delivered instanceof Set + ? new Set(previousEntry.delivered) + : new Set(), }; const validKeys = new Set(entry.items.map((item) => pointerKey(item))); @@ -1113,11 +1119,21 @@ class NostrClient { entry.resolved.delete(key); } } + for (const key of entry.resolvedVideos.keys()) { + if (!validKeys.has(key)) { + entry.resolvedVideos.delete(key); + } + } for (const key of [...entry.resolving]) { if (!validKeys.has(key)) { entry.resolving.delete(key); } } + for (const key of [...entry.delivered]) { + if (!validKeys.has(key)) { + entry.delivered.delete(key); + } + } const previousChunkPointers = previousEntry?.chunkPointers; const headPointer = previousChunkPointers?.head @@ -1214,6 +1230,40 @@ class NostrClient { return `${pointerId}:${pointerCreated}:${itemKeys}`; } + resetWatchHistoryProgress(pubkeyOrSession = null) { + let actor = + typeof pubkeyOrSession === "string" && pubkeyOrSession.trim() + ? pubkeyOrSession.trim() + : ""; + + if (!actor) { + if (this.sessionActor?.pubkey) { + actor = this.sessionActor.pubkey.trim(); + } else if (typeof this.pubkey === "string" && this.pubkey.trim()) { + actor = this.pubkey.trim(); + } + } + + if (!actor) { + return; + } + + const entry = this.watchHistoryCache.get(actor); + if (!entry) { + return; + } + + if (!(entry.delivered instanceof Set)) { + entry.delivered = new Set(); + } else { + entry.delivered.clear(); + } + + if (entry.resolving instanceof Set) { + entry.resolving.clear(); + } + } + async encryptWatchHistoryPayload(actorPubkey, payload) { const normalizedActor = typeof actorPubkey === "string" && actorPubkey.trim() @@ -2518,7 +2568,10 @@ class NostrClient { } for (const key of Array.from(entry.resolved.keys())) { - if (!key || !key.startsWith("a:")) { + if ( + !key || + !key.startsWith(`a:${WATCH_HISTORY_KIND}:`) + ) { continue; } if (!chunkPointerKeys.has(key)) { @@ -2570,6 +2623,13 @@ class NostrClient { return []; } + if (!(entry.resolvedVideos instanceof Map)) { + entry.resolvedVideos = new Map(); + } + if (!(entry.delivered instanceof Set)) { + entry.delivered = new Set(); + } + const parsedBatchSize = Number(batchSize); const normalizedBatchSize = Number.isFinite(parsedBatchSize) && parsedBatchSize > 0 @@ -2691,12 +2751,18 @@ class NostrClient { if (!key) { return false; } - if (entry.resolved.has(key)) { + if (entry.delivered.has(key)) { return false; } if (entry.resolving.has(key)) { return false; } + if (entry.resolvedVideos.has(key)) { + const cachedVideo = entry.resolvedVideos.get(key); + if (cachedVideo && typeof cachedVideo === "object" && cachedVideo.id) { + return false; + } + } return true; }); @@ -2727,56 +2793,53 @@ class NostrClient { const videoBatch = available.slice(0, effectiveBatchSize); const batch = [...videoBatch, ...chunkAvailable]; - if (!batch.length) { - return []; - } - - batch.forEach((item) => { - const key = pointerKey(item); - if (key) { - entry.resolving.add(key); - } - }); const relaySet = new Set( Array.isArray(this.relays) && this.relays.length ? this.relays : RELAY_URLS ); const filters = []; - for (const pointer of batch) { - if (pointer.relay) { - relaySet.add(pointer.relay); - } - if (pointer.type === "e") { - filters.push({ ids: [pointer.value] }); - } else if (pointer.type === "a") { - const [kindStr, pubkey, identifier] = pointer.value.split(":"); - const kind = Number(kindStr); - if (Number.isFinite(kind) && pubkey) { - const filter = { kinds: [kind], authors: [pubkey] }; - if (identifier) { - filter["#d"] = [identifier]; + if (batch.length) { + batch.forEach((item) => { + const key = pointerKey(item); + if (key) { + entry.resolving.add(key); + } + }); + + for (const pointer of batch) { + if (pointer.relay) { + relaySet.add(pointer.relay); + } + if (pointer.type === "e") { + filters.push({ ids: [pointer.value] }); + } else if (pointer.type === "a") { + const [kindStr, pubkey, identifier] = pointer.value.split(":"); + const kind = Number(kindStr); + if (Number.isFinite(kind) && pubkey) { + const filter = { kinds: [kind], authors: [pubkey] }; + if (identifier) { + filter["#d"] = [identifier]; + } + filters.push(filter); } - filters.push(filter); } } } - if (!filters.length) { - batch.forEach((item) => entry.resolving.delete(pointerKey(item))); - return []; - } - let events = []; - try { - events = await this.pool.list(Array.from(relaySet), filters); - } catch (error) { - if (isDevMode) { - console.warn("[nostr] Failed to resolve watch history batch:", error); + if (filters.length) { + try { + events = await this.pool.list(Array.from(relaySet), filters); + } catch (error) { + if (isDevMode) { + console.warn("[nostr] Failed to resolve watch history batch:", error); + } } + } else if (batch.length) { + batch.forEach((item) => entry.resolving.delete(pointerKey(item))); } - const resolvedVideos = []; const pointerMatches = new Map(); for (const evt of events || []) { @@ -2830,8 +2893,7 @@ class NostrClient { this.activeMap.set(activeKey, video); } } - entry.resolved.set(key, video); - resolvedVideos.push(video); + entry.resolvedVideos.set(key, video); } } catch (err) { if (isDevMode) { @@ -2842,7 +2904,23 @@ class NostrClient { entry.resolving.delete(key); } - return resolvedVideos; + const deliverableVideos = []; + for (const pointer of videoCandidates) { + if (deliverableVideos.length >= effectiveBatchSize) { + break; + } + const key = pointerKey(pointer); + if (!key || entry.delivered.has(key)) { + continue; + } + const cachedVideo = entry.resolvedVideos.get(key); + if (cachedVideo && typeof cachedVideo === "object" && cachedVideo.id) { + deliverableVideos.push(cachedVideo); + entry.delivered.add(key); + } + } + + return deliverableVideos; } async recordVideoView(videoPointer, options = {}) { diff --git a/tests/watch-history.test.mjs b/tests/watch-history.test.mjs index ab464639..5140e3ab 100644 --- a/tests/watch-history.test.mjs +++ b/tests/watch-history.test.mjs @@ -456,12 +456,19 @@ assert.equal( const pointerKey = `a:${videoPointer.value.trim().toLowerCase()}`; const cachedEntry = pointerClient.watchHistoryCache.get(ACTOR); -assert(cachedEntry.resolved.has(pointerKey), "resolved cache should retain the video"); +assert( + cachedEntry.resolvedVideos.has(pointerKey), + "resolved cache should retain the video" +); assert.equal( - cachedEntry.resolved.get(pointerKey)?.id, + cachedEntry.resolvedVideos.get(pointerKey)?.id, videoEvent.id, "resolved cache should store the converted video result" ); +assert( + cachedEntry.delivered.has(pointerKey), + "delivery set should mark the pointer as delivered" +); assert( observedResolveFilters.some((filters) => @@ -473,6 +480,38 @@ assert( "resolveWatchHistory should request the video identifier instead of treating it as a chunk" ); +const secondResolve = await pointerClient.resolveWatchHistory(1); +assert.equal( + secondResolve.length, + 0, + "subsequent resolve calls should avoid refetching already delivered videos" +); + +pointerClient.resetWatchHistoryProgress(ACTOR); +assert.equal( + cachedEntry.delivered.size, + 0, + "resetWatchHistoryProgress should clear delivered pointers" +); + +pointerClient.pool = { + list: async () => { + throw new Error("cached resolve should not trigger network fetch"); + }, +}; + +const cachedResolve = await pointerClient.resolveWatchHistory(1); +assert.equal( + cachedResolve.length, + 1, + "reset should allow cached videos to be delivered again" +); +assert.equal( + cachedResolve[0].id, + videoEvent.id, + "cached resolve should reuse the stored video payload" +); + const originalDateNow = Date.now; try { const baseTime = 1_700_000_000_000;