Files
bitvid/js/watchHistoryService.js
T
google-labs-jules[bot]andPR0M3TH3AN c47392aba6 Refactor numeric constants to centralized module
- Introduce `js/constants.js` with semantic time constants (e.g., `ONE_MINUTE_MS`, `STANDARD_TIMEOUT_MS`) and feature-specific TTLs.
- Export `KIND_MUTE_LIST` from `js/nostrEventSchemas.js` to standardize NIP-51 mute list kind usage.
- Refactor `js/state/cache.js`, `js/nostr/cachePolicies.js`, `js/channelProfile.js`, and various services to use centralized constants instead of hardcoded magic numbers.
- Update `js/nostr/relayConstants.js` to derive values from the shared time constants.
- Replace duplicate definitions of timeouts and retry delays across the codebase.

Co-authored-by: PR0M3TH3AN <[email protected]>
2026-02-11 03:27:26 +00:00

1396 lines
35 KiB
JavaScript

// js/watchHistoryService.js
import {
getActiveSigner,
nostrClient,
requestDefaultExtensionPermissions,
} from "./nostrClientFacade.js";
import {
normalizePointerInput,
pointerKey,
normalizeActorKey,
} from "./nostr/watchHistory.js";
import {
WATCH_HISTORY_CACHE_TTL_MS,
WATCH_HISTORY_MAX_ITEMS,
} from "./config.js";
import { getApplication } from "./applicationContext.js";
import { devLogger, userLogger } from "./utils/logger.js";
import { ONE_MINUTE_MS } from "./constants.js";
const LOCAL_STORAGE_QUEUE_KEY = "bitvid:watch-history:queue:v1";
const SESSION_STORAGE_VERSION = 1;
const POINTER_THROTTLE_MS = ONE_MINUTE_MS;
const state = {
restored: false,
queues: new Map(),
inflightSnapshots: new Map(),
fingerprintCache: new Map(),
listeners: new Map(),
};
function getLoggedInActorKey() {
const direct = normalizeActorKey(nostrClient?.pubkey);
if (direct) {
return direct;
}
if (typeof window !== "undefined") {
const appCandidate =
getApplication() || null;
if (appCandidate && typeof appCandidate === "object") {
if (typeof appCandidate.normalizeHexPubkey === "function") {
try {
const normalized = appCandidate.normalizeHexPubkey(
appCandidate.pubkey
);
if (normalized) {
return normalizeActorKey(normalized);
}
} catch (error) {
devLogger.warn(
"[watchHistoryService] Failed to normalize app login pubkey:",
error
);
}
}
if (typeof appCandidate.pubkey === "string" && appCandidate.pubkey) {
const fallback = normalizeActorKey(appCandidate.pubkey);
if (fallback) {
return fallback;
}
}
}
}
return "";
}
function getSessionActorKey() {
const logged = getLoggedInActorKey();
if (logged) {
return "";
}
return normalizeActorKey(nostrClient?.sessionActor?.pubkey);
}
async function ensureWatchHistoryExtensionPermissions(actorKey, options = {}) {
const normalizedActor = normalizeActorKey(actorKey);
if (!normalizedActor) {
return { ok: true };
}
const loggedActor = normalizeActorKey(nostrClient?.pubkey);
if (!loggedActor || loggedActor !== normalizedActor) {
return { ok: true };
}
const allowPermissionPrompt = options?.allowPermissionPrompt !== false;
let signer = getActiveSigner();
if (
allowPermissionPrompt &&
(!signer && typeof nostrClient?.ensureActiveSignerForPubkey === "function")
) {
signer = await nostrClient.ensureActiveSignerForPubkey(normalizedActor);
}
const canSign = typeof signer?.canSign === "function"
? signer.canSign()
: typeof signer?.signEvent === "function";
if (!canSign || signer?.type !== "extension") {
return { ok: true };
}
if (!allowPermissionPrompt) {
return { ok: true };
}
const permissionResult = await requestDefaultExtensionPermissions();
if (permissionResult.ok) {
return { ok: true };
}
const message =
"Approve your NIP-07 extension to sync watch history.";
const error = new Error(message);
error.code = "watch-history-extension-permission-denied";
error.cause = permissionResult.error;
userLogger.warn(
"[watchHistoryService] Extension denied decrypt permission required for watch history.",
{
actor: normalizeActorKey(actorKey) || null,
error: permissionResult.error,
},
);
return { ok: false, error };
}
function resolveEffectiveActorKey(actorInput) {
const supplied =
typeof actorInput === "string" && actorInput.trim()
? normalizeActorKey(actorInput)
: "";
if (supplied) {
return supplied;
}
const logged = getLoggedInActorKey();
if (logged) {
return logged;
}
const session = getSessionActorKey();
if (session) {
return session;
}
return "";
}
function isFeatureEnabled(actorInput) {
const actorKey = resolveEffectiveActorKey(actorInput);
const session = getSessionActorKey();
if (actorKey && session && actorKey === session) {
return false;
}
return true;
}
function isLocalOnly(actorInput) {
const actorKey = resolveEffectiveActorKey(actorInput);
const session = getSessionActorKey();
if (!session) {
return false;
}
if (!actorKey) {
return true;
}
return actorKey === session;
}
function supportsLocalHistory(actorInput) {
return isLocalOnly(actorInput);
}
function getQueueStorage() {
try {
if (typeof window !== "undefined" && window.localStorage) {
return window.localStorage;
}
} catch (error) {
devLogger.warn("[watchHistoryService] localStorage unavailable for queue:", error);
}
return null;
}
function getLocalStorage() {
try {
if (typeof window !== "undefined" && window.localStorage) {
return window.localStorage;
}
} catch (error) {
devLogger.warn("[watchHistoryService] localStorage unavailable:", error);
}
return null;
}
function emit(eventName, payload) {
const listeners = state.listeners.get(eventName);
if (listeners && listeners.size) {
for (const callback of Array.from(listeners)) {
try {
callback(payload);
} catch (error) {
devLogger.warn(
`[watchHistoryService] Listener for ${eventName} failed:`,
error
);
}
}
}
const wildcard = state.listeners.get("*");
if (wildcard && wildcard.size) {
for (const callback of Array.from(wildcard)) {
try {
callback({ event: eventName, payload });
} catch (error) {
devLogger.warn(
`[watchHistoryService] Wildcard listener error for ${eventName}:`,
error
);
}
}
}
if (typeof window !== "undefined" && typeof window.dispatchEvent === "function") {
try {
const detail = { event: eventName, payload };
const evt = new CustomEvent("bitvid:watchHistory", { detail });
window.dispatchEvent(evt);
} catch (error) {
devLogger.warn(
`[watchHistoryService] Failed to dispatch window event for ${eventName}:`,
error
);
}
}
}
function subscribe(eventName, callback) {
if (typeof callback !== "function") {
return () => {};
}
if (!state.listeners.has(eventName)) {
state.listeners.set(eventName, new Set());
}
const listeners = state.listeners.get(eventName);
listeners.add(callback);
return () => {
listeners.delete(callback);
if (!listeners.size) {
state.listeners.delete(eventName);
}
};
}
function getOrCreateQueue(actorKey) {
let queue = state.queues.get(actorKey);
if (!queue) {
queue = {
items: new Map(),
throttle: new Map(),
lastSnapshotReason: null,
republishScheduled: false,
};
state.queues.set(actorKey, queue);
}
return queue;
}
function restoreQueueState() {
if (state.restored) {
return;
}
state.restored = true;
const storage = getQueueStorage();
if (!storage) {
return;
}
let raw = null;
try {
raw = storage.getItem(LOCAL_STORAGE_QUEUE_KEY);
} catch (error) {
devLogger.warn("[watchHistoryService] Failed to read queue storage:", error);
return;
}
if (!raw || typeof raw !== "string") {
return;
}
let parsed = null;
try {
parsed = JSON.parse(raw);
} catch (error) {
devLogger.warn("[watchHistoryService] Failed to parse queue storage:", error);
try {
storage.removeItem(LOCAL_STORAGE_QUEUE_KEY);
} catch (cleanupError) {
devLogger.warn(
"[watchHistoryService] Failed to clear corrupt queue storage:",
cleanupError
);
}
return;
}
if (!parsed || parsed.version !== SESSION_STORAGE_VERSION) {
try {
storage.removeItem(LOCAL_STORAGE_QUEUE_KEY);
} catch (cleanupError) {
devLogger.warn(
"[watchHistoryService] Failed to clear outdated queue storage:",
cleanupError
);
}
return;
}
const actors = parsed.actors;
if (!actors || typeof actors !== "object") {
return;
}
const now = Date.now();
for (const [actor, entry] of Object.entries(actors)) {
const actorKey = normalizeActorKey(actor);
if (!actorKey) {
continue;
}
const queue = getOrCreateQueue(actorKey);
queue.items.clear();
const items = Array.isArray(entry?.items) ? entry.items : [];
for (const candidate of items) {
const pointer = normalizePointerInput(candidate);
if (!pointer) {
continue;
}
const key = pointerKey(pointer);
if (!key) {
continue;
}
queue.items.set(key, {
pointer,
addedAt: now,
updatedAt: now,
});
}
if (
typeof entry?.lastSnapshotReason === "string" &&
entry.lastSnapshotReason
) {
queue.lastSnapshotReason = entry.lastSnapshotReason;
} else {
queue.lastSnapshotReason = null;
}
// If there are items in the queue, schedule a sync
if (queue.items.size > 0 && isFeatureEnabled(actorKey)) {
scheduleRepublishForQueue(actorKey);
}
}
}
function ensureQueue(actorKey) {
if (!actorKey) {
return null;
}
if (!state.restored) {
restoreQueueState();
}
return getOrCreateQueue(actorKey);
}
function persistQueueState() {
const storage = getQueueStorage();
if (!storage) {
return;
}
const payload = { version: SESSION_STORAGE_VERSION, actors: {} };
let hasEntries = false;
for (const [actorKey, queue] of state.queues.entries()) {
const items = collectQueueItems(actorKey);
if (!items.length) {
continue;
}
payload.actors[actorKey] = {
items,
lastSnapshotReason: queue.lastSnapshotReason,
};
hasEntries = true;
}
if (!hasEntries) {
try {
storage.removeItem(LOCAL_STORAGE_QUEUE_KEY);
} catch (error) {
devLogger.warn(
"[watchHistoryService] Failed to clear empty queue storage:",
error
);
}
return;
}
try {
storage.setItem(LOCAL_STORAGE_QUEUE_KEY, JSON.stringify(payload));
} catch (error) {
devLogger.warn("[watchHistoryService] Failed to persist queue storage:", error);
}
}
function resolveActorKey(actorInput) {
const supplied =
typeof actorInput === "string" && actorInput.trim()
? normalizeActorKey(actorInput)
: "";
if (supplied) {
return supplied;
}
const logged = normalizeActorKey(nostrClient?.pubkey);
if (logged) {
return logged;
}
const session = normalizeActorKey(nostrClient?.sessionActor?.pubkey);
if (session) {
return session;
}
return "";
}
function collectQueueItems(actorKey) {
const queue = state.queues.get(actorKey);
if (!queue) {
return [];
}
const items = [];
for (const { pointer } of queue.items.values()) {
const normalized = normalizePointerInput(pointer);
if (normalized) {
items.push(normalized);
}
}
items.sort((a, b) => {
const watchedA = Number.isFinite(a?.watchedAt) ? a.watchedAt : 0;
const watchedB = Number.isFinite(b?.watchedAt) ? b.watchedAt : 0;
if (watchedA !== watchedB) {
return watchedB - watchedA;
}
const keyA = pointerKey(a);
const keyB = pointerKey(b);
return keyA.localeCompare(keyB);
});
return items.slice(0, WATCH_HISTORY_MAX_ITEMS);
}
function getCachedSnapshotItems(actorKey) {
if (!actorKey) {
return [];
}
const fingerprintEntry = state.fingerprintCache.get(actorKey);
if (Array.isArray(fingerprintEntry?.items) && fingerprintEntry.items.length) {
return Array.from(fingerprintEntry.items);
}
const clientCache =
nostrClient &&
nostrClient.watchHistoryCache instanceof Map &&
nostrClient.watchHistoryCache.get(actorKey);
if (Array.isArray(clientCache?.items) && clientCache.items.length) {
return Array.from(clientCache.items);
}
if (typeof nostrClient?.getWatchHistoryStorage === "function") {
try {
const storage = nostrClient.getWatchHistoryStorage();
const storedItems = storage?.actors?.[actorKey]?.items;
if (Array.isArray(storedItems) && storedItems.length) {
return Array.from(storedItems);
}
} catch (error) {
devLogger.warn(
"[watchHistoryService] Failed to read cached watch history items:",
error,
);
}
}
return [];
}
function notifyQueueChange(actorKey) {
emit("queue-changed", {
actor: actorKey,
items: collectQueueItems(actorKey),
});
}
function clearQueue(actorKey) {
const queue = state.queues.get(actorKey);
if (!queue) {
return;
}
queue.items.clear();
queue.throttle.clear();
queue.republishScheduled = false;
persistQueueState();
notifyQueueChange(actorKey);
}
function pruneQueueAfterSnapshot(actorKey, queue, keysToClear, snapshotStart) {
if (!actorKey || !queue) {
return;
}
const keySet = new Set(keysToClear || []);
const normalizedStart = Number(snapshotStart);
const cutoff = Number.isFinite(normalizedStart) ? normalizedStart : Date.now();
let mutated = false;
if (keySet.size > 0) {
for (const key of keySet) {
const entry = queue.items.get(key);
if (!entry) {
continue;
}
const updatedAt = Number(entry?.updatedAt);
const addedAt = Number(entry?.addedAt);
const referenceTime = Number.isFinite(updatedAt)
? updatedAt
: Number.isFinite(addedAt)
? addedAt
: 0;
if (referenceTime > cutoff) {
continue;
}
queue.items.delete(key);
queue.throttle.delete(key);
mutated = true;
}
}
if (queue.republishScheduled) {
queue.republishScheduled = false;
mutated = true;
}
if (mutated) {
persistQueueState();
notifyQueueChange(actorKey);
}
}
function resolveWatchedAt(...candidates) {
for (const candidate of candidates) {
if (!Number.isFinite(candidate)) {
continue;
}
let value = Math.floor(Number(candidate));
if (value <= 0) {
continue;
}
if (value > 10_000_000_000) {
value = Math.floor(value / 1000);
}
if (value > 0) {
return value;
}
}
return Math.floor(Date.now() / 1000);
}
function scheduleRepublishForQueue(actorKey) {
if (!isFeatureEnabled(actorKey)) {
return;
}
const queue = state.queues.get(actorKey);
if (!queue || !queue.items.size || queue.republishScheduled) {
return;
}
const taskId = `watch-history-sync:${actorKey}`;
queue.republishScheduled = true;
nostrClient.scheduleWatchHistoryRepublish(
taskId,
async (attempt) => {
const latestItems = collectQueueItems(actorKey);
if (!latestItems.length) {
queue.republishScheduled = false;
persistQueueState();
return {
ok: true,
skipped: true,
actor: actorKey,
};
}
const snapshotStartTime = Date.now();
const keysToClear = new Set(
latestItems
.map((pointer) => pointerKey(pointer))
.filter((keyValue) => typeof keyValue === "string" && keyValue)
);
const publishResult = await nostrClient.updateWatchHistoryList(
latestItems,
{
actorPubkey: actorKey,
attempt,
}
);
if (publishResult?.ok) {
pruneQueueAfterSnapshot(
actorKey,
queue,
keysToClear,
snapshotStartTime,
);
await updateFingerprintCache(
actorKey,
publishResult.items || latestItems,
);
if (queue.items.size > 0) {
scheduleRepublishForQueue(actorKey);
}
} else if (!publishResult?.retryable) {
// If not retryable, we clear the scheduled flag to allow new attempts later
queue.republishScheduled = false;
persistQueueState();
}
return publishResult;
},
{
onSchedule: ({ delay }) => {
emit("republish-scheduled", {
actor: actorKey,
delayMs: Number.isFinite(delay) ? delay : null,
});
},
}
);
}
async function updateFingerprintCache(actorKey, items) {
const ttl = Math.max(0, Number(WATCH_HISTORY_CACHE_TTL_MS) || 0);
const fingerprint = await nostrClient.getWatchHistoryFingerprint(
actorKey,
items
);
const entry = state.fingerprintCache.get(actorKey) || {};
const nextEntry = {
...entry,
items: Array.isArray(items) ? items : [],
fingerprint,
expiresAt: Date.now() + ttl,
};
delete nextEntry.promise;
state.fingerprintCache.set(actorKey, nextEntry);
emit("fingerprint", { actor: actorKey, fingerprint, items: nextEntry.items });
return fingerprint;
}
async function publishView(pointerInput, createdAt) {
const recordOptions = {};
if (Number.isFinite(createdAt)) {
recordOptions.created_at = createdAt;
}
const viewResult = await nostrClient.recordVideoView(pointerInput, recordOptions);
devLogger.info(
"[watchHistoryService] Video view recorded. Preparing watch list update.",
{
pointer: pointerInput,
createdAt,
}
);
const pointer = normalizePointerInput(pointerInput);
if (!pointer) {
userLogger.warn(
"[watchHistoryService] Skipping watch list update because pointer normalization failed.",
{ pointerInput }
);
return viewResult;
}
let actorCandidate =
viewResult?.event?.pubkey ||
nostrClient?.pubkey ||
nostrClient?.sessionActor?.pubkey ||
"";
let actorKey = normalizeActorKey(actorCandidate);
let usedSessionFallback = false;
if (!actorKey && typeof nostrClient?.ensureSessionActor === "function") {
await nostrClient.ensureSessionActor();
const sessionActorKey = normalizeActorKey(nostrClient?.sessionActor?.pubkey);
if (sessionActorKey) {
actorKey = sessionActorKey;
actorCandidate = nostrClient?.sessionActor?.pubkey || "";
usedSessionFallback = true;
}
}
if (!actorKey) {
userLogger.warn(
"[watchHistoryService] Unable to resolve actor for watch list update.",
{ actorCandidate }
);
return viewResult;
}
if (usedSessionFallback) {
devLogger.info(
"[watchHistoryService] Using session actor fallback for watch history update.",
{ actor: actorKey }
);
}
const queue = ensureQueue(actorKey);
if (!queue) {
userLogger.warn(
"[watchHistoryService] Failed to resolve queue for actor.",
{ actorKey }
);
return viewResult;
}
const remoteEnabled = isFeatureEnabled(actorKey);
const normalizedPointer = normalizePointerInput(pointer);
if (!normalizedPointer) {
userLogger.warn(
"[watchHistoryService] Pointer normalization failed during queue preparation.",
{ pointerInput }
);
return viewResult;
}
normalizedPointer.watchedAt = resolveWatchedAt(
createdAt,
viewResult?.event?.created_at
);
delete normalizedPointer.video;
delete normalizedPointer.profile;
delete normalizedPointer.resumeAt;
delete normalizedPointer.completed;
const key = pointerKey(normalizedPointer);
devLogger.info(
"[watchHistoryService] Watch list process triggered by video view.",
{
actor: actorKey,
pointerKey: key,
watchedAt: normalizedPointer.watchedAt,
sessionEvent: normalizedPointer.session === true,
remoteSyncEnabled: remoteEnabled,
}
);
const normalizedLogged = normalizeActorKey(nostrClient?.pubkey);
const normalizedEventActor = normalizeActorKey(
viewResult?.event?.pubkey || actorCandidate
);
if (normalizedEventActor && normalizedEventActor !== normalizedLogged) {
normalizedPointer.session = true;
}
if (!key) {
userLogger.warn(
"[watchHistoryService] Pointer key generation failed. Skipping queue update.",
{ pointer: normalizedPointer }
);
return viewResult;
}
const now = Date.now();
const existing = queue.items.get(key);
const lastSeen = queue.throttle.get(key) || 0;
const throttled = !existing && now - lastSeen < POINTER_THROTTLE_MS;
if (existing) {
const currentWatched = Number.isFinite(existing.pointer?.watchedAt)
? existing.pointer.watchedAt
: 0;
if (
Number.isFinite(normalizedPointer.watchedAt) &&
normalizedPointer.watchedAt > currentWatched
) {
existing.pointer.watchedAt = normalizedPointer.watchedAt;
}
if (normalizedPointer.session === true) {
existing.pointer.session = true;
}
existing.updatedAt = now;
queue.items.set(key, existing);
queue.throttle.set(key, now);
persistQueueState();
notifyQueueChange(actorKey);
devLogger.debug(
"[watchHistoryService] Updated existing watch list entry.",
{
actor: actorKey,
pointerKey: key,
watchedAt: existing.pointer.watchedAt,
}
);
return viewResult;
}
if (throttled) {
queue.throttle.set(key, now);
devLogger.debug(
`[watchHistoryService] Throttled pointer ${key} for actor ${actorKey}.`
);
return viewResult;
}
queue.items.set(key, {
pointer: normalizedPointer,
addedAt: now,
updatedAt: now,
});
queue.throttle.set(key, now);
persistQueueState();
notifyQueueChange(actorKey);
devLogger.info(
"[watchHistoryService] Added pointer to pending watch list snapshot queue.",
{
actor: actorKey,
pointerKey: key,
queueSize: queue.items.size,
}
);
if (!remoteEnabled) {
devLogger.debug(
"[watchHistoryService] Watch history sync unavailable for this actor; retaining pointer in local session queue only.",
{
actor: actorKey,
pointerKey: key,
watchedAt: normalizedPointer.watchedAt,
}
);
} else {
// Automatically schedule a republish if enabled
scheduleRepublishForQueue(actorKey);
}
return viewResult;
}
async function snapshot(items, options = {}) {
const reason = typeof options.reason === "string" ? options.reason : "manual";
const actorKey = resolveActorKey(options.actor);
if (!actorKey) {
return { ok: false, error: "missing-actor" };
}
if (!isFeatureEnabled(actorKey)) {
devLogger.debug(
"[watchHistoryService] Snapshot skipped because this actor is limited to local watch history.",
{
actor: actorKey,
requestedItems: Array.isArray(items) ? items.length : 0,
reason,
}
);
return { ok: true, skipped: true, reason: "local-only" };
}
if (state.inflightSnapshots.has(actorKey)) {
return state.inflightSnapshots.get(actorKey);
}
let payloadItems = [];
if (Array.isArray(items) && items.length) {
payloadItems = items
.map((item) => normalizePointerInput(item))
.filter(Boolean);
} else {
payloadItems = collectQueueItems(actorKey);
}
if (!payloadItems.length) {
const result = { ok: true, empty: true, actor: actorKey };
emit("snapshot-empty", result);
return result;
}
const queue = ensureQueue(actorKey);
const queuePayloadKeys =
!items && queue && payloadItems.length
? new Set(
payloadItems
.map((pointer) => pointerKey(pointer))
.filter((keyValue) => typeof keyValue === "string" && keyValue)
)
: new Set();
if (!items) {
const cachedItems = getCachedSnapshotItems(actorKey);
if (cachedItems.length) {
payloadItems = [...payloadItems, ...cachedItems];
}
}
devLogger.info(
"[watchHistoryService] Initiating watch list snapshot publish.",
{
actor: actorKey,
reason,
itemCount: payloadItems.length,
queued: queue?.items?.size ?? 0,
}
);
const run = (async () => {
const snapshotStartTime = Date.now();
emit("snapshot-start", { actor: actorKey, reason, items: payloadItems });
const publishResult = await nostrClient.updateWatchHistoryList(
payloadItems,
{
actorPubkey: actorKey,
}
);
devLogger.debug(
"[watchHistoryService] Watch list snapshot publish completed.",
{
actor: actorKey,
reason,
success: !!publishResult?.ok,
retryable: !!publishResult?.retryable,
}
);
if (!publishResult?.ok) {
if (publishResult?.retryable) {
if (queue) {
queue.lastSnapshotReason = reason;
queue.republishScheduled = false;
persistQueueState();
scheduleRepublishForQueue(actorKey);
}
}
const error = new Error("watch-history-snapshot-failed");
error.result = publishResult;
emit("snapshot-error", { actor: actorKey, reason, error });
throw error;
}
if (publishResult?.ok) {
if (!items) {
pruneQueueAfterSnapshot(actorKey, queue, queuePayloadKeys, snapshotStartTime);
} else if (queue) {
queue.republishScheduled = false;
persistQueueState();
}
}
await updateFingerprintCache(actorKey, publishResult.items || payloadItems);
emit("snapshot-complete", { actor: actorKey, reason, result: publishResult });
devLogger.debug(
"[watchHistoryService] Watch list snapshot processed and fingerprint cache updated.",
{
actor: actorKey,
reason,
fingerprintUpdated: true,
}
);
return publishResult;
})()
.catch((error) => {
devLogger.warn("[watchHistoryService] Snapshot failed:", error);
throw error;
})
.finally(() => {
state.inflightSnapshots.delete(actorKey);
});
state.inflightSnapshots.set(actorKey, run);
return run;
}
function scheduleWatchHistoryRefresh(actorKey, cacheEntry = {}, options = {}) {
if (!actorKey) {
return Promise.resolve([]);
}
if (cacheEntry && typeof cacheEntry === "object" && cacheEntry.promise) {
return cacheEntry.promise;
}
const promise = (async () => {
const permissionResult = await ensureWatchHistoryExtensionPermissions(
actorKey,
options
);
if (!permissionResult.ok) {
throw permissionResult.error;
}
return nostrClient.resolveWatchHistory(actorKey, { forceRefresh: true });
})()
.then((resolvedItems) => updateFingerprintCache(actorKey, resolvedItems))
.then(() => {
const latest = state.fingerprintCache.get(actorKey);
return latest?.items || [];
})
.catch((error) => {
devLogger.warn("[watchHistoryService] Failed to load watch history:", error);
throw error;
})
.finally(() => {
const entry = state.fingerprintCache.get(actorKey);
if (entry) {
delete entry.promise;
}
});
devLogger.info("[watchHistoryService] Triggered nostr watch list refresh.", {
actor: actorKey,
});
const baseEntry = cacheEntry && typeof cacheEntry === "object" ? cacheEntry : {};
state.fingerprintCache.set(actorKey, {
...baseEntry,
promise,
});
return promise;
}
function resolveEntryPointer(entry) {
if (!entry) {
return null;
}
const candidate = entry.pointer || entry;
return normalizePointerInput(candidate);
}
function resolveEntryWatchedAt(entry, pointer) {
const watchedAtRaw = Number.isFinite(entry?.watchedAt)
? entry.watchedAt
: Number.isFinite(entry?.pointer?.watchedAt)
? entry.pointer.watchedAt
: Number.isFinite(entry?.timestamp)
? entry.timestamp
: Number.isFinite(pointer?.watchedAt)
? pointer.watchedAt
: 0;
return Math.max(0, Math.floor(Number(watchedAtRaw) || 0));
}
function getLatestWatchedAt(items) {
if (!Array.isArray(items) || !items.length) {
return 0;
}
let latest = 0;
for (const entry of items) {
const pointer = resolveEntryPointer(entry);
if (!pointer) {
continue;
}
const watchedAt = resolveEntryWatchedAt(entry, pointer);
if (watchedAt > latest) {
latest = watchedAt;
}
}
return latest;
}
function buildMergedEntry(entry, pointer, watchedAt) {
const normalizedPointer = pointer ? { ...pointer } : null;
if (normalizedPointer) {
normalizedPointer.watchedAt = watchedAt;
if (pointer?.session === true) {
normalizedPointer.session = true;
}
}
if (entry && typeof entry === "object" && !Array.isArray(entry)) {
const next = { ...entry };
if (entry.pointer && typeof entry.pointer === "object") {
next.pointer = {
...(entry.pointer || {}),
...(normalizedPointer || {}),
watchedAt,
};
} else if (normalizedPointer) {
Object.assign(next, normalizedPointer);
next.watchedAt = watchedAt;
} else {
next.watchedAt = watchedAt;
}
if (normalizedPointer?.session === true) {
if (next.pointer) {
next.pointer.session = true;
} else {
next.session = true;
}
}
return next;
}
if (normalizedPointer) {
return normalizedPointer;
}
return entry;
}
function mergeWatchHistoryItems(baseItems, queueItems) {
const merged = new Map();
const ingest = (entry, pointer, watchedAt) => {
const key = pointerKey(pointer);
if (!key) {
return;
}
merged.set(key, {
entry,
watchedAt,
key,
});
};
for (const entry of Array.isArray(baseItems) ? baseItems : []) {
const pointer = resolveEntryPointer(entry);
if (!pointer) {
continue;
}
const watchedAt = resolveEntryWatchedAt(entry, pointer);
ingest(entry, pointer, watchedAt);
}
for (const entry of Array.isArray(queueItems) ? queueItems : []) {
const pointer = resolveEntryPointer(entry);
if (!pointer) {
continue;
}
const watchedAt = resolveEntryWatchedAt(entry, pointer);
const key = pointerKey(pointer);
if (!key) {
continue;
}
const existing = merged.get(key);
if (!existing || watchedAt > existing.watchedAt) {
const mergedEntry = buildMergedEntry(existing?.entry || entry, pointer, watchedAt);
merged.set(key, {
entry: mergedEntry,
watchedAt,
key,
});
}
}
const ordered = Array.from(merged.values());
ordered.sort((a, b) => {
if (a.watchedAt !== b.watchedAt) {
return b.watchedAt - a.watchedAt;
}
return a.key.localeCompare(b.key);
});
return ordered.map((entry) => entry.entry);
}
function mergeQueuedItemsIfNeeded(actorKey, items) {
const queueItems = collectQueueItems(actorKey);
if (!queueItems.length) {
return items;
}
const snapshotInflight = state.inflightSnapshots.has(actorKey);
if (!snapshotInflight) {
const latestQueue = getLatestWatchedAt(queueItems);
const latestBase = getLatestWatchedAt(items);
if (latestQueue <= latestBase) {
return items;
}
}
return mergeWatchHistoryItems(items, queueItems);
}
async function loadLatest(actorInput, options = {}) {
const actorKey = resolveActorKey(actorInput);
if (!actorKey) {
return [];
}
if (!state.restored) {
restoreQueueState();
}
const normalizedOptions =
options && typeof options === "object" ? options : {};
// Only callers that can react to a later fingerprint update should opt into
// stale cache reads; everyone else waits for the fresh list so they do not
// miss entries.
const allowStale = normalizedOptions.allowStale === true;
const forceRefresh = normalizedOptions.forceRefresh === true;
if (!isFeatureEnabled(actorKey)) {
const items = collectQueueItems(actorKey);
devLogger.debug(
"[watchHistoryService] loadLatest returning local-only watch history queue.",
{
actor: actorKey,
itemCount: items.length,
}
);
return items;
}
const now = Date.now();
const cacheEntry = state.fingerprintCache.get(actorKey);
devLogger.info(
"[watchHistoryService] loadLatest invoked.",
{
actor: actorKey,
cacheHasItems: Array.isArray(cacheEntry?.items),
cacheExpiresAt: cacheEntry?.expiresAt || null,
forceRefresh,
}
);
const hasCachedItems = Array.isArray(cacheEntry?.items);
const cacheIsFresh =
hasCachedItems &&
cacheEntry?.expiresAt &&
cacheEntry.expiresAt > now;
if (cacheIsFresh && !forceRefresh) {
devLogger.debug(
"[watchHistoryService] Returning cached watch list items.",
{
actor: actorKey,
itemCount: cacheEntry.items.length,
}
);
return mergeQueuedItemsIfNeeded(actorKey, cacheEntry.items);
}
if (cacheEntry?.promise) {
devLogger.debug(
"[watchHistoryService] Awaiting in-flight watch list refresh.",
{
actor: actorKey,
}
);
if (allowStale && hasCachedItems) {
devLogger.debug(
"[watchHistoryService] Returning stale watch list items while refresh completes.",
{
actor: actorKey,
itemCount: cacheEntry.items.length,
}
);
return mergeQueuedItemsIfNeeded(actorKey, cacheEntry.items);
}
const resolved = await cacheEntry.promise;
return mergeQueuedItemsIfNeeded(actorKey, resolved);
}
if (!allowStale || !hasCachedItems) {
const resolved = await scheduleWatchHistoryRefresh(
actorKey,
cacheEntry,
normalizedOptions
);
return mergeQueuedItemsIfNeeded(actorKey, resolved);
}
const refreshPromise = scheduleWatchHistoryRefresh(
actorKey,
cacheEntry,
normalizedOptions
);
refreshPromise.catch(() => {});
devLogger.info(
"[watchHistoryService] Returning stale watch list items while refresh is pending.",
{
actor: actorKey,
itemCount: cacheEntry?.items?.length || 0,
}
);
return mergeQueuedItemsIfNeeded(actorKey, cacheEntry.items || []);
}
async function getFingerprint(actorInput) {
const actorKey = resolveActorKey(actorInput);
if (!actorKey) {
return "";
}
if (!isFeatureEnabled(actorKey)) {
return "";
}
const now = Date.now();
const cacheEntry = state.fingerprintCache.get(actorKey);
if (
cacheEntry?.fingerprint &&
cacheEntry.expiresAt &&
cacheEntry.expiresAt > now
) {
return cacheEntry.fingerprint;
}
const fingerprint = await nostrClient.getWatchHistoryFingerprint(actorKey);
const ttl = Math.max(0, Number(WATCH_HISTORY_CACHE_TTL_MS) || 0);
const nextEntry = {
...(cacheEntry || {}),
fingerprint,
expiresAt: now + ttl,
};
state.fingerprintCache.set(actorKey, nextEntry);
emit("fingerprint", { actor: actorKey, fingerprint });
return fingerprint;
}
function resetProgress(actorInput) {
const actorKey = normalizeActorKey(actorInput);
if (actorKey) {
state.queues.delete(actorKey);
state.fingerprintCache.delete(actorKey);
state.inflightSnapshots.delete(actorKey);
persistQueueState();
notifyQueueChange(actorKey);
return;
}
state.queues.clear();
state.fingerprintCache.clear();
state.inflightSnapshots.clear();
persistQueueState();
emit("queue-changed", { actor: null, items: [] });
}
function getQueuedPointers(actorInput) {
const actorKey = normalizeActorKey(actorInput);
if (!actorKey) {
return [];
}
if (!state.restored) {
restoreQueueState();
}
return collectQueueItems(actorKey);
}
function getAllQueues() {
if (!state.restored) {
restoreQueueState();
}
const summary = {};
for (const actorKey of state.queues.keys()) {
const items = collectQueueItems(actorKey);
if (items.length) {
summary[actorKey] = items;
}
}
return summary;
}
const watchHistoryService = {
isEnabled: isFeatureEnabled,
supportsLocalHistory,
isLocalOnly,
publishView,
snapshot,
loadLatest,
getFingerprint,
resetProgress,
getQueuedPointers,
getAllQueues,
subscribe,
};
if (typeof window !== "undefined") {
window.bitvid = window.bitvid || {};
window.bitvid.watchHistory = watchHistoryService;
}
export { watchHistoryService };
export default watchHistoryService;