From 7c3af36003f435393594f3924d5e91e21f77e182 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 4 Oct 2025 17:25:43 -0400 Subject: [PATCH] Add watch history feed adapter and update renderer --- js/app.js | 26 +++ js/feedEngine/index.js | 1 + js/feedEngine/watchHistoryFeed.js | 342 +++++++++++++++++++++++++++ js/historyView.js | 368 ++++++++++++------------------ js/watchHistoryMetadata.js | 267 ++++++++++++++++++++++ tests/watch-history-feed.test.mjs | 132 +++++++++++ 6 files changed, 916 insertions(+), 220 deletions(-) create mode 100644 js/feedEngine/watchHistoryFeed.js create mode 100644 js/watchHistoryMetadata.js create mode 100644 tests/watch-history-feed.test.mjs diff --git a/js/app.js b/js/app.js index e9de5332..d880145a 100644 --- a/js/app.js +++ b/js/app.js @@ -25,6 +25,7 @@ import { createDedupeByRootStage, createChronologicalSorter, createSubscriptionAuthorsSource, + registerWatchHistoryFeed, } from "./feedEngine/index.js"; import watchHistoryService from "./watchHistoryService.js"; import r2Service from "./services/r2Service.js"; @@ -186,6 +187,7 @@ class Application { } this.registerRecentFeed(); this.registerSubscriptionsFeed(); + this.registerWatchHistoryFeed(); this.playbackService = services.playbackService || @@ -4997,6 +4999,30 @@ class Application { } } + registerWatchHistoryFeed() { + if (!this.feedEngine || typeof this.feedEngine.registerFeed !== "function") { + return null; + } + + const existingDefinition = + typeof this.feedEngine.getFeedDefinition === "function" + ? this.feedEngine.getFeedDefinition("watch-history") + : null; + if (existingDefinition) { + return existingDefinition; + } + + try { + return registerWatchHistoryFeed(this.feedEngine, { + service: watchHistoryService, + nostr: this.nostrService, + }); + } catch (error) { + console.warn("[Application] Failed to register watch history feed:", error); + return null; + } + } + buildRecentFeedRuntime() { const blacklist = this.blacklistedEventIds instanceof Set diff --git a/js/feedEngine/index.js b/js/feedEngine/index.js index f6ed14f7..d7c968c2 100644 --- a/js/feedEngine/index.js +++ b/js/feedEngine/index.js @@ -16,3 +16,4 @@ export { createSubscriptionAuthorsSource, createWatchHistoryPointerSource, } from "./sources.js"; +export { createWatchHistoryFeedDefinition, registerWatchHistoryFeed } from "./watchHistoryFeed.js"; diff --git a/js/feedEngine/watchHistoryFeed.js b/js/feedEngine/watchHistoryFeed.js new file mode 100644 index 00000000..3b514b37 --- /dev/null +++ b/js/feedEngine/watchHistoryFeed.js @@ -0,0 +1,342 @@ +// js/feedEngine/watchHistoryFeed.js + +import { pointerKey, normalizePointerInput } from "../nostr.js"; +import watchHistoryService from "../watchHistoryService.js"; +import nostrService from "../services/nostrService.js"; +import { createWatchHistoryMetadataResolver } from "../watchHistoryMetadata.js"; +import { + createBlacklistFilterStage, + createWatchHistorySuppressionStage, +} from "./stages.js"; +import { isPlainObject } from "./utils.js"; + +function normalizeActorCandidate(...values) { + for (const value of values) { + if (typeof value === "string") { + const trimmed = value.trim(); + if (trimmed) { + return trimmed; + } + } + } + return ""; +} + +function resolveWatchedAt(...candidates) { + for (const candidate of candidates) { + if (!Number.isFinite(candidate)) { + continue; + } + let normalized = Math.floor(Number(candidate)); + if (normalized <= 0) { + continue; + } + if (normalized > 10_000_000_000) { + normalized = Math.floor(normalized / 1000); + } + if (normalized > 0) { + return normalized; + } + } + return 0; +} + +function resolveResumeSeconds(candidate) { + const candidates = [ + candidate?.resumeSeconds, + candidate?.resume, + candidate?.resumeAt, + candidate?.resumePosition, + candidate?.resumeTime, + candidate?.progress?.resume, + candidate?.progress?.seconds, + candidate?.progress?.position, + candidate?.pointer?.resumeSeconds, + candidate?.pointer?.resume, + candidate?.pointer?.resumeAt, + ]; + + for (const entry of candidates) { + if (!Number.isFinite(entry)) { + continue; + } + const normalized = Math.max(0, Math.floor(entry)); + if (normalized > 0) { + return normalized; + } + } + return null; +} + +function resolveCompleted(candidate) { + const candidates = [ + candidate?.completed, + candidate?.complete, + candidate?.progress?.completed, + candidate?.progress?.complete, + candidate?.pointer?.completed, + candidate?.pointer?.complete, + ]; + return candidates.some((value) => value === true); +} + +function normalizeHistoryEntry(raw) { + const pointer = normalizePointerInput(raw?.pointer || raw); + if (!pointer) { + return null; + } + + const key = pointerKey(pointer); + if (!key) { + return null; + } + + const watchedAt = resolveWatchedAt( + raw?.watchedAt, + raw?.timestamp, + raw?.created_at, + pointer?.watchedAt, + ); + + const resumeAt = resolveResumeSeconds(raw); + const completed = resolveCompleted(raw); + + return { + pointer, + pointerKey: key, + watchedAt, + video: isPlainObject(raw?.video) ? raw.video : null, + metadata: { + source: "watch-history", + pointerKey: key, + watchedAt, + resumeAt, + completed, + session: raw?.session === true || pointer.session === true, + }, + }; +} + +function createWatchHistorySource({ service = watchHistoryService } = {}) { + return async function watchHistorySource(context = {}) { + const actor = normalizeActorCandidate( + context?.runtime?.watchHistory?.actor, + context?.config?.actor, + context?.runtime?.actor, + ); + + if (!service || typeof service.loadLatest !== "function") { + return []; + } + + let items = []; + try { + items = await Promise.resolve(service.loadLatest(actor || undefined)); + } catch (error) { + context?.log?.("[watch-history-feed] Failed to load history", error); + items = []; + } + + const map = new Map(); + for (const raw of Array.isArray(items) ? items : []) { + const normalized = normalizeHistoryEntry(raw); + if (!normalized) { + continue; + } + const existing = map.get(normalized.pointerKey); + if (!existing || existing.watchedAt < normalized.watchedAt) { + map.set(normalized.pointerKey, normalized); + } + } + + const results = Array.from(map.values()); + results.sort((a, b) => { + if (a.watchedAt !== b.watchedAt) { + return b.watchedAt - a.watchedAt; + } + return a.pointerKey.localeCompare(b.pointerKey); + }); + + return results; + }; +} + +function createWatchHistoryHydratorStage({ + service = watchHistoryService, + metadataResolver, +} = {}) { + const resolver = metadataResolver || createWatchHistoryMetadataResolver(); + + return async function watchHistoryHydratorStage(items = [], context = {}) { + const shouldStoreMetadata = + typeof service?.shouldStoreMetadata === "function" + ? service.shouldStoreMetadata() !== false + : true; + + const results = []; + + for (const item of Array.isArray(items) ? items : []) { + if (!item || typeof item !== "object") { + continue; + } + + const pointerKeyValue = + typeof item.pointerKey === "string" && item.pointerKey + ? item.pointerKey + : pointerKey(item.pointer); + + const watchedAtValue = Number.isFinite(item?.watchedAt) + ? Math.max(0, Math.floor(Number(item.watchedAt))) + : Number.isFinite(item?.metadata?.watchedAt) + ? Math.max(0, Math.floor(Number(item.metadata.watchedAt))) + : 0; + + let video = item.video || null; + let profile = item.metadata?.profile || null; + + if (pointerKeyValue && typeof service?.getLocalMetadata === "function") { + try { + const stored = service.getLocalMetadata(pointerKeyValue); + if (stored) { + video = video || stored.video || null; + profile = profile || stored.profile || null; + } + } catch (error) { + context?.log?.( + "[watch-history-feed] Failed to read cached metadata", + error, + ); + } + } + + if (!video && resolver?.resolveVideo) { + try { + video = await resolver.resolveVideo(item.pointer); + } catch (error) { + context?.log?.( + "[watch-history-feed] Failed to resolve video from pointer", + error, + ); + } + } + + if (video?.pubkey && !profile && resolver?.resolveProfile) { + try { + profile = resolver.resolveProfile(video.pubkey) || null; + } catch (error) { + context?.log?.( + "[watch-history-feed] Failed to resolve profile for pointer", + error, + ); + } + } + + const metadata = { + ...(isPlainObject(item.metadata) ? item.metadata : {}), + pointerKey: pointerKeyValue, + watchedAt: watchedAtValue || null, + resumeAt: item.metadata?.resumeAt ?? null, + completed: item.metadata?.completed ?? false, + session: item.metadata?.session === true, + video: video || null, + profile: profile || null, + }; + + const nextItem = { + ...item, + pointerKey: pointerKeyValue, + watchedAt: watchedAtValue, + video: video || null, + metadata, + }; + results.push(nextItem); + + if (pointerKeyValue) { + if (shouldStoreMetadata) { + try { + service?.setLocalMetadata?.(pointerKeyValue, { + video: metadata.video, + profile: metadata.profile, + }); + } catch (error) { + context?.log?.( + "[watch-history-feed] Failed to persist metadata cache", + error, + ); + } + } else if (typeof service?.removeLocalMetadata === "function") { + try { + service.removeLocalMetadata(pointerKeyValue); + } catch (error) { + context?.log?.( + "[watch-history-feed] Failed to clear cached metadata", + error, + ); + } + } + } + } + + return results; + }; +} + +function createWatchHistorySorter() { + return function watchHistorySorter(items = []) { + if (!Array.isArray(items)) { + return []; + } + const copy = [...items]; + copy.sort((a, b) => { + if (a?.watchedAt !== b?.watchedAt) { + return (b?.watchedAt || 0) - (a?.watchedAt || 0); + } + const aKey = typeof a?.pointerKey === "string" ? a.pointerKey : ""; + const bKey = typeof b?.pointerKey === "string" ? b.pointerKey : ""; + return aKey.localeCompare(bKey); + }); + return copy; + }; +} + +export function createWatchHistoryFeedDefinition({ + service = watchHistoryService, + nostr = nostrService, + metadataResolverFactory = createWatchHistoryMetadataResolver, + shouldIncludeVideo, +} = {}) { + const metadataResolver = metadataResolverFactory({ nostr }); + + const blacklistStage = createBlacklistFilterStage({ + shouldIncludeVideo: + typeof shouldIncludeVideo === "function" + ? shouldIncludeVideo + : (video, options) => + (nostr?.shouldIncludeVideo + ? nostr.shouldIncludeVideo(video, options) + : true), + }); + + return { + source: createWatchHistorySource({ service }), + stages: [ + createWatchHistoryHydratorStage({ service, metadataResolver }), + blacklistStage, + createWatchHistorySuppressionStage(), + ], + sorter: createWatchHistorySorter(), + defaultConfig: { + actorFilters: [], + }, + }; +} + +export function registerWatchHistoryFeed(engine, options = {}) { + if (!engine || typeof engine.registerFeed !== "function") { + return null; + } + + const definition = createWatchHistoryFeedDefinition(options); + return engine.registerFeed("watch-history", definition); +} + diff --git a/js/historyView.js b/js/historyView.js index ac567410..4ea51288 100644 --- a/js/historyView.js +++ b/js/historyView.js @@ -7,7 +7,6 @@ import { nostrClient, updateWatchHistoryList, } from "./nostr.js"; -import nostrService from "./services/nostrService.js"; import { WATCH_HISTORY_BATCH_RESOLVE, WATCH_HISTORY_BATCH_PAGE_SIZE, @@ -83,6 +82,34 @@ function getAppInstance() { return getApplication(); } +function buildWatchHistoryFeedRuntime({ actor, cursor = 0 } = {}) { + const app = getAppInstance(); + const normalizedActor = + typeof actor === "string" && actor.trim() ? actor.trim() : ""; + + const blacklist = + app?.blacklistedEventIds instanceof Set + ? new Set(app.blacklistedEventIds) + : new Set(); + + const runtime = { + blacklistedEventIds: blacklist, + isAuthorBlocked: (pubkey) => + (typeof app?.isAuthorBlocked === "function" && app.isAuthorBlocked(pubkey)) || + false, + watchHistory: { + actor: normalizedActor, + cursor: Number.isFinite(cursor) ? cursor : 0, + }, + }; + + if (normalizedActor) { + runtime.actor = normalizedActor; + } + + return runtime; +} + function safeLocaleDate(date) { try { return date.toLocaleDateString(undefined, { @@ -311,171 +338,6 @@ function getPointerVideoId(video, pointer) { return ""; } -async function resolveVideoFromPointer(pointer, caches) { - if (!pointer) { - return null; - } - const app = getAppInstance(); - const type = pointer.type === "a" ? "a" : "e"; - const value = typeof pointer.value === "string" ? pointer.value.trim() : ""; - if (!value) { - return null; - } - - const normalizeVideo = (video) => { - if (!video || typeof video !== "object") { - return null; - } - if (video.deleted) { - return null; - } - return video; - }; - - if (type === "e") { - const fromCache = app?.videosMap?.get(value); - if (fromCache) { - const normalized = normalizeVideo(fromCache); - if (normalized) { - return normalized; - } - } - if (nostrClient?.allEvents instanceof Map) { - const event = nostrClient.allEvents.get(value); - if (event) { - const normalized = normalizeVideo(event); - if (normalized) { - return normalized; - } - } - } - if (typeof app?.getOldEventById === "function") { - try { - const fetched = await app.getOldEventById(value); - if (fetched) { - const normalized = normalizeVideo(fetched); - if (normalized) { - return normalized; - } - } - } catch (error) { - if (isDevEnv) { - console.warn("[historyView] Failed to load video via app cache:", error); - } - } - } - if (typeof nostrClient?.getEventById === "function") { - try { - const fetched = await nostrClient.getEventById(value); - const normalized = normalizeVideo(fetched); - if (normalized) { - return normalized; - } - } catch (error) { - if (isDevEnv) { - console.warn("[historyView] Failed to fetch event by id:", error); - } - } - } - return null; - } - - const compareAddress = (video) => { - if (!video || typeof video !== "object") { - return false; - } - if (typeof app?.getVideoAddressPointer !== "function") { - return false; - } - const address = app.getVideoAddressPointer(video); - return typeof address === "string" && address.trim() === value; - }; - - if (app?.videosMap instanceof Map) { - for (const cached of app.videosMap.values()) { - if (compareAddress(cached)) { - const normalized = normalizeVideo(cached); - if (normalized) { - return normalized; - } - } - } - } - - const filterOptions = { - blacklistedEventIds: app?.blacklistedEventIds, - isAuthorBlocked: (pubkey) => - (typeof app?.isAuthorBlocked === "function" && app.isAuthorBlocked(pubkey)) || false, - }; - - const activeVideos = Array.isArray(caches?.activeVideos) - ? caches.activeVideos - : nostrService.getFilteredActiveVideos(filterOptions); - for (const candidate of activeVideos) { - if (compareAddress(candidate)) { - const normalized = normalizeVideo(candidate); - if (normalized) { - return normalized; - } - } - } - - if (!caches?.catalogPromise) { - caches.catalogPromise = nostrService - .fetchVideos(filterOptions) - .catch((error) => { - if (isDevEnv) { - console.warn("[historyView] Failed to fetch video catalog:", error); - } - return []; - }); - } - if (caches?.catalogPromise) { - try { - const catalog = await caches.catalogPromise; - if (Array.isArray(catalog)) { - for (const candidate of catalog) { - if (compareAddress(candidate)) { - const normalized = normalizeVideo(candidate); - if (normalized) { - return normalized; - } - } - } - } - } catch (error) { - if (isDevEnv) { - console.warn("[historyView] Failed to search catalog for pointer:", error); - } - } - } - - return null; -} - -function resolveProfileForPubkey(pubkey) { - if (typeof pubkey !== "string" || !pubkey.trim()) { - return null; - } - const app = getAppInstance(); - if (!app) { - return null; - } - try { - const cacheEntry = app.getProfileCacheEntry - ? app.getProfileCacheEntry(pubkey) - : null; - if (cacheEntry && typeof cacheEntry === "object") { - return cacheEntry.profile || null; - } - } catch (error) { - if (isDevEnv) { - console.warn("[historyView] Failed to read profile cache:", error); - } - } - return null; -} - function buildHistoryCard({ item, video, @@ -700,7 +562,20 @@ function buildHistoryCard({ export function createWatchHistoryRenderer(config = {}) { const { - fetchHistory = watchHistoryService.loadLatest.bind(watchHistoryService), + fetchHistory = async (actorInput, { cursor = 0 } = {}) => { + const app = getAppInstance(); + const engine = app?.feedEngine; + if (engine && typeof engine.run === "function") { + const runtime = buildWatchHistoryFeedRuntime({ + actor: actorInput, + cursor, + }); + return engine.run("watch-history", { runtime }); + } + const items = await watchHistoryService.loadLatest(actorInput); + const normalized = normalizeHistoryItems(items); + return { items: normalized, metadata: { engine: "service-fallback" } }; + }, snapshot = watchHistoryService.snapshot.bind(watchHistoryService), getActor, viewSelector = "#watchHistoryView", @@ -766,10 +641,7 @@ export function createWatchHistoryRenderer(config = {}) { metadataPreference: "encrypted-only", privacyDismissed: false, metadataCache: new Map(), - catalogCaches: { - activeVideos: null, - catalogPromise: null, - }, + feedMetadata: null, metadataStorageEnabled: typeof watchHistoryService.shouldStoreMetadata === "function" ? watchHistoryService.shouldStoreMetadata() !== false @@ -1225,31 +1097,21 @@ export function createWatchHistoryRenderer(config = {}) { async function hydrateBatch(batch) { const hydrated = []; - const caches = state.catalogCaches; - if (!caches.activeVideos && typeof nostrClient?.getActiveVideos === "function") { - caches.activeVideos = nostrClient.getActiveVideos(); - } for (const item of batch) { if (!item) { continue; } - if (state.metadataCache.has(item.pointerKey)) { - hydrated.push({ ...item, metadata: state.metadataCache.get(item.pointerKey) }); - continue; - } - const pointerKeyValue = item.pointerKey; - let video = null; - let profile = null; - if ( - pointerKeyValue && - typeof watchHistoryService.getLocalMetadata === "function" - ) { + + const pointerKeyValue = item.pointerKey || null; + const baseMetadata = + item && typeof item === "object" && item.metadata && typeof item.metadata === "object" + ? { ...item.metadata } + : {}; + + let cached = pointerKeyValue ? state.metadataCache.get(pointerKeyValue) : null; + if (!cached && pointerKeyValue && typeof watchHistoryService.getLocalMetadata === "function") { try { - const stored = watchHistoryService.getLocalMetadata(pointerKeyValue); - if (stored) { - video = stored.video || null; - profile = stored.profile || null; - } + cached = watchHistoryService.getLocalMetadata(pointerKeyValue) || null; } catch (error) { if (isDevEnv) { console.warn( @@ -1260,28 +1122,32 @@ export function createWatchHistoryRenderer(config = {}) { } } } - try { - const resolvedVideo = await resolveVideoFromPointer(item.pointer, caches); - if (resolvedVideo) { - video = resolvedVideo; - } - } catch (error) { - if (isDevEnv) { - console.warn( - "[historyView] Failed to resolve video for pointer:", - error, - ); - } + + let video = item.video || baseMetadata.video || null; + let profile = baseMetadata.profile || null; + + if (cached) { + video = video || cached.video || null; + profile = profile || cached.profile || null; } - if (video?.pubkey) { - profile = resolveProfileForPubkey(video.pubkey) || profile; - } - const metadata = { video: video || null, profile: profile || null }; - state.metadataCache.set(pointerKeyValue, metadata); + + const metadata = { + ...baseMetadata, + video: video || null, + profile: profile || null, + }; + if (pointerKeyValue) { + state.metadataCache.set(pointerKeyValue, { + video: metadata.video, + profile: metadata.profile, + }); if (state.metadataStorageEnabled) { try { - watchHistoryService.setLocalMetadata?.(pointerKeyValue, metadata); + watchHistoryService.setLocalMetadata?.(pointerKeyValue, { + video: metadata.video, + profile: metadata.profile, + }); } catch (error) { if (isDevEnv) { console.warn( @@ -1291,9 +1157,9 @@ export function createWatchHistoryRenderer(config = {}) { ); } } - } else { + } else if (typeof watchHistoryService.removeLocalMetadata === "function") { try { - watchHistoryService.removeLocalMetadata?.(pointerKeyValue); + watchHistoryService.removeLocalMetadata(pointerKeyValue); } catch (error) { if (isDevEnv) { console.warn( @@ -1305,7 +1171,8 @@ export function createWatchHistoryRenderer(config = {}) { } } } - hydrated.push({ ...item, metadata }); + + hydrated.push({ ...item, video: metadata.video, metadata }); } return hydrated; } @@ -1653,9 +1520,9 @@ export function createWatchHistoryRenderer(config = {}) { : await resolveActorKey(); state.actor = actor; updateSessionFallbackWarning(); - let items = []; + let result = null; try { - items = await fetchHistory(actor); + result = await fetchHistory(actor, { cursor: 0 }); state.lastError = null; hideErrorBanner(); } catch (error) { @@ -1665,21 +1532,70 @@ export function createWatchHistoryRenderer(config = {}) { ? error.message : "Failed to load watch history from relays."; showErrorBanner(message); - items = []; + result = null; } setLoadingState(false); - const normalized = normalizeHistoryItems(items); + const feedItems = Array.isArray(result?.items) ? result.items : []; + const normalized = []; + state.metadataCache.clear(); + for (const entry of feedItems) { + const pointer = normalizePointerInput(entry?.pointer || entry); + if (!pointer) { + continue; + } + const pointerKeyValue = + typeof entry?.pointerKey === "string" && entry.pointerKey + ? entry.pointerKey + : pointerKey(pointer); + if (!pointerKeyValue) { + continue; + } + const watchedAtRaw = Number.isFinite(entry?.watchedAt) + ? entry.watchedAt + : Number.isFinite(entry?.metadata?.watchedAt) + ? entry.metadata.watchedAt + : 0; + const watchedAt = Math.max(0, Math.floor(Number(watchedAtRaw) || 0)); + const baseMetadata = + entry && typeof entry === "object" && entry.metadata && typeof entry.metadata === "object" + ? { ...entry.metadata } + : {}; + const video = entry?.video || baseMetadata.video || null; + const profile = baseMetadata.profile || null; + const metadata = { + ...baseMetadata, + pointerKey: pointerKeyValue, + watchedAt: Number.isFinite(baseMetadata.watchedAt) + ? baseMetadata.watchedAt + : watchedAt || null, + video: video || null, + profile: profile || null, + }; + state.metadataCache.set(pointerKeyValue, { + video: metadata.video, + profile: metadata.profile, + }); + normalized.push({ + pointer, + pointerKey: pointerKeyValue, + watchedAt, + video: metadata.video, + metadata, + }); + } + const fingerprint = computeFingerprint(normalized); if (!force && fingerprint && fingerprint === state.fingerprint) { return false; } state.items = normalized; + state.feedMetadata = + result && typeof result.metadata === "object" && result.metadata + ? { ...result.metadata } + : null; state.fingerprint = fingerprint; state.cursor = 0; state.hasMore = state.items.length > 0; - state.metadataCache.clear(); - state.catalogCaches.catalogPromise = null; - state.catalogCaches.activeVideos = null; return true; } @@ -1902,6 +1818,7 @@ export function createWatchHistoryRenderer(config = {}) { } const remainingItems = state.items.slice(); const actor = await resolveActorKey(); + let refreshNeeded = true; try { const handler = typeof remove === "function" @@ -1927,11 +1844,22 @@ export function createWatchHistoryRenderer(config = {}) { if (isDevEnv) { console.warn("[historyView] Removal failed:", error); } - await this.refresh(); } finally { if (parentDay instanceof HTMLElement) { removeEmptyDayContainers(elements.grid); } + if (refreshNeeded) { + try { + await this.refresh({ force: true }); + } catch (error) { + if (isDevEnv) { + console.warn( + "[historyView] Failed to refresh watch history after removal:", + error, + ); + } + } + } } }, async render() { diff --git a/js/watchHistoryMetadata.js b/js/watchHistoryMetadata.js new file mode 100644 index 00000000..1cb1cd2f --- /dev/null +++ b/js/watchHistoryMetadata.js @@ -0,0 +1,267 @@ +// js/watchHistoryMetadata.js + +import { getApplication } from "./applicationContext.js"; +import nostrService from "./services/nostrService.js"; +import { nostrClient } from "./nostr.js"; + +const isDevEnv = + typeof process !== "undefined" && process?.env?.NODE_ENV !== "production"; + +function normalizeVideoCandidate(candidate) { + if (!candidate || typeof candidate !== "object") { + return null; + } + if (candidate.deleted) { + return null; + } + return candidate; +} + +export function createWatchHistoryMetadataResolver({ + appResolver = getApplication, + nostr = nostrService, + client = nostrClient, +} = {}) { + const caches = { + activeVideos: null, + catalogPromise: null, + }; + + function getAppInstance() { + try { + return typeof appResolver === "function" ? appResolver() : getApplication(); + } catch (error) { + if (isDevEnv) { + console.warn( + "[watchHistoryMetadata] Failed to resolve application instance:", + error, + ); + } + return null; + } + } + + async function resolveVideo(pointer) { + if (!pointer || typeof pointer !== "object") { + return null; + } + + const app = getAppInstance(); + const type = pointer.type === "a" ? "a" : "e"; + const value = typeof pointer.value === "string" ? pointer.value.trim() : ""; + if (!value) { + return null; + } + + if (type === "e") { + const fromAppCache = app?.videosMap?.get?.(value); + const normalizedAppCache = normalizeVideoCandidate(fromAppCache); + if (normalizedAppCache) { + return normalizedAppCache; + } + + const fromClientCache = client?.allEvents instanceof Map + ? client.allEvents.get(value) + : null; + const normalizedClientCache = normalizeVideoCandidate(fromClientCache); + if (normalizedClientCache) { + return normalizedClientCache; + } + + if (typeof app?.getOldEventById === "function") { + try { + const fetched = await app.getOldEventById(value); + const normalized = normalizeVideoCandidate(fetched); + if (normalized) { + return normalized; + } + } catch (error) { + if (isDevEnv) { + console.warn( + "[watchHistoryMetadata] Failed to fetch old event by id:", + error, + ); + } + } + } + + if (typeof client?.getEventById === "function") { + try { + const fetched = await client.getEventById(value); + const normalized = normalizeVideoCandidate(fetched); + if (normalized) { + return normalized; + } + } catch (error) { + if (isDevEnv) { + console.warn( + "[watchHistoryMetadata] Failed to fetch event by id:", + error, + ); + } + } + } + + return null; + } + + const compareAddress = (candidate) => { + if (!candidate || typeof candidate !== "object") { + return false; + } + if (typeof app?.getVideoAddressPointer !== "function") { + return false; + } + try { + const address = app.getVideoAddressPointer(candidate); + return typeof address === "string" && address.trim() === value; + } catch (error) { + if (isDevEnv) { + console.warn( + "[watchHistoryMetadata] Failed to compute address pointer:", + error, + ); + } + } + return false; + }; + + if (app?.videosMap instanceof Map) { + for (const cached of app.videosMap.values()) { + if (compareAddress(cached)) { + const normalized = normalizeVideoCandidate(cached); + if (normalized) { + return normalized; + } + } + } + } + + const blacklist = app?.blacklistedEventIds instanceof Set + ? app.blacklistedEventIds + : undefined; + const filterOptions = { + blacklistedEventIds: blacklist, + isAuthorBlocked: (pubkey) => { + try { + return ( + typeof app?.isAuthorBlocked === "function" && + app.isAuthorBlocked(pubkey) + ); + } catch (error) { + if (isDevEnv) { + console.warn( + "[watchHistoryMetadata] Failed to evaluate isAuthorBlocked:", + error, + ); + } + return false; + } + }, + }; + + if (!Array.isArray(caches.activeVideos)) { + try { + caches.activeVideos = nostr.getFilteredActiveVideos + ? nostr.getFilteredActiveVideos(filterOptions) + : []; + } catch (error) { + if (isDevEnv) { + console.warn( + "[watchHistoryMetadata] Failed to read active videos cache:", + error, + ); + } + caches.activeVideos = []; + } + } + + for (const candidate of Array.isArray(caches.activeVideos) + ? caches.activeVideos + : []) { + if (compareAddress(candidate)) { + const normalized = normalizeVideoCandidate(candidate); + if (normalized) { + return normalized; + } + } + } + + if (!caches.catalogPromise && typeof nostr.fetchVideos === "function") { + caches.catalogPromise = nostr + .fetchVideos(filterOptions) + .catch((error) => { + if (isDevEnv) { + console.warn( + "[watchHistoryMetadata] Failed to fetch video catalog:", + error, + ); + } + return []; + }); + } + + if (caches.catalogPromise) { + try { + const catalog = await caches.catalogPromise; + for (const candidate of Array.isArray(catalog) ? catalog : []) { + if (compareAddress(candidate)) { + const normalized = normalizeVideoCandidate(candidate); + if (normalized) { + return normalized; + } + } + } + } catch (error) { + if (isDevEnv) { + console.warn( + "[watchHistoryMetadata] Failed to resolve catalog pointer:", + error, + ); + } + } + } + + return null; + } + + function resolveProfile(pubkey) { + if (typeof pubkey !== "string" || !pubkey.trim()) { + return null; + } + const app = getAppInstance(); + if (!app) { + return null; + } + try { + const cacheEntry = + typeof app.getProfileCacheEntry === "function" + ? app.getProfileCacheEntry(pubkey) + : null; + if (cacheEntry && typeof cacheEntry === "object") { + return cacheEntry.profile || null; + } + } catch (error) { + if (isDevEnv) { + console.warn( + "[watchHistoryMetadata] Failed to read profile cache entry:", + error, + ); + } + } + return null; + } + + function reset() { + caches.activeVideos = null; + caches.catalogPromise = null; + } + + return { + resolveVideo, + resolveProfile, + reset, + caches, + }; +} + diff --git a/tests/watch-history-feed.test.mjs b/tests/watch-history-feed.test.mjs new file mode 100644 index 00000000..102476d0 --- /dev/null +++ b/tests/watch-history-feed.test.mjs @@ -0,0 +1,132 @@ +// Run with: node tests/watch-history-feed.test.mjs + +import assert from "node:assert/strict"; + +if (typeof globalThis.window === "undefined") { + globalThis.window = {}; +} + +const noopStorage = { + getItem: () => null, + setItem: () => {}, + removeItem: () => {}, + clear: () => {}, +}; + +if (typeof globalThis.localStorage === "undefined") { + globalThis.localStorage = noopStorage; +} + +if (typeof window.localStorage === "undefined") { + window.localStorage = globalThis.localStorage; +} + +const { createFeedEngine, registerWatchHistoryFeed } = await import( + "../js/feedEngine/index.js" +); + +function createStubService(history = []) { + let loadCount = 0; + return { + get loadCount() { + return loadCount; + }, + loadLatest: async () => { + loadCount += 1; + return history.map((entry) => ({ ...entry })); + }, + setHistory(next) { + history = Array.isArray(next) ? next : []; + }, + shouldStoreMetadata: () => false, + getLocalMetadata: () => null, + setLocalMetadata: () => {}, + removeLocalMetadata: () => {}, + }; +} + +function createResolverFactory() { + return () => ({ + resolveVideo: async () => null, + resolveProfile: () => null, + }); +} + +async function testOrderingByWatchedAt() { + const service = createStubService([ + { + pointer: { type: "e", value: "earlier" }, + watchedAt: 100, + }, + { + pointer: { type: "e", value: "latest" }, + watchedAt: 200, + }, + ]); + + const engine = createFeedEngine(); + registerWatchHistoryFeed(engine, { + service, + nostr: { shouldIncludeVideo: () => true }, + metadataResolverFactory: createResolverFactory(), + }); + + const result = await engine.runFeed("watch-history", { + runtime: { watchHistory: { actor: "npub-order" } }, + }); + + assert.equal(result.items.length, 2, "feed should include two watch history entries"); + assert.equal( + result.items[0].metadata.pointerKey, + "e:latest", + "newest watch event should be first in the feed", + ); + assert.equal( + result.items[1].metadata.pointerKey, + "e:earlier", + "older watch event should be last in the feed", + ); + assert.ok( + result.items[0].metadata.watchedAt >= result.items[1].metadata.watchedAt, + "items should be sorted by watchedAt descending", + ); +} + +async function testRemovalRefreshesSource() { + const initialHistory = [ + { pointer: { type: "e", value: "keep" }, watchedAt: 300 }, + { pointer: { type: "e", value: "remove" }, watchedAt: 250 }, + ]; + const service = createStubService(initialHistory); + const engine = createFeedEngine(); + registerWatchHistoryFeed(engine, { + service, + nostr: { shouldIncludeVideo: () => true }, + metadataResolverFactory: createResolverFactory(), + }); + + const firstRun = await engine.runFeed("watch-history", { + runtime: { watchHistory: { actor: "npub-refresh" } }, + }); + assert.equal(firstRun.items.length, 2, "initial run should return full history"); + assert.equal(service.loadCount, 1, "loadLatest should be invoked for the initial run"); + + service.setHistory([{ pointer: { type: "e", value: "keep" }, watchedAt: 300 }]); + + const secondRun = await engine.runFeed("watch-history", { + runtime: { watchHistory: { actor: "npub-refresh" } }, + }); + + assert.equal(service.loadCount, 2, "engine should request fresh history after change"); + assert.equal(secondRun.items.length, 1, "second run should reflect updated history"); + assert.equal( + secondRun.items[0].metadata.pointerKey, + "e:keep", + "remaining pointer should persist after refresh", + ); +} + +await testOrderingByWatchedAt(); +await testRemovalRefreshesSource(); + +console.log("watch-history-feed.test.mjs completed successfully.");