From f6729b8e3558d66779324e18aa120759c3430f09 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 2 Oct 2025 15:59:06 -0400 Subject: [PATCH] Fix watch history batch window --- config/instance-config.js | 11 +++++++ docs/watch-history-logging.md | 9 ++++++ js/config.js | 2 ++ js/historyView.js | 14 ++++++-- js/nostr.js | 10 ++++-- tests/watch-history.test.mjs | 60 ++++++++++++++++++++++++++++++++++- 6 files changed, 100 insertions(+), 6 deletions(-) diff --git a/config/instance-config.js b/config/instance-config.js index 12eca0cc..6a1e86d4 100644 --- a/config/instance-config.js +++ b/config/instance-config.js @@ -106,6 +106,17 @@ export const WATCH_HISTORY_MAX_ITEMS = 1500; */ export const WATCH_HISTORY_BATCH_RESOLVE = true; +/** + * Maximum number of canonical items each batched watch-history response should + * include. + * + * Leave this as `null` (the default) to return the full + * `WATCH_HISTORY_MAX_ITEMS` window, even when batching is enabled. Deployments + * that page through histories in smaller slices can set a positive integer to + * cap the resolver output so pagination and API responses stay aligned. + */ +export const WATCH_HISTORY_BATCH_PAGE_SIZE = null; + /** * Maximum size of the JSON payload for each watch-history chunk, measured * before encryption. diff --git a/docs/watch-history-logging.md b/docs/watch-history-logging.md index c31998b8..ea686dc1 100644 --- a/docs/watch-history-logging.md +++ b/docs/watch-history-logging.md @@ -27,6 +27,15 @@ chunkIndex, totalChunks, items[] }`. Items are pointer descriptors (`e` or `a` references plus optional relay hints) so that relays never learn which titles were played—only which events or addresses the client can dereference later.【F:js/nostr.js†L2337-L2364】 +## Batching configuration + +Operators can toggle grouped resolution with `WATCH_HISTORY_BATCH_RESOLVE` and +optionally cap each response by defining `WATCH_HISTORY_BATCH_PAGE_SIZE` in +`config/instance-config.js`. Leaving the page size unset returns the full +`WATCH_HISTORY_MAX_ITEMS` window while still letting the UI paginate locally, +but setting a positive integer keeps API payloads in lockstep with batched +renderers like `historyView`.【F:config/instance-config.js†L101-L123】【F:js/nostr.js†L2987-L2998】【F:js/historyView.js†L1-L38】 + ## Snapshot cadence `WatchHistoryService.publishView` queues pointer entries while the feature flag diff --git a/js/config.js b/js/config.js index 0a102695..9e342839 100644 --- a/js/config.js +++ b/js/config.js @@ -9,6 +9,7 @@ import { WATCH_HISTORY_LEGACY_LIST_IDENTIFIERS, WATCH_HISTORY_MAX_ITEMS, WATCH_HISTORY_BATCH_RESOLVE, + WATCH_HISTORY_BATCH_PAGE_SIZE, WATCH_HISTORY_PAYLOAD_MAX_BYTES, WATCH_HISTORY_FETCH_EVENT_LIMIT, WATCH_HISTORY_CACHE_TTL_MS, @@ -63,6 +64,7 @@ export { WATCH_HISTORY_LIST_IDENTIFIER }; export { WATCH_HISTORY_LEGACY_LIST_IDENTIFIERS }; export { WATCH_HISTORY_MAX_ITEMS }; export { WATCH_HISTORY_BATCH_RESOLVE }; +export { WATCH_HISTORY_BATCH_PAGE_SIZE }; export { WATCH_HISTORY_PAYLOAD_MAX_BYTES }; export { WATCH_HISTORY_FETCH_EVENT_LIMIT }; export { WATCH_HISTORY_CACHE_TTL_MS }; diff --git a/js/historyView.js b/js/historyView.js index 46a3e5d6..92ccac72 100644 --- a/js/historyView.js +++ b/js/historyView.js @@ -7,7 +7,10 @@ import { nostrClient, updateWatchHistoryList, } from "./nostr.js"; -import { WATCH_HISTORY_BATCH_RESOLVE } from "./config.js"; +import { + WATCH_HISTORY_BATCH_RESOLVE, + WATCH_HISTORY_BATCH_PAGE_SIZE, +} from "./config.js"; export const WATCH_HISTORY_EMPTY_COPY = "Your watch history is empty. Watch some videos to populate this list."; @@ -19,7 +22,14 @@ const WATCH_HISTORY_METADATA_PREF_KEY = "bitvid:watch-history:metadata-preference"; const WATCH_HISTORY_PRIVACY_DISMISSED_KEY = "bitvid:watch-history:privacy-banner-dismissed"; -const WATCH_HISTORY_BATCH_SIZE = 12; +const DEFAULT_WATCH_HISTORY_BATCH_SIZE = 12; +const WATCH_HISTORY_BATCH_SIZE = (() => { + const raw = Number(WATCH_HISTORY_BATCH_PAGE_SIZE); + if (!Number.isFinite(raw) || raw <= 0) { + return DEFAULT_WATCH_HISTORY_BATCH_SIZE; + } + return Math.floor(raw); +})(); const FALLBACK_THUMBNAIL = "assets/svg/default-thumbnail.svg"; const FALLBACK_AVATAR = "assets/svg/default-profile.svg"; const isDevEnv = diff --git a/js/nostr.js b/js/nostr.js index 182382a0..0be0469f 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -9,6 +9,7 @@ import { WATCH_HISTORY_LEGACY_LIST_IDENTIFIERS, WATCH_HISTORY_MAX_ITEMS, WATCH_HISTORY_BATCH_RESOLVE, + WATCH_HISTORY_BATCH_PAGE_SIZE, WATCH_HISTORY_PAYLOAD_MAX_BYTES, WATCH_HISTORY_FETCH_EVENT_LIMIT, WATCH_HISTORY_CACHE_TTL_MS, @@ -2986,9 +2987,12 @@ class NostrClient { fallbackItems, ); - const batchLimitRaw = Number(WATCH_HISTORY_BATCH_RESOLVE); - const batchLimit = Number.isFinite(batchLimitRaw) && batchLimitRaw > 0 - ? Math.min(Math.floor(batchLimitRaw), WATCH_HISTORY_MAX_ITEMS) + const shouldBatch = Boolean(WATCH_HISTORY_BATCH_RESOLVE); + const batchPageSizeRaw = Number(WATCH_HISTORY_BATCH_PAGE_SIZE); + const hasCustomBatchSize = + Number.isFinite(batchPageSizeRaw) && batchPageSizeRaw > 0; + const batchLimit = shouldBatch && hasCustomBatchSize + ? Math.min(Math.floor(batchPageSizeRaw), WATCH_HISTORY_MAX_ITEMS) : WATCH_HISTORY_MAX_ITEMS; const canonicalItems = canonicalizeWatchHistoryItems( diff --git a/tests/watch-history.test.mjs b/tests/watch-history.test.mjs index d0d594de..10c9a2a1 100644 --- a/tests/watch-history.test.mjs +++ b/tests/watch-history.test.mjs @@ -3,7 +3,8 @@ import "./test-helpers/setup-localstorage.mjs"; import assert from "node:assert/strict"; -const { WATCH_HISTORY_PAYLOAD_MAX_BYTES } = await import("../js/config.js"); +const { WATCH_HISTORY_PAYLOAD_MAX_BYTES, WATCH_HISTORY_MAX_ITEMS } = + await import("../js/config.js"); const { getWatchHistoryV2Enabled, setWatchHistoryV2Enabled, @@ -604,6 +605,62 @@ async function testPublishSnapshotFailureRetry() { } } +async function testResolveWatchHistoryBatchingWindow() { + const actor = "batch-window-actor"; + const originalEnsure = nostrClient.ensureSessionActor; + const originalPub = nostrClient.pubkey; + const originalFetch = nostrClient.fetchWatchHistory; + const originalCache = nostrClient.watchHistoryCache; + const originalStorage = nostrClient.watchHistoryStorage; + + try { + nostrClient.pubkey = ""; + nostrClient.ensureSessionActor = async () => actor; + nostrClient.watchHistoryCache = new Map(); + nostrClient.watchHistoryStorage = { version: 2, actors: {} }; + + const syntheticItems = Array.from({ length: 24 }, (_, index) => ({ + type: "e", + value: `pointer-${index}`, + watchedAt: 1_700_100_000 + index, + })); + + nostrClient.fetchWatchHistory = async () => ({ + items: syntheticItems.map((item) => ({ ...item })), + snapshotId: "batch-snapshot", + pointerEvent: null, + }); + + const resolved = await nostrClient.resolveWatchHistory(actor, { + forceRefresh: true, + }); + + assert.equal( + resolved.length, + Math.min(WATCH_HISTORY_MAX_ITEMS, syntheticItems.length), + "batched resolve should return the full window when no page size override is configured", + ); + assert(resolved.length > 1, "resolved history should include multiple entries"); + assert.equal( + resolved[0]?.value, + "pointer-23", + "resolved items should remain sorted by newest watchedAt value", + ); + const uniqueValues = new Set(resolved.map((item) => item?.value)); + assert.equal( + uniqueValues.size, + resolved.length, + "resolved history should retain all unique pointers", + ); + } finally { + nostrClient.ensureSessionActor = originalEnsure; + nostrClient.pubkey = originalPub; + nostrClient.fetchWatchHistory = originalFetch; + nostrClient.watchHistoryCache = originalCache; + nostrClient.watchHistoryStorage = originalStorage; + } +} + async function testWatchHistoryServiceIntegration() { poolHarness.reset(); poolHarness.setResolver(() => ({ ok: true })); @@ -719,6 +776,7 @@ async function testWatchHistoryServiceIntegration() { await testPublishSnapshotCanonicalizationAndChunking(); await testPublishSnapshotUsesExtensionCrypto(); await testPublishSnapshotFailureRetry(); +await testResolveWatchHistoryBatchingWindow(); await testWatchHistoryServiceIntegration(); console.log("watch-history.test.mjs completed successfully");