diff --git a/CHANGELOG.md b/CHANGELOG.md index b7af742..8594757 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi ## [Unreleased] ### Changed +- **Rehearser library extraction.** SQLite persistence helpers and the one-time IndexedDB migration now live in + `rehearser-library.js`, loaded with the Rehearser/Reader/Library lazy groups; behavior remains global-compatible. - **Expanded resource diagnostics.** The Performance panel now reports bounded browser cache counters, container cgroup current/max memory, artifact-cache bytes, and sanitized largest recent operation durations. - **Current-release constrained validation.** Built `1.24.21` and served it under a hard 4 GiB cgroup limit; diff --git a/TODO.md b/TODO.md index c9d91c8..6738be1 100644 --- a/TODO.md +++ b/TODO.md @@ -123,6 +123,7 @@ architecture change: the larger Audiobook/Rehearser/style split. - [x] Add a dependency-free frontend syntax and top-level declaration collision gate (`npm run check:frontend`); a full style/lint policy remains a follow-up. - [ ] Extract Rehearser voice matching and voice design. +- [x] Extract Rehearser library persistence and IndexedDB migration into `rehearser-library.js`. - [ ] Extract Audiobook parsing and casting. - [ ] Split `style.css` by feature. - [ ] Do not migrate to TypeScript or rewrite the frontend until behavior is covered by tests. diff --git a/VERSION b/VERSION index 89394fd..5f4c2c9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.24.23 +1.24.24 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b4505e5..c35dca0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -33,7 +33,8 @@ Done incrementally as global-scope ordered files (no ES-module change needed) - ⬜ `rehearser-cast.js` — `renderCastList`, sort/filter, online voice picker, character research. - ⬜ `rehearser-stage.js` — playback, synth cache, pre-decode window, transport. - ⬜ `rehearser-train.js` — mic record / STT compare. -- ⬜ `rehearser-library.js` — IndexedDB session store, import/export, auto-save. +- ✅ `rehearser-library.js` — SQLite persistence helpers and IndexedDB migration; import/export and auto-save remain + in `rehearser.js` until their UI/event-handler dependencies are isolated. **Pattern (proven):** extract a cohesive block whose only cross-file references resolve at *call time* (event handlers), not at module load. Top-level `const rehState` and top-level `addEventListener` registrations must stay ordered — extract those last and load them after their dependencies. Re-run `npx playwright test` after each extraction. diff --git a/static/js/rehearser-library.js b/static/js/rehearser-library.js new file mode 100644 index 0000000..2367dd1 --- /dev/null +++ b/static/js/rehearser-library.js @@ -0,0 +1,59 @@ +// ── Rehearser library persistence ──────────────────────────────────────────── +// SQLite-backed rehearsal records and the one-time IndexedDB migration are +// isolated from parsing, casting, and stage playback. Functions stay global +// during the compatibility migration because audiobook.js/library.js call them. + +async function rehDbGetAll() { + const r = await fetch('/api/rehearsals'); + if (!r.ok) throw new Error('rehDbGetAll failed: ' + r.status); + const d = await r.json(); + return d.rehearsals || []; +} +async function rehDbGetById(id) { + const r = await fetch('/api/rehearsals/' + id); + if (r.status === 404) return undefined; + if (!r.ok) throw new Error('rehDbGetById failed: ' + r.status); + return r.json(); +} +async function rehDbAdd(record) { + const r = await fetch('/api/rehearsals', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(_rehSanitize(record)) }); + if (!r.ok) throw new Error('rehDbAdd failed: ' + r.status); + return (await r.json()).id; +} +async function rehDbPut(record) { + if (!record.id) return rehDbAdd(record); + const r = await fetch('/api/rehearsals/' + record.id, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(_rehSanitize(record)) }); + if (!r.ok) throw new Error('rehDbPut failed: ' + r.status); +} +async function rehDbDelete(id) { + const r = await fetch('/api/rehearsals/' + id, { method: 'DELETE' }); + if (!r.ok) throw new Error('rehDbDelete failed: ' + r.status); +} +function _rehSanitize(rec) { + const out = { ...rec }; + if (out.clips) out.clips = (out.clips || []).map(c => ({ lineIndex: c.lineIndex, speaker: c.speaker, type: c.type })); + return out; +} +function _rehIdbGetAll() { + return new Promise(resolve => { + const req = indexedDB.open('reh-library', 1); + req.onerror = () => resolve([]); + req.onsuccess = e => { + const db = e.target.result; + if (!db.objectStoreNames.contains('rehearsals')) { db.close(); resolve([]); return; } + const all = db.transaction('rehearsals', 'readonly').objectStore('rehearsals').getAll(); + all.onsuccess = ev => { db.close(); resolve(ev.target.result || []); }; + all.onerror = () => { db.close(); resolve([]); }; + }; + }); +} +(async function _rehMigrateIfNeeded() { + try { + if ((await rehDbGetAll()).length) return; + const old = await _rehIdbGetAll(); + if (!old.length) return; + const r = await fetch('/api/rehearsals/migrate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(old.map(_rehSanitize)) }); + if (r.ok) console.log(`[rehearser] migrated ${(await r.json()).imported} records from IndexedDB → SQLite`); + } catch (e) { console.warn('[rehearser] migration skipped:', e); } +})(); +window.rehDbGetById = rehDbGetById; diff --git a/static/js/rehearser.js b/static/js/rehearser.js index 268c425..8bcdb48 100644 --- a/static/js/rehearser.js +++ b/static/js/rehearser.js @@ -120,92 +120,6 @@ const rehState = { }; window.rehState = rehState; -// ── Library — SQLite via /api/rehearsals ────────────────────────────────── - -async function rehDbGetAll() { - const r = await fetch('/api/rehearsals'); - if (!r.ok) throw new Error('rehDbGetAll failed: ' + r.status); - const d = await r.json(); - return d.rehearsals || []; -} - -async function rehDbGetById(id) { - const r = await fetch('/api/rehearsals/' + id); - if (r.status === 404) return undefined; - if (!r.ok) throw new Error('rehDbGetById failed: ' + r.status); - return r.json(); -} - -async function rehDbAdd(record) { - const r = await fetch('/api/rehearsals', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(_rehSanitize(record)), - }); - if (!r.ok) throw new Error('rehDbAdd failed: ' + r.status); - const d = await r.json(); - return d.id; -} - -async function rehDbPut(record) { - if (!record.id) { return rehDbAdd(record); } - const r = await fetch('/api/rehearsals/' + record.id, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(_rehSanitize(record)), - }); - if (!r.ok) throw new Error('rehDbPut failed: ' + r.status); -} - -async function rehDbDelete(id) { - const r = await fetch('/api/rehearsals/' + id, { method: 'DELETE' }); - if (!r.ok) throw new Error('rehDbDelete failed: ' + r.status); -} - -// Strip binary clip blobs before sending — blobs can't be JSON-serialised and -// are session-only anyway (they live in rehState.clips, not the library record). -function _rehSanitize(rec) { - const out = { ...rec }; - if (out.clips) out.clips = (out.clips || []).map(c => ({ lineIndex: c.lineIndex, speaker: c.speaker, type: c.type })); - return out; -} - -// One-time IndexedDB → SQLite migration. Runs silently on page load. -(async function _rehMigrateIfNeeded() { - try { - const serverRecs = await rehDbGetAll(); - if (serverRecs.length > 0) return; - const idbRecs = await _rehIdbGetAll().catch(() => []); - if (!idbRecs.length) return; - const r = await fetch('/api/rehearsals/migrate', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(idbRecs.map(_rehSanitize)), - }); - if (r.ok) { - const d = await r.json(); - console.log(`[rehearser] migrated ${d.imported} records from IndexedDB → SQLite`); - } - } catch (e) { - console.warn('[rehearser] migration skipped:', e); - } -})(); - -function _rehIdbGetAll() { - return new Promise((resolve) => { - const req = indexedDB.open('reh-library', 1); - req.onerror = () => resolve([]); - req.onsuccess = e => { - const db = e.target.result; - if (!db.objectStoreNames.contains('rehearsals')) { db.close(); resolve([]); return; } - const all = db.transaction('rehearsals', 'readonly').objectStore('rehearsals').getAll(); - all.onsuccess = ev => { db.close(); resolve(ev.target.result || []); }; - all.onerror = () => { db.close(); resolve([]); }; - }; - }); -} - -window.rehDbGetById = rehDbGetById; window.rehLoadRecord = loadRecord; // expose so audiobook.js can open a record directly // ── Serialization ───────────────────────────────────────────────────────── diff --git a/static/module-manifest.json b/static/module-manifest.json index ac2ed74..972367e 100644 --- a/static/module-manifest.json +++ b/static/module-manifest.json @@ -4,9 +4,9 @@ "integrations", "routing", "voice-clone", "voice-library", "tts-preview", "generation", "benchmark", "stt", "namespace" ], "lazy": { - "s-rehearser": ["rehearser-parse", "rehearser"], - "s-reader": ["rehearser-parse", "rehearser", "characters-library", "reader", "audiobook/network", "audiobook", "character-sheets"], - "s-library": ["rehearser-parse", "rehearser", "characters-library", "reader", "audiobook/network", "audiobook", "character-sheets", "library", "character-columns", "library-characters", "sillytavern"], - "s-caststudio": ["rehearser-parse", "rehearser", "characters-library", "reader", "audiobook/network", "audiobook", "character-sheets", "library", "character-columns", "library-characters", "sillytavern", "studio"] + "s-rehearser": ["rehearser-parse", "rehearser", "rehearser-library"], + "s-reader": ["rehearser-parse", "rehearser", "rehearser-library", "characters-library", "reader", "audiobook/network", "audiobook", "character-sheets"], + "s-library": ["rehearser-parse", "rehearser", "rehearser-library", "characters-library", "reader", "audiobook/network", "audiobook", "character-sheets", "library", "character-columns", "library-characters", "sillytavern"], + "s-caststudio": ["rehearser-parse", "rehearser", "rehearser-library", "characters-library", "reader", "audiobook/network", "audiobook", "character-sheets", "library", "character-columns", "library-characters", "sillytavern", "studio"] } }