"use client"; import type { TranscriptDetail } from "../lib/transcripts"; import { PLATFORM_VALUES } from "../lib/platform"; const DB_NAME = "yt-dlp-transcript-browser"; // Bump to invalidate stale cached entries on existing clients. v2 wiped // pre-multi-platform records cached without a `platform` field. v3 switches to // out-of-line keys (the record is stored under an OriginId, not its `slug` // keyPath) so cross-origin content in the hub can't collide with same-origin // content that happens to share a channelSlug/videoId. Same-origin ids equal // the bare slug, so behavior is unchanged after the one-time wipe. // v4 adds the optional `hlsUrl` field to cached TranscriptDetail (Kick VOD // playback); wipe so stale entries re-fetch the authoritative page JSON. const DB_VERSION = 4; const STORE = "transcripts"; 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; // Drop any existing store so a version bump discards stale entries // (the cache is a pure perf layer over authoritative page JSON, so // wiping is safe — `load()` re-fetches and re-warms on a miss). if (db.objectStoreNames.contains(STORE)) { db.deleteObjectStore(STORE); } // Out-of-line keys: the caller supplies an OriginId key (see idbPutBatch). 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( `[transcriptStore] IndexedDB unavailable (${reason}); falling back to network-only.`, ); } export async function idbGet( id: string, ): Promise { 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< TranscriptDetail | undefined >; } catch { downgrade("read tx threw"); resolve(null); return; } req.onsuccess = () => { const entry = req.result ?? null; // Reject entries whose shape predates multi-platform support (or is // otherwise corrupt): treat as a miss so `load()` re-fetches the // authoritative page. Self-heals without per-entry version bookkeeping. if (entry && !PLATFORM_VALUES.includes(entry.platform)) { resolve(null); return; } resolve(entry); }; req.onerror = () => resolve(null); }); } type PendingEntry = { id: string; detail: TranscriptDetail }; let pending: PendingEntry[] = []; let flushScheduled = false; let flushInFlight: Promise | null = null; // Store `detail` under the given OriginId (out-of-line key). Same-origin ids // are the bare slug, so single-site behavior is unchanged. export function idbPutBatch(id: string, detail: TranscriptDetail): void { if (mode === "unavailable") return; pending.push({ id, detail }); 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 entry of batch) { try { store.put(entry.detail, entry.id); } catch { // Per-entry errors (e.g. unclonable values) shouldn't fail the batch. } } tx.oncomplete = () => resolve(); tx.onerror = () => { // Quota errors land here; degrade silently after one warn. downgrade("write tx error"); resolve(); }; tx.onabort = () => { downgrade("write tx abort"); resolve(); }; }); }