From 4d367109d5a586b7f6abb2d6d90fa4ae32c2a852 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 9 Oct 2025 19:23:29 -0400 Subject: [PATCH] Preserve playback metadata in watch history --- js/historyView.js | 2 +- js/nostr.js | 3 + js/services/watchHistoryTelemetry.js | 5 + js/watchHistoryService.js | 9 + tests/watch-history.test.mjs | 286 ++++++++++++++++++++++++++- 5 files changed, 302 insertions(+), 3 deletions(-) diff --git a/js/historyView.js b/js/historyView.js index bb5e58e8..56d5416c 100644 --- a/js/historyView.js +++ b/js/historyView.js @@ -338,7 +338,7 @@ function getPointerVideoId(video, pointer) { return ""; } -function buildHistoryCard({ +export function buildHistoryCard({ item, video, profile, diff --git a/js/nostr.js b/js/nostr.js index e3f04ed4..4f99e4d2 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -1374,6 +1374,9 @@ function cloneVideoMetadata(video) { created_at: createdAt, url: typeof video.url === "string" ? video.url : "", magnet: typeof video.magnet === "string" ? video.magnet : "", + infoHash: typeof video.infoHash === "string" ? video.infoHash : "", + legacyInfoHash: + typeof video.legacyInfoHash === "string" ? video.legacyInfoHash : "", }; } diff --git a/js/services/watchHistoryTelemetry.js b/js/services/watchHistoryTelemetry.js index b5176fe0..a34e7a35 100644 --- a/js/services/watchHistoryTelemetry.js +++ b/js/services/watchHistoryTelemetry.js @@ -36,6 +36,11 @@ function sanitizeVideoMetadata(video) { thumbnail: typeof video.thumbnail === "string" ? video.thumbnail : "", pubkey: typeof video.pubkey === "string" ? video.pubkey : "", created_at: createdAt, + url: typeof video.url === "string" ? video.url : "", + magnet: typeof video.magnet === "string" ? video.magnet : "", + infoHash: typeof video.infoHash === "string" ? video.infoHash : "", + legacyInfoHash: + typeof video.legacyInfoHash === "string" ? video.legacyInfoHash : "", }; } diff --git a/js/watchHistoryService.js b/js/watchHistoryService.js index 69c82809..052d1453 100644 --- a/js/watchHistoryService.js +++ b/js/watchHistoryService.js @@ -253,6 +253,8 @@ function sanitizeVideoForStorage(video) { pubkey: typeof video.pubkey === "string" ? video.pubkey : "", created_at: createdAt, infoHash: typeof video.infoHash === "string" ? video.infoHash : "", + legacyInfoHash: + typeof video.legacyInfoHash === "string" ? video.legacyInfoHash : "", mode: typeof video.mode === "string" ? video.mode : "", isPrivate: video?.isPrivate === true, description: @@ -274,6 +276,13 @@ function sanitizeVideoForHistory(video) { created_at: sanitized.created_at, url: sanitized.url, magnet: sanitized.magnet, + infoHash: sanitized.infoHash, + legacyInfoHash: + typeof video?.legacyInfoHash === "string" + ? video.legacyInfoHash + : typeof sanitized.legacyInfoHash === "string" + ? sanitized.legacyInfoHash + : "", }; } diff --git a/tests/watch-history.test.mjs b/tests/watch-history.test.mjs index 85116589..285ec24a 100644 --- a/tests/watch-history.test.mjs +++ b/tests/watch-history.test.mjs @@ -23,6 +23,7 @@ const { normalizeActorKey, } = await import("../js/nostr.js"); const { watchHistoryService } = await import("../js/watchHistoryService.js"); +const { buildHistoryCard } = await import("../js/historyView.js"); const { getApplication, setApplication } = await import( "../js/applicationContext.js" ); @@ -218,6 +219,43 @@ function createFakeSimplePool() { const poolHarness = createFakeSimplePool(); nostrClient.pool = poolHarness; + +function extractVideoMetadataFromItem(item) { + if (!item || typeof item !== "object") { + return null; + } + + const directVideo = item.video; + if (directVideo && typeof directVideo === "object") { + return directVideo; + } + + const metadataVideo = + item.metadata && typeof item.metadata === "object" + ? item.metadata.video + : null; + if (metadataVideo && typeof metadataVideo === "object") { + return metadataVideo; + } + + const pointer = item.pointer && typeof item.pointer === "object" + ? item.pointer + : null; + if (pointer) { + if (pointer.video && typeof pointer.video === "object") { + return pointer.video; + } + const pointerMetadata = + pointer.metadata && typeof pointer.metadata === "object" + ? pointer.metadata.video + : null; + if (pointerMetadata && typeof pointerMetadata === "object") { + return pointerMetadata; + } + } + + return null; +} nostrClient.relays = ["wss://relay.test"]; nostrClient.readRelays = ["wss://relay.test"]; nostrClient.writeRelays = ["wss://relay.test"]; @@ -1252,6 +1290,15 @@ async function testWatchHistoryServiceIntegration() { poolHarness.setResolver(() => ({ ok: true })); const actor = "service-actor"; + const pointerVideo = { + id: "video-one", + title: "Video One", + url: "https://cdn.example/video-one.mp4", + magnet: + "magnet:?xt=urn:btih:89abcdef0123456789abcdef0123456789abcdef", + infoHash: "89abcdef0123456789abcdef0123456789abcdef", + legacyInfoHash: "89abcdef0123456789abcdef0123456789abcdef", + }; const restoreCrypto = installSessionCrypto({ privateKey: "service-priv" }); const originalEnsure = nostrClient.ensureSessionActor; const originalSession = nostrClient.sessionActor; @@ -1290,13 +1337,13 @@ async function testWatchHistoryServiceIntegration() { await watchHistoryService.publishView( { type: "e", value: "video-one" }, viewCreatedAt, - { actor }, + { actor, video: pointerVideo }, ); viewCreatedAt += 60; await watchHistoryService.publishView( { type: "e", value: "video-one" }, viewCreatedAt, - { actor }, + { actor, video: pointerVideo }, ); viewCreatedAt += 30; await watchHistoryService.publishView( @@ -1313,6 +1360,36 @@ async function testWatchHistoryServiceIntegration() { reason: "integration", }); assert.ok(snapshotResult.ok, "snapshot should publish queued pointers"); + const snapshotItems = Array.isArray(snapshotResult.items) + ? snapshotResult.items + : []; + const snapshotVideo = extractVideoMetadataFromItem( + snapshotItems.find( + (entry) => + (entry?.value || entry?.pointer?.value || "") === "video-one", + ), + ); + assert(snapshotVideo, "snapshot should retain pointer video metadata"); + assert.equal( + snapshotVideo?.url, + pointerVideo.url, + "snapshot pointer video should preserve url", + ); + assert.equal( + snapshotVideo?.magnet, + pointerVideo.magnet, + "snapshot pointer video should preserve magnet", + ); + assert.equal( + snapshotVideo?.infoHash, + pointerVideo.infoHash, + "snapshot pointer video should preserve infoHash", + ); + assert.equal( + snapshotVideo?.legacyInfoHash, + pointerVideo.legacyInfoHash, + "snapshot pointer video should preserve legacy info hash", + ); assert.equal( watchHistoryService.getQueuedPointers(actor).length, 0, @@ -1325,6 +1402,33 @@ async function testWatchHistoryServiceIntegration() { snapshotResult.items, "loadLatest should return decrypted canonical pointers", ); + const resolvedVideo = extractVideoMetadataFromItem( + resolvedItems.find( + (entry) => + (entry?.value || entry?.pointer?.value || "") === "video-one", + ), + ); + assert(resolvedVideo, "decrypted history should include pointer video"); + assert.equal( + resolvedVideo?.url, + pointerVideo.url, + "decrypted pointer video should expose url", + ); + assert.equal( + resolvedVideo?.magnet, + pointerVideo.magnet, + "decrypted pointer video should expose magnet", + ); + assert.equal( + resolvedVideo?.infoHash, + pointerVideo.infoHash, + "decrypted pointer video should expose infoHash", + ); + assert.equal( + resolvedVideo?.legacyInfoHash, + pointerVideo.legacyInfoHash, + "decrypted pointer video should expose legacy info hash", + ); assert(resolvedItems[0].watchedAt >= resolvedItems[1].watchedAt); assert.equal( resolvedItems[0].session, @@ -1359,6 +1463,183 @@ async function testWatchHistoryServiceIntegration() { } } +async function testHistoryCardsUseDecryptedPlaybackMetadata() { + console.log("Running watch history card playback metadata test..."); + + const pointerVideo = { + id: "history-card", + title: "History Card Video", + url: "https://cdn.example/history-card.mp4", + magnet: + "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567", + infoHash: "0123456789abcdef0123456789abcdef01234567", + legacyInfoHash: "0123456789abcdef0123456789abcdef01234567", + }; + + const item = { + pointerKey: "e:history-card", + pointer: { type: "e", value: "history-card" }, + watchedAt: 1_700_000_500, + }; + + const originalDocument = globalThis.document; + const originalHTMLElement = globalThis.HTMLElement; + + class FakeClassList { + constructor(element) { + this.element = element; + } + + _sync() { + this.element._className = Array.from(this.element._classSet).join(" "); + } + + add(...tokens) { + tokens.forEach((token) => { + if (token) { + this.element._classSet.add(token); + } + }); + this._sync(); + } + + remove(...tokens) { + tokens.forEach((token) => this.element._classSet.delete(token)); + this._sync(); + } + + toggle(token, force) { + if (!token) { + return false; + } + if (force === true) { + this.element._classSet.add(token); + this._sync(); + return true; + } + if (force === false) { + this.element._classSet.delete(token); + this._sync(); + return false; + } + if (this.element._classSet.has(token)) { + this.element._classSet.delete(token); + this._sync(); + return false; + } + this.element._classSet.add(token); + this._sync(); + return true; + } + + contains(token) { + return this.element._classSet.has(token); + } + } + + class FakeElement { + constructor(tagName) { + this.tagName = typeof tagName === "string" ? tagName.toUpperCase() : ""; + this.children = []; + this.parentNode = null; + this.dataset = {}; + this.attributes = {}; + this.textContent = ""; + this._classSet = new Set(); + this._className = ""; + this.classList = new FakeClassList(this); + } + + appendChild(child) { + if (child && typeof child === "object") { + child.parentNode = this; + } + this.children.push(child); + return child; + } + + get className() { + return this._className; + } + + set className(value) { + const tokens = + typeof value === "string" + ? value + .split(/\s+/) + .map((token) => token.trim()) + .filter(Boolean) + : []; + this._classSet = new Set(tokens); + this._className = tokens.join(" "); + } + + setAttribute(name, value) { + this.attributes[name] = String(value); + } + + removeAttribute(name) { + delete this.attributes[name]; + } + } + + function collectElements(root, predicate, results = []) { + if (!(root instanceof FakeElement)) { + return results; + } + if (predicate(root)) { + results.push(root); + } + for (const child of root.children) { + collectElements(child, predicate, results); + } + return results; + } + + const fakeDocument = { + createElement(tagName) { + return new FakeElement(tagName); + }, + }; + + globalThis.document = fakeDocument; + globalThis.HTMLElement = FakeElement; + + try { + const card = buildHistoryCard({ + item, + video: pointerVideo, + profile: null, + metadataPreference: "encrypted-only", + }); + + assert(card instanceof FakeElement); + assert.equal(card.dataset.pointerKey, item.pointerKey); + + const playLinks = collectElements( + card, + (element) => + element.tagName === "A" && + element.dataset.historyAction === "play", + ); + + assert(playLinks.length >= 1, "card should expose play actions"); + assert.equal( + playLinks[0].dataset.playUrl, + encodeURIComponent(pointerVideo.url), + "play action should encode url from metadata", + ); + assert.equal( + playLinks[0].dataset.playMagnet, + pointerVideo.magnet, + "play action should surface magnet from metadata", + ); + } finally { + globalThis.document = originalDocument; + globalThis.HTMLElement = originalHTMLElement; + } +} + async function testWatchHistoryStaleCacheRefresh() { console.log("Running watch history stale cache refresh test..."); @@ -1696,6 +1977,7 @@ await testPublishSnapshotFailureRetry(); await testWatchHistoryPartialRelayRetry(); await testResolveWatchHistoryBatchingWindow(); await testWatchHistoryServiceIntegration(); +await testHistoryCardsUseDecryptedPlaybackMetadata(); await testWatchHistoryStaleCacheRefresh(); await testWatchHistoryLocalFallbackWhenDisabled(); await testWatchHistorySyncEnabledForLoggedInUsers();