feat(v5): Etappe W-5 — Oberflaeche: Tray, Toasts, Taskleiste, Einstellungen, Bibliothek
Ampel / ampel (push) Successful in 1m23s
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:
co-authored by
Claude Fable 5
parent
a9fa8fa2d8
commit
c78ff22ab7
Binary file not shown.
|
After Width: | Height: | Size: 7.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 486 B |
@@ -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
@@ -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
@@ -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 })
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user