feat(v5): Etappe W-5 — Oberflaeche: Tray, Toasts, Taskleiste, Einstellungen, Bibliothek
Ampel / ampel (push) Successful in 1m23s

haupt/: tray.ts (Symbol mit Zustand, Kontextmenue, Fenster zu = weiter-
laufen, Beenden nur noch gewollt), Windows-Toasts bei Disc erkannt /
fertig / Fehler (Klick holt das Fenster), Taskleisten-Fortschritt
(setProgressBar, Fehlermodus), echter Windows-Ordnerdialog per IPC.
Eigenes Icon (bau/icon.png + tray.png, Disc-Optik).

Kern: Einstellungen lesen/schreiben NUR ueber die eine Quelle (§ 6.7,
Whitelist der Schluessel), Werkzeug-Auskunft (Katalog + gemessene
HandBrake-Presets/Backends), Bibliothek in db.ts (Schema v2 —
fingerprint, Titel, Groesse, Ort, Dauer), Duplikat-Erkennung beim
Einlegen (§ 6.6: 'schon einmal gerippt am …' als disc-info + Toast),
Pipeline traegt fertige Vorgaenge ein.

Fenster: drei Bereiche (Uebersicht / Einstellungen / Bibliothek).
Einstellungen: Ablage mit Durchsuchen-Dialog, Kompression je Disc-Typ
(Preset-Liste VOM eigenen HandBrake gemessen, 'NICHT komprimieren'
waehlbar), Sprachlisten, TMDb/OMDb-Keys, Medienserver, Werkzeug-Stand
mit Klartext (gefunden/fehlt + Folgen). Bibliothek als Tabelle.

Der Smoke-Screenshot zeigt nebenbei den Ernstfall in schoen: Das
Laufwerk ist nach dem rc11-Vorfall inzwischen auch am Storage-Stack in
'unknown' gekippt — und die Kachel zeigt den ZUGRIFFS_GRUENDE-Klartext
samt Abhilfe statt eines stummen Zustands.

