mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-02 10:52:27 +00:00
Merge pull request #253 from PR0M3TH3AN/codex/create-watchhistoryservice-with-session-management
Add watch history service and integrate playback flush
This commit is contained in:
4 files changed
+874
-2
No files matched your search
@@ -1,7 +1,7 @@
|
||||
// js/app.js
|
||||
|
||||
import { loadView } from "./viewManager.js";
|
||||
import { nostrClient, recordVideoView } from "./nostr.js";
|
||||
import { nostrClient } from "./nostr.js";
|
||||
import { torrentClient } from "./webtorrent.js";
|
||||
import { isDevMode, ADMIN_SUPER_NPUB } from "./config.js";
|
||||
import { accessControl, normalizeNpub } from "./accessControl.js";
|
||||
@@ -15,6 +15,7 @@ import { attachUrlHealthBadges } from "./urlHealthObserver.js";
|
||||
import { ADMIN_INITIAL_EVENT_BLACKLIST } from "./lists.js";
|
||||
import { userBlocks } from "./userBlocks.js";
|
||||
import { relayManager } from "./relayManager.js";
|
||||
import watchHistoryService from "./watchHistoryService.js";
|
||||
import {
|
||||
loadR2Settings,
|
||||
saveR2Settings,
|
||||
@@ -5968,11 +5969,31 @@ class bitvidApp {
|
||||
|
||||
// 7) Cleanup on page unload
|
||||
window.addEventListener("beforeunload", () => {
|
||||
this.flushWatchHistory("session-end", "beforeunload").catch((error) => {
|
||||
if (isDevMode) {
|
||||
console.warn("[beforeunload] Watch history flush failed:", error);
|
||||
}
|
||||
});
|
||||
this.cleanup().catch((err) => {
|
||||
console.error("Cleanup before unload failed:", err);
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.visibilityState === "hidden") {
|
||||
this.flushWatchHistory("session-end", "visibilitychange").catch(
|
||||
(error) => {
|
||||
if (isDevMode) {
|
||||
console.warn(
|
||||
"[visibilitychange] Watch history flush failed:",
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// 8) Handle back/forward navigation => hide video modal
|
||||
window.addEventListener("popstate", async () => {
|
||||
console.log("[popstate] user navigated back/forward; cleaning modal...");
|
||||
@@ -6846,6 +6867,15 @@ class bitvidApp {
|
||||
);
|
||||
try {
|
||||
this.cancelPendingViewLogging();
|
||||
await this.flushWatchHistory("session-end", "cleanup").catch(
|
||||
(error) => {
|
||||
const message =
|
||||
error && typeof error.message === "string"
|
||||
? error.message
|
||||
: String(error ?? "unknown error");
|
||||
this.log(`[cleanup] Watch history flush failed: ${message}`);
|
||||
}
|
||||
);
|
||||
this.clearActiveIntervals();
|
||||
this.cleanupUrlPlaybackWatchdog();
|
||||
this.teardownModalViewCountSubscription();
|
||||
@@ -6976,6 +7006,18 @@ class bitvidApp {
|
||||
}
|
||||
|
||||
this.playbackTelemetryState = null;
|
||||
|
||||
this.flushWatchHistory("session-end", "cancelPendingViewLogging").catch(
|
||||
(error) => {
|
||||
const message =
|
||||
error && typeof error.message === "string"
|
||||
? error.message
|
||||
: String(error ?? "unknown error");
|
||||
this.log(
|
||||
`[cancelPendingViewLogging] Watch history flush failed: ${message}`
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
resetViewLoggingState() {
|
||||
@@ -6985,6 +7027,26 @@ class bitvidApp {
|
||||
}
|
||||
}
|
||||
|
||||
flushWatchHistory(reason = "session-end", context = "watch-history") {
|
||||
if (!watchHistoryService?.isEnabled?.()) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
try {
|
||||
const result = watchHistoryService.snapshot(undefined, { reason });
|
||||
return Promise.resolve(result).catch((error) => {
|
||||
if (isDevMode) {
|
||||
console.warn(`[${context}] Watch history flush failed:`, error);
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn(`[${context}] Failed to queue watch history flush:`, error);
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
getActiveViewIdentityKey() {
|
||||
const normalizedUser = this.normalizeHexPubkey(this.pubkey);
|
||||
if (normalizedUser) {
|
||||
@@ -7086,7 +7148,7 @@ class bitvidApp {
|
||||
(async () => {
|
||||
let viewResult;
|
||||
try {
|
||||
viewResult = await recordVideoView(thresholdPointer);
|
||||
viewResult = await watchHistoryService.publishView(thresholdPointer);
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn(
|
||||
|
||||
@@ -2,6 +2,7 @@ const DEFAULT_FLAGS = Object.freeze({
|
||||
URL_FIRST_ENABLED: true, // try URL before magnet in the player
|
||||
ACCEPT_LEGACY_V1: true, // accept v1 magnet-only notes
|
||||
VIEW_FILTER_INCLUDE_LEGACY_VIDEO: false,
|
||||
FEATURE_WATCH_HISTORY_V2: false,
|
||||
WSS_TRACKERS: Object.freeze([
|
||||
"wss://tracker.openwebtorrent.com",
|
||||
"wss://tracker.fastcast.nz",
|
||||
@@ -21,6 +22,7 @@ const runtimeFlags = (() => {
|
||||
ACCEPT_LEGACY_V1: DEFAULT_FLAGS.ACCEPT_LEGACY_V1,
|
||||
VIEW_FILTER_INCLUDE_LEGACY_VIDEO:
|
||||
DEFAULT_FLAGS.VIEW_FILTER_INCLUDE_LEGACY_VIDEO,
|
||||
FEATURE_WATCH_HISTORY_V2: DEFAULT_FLAGS.FEATURE_WATCH_HISTORY_V2,
|
||||
WSS_TRACKERS: [...DEFAULT_FLAGS.WSS_TRACKERS],
|
||||
};
|
||||
if (globalScope) {
|
||||
@@ -101,6 +103,11 @@ export let VIEW_FILTER_INCLUDE_LEGACY_VIDEO = coerceBoolean(
|
||||
DEFAULT_FLAGS.VIEW_FILTER_INCLUDE_LEGACY_VIDEO
|
||||
);
|
||||
|
||||
export let FEATURE_WATCH_HISTORY_V2 = coerceBoolean(
|
||||
runtimeFlags.FEATURE_WATCH_HISTORY_V2,
|
||||
DEFAULT_FLAGS.FEATURE_WATCH_HISTORY_V2
|
||||
);
|
||||
|
||||
export let WSS_TRACKERS = freezeTrackers(
|
||||
sanitizeTrackerList(runtimeFlags.WSS_TRACKERS)
|
||||
);
|
||||
@@ -141,6 +148,20 @@ Object.defineProperty(runtimeFlags, "VIEW_FILTER_INCLUDE_LEGACY_VIDEO", {
|
||||
},
|
||||
});
|
||||
|
||||
Object.defineProperty(runtimeFlags, "FEATURE_WATCH_HISTORY_V2", {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get() {
|
||||
return FEATURE_WATCH_HISTORY_V2;
|
||||
},
|
||||
set(next) {
|
||||
FEATURE_WATCH_HISTORY_V2 = coerceBoolean(
|
||||
next,
|
||||
DEFAULT_FLAGS.FEATURE_WATCH_HISTORY_V2
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
Object.defineProperty(runtimeFlags, "WSS_TRACKERS", {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
@@ -156,6 +177,7 @@ Object.defineProperty(runtimeFlags, "WSS_TRACKERS", {
|
||||
runtimeFlags.URL_FIRST_ENABLED = URL_FIRST_ENABLED;
|
||||
runtimeFlags.ACCEPT_LEGACY_V1 = ACCEPT_LEGACY_V1;
|
||||
runtimeFlags.VIEW_FILTER_INCLUDE_LEGACY_VIDEO = VIEW_FILTER_INCLUDE_LEGACY_VIDEO;
|
||||
runtimeFlags.FEATURE_WATCH_HISTORY_V2 = FEATURE_WATCH_HISTORY_V2;
|
||||
runtimeFlags.WSS_TRACKERS = WSS_TRACKERS;
|
||||
|
||||
export function setUrlFirstEnabled(next) {
|
||||
@@ -184,8 +206,14 @@ export function resetRuntimeFlags() {
|
||||
setViewFilterIncludeLegacyVideo(
|
||||
DEFAULT_FLAGS.VIEW_FILTER_INCLUDE_LEGACY_VIDEO
|
||||
);
|
||||
setWatchHistoryV2Enabled(DEFAULT_FLAGS.FEATURE_WATCH_HISTORY_V2);
|
||||
setWssTrackers(DEFAULT_FLAGS.WSS_TRACKERS);
|
||||
}
|
||||
|
||||
export const RUNTIME_FLAGS = runtimeFlags;
|
||||
|
||||
export function setWatchHistoryV2Enabled(next) {
|
||||
runtimeFlags.FEATURE_WATCH_HISTORY_V2 = next;
|
||||
return FEATURE_WATCH_HISTORY_V2;
|
||||
}
|
||||
|
||||
@@ -265,6 +265,10 @@ function clonePointerItem(pointer) {
|
||||
cloned.watchedAt = watchedAt;
|
||||
}
|
||||
|
||||
if (pointer.session === true) {
|
||||
cloned.session = true;
|
||||
}
|
||||
|
||||
return cloned;
|
||||
}
|
||||
|
||||
@@ -5312,3 +5316,5 @@ export const countVideoViewEvents = (...args) => {
|
||||
}
|
||||
return nostrClient.countVideoViewEvents(...args);
|
||||
};
|
||||
|
||||
export { normalizePointerInput, pointerKey, chunkWatchHistoryPayloadItems, normalizeActorKey };
|
||||
@@ -0,0 +1,776 @@
|
||||
// js/watchHistoryService.js
|
||||
|
||||
import {
|
||||
nostrClient,
|
||||
normalizePointerInput,
|
||||
pointerKey,
|
||||
normalizeActorKey,
|
||||
} from "./nostr.js";
|
||||
import {
|
||||
WATCH_HISTORY_CACHE_TTL_MS,
|
||||
WATCH_HISTORY_MAX_ITEMS,
|
||||
} from "./config.js";
|
||||
import { FEATURE_WATCH_HISTORY_V2 } from "./constants.js";
|
||||
import { isDevMode } from "./config.js";
|
||||
|
||||
const SESSION_STORAGE_KEY = "bitvid:watch-history:session:v1";
|
||||
const SESSION_STORAGE_VERSION = 1;
|
||||
const POINTER_THROTTLE_MS = 60 * 1000;
|
||||
|
||||
const state = {
|
||||
restored: false,
|
||||
queues: new Map(),
|
||||
inflightSnapshots: new Map(),
|
||||
fingerprintCache: new Map(),
|
||||
listeners: new Map(),
|
||||
};
|
||||
|
||||
function isFeatureEnabled() {
|
||||
return FEATURE_WATCH_HISTORY_V2 === true;
|
||||
}
|
||||
|
||||
function getSessionStorage() {
|
||||
try {
|
||||
if (typeof window !== "undefined" && window.sessionStorage) {
|
||||
return window.sessionStorage;
|
||||
}
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn("[watchHistoryService] sessionStorage 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) {
|
||||
if (isDevMode) {
|
||||
console.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) {
|
||||
if (isDevMode) {
|
||||
console.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) {
|
||||
if (isDevMode) {
|
||||
console.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(),
|
||||
pendingSnapshotId: null,
|
||||
lastSnapshotReason: null,
|
||||
republishScheduled: false,
|
||||
};
|
||||
state.queues.set(actorKey, queue);
|
||||
}
|
||||
return queue;
|
||||
}
|
||||
|
||||
function restoreQueueState() {
|
||||
if (state.restored) {
|
||||
return;
|
||||
}
|
||||
state.restored = true;
|
||||
|
||||
const storage = getSessionStorage();
|
||||
if (!storage) {
|
||||
return;
|
||||
}
|
||||
|
||||
let raw = null;
|
||||
try {
|
||||
raw = storage.getItem(SESSION_STORAGE_KEY);
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn("[watchHistoryService] Failed to read session cache:", error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!raw || typeof raw !== "string") {
|
||||
return;
|
||||
}
|
||||
|
||||
let parsed = null;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn("[watchHistoryService] Failed to parse session cache:", error);
|
||||
}
|
||||
try {
|
||||
storage.removeItem(SESSION_STORAGE_KEY);
|
||||
} catch (cleanupError) {
|
||||
if (isDevMode) {
|
||||
console.warn(
|
||||
"[watchHistoryService] Failed to clear corrupt session cache:",
|
||||
cleanupError
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!parsed || parsed.version !== SESSION_STORAGE_VERSION) {
|
||||
try {
|
||||
storage.removeItem(SESSION_STORAGE_KEY);
|
||||
} catch (cleanupError) {
|
||||
if (isDevMode) {
|
||||
console.warn(
|
||||
"[watchHistoryService] Failed to clear outdated session cache:",
|
||||
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?.pendingSnapshotId === "string" && entry.pendingSnapshotId) {
|
||||
queue.pendingSnapshotId = entry.pendingSnapshotId;
|
||||
} else {
|
||||
queue.pendingSnapshotId = null;
|
||||
}
|
||||
if (
|
||||
typeof entry?.lastSnapshotReason === "string" &&
|
||||
entry.lastSnapshotReason
|
||||
) {
|
||||
queue.lastSnapshotReason = entry.lastSnapshotReason;
|
||||
} else {
|
||||
queue.lastSnapshotReason = null;
|
||||
}
|
||||
|
||||
if (queue.pendingSnapshotId && isFeatureEnabled()) {
|
||||
scheduleRepublishForQueue(actorKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ensureQueue(actorKey) {
|
||||
if (!actorKey) {
|
||||
return null;
|
||||
}
|
||||
if (!state.restored) {
|
||||
restoreQueueState();
|
||||
}
|
||||
return getOrCreateQueue(actorKey);
|
||||
}
|
||||
|
||||
function persistQueueState() {
|
||||
const storage = getSessionStorage();
|
||||
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 && !queue.pendingSnapshotId) {
|
||||
continue;
|
||||
}
|
||||
payload.actors[actorKey] = {
|
||||
items,
|
||||
pendingSnapshotId: queue.pendingSnapshotId,
|
||||
lastSnapshotReason: queue.lastSnapshotReason,
|
||||
};
|
||||
hasEntries = true;
|
||||
}
|
||||
|
||||
if (!hasEntries) {
|
||||
try {
|
||||
storage.removeItem(SESSION_STORAGE_KEY);
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn(
|
||||
"[watchHistoryService] Failed to clear empty session cache:",
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
storage.setItem(SESSION_STORAGE_KEY, JSON.stringify(payload));
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn("[watchHistoryService] Failed to persist session cache:", 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 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.pendingSnapshotId = null;
|
||||
queue.republishScheduled = false;
|
||||
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) {
|
||||
const queue = state.queues.get(actorKey);
|
||||
if (!queue || !queue.pendingSnapshotId || queue.republishScheduled) {
|
||||
return;
|
||||
}
|
||||
const snapshotId = queue.pendingSnapshotId;
|
||||
queue.republishScheduled = true;
|
||||
nostrClient.scheduleWatchHistoryRepublish(snapshotId, async (attempt) => {
|
||||
const latestItems = collectQueueItems(actorKey);
|
||||
if (!latestItems.length) {
|
||||
queue.pendingSnapshotId = null;
|
||||
queue.republishScheduled = false;
|
||||
persistQueueState();
|
||||
return {
|
||||
ok: true,
|
||||
skipped: true,
|
||||
actor: actorKey,
|
||||
snapshotId,
|
||||
};
|
||||
}
|
||||
const publishResult = await nostrClient.publishWatchHistorySnapshot(
|
||||
latestItems,
|
||||
{
|
||||
actorPubkey: actorKey,
|
||||
snapshotId,
|
||||
attempt,
|
||||
source: `${queue.lastSnapshotReason || "session"}-retry`,
|
||||
}
|
||||
);
|
||||
if (publishResult?.ok) {
|
||||
queue.pendingSnapshotId = null;
|
||||
queue.republishScheduled = false;
|
||||
await updateFingerprintCache(actorKey, publishResult.items || latestItems);
|
||||
persistQueueState();
|
||||
notifyQueueChange(actorKey);
|
||||
} else if (!publishResult?.retryable) {
|
||||
queue.pendingSnapshotId = publishResult?.snapshotId || queue.pendingSnapshotId;
|
||||
queue.republishScheduled = false;
|
||||
persistQueueState();
|
||||
}
|
||||
return publishResult;
|
||||
});
|
||||
}
|
||||
|
||||
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, metadata = {}) {
|
||||
const recordOptions = {};
|
||||
if (Number.isFinite(createdAt)) {
|
||||
recordOptions.created_at = createdAt;
|
||||
}
|
||||
if (metadata && typeof metadata === "object") {
|
||||
if (Array.isArray(metadata.additionalTags)) {
|
||||
recordOptions.additionalTags = metadata.additionalTags;
|
||||
}
|
||||
if (Array.isArray(metadata.relays)) {
|
||||
recordOptions.relays = metadata.relays;
|
||||
}
|
||||
if (metadata.content != null) {
|
||||
recordOptions.content = metadata.content;
|
||||
}
|
||||
}
|
||||
|
||||
const viewResult = await nostrClient.recordVideoView(pointerInput, recordOptions);
|
||||
|
||||
if (!isFeatureEnabled()) {
|
||||
return viewResult;
|
||||
}
|
||||
|
||||
const pointer = normalizePointerInput(pointerInput);
|
||||
if (!pointer) {
|
||||
return viewResult;
|
||||
}
|
||||
|
||||
const actorCandidate =
|
||||
(typeof metadata.actor === "string" && metadata.actor.trim()) ||
|
||||
viewResult?.event?.pubkey ||
|
||||
nostrClient?.pubkey ||
|
||||
nostrClient?.sessionActor?.pubkey ||
|
||||
"";
|
||||
const actorKey = normalizeActorKey(actorCandidate);
|
||||
if (!actorKey) {
|
||||
return viewResult;
|
||||
}
|
||||
|
||||
const queue = ensureQueue(actorKey);
|
||||
if (!queue) {
|
||||
return viewResult;
|
||||
}
|
||||
|
||||
const normalizedPointer = normalizePointerInput(pointer);
|
||||
if (!normalizedPointer) {
|
||||
return viewResult;
|
||||
}
|
||||
|
||||
normalizedPointer.watchedAt = resolveWatchedAt(
|
||||
createdAt,
|
||||
viewResult?.event?.created_at
|
||||
);
|
||||
|
||||
const normalizedLogged = normalizeActorKey(nostrClient?.pubkey);
|
||||
const normalizedEventActor = normalizeActorKey(
|
||||
viewResult?.event?.pubkey || actorCandidate
|
||||
);
|
||||
if (normalizedEventActor && normalizedEventActor !== normalizedLogged) {
|
||||
normalizedPointer.session = true;
|
||||
}
|
||||
|
||||
const key = pointerKey(normalizedPointer);
|
||||
if (!key) {
|
||||
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);
|
||||
return viewResult;
|
||||
}
|
||||
|
||||
if (throttled) {
|
||||
queue.throttle.set(key, now);
|
||||
if (isDevMode) {
|
||||
console.info(
|
||||
`[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);
|
||||
|
||||
return viewResult;
|
||||
}
|
||||
|
||||
async function snapshot(items, options = {}) {
|
||||
if (!isFeatureEnabled()) {
|
||||
return { ok: true, skipped: true, reason: "feature-disabled" };
|
||||
}
|
||||
const reason = typeof options.reason === "string" ? options.reason : "manual";
|
||||
const actorKey = resolveActorKey(options.actor);
|
||||
if (!actorKey) {
|
||||
return { ok: false, error: "missing-actor" };
|
||||
}
|
||||
|
||||
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 run = (async () => {
|
||||
emit("snapshot-start", { actor: actorKey, reason, items: payloadItems });
|
||||
const publishResult = await nostrClient.publishWatchHistorySnapshot(
|
||||
payloadItems,
|
||||
{
|
||||
actorPubkey: actorKey,
|
||||
source: reason,
|
||||
}
|
||||
);
|
||||
|
||||
if (!publishResult?.ok) {
|
||||
if (publishResult?.retryable && publishResult?.snapshotId) {
|
||||
if (queue) {
|
||||
queue.pendingSnapshotId = publishResult.snapshotId;
|
||||
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 (!items) {
|
||||
clearQueue(actorKey);
|
||||
} else if (queue) {
|
||||
queue.pendingSnapshotId = null;
|
||||
queue.republishScheduled = false;
|
||||
persistQueueState();
|
||||
}
|
||||
|
||||
await updateFingerprintCache(actorKey, publishResult.items || payloadItems);
|
||||
emit("snapshot-complete", { actor: actorKey, reason, result: publishResult });
|
||||
return publishResult;
|
||||
})()
|
||||
.catch((error) => {
|
||||
if (isDevMode) {
|
||||
console.warn("[watchHistoryService] Snapshot failed:", error);
|
||||
}
|
||||
throw error;
|
||||
})
|
||||
.finally(() => {
|
||||
state.inflightSnapshots.delete(actorKey);
|
||||
});
|
||||
|
||||
state.inflightSnapshots.set(actorKey, run);
|
||||
return run;
|
||||
}
|
||||
|
||||
async function loadLatest(actorInput) {
|
||||
if (!isFeatureEnabled()) {
|
||||
return [];
|
||||
}
|
||||
const actorKey = resolveActorKey(actorInput);
|
||||
if (!actorKey) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const cacheEntry = state.fingerprintCache.get(actorKey);
|
||||
if (
|
||||
cacheEntry?.items &&
|
||||
cacheEntry.expiresAt &&
|
||||
cacheEntry.expiresAt > now &&
|
||||
Array.isArray(cacheEntry.items)
|
||||
) {
|
||||
return cacheEntry.items;
|
||||
}
|
||||
|
||||
if (cacheEntry?.promise) {
|
||||
return cacheEntry.promise;
|
||||
}
|
||||
|
||||
const promise = nostrClient
|
||||
.resolveWatchHistory(actorKey, { forceRefresh: true })
|
||||
.then((resolvedItems) => updateFingerprintCache(actorKey, resolvedItems))
|
||||
.then(() => {
|
||||
const latest = state.fingerprintCache.get(actorKey);
|
||||
return latest?.items || [];
|
||||
})
|
||||
.catch((error) => {
|
||||
if (isDevMode) {
|
||||
console.warn("[watchHistoryService] Failed to load watch history:", error);
|
||||
}
|
||||
throw error;
|
||||
})
|
||||
.finally(() => {
|
||||
const entry = state.fingerprintCache.get(actorKey);
|
||||
if (entry) {
|
||||
delete entry.promise;
|
||||
}
|
||||
});
|
||||
|
||||
state.fingerprintCache.set(actorKey, {
|
||||
...(cacheEntry || {}),
|
||||
promise,
|
||||
});
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
async function getFingerprint(actorInput) {
|
||||
if (!isFeatureEnabled()) {
|
||||
return "";
|
||||
}
|
||||
const actorKey = resolveActorKey(actorInput);
|
||||
if (!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,
|
||||
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;
|
||||
Reference in new issue
Block a user