From 507f09dff57eea3d8f1ef1f153efbe348c306ec3 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 9 Oct 2025 11:31:47 -0400 Subject: [PATCH] Preserve video metadata in watch history payloads --- js/app.js | 1 + js/feedEngine/watchHistoryFeed.js | 39 ++- js/nostr.js | 242 ++++++++++++++++-- js/services/watchHistoryTelemetry.js | 51 +++- js/watchHistoryService.js | 79 ++++++ .../watch-history-telemetry.test.mjs | 53 ++++ 6 files changed, 435 insertions(+), 30 deletions(-) diff --git a/js/app.js b/js/app.js index bc52e369..c0be7a2d 100644 --- a/js/app.js +++ b/js/app.js @@ -3237,6 +3237,7 @@ class Application { videoElement: videoEl, pointer, pointerKey, + video: this.currentVideo, }); } diff --git a/js/feedEngine/watchHistoryFeed.js b/js/feedEngine/watchHistoryFeed.js index be7f9f6e..0f76d96a 100644 --- a/js/feedEngine/watchHistoryFeed.js +++ b/js/feedEngine/watchHistoryFeed.js @@ -96,16 +96,47 @@ function normalizeHistoryEntry(raw) { raw?.timestamp, raw?.created_at, pointer?.watchedAt, + raw?.metadata?.watchedAt, + pointer?.metadata?.watchedAt, ); - const resumeAt = resolveResumeSeconds(raw); - const completed = resolveCompleted(raw); + const resumeAt = (() => { + const fromPointer = Number.isFinite(pointer?.resumeAt) + ? Math.max(0, Math.floor(pointer.resumeAt)) + : Number.isFinite(pointer?.metadata?.resumeAt) + ? Math.max(0, Math.floor(pointer.metadata.resumeAt)) + : null; + if (fromPointer !== null) { + return fromPointer; + } + return resolveResumeSeconds(raw); + })(); + + const completed = + pointer?.completed === true || + pointer?.metadata?.completed === true || + resolveCompleted(raw); + + const pointerVideo = + (pointer?.video && isPlainObject(pointer.video) ? pointer.video : null) || + (pointer?.metadata?.video && isPlainObject(pointer.metadata.video) + ? pointer.metadata.video + : null); + + const pointerProfile = + (pointer?.profile && isPlainObject(pointer.profile) ? pointer.profile : null) || + (pointer?.metadata?.profile && isPlainObject(pointer.metadata.profile) + ? pointer.metadata.profile + : null); return { pointer, pointerKey: key, watchedAt, - video: isPlainObject(raw?.video) ? raw.video : null, + video: + isPlainObject(raw?.video) && raw.video + ? raw.video + : pointerVideo, metadata: { source: "watch-history", pointerKey: key, @@ -113,6 +144,8 @@ function normalizeHistoryEntry(raw) { resumeAt, completed, session: raw?.session === true || pointer.session === true, + video: pointerVideo, + profile: pointerProfile, }, }; } diff --git a/js/nostr.js b/js/nostr.js index 648afad3..f9e92491 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -1329,6 +1329,117 @@ function pointerKey(pointer) { return `${type}:${value}`; } +function cloneVideoMetadata(video) { + if (!video || typeof video !== "object") { + return null; + } + + const createdAt = Number.isFinite(video.created_at) + ? Math.floor(video.created_at) + : null; + + return { + id: typeof video.id === "string" ? video.id : "", + title: typeof video.title === "string" ? video.title : "", + 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 : "", + }; +} + +function cloneProfileMetadata(profile) { + if (!profile || typeof profile !== "object") { + return null; + } + + return { + pubkey: typeof profile.pubkey === "string" ? profile.pubkey : "", + name: typeof profile.name === "string" ? profile.name : "", + display_name: + typeof profile.display_name === "string" ? profile.display_name : "", + picture: typeof profile.picture === "string" ? profile.picture : "", + nip05: typeof profile.nip05 === "string" ? profile.nip05 : "", + }; +} + +function clonePointerMetadata(metadata) { + if (!metadata || typeof metadata !== "object") { + return null; + } + + const cloned = {}; + const video = cloneVideoMetadata(metadata.video); + if (video) { + cloned.video = video; + } + + const profile = cloneProfileMetadata(metadata.profile); + if (profile) { + cloned.profile = profile; + } + + if (Number.isFinite(metadata.resumeAt)) { + cloned.resumeAt = Math.max(0, Math.floor(metadata.resumeAt)); + } + + if (metadata.completed === true) { + cloned.completed = true; + } + + if (Number.isFinite(metadata.watchedAt)) { + cloned.watchedAt = Math.max(0, Math.floor(metadata.watchedAt)); + } + + return Object.keys(cloned).length ? cloned : null; +} + +function mergePointerDetails(target, source) { + if (!target || !source) { + return; + } + + if (!target.metadata && source.metadata) { + target.metadata = clonePointerMetadata(source.metadata); + } else if (target.metadata && source.metadata) { + const merged = clonePointerMetadata(target.metadata) || {}; + const additional = clonePointerMetadata(source.metadata) || {}; + if (!merged.video && additional.video) { + merged.video = additional.video; + } + if (!merged.profile && additional.profile) { + merged.profile = additional.profile; + } + if (!Number.isFinite(merged.resumeAt) && Number.isFinite(additional.resumeAt)) { + merged.resumeAt = additional.resumeAt; + } + if (additional.completed === true) { + merged.completed = true; + } + if (!Number.isFinite(merged.watchedAt) && Number.isFinite(additional.watchedAt)) { + merged.watchedAt = additional.watchedAt; + } + target.metadata = Object.keys(merged).length ? merged : undefined; + } + + if (!target.video && source.video) { + target.video = cloneVideoMetadata(source.video); + } + + if (!target.profile && source.profile) { + target.profile = cloneProfileMetadata(source.profile); + } + + if (!Number.isFinite(target.resumeAt) && Number.isFinite(source.resumeAt)) { + target.resumeAt = Math.max(0, Math.floor(source.resumeAt)); + } + + if (source.completed === true) { + target.completed = true; + } +} + function clonePointerItem(pointer) { if (!pointer || typeof pointer !== "object") { return null; @@ -1359,6 +1470,31 @@ function clonePointerItem(pointer) { cloned.session = true; } + const metadata = clonePointerMetadata(pointer.metadata); + if (metadata) { + cloned.metadata = metadata; + } + + const video = cloneVideoMetadata(pointer.video) || metadata?.video || null; + if (video) { + cloned.video = video; + } + + const profile = cloneProfileMetadata(pointer.profile) || metadata?.profile || null; + if (profile) { + cloned.profile = profile; + } + + if (Number.isFinite(pointer.resumeAt)) { + cloned.resumeAt = Math.max(0, Math.floor(pointer.resumeAt)); + } else if (Number.isFinite(metadata?.resumeAt)) { + cloned.resumeAt = metadata.resumeAt; + } + + if (pointer.completed === true || metadata?.completed === true) { + cloned.completed = true; + } + return cloned; } @@ -2181,7 +2317,21 @@ function canonicalizeWatchHistoryItems(rawItems, maxItems = WATCH_HISTORY_MAX_IT ? pointer.watchedAt : 0; if (incomingWatched > currentWatched) { + mergePointerDetails(pointer, existing); + pointer.watchedAt = incomingWatched; + pointer.session = existing.session === true || pointer.session === true; seen.set(key, pointer); + continue; + } + + if (incomingWatched === currentWatched) { + mergePointerDetails(existing, pointer); + } else { + mergePointerDetails(existing, pointer); + } + + if (pointer.session === true) { + existing.session = true; } } } @@ -2230,25 +2380,81 @@ function serializeWatchHistoryItems(items) { return "[]"; } - const normalized = items.map((item) => { - const type = item?.type === "a" ? "a" : "e"; - const value = typeof item?.value === "string" ? item.value : ""; - const relay = - typeof item?.relay === "string" && item.relay.trim() - ? item.relay.trim() + const normalized = items + .map((item) => { + const type = item?.type === "a" ? "a" : "e"; + const value = typeof item?.value === "string" ? item.value : ""; + if (!type || !value) { + return null; + } + + const relay = + typeof item?.relay === "string" && item.relay.trim() + ? item.relay.trim() + : undefined; + const watchedAt = Number.isFinite(item?.watchedAt) + ? Math.max(0, Math.floor(item.watchedAt)) : undefined; - const watchedAt = Number.isFinite(item?.watchedAt) - ? Math.max(0, Math.floor(item.watchedAt)) - : undefined; - const payload = { type, value }; - if (relay) { - payload.relay = relay; - } - if (watchedAt !== undefined) { - payload.watchedAt = watchedAt; - } - return payload; - }); + + const payload = { type, value }; + if (relay) { + payload.relay = relay; + } + if (watchedAt !== undefined) { + payload.watchedAt = watchedAt; + } + + const metadata = clonePointerMetadata(item?.metadata); + if (metadata) { + payload.metadata = metadata; + } + + const video = metadata?.video || cloneVideoMetadata(item?.video); + if (video) { + if (payload.metadata) { + payload.metadata.video = payload.metadata.video || video; + } else { + payload.video = video; + } + } + + const profile = metadata?.profile || cloneProfileMetadata(item?.profile); + if (profile) { + if (payload.metadata) { + payload.metadata.profile = payload.metadata.profile || profile; + } else { + payload.profile = profile; + } + } + + const resumeAt = Number.isFinite(item?.resumeAt) + ? Math.max(0, Math.floor(item.resumeAt)) + : undefined; + if (resumeAt !== undefined) { + if (payload.metadata) { + if (!Number.isFinite(payload.metadata.resumeAt)) { + payload.metadata.resumeAt = resumeAt; + } + } else { + payload.resumeAt = resumeAt; + } + } + + if (item?.completed === true) { + if (payload.metadata) { + payload.metadata.completed = true; + } else { + payload.completed = true; + } + } + + if (payload.metadata && !Object.keys(payload.metadata).length) { + delete payload.metadata; + } + + return payload; + }) + .filter(Boolean); return JSON.stringify(normalized); } diff --git a/js/services/watchHistoryTelemetry.js b/js/services/watchHistoryTelemetry.js index 11d3cbb7..b5176fe0 100644 --- a/js/services/watchHistoryTelemetry.js +++ b/js/services/watchHistoryTelemetry.js @@ -10,6 +10,35 @@ function normalizeString(value) { return typeof value === "string" ? value.trim() : ""; } +function sanitizeVideoMetadata(video) { + if (!video || typeof video !== "object") { + return null; + } + + const createdAtCandidates = [ + video.rootCreatedAt, + video.created_at, + video.createdAt, + video.publishedAt, + ]; + + let createdAt = null; + for (const candidate of createdAtCandidates) { + if (Number.isFinite(candidate)) { + createdAt = Math.floor(candidate); + break; + } + } + + return { + id: typeof video.id === "string" ? video.id : "", + title: typeof video.title === "string" ? video.title : "", + thumbnail: typeof video.thumbnail === "string" ? video.thumbnail : "", + pubkey: typeof video.pubkey === "string" ? video.pubkey : "", + created_at: createdAt, + }; +} + export default class WatchHistoryTelemetry { constructor({ watchHistoryService = null, @@ -165,12 +194,7 @@ export default class WatchHistoryTelemetry { } const metadata = { - video: { - id: typeof video.id === "string" ? video.id : "", - title: typeof video.title === "string" ? video.title : "", - thumbnail: typeof video.thumbnail === "string" ? video.thumbnail : "", - pubkey: typeof video.pubkey === "string" ? video.pubkey : "", - }, + video: sanitizeVideoMetadata(video), }; try { @@ -279,6 +303,7 @@ export default class WatchHistoryTelemetry { videoElement, pointer, pointerKey: explicitPointerKey, + video, } = {}) { this.cancelPlaybackLogging(); @@ -305,6 +330,7 @@ export default class WatchHistoryTelemetry { handlers: [], viewTimerId: null, viewFired: false, + videoMetadata: sanitizeVideoMetadata(video), }; const clearTimer = (timerKey) => { @@ -367,15 +393,22 @@ export default class WatchHistoryTelemetry { }; const activeWatchActor = resolveWatchActor(); - const watchMetadata = activeWatchActor - ? { actor: activeWatchActor } + const watchMetadata = {}; + if (activeWatchActor) { + watchMetadata.actor = activeWatchActor; + } + if (state.videoMetadata) { + watchMetadata.video = state.videoMetadata; + } + const metadataPayload = Object.keys(watchMetadata).length + ? watchMetadata : undefined; if (canUseWatchHistoryService) { viewResult = await this.watchHistoryService.publishView( thresholdPointer, undefined, - watchMetadata, + metadataPayload, ); } else if (typeof this.nostrClient?.recordVideoView === "function") { viewResult = await this.nostrClient.recordVideoView( diff --git a/js/watchHistoryService.js b/js/watchHistoryService.js index e40f855d..69c82809 100644 --- a/js/watchHistoryService.js +++ b/js/watchHistoryService.js @@ -260,6 +260,23 @@ function sanitizeVideoForStorage(video) { }; } +function sanitizeVideoForHistory(video) { + const sanitized = sanitizeVideoForStorage(video); + if (!sanitized) { + return null; + } + + return { + id: sanitized.id, + title: sanitized.title, + thumbnail: sanitized.thumbnail, + pubkey: sanitized.pubkey, + created_at: sanitized.created_at, + url: sanitized.url, + magnet: sanitized.magnet, + }; +} + function sanitizeProfileForStorage(profile) { if (!profile || typeof profile !== "object") { return null; @@ -357,6 +374,41 @@ function restoreMetadataCache() { } } +function sanitizePointerMetadata(metadata) { + if (!metadata || typeof metadata !== "object") { + return null; + } + + const pointerMetadata = {}; + const video = sanitizeVideoForHistory(metadata.video); + if (video) { + pointerMetadata.video = video; + } + + const profile = sanitizeProfileForStorage(metadata.profile); + if (profile) { + pointerMetadata.profile = profile; + } + + const resumeCandidates = [ + metadata.resumeAt, + metadata.resume, + metadata.resumeSeconds, + ]; + for (const candidate of resumeCandidates) { + if (Number.isFinite(candidate)) { + pointerMetadata.resumeAt = Math.max(0, Math.floor(candidate)); + break; + } + } + + if (metadata.completed === true) { + pointerMetadata.completed = true; + } + + return Object.keys(pointerMetadata).length ? pointerMetadata : null; +} + function persistMetadataCache() { const storage = getLocalStorage(); if (!storage) { @@ -975,6 +1027,33 @@ async function publishView(pointerInput, createdAt, metadata = {}) { viewResult?.event?.created_at ); + const pointerMetadata = sanitizePointerMetadata(metadata); + if (pointerMetadata) { + const existingMetadata = + typeof normalizedPointer.metadata === "object" + ? normalizedPointer.metadata + : {}; + normalizedPointer.metadata = { ...existingMetadata, ...pointerMetadata }; + if (pointerMetadata.video) { + normalizedPointer.video = pointerMetadata.video; + } + if (pointerMetadata.profile) { + normalizedPointer.profile = pointerMetadata.profile; + } + if ( + Number.isFinite(pointerMetadata.resumeAt) && + !Number.isFinite(normalizedPointer.resumeAt) + ) { + normalizedPointer.resumeAt = Math.max( + 0, + Math.floor(pointerMetadata.resumeAt), + ); + } + if (pointerMetadata.completed === true) { + normalizedPointer.completed = true; + } + } + const key = pointerKey(normalizedPointer); console.info( "[watchHistoryService] Watch list process triggered by video view.", diff --git a/tests/watchHistory/watch-history-telemetry.test.mjs b/tests/watchHistory/watch-history-telemetry.test.mjs index dd1a8008..41ca738e 100644 --- a/tests/watchHistory/watch-history-telemetry.test.mjs +++ b/tests/watchHistory/watch-history-telemetry.test.mjs @@ -174,6 +174,58 @@ async function testPreparePlaybackLoggingPublishesOnce() { ); } +async function testPreparePlaybackLoggingIncludesVideoMetadata() { + const publishCalls = []; + + const pointer = { type: "e", value: "event-id" }; + const pointerKey = "event-id"; + + const { telemetry } = createTelemetry({ + serviceOverrides: { + publishView: async (ptr, _relay, metadata) => { + publishCalls.push({ ptr, metadata }); + return { + ok: true, + event: { pubkey: hexPubkey }, + }; + }, + }, + }); + + const videoElement = new FakeVideoElement(); + videoElement.paused = false; + videoElement.currentTime = telemetry.viewThresholdSeconds; + + const videoMetadata = { + id: "evt1", + title: "Example Video", + thumbnail: "https://example.com/thumb.jpg", + pubkey: hexPubkey, + rootCreatedAt: 1_700_000_000, + url: "https://example.com/video.mp4", + }; + + telemetry.preparePlaybackLogging({ + videoElement, + pointer, + pointerKey, + video: videoMetadata, + }); + + await flushAsyncOperations(); + + assert.equal(publishCalls.length, 1, "publishView should be invoked once"); + assert.equal(publishCalls[0].ptr, pointer); + assert.ok(publishCalls[0].metadata, "metadata payload should exist"); + assert.deepEqual(publishCalls[0].metadata.video, { + id: "evt1", + title: "Example Video", + thumbnail: "https://example.com/thumb.jpg", + pubkey: hexPubkey, + created_at: 1_700_000_000, + }); +} + async function testCancelPlaybackLoggingFlushes() { let flushArgs = null; const video = new FakeVideoElement(); @@ -202,6 +254,7 @@ async function testCancelPlaybackLoggingFlushes() { await testMetadataPreferenceSync(); await testPreparePlaybackLoggingPublishesOnce(); +await testPreparePlaybackLoggingIncludesVideoMetadata(); await testCancelPlaybackLoggingFlushes(); console.log("watch-history-telemetry tests passed");