fix(watch-history): resolve freshly-watched videos from cache (default video kind in address lookup)

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 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-21 14:10:21 -04:00
1 parent 48b6f10e0d
commit ebacf223c8
2 files changed
+101 -3

No files matched your search

+11 -3
View File
@@ -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 = [];
@@ -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;
}
});