From 891226697dc95779b00f35e4163d4d208e0880e4 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 6 Jan 2026 18:53:27 -0500 Subject: [PATCH] Simplify watch history pipeline --- config/instance-config.js | 24 ++++-------------- docs/nostr-analytics.md | 3 ++- docs/nostr-event-schemas.md | 22 ++++++----------- docs/watch-history-logging.md | 18 ++++---------- js/watchHistoryService.js | 42 +++----------------------------- tests/nostr/watchHistory.test.js | 2 +- tests/watch-history.test.mjs | 21 ++-------------- 7 files changed, 26 insertions(+), 106 deletions(-) diff --git a/config/instance-config.js b/config/instance-config.js index 8c81c133..15f519ed 100644 --- a/config/instance-config.js +++ b/config/instance-config.js @@ -293,15 +293,12 @@ export const THEME_ACCENT_OVERRIDES = Object.freeze({ /** * Nostr kind used when persisting watch history events. * - * bitvid’s roadmap standardizes on kind 30079 so that watch events, view logs, - * and media metadata stay in the same family of documents. During the rollout - * from the legacy 30078 payloads, clients query both kinds so historical data - * keeps syncing; remove the compatibility fetch once all writers emit the new - * kind. Operators that want to experiment with a separate list kind (for - * example, a NIP-51 collection) can flip this number so long as their relays - * accept the chosen kind. + * bitvid standardizes on kind 30079 so that watch events, view logs, and media + * metadata stay in the same family of documents. Operators that want to + * experiment with a separate list kind (for example, a NIP-51 collection) can + * flip this number so long as their relays accept the chosen kind. */ -export const WATCH_HISTORY_KIND = 30003; +export const WATCH_HISTORY_KIND = 30079; /** * Identifier applied to the watch-history list when storing it on relays. @@ -313,17 +310,6 @@ export const WATCH_HISTORY_KIND = 30003; */ export const WATCH_HISTORY_LIST_IDENTIFIER = "watch-history"; -/** - * Whether to enable the V2 encrypted watch-history service. - * - * The runtime flag that controls this feature defaults to `false` so that new - * deployments stick with the analytics-only view flow until operators opt in. - * To enable V2, set `window.__BITVID_RUNTIME_FLAGS__.FEATURE_WATCH_HISTORY_V2 = true` - * in a bootstrap script (or override the value before the app loads). When the - * flag stays off, bitvid still emits legacy view events and will honor - * existing watch-history reads per plan §12, but the sync UI will surface a - * disabled banner instead of querying relays. - */ /** * Maximum number of watch-history entries to retain per user. * diff --git a/docs/nostr-analytics.md b/docs/nostr-analytics.md index d10a0847..1fb41edd 100644 --- a/docs/nostr-analytics.md +++ b/docs/nostr-analytics.md @@ -18,7 +18,8 @@ Playback telemetry runs even when a viewer has not connected a Nostr account. Th When a player crosses the 12-second watch threshold, bitvid invokes [`publishViewEvent`](../js/nostr/viewEvents.js#L662-L838) through the view-only [`recordVideoView`](../js/nostr/viewEvents.js#L854-L879) contract. The helper considers the publish successful as soon as any configured relay acknowledges the event, which in practice keeps latency under a second on public relays. The UI optimistically increments totals by feeding the signed event into [`ingestLocalViewEvent`](../js/viewCounter.js#L619-L633), so cards and modals reflect the new view immediately even while other relays finish syncing. -The watch history pipeline has been retired; `recordVideoView` now focuses solely on analytics publishing. +After a view is recorded, the watch history service updates the actor's queue +and publishes snapshots as needed, keeping analytics and history in sync. To avoid double-counting the same session, the playback stack respects the cooldown keys generated in [`js/app.js`](../js/app.js#L6825-L6893). The combination of pointer identity and viewer fingerprint prevents re-logging until the dedupe window expires or the user switches accounts. diff --git a/docs/nostr-event-schemas.md b/docs/nostr-event-schemas.md index 9bb91cdf..05c66dc1 100644 --- a/docs/nostr-event-schemas.md +++ b/docs/nostr-event-schemas.md @@ -144,8 +144,8 @@ import { updateWatchHistoryListWithDefaultClient } from "./nostrWatchHistoryFaca | NIP-71 video (`NOTE_TYPES.NIP71_VIDEO`) | `21` | `['title', ]`, optional `['published_at', <unix seconds>]`, optional `['alt', <text>]`, repeated `['imeta', ...]` entries describing NIP-92 media variants, optional `['duration', <seconds>]`, repeated `['text-track', <url>, <kind>, <language>]`, optional `['content-warning', <reason>]`, repeated `['segment', <start>, <end>, <title>, <thumbnail>]`, repeated hashtags `['t', <tag>]`, repeated participants `['p', <pubkey>, <relay?>]`, repeated references `['r', <url>]` | Plain text summary carried in the content field. Publishing is gated by the `FEATURE_PUBLISH_NIP71` runtime flag while the rollout stabilizes. | | NIP-71 short video (`NOTE_TYPES.NIP71_SHORT_VIDEO`) | `22` | Same as `NOTE_TYPES.NIP71_VIDEO`; the kind differentiates short-form presentations. | Plain text summary; gated by `FEATURE_PUBLISH_NIP71`. | | Relay list (`NOTE_TYPES.RELAY_LIST`) | `10002` | Repeating `['r', <relay url>]` tags, optionally with a marker of `'read'` or `'write'` to scope the relay; marker omitted for read/write relays | Empty content | -| View counter (`NOTE_TYPES.VIEW_EVENT`) | `WATCH_HISTORY_KIND` (default `30079`, clients also read legacy `30078`) | Canonical tag set: `['t','view']`, a pointer tag (`['e', <eventId>]` or `['a', <address>]`), and a stable dedupe tag `['d', <view identifier>]`, with optional `['session','true']` when a session actor signs; schema overrides may append extra tags. `['video', ...]` is supported for legacy overrides only. | Optional plaintext message | -| Watch history month (`NOTE_TYPES.WATCH_HISTORY_INDEX`) | `WATCH_HISTORY_KIND` (default `30079`, clients also read legacy `30078`) | Replaceable list tag `['d', `${WATCH_HISTORY_LIST_IDENTIFIER}:<YYYY-MM>`]` with optional `['month', <YYYY-MM>]` marker plus schema append tags; no chunk pointers required. | JSON payload `{ version, month: 'YYYY-MM', items: [{ id: <eventId\|address>, watched_at?: <unix seconds> }] }` | +| View counter (`NOTE_TYPES.VIEW_EVENT`) | `WATCH_HISTORY_KIND` (default `30079`) | Canonical tag set: `['t','view']`, a pointer tag (`['e', <eventId>]` or `['a', <address>]`), and a stable dedupe tag `['d', <view identifier>]`, with optional `['session','true']` when a session actor signs; schema overrides may append extra tags. `['video', ...]` is supported for legacy overrides only. | Optional plaintext message | +| Watch history month (`NOTE_TYPES.WATCH_HISTORY_INDEX`) | `WATCH_HISTORY_KIND` (default `30079`) | Replaceable list tag `['d', `${WATCH_HISTORY_LIST_IDENTIFIER}:<YYYY-MM>`]` with optional `['month', <YYYY-MM>]` marker plus schema append tags; no chunk pointers required. | JSON payload `{ version, month: 'YYYY-MM', items: [{ id: <eventId\|address>, watched_at?: <unix seconds> }] }` | | Subscription list (`NOTE_TYPES.SUBSCRIPTION_LIST`) | `30000` | `['d', 'subscriptions']` | NIP-04/NIP-44 encrypted JSON array of NIP-51 follow-set tuples (e.g., `[['p', <hex>], …]`) | | User block list (`NOTE_TYPES.USER_BLOCK_LIST`) | `10000` | `['d', 'user-blocks']` | NIP-04/NIP-44 encrypted JSON `{ blockedPubkeys: string[] }` | | Hashtag preferences (`NOTE_TYPES.HASHTAG_PREFERENCES`) | `30015` | `['d', 'bitvid:tag-preferences']` plus schema-appended `['encrypted','nip44_v2']` | NIP-44 encrypted JSON `{ version, interests: string[], disinterests: string[] }` | @@ -243,18 +243,12 @@ experimentation. ### Watch history identifiers -The watch history pipeline is gated by the `FEATURE_WATCH_HISTORY_V2` runtime -flag. When the flag is disabled, clients continue emitting view events but skip -publishing monthly records; the UI still resolves legacy -`watch-history:v2:index` lists so operators can stage the rollout per -deployment.【F:config/instance-config.js†L69-L94】【F:js/watchHistoryService.js†L82-L140】【F:js/watchHistoryService.js†L948-L985】 - -Active identifiers include the default `WATCH_HISTORY_LIST_IDENTIFIER` -(`"watch-history"`) and the legacy aliases enumerated in -`WATCH_HISTORY_LEGACY_LIST_IDENTIFIERS`. Each month publishes as a replaceable -record keyed by `['d', `${identifier}:${YYYY-MM}`]`, optionally echoed in a -`['month', <YYYY-MM>]` tag for readability. The content carries a compact JSON -body such as: +The watch history pipeline is always enabled and publishes encrypted monthly +records to the canonical `WATCH_HISTORY_KIND` stream. Clients rely on the +default `WATCH_HISTORY_LIST_IDENTIFIER` (`"watch-history"`) and store each +month as a replaceable record keyed by `['d', `${identifier}:${YYYY-MM}`]`, +optionally echoed in a `['month', <YYYY-MM>]` tag for readability. The content +contains a compact JSON body such as: ```json { diff --git a/docs/watch-history-logging.md b/docs/watch-history-logging.md index c0272e3e..a216956e 100644 --- a/docs/watch-history-logging.md +++ b/docs/watch-history-logging.md @@ -38,11 +38,11 @@ renderers like `historyView`.【F:config/instance-config.js†L101-L123】【F:j ## Snapshot cadence -`WatchHistoryService.publishView` queues pointer entries while the feature flag -is enabled, merging repeats with a one-minute throttle and persisting the queue -in `sessionStorage`. Snapshots run when `watchHistoryService.snapshot()` is -invoked (e.g., on `beforeunload`, `visibilitychange`, or manual sync actions in -the history UI) and clear the queue on success.【F:js/watchHistoryService.js†L872-L917】【F:js/app.js†L6041-L6081】【F:js/app.js†L7467-L7484】 +`WatchHistoryService.publishView` queues pointer entries, merging repeats with a +one-minute throttle and persisting the queue in `sessionStorage`. Snapshots run +when `watchHistoryService.snapshot()` is invoked (e.g., on `beforeunload`, +`visibilitychange`, or manual sync actions in the history UI) and clear the +queue on success.【F:js/watchHistoryService.js†L872-L917】【F:js/app.js†L6041-L6081】【F:js/app.js†L7467-L7484】 If no items are pending, the snapshot resolves with an empty result so UI calls remain idempotent.【F:js/watchHistoryService.js†L932-L944】 @@ -54,14 +54,6 @@ Nostr client to retry with exponential backoff. Retries start after two seconds, double per attempt with 25% jitter, cap at five minutes, and stop after eight attempts unless a publish succeeds sooner.【F:js/watchHistoryService.js†L948-L985】【F:js/watchHistoryService.js†L723-L777】【F:js/nostr/watchHistory.js†L929-L999】 -## Legacy payload handling - -Readers hydrate history by combining the pointer event, any encrypted chunks, -and fallback metadata embedded in legacy watch-history lists. The resolver still -accepts the `watch-history:v2:index` identifier, merges pointer tags from the -index event, and falls back to plaintext content when decryption fails so older -relays remain compatible.【F:config/instance-config.js†L60-L78】【F:js/nostr/watchHistory.js†L2120-L2170】【F:js/nostr/watchHistory.js†L660-L689】 - ## Local metadata toggles By default the service only persists pointer data to relays; richer metadata is diff --git a/js/watchHistoryService.js b/js/watchHistoryService.js index e8f1a1ed..f2c27a51 100644 --- a/js/watchHistoryService.js +++ b/js/watchHistoryService.js @@ -13,11 +13,6 @@ import { WATCH_HISTORY_CACHE_TTL_MS, WATCH_HISTORY_MAX_ITEMS, } from "./config.js"; -import { - FEATURE_WATCH_HISTORY_V2, - getWatchHistoryV2Enabled, -} from "./constants.js"; -import { isDevMode } from "./config.js"; import { getApplication } from "./applicationContext.js"; import { devLogger, userLogger } from "./utils/logger.js"; @@ -32,20 +27,6 @@ const state = { listeners: new Map(), }; -function resolveFlagEnabled() { - try { - if (typeof getWatchHistoryV2Enabled === "function") { - return getWatchHistoryV2Enabled() === true; - } - } catch (error) { - devLogger.warn( - "[watchHistoryService] Failed to read FEATURE_WATCH_HISTORY_V2 flag:", - error, - ); - } - return FEATURE_WATCH_HISTORY_V2 === true; -} - function getLoggedInActorKey() { const direct = normalizeActorKey(nostrClient?.pubkey); if (direct) { @@ -162,29 +143,12 @@ function resolveEffectiveActorKey(actorInput) { } function isFeatureEnabled(actorInput) { - const baseEnabled = resolveFlagEnabled(); const actorKey = resolveEffectiveActorKey(actorInput); - const logged = getLoggedInActorKey(); const session = getSessionActorKey(); - - if (actorKey && logged && actorKey === logged) { - return true; - } - if (actorKey && session && actorKey === session) { return false; } - - if (!actorKey) { - if (logged) { - return true; - } - if (session) { - return false; - } - } - - return baseEnabled; + return true; } function isLocalOnly(actorInput) { @@ -881,14 +845,14 @@ async function snapshot(items, options = {}) { if (!isFeatureEnabled(actorKey)) { devLogger.debug( - "[watchHistoryService] Snapshot skipped because watch history sync is unavailable for this actor.", + "[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: "feature-disabled" }; + return { ok: true, skipped: true, reason: "local-only" }; } if (state.inflightSnapshots.has(actorKey)) { diff --git a/tests/nostr/watchHistory.test.js b/tests/nostr/watchHistory.test.js index 8fe7d92d..7be1c096 100644 --- a/tests/nostr/watchHistory.test.js +++ b/tests/nostr/watchHistory.test.js @@ -11,7 +11,7 @@ import { } from "../../js/nostr/watchHistory.js"; import { WATCH_HISTORY_KIND } from "../../js/config.js"; -const WATCH_HISTORY_STORAGE_KEY = "bitvid:watch-history:v2"; +const WATCH_HISTORY_STORAGE_KEY = "bitvid:watch-history:v4"; function ensureLocalStorageCleared() { if (typeof localStorage?.clear === "function") { diff --git a/tests/watch-history.test.mjs b/tests/watch-history.test.mjs index 2dd5f9a6..9ab7a6eb 100644 --- a/tests/watch-history.test.mjs +++ b/tests/watch-history.test.mjs @@ -13,10 +13,6 @@ const { WATCH_HISTORY_KIND, } = await import("../js/config.js"); -const { - getWatchHistoryV2Enabled, - setWatchHistoryV2Enabled, -} = await import("../js/constants.js"); const [{ nostrClient }, { setActiveSigner, getActiveSigner, clearActiveSigner }] = await Promise.all([ import("../js/nostrClientFacade.js"), @@ -43,9 +39,6 @@ if (!window.crypto || !window.crypto.subtle) { window.crypto = webcrypto; } -const originalFlag = getWatchHistoryV2Enabled(); -setWatchHistoryV2Enabled(true); - const originalWindowNostr = window.nostr; const originalNostrTools = window.NostrTools || {}; const originalWatchHistoryEnsureTools = @@ -2323,7 +2316,6 @@ async function testWatchHistoryLocalFallbackWhenDisabled() { const originalSession = nostrClient.sessionActor; try { - setWatchHistoryV2Enabled(false); localStorage.clear(); watchHistoryService.resetProgress(); nostrClient.pubkey = ""; @@ -2358,7 +2350,7 @@ async function testWatchHistoryLocalFallbackWhenDisabled() { assert.equal( enabled, false, - "sync should be disabled for session actors when feature flag is off", + "sync should be disabled for session actors", ); const createdAt = 1_700_500_000; @@ -2387,7 +2379,6 @@ async function testWatchHistoryLocalFallbackWhenDisabled() { "local fallback entries should carry watchedAt timestamps", ); } finally { - setWatchHistoryV2Enabled(true); watchHistoryService.resetProgress(); nostrClient.recordVideoView = originalRecordView; nostrClient.pubkey = originalPub; @@ -2405,7 +2396,6 @@ async function testWatchHistorySyncEnabledForLoggedInUsers() { const originalFingerprint = nostrClient.getWatchHistoryFingerprint; try { - setWatchHistoryV2Enabled(false); localStorage.clear(); watchHistoryService.resetProgress(); nostrClient.pubkey = actor; @@ -2452,7 +2442,7 @@ async function testWatchHistorySyncEnabledForLoggedInUsers() { assert.equal( enabled, true, - "sync should remain enabled for logged-in actors even when the flag is disabled", + "sync should remain enabled for logged-in actors", ); const items = await watchHistoryService.loadLatest(actor); @@ -2469,7 +2459,6 @@ async function testWatchHistorySyncEnabledForLoggedInUsers() { "fingerprint cache should update for logged-in actors", ); } finally { - setWatchHistoryV2Enabled(true); watchHistoryService.resetProgress(); nostrClient.resolveWatchHistory = originalResolve; nostrClient.getWatchHistoryFingerprint = originalFingerprint; @@ -2487,7 +2476,6 @@ async function testWatchHistoryAppLoginFallback() { const originalApp = getApplication(); try { - setWatchHistoryV2Enabled(false); localStorage.clear(); watchHistoryService.resetProgress(); nostrClient.pubkey = ""; @@ -2512,7 +2500,6 @@ async function testWatchHistoryAppLoginFallback() { "sync should be enabled when the app reports a logged-in pubkey", ); } finally { - setWatchHistoryV2Enabled(true); watchHistoryService.resetProgress(); nostrClient.pubkey = originalPub; nostrClient.sessionActor = originalSession; @@ -2682,7 +2669,3 @@ if (originalExtensionPermissionSnapshot.length) { } else { clearStoredExtensionPermissions(); } - -if (!originalFlag) { - setWatchHistoryV2Enabled(false); -}