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
+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()