165 Tests gruen, Smoke gruen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-08-30 18:47:39 +02:00
co-authored by Claude Fable 5
parent a9fa8fa2d8
commit c78ff22ab7
13 changed files with 782 additions and 128 deletions
+32 -3
View File
@@ -7,10 +7,10 @@
// Roh-Ordner; die echten Titel ersetzen das dann. Roh-Dateien werden in
// dieser Etappe GRUNDSÄTZLICH BEHALTEN — Löschen bekommt erst mit der
// Einstellung im UI (W-5) eine Stimme.
import { copyFileSync, mkdirSync, writeFileSync } from 'node:fs'
import { copyFileSync, mkdirSync, statSync, writeFileSync } from 'node:fs'
// Plattform-Pfade wie in struktur.ts: Laufzeit-Wurzeln, CI testet auf Linux.
import { basename, join } from 'node:path'
import type { GeraeteInfo, RipStatus } from '../../gemeinsam/nachrichten'
import type { BibliothekEintrag, GeraeteInfo, RipStatus } from '../../gemeinsam/nachrichten'
import { baueNfo } from '../ablage/nfo'
import { bibliothekRefresh } from '../ablage/medienserver'
import { posterSpeichern } from '../ablage/poster'
@@ -38,6 +38,10 @@ export interface PipelineUmgebung {
melden: (status: RipStatus) => void
/** Die letzte Metadaten-Zuordnung dieses Laufwerks (W-4) — oder null. */
zuordnung?: (id: string) => Zuordnung | null
/** Disc-Fingerabdruck fürs Wiedererkennen (§ 6.6) — oder null. */
fingerprint?: (id: string) => string | null
/** Trägt einen fertigen Vorgang in die Bibliothek ein (§ 6.6). */
bibliothekEintragen?: (eintrag: BibliothekEintrag) => void
/** Zeitstempel für Ordnernamen — injizierbar für Tests. */
jetzt?: () => Date
}
@@ -99,7 +103,8 @@ export class Pipeline {
}
const abbruch = new AbortController()
const zeit = (this.umgebung.jetzt?.() ?? new Date()).toISOString().replaceAll(/[:.]/g, '-').slice(0, 19)
const startZeit = this.umgebung.jetzt?.() ?? new Date()
const zeit = startZeit.toISOString().replaceAll(/[:.]/g, '-').slice(0, 19)
const vorgangsName = `${geraet.typ}-${zeit}`
const rohOrdner = join(this.umgebung.ablage(), 'roh', vorgangsName)
@@ -267,6 +272,30 @@ export class Pipeline {
const serverKey = String(einstellungen['mediaServerApiKey'] ?? '')
const refresh = await bibliothekRefresh(server, serverUrl, serverKey)
// § 6.6: Merken — beim nächsten Einlegen wird die Disc wiedererkannt.
const fingerprint = this.umgebung.fingerprint?.(id) ?? null
if (fingerprint !== null && this.umgebung.bibliothekEintragen !== undefined) {
let groesse = 0
for (const datei of fertigeDateien) {
try {
groesse += statSync(datei).size
} catch {
// Größe ist Anzeige-Komfort — eine unlesbare Datei ändert nichts
// am Eintrag selbst.
}
}
this.umgebung.bibliothekEintragen({
fingerprint,
titel: zuordnung !== null && zuordnung.titel.length > 0 ? zuordnung.titel : vorgangsName,
jahr: zuordnung?.jahr ?? null,
typ: geraet.typ,
groesseBytes: groesse,
ablageort: fertigOrdner,
datum: startZeit.toISOString(),
dauerS: Math.round(((this.umgebung.jetzt?.() ?? new Date()).getTime() - startZeit.getTime()) / 1000),
})
}
this.laufend.delete(id)
this.melden({
id,
+88 -4
View File
@@ -18,7 +18,7 @@ import { Pipeline } from './ablauf/pipeline'
import type { LaufwerkApi } from './laufwerk/codes'
import { auswerfenMitGrund, geraeteInfo, kennung, listOptischeLaufwerke } from './laufwerk/disc'
import { DiscWache } from './laufwerk/wache'
import { discTitel } from './metadaten/discmerkmale'
import { discFingerprint, discTitel } from './metadaten/discmerkmale'
import { OmdbClient } from './metadaten/omdb'
import { TmdbClient } from './metadaten/tmdb'
import { zuordnen, type Zuordnung } from './metadaten/zuordnung'
@@ -84,10 +84,13 @@ let wache: DiscWache | null = null
// ── Metadaten (W-4): Was liegt da drin? ─────────────────────────────────
const discZuordnungen = new Map<string, Zuordnung>()
const discFingerprints = new Map<string, string>()
async function discErmitteln(id: string, geraetepfad: string): Promise<void> {
async function discErmitteln(id: string, geraetepfad: string, groesseBytes: number): Promise<void> {
try {
const gefunden = discTitel(geraetepfad)
const fingerprint = discFingerprint(geraetepfad, groesseBytes)
discFingerprints.set(id, fingerprint)
const tmdb = new TmdbClient(db?.einstellungLesen('tmdbApiKey') ?? '')
const omdb = new OmdbClient(db?.einstellungLesen('omdbApiKey') ?? '')
const zuordnung = await zuordnen(gefunden.titel, tmdb, omdb)
@@ -101,6 +104,9 @@ async function discErmitteln(id: string, geraetepfad: string): Promise<void> {
confidence: zuordnung.confidence,
quelle: zuordnung.meta.quelle,
posterPfad: zuordnung.meta.posterPfad,
// § 6.6: Rippy erkennt beim Einlegen, wenn eine Disc schon einmal
// durchgelaufen ist — und fragt nach, statt stumm zu doppeln.
schonGerippt: db?.bibliothekSchonGerippt(fingerprint) ?? null,
},
})
} catch (fehler) {
@@ -124,12 +130,21 @@ const laufwerkeBereit: Promise<void> = (async () => {
anHauptUndFenster({ art: 'laufwerke', liste: meldung.laufwerke, ereignisse: meldung.ereignisse })
for (const ereignis of meldung.ereignisse) {
if (ereignis.art === 'disc-eingelegt') {
void discErmitteln(ereignis.geraet.id, ereignis.geraet.pfad)
void discErmitteln(ereignis.geraet.id, ereignis.geraet.pfad, ereignis.geraet.groesseBytes)
} else if (ereignis.art === 'disc-entnommen') {
discZuordnungen.delete(ereignis.geraet.id)
discFingerprints.delete(ereignis.geraet.id)
anHauptUndFenster({
art: 'disc-info',
info: { id: ereignis.geraet.id, titel: '', jahr: null, confidence: 0, quelle: '', posterPfad: '' },
info: {
id: ereignis.geraet.id,
titel: '',
jahr: null,
confidence: 0,
quelle: '',
posterPfad: '',
schonGerippt: null,
},
})
}
}
@@ -193,8 +208,62 @@ const ripVerwaltung = new Pipeline({
einstellungen: einstellungenLaden,
melden: (status) => anHauptUndFenster({ art: 'rip-status', status }),
zuordnung: (id) => discZuordnungen.get(id) ?? null,
fingerprint: (id) => discFingerprints.get(id) ?? null,
bibliothekEintragen: (eintrag) => {
try {
db?.bibliothekEintragen(eintrag)
anFenster({ art: 'bibliothek', eintraege: db?.bibliothekListe() ?? [] })
} catch (fehler) {
// R4: Ein Bibliotheks-Fehler kippt keinen gelungenen Rip, wird aber
// gesagt.
anFenster({ art: 'kern-fehler', text: `Bibliothek: ${String(fehler)}` })
}
},
})
// ── Einstellungen (§ 6.7): das Fenster liest und schreibt NUR über den
// Kern; erlaubt sind genau diese Schlüssel ───────────────────────────
const ERLAUBTE_EINSTELLUNGEN = new Set([
'ablage',
'transcodeEnabled',
'transcodePreset',
'transcodePresetDvd',
'transcodePresetBluray',
'transcodePresetUhd',
'audioSprachen',
'untertitelSprachen',
'mediaServer',
'mediaServerUrl',
'mediaServerApiKey',
'tmdbApiKey',
'omdbApiKey',
'autoAuswurf',
'werkzeug.makemkv',
'werkzeug.handbrake',
'werkzeug.flac',
])
function einstellungenSenden(): void {
const werte: Record<string, string> = { ablage: ablageOrdner() }
for (const schluessel of ERLAUBTE_EINSTELLUNGEN) {
const wert = db?.einstellungLesen(schluessel)
if (wert !== null && wert !== undefined) werte[schluessel] = wert
}
anFenster({ art: 'einstellungen', werte })
}
async function werkzeugeSenden(): Promise<void> {
const makemkvcon = finden('makemkv', db?.einstellungLesen('werkzeug.makemkv') ?? '')
const handbrake = finden('handbrake', db?.einstellungLesen('werkzeug.handbrake') ?? '')
const flac = finden('flac', db?.einstellungLesen('werkzeug.flac') ?? '')
const { encoderMessen } = await import('./komprimieren/encoder')
const gemessen = await encoderMessen(handbrake)
anFenster({
art: 'werkzeuge',
auskunft: { makemkvcon, handbrake, flac, presets: gemessen.presets, backends: gemessen.backends },
})
}
async function auswerfen(id: string): Promise<void> {
await laufwerkeBereit
if (ripVerwaltung.laeuft(id)) {
@@ -242,6 +311,21 @@ process.parentPort.on('message', (ereignis) => {
void laufwerkeBereit.then(() => ripVerwaltung.starten(frage.id))
} else if (frage.art === 'rip-abbruch') {
ripVerwaltung.abbrechen(frage.id)
} else if (frage.art === 'einstellung-setzen') {
if (!ERLAUBTE_EINSTELLUNGEN.has(frage.schluessel)) {
anFenster({ art: 'kern-fehler', text: `Unbekannter Einstellungs-Schlüssel: ${frage.schluessel}` })
} else if (db === null) {
anFenster({ art: 'kern-fehler', text: 'Einstellung nicht gespeichert — die Datenbank ist nicht da.' })
} else {
db.einstellungSchreiben(frage.schluessel, frage.wert)
einstellungenSenden()
}
} else if (frage.art === 'einstellungen-laden') {
einstellungenSenden()
} else if (frage.art === 'werkzeuge-laden') {
void werkzeugeSenden()
} else if (frage.art === 'bibliothek-laden') {
anFenster({ art: 'bibliothek', eintraege: db?.bibliothekListe() ?? [] })
}
})
fensterPort.start()
+57 -1
View File
@@ -10,8 +10,9 @@
import { DatabaseSync } from 'node:sqlite'
import { mkdirSync } from 'node:fs'
import { dirname } from 'node:path'
import type { BibliothekEintrag } from '../../gemeinsam/nachrichten'
const SCHEMA_VERSION = 1
export const SCHEMA_VERSION = 2
export class Datenbank {
readonly pfad: string
@@ -36,6 +37,16 @@ export class Datenbank {
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
);`,
)
this.db
@@ -64,6 +75,51 @@ export class Datenbank {
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)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(
eintrag.fingerprint,
eintrag.titel,
eintrag.jahr,
eintrag.typ,
eintrag.groesseBytes,
eintrag.ablageort,
eintrag.datum,
eintrag.dauerS,
)
}
bibliothekListe(): BibliothekEintrag[] {
const zeilen = this.db
.prepare(`SELECT * FROM bibliothek ORDER BY datum DESC`)
.all() as Array<Record<string, unknown>>
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),
}))
}
/** 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
}
schliessen(): void {
this.db.close()
}