// Der EINZIGE Ort, der die Datenbank anfasst — § 6.7: EINE Quelle für // Einstellungen. Ein Wächter-Test (test/waechter.test.ts) prüft mechanisch, // dass 'node:sqlite' nirgendwo sonst importiert wird. Der Fund hinter der // Regel: In rc11 schrieb die Oberfläche in die Datenbank, der Betrieb las // aus einer Konfigurationsdatei, die niemand schrieb. // // node:sqlite statt better-sqlite3: am 30.08.2026 in Electron 44 im // utilityProcess gemessen (KONZEPT-WINDOWS.md § 3.4, beweise/sqlite-*.js) — // keine native Kompilierung, kein node-gyp in der Bau-Kette. // // Schema 4 (5.4.0): die Tabelle `vorgaenge`. Bis dahin wusste Rippy nach // einem Rip NICHT mehr, welcher Roh-Ordner zu welchem Film gehört — und // genau deshalb konnte niemand aufräumen (jede Blu-ray ließ 30+ GB liegen), // niemand „ab Kompression wiederholen" und niemand nach einem Abbruch // nachsehen, was halb fertig herumlag. Der Vorgang ist jetzt ein Datensatz. import { DatabaseSync } from 'node:sqlite' import { mkdirSync } from 'node:fs' import { dirname } from 'node:path' import type { BibliothekEintrag, VorgangPhase } from '../../gemeinsam/nachrichten' export const SCHEMA_VERSION = 4 /** Ein Vorgang, wie die Datenbank ihn kennt (§ 6.7 / 5.4.0). */ export interface Vorgang { /** Der Vorgangsname — zugleich der Name des Roh-Ordners. */ id: string laufwerk: string typ: string titel: string jahr: number | null phase: VorgangPhase begonnen: string beendet: string | null rohOrdner: string rohBytes: number rohGeloeschtAm: string | null zielOrdner: string fertigBytes: number /** 0 = die Roh-Dateien SIND das Ergebnis (verschoben, nicht komprimiert). */ komprimiert: boolean preset: string fehler: string /** Der Kompressions-Auftrag als JSON — Grundlage für „ab Kompression * wiederholen" (kern/ablauf/auftrag.ts). */ auftrag: string lesefehler: boolean /** Die fertigen Dateien als JSON-Liste — das Aufräumen prüft, dass sie * wirklich da sind, BEVOR es den Roh-Ordner löscht. */ fertigeDateien: string } export class Datenbank { readonly pfad: string private db: DatabaseSync /** * Öffnet (oder erzeugt) die Datenbank und legt das Schema an. * Wirft bei nicht anlegbarem Pfad — der Aufrufer meldet den Fehler * LAUT weiter (R4), statt mit einer stillen Ersatz-Datenbank zu starten. */ constructor(pfad: string) { this.pfad = pfad if (pfad !== ':memory:') { mkdirSync(dirname(pfad), { recursive: true }) } this.db = new DatabaseSync(pfad) this.db.exec( `CREATE TABLE IF NOT EXISTS meta ( schluessel TEXT PRIMARY KEY, wert TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS einstellungen ( schluessel TEXT PRIMARY KEY, wert TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS bibliothek ( fingerprint TEXT PRIMARY KEY, titel TEXT NOT NULL, jahr INTEGER, typ TEXT NOT NULL, groesse_bytes INTEGER NOT NULL, ablageort TEXT NOT NULL, datum TEXT NOT NULL, dauer_s INTEGER NOT NULL, poster_pfad TEXT NOT NULL DEFAULT '' ); CREATE TABLE IF NOT EXISTS vorgaenge ( id TEXT PRIMARY KEY, laufwerk TEXT NOT NULL, typ TEXT NOT NULL, titel TEXT NOT NULL, jahr INTEGER, phase TEXT NOT NULL, begonnen TEXT NOT NULL, beendet TEXT, roh_ordner TEXT NOT NULL, roh_bytes INTEGER NOT NULL DEFAULT 0, roh_geloescht_am TEXT, ziel_ordner TEXT NOT NULL DEFAULT '', fertig_bytes INTEGER NOT NULL DEFAULT 0, komprimiert INTEGER NOT NULL DEFAULT 0, preset TEXT NOT NULL DEFAULT '', fehler TEXT NOT NULL DEFAULT '', auftrag TEXT NOT NULL DEFAULT '{}', lesefehler INTEGER NOT NULL DEFAULT 0, fertige_dateien TEXT NOT NULL DEFAULT '[]' );`, ) // Schema 2 → 3: Bestands-Datenbanken haben die Bibliothek noch ohne // Poster-Spalte — nachrüsten, ohne Einträge anzufassen. const spalten = this.db.prepare(`PRAGMA table_info(bibliothek)`).all() as Array<{ name: string }> if (!spalten.some((s) => s.name === 'poster_pfad')) { this.db.exec(`ALTER TABLE bibliothek ADD COLUMN poster_pfad TEXT NOT NULL DEFAULT ''`) } this.db .prepare(`INSERT OR REPLACE INTO meta (schluessel, wert) VALUES ('schema_version', ?)`) .run(String(SCHEMA_VERSION)) } /** Liest eine Einstellung; unbekannter Schlüssel ist `null`, kein Fehler. */ einstellungLesen(schluessel: string): string | null { const zeile = this.db .prepare(`SELECT wert FROM einstellungen WHERE schluessel = ?`) .get(schluessel) as { wert: string } | undefined return zeile === undefined ? null : zeile.wert } einstellungSchreiben(schluessel: string, wert: string): void { this.db .prepare(`INSERT OR REPLACE INTO einstellungen (schluessel, wert) VALUES (?, ?)`) .run(schluessel, wert) } schemaVersion(): number { const zeile = this.db .prepare(`SELECT wert FROM meta WHERE schluessel = 'schema_version'`) .get() as { wert: string } | undefined return zeile === undefined ? 0 : Number(zeile.wert) } // ── Bibliothek (§ 6.6): alles Gerippte, wiedererkennbar am // Disc-Fingerabdruck ──────────────────────────────────────────────── bibliothekEintragen(eintrag: BibliothekEintrag): void { this.db .prepare( `INSERT OR REPLACE INTO bibliothek (fingerprint, titel, jahr, typ, groesse_bytes, ablageort, datum, dauer_s, poster_pfad) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) .run( eintrag.fingerprint, eintrag.titel, eintrag.jahr, eintrag.typ, eintrag.groesseBytes, eintrag.ablageort, eintrag.datum, eintrag.dauerS, eintrag.posterPfad, ) } bibliothekListe(): BibliothekEintrag[] { const zeilen = this.db .prepare(`SELECT * FROM bibliothek ORDER BY datum DESC`) .all() as Array> return zeilen.map((z) => ({ fingerprint: String(z.fingerprint), titel: String(z.titel), jahr: z.jahr === null ? null : Number(z.jahr), typ: String(z.typ), groesseBytes: Number(z.groesse_bytes), ablageort: String(z.ablageort), datum: String(z.datum), dauerS: Number(z.dauer_s), posterPfad: String(z.poster_pfad ?? ''), })) } /** Datum des früheren Rips dieser Disc — oder null (neu). */ bibliothekSchonGerippt(fingerprint: string): string | null { const zeile = this.db .prepare(`SELECT datum FROM bibliothek WHERE fingerprint = ?`) .get(fingerprint) as { datum: string } | undefined return zeile === undefined ? null : zeile.datum } // ── Vorgänge (5.4.0): welcher Roh-Ordner gehört zu welchem Rip ────────── vorgangAnlegen(v: Vorgang): void { this.db .prepare( `INSERT OR REPLACE INTO vorgaenge (id, laufwerk, typ, titel, jahr, phase, begonnen, beendet, roh_ordner, roh_bytes, roh_geloescht_am, ziel_ordner, fertig_bytes, komprimiert, preset, fehler, auftrag, lesefehler, fertige_dateien) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) .run( v.id, v.laufwerk, v.typ, v.titel, v.jahr, v.phase, v.begonnen, v.beendet, v.rohOrdner, v.rohBytes, v.rohGeloeschtAm, v.zielOrdner, v.fertigBytes, v.komprimiert ? 1 : 0, v.preset, v.fehler, v.auftrag, v.lesefehler ? 1 : 0, v.fertigeDateien, ) } /** Ändert nur die genannten Felder — der Rest bleibt, wie er war. */ vorgangAktualisieren(id: string, teil: Partial>): void { const alt = this.vorgangLesen(id) if (alt === null) return this.vorgangAnlegen({ ...alt, ...teil, id }) } vorgangLesen(id: string): Vorgang | null { const zeile = this.db.prepare(`SELECT * FROM vorgaenge WHERE id = ?`).get(id) as | Record | undefined return zeile === undefined ? null : vorgangAusZeile(zeile) } /** Alle Vorgänge, neueste zuerst. */ vorgangListe(): Vorgang[] { const zeilen = this.db .prepare(`SELECT * FROM vorgaenge ORDER BY begonnen DESC`) .all() as Array> return zeilen.map(vorgangAusZeile) } vorgangLoeschen(id: string): void { this.db.prepare(`DELETE FROM vorgaenge WHERE id = ?`).run(id) } schliessen(): void { this.db.close() } } function vorgangAusZeile(z: Record): Vorgang { return { id: String(z.id), laufwerk: String(z.laufwerk), typ: String(z.typ), titel: String(z.titel), jahr: z.jahr === null || z.jahr === undefined ? null : Number(z.jahr), phase: String(z.phase) as VorgangPhase, begonnen: String(z.begonnen), beendet: z.beendet === null || z.beendet === undefined ? null : String(z.beendet), rohOrdner: String(z.roh_ordner), rohBytes: Number(z.roh_bytes ?? 0), rohGeloeschtAm: z.roh_geloescht_am === null || z.roh_geloescht_am === undefined ? null : String(z.roh_geloescht_am), zielOrdner: String(z.ziel_ordner ?? ''), fertigBytes: Number(z.fertig_bytes ?? 0), komprimiert: Number(z.komprimiert ?? 0) === 1, preset: String(z.preset ?? ''), fehler: String(z.fehler ?? ''), auftrag: String(z.auftrag ?? '{}'), lesefehler: Number(z.lesefehler ?? 0) === 1, fertigeDateien: String(z.fertige_dateien ?? '[]'), } }