diff --git a/js/app.js b/js/app.js index 00ea290f..e29aab5e 100644 --- a/js/app.js +++ b/js/app.js @@ -42,6 +42,7 @@ import WatchHistoryController from "./ui/watchHistoryController.js"; import WatchHistoryTelemetry from "./services/watchHistoryTelemetry.js"; import { getSidebarLoadingMarkup } from "./sidebarLoading.js"; import { subscriptions } from "./subscriptions.js"; +import { isWatchHistoryDebugEnabled } from "./watchHistoryDebug.js"; import { initViewCounter, subscribeToVideoViewCount, @@ -186,7 +187,38 @@ class Application { this.nostrService = services.nostrService || nostrService; this.r2Service = services.r2Service || r2Service; - this.feedEngine = services.feedEngine || createFeedEngine(); + + const feedEngineLogger = (...args) => { + if (!isWatchHistoryDebugEnabled()) { + return; + } + if (!Array.isArray(args) || args.length === 0) { + return; + } + const [prefix, ...rest] = args; + if (typeof prefix === "string" && !prefix.includes("watch-history")) { + return; + } + try { + if (rest.length) { + console.info(prefix, ...rest); + } else { + console.info(prefix); + } + } catch (error) { + if (rest.length) { + console.log(prefix, ...rest); + } else { + console.log(prefix); + } + } + }; + + this.feedEngine = + services.feedEngine || + createFeedEngine({ + logger: feedEngineLogger, + }); this.payments = services.payments || null; this.splitAndZap = (services.payments && services.payments.splitAndZap) || diff --git a/js/feedEngine/watchHistoryFeed.js b/js/feedEngine/watchHistoryFeed.js index 0f76d96a..b93d19c1 100644 --- a/js/feedEngine/watchHistoryFeed.js +++ b/js/feedEngine/watchHistoryFeed.js @@ -4,12 +4,26 @@ import { pointerKey, normalizePointerInput } from "../nostr.js"; import watchHistoryService from "../watchHistoryService.js"; import nostrService from "../services/nostrService.js"; import { createWatchHistoryMetadataResolver } from "../watchHistoryMetadata.js"; +import { + isWatchHistoryDebugEnabled, + logWatchHistoryDebug, +} from "../watchHistoryDebug.js"; import { createBlacklistFilterStage, createWatchHistorySuppressionStage, } from "./stages.js"; import { isPlainObject } from "./utils.js"; +const WATCH_HISTORY_FEED_LOG_NAMESPACE = "watchHistoryFeed"; + +function debugInfo(message, details) { + logWatchHistoryDebug(WATCH_HISTORY_FEED_LOG_NAMESPACE, "info", message, details); +} + +function debugWarn(message, details) { + logWatchHistoryDebug(WATCH_HISTORY_FEED_LOG_NAMESPACE, "warn", message, details); +} + function normalizeActorCandidate(...values) { for (const value of values) { if (typeof value === "string") { @@ -213,6 +227,11 @@ function createWatchHistoryHydratorStage({ ? item.pointerKey : pointerKey(item.pointer); + const pointerType = + typeof item?.pointer?.type === "string" ? item.pointer.type : null; + const pointerValue = + typeof item?.pointer?.value === "string" ? item.pointer.value : null; + const watchedAtValue = Number.isFinite(item?.watchedAt) ? Math.max(0, Math.floor(Number(item.watchedAt))) : Number.isFinite(item?.metadata?.watchedAt) @@ -222,40 +241,133 @@ function createWatchHistoryHydratorStage({ let video = item.video || null; let profile = item.metadata?.profile || null; + if (isWatchHistoryDebugEnabled()) { + debugInfo("Hydrating watch history item.", { + pointerKey: pointerKeyValue || null, + pointerType, + pointerValue, + hasInitialVideo: Boolean(video), + hasInitialProfile: Boolean(profile), + watchedAt: watchedAtValue || null, + }); + } + if (pointerKeyValue && typeof service?.getLocalMetadata === "function") { try { const stored = service.getLocalMetadata(pointerKeyValue); if (stored) { + const hadVideo = Boolean(video); + const hadProfile = Boolean(profile); video = video || stored.video || null; profile = profile || stored.profile || null; + if (isWatchHistoryDebugEnabled()) { + debugInfo("Loaded cached metadata for pointer.", { + pointerKey: pointerKeyValue, + hadVideo, + hadProfile, + cachedVideo: Boolean(stored.video), + cachedProfile: Boolean(stored.profile), + resultingVideo: Boolean(video), + resultingProfile: Boolean(profile), + }); + } } } catch (error) { context?.log?.( "[watch-history-feed] Failed to read cached metadata", error, ); + if (isWatchHistoryDebugEnabled()) { + debugWarn("Failed to read cached metadata for pointer.", { + pointerKey: pointerKeyValue || null, + error, + }); + } } } if (!video && resolver?.resolveVideo) { + if (isWatchHistoryDebugEnabled()) { + debugInfo("Resolving video via metadata resolver.", { + pointerKey: pointerKeyValue || null, + pointerType, + pointerValue, + }); + } try { video = await resolver.resolveVideo(item.pointer); + if (isWatchHistoryDebugEnabled()) { + if (video) { + debugInfo("Metadata resolver returned video candidate.", { + pointerKey: pointerKeyValue || null, + videoId: typeof video.id === "string" ? video.id : null, + title: + typeof video.title === "string" && video.title.trim() + ? video.title + : null, + invalid: Boolean(video.invalid), + }); + } else { + debugWarn("Metadata resolver returned no video candidate.", { + pointerKey: pointerKeyValue || null, + }); + } + } } catch (error) { context?.log?.( "[watch-history-feed] Failed to resolve video from pointer", error, ); + if (isWatchHistoryDebugEnabled()) { + debugWarn("Metadata resolver threw while resolving video.", { + pointerKey: pointerKeyValue || null, + error, + }); + } } } if (video?.pubkey && !profile && resolver?.resolveProfile) { + if (isWatchHistoryDebugEnabled()) { + debugInfo("Resolving profile for video author.", { + pointerKey: pointerKeyValue || null, + pubkey: video.pubkey, + }); + } try { profile = resolver.resolveProfile(video.pubkey) || null; + if (isWatchHistoryDebugEnabled()) { + if (profile) { + const label = + profile.display_name || + profile.name || + profile.username || + profile.nip05 || + null; + debugInfo("Resolved profile for video author.", { + pointerKey: pointerKeyValue || null, + pubkey: video.pubkey, + label: label || null, + }); + } else { + debugWarn("Resolver returned no profile for video author.", { + pointerKey: pointerKeyValue || null, + pubkey: video.pubkey, + }); + } + } } catch (error) { context?.log?.( "[watch-history-feed] Failed to resolve profile for pointer", error, ); + if (isWatchHistoryDebugEnabled()) { + debugWarn("Profile resolver threw while hydrating metadata.", { + pointerKey: pointerKeyValue || null, + pubkey: video.pubkey, + error, + }); + } } } @@ -270,6 +382,27 @@ function createWatchHistoryHydratorStage({ profile: profile || null, }; + if (isWatchHistoryDebugEnabled()) { + const summary = { + pointerKey: pointerKeyValue || null, + pointerType, + pointerValue, + watchedAt: watchedAtValue || null, + videoId: typeof video?.id === "string" ? video.id : null, + title: + typeof video?.title === "string" && video.title?.trim() + ? video.title + : null, + invalid: Boolean(video?.invalid), + hasProfile: Boolean(profile), + }; + if (video) { + debugInfo("Hydrated watch history entry metadata.", summary); + } else { + debugWarn("Hydrated entry is missing resolved video metadata.", summary); + } + } + const nextItem = { ...item, pointerKey: pointerKeyValue, @@ -286,20 +419,44 @@ function createWatchHistoryHydratorStage({ video: metadata.video, profile: metadata.profile, }); + if (isWatchHistoryDebugEnabled()) { + debugInfo("Stored metadata cache entry for pointer.", { + pointerKey: pointerKeyValue, + cachedVideo: Boolean(metadata.video), + cachedProfile: Boolean(metadata.profile), + }); + } } catch (error) { context?.log?.( "[watch-history-feed] Failed to persist metadata cache", error, ); + if (isWatchHistoryDebugEnabled()) { + debugWarn("Failed to persist metadata cache entry.", { + pointerKey: pointerKeyValue, + error, + }); + } } } else if (typeof service?.removeLocalMetadata === "function") { try { service.removeLocalMetadata(pointerKeyValue); + if (isWatchHistoryDebugEnabled()) { + debugInfo("Cleared metadata cache entry for pointer.", { + pointerKey: pointerKeyValue, + }); + } } catch (error) { context?.log?.( "[watch-history-feed] Failed to clear cached metadata", error, ); + if (isWatchHistoryDebugEnabled()) { + debugWarn("Failed to clear cached metadata entry.", { + pointerKey: pointerKeyValue, + error, + }); + } } } } diff --git a/js/watchHistoryDebug.js b/js/watchHistoryDebug.js new file mode 100644 index 00000000..64c7e5f8 --- /dev/null +++ b/js/watchHistoryDebug.js @@ -0,0 +1,203 @@ +// js/watchHistoryDebug.js + +const WATCH_HISTORY_DEBUG_STORAGE_KEY = + "bitvid:debug:watch-history"; + +const globalScope = + typeof globalThis !== "undefined" + ? globalThis + : typeof window !== "undefined" + ? window + : typeof self !== "undefined" + ? self + : null; + +const TRUTHY_VALUES = new Set([ + "1", + "true", + "yes", + "on", + "enable", + "enabled", + "debug", + "verbose", + "watch", +]); + +const FALSY_VALUES = new Set([ + "0", + "false", + "no", + "off", + "disable", + "disabled", + "quiet", + "silent", +]); + +function parseFlagValue(value) { + if (value === null || value === undefined) { + return true; + } + if (typeof value === "boolean") { + return value; + } + if (typeof value !== "string") { + return null; + } + const normalized = value.trim().toLowerCase(); + if (!normalized) { + return true; + } + if (TRUTHY_VALUES.has(normalized)) { + return true; + } + if (FALSY_VALUES.has(normalized)) { + return false; + } + return null; +} + +function safeReadStorageFlag(storage) { + if (!storage || typeof storage.getItem !== "function") { + return null; + } + try { + const raw = storage.getItem(WATCH_HISTORY_DEBUG_STORAGE_KEY); + if (raw === null || raw === undefined) { + return null; + } + return parseFlagValue(String(raw)); + } catch (error) { + return null; + } +} + +function safeWriteStorageFlag(storage, value) { + if (!storage || typeof storage.setItem !== "function") { + return; + } + try { + storage.setItem(WATCH_HISTORY_DEBUG_STORAGE_KEY, value ? "true" : "false"); + } catch (error) { + // Ignore storage errors (e.g., quota exceeded, private mode restrictions). + } +} + +function persistFlag(scope, value) { + if (value === null) { + return; + } + safeWriteStorageFlag(scope?.sessionStorage, value); + safeWriteStorageFlag(scope?.localStorage, value); +} + +function readFlagFromUrl(scope) { + if (!scope || typeof scope.location?.search !== "string") { + return null; + } + const search = scope.location.search; + if (!search) { + return null; + } + try { + const params = new URLSearchParams(search); + if (!params.has("watchHistoryDebug")) { + return null; + } + const parsed = parseFlagValue(params.get("watchHistoryDebug")); + if (parsed === null) { + return null; + } + persistFlag(scope, parsed); + return parsed; + } catch (error) { + return null; + } +} + +function resolveExplicitFlag(scope) { + if (!scope) { + return null; + } + + const runtimeFlag = scope.__BITVID_ENABLE_WATCH_HISTORY_DEBUG__; + if (runtimeFlag === true || runtimeFlag === false) { + return Boolean(runtimeFlag); + } + + const altRuntimeFlag = scope.BITVID_WATCH_HISTORY_DEBUG; + if (altRuntimeFlag === true || altRuntimeFlag === false) { + return Boolean(altRuntimeFlag); + } + + const urlFlag = readFlagFromUrl(scope); + if (urlFlag !== null) { + return urlFlag; + } + + const sessionFlag = safeReadStorageFlag(scope.sessionStorage); + if (sessionFlag !== null) { + return sessionFlag; + } + + const localFlag = safeReadStorageFlag(scope.localStorage); + if (localFlag !== null) { + return localFlag; + } + + return null; +} + +export function isWatchHistoryDebugEnabled() { + const scope = globalScope; + const explicitFlag = resolveExplicitFlag(scope); + if (explicitFlag !== null) { + return explicitFlag; + } + + if (typeof process !== "undefined" && process?.env?.NODE_ENV) { + return process.env.NODE_ENV !== "production"; + } + + return false; +} + +export function logWatchHistoryDebug(namespace, level, message, details) { + if (!isWatchHistoryDebugEnabled()) { + return; + } + + const scopeConsole = + (globalScope && globalScope.console) || (typeof console !== "undefined" ? console : null); + if (!scopeConsole) { + return; + } + + const methodName = + typeof level === "string" && level && typeof scopeConsole[level] === "function" + ? level + : typeof scopeConsole.info === "function" + ? "info" + : "log"; + + const prefix = namespace ? `[${namespace}] ${message}` : message; + + try { + if (details && typeof details === "object") { + scopeConsole[methodName](prefix, details); + } else { + scopeConsole[methodName](prefix); + } + } catch (error) { + try { + if (details && typeof details === "object") { + scopeConsole.log(prefix, details); + } else { + scopeConsole.log(prefix); + } + } catch (fallbackError) { + // Swallow logging errors entirely. + } + } +} diff --git a/js/watchHistoryMetadata.js b/js/watchHistoryMetadata.js index 742e5cbc..1efda14d 100644 --- a/js/watchHistoryMetadata.js +++ b/js/watchHistoryMetadata.js @@ -3,30 +3,14 @@ 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"; +import { logWatchHistoryDebug } from "./watchHistoryDebug.js"; function logInfo(message, details) { - if (!isDevEnv) { - return; - } - if (details && typeof details === "object") { - console.info(`[watchHistoryMetadata] ${message}`, details); - } else { - console.info(`[watchHistoryMetadata] ${message}`); - } + logWatchHistoryDebug("watchHistoryMetadata", "info", message, details); } function logWarn(message, details) { - if (!isDevEnv) { - return; - } - if (details && typeof details === "object") { - console.warn(`[watchHistoryMetadata] ${message}`, details); - } else { - console.warn(`[watchHistoryMetadata] ${message}`); - } + logWatchHistoryDebug("watchHistoryMetadata", "warn", message, details); } function normalizeVideoCandidate(candidate) {