Merge pull request #1105 from PR0M3TH3AN/codex/update-watch-history-configuration-and-cleanup

Simplify watch history pipeline
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2026-01-06 18:59:09 -05:00
commit b7468e0047
7 files changed
+26 -106

No files matched your search

+5 -19
View File
@@ -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.
*
+2 -1
View File
@@ -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.
+8 -14
View File
@@ -144,8 +144,8 @@ import { updateWatchHistoryListWithDefaultClient } from "./nostrWatchHistoryFaca
| NIP-71 video (`NOTE_TYPES.NIP71_VIDEO`) | `21` | `['title', <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
{
+5 -13
View File
@@ -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
+3 -39
View File
@@ -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)) {
+1 -1
View File
@@ -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") {
+2 -19
View File
@@ -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);
}