From ebacf223c8b0adb0b31e6413a51b0adbd3b77c24 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 21 Jun 2026 14:10:21 -0400 Subject: [PATCH] fix(watch-history): resolve freshly-watched videos from cache (default video kind in address lookup) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Watch-history cards rendered "Untitled video" / "Unknown" for newly watched videos. nostrClient.allEvents stores CONVERTED video objects (convertEventToVideo), which carry tags/pubkey but no `kind` field — only raw relay events do. resolveEventAddress() did Number(event.kind) → NaN and bailed with "", so the address (cache-by-"a"-pointer) scan matched nothing. Every video pointer (all are "a" addresses) was forced to a relay round-trip, and when that returned nothing the card showed Unknown — even though the just-watched video was sitting right there in allEvents. Fix: default to the video kind (30078) when a cached object has no `kind`, so the local cache scan resolves freshly-watched "a" pointers without a relay round-trip. Adds tests/watch-history-hydration-cache.test.mjs: relays return nothing, so a pass can only come from the cache scan (mutation-verified against the NaN-reject regression). Exports createWatchHistoryHydrationStage for boundary testing. Co-Authored-By: Claude Opus 4.8 --- js/feedEngine/watchHistoryFeed.js | 14 ++- tests/watch-history-hydration-cache.test.mjs | 90 ++++++++++++++++++++ 2 files changed, 101 insertions(+), 3 deletions(-) create mode 100644 tests/watch-history-hydration-cache.test.mjs diff --git a/js/feedEngine/watchHistoryFeed.js b/js/feedEngine/watchHistoryFeed.js index 6b9689e6..de4045cd 100644 --- a/js/feedEngine/watchHistoryFeed.js +++ b/js/feedEngine/watchHistoryFeed.js @@ -2,6 +2,7 @@ import { pointerKey, normalizePointerInput } from "../nostr/watchHistory.js"; import { convertEventToVideo } from "../nostr/index.js"; +import { DEFAULT_VIDEO_KIND } from "../utils/videoPointer.js"; import watchHistoryService from "../watchHistoryService.js"; import nostrService from "../services/nostrService.js"; import { nostrClient } from "../nostrClientFacade.js"; @@ -237,9 +238,16 @@ function resolveEventAddress(event) { if (!event || typeof event !== "object") { return ""; } - const kind = Number(event.kind); + // Converted video objects (convertEventToVideo) carry tags/pubkey but NOT a + // `kind` field — only raw relay events do. Watch-history pointers are always + // video-kind addresses, so default to the video kind when it's absent. + // Without this, the cache-by-address scan produced "" for every cached video + // and never matched, forcing a relay round-trip that left freshly-watched + // videos (already sitting in allEvents) showing "Unknown". + const rawKind = Number(event.kind); + const kind = Number.isFinite(rawKind) ? rawKind : DEFAULT_VIDEO_KIND; const pubkey = typeof event.pubkey === "string" ? event.pubkey.toLowerCase() : ""; - if (!Number.isFinite(kind) || !pubkey) { + if (!pubkey) { return ""; } const dTag = event.tags?.find((t) => t[0] === "d" && t[1]); @@ -266,7 +274,7 @@ function checkLocalCacheForVideo(pointer) { return null; } -function createWatchHistoryHydrationStage() { +export function createWatchHistoryHydrationStage() { return async function watchHistoryHydrationStage(items = [], context = {}) { debugInfo(`Starting hydration stage for ${items.length} items.`); const missing = []; diff --git a/tests/watch-history-hydration-cache.test.mjs b/tests/watch-history-hydration-cache.test.mjs new file mode 100644 index 00000000..4730bcde --- /dev/null +++ b/tests/watch-history-hydration-cache.test.mjs @@ -0,0 +1,90 @@ +// A freshly-watched video is already in nostrClient.allEvents (you just played +// it). The watch-history feed stores an "a" (address) pointer for it and must +// resolve the title/thumbnail from that local cache WITHOUT a relay round-trip. +// +// Bug: allEvents holds *converted* video objects (convertEventToVideo), which +// carry tags/pubkey but NO `kind` field. resolveEventAddress did Number(kind) → +// NaN → returned "" for every cached video, so the address cache-scan never +// matched. Every video pointer fell through to a relay fetch, and when that came +// up empty the card rendered "Untitled video" / "Unknown". +// +// Correct behavior: the cache-scan resolves an "a" pointer to the cached video +// using the video kind by default, so freshly-watched videos show their real +// metadata immediately — even when relays return nothing. + +import "./test-helpers/setup-localstorage.mjs"; +import assert from "node:assert/strict"; +import test from "node:test"; +import { createWatchHistoryHydrationStage } from "../js/feedEngine/watchHistoryFeed.js"; +import { nostrClient } from "../js/nostrClientFacade.js"; + +const PUBKEY = "c".repeat(64); +const DTAG = "my-video-root-id"; +const ADDRESS = `30078:${PUBKEY}:${DTAG}`; + +// Mirrors convertEventToVideo output: tags + pubkey, but NO `kind`. +function cachedVideoWithoutKind() { + return { + id: "e".repeat(64), + pubkey: PUBKEY, + title: "My Real Video Title", + thumbnail: "https://example.com/thumb.jpg", + tags: [["d", DTAG]], + created_at: 1750000000, + invalid: false, + }; +} + +test("an 'a'-pointer resolves from the local cache (no relay needed) for a converted video lacking `kind`", async () => { + const prevAllEvents = nostrClient.allEvents; + const prevPool = nostrClient.pool; + const prevRead = nostrClient.readRelays; + + nostrClient.allEvents = new Map([ + [cachedVideoWithoutKind().id, cachedVideoWithoutKind()], + ]); + // Relays return nothing — so a pass can ONLY come from the cache scan. + let relayHits = 0; + nostrClient.pool = { + list: async () => { + relayHits += 1; + return []; + }, + }; + nostrClient.readRelays = ["wss://relay.example"]; + + try { + const stage = createWatchHistoryHydrationStage(); + const items = [ + { + pointer: { type: "a", value: ADDRESS }, + pointerKey: `a:${ADDRESS}`, + watchedAt: 1750000001, + video: null, + metadata: {}, + }, + ]; + + const result = await stage(items, {}); + + assert.equal(result.length, 1); + assert.ok( + result[0].video, + "freshly-watched video must resolve from the local cache", + ); + assert.equal( + result[0].video.title, + "My Real Video Title", + "resolved video must carry the real title (not Unknown)", + ); + assert.equal( + relayHits, + 0, + "cache hit must avoid a relay round-trip entirely", + ); + } finally { + nostrClient.allEvents = prevAllEvents; + nostrClient.pool = prevPool; + nostrClient.readRelays = prevRead; + } +});