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
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 486 B

+4
View File
@@ -48,4 +48,8 @@ contextBridge.exposeInMainWorld('rippy', {
melden(art: string): void { melden(art: string): void {
ipcRenderer.send('fenster-meldung', String(art)) ipcRenderer.send('fenster-meldung', String(art))
}, },
/** Echter Windows-Ordnerdialog (§ 7). null = abgebrochen. */
ordnerWaehlen(titel: string): Promise<string | null> {
return ipcRenderer.invoke('ordner-waehlen', String(titel)) as Promise<string | null>
},
}) })
+363 -100
View File
@@ -1,11 +1,12 @@
// Statusbild W-0/W-1: die drei Prozesse plus die echten Laufwerke aus der // Die Oberfläche (W-5): Übersicht (Laufwerke + Gerüst-Status),
// Disc-Wache. Die große Oberfläche (Dashboard, Bibliothek, Einstellungen) // Einstellungen (EINE Quelle — alles läuft über den Kern, § 6.7) und die
// ist Etappe W-5 — hier wird das Gerüst SICHTBAR bewiesen. // Bibliothek (§ 6.6). Design-Sprache wie das heutige Rippy: dunkel, Kacheln.
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import type { DiscInfoStand, GeraeteInfo, HauptStatus, RipStatus } from '../gemeinsam/nachrichten' import type { DiscInfoStand, GeraeteInfo, HauptStatus, RipStatus } from '../gemeinsam/nachrichten'
import { import {
aufVerbindung, aufVerbindung,
auswerfen, auswerfen,
einstellungSetzen,
ripAbbrechen, ripAbbrechen,
ripStarten, ripStarten,
verbindungStarten, verbindungStarten,
@@ -32,6 +33,15 @@ const TYP_NAMEN: Record<GeraeteInfo['typ'], string> = {
unknown: 'unbekannt', unknown: 'unbekannt',
} }
const LAUFENDE_PHASEN = ['liest-info', 'rippt', 'komprimiert', 'legt-ab']
function sicherheitsWort(confidence: number): string {
if (confidence >= 0.9) return 'sehr wahrscheinlich'
if (confidence >= 0.8) return 'wahrscheinlich'
if (confidence >= 0.6) return 'Vorschlag'
return 'unsicher'
}
interface KachelProps { interface KachelProps {
titel: string titel: string
zustand: 'ok' | 'fehler' | 'wartet' zustand: 'ok' | 'fehler' | 'wartet'
@@ -58,15 +68,6 @@ function Kachel({ titel, zustand, zeilen }: KachelProps) {
) )
} }
const LAUFENDE_PHASEN = ['liest-info', 'rippt', 'komprimiert', 'legt-ab']
function sicherheitsWort(confidence: number): string {
if (confidence >= 0.9) return 'sehr wahrscheinlich'
if (confidence >= 0.8) return 'wahrscheinlich'
if (confidence >= 0.6) return 'Vorschlag'
return 'unsicher'
}
function LaufwerkKachel({ function LaufwerkKachel({
geraet, geraet,
rip, rip,
@@ -105,7 +106,7 @@ function LaufwerkKachel({
onClick={() => ripStarten(geraet.id)} onClick={() => ripStarten(geraet.id)}
disabled={geraet.status !== 'ready'} disabled={geraet.status !== 'ready'}
className="rounded-lg border border-emerald-500/60 px-3 py-1 text-xs text-emerald-300 hover:bg-emerald-500/10 disabled:cursor-not-allowed disabled:opacity-40" className="rounded-lg border border-emerald-500/60 px-3 py-1 text-xs text-emerald-300 hover:bg-emerald-500/10 disabled:cursor-not-allowed disabled:opacity-40"
title="Alle Titel, verlustfrei (MakeMKV)" title="Alle Titel, verlustfrei (MakeMKV) — danach Kompression laut Einstellungen"
> >
Rippen Rippen
</button> </button>
@@ -135,6 +136,11 @@ function LaufwerkKachel({
{info.quelle.length > 0 ? ` · ${info.quelle}` : ''} {info.quelle.length > 0 ? ` · ${info.quelle}` : ''}
</p> </p>
)} )}
{geraet.status === 'ready' && info !== undefined && info.schonGerippt !== null && (
<p className="text-amber-300">
Diese Disc wurde schon einmal gerippt ({info.schonGerippt.slice(0, 10)}) siehe Bibliothek.
</p>
)}
{geraet.status === 'empty' && <p>keine Disc eingelegt</p>} {geraet.status === 'empty' && <p>keine Disc eingelegt</p>}
{geraet.status === 'unknown' && <p className="text-red-300">{geraet.grund}</p>} {geraet.status === 'unknown' && <p className="text-red-300">{geraet.grund}</p>}
{geraet.status !== 'unknown' && geraet.grund.length > 0 && ( {geraet.status !== 'unknown' && geraet.grund.length > 0 && (
@@ -176,8 +182,240 @@ function LaufwerkKachel({
) )
} }
// ── Einstellungen ───────────────────────────────────────────────────────
function Feld({
name,
wert,
hinweis,
geheim,
aufAendern,
}: {
name: string
wert: string
hinweis?: string
geheim?: boolean
aufAendern: (wert: string) => void
}) {
const [entwurf, setEntwurf] = useState(wert)
useEffect(() => setEntwurf(wert), [wert])
return (
<label className="block">
<span className="text-xs font-semibold text-slate-300">{name}</span>
{hinweis !== undefined && <span className="ml-2 text-xs text-slate-500">{hinweis}</span>}
<input
type={geheim === true ? 'password' : 'text'}
value={entwurf}
onChange={(e) => setEntwurf(e.target.value)}
onBlur={() => {
if (entwurf !== wert) aufAendern(entwurf)
}}
className="mt-1 w-full rounded-lg border border-slate-600 bg-slate-900 px-3 py-1.5 font-mono text-xs text-slate-200 focus:border-sky-400 focus:outline-none"
/>
</label>
)
}
function PresetWahl({
name,
schluessel,
wert,
presets,
aufAendern,
}: {
name: string
schluessel: string
wert: string
presets: string[]
aufAendern: (schluessel: string, wert: string) => void
}) {
return (
<label className="block">
<span className="text-xs font-semibold text-slate-300">{name}</span>
<select
value={wert}
onChange={(e) => aufAendern(schluessel, e.target.value)}
className="mt-1 w-full rounded-lg border border-slate-600 bg-slate-900 px-3 py-1.5 font-mono text-xs text-slate-200 focus:border-sky-400 focus:outline-none"
>
<option value="">(allgemeines Preset)</option>
<option value="keine">NICHT komprimieren (verlustfrei lassen)</option>
{presets.map((preset) => (
<option key={preset} value={preset}>
{preset}
</option>
))}
</select>
</label>
)
}
function Einstellungen({ verbindung }: { verbindung: Verbindung }) {
const werte = verbindung.einstellungen
const presets = verbindung.werkzeuge?.presets ?? []
const setzen = einstellungSetzen
const ordnerWaehlen = async (): Promise<void> => {
const ordner = await window.rippy.ordnerWaehlen('Ablage-Ordner wählen')
if (ordner !== null) setzen('ablage', ordner)
}
return (
<div className="max-w-2xl space-y-6">
<section className="space-y-3">
<h2 className="text-xs font-semibold uppercase tracking-widest text-slate-500">Ablage</h2>
<div className="flex items-end gap-2">
<div className="flex-1">
<Feld name="Ablage-Ordner" wert={werte['ablage'] ?? ''} aufAendern={(w) => setzen('ablage', w)} />
</div>
<button
onClick={() => void ordnerWaehlen()}
className="rounded-lg border border-slate-600 px-3 py-1.5 text-xs text-slate-300 hover:bg-slate-700"
>
Durchsuchen
</button>
</div>
</section>
<section className="space-y-3">
<h2 className="text-xs font-semibold uppercase tracking-widest text-slate-500">
Kompression {verbindung.werkzeuge !== null && verbindung.werkzeuge.handbrake.length === 0 && (
<span className="text-red-400"> HandBrakeCLI nicht gefunden</span>
)}
</h2>
<label className="flex items-center gap-2 text-xs text-slate-300">
<input
type="checkbox"
checked={werte['transcodeEnabled'] !== 'false'}
onChange={(e) => setzen('transcodeEnabled', e.target.checked ? 'true' : 'false')}
/>
Nach dem Rip komprimieren (der verlustfreie Rip bleibt bis zum Erfolg erhalten)
</label>
<div className="grid gap-3 sm:grid-cols-3">
<PresetWahl name="DVD" schluessel="transcodePresetDvd" wert={werte['transcodePresetDvd'] ?? ''} presets={presets} aufAendern={setzen} />
<PresetWahl name="Blu-ray" schluessel="transcodePresetBluray" wert={werte['transcodePresetBluray'] ?? ''} presets={presets} aufAendern={setzen} />
<PresetWahl name="4K-UHD" schluessel="transcodePresetUhd" wert={werte['transcodePresetUhd'] ?? ''} presets={presets} aufAendern={setzen} />
</div>
<Feld
name="Allgemeines Preset"
hinweis="gilt, wo oben nichts gewählt ist"
wert={werte['transcodePreset'] ?? ''}
aufAendern={(w) => setzen('transcodePreset', w)}
/>
<div className="grid gap-3 sm:grid-cols-2">
<Feld
name="Tonspuren (Sprachen)"
hinweis="ISO-639-2, z. B. deu,eng — leer = alle"
wert={werte['audioSprachen'] ?? ''}
aufAendern={(w) => setzen('audioSprachen', w)}
/>
<Feld
name="Untertitel (Sprachen)"
hinweis="leer = alle"
wert={werte['untertitelSprachen'] ?? ''}
aufAendern={(w) => setzen('untertitelSprachen', w)}
/>
</div>
</section>
<section className="space-y-3">
<h2 className="text-xs font-semibold uppercase tracking-widest text-slate-500">Metadaten</h2>
<div className="grid gap-3 sm:grid-cols-2">
<Feld name="TMDb-API-Key" geheim wert={werte['tmdbApiKey'] ?? ''} aufAendern={(w) => setzen('tmdbApiKey', w)} />
<Feld name="OMDb-API-Key" geheim wert={werte['omdbApiKey'] ?? ''} aufAendern={(w) => setzen('omdbApiKey', w)} />
</div>
</section>
<section className="space-y-3">
<h2 className="text-xs font-semibold uppercase tracking-widest text-slate-500">Medienserver</h2>
<div className="grid gap-3 sm:grid-cols-3">
<label className="block">
<span className="text-xs font-semibold text-slate-300">System</span>
<select
value={werte['mediaServer'] ?? 'none'}
onChange={(e) => setzen('mediaServer', e.target.value)}
className="mt-1 w-full rounded-lg border border-slate-600 bg-slate-900 px-3 py-1.5 font-mono text-xs text-slate-200 focus:border-sky-400 focus:outline-none"
>
<option value="none">keiner</option>
<option value="jellyfin">Jellyfin</option>
<option value="emby">Emby</option>
<option value="kodi">Kodi</option>
<option value="plex">Plex (nur Benennung)</option>
</select>
</label>
<Feld name="Adresse" hinweis="http(s)://…" wert={werte['mediaServerUrl'] ?? ''} aufAendern={(w) => setzen('mediaServerUrl', w)} />
<Feld name="API-Key" geheim hinweis="Kodi: benutzer:passwort" wert={werte['mediaServerApiKey'] ?? ''} aufAendern={(w) => setzen('mediaServerApiKey', w)} />
</div>
</section>
<section className="space-y-3">
<h2 className="text-xs font-semibold uppercase tracking-widest text-slate-500">Werkzeuge</h2>
<div className="space-y-1 rounded-xl border border-slate-700/60 bg-slate-800/40 p-4 font-mono text-xs">
<p className={verbindung.werkzeuge === null ? 'text-slate-400' : verbindung.werkzeuge.makemkvcon.length > 0 ? 'text-emerald-300' : 'text-red-300'}>
makemkvcon: {verbindung.werkzeuge === null ? 'wird gesucht …' : verbindung.werkzeuge.makemkvcon || 'NICHT GEFUNDEN — MakeMKV installieren'}
</p>
<p className={verbindung.werkzeuge === null ? 'text-slate-400' : verbindung.werkzeuge.handbrake.length > 0 ? 'text-emerald-300' : 'text-amber-300'}>
HandBrakeCLI: {verbindung.werkzeuge === null ? 'wird gesucht …' : verbindung.werkzeuge.handbrake || 'nicht gefunden — Rippen geht, Kompression nicht'}
</p>
<p className={verbindung.werkzeuge === null ? 'text-slate-400' : verbindung.werkzeuge.flac.length > 0 ? 'text-emerald-300' : 'text-amber-300'}>
flac: {verbindung.werkzeuge === null ? 'wird gesucht …' : verbindung.werkzeuge.flac || 'nicht gefunden — nur für Audio-CDs nötig'}
</p>
{verbindung.werkzeuge !== null && verbindung.werkzeuge.backends.length > 0 && (
<p className="text-slate-400">Encoder: {verbindung.werkzeuge.backends.join(', ')}</p>
)}
</div>
</section>
</div>
)
}
// ── Bibliothek ──────────────────────────────────────────────────────────
function Bibliothek({ verbindung }: { verbindung: Verbindung }) {
if (verbindung.bibliothek.length === 0) {
return (
<p className="font-mono text-xs text-slate-500">
Noch nichts gerippt die Bibliothek füllt sich mit jedem fertigen Vorgang.
</p>
)
}
return (
<div className="overflow-x-auto rounded-xl border border-slate-700/60">
<table className="w-full text-left font-mono text-xs">
<thead className="bg-slate-800/60 text-slate-400">
<tr>
<th className="px-3 py-2">Titel</th>
<th className="px-3 py-2">Typ</th>
<th className="px-3 py-2">Größe</th>
<th className="px-3 py-2">Datum</th>
<th className="px-3 py-2">Dauer</th>
<th className="px-3 py-2">Ablageort</th>
</tr>
</thead>
<tbody className="text-slate-300">
{verbindung.bibliothek.map((eintrag) => (
<tr key={eintrag.fingerprint} className="border-t border-slate-700/40">
<td className="px-3 py-2">
{eintrag.titel}
{eintrag.jahr !== null ? ` (${eintrag.jahr})` : ''}
</td>
<td className="px-3 py-2">{eintrag.typ}</td>
<td className="px-3 py-2">{gb(eintrag.groesseBytes)}</td>
<td className="px-3 py-2">{eintrag.datum.slice(0, 16).replace('T', ' ')}</td>
<td className="px-3 py-2">{Math.round(eintrag.dauerS / 60)} min</td>
<td className="max-w-64 truncate px-3 py-2 text-slate-500" title={eintrag.ablageort}>
{eintrag.ablageort}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
type Bereich = 'uebersicht' | 'einstellungen' | 'bibliothek'
export default function App() { export default function App() {
const [haupt, setHaupt] = useState<HauptStatus | null>(null) const [haupt, setHaupt] = useState<HauptStatus | null>(null)
const [bereich, setBereich] = useState<Bereich>('uebersicht')
const [verbindung, setVerbindung] = useState<Verbindung>({ const [verbindung, setVerbindung] = useState<Verbindung>({
verbunden: false, verbunden: false,
kern: null, kern: null,
@@ -185,6 +423,9 @@ export default function App() {
laufwerke: [], laufwerke: [],
rips: {}, rips: {},
discInfos: {}, discInfos: {},
einstellungen: {},
werkzeuge: null,
bibliothek: [],
meldungen: [], meldungen: [],
}) })
@@ -198,102 +439,124 @@ export default function App() {
const db = verbindung.kern?.datenbank ?? null const db = verbindung.kern?.datenbank ?? null
const knopf = (ziel: Bereich, text: string): React.JSX.Element => (
<button
onClick={() => setBereich(ziel)}
className={`rounded-lg px-3 py-1 text-xs ${
bereich === ziel
? 'bg-sky-500/20 text-sky-300'
: 'text-slate-400 hover:bg-slate-800 hover:text-slate-200'
}`}
>
{text}
</button>
)
return ( return (
<div className="flex min-h-screen flex-col bg-slate-900 text-slate-100"> <div className="flex min-h-screen flex-col bg-slate-900 text-slate-100">
<header className="border-b border-slate-700/60 px-6 py-4"> <header className="border-b border-slate-700/60 px-6 py-4">
<div className="flex items-baseline gap-3"> <div className="flex items-baseline justify-between gap-3">
<h1 className="text-2xl font-bold tracking-tight">Rippy</h1> <div className="flex items-baseline gap-3">
<span className="font-mono text-xs text-slate-400"> <h1 className="text-2xl font-bold tracking-tight">Rippy</h1>
v{haupt?.version ?? '…'} · Etappe W-1 Laufwerk <span className="font-mono text-xs text-slate-400">v{haupt?.version ?? '…'}</span>
</span> </div>
<nav className="flex gap-1">
{knopf('uebersicht', 'Übersicht')}
{knopf('einstellungen', 'Einstellungen')}
{knopf('bibliothek', `Bibliothek${verbindung.bibliothek.length > 0 ? ` (${verbindung.bibliothek.length})` : ''}`)}
</nav>
</div> </div>
<p className="mt-1 text-sm text-slate-400">
Drei Prozesse, eine Datenbank, eine Leine und die Disc-Wache läuft.
</p>
</header> </header>
<main className="flex-1 space-y-6 p-6"> <main className="flex-1 space-y-6 p-6">
<section> {bereich === 'uebersicht' && (
<h2 className="mb-3 text-xs font-semibold uppercase tracking-widest text-slate-500"> <>
Laufwerke <section>
</h2> <h2 className="mb-3 text-xs font-semibold uppercase tracking-widest text-slate-500">
{verbindung.laufwerke.length === 0 ? ( Laufwerke
<p className="font-mono text-xs text-slate-500"> </h2>
{verbindung.verbunden ? 'Kein optisches Laufwerk gefunden.' : 'Warte auf den Kern …'} {verbindung.laufwerke.length === 0 ? (
</p> <p className="font-mono text-xs text-slate-500">
) : ( {verbindung.verbunden ? 'Kein optisches Laufwerk gefunden.' : 'Warte auf den Kern …'}
<div className="grid gap-4 sm:grid-cols-2">
{verbindung.laufwerke.map((geraet) => (
<LaufwerkKachel
key={geraet.id}
geraet={geraet}
rip={verbindung.rips[geraet.id]}
info={verbindung.discInfos[geraet.id]}
/>
))}
</div>
)}
</section>
<section>
<h2 className="mb-3 text-xs font-semibold uppercase tracking-widest text-slate-500">
Gerüst
</h2>
<div className="grid content-start gap-4 sm:grid-cols-2">
<Kachel
titel="Kern (utilityProcess)"
zustand={verbindung.kern !== null ? 'ok' : haupt?.kern.laeuft ? 'wartet' : 'fehler'}
zeilen={
verbindung.kern !== null
? [
`PID ${verbindung.kern.pid} · Node ${verbindung.kern.nodeVersion}`,
`Neustarts: ${haupt?.kern.neustarts ?? 0}`,
]
: ['wartet auf Meldung …']
}
/>
<Kachel
titel="Datenbank (node:sqlite)"
zustand={db === null ? 'wartet' : db.ok ? 'ok' : 'fehler'}
zeilen={db === null ? ['wartet auf Kern …'] : db.ok ? [db.pfad] : [db.fehler]}
/>
<Kachel
titel="Prozess-Leine (Job Object)"
zustand={haupt === null ? 'wartet' : haupt.leine.ok ? 'ok' : 'fehler'}
zeilen={
haupt === null
? ['wartet auf Haupt …']
: haupt.leine.ok
? ['gesetzt — Kern und Werkzeuge sterben mit Rippy']
: [haupt.leine.fehler ?? 'nicht gesetzt']
}
/>
<Kachel
titel="Fenster ↔ Kern (MessagePort)"
zustand={verbindung.pongLatenzMs !== null ? 'ok' : 'wartet'}
zeilen={
verbindung.pongLatenzMs !== null
? [`Ping → Pong in ${verbindung.pongLatenzMs} ms`]
: ['Ping unterwegs …']
}
/>
</div>
</section>
{verbindung.meldungen.length > 0 && (
<section>
<h2 className="mb-3 text-xs font-semibold uppercase tracking-widest text-slate-500">
Ereignisse
</h2>
<div className="space-y-1 rounded-xl border border-slate-700/60 bg-slate-800/40 p-4">
{verbindung.meldungen.map((zeile, i) => (
<p key={i} className="font-mono text-xs text-slate-400">
{zeile}
</p> </p>
))} ) : (
</div> <div className="grid gap-4 sm:grid-cols-2">
</section> {verbindung.laufwerke.map((geraet) => (
<LaufwerkKachel
key={geraet.id}
geraet={geraet}
rip={verbindung.rips[geraet.id]}
info={verbindung.discInfos[geraet.id]}
/>
))}
</div>
)}
</section>
<section>
<h2 className="mb-3 text-xs font-semibold uppercase tracking-widest text-slate-500">
Status
</h2>
<div className="grid content-start gap-4 sm:grid-cols-2">
<Kachel
titel="Kern (utilityProcess)"
zustand={verbindung.kern !== null ? 'ok' : haupt?.kern.laeuft ? 'wartet' : 'fehler'}
zeilen={
verbindung.kern !== null
? [
`PID ${verbindung.kern.pid} · Node ${verbindung.kern.nodeVersion}`,
`Neustarts: ${haupt?.kern.neustarts ?? 0}`,
]
: ['wartet auf Meldung …']
}
/>
<Kachel
titel="Datenbank (node:sqlite)"
zustand={db === null ? 'wartet' : db.ok ? 'ok' : 'fehler'}
zeilen={db === null ? ['wartet auf Kern …'] : db.ok ? [db.pfad] : [db.fehler]}
/>
<Kachel
titel="Prozess-Leine (Job Object)"
zustand={haupt === null ? 'wartet' : haupt.leine.ok ? 'ok' : 'fehler'}
zeilen={
haupt === null
? ['wartet auf Haupt …']
: haupt.leine.ok
? ['gesetzt — Kern und Werkzeuge sterben mit Rippy']
: [haupt.leine.fehler ?? 'nicht gesetzt']
}
/>
<Kachel
titel="Fenster ↔ Kern (MessagePort)"
zustand={verbindung.pongLatenzMs !== null ? 'ok' : 'wartet'}
zeilen={
verbindung.pongLatenzMs !== null
? [`Ping → Pong in ${verbindung.pongLatenzMs} ms`]
: ['Ping unterwegs …']
}
/>
</div>
</section>
{verbindung.meldungen.length > 0 && (
<section>
<h2 className="mb-3 text-xs font-semibold uppercase tracking-widest text-slate-500">
Ereignisse
</h2>
<div className="space-y-1 rounded-xl border border-slate-700/60 bg-slate-800/40 p-4">
{verbindung.meldungen.map((zeile, i) => (
<p key={i} className="break-all font-mono text-xs text-slate-400">
{zeile}
</p>
))}
</div>
</section>
)}
</>
)} )}
{bereich === 'einstellungen' && <Einstellungen verbindung={verbindung} />}
{bereich === 'bibliothek' && <Bibliothek verbindung={verbindung} />}
</main> </main>
<footer className="border-t border-slate-700/60 px-6 py-3 text-center text-xs text-slate-500"> <footer className="border-t border-slate-700/60 px-6 py-3 text-center text-xs text-slate-500">
+1
View File
@@ -7,6 +7,7 @@ declare global {
bereit(): void bereit(): void
aufStatus(horcher: (status: unknown) => void): void aufStatus(horcher: (status: unknown) => void): void
melden(art: string): void melden(art: string): void
ordnerWaehlen(titel: string): Promise<string | null>
} }
} }
} }
@@ -8,10 +8,12 @@
// Laufwerksliste: Sie ändert sich NUR durch eine neue Liste vom Kern. // Laufwerksliste: Sie ändert sich NUR durch eine neue Liste vom Kern.
import { import {
istKernNachricht, istKernNachricht,
type BibliothekEintrag,
type DiscInfoStand, type DiscInfoStand,
type GeraeteInfo, type GeraeteInfo,
type KernNachricht, type KernNachricht,
type RipStatus, type RipStatus,
type WerkzeugAuskunft,
} from '../gemeinsam/nachrichten' } from '../gemeinsam/nachrichten'
export interface Verbindung { export interface Verbindung {
@@ -24,6 +26,9 @@ export interface Verbindung {
rips: Record<string, RipStatus> rips: Record<string, RipStatus>
/** Was Rippy über die eingelegte Disc weiß (§ 5 Schritt 3). */ /** Was Rippy über die eingelegte Disc weiß (§ 5 Schritt 3). */
discInfos: Record<string, DiscInfoStand> discInfos: Record<string, DiscInfoStand>
einstellungen: Record<string, string>
werkzeuge: WerkzeugAuskunft | null
bibliothek: BibliothekEintrag[]
/** Kurze Ereigniszeilen (neueste zuerst): Disc rein/raus, Auswurf, Fehler. */ /** Kurze Ereigniszeilen (neueste zuerst): Disc rein/raus, Auswurf, Fehler. */
meldungen: string[] meldungen: string[]
} }
@@ -39,6 +44,9 @@ const stand: Verbindung = {
laufwerke: [], laufwerke: [],
rips: {}, rips: {},
discInfos: {}, discInfos: {},
einstellungen: {},
werkzeuge: null,
bibliothek: [],
meldungen: [], meldungen: [],
} }
const horcher = new Set<Horcher>() const horcher = new Set<Horcher>()
@@ -52,6 +60,8 @@ function melden(): void {
laufwerke: [...stand.laufwerke], laufwerke: [...stand.laufwerke],
rips: { ...stand.rips }, rips: { ...stand.rips },
discInfos: { ...stand.discInfos }, discInfos: { ...stand.discInfos },
einstellungen: { ...stand.einstellungen },
bibliothek: [...stand.bibliothek],
meldungen: [...stand.meldungen], meldungen: [...stand.meldungen],
}) })
} }
@@ -103,6 +113,15 @@ function kernNachricht(nachricht: KernNachricht): void {
} }
break break
} }
case 'einstellungen':
stand.einstellungen = nachricht.werte
break
case 'werkzeuge':
stand.werkzeuge = nachricht.auskunft
break
case 'bibliothek':
stand.bibliothek = nachricht.eintraege
break
case 'rip-status': { case 'rip-status': {
stand.rips = { ...stand.rips, [nachricht.status.id]: nachricht.status } stand.rips = { ...stand.rips, [nachricht.status.id]: nachricht.status }
if (nachricht.status.phase === 'fertig') { if (nachricht.status.phase === 'fertig') {
@@ -130,6 +149,9 @@ function portAnnehmen(neuerPort: MessagePort): void {
} }
neuerPort.start() neuerPort.start()
neuerPort.postMessage({ art: 'ping', zeit: Date.now() }) neuerPort.postMessage({ art: 'ping', zeit: Date.now() })
neuerPort.postMessage({ art: 'einstellungen-laden' })
neuerPort.postMessage({ art: 'werkzeuge-laden' })
neuerPort.postMessage({ art: 'bibliothek-laden' })
melden() melden()
} }
@@ -159,6 +181,8 @@ export function aufVerbindung(h: Horcher): () => void {
laufwerke: [...stand.laufwerke], laufwerke: [...stand.laufwerke],
rips: { ...stand.rips }, rips: { ...stand.rips },
discInfos: { ...stand.discInfos }, discInfos: { ...stand.discInfos },
einstellungen: { ...stand.einstellungen },
bibliothek: [...stand.bibliothek],
meldungen: [...stand.meldungen], meldungen: [...stand.meldungen],
}) })
return () => horcher.delete(h) return () => horcher.delete(h)
@@ -184,3 +208,12 @@ export function ripStarten(id: string): void {
export function ripAbbrechen(id: string): void { export function ripAbbrechen(id: string): void {
anKern({ art: 'rip-abbruch', id }, `Rip-Abbruch ${id}`) anKern({ art: 'rip-abbruch', id }, `Rip-Abbruch ${id}`)
} }
export function einstellungSetzen(schluessel: string, wert: string): void {
if (port === null) {
meldung(`⚠ Einstellung ${schluessel}: keine Verbindung zum Kern`)
melden()
return
}
port.postMessage({ art: 'einstellung-setzen', schluessel, wert })
}
+41 -1
View File
@@ -68,6 +68,30 @@ export interface DiscInfoStand {
confidence: number confidence: number
quelle: string quelle: string
posterPfad: string posterPfad: string
/** Datum des früheren Rips dieser Disc — die Bibliothek erkennt sie am
* Fingerabdruck wieder (§ 6.6); null wenn neu. */
schonGerippt: string | null
}
/** Ein Eintrag der Bibliothek (§ 6.6). */
export interface BibliothekEintrag {
fingerprint: string
titel: string
jahr: number | null
typ: string
groesseBytes: number
ablageort: string
datum: string
dauerS: number
}
/** Auskunft über die Werkzeuge (Katalog + gemessene HandBrake-Listen). */
export interface WerkzeugAuskunft {
makemkvcon: string
handbrake: string
flac: string
presets: string[]
backends: string[]
} }
/** Nachrichten, die der Kern verschickt. */ /** Nachrichten, die der Kern verschickt. */
@@ -79,6 +103,9 @@ export type KernNachricht =
| { art: 'auswurf-ergebnis'; id: string; grund: AuswurfGrund; fehler: string | null } | { art: 'auswurf-ergebnis'; id: string; grund: AuswurfGrund; fehler: string | null }
| { art: 'rip-status'; status: RipStatus } | { art: 'rip-status'; status: RipStatus }
| { art: 'disc-info'; info: DiscInfoStand } | { art: 'disc-info'; info: DiscInfoStand }
| { art: 'einstellungen'; werte: Record<string, string> }
| { art: 'werkzeuge'; auskunft: WerkzeugAuskunft }
| { art: 'bibliothek'; eintraege: BibliothekEintrag[] }
/** Nachrichten, die das Fenster über den MessagePort an den Kern schickt. */ /** Nachrichten, die das Fenster über den MessagePort an den Kern schickt. */
export type FensterNachricht = export type FensterNachricht =
@@ -86,6 +113,10 @@ export type FensterNachricht =
| { art: 'auswerfen'; id: string } | { art: 'auswerfen'; id: string }
| { art: 'rip-start'; id: string } | { art: 'rip-start'; id: string }
| { art: 'rip-abbruch'; id: string } | { art: 'rip-abbruch'; id: string }
| { art: 'einstellung-setzen'; schluessel: string; wert: string }
| { art: 'einstellungen-laden' }
| { art: 'werkzeuge-laden' }
| { art: 'bibliothek-laden' }
/** Nachrichten, die das Haupt an den Kern schickt (Port-Übergabe). */ /** Nachrichten, die das Haupt an den Kern schickt (Port-Übergabe). */
export type HauptNachricht = { art: 'fenster-port' } export type HauptNachricht = { art: 'fenster-port' }
@@ -132,6 +163,12 @@ export function istKernNachricht(wert: unknown): wert is KernNachricht {
typeof (wert.info as Record<string, unknown>).titel === 'string' && typeof (wert.info as Record<string, unknown>).titel === 'string' &&
typeof (wert.info as Record<string, unknown>).confidence === 'number' typeof (wert.info as Record<string, unknown>).confidence === 'number'
) )
case 'einstellungen':
return istObjekt(wert.werte)
case 'werkzeuge':
return istObjekt(wert.auskunft) && Array.isArray((wert.auskunft as Record<string, unknown>).presets)
case 'bibliothek':
return Array.isArray(wert.eintraege)
default: default:
return false return false
} }
@@ -162,7 +199,10 @@ export function istFensterNachricht(wert: unknown): wert is FensterNachricht {
if (wert.art === 'auswerfen' || wert.art === 'rip-start' || wert.art === 'rip-abbruch') { if (wert.art === 'auswerfen' || wert.art === 'rip-start' || wert.art === 'rip-abbruch') {
return typeof wert.id === 'string' && wert.id.length > 0 return typeof wert.id === 'string' && wert.id.length > 0
} }
return false if (wert.art === 'einstellung-setzen') {
return typeof wert.schluessel === 'string' && wert.schluessel.length > 0 && typeof wert.wert === 'string'
}
return wert.art === 'einstellungen-laden' || wert.art === 'werkzeuge-laden' || wert.art === 'bibliothek-laden'
} }
export function istHauptNachricht(wert: unknown): wert is HauptNachricht { export function istHauptNachricht(wert: unknown): wert is HauptNachricht {
+109 -17
View File
@@ -1,18 +1,21 @@
// Rippy v5 — Haupt-Prozess (KONZEPT-WINDOWS.md § 4.1). // Rippy v5 — Haupt-Prozess (KONZEPT-WINDOWS.md § 4.1).
// Ablauf: Einzelinstanz-Sperre → Prozess-Leine → Kern starten → Fenster // Ablauf: Einzelinstanz-Sperre → Prozess-Leine → Kern starten → Fenster
// öffnen → Fenster und Kern über einen MessagePort verdrahten. // öffnen → Fenster und Kern über einen MessagePort verdrahten. Seit W-5
// mit Tray: Fenster zu heißt weiterlaufen (§ 4.1 Punkt 3), beendet wird
// über das Tray-Menü; dazu Windows-Toasts und Taskleisten-Fortschritt (§ 7).
// //
// Smoke-Modus (`electron . --smoke`): beweist das W-0-Fertigkriterium // Smoke-Modus (`electron . --smoke`): beweist das W-0-Fertigkriterium
// („Ein Fenster geht auf, der Kern läuft") maschinell — alle drei Prozesse, // („Ein Fenster geht auf, der Kern läuft") maschinell — alle drei Prozesse,
// beide IPC-Wege und die Datenbank — und endet mit Code 0 (grün) oder 2. // beide IPC-Wege und die Datenbank — und endet mit Code 0 (grün) oder 2.
// RIPPY_SMOKE_BILD=<pfad.png> legt zusätzlich einen Screenshot ab. // RIPPY_SMOKE_BILD=<pfad.png> legt zusätzlich einen Screenshot ab.
import { app, ipcMain, type BrowserWindow } from 'electron' import { app, dialog, ipcMain, Notification, type BrowserWindow } from 'electron'
import { writeFile } from 'node:fs/promises' import { writeFile } from 'node:fs/promises'
import { join } from 'node:path' import { join } from 'node:path'
import type { HauptStatus, KernNachricht } from '../gemeinsam/nachrichten' import type { HauptStatus, KernNachricht } from '../gemeinsam/nachrichten'
import { fensterErstellen } from './fenster' import { fensterErstellen } from './fenster'
import { KernVerwaltung } from './kernstart' import { KernVerwaltung } from './kernstart'
import { leineSetzen, type LeineStatus } from './leine' import { leineSetzen, type LeineStatus } from './leine'
import { trayErstellen, trayHinweis } from './tray'
const smokeModus = process.argv.includes('--smoke') const smokeModus = process.argv.includes('--smoke')
@@ -40,6 +43,7 @@ function start(): void {
let fenster: BrowserWindow | null = null let fenster: BrowserWindow | null = null
let kernBereit: Extract<KernNachricht, { art: 'kern-bereit' }> | null = null let kernBereit: Extract<KernNachricht, { art: 'kern-bereit' }> | null = null
let beendenGewollt = smokeModus
const smoke = { const smoke = {
fenster: false, fenster: false,
@@ -61,11 +65,60 @@ function start(): void {
} }
statusSenden() statusSenden()
smokePruefen() smokePruefen()
} else if (nachricht.art === 'rip-status') {
ripStatusVerarbeiten(nachricht.status)
} else if (nachricht.art === 'disc-info' && nachricht.info.titel.length > 0 && !smokeModus) {
// Windows-Toast beim Erkennen (§ 7) — Klick holt das Fenster hoch.
toast(
'Disc erkannt',
`${nachricht.info.titel}${nachricht.info.jahr !== null ? ` (${nachricht.info.jahr})` : ''} · ${Math.round(nachricht.info.confidence * 100)} %` +
(nachricht.info.schonGerippt !== null
? ` — schon einmal gerippt (${nachricht.info.schonGerippt.slice(0, 10)})`
: ''),
)
} }
}, },
() => statusSenden(), () => statusSenden(),
) )
// § 7: Toasts, Taskleisten-Fortschritt und Tray-Zustand aus dem
// Rip-Status — die Information, die man will, ohne das Fenster zu öffnen.
function ripStatusVerarbeiten(status: Extract<KernNachricht, { art: 'rip-status' }>['status']): void {
if (fenster !== null && !fenster.isDestroyed()) {
if ((status.phase === 'rippt' || status.phase === 'komprimiert') && status.prozent >= 0) {
fenster.setProgressBar(status.prozent / 100)
} else if (status.phase === 'fehler') {
fenster.setProgressBar(1, { mode: 'error' })
} else {
fenster.setProgressBar(-1)
}
}
if (status.phase === 'rippt') {
trayHinweis(`rippt Laufwerk ${status.id}${status.prozent >= 0 ? ` (${status.prozent} %)` : ''}`)
} else if (status.phase === 'komprimiert') {
trayHinweis(`komprimiert Laufwerk ${status.id}${status.prozent >= 0 ? ` (${status.prozent} %)` : ''}`)
} else {
trayHinweis('')
}
if (smokeModus) return
if (status.phase === 'fertig') {
toast('Rippy ist fertig', status.text)
} else if (status.phase === 'fehler') {
toast('Rippy: Fehler', status.text)
}
}
function toast(titel: string, text: string): void {
try {
if (!Notification.isSupported()) return
const meldung = new Notification({ title: titel, body: text.slice(0, 200) })
meldung.on('click', () => fensterZeigen())
meldung.show()
} catch (fehler) {
console.error(`[haupt] Toast fehlgeschlagen: ${String(fehler)}`)
}
}
function statusSenden(): void { function statusSenden(): void {
if (fenster === null || fenster.isDestroyed()) return if (fenster === null || fenster.isDestroyed()) return
const status: HauptStatus = { const status: HauptStatus = {
@@ -76,6 +129,39 @@ function start(): void {
fenster.webContents.send('haupt-status', status) fenster.webContents.send('haupt-status', status)
} }
function fensterVerdrahten(): void {
if (fenster === null) return
fenster.webContents.on('did-finish-load', () => {
smoke.fenster = true
// Verdrahtung NACH dem Laden: Die Seite lauscht erst dann auf den Port.
kernVerwaltung.fensterVerbinden(fenster!.webContents)
statusSenden()
smokePruefen()
})
// § 4.1 Punkt 3: Fenster zu, Rip läuft weiter — Rippy lebt im Tray.
fenster.on('close', (ereignis) => {
if (beendenGewollt) return
ereignis.preventDefault()
fenster?.hide()
})
}
function fensterZeigen(): void {
if (fenster === null || fenster.isDestroyed()) {
fenster = fensterErstellen()
fensterVerdrahten()
return
}
if (fenster.isMinimized()) fenster.restore()
fenster.show()
fenster.focus()
}
function beenden(): void {
beendenGewollt = true
app.quit()
}
// Kurze Rückmeldungen aus dem Fenster (Brücke: rippy.melden). Im // Kurze Rückmeldungen aus dem Fenster (Brücke: rippy.melden). Im
// Smoke-Lauf ist 'kern-pong' der Beweis, dass der MessagePort // Smoke-Lauf ist 'kern-pong' der Beweis, dass der MessagePort
// Fenster ↔ Kern in BEIDE Richtungen trägt. // Fenster ↔ Kern in BEIDE Richtungen trägt.
@@ -86,33 +172,39 @@ function start(): void {
} }
}) })
app.on('second-instance', () => { // Echter Windows-Ordnerdialog (§ 7) — der Nutzer sieht seine
if (fenster !== null && !fenster.isDestroyed()) { // Netzlaufwerke und Schnellzugriffe statt eines nachgebauten Browsers.
if (fenster.isMinimized()) fenster.restore() ipcMain.handle('ordner-waehlen', async (_ereignis, titel: unknown) => {
fenster.focus() if (fenster === null) return null
} const ergebnis = await dialog.showOpenDialog(fenster, {
title: typeof titel === 'string' && titel.length > 0 ? titel : 'Ordner wählen',
properties: ['openDirectory', 'createDirectory'],
})
return ergebnis.canceled || ergebnis.filePaths.length === 0 ? null : ergebnis.filePaths[0]
}) })
// W-0: Fenster zu = Rippy zu. Erst W-5 bringt das Tray-Symbol — dann lebt app.on('second-instance', () => {
// Rippy nach dem Schließen im Hintergrund weiter (§ 4.1, Punkt 3). fensterZeigen()
})
// Mit Tray beendet ein geschlossenes Fenster Rippy NICHT mehr — es läuft
// im Hintergrund weiter. Beendet wird über das Tray-Menü.
app.on('window-all-closed', () => { app.on('window-all-closed', () => {
app.quit() if (beendenGewollt) app.quit()
}) })
app.on('before-quit', () => { app.on('before-quit', () => {
beendenGewollt = true
kernVerwaltung.beenden() kernVerwaltung.beenden()
}) })
void app.whenReady().then(() => { void app.whenReady().then(() => {
kernVerwaltung.starten() kernVerwaltung.starten()
if (!smokeModus) {
trayErstellen(fensterZeigen, beenden)
}
fenster = fensterErstellen() fenster = fensterErstellen()
fenster.webContents.on('did-finish-load', () => { fensterVerdrahten()
smoke.fenster = true
// Verdrahtung NACH dem Laden: Die Seite lauscht erst dann auf den Port.
kernVerwaltung.fensterVerbinden(fenster!.webContents)
statusSenden()
smokePruefen()
})
}) })
let smokeAbgeschlossen = false let smokeAbgeschlossen = false
+52
View File
@@ -0,0 +1,52 @@
// Tray-Symbol mit Zustand (KONZEPT § 7): auf einen Blick, ob ein Rip
// läuft und wie weit. Mit dem Tray ändert sich das Schließen-Verhalten:
// Fenster zu heißt „im Hintergrund weiterarbeiten" (§ 4.1 Punkt 3) —
// beendet wird über das Kontextmenü oder den Menüpunkt.
import { Menu, nativeImage, Tray } from 'electron'
import { join } from 'node:path'
let tray: Tray | null = null
export function trayErstellen(oeffnen: () => void, beenden: () => void): Tray | null {
try {
// Das Bau-Icon liegt neben dem Bundle: out/haupt → ../../bau/tray.png
// im Quell-Lauf; im gepackten Programm über resourcesPath (W-6 legt es
// per extraResources dorthin).
const kandidaten = [
join(__dirname, '..', '..', 'bau', 'tray.png'),
join(process.resourcesPath ?? '', 'bau', 'tray.png'),
]
let bild = nativeImage.createEmpty()
for (const kandidat of kandidaten) {
const versuch = nativeImage.createFromPath(kandidat)
if (!versuch.isEmpty()) {
bild = versuch
break
}
}
tray = new Tray(bild)
tray.setToolTip('Rippy — bereit')
tray.setContextMenu(
Menu.buildFromTemplate([
{ label: 'Rippy öffnen', click: oeffnen },
{ type: 'separator' },
{ label: 'Beenden', click: beenden },
]),
)
tray.on('click', oeffnen)
return tray
} catch (fehler) {
// R4: Ohne Tray läuft Rippy trotzdem — aber der Grund steht im Log.
console.error(`[haupt] Tray nicht erstellt: ${String(fehler)}`)
return null
}
}
export function trayHinweis(text: string): void {
tray?.setToolTip(text.length > 0 ? `Rippy — ${text}` : 'Rippy — bereit')
}
export function trayWeg(): void {
tray?.destroy()
tray = null
}
+32 -3
View File
@@ -7,10 +7,10 @@
// Roh-Ordner; die echten Titel ersetzen das dann. Roh-Dateien werden in // Roh-Ordner; die echten Titel ersetzen das dann. Roh-Dateien werden in
// dieser Etappe GRUNDSÄTZLICH BEHALTEN — Löschen bekommt erst mit der // dieser Etappe GRUNDSÄTZLICH BEHALTEN — Löschen bekommt erst mit der
// Einstellung im UI (W-5) eine Stimme. // 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. // Plattform-Pfade wie in struktur.ts: Laufzeit-Wurzeln, CI testet auf Linux.
import { basename, join } from 'node:path' 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 { baueNfo } from '../ablage/nfo'
import { bibliothekRefresh } from '../ablage/medienserver' import { bibliothekRefresh } from '../ablage/medienserver'
import { posterSpeichern } from '../ablage/poster' import { posterSpeichern } from '../ablage/poster'
@@ -38,6 +38,10 @@ export interface PipelineUmgebung {
melden: (status: RipStatus) => void melden: (status: RipStatus) => void
/** Die letzte Metadaten-Zuordnung dieses Laufwerks (W-4) — oder null. */ /** Die letzte Metadaten-Zuordnung dieses Laufwerks (W-4) — oder null. */
zuordnung?: (id: string) => Zuordnung | 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. */ /** Zeitstempel für Ordnernamen — injizierbar für Tests. */
jetzt?: () => Date jetzt?: () => Date
} }
@@ -99,7 +103,8 @@ export class Pipeline {
} }
const abbruch = new AbortController() 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 vorgangsName = `${geraet.typ}-${zeit}`
const rohOrdner = join(this.umgebung.ablage(), 'roh', vorgangsName) const rohOrdner = join(this.umgebung.ablage(), 'roh', vorgangsName)
@@ -267,6 +272,30 @@ export class Pipeline {
const serverKey = String(einstellungen['mediaServerApiKey'] ?? '') const serverKey = String(einstellungen['mediaServerApiKey'] ?? '')
const refresh = await bibliothekRefresh(server, serverUrl, serverKey) 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.laufend.delete(id)
this.melden({ this.melden({
id, id,
+88 -4
View File
@@ -18,7 +18,7 @@ import { Pipeline } from './ablauf/pipeline'
import type { LaufwerkApi } from './laufwerk/codes' import type { LaufwerkApi } from './laufwerk/codes'
import { auswerfenMitGrund, geraeteInfo, kennung, listOptischeLaufwerke } from './laufwerk/disc' import { auswerfenMitGrund, geraeteInfo, kennung, listOptischeLaufwerke } from './laufwerk/disc'
import { DiscWache } from './laufwerk/wache' import { DiscWache } from './laufwerk/wache'
import { discTitel } from './metadaten/discmerkmale' import { discFingerprint, discTitel } from './metadaten/discmerkmale'
import { OmdbClient } from './metadaten/omdb' import { OmdbClient } from './metadaten/omdb'
import { TmdbClient } from './metadaten/tmdb' import { TmdbClient } from './metadaten/tmdb'
import { zuordnen, type Zuordnung } from './metadaten/zuordnung' import { zuordnen, type Zuordnung } from './metadaten/zuordnung'
@@ -84,10 +84,13 @@ let wache: DiscWache | null = null
// ── Metadaten (W-4): Was liegt da drin? ───────────────────────────────── // ── Metadaten (W-4): Was liegt da drin? ─────────────────────────────────
const discZuordnungen = new Map<string, Zuordnung>() 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 { try {
const gefunden = discTitel(geraetepfad) const gefunden = discTitel(geraetepfad)
const fingerprint = discFingerprint(geraetepfad, groesseBytes)
discFingerprints.set(id, fingerprint)
const tmdb = new TmdbClient(db?.einstellungLesen('tmdbApiKey') ?? '') const tmdb = new TmdbClient(db?.einstellungLesen('tmdbApiKey') ?? '')
const omdb = new OmdbClient(db?.einstellungLesen('omdbApiKey') ?? '') const omdb = new OmdbClient(db?.einstellungLesen('omdbApiKey') ?? '')
const zuordnung = await zuordnen(gefunden.titel, tmdb, omdb) const zuordnung = await zuordnen(gefunden.titel, tmdb, omdb)
@@ -101,6 +104,9 @@ async function discErmitteln(id: string, geraetepfad: string): Promise<void> {
confidence: zuordnung.confidence, confidence: zuordnung.confidence,
quelle: zuordnung.meta.quelle, quelle: zuordnung.meta.quelle,
posterPfad: zuordnung.meta.posterPfad, 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) { } catch (fehler) {
@@ -124,12 +130,21 @@ const laufwerkeBereit: Promise<void> = (async () => {
anHauptUndFenster({ art: 'laufwerke', liste: meldung.laufwerke, ereignisse: meldung.ereignisse }) anHauptUndFenster({ art: 'laufwerke', liste: meldung.laufwerke, ereignisse: meldung.ereignisse })
for (const ereignis of meldung.ereignisse) { for (const ereignis of meldung.ereignisse) {
if (ereignis.art === 'disc-eingelegt') { 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') { } else if (ereignis.art === 'disc-entnommen') {
discZuordnungen.delete(ereignis.geraet.id) discZuordnungen.delete(ereignis.geraet.id)
discFingerprints.delete(ereignis.geraet.id)
anHauptUndFenster({ anHauptUndFenster({
art: 'disc-info', 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, einstellungen: einstellungenLaden,
melden: (status) => anHauptUndFenster({ art: 'rip-status', status }), melden: (status) => anHauptUndFenster({ art: 'rip-status', status }),
zuordnung: (id) => discZuordnungen.get(id) ?? null, 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> { async function auswerfen(id: string): Promise<void> {
await laufwerkeBereit await laufwerkeBereit
if (ripVerwaltung.laeuft(id)) { if (ripVerwaltung.laeuft(id)) {
@@ -242,6 +311,21 @@ process.parentPort.on('message', (ereignis) => {
void laufwerkeBereit.then(() => ripVerwaltung.starten(frage.id)) void laufwerkeBereit.then(() => ripVerwaltung.starten(frage.id))
} else if (frage.art === 'rip-abbruch') { } else if (frage.art === 'rip-abbruch') {
ripVerwaltung.abbrechen(frage.id) 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() fensterPort.start()
+57 -1
View File
@@ -10,8 +10,9 @@
import { DatabaseSync } from 'node:sqlite' import { DatabaseSync } from 'node:sqlite'
import { mkdirSync } from 'node:fs' import { mkdirSync } from 'node:fs'
import { dirname } from 'node:path' import { dirname } from 'node:path'
import type { BibliothekEintrag } from '../../gemeinsam/nachrichten'
const SCHEMA_VERSION = 1 export const SCHEMA_VERSION = 2
export class Datenbank { export class Datenbank {
readonly pfad: string readonly pfad: string
@@ -36,6 +37,16 @@ export class Datenbank {
CREATE TABLE IF NOT EXISTS einstellungen ( CREATE TABLE IF NOT EXISTS einstellungen (
schluessel TEXT PRIMARY KEY, schluessel TEXT PRIMARY KEY,
wert TEXT NOT NULL 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 this.db
@@ -64,6 +75,51 @@ export class Datenbank {
return zeile === undefined ? 0 : Number(zeile.wert) 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 { schliessen(): void {
this.db.close() this.db.close()
} }
+2 -2
View File
@@ -4,7 +4,7 @@ import { mkdtempSync, rmSync, existsSync } from 'node:fs'
import { tmpdir } from 'node:os' import { tmpdir } from 'node:os'
import { join } from 'node:path' import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest' import { afterEach, describe, expect, it } from 'vitest'
import { Datenbank } from '../src/kern/speicher/db' import { Datenbank, SCHEMA_VERSION } from '../src/kern/speicher/db'
let aufraeumen: Array<() => void> = [] let aufraeumen: Array<() => void> = []
@@ -26,7 +26,7 @@ describe('Datenbank', () => {
const db = new Datenbank(pfad) const db = new Datenbank(pfad)
aufraeumen.push(() => db.schliessen()) aufraeumen.push(() => db.schliessen())
expect(existsSync(pfad)).toBe(true) expect(existsSync(pfad)).toBe(true)
expect(db.schemaVersion()).toBe(1) expect(db.schemaVersion()).toBe(SCHEMA_VERSION)
}) })
it('schreibt und liest eine Einstellung', () => { it('schreibt und liest eine Einstellung', () => {