refactor: extract rehearser library persistence

This commit is contained in:
mARTin-B78 2026-09-04 10:46:02 +02:00
parent c815b7941a
commit 2712843322
7 changed files with 69 additions and 92 deletions

View File

@ -8,6 +8,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
## [Unreleased] ## [Unreleased]
### Changed ### 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 - **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. 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; - **Current-release constrained validation.** Built `1.24.21` and served it under a hard 4 GiB cgroup limit;

View File

@ -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`); - [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. a full style/lint policy remains a follow-up.
- [ ] Extract Rehearser voice matching and voice design. - [ ] Extract Rehearser voice matching and voice design.
- [x] Extract Rehearser library persistence and IndexedDB migration into `rehearser-library.js`.
- [ ] Extract Audiobook parsing and casting. - [ ] Extract Audiobook parsing and casting.
- [ ] Split `style.css` by feature. - [ ] Split `style.css` by feature.
- [ ] Do not migrate to TypeScript or rewrite the frontend until behavior is covered by tests. - [ ] Do not migrate to TypeScript or rewrite the frontend until behavior is covered by tests.

View File

@ -1 +1 @@
1.24.23 1.24.24

View File

@ -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-cast.js` — `renderCastList`, sort/filter, online voice picker, character research.
- ⬜ `rehearser-stage.js` — playback, synth cache, pre-decode window, transport. - ⬜ `rehearser-stage.js` — playback, synth cache, pre-decode window, transport.
- ⬜ `rehearser-train.js` — mic record / STT compare. - ⬜ `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. **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.

View File

@ -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;

View File

@ -120,92 +120,6 @@ const rehState = {
}; };
window.rehState = 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 window.rehLoadRecord = loadRecord; // expose so audiobook.js can open a record directly
// ── Serialization ───────────────────────────────────────────────────────── // ── Serialization ─────────────────────────────────────────────────────────

View File

@ -4,9 +4,9 @@
"integrations", "routing", "voice-clone", "voice-library", "tts-preview", "generation", "benchmark", "stt", "namespace" "integrations", "routing", "voice-clone", "voice-library", "tts-preview", "generation", "benchmark", "stt", "namespace"
], ],
"lazy": { "lazy": {
"s-rehearser": ["rehearser-parse", "rehearser"], "s-rehearser": ["rehearser-parse", "rehearser", "rehearser-library"],
"s-reader": ["rehearser-parse", "rehearser", "characters-library", "reader", "audiobook/network", "audiobook", "character-sheets"], "s-reader": ["rehearser-parse", "rehearser", "rehearser-library", "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-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", "characters-library", "reader", "audiobook/network", "audiobook", "character-sheets", "library", "character-columns", "library-characters", "sillytavern", "studio"] "s-caststudio": ["rehearser-parse", "rehearser", "rehearser-library", "characters-library", "reader", "audiobook/network", "audiobook", "character-sheets", "library", "character-columns", "library-characters", "sillytavern", "studio"]
} }
} }