mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
- 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]>
1396 lines
35 KiB
JavaScript
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;
|