mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
The convert-time guard (17b48cbf) only blocks FRESH events. Audio notes cached
before it existed are rehydrated straight from IndexedDB/localStorage as
already-converted video objects (`sig: "unavailable (cached)"`), bypassing
convertEventToVideo — so they kept showing in the main feed and channel grids.
Extract the audio check into a reusable object-level predicate
`isAudioOnlyVideoObject(video)` (url + magnet + imeta tags) and apply it at every
boundary a cached object can slip through:
- convertEventToVideo now delegates to it (fresh ingest, unchanged behavior)
- PersistenceManager.applyCachedPayload drops audio-only on hydration (purges
the cache going forward)
- NostrClient.getActiveVideos filters it (main feed, immediate effect)
- nostrService.shouldIncludeVideo filters it above the viewer-is-author
short-circuit (channel/author grids, incl. the creator's own channel)
Audio-only = no magnet AND no imeta `m video/*` AND (imeta `m audio/*` OR an
unambiguous audio url); `.ogg` alone stays video. Bumped client.js size baseline
(+6 lines). Tests: video-schema-and-conversion (+3 predicate scenarios, 11/11);
nostr-service + ingest-adapter suites green. Lint + build clean. TODO #60.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
362 lines
11 KiB
JavaScript
362 lines
11 KiB
JavaScript
import { EventsCacheStore } from "./EventsCacheStore.js";
|
|
import { isAudioOnlyVideoObject } from "../nip71.js";
|
|
import { devLogger, userLogger } from "../../utils/logger.js";
|
|
import { isDevMode } from "../../config.js";
|
|
import { NOTE_TYPES } from "../../nostrEventSchemas.js";
|
|
import { CACHE_POLICIES } from "../cachePolicies.js";
|
|
|
|
const EVENTS_CACHE_STORAGE_KEY = "bitvid:eventsCache:v1";
|
|
const EVENTS_CACHE_TTL_MS = CACHE_POLICIES[NOTE_TYPES.VIDEO_POST]?.ttl ?? (10 * 60 * 1000);
|
|
const EVENTS_CACHE_PERSIST_DELAY_MS = 450;
|
|
const EVENTS_CACHE_IDLE_TIMEOUT_MS = 1500;
|
|
|
|
function scheduleIdleTask(callback, timeout = EVENTS_CACHE_IDLE_TIMEOUT_MS) {
|
|
if (typeof requestIdleCallback === "function") {
|
|
return requestIdleCallback(callback, { timeout });
|
|
}
|
|
return setTimeout(callback, timeout);
|
|
}
|
|
|
|
export class PersistenceManager {
|
|
constructor(client) {
|
|
this.client = client;
|
|
this.eventsCacheStore = new EventsCacheStore();
|
|
this.cachePersistTimerId = null;
|
|
this.cachePersistIdleId = null;
|
|
this.cachePersistInFlight = null;
|
|
this.cachePersistReason = null;
|
|
this.hasRestoredLocalData = false;
|
|
}
|
|
|
|
/**
|
|
* Restores application state from the best available local cache.
|
|
*
|
|
* 1. Attempts to load from `IndexedDB` (preferred).
|
|
* 2. Falls back to `localStorage` if IDB fails or is empty.
|
|
* 3. Rehydrates `allEvents`, `activeMap`, and `tombstones`.
|
|
*
|
|
* This implements the "Stale-While-Revalidate" pattern: the UI renders
|
|
* immediately with this data while network requests proceed in the background.
|
|
*
|
|
* @returns {Promise<boolean>} True if data was successfully restored.
|
|
*/
|
|
async restoreLocalData() {
|
|
if (this.hasRestoredLocalData) {
|
|
return this.client.allEvents.size > 0;
|
|
}
|
|
|
|
this.hasRestoredLocalData = true;
|
|
|
|
const restoredFromIndexedDb = await this.restoreFromIndexedDb();
|
|
if (restoredFromIndexedDb) {
|
|
return true;
|
|
}
|
|
|
|
return this.restoreFromLocalStorage();
|
|
}
|
|
|
|
async restoreFromIndexedDb() {
|
|
try {
|
|
const payload = await this.eventsCacheStore.restoreSnapshot();
|
|
if (!payload) {
|
|
return false;
|
|
}
|
|
return this.applyCachedPayload(payload, "IndexedDB");
|
|
} catch (error) {
|
|
devLogger.warn("[nostr] Failed to restore IndexedDB cache:", error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
restoreFromLocalStorage() {
|
|
if (typeof localStorage === "undefined") {
|
|
return false;
|
|
}
|
|
|
|
const now = Date.now();
|
|
const parsePayload = (raw) => {
|
|
if (!raw) {
|
|
return null;
|
|
}
|
|
try {
|
|
const parsed = JSON.parse(raw);
|
|
if (parsed && typeof parsed === "object") {
|
|
return parsed;
|
|
}
|
|
} catch (err) {
|
|
devLogger.warn("[nostr] Failed to parse cached events:", err);
|
|
}
|
|
return null;
|
|
};
|
|
|
|
let payload = parsePayload(localStorage.getItem(EVENTS_CACHE_STORAGE_KEY));
|
|
|
|
const applied = this.applyCachedPayload(payload, "localStorage");
|
|
|
|
if (!applied && payload) {
|
|
try {
|
|
localStorage.removeItem(EVENTS_CACHE_STORAGE_KEY);
|
|
} catch (err) {
|
|
devLogger.warn("[nostr] Failed to clear expired cache:", err);
|
|
}
|
|
}
|
|
|
|
return applied;
|
|
}
|
|
|
|
applyCachedPayload(payload, sourceLabel) {
|
|
if (!payload || payload.version !== 1) {
|
|
return false;
|
|
}
|
|
|
|
const now = Date.now();
|
|
if (
|
|
typeof payload.savedAt !== "number" ||
|
|
payload.savedAt <= 0 ||
|
|
now - payload.savedAt > EVENTS_CACHE_TTL_MS
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
const events = payload.events;
|
|
if (!events || (typeof events !== "object" && !(events instanceof Map))) {
|
|
return false;
|
|
}
|
|
|
|
this.client.allEvents.clear();
|
|
this.client.rawEvents.clear();
|
|
this.client.activeMap.clear();
|
|
this.client.rootCreatedAtByRoot.clear();
|
|
this.client.tombstones.clear();
|
|
this.client.dirtyEventIds.clear();
|
|
this.client.dirtyTombstones.clear();
|
|
|
|
const tombstoneEntries =
|
|
events instanceof Map && payload.tombstones instanceof Map
|
|
? payload.tombstones.entries()
|
|
: Array.isArray(payload.tombstones)
|
|
? payload.tombstones
|
|
: [];
|
|
|
|
for (const entry of tombstoneEntries) {
|
|
const [key, value] = Array.isArray(entry) ? entry : [entry.key, entry.timestamp];
|
|
const normalizedKey = typeof key === "string" ? key.trim() : "";
|
|
const timestamp = Number.isFinite(value) ? Math.floor(value) : 0;
|
|
if (normalizedKey && timestamp > 0) {
|
|
this.client.tombstones.set(normalizedKey, timestamp);
|
|
}
|
|
}
|
|
|
|
const eventEntries =
|
|
events instanceof Map ? events.entries() : Object.entries(events);
|
|
|
|
for (const [id, video] of eventEntries) {
|
|
if (!id || !video || typeof video !== "object") {
|
|
continue;
|
|
}
|
|
|
|
// Drop audio-only notes (podcasts/music) cached before the audio guard
|
|
// existed so they don't get rehydrated into the feed. See TODO #60.
|
|
if (isAudioOnlyVideoObject(video)) {
|
|
continue;
|
|
}
|
|
|
|
this.client.applyRootCreatedAt(video);
|
|
const activeKey = this.client.getActiveKey(video);
|
|
|
|
if (video.deleted) {
|
|
this.client.recordTombstone(activeKey, video.created_at);
|
|
} else {
|
|
this.client.applyTombstoneGuard(video);
|
|
}
|
|
|
|
this.client.allEvents.set(id, video);
|
|
if (video.deleted) {
|
|
continue;
|
|
}
|
|
|
|
const existing = this.client.activeMap.get(activeKey);
|
|
if (!existing || video.created_at > existing.created_at) {
|
|
this.client.activeMap.set(activeKey, video);
|
|
}
|
|
}
|
|
|
|
if (this.client.allEvents.size > 0 && isDevMode) {
|
|
devLogger.log(
|
|
`[nostr] Restored ${this.client.allEvents.size} cached events from ${sourceLabel}`,
|
|
);
|
|
}
|
|
|
|
this.client.dirtyEventIds.clear();
|
|
this.client.dirtyTombstones.clear();
|
|
|
|
return this.client.allEvents.size > 0;
|
|
}
|
|
|
|
clearCachePersistHandles() {
|
|
if (this.cachePersistTimerId) {
|
|
clearTimeout(this.cachePersistTimerId);
|
|
this.cachePersistTimerId = null;
|
|
}
|
|
|
|
if (this.cachePersistIdleId) {
|
|
if (typeof cancelIdleCallback === "function") {
|
|
try {
|
|
cancelIdleCallback(this.cachePersistIdleId);
|
|
} catch (error) {
|
|
devLogger.warn("[nostr] Failed to cancel cache idle callback:", error);
|
|
}
|
|
} else {
|
|
clearTimeout(this.cachePersistIdleId);
|
|
}
|
|
this.cachePersistIdleId = null;
|
|
}
|
|
}
|
|
|
|
buildCachePayload() {
|
|
return {
|
|
version: 1,
|
|
savedAt: Date.now(),
|
|
events: new Map(this.client.allEvents),
|
|
tombstones: new Map(this.client.tombstones),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Schedules a persistence operation to save current state to cache.
|
|
*
|
|
* - Uses a debounce strategy (75ms) to prevent write thrashing during bursts.
|
|
* - Can be forced to run immediately via `options.immediate`.
|
|
* - Coordinates with `requestIdleCallback` to avoid blocking the main thread.
|
|
*
|
|
* @param {string} [reason="unspecified"] - Debug label for why persistence was triggered.
|
|
* @param {object} [options] - Configuration.
|
|
* @param {boolean} [options.immediate=false] - If true, bypasses debounce and persists immediately.
|
|
* @returns {Promise<boolean>|null} The persistence promise or null if debounced.
|
|
*/
|
|
saveLocalData(reason = "unspecified", options = {}) {
|
|
const { immediate = false } = options;
|
|
|
|
if (immediate) {
|
|
this.clearCachePersistHandles();
|
|
this.cachePersistInFlight = this.persistLocalData(reason).finally(() => {
|
|
this.cachePersistInFlight = null;
|
|
});
|
|
return this.cachePersistInFlight;
|
|
}
|
|
|
|
if (this.cachePersistTimerId || this.cachePersistIdleId || this.cachePersistInFlight) {
|
|
return this.cachePersistInFlight;
|
|
}
|
|
|
|
this.cachePersistReason = reason;
|
|
devLogger.log(
|
|
`[nostr] Scheduling cached events persist (${reason}) with debounce ${EVENTS_CACHE_PERSIST_DELAY_MS}ms`,
|
|
);
|
|
this.cachePersistTimerId = setTimeout(() => {
|
|
this.cachePersistTimerId = null;
|
|
this.cachePersistIdleId = scheduleIdleTask(() => {
|
|
this.cachePersistIdleId = null;
|
|
this.cachePersistInFlight = this.persistLocalData(reason).finally(() => {
|
|
this.cachePersistInFlight = null;
|
|
});
|
|
});
|
|
}, EVENTS_CACHE_PERSIST_DELAY_MS);
|
|
|
|
return this.cachePersistInFlight;
|
|
}
|
|
|
|
/**
|
|
* Executes the actual persistence logic, writing to IndexedDB (preferred) or localStorage.
|
|
*
|
|
* @param {string} reason - Debug label.
|
|
* @returns {Promise<boolean>} True if successful.
|
|
* @private
|
|
*/
|
|
async persistLocalData(reason = "unspecified") {
|
|
// Strategy:
|
|
// 1. Try to persist to IndexedDB (preferred, large capacity).
|
|
// 2. If IDB fails or returns false, fall back to LocalStorage (limited capacity).
|
|
// The `EventsCacheStore` handles differential updates (only saving changed fingerprints)
|
|
// to keep IDB writes fast.
|
|
|
|
// Capture snapshots of dirty sets to optimize persistence
|
|
const dirtyEventIdsSnapshot = new Set(this.client.dirtyEventIds);
|
|
const dirtyTombstonesSnapshot = new Set(this.client.dirtyTombstones);
|
|
|
|
// Clear the tracked items from the main sets so we don't re-process them next time.
|
|
// New items added during the async save will remain in the main sets.
|
|
for (const id of dirtyEventIdsSnapshot) {
|
|
this.client.dirtyEventIds.delete(id);
|
|
}
|
|
for (const key of dirtyTombstonesSnapshot) {
|
|
this.client.dirtyTombstones.delete(key);
|
|
}
|
|
|
|
const payload = this.buildCachePayload();
|
|
const startedAt = Date.now();
|
|
let summary = null;
|
|
let target = "localStorage";
|
|
|
|
try {
|
|
summary = await this.eventsCacheStore.persistSnapshot(
|
|
payload,
|
|
dirtyEventIdsSnapshot,
|
|
dirtyTombstonesSnapshot
|
|
);
|
|
if (summary?.persisted) {
|
|
target = "IndexedDB";
|
|
}
|
|
} catch (error) {
|
|
devLogger.warn(
|
|
"[nostr] Failed to persist events cache to IndexedDB:",
|
|
error
|
|
);
|
|
}
|
|
|
|
if (!summary?.persisted) {
|
|
// If persistence failed, re-queue the dirty items for the next attempt.
|
|
for (const id of dirtyEventIdsSnapshot) {
|
|
this.client.dirtyEventIds.add(id);
|
|
}
|
|
for (const key of dirtyTombstonesSnapshot) {
|
|
this.client.dirtyTombstones.add(key);
|
|
}
|
|
this.persistCacheToLocalStorage(payload);
|
|
}
|
|
|
|
const durationMs = Date.now() - startedAt;
|
|
devLogger.log(
|
|
`[nostr] Cached events persisted via ${target} (reason=${reason}, duration=${durationMs}ms, events+${summary?.eventWrites ?? 0}/-${summary?.eventDeletes ?? 0}, tombstones+${summary?.tombstoneWrites ?? 0}/-${summary?.tombstoneDeletes ?? 0}, skipped=${summary?.skipped ?? 0})`,
|
|
);
|
|
|
|
return summary?.persisted;
|
|
}
|
|
|
|
persistCacheToLocalStorage(payload) {
|
|
if (typeof localStorage === "undefined") {
|
|
return;
|
|
}
|
|
|
|
const serializedEvents = {};
|
|
for (const [id, vid] of payload.events.entries()) {
|
|
serializedEvents[id] = vid;
|
|
}
|
|
|
|
const serializedPayload = {
|
|
...payload,
|
|
events: serializedEvents,
|
|
tombstones: Array.from(payload.tombstones.entries()),
|
|
};
|
|
|
|
try {
|
|
localStorage.setItem(
|
|
EVENTS_CACHE_STORAGE_KEY,
|
|
JSON.stringify(serializedPayload),
|
|
);
|
|
} catch (err) {
|
|
devLogger.warn("[nostr] Failed to persist events cache:", err);
|
|
}
|
|
}
|
|
}
|