"use client"; import type { VideoDigest } from "../lib/digests"; // A SEPARATE DATABASE, not a second store inside transcriptStore.ts's. // // `DB_VERSION` is a property of the DATABASE, not of an object store, and // transcriptStore.ts's `onupgradeneeded` does deleteObjectStore + // createObjectStore on every upgrade. Adding a `digests` store there would // force a version bump and WIPE every existing client's transcript cache — a // large, silent, entirely avoidable cost for shipping a new layer. Following // searchLayerCache.ts instead: own DB name, own version, independent evolution. const DB_NAME = "yt-dlp-transcript-browser:digests"; const DB_VERSION = 1; const STORE = "digests"; // PER-ENTRY VERSIONING, which transcripts deliberately do without. // // transcriptStore.ts gets away with an ad-hoc shape sniff (does `platform` look // valid?) because a transcript is near-immutable: once written it does not // change, so a structurally-valid cached copy is also a CURRENT one. Digests // break that assumption — they are regenerated in place whenever the prompt, // model or a human correction changes, and the regenerated record has exactly // the same shape. A shape sniff cannot see the difference, so a returning // reader would keep being served the superseded digest forever, which is the // one failure this whole layer exists to avoid (a corrected chapter that never // reaches anyone). We therefore store the record's own `generatedAt` alongside // it and treat any mismatch with the manifest-fresh copy as a miss. // // This is the same class of bug already recorded for the transcript cache when // TranscriptDetail's shape changed; here it is designed out rather than patched // with a version bump after the fact. // // The version we compare is the CONTENT HASH of the page the record came from // (ChannelDigestsManifest.pageHashes), not the record's own `generatedAt`. Both // detect a regeneration, but only the page hash is knowable from the manifest // alone — i.e. before paying for the fetch the cache exists to avoid — and it // additionally catches changes that leave `generatedAt` untouched, such as a // human retitling a chapter through the overrides file. type StoredEntry = { digest: VideoDigest; // The manifest pageHash this record was fetched under. version: string; }; type Mode = "pending" | "ok" | "unavailable"; let mode: Mode = "pending"; let dbPromise: Promise | null = null; function openDb(): Promise { if (typeof indexedDB === "undefined") { mode = "unavailable"; return Promise.resolve(null); } if (dbPromise) return dbPromise; dbPromise = new Promise((resolve) => { let req: IDBOpenDBRequest; try { req = indexedDB.open(DB_NAME, DB_VERSION); } catch { downgrade("open threw"); resolve(null); return; } req.onupgradeneeded = () => { const db = req.result; if (db.objectStoreNames.contains(STORE)) { db.deleteObjectStore(STORE); } // Out-of-line keys: the caller supplies an OriginId (see idbPut). db.createObjectStore(STORE); }; req.onsuccess = () => { mode = "ok"; resolve(req.result); }; req.onerror = () => { downgrade("open failed"); resolve(null); }; req.onblocked = () => { downgrade("open blocked"); resolve(null); }; }); return dbPromise; } function downgrade(reason: string): void { if (mode === "unavailable") return; mode = "unavailable"; console.warn( `[digestStore] IndexedDB unavailable (${reason}); falling back to network-only.`, ); } // Read a cached digest, but only if its recorded version matches `expected` // (the page hash the manifest says is current). A mismatch — or an entry // written before this field existed — resolves null, so the caller re-fetches. // An empty `expected` also misses: a record we cannot version is one we must // not serve from cache. export async function idbGet( id: string, expected: string, ): Promise { if (!expected) return null; const db = await openDb(); if (!db) return null; return new Promise((resolve) => { let req: IDBRequest; try { const tx = db.transaction(STORE, "readonly"); req = tx.objectStore(STORE).get(id) as IDBRequest; } catch { downgrade("read tx threw"); resolve(null); return; } req.onsuccess = () => { const entry = req.result; if (!entry || !entry.digest || entry.version !== expected) { resolve(null); return; } resolve(entry.digest); }; req.onerror = () => resolve(null); }); } type PendingEntry = { id: string; entry: StoredEntry }; let pending: PendingEntry[] = []; let flushScheduled = false; let flushInFlight: Promise | null = null; // Queue a digest for persistence under the given OriginId, stamped with the // page hash it was fetched under. Batched via queueMicrotask exactly as // transcriptStore.ts does, so warming a whole page's worth of digests costs one // transaction. An unversioned write is dropped rather than stored — an entry // with no version could never be validated and would only ever waste quota. export function idbPut(id: string, digest: VideoDigest, version: string): void { if (mode === "unavailable") return; if (!version) return; pending.push({ id, entry: { digest, version } }); if (flushScheduled) return; flushScheduled = true; queueMicrotask(() => { flushScheduled = false; void flush(); }); } async function flush(): Promise { if (flushInFlight) { await flushInFlight; } if (pending.length === 0) return; const batch: PendingEntry[] = pending; pending = []; flushInFlight = writeBatch(batch).finally(() => { flushInFlight = null; if (pending.length > 0 && !flushScheduled) { flushScheduled = true; queueMicrotask(() => { flushScheduled = false; void flush(); }); } }); await flushInFlight; } async function writeBatch(batch: PendingEntry[]): Promise { const db = await openDb(); if (!db) return; return new Promise((resolve) => { let tx: IDBTransaction; try { tx = db.transaction(STORE, "readwrite"); } catch { downgrade("write tx threw"); resolve(); return; } const store = tx.objectStore(STORE); for (const item of batch) { try { store.put(item.entry, item.id); } catch { // Per-entry errors (e.g. unclonable values) shouldn't fail the batch. } } tx.oncomplete = () => resolve(); tx.onerror = () => { downgrade("write tx error"); resolve(); }; tx.onabort = () => { downgrade("write tx abort"); resolve(); }; }); }