feat(v5): 5.5.0 — Kern und Oberfläche aufgeteilt, Übersicht und Einstellungen neu, Erst-Einrichtung mit Poster-Kulisse

Commander-Wünsche vom 02.09.2026 vor der Freigabe von 5.4.0 (die deshalb nie
veröffentlicht wurde):

Kern: kern/index.ts ist nur noch Bootstrap und Router; Dienste in
kern/dienste/ (Einstellungen, Werkzeuge, Laufwerke, Erkennung, Vorgänge,
Speicher, Benachrichtigung, Automatik), Kontext in kern/kontext.ts,
Protokoll-Datei in gemeinsam/protokoll.ts, Platz-Rechnung in gemeinsam/platz.ts.

Oberfläche: fenster/App.tsx ist nur noch der Rahmen; bausteine.tsx, dialoge/,
uebersicht/ (LaufwerkKachel, KompressionsKarte, SpeicherKarte, VerlaufKarte),
einstellungen/ (Karten je Funktion, SprachAuswahl), einrichtung/
(ErstEinrichtung, PosterHintergrund).

Neu: Bibliothek weg, dafür Verlauf; Speicher-und-Bilanz-Karte (statfs);
LED-Fußleiste; MakeMKV-Kopf in die Einstellungen; Sprach-Chips mit Dropdown
statt Allgemeinem Preset; Karten Automatik/Benachrichtigung/Allgemein/Update;
Durchsuchen bei Werkzeugen; Arbeitsordner lokal (Encode dort, dann in die
Ablage); Platz-Prüfung vor dem Rip (Dialog und Kern); Automatik mit Countdown;
Serien-Merker über Discs; Rettungs-Abbild mit Nullen nur nach gesehenen
Lesefehlern (kern/rip/rettung.ts, Phase rettet, iso:-Quelle); Warteschlange
pausieren; Discord/Telegram-Benachrichtigung; Protokoll-Datei je Tag;
Fensterlage 80 % mittig, gemerkt; Erst-Einrichtung in acht Schritten vor dem
Poster-Grid.

Beweise: 413 Tests (neu: speicher, benachrichtigung, automatik, rettung,
fensterlage, protokoll, kleinkram-550), drei Typprüfungen, Klick-Beweis mit
29 Schritten inklusive Erst-Einrichtung (zweiter Start mit
RIPPY_START_BEREICH=einrichtung), Bilder beweise/klick/01…09.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-02 17:51:15 +02:00
co-authored by Claude Fable 5.1
parent 6678c81592
commit 3dc6798c79
67 changed files with 6961 additions and 3950 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 246 KiB

After

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 259 KiB

After

Width:  |  Height:  |  Size: 368 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 554 KiB

After

Width:  |  Height:  |  Size: 730 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 731 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 369 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 503 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 485 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 554 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

+1 -1
View File
@@ -34,7 +34,7 @@ als npm-Skript hinterlegt: `npm run werkzeug:electron`.
| `npm run build` | Typprüfung (beide tsconfig) + Bau nach `out/` |
| `npm test` | Vitest: Wächter-Tests (§ 4.3), Datenbank, Nachrichten-Schema |
| `npm run smoke` | **Der W-0-Beweis:** baut, startet das echte Programm mit `--smoke`, prüft Fenster + Kern + Datenbank + Leine + Ping/Pong über den MessagePort und endet mit Code 0 (grün) oder 2. `RIPPY_SMOKE_BILD=<pfad.png>` legt zusätzlich einen Screenshot ab. Läuft nur unter Windows (Electron-Binary + koffi). |
| `npm run klick` | **Der Klick-Beweis (5.4.0):** baut, startet das echte Programm mit eigenem Profil (`--profil=…`, `--klick-smoke`: ohne Tray, Updater, Toasts), einem nachgebauten Laufwerk (`RIPPY_FAKE_LAUFWERK=Q`) und nachgebauten Werkzeugen (`bau/klick/fake-*.cjs`, laufen über `kern/werkzeuge/aufruf.ts`) — und klickt per Playwright durch Rippen → Dialog → Sprachen → Folgen → Rip → Warteschlange → Kompression → Ablage → Roh-Dateien. Bilder in `../beweise/klick/`, Code 0 oder 2. Grund: Beide Fehler vom 01.09.2026 waren Verkabelung, die kein Unit-Test sah. Playwright ist reines Bau-Werkzeug (MIT, `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1` beim Installieren — Electron braucht keinen Browser-Download). |
| `npm run klick` | **Der Klick-Beweis (5.4.0):** baut, startet das echte Programm mit eigenem Profil (`--profil=…`, `--klick-smoke`: ohne Tray, Updater, Toasts), einem nachgebauten Laufwerk (`RIPPY_FAKE_LAUFWERK=Q`) und nachgebauten Werkzeugen (`bau/klick/fake-*.cjs`, laufen über `kern/werkzeuge/aufruf.ts`) — und klickt per Playwright durch Speicher-Karte → Rippen → Dialog → Sprachen → Folgen → Platz-Prüfung → Rip → Warteschlange → Kompression → Arbeitsordner (Roh lokal, Zwischenordner geräumt) → Ablage → Verlauf → Bilanz → Roh-Dateien → Einstellungen (Sprach-Chips, Durchsuchen, Karten Automatik/Benachrichtigung/Allgemein/Update). Seit 5.5.0 mit getrenntem Arbeitsordner und Automatik aus (der Beweis klickt selbst). Bilder in `../beweise/klick/`, Code 0 oder 2. Grund: Beide Fehler vom 01.09.2026 waren Verkabelung, die kein Unit-Test sah. Playwright ist reines Bau-Werkzeug (MIT, `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1` beim Installieren — Electron braucht keinen Browser-Download). |
Der Smoke-Lauf benutzt ein Wegwerf-Profil unter `%TEMP%\rippy-smoke` — er
fasst die echten Einstellungen nicht an.
+135 -10
View File
@@ -22,11 +22,16 @@ import { fileURLToPath } from 'node:url'
const wurzel = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const profil = join(tmpdir(), 'rippy-klick')
const ablage = join(profil, 'ablage')
// 5.5.0: Arbeitsordner getrennt von der Ablage — Roh und Encode liegen
// lokal, nur das Fertige wandert (hier: von Ordner zu Ordner im Profil).
const arbeit = join(profil, 'arbeit')
const bilder = resolve(wurzel, '..', 'beweise', 'klick')
rmSync(profil, { recursive: true, force: true })
mkdirSync(profil, { recursive: true })
mkdirSync(bilder, { recursive: true })
// Alte Bilder weg — sonst bleiben Nummern früherer Fassungen liegen.
for (const name of readdirSync(bilder)) if (name.endsWith('.png')) rmSync(join(bilder, name))
// Das Profil vorbereiten: Einrichtung erledigt, Ablage im Profil, die
// Werkzeuge sind die Nachbauten (kern/werkzeuge/aufruf.ts startet Skripte
@@ -44,20 +49,29 @@ mkdirSync(bilder, { recursive: true })
setzen.run('untertitelSprachen', 'deu')
setzen.run('rohAufbewahrung', 'behalten')
setzen.run('autoAuswurf', 'false')
setzen.run('arbeitsordner', arbeit)
// Die Automatik bleibt aus: Der Beweis klickt selbst.
setzen.run('automatik', 'aus')
db.close()
}
const app = await electron.launch({
const start = (env) =>
electron.launch({
executablePath: join(wurzel, 'node_modules', 'electron', 'dist', 'electron.exe'),
args: [wurzel, `--profil=${profil}`, '--klick-smoke'],
env: { ...process.env, RIPPY_FAKE_LAUFWERK: 'Q', RIPPY_OHNE_SCHLUESSEL: '1' },
timeout: 60_000,
})
const seite = await app.firstWindow()
seite.setDefaultTimeout(30_000)
seite.on('console', (m) => {
if (m.type() === 'error') console.log(`KLICK fenster: ${m.text().slice(0, 200)}`)
})
env: { ...process.env, RIPPY_FAKE_LAUFWERK: 'Q', RIPPY_OHNE_SCHLUESSEL: '1', ...env },
timeout: 60_000,
})
let app = await start({})
async function fenster() {
const s = await app.firstWindow()
s.setDefaultTimeout(30_000)
s.on('console', (m) => {
if (m.type() === 'error') console.log(`KLICK fenster: ${m.text().slice(0, 200)}`)
})
return s
}
let seite = await fenster()
let nr = 0
async function bild(name) {
@@ -87,6 +101,18 @@ await schritt('Fenster offen, nachgebautes Laufwerk Q: sichtbar', async () => {
})
await bild('uebersicht')
await schritt('Speicher-Karte misst Arbeitsordner und Ablage getrennt, die Fußleiste zeigt FREI', async () => {
await seite.getByText('SPEICHER UND BILANZ').waitFor()
await seite.getByText('ARBEITSORDNER (LOKAL)').waitFor()
await seite.getByText('ABLAGE (SERVER)').waitFor()
await seite.getByText(/^FREI [0-9]/).waitFor()
})
await schritt('Keine Bibliothek mehr — Übersicht und Einstellungen sind die Bereiche', async () => {
const n = await seite.getByRole('button', { name: 'Bibliothek' }).count()
if (n !== 0) throw new Error('Bibliothek-Knopf ist noch da')
})
await schritt('Rippen → Dialog öffnet sich', async () => {
await seite.getByRole('button', { name: 'Rippen', exact: true }).click()
await seite.getByText('Was soll gerippt werden?').waitFor()
@@ -130,6 +156,10 @@ await schritt('Vorschau nennt Serien\\…\\Season 01 und S01E03', async () => {
await dialog().getByText(/Season 01/).waitFor()
await dialog().getByText(/S01E03/).waitFor()
})
await schritt('Platz-Prüfung im Dialog: der Arbeitsordner reicht', async () => {
await dialog().getByText(/^Platz: .* frei im Arbeitsordner/).waitFor()
})
await bild('dialog')
await schritt('Rip starten → Phase RIPPT', async () => {
@@ -168,6 +198,26 @@ await schritt('Ergebnis liegt als S01E03/S01E04 unter Serien\\…\\Season 01', a
console.log(`KLICK dateien: ${gefunden.join(' | ')}`)
})
await schritt('Arbeitsordner: Roh liegt lokal, der Encode-Zwischenordner ist aufgeräumt', async () => {
const roh = join(arbeit, 'roh')
if (!existsSync(roh) || readdirSync(roh).length === 0) throw new Error(`kein Roh-Ordner unter ${roh}`)
const fertig = join(arbeit, 'fertig')
if (existsSync(fertig) && readdirSync(fertig).length > 0) throw new Error(`Zwischenordner nicht geräumt: ${readdirSync(fertig).join(', ')}`)
const rohInAblage = join(ablage, 'roh')
if (existsSync(rohInAblage)) throw new Error('Roh-Ordner liegt in der Ablage statt im Arbeitsordner')
})
await schritt('Verlauf in der Übersicht nennt den Vorgang als fertig', async () => {
await seite.getByText('VERLAUF', { exact: true }).waitFor()
await seite.getByText(/die letzten 1 Vorgänge/).waitFor()
await seite.getByRole('button', { name: 'Ordner öffnen' }).first().waitFor()
})
await schritt('Bilanz zählt eine Disc', async () => {
await seite.getByText('DISCS', { exact: true }).waitFor()
await seite.getByText('1 fertig', { exact: true }).waitFor()
})
await schritt('Einstellungen → Roh-Dateien zeigt den Vorgang mit Belegung', async () => {
await seite.getByRole('button', { name: 'Einstellungen' }).click()
await seite.locator('#roh').waitFor()
@@ -176,6 +226,25 @@ await schritt('Einstellungen → Roh-Dateien zeigt den Vorgang mit Belegung', as
})
await bild('roh-dateien')
await schritt('Kompression: kein Allgemeines Preset, Sprachen als Chips (Deutsch, Englisch)', async () => {
if ((await seite.getByText(/Allgemeines Preset/).count()) !== 0) throw new Error('Allgemeines Preset ist noch da')
await seite.locator('#kompression').getByText('Deutsch', { exact: true }).first().waitFor()
await seite.locator('#kompression').getByText('Englisch', { exact: true }).first().waitFor()
await seite.locator('#kompression').locator('select').first().waitFor()
})
await schritt('Werkzeuge haben Durchsuchen, Allgemein und Update sind eigene Karten, MakeMKV-Karte trägt den Schlüssel', async () => {
const n = await seite.locator('#werkzeuge').getByRole('button', { name: /Durchsuchen/ }).count()
if (n < 3) throw new Error(`nur ${n} Durchsuchen-Knöpfe`)
await seite.locator('#allgemein').waitFor()
await seite.locator('#update').waitFor()
await seite.locator('#automatik').waitFor()
await seite.locator('#benachrichtigung').waitFor()
await seite.locator('#makemkv').getByText(/Eigener Schlüssel/).waitFor()
await seite.getByRole('button', { name: 'Protokoll öffnen' }).waitFor()
})
await bild('einstellungen')
await schritt('Roh-Dateien löschen → „gelöscht"', async () => {
await seite.getByRole('button', { name: 'Roh-Dateien löschen' }).first().click()
await seite.getByText(/Roh: gelöscht/).first().waitFor()
@@ -187,6 +256,62 @@ await schritt('Ereignisse nennen die Kompression', async () => {
})
await bild('ereignisse')
console.log('KLICK: OK — Rippen, Dialog, Sprachen, Folgen, Rip, Warteschlange, Kompression, Ablage, Roh-Dateien: alles geklickt.')
// ── Zweiter Start: die Erst-Einrichtung (5.5.0) ─────────────────────────
// RIPPY_START_BEREICH=einrichtung erzwingt den Assistenten (#einrichtung),
// auch wenn setup.done schon gesetzt ist — derselbe Weg wie im Smoke.
await app.close().catch(() => {})
app = await start({ RIPPY_START_BEREICH: 'einrichtung' })
seite = await fenster()
const weiter = () => seite.getByRole('button', { name: 'Weiter', exact: true }).click()
await schritt('Erst-Einrichtung: Willkommen vor der Poster-Kulisse (7 Spalten Platzhalter)', async () => {
await seite.getByText('Willkommen bei Rippy').waitFor()
const spalten = await seite.locator('.poster-spalte').count()
if (spalten !== 7) throw new Error(`${spalten} Poster-Spalten`)
})
await bild('einrichtung-willkommen')
await schritt('Prüfung: MakeMKV (Nachbau) gefunden, HandBrake dabei, Laufwerk Q: gesehen', async () => {
await weiter()
await seite.getByText(/MakeMKV gefunden/).waitFor()
await seite.getByText(/HandBrakeCLI dabei/).waitFor()
await seite.getByText(/1 optisches Laufwerk gefunden/).waitFor()
})
await schritt('Ordner: Ablage und Arbeitsordner mit Durchsuchen und freiem Platz', async () => {
await weiter()
await seite.getByText('Ablage-Ordner', { exact: true }).waitFor()
await seite.getByText('Arbeitsordner (lokal, optional)').waitFor()
const n = await seite.getByRole('button', { name: /Durchsuchen/ }).count()
if (n < 2) throw new Error(`nur ${n} Durchsuchen-Knöpfe`)
await seite.getByText(/frei von/).first().waitFor()
})
await schritt('Qualität: gemessene Encoder, Empfehlung je Disc-Typ, Sprach-Chips', async () => {
await weiter()
await seite.getByText('AUF DIESER MASCHINE GEMESSEN:').waitFor()
await seite.getByText('4K-UHD', { exact: true }).waitFor()
await seite.getByText('Deutsch', { exact: true }).first().waitFor()
})
await bild('einrichtung-qualitaet')
await schritt('Titel, Meldungen, Automatik: TMDb-Schlüssel, Discord/Telegram mit Testknopf, Countdown und Roh-Regel', async () => {
await weiter()
await seite.getByText('TMDb-API-Key', { exact: true }).waitFor()
await weiter()
await seite.getByText('Discord-Webhook', { exact: true }).waitFor()
await seite.getByRole('button', { name: 'Testnachricht schicken' }).waitFor()
await weiter()
await seite.getByText('Countdown', { exact: true }).waitFor()
await seite.getByText(/Roh-Dateien nach gelungener Kompression/).waitFor()
})
await schritt('Fertig: der Knopf Los gehts', async () => {
await weiter()
await seite.getByRole('button', { name: "Los geht's" }).waitFor()
})
await bild('einrichtung-fertig')
console.log('KLICK: OK — Speicher, Rippen, Dialog, Sprachen, Folgen, Platz, Rip, Warteschlange, Kompression, Arbeitsordner, Ablage, Verlauf, Bilanz, Roh-Dateien, Einstellungen, Erst-Einrichtung: alles geklickt.')
await app.close().catch(() => {})
process.exit(0)
+54
View File
@@ -1,3 +1,57 @@
Version 5.5.0
Uebersicht und Einstellungen neu sortiert.
Die Bibliothek ist weg (Jellyfin IST die Bibliothek). Dafuer zeigt die
Uebersicht den Verlauf der letzten Vorgaenge, den freien Platz auf
Arbeitsordner und Ablage samt Bilanz (Discs, gesicherte GB, Dauer je Disc)
und eine Fussleiste mit Kern, Datenbank, Werkzeugen, Schluessel, freiem
Platz und Update-Stand. Der MakeMKV-Kopf ist in die Einstellungen gezogen.
Dort gibt es eigene Karten fuer Automatik, Benachrichtigung, Allgemein und
Update; "Allgemeines Preset" ist weg, Ton- und Untertitelsprachen sind
Auswahl-Chips mit Dropdown in Wunschreihenfolge; Werkzeuge haben
"Durchsuchen ...".
Arbeitsordner lokal, Ablage auf dem Server.
Roh-Dateien und Kompression liegen im Arbeitsordner (lokale Platte), nur
das fertige Ergebnis wandert in die Ablage. Vor jedem Rip prueft Rippy den
Platz: zu wenig fuer den verlustfreien Rip heisst kein Start, knapp heisst
Hinweis.
Automatik mit Countdown.
Ist Rippy sicher (ab 90 %) und die Disc noch nicht gerippt, liest es die
Titel und startet nach 30 s (einstellbar) mit seinem Vorschlag - mit
"Stopp" und "Jetzt" in der Kachel. Bei Unsicherheit wartet es und sagt
warum.
Serien-Merker ueber Discs hinweg.
Disc 1 endet mit Folge 4, Disc 2 zaehlt ab 5 weiter - der Dialog schlaegt
es vor, aenderbar.
Rettungs-Abbild fuer defekte Discs.
Hat Rippy Lesefehler gesehen (Defekt-Waechter, Lesefehler-Meldung), bietet
die Kachel "Rettungs-Abbild" an: Rippys eigener Sektor-Leser liest die Disc
Stueck fuer Stueck, wiederholt in immer kleineren Stuecken und fuellt nur
das wirklich Unlesbare mit Nullen; danach laeuft derselbe Rip aus dem
Abbild, an dem MakeMKV nicht mehr haengen bleibt. Ein Bericht daneben
nennt jede genullte Stelle. Bei einer heilen Disc passiert das nie.
Warteschlange pausieren, Benachrichtigung, Protokoll.
"Pausieren" laesst den laufenden Film fertig rechnen und haelt die Schlange
an. Discord-Webhook oder Telegram-Bot melden fertige Rips, Kompressionen
und Fehler aufs Handy (Testknopf). Haupt und Kern schreiben eine
Protokoll-Datei je Tag (14 Tage), "Protokoll oeffnen" unter Allgemein;
Ordner oeffnen sich aus Verlauf, Roh-Dateien und Speicher-Karte im Explorer.
Erst-Einrichtung mit Poster-Kulisse.
Acht Schritte mit denselben Bausteinen wie die Einstellungen (Ordner,
Qualitaet mit Sprachen, Titel, Meldungen, Automatik) vor einem langsam
laufenden Poster-Raster - Platzhalter bis zum TMDb-Schluessel, danach
echte Poster.
Dazu: Das Fenster oeffnet mit 80 % des Bildschirms, mittig, mindestens
1100 x 720, und merkt sich Lage und Groesse (nur, wenn sie noch auf einem
Bildschirm liegt). Oberflaeche und Kern sind in Bereiche aufgeteilt.
Version 5.4.0
Das Laufwerk ist frei, sobald die Roh-Dateien liegen.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "rippy",
"version": "5.4.0",
"version": "5.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "rippy",
"version": "5.4.0",
"version": "5.5.0",
"license": "UNLICENSED",
"dependencies": {
"electron-updater": "^6.8.9",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "rippy",
"productName": "Rippy",
"version": "5.4.0",
"version": "5.5.0",
"description": "Rippy v5 — Disc-Ripping als eigenständiges Windows-Programm (KONZEPT-WINDOWS.md)",
"main": "out/haupt/index.js",
"author": "KrBrZ",
+14
View File
@@ -5,6 +5,8 @@
// Kontextgrenze — contextBridge kann keine Ports übertragen)
// 2. den Haupt-Status (Kanal 'haupt-status')
// 3. rippy.melden(art) für kurze Rückmeldungen ans Haupt
// … plus die Windows-Dialoge und Shell-Wege, die nur das Haupt öffnen kann
// (Ordner/Datei wählen, Ordner im Explorer zeigen, Protokoll öffnen).
//
// Beides wird GEPUFFERT: Das Haupt schickt Port und Status direkt nach
// did-finish-load — ob die React-Seite ihre Horcher da schon registriert
@@ -52,6 +54,18 @@ contextBridge.exposeInMainWorld('rippy', {
ordnerWaehlen(titel: string): Promise<string | null> {
return ipcRenderer.invoke('ordner-waehlen', String(titel)) as Promise<string | null>
},
/** Echter Windows-Dateidialog für Programme (5.5.0: Werkzeuge → Durchsuchen). */
dateiWaehlen(titel: string): Promise<string | null> {
return ipcRenderer.invoke('datei-waehlen', String(titel)) as Promise<string | null>
},
/** Einen Ordner im Explorer zeigen (5.5.0). Liefert '' oder den Fehler. */
ordnerOeffnen(pfad: string): Promise<string> {
return ipcRenderer.invoke('ordner-oeffnen', String(pfad)) as Promise<string>
},
/** Den Protokoll-Ordner im Explorer zeigen (5.5.0). */
protokollOeffnen(): Promise<string> {
return ipcRenderer.invoke('protokoll-oeffnen') as Promise<string>
},
/** Autostart an/aus (§ 8) — liefert den tatsächlichen Zustand zurück. */
autostartSetzen(an: boolean): Promise<boolean> {
return ipcRenderer.invoke('autostart-setzen', an === true) as Promise<boolean>
File diff suppressed because it is too large Load Diff
+462
View File
@@ -0,0 +1,462 @@
// Die Bausteine der Oberfläche (5.5.0, Separation of Concerns): alles,
// was mehr als ein Bereich braucht — Formatierer, Symbole, Badge, Kacheln,
// Segment-Anzeige, Schalter, Felder, Karten. Der Kino-Mix (Design-Entscheid
// des Commanders, 31.08.2026) lebt in den Tokens von stil.css; hier sind
// nur die Klassen, die ihn tragen.
import { useEffect, useRef, useState } from 'react'
import type { DiscInfoStand, GeraeteInfo, RipStatus, SprachEintrag } from '../gemeinsam/nachrichten'
// ── Formatierer ─────────────────────────────────────────────────────────
export function gb(bytes: number): string {
return `${(bytes / 1e9).toFixed(2).replace('.', ',')} GB`
}
/** Größen mit passender Einheit: 412 GB, 2,2 TB. */
export function gbKurz(bytes: number): string {
if (bytes >= 1e12) return `${(bytes / 1e12).toFixed(1).replace('.', ',')} TB`
if (bytes >= 1e9) return `${Math.round(bytes / 1e9)} GB`
return `${(bytes / 1e6).toFixed(0)} MB`
}
export function dauerText(s: number): string {
const m = Math.floor(s / 60)
return m >= 60
? `${Math.floor(m / 60)}:${String(m % 60).padStart(2, '0')} h`
: `${m}:${String(Math.round(s % 60)).padStart(2, '0')} min`
}
/** „1 h 42 min" für Statistik und Verlauf. */
export function dauerGrob(s: number): string {
const m = Math.round(s / 60)
if (m < 60) return `${m} min`
return `${Math.floor(m / 60)} h ${String(m % 60).padStart(2, '0')} min`
}
export function datumKurz(iso: string | null): string {
if (iso === null || iso.length < 16) return '—'
return iso.slice(0, 16).replace('T', ' ')
}
export function restzeitText(prozent: number, startMs: number | undefined): string {
if (startMs === undefined || prozent <= 2 || prozent >= 100) return ''
const verstrichen = (Date.now() - startMs) / 1000
const restS = Math.round((verstrichen * (100 - prozent)) / prozent)
if (restS < 90) return `REST ~${restS} S`
return `REST ~${Math.round(restS / 60)} MIN`
}
// ── TMDb-Bilder: Größen aus /configuration gemessen (01.09.2026) ────────
export function posterUrlKlein(pfad: string): string {
return bildUrl(pfad, 'w154')
}
export function posterUrlGross(pfad: string): string {
return bildUrl(pfad, 'w342')
}
export function backdropUrl(pfad: string): string {
return bildUrl(pfad, 'w780')
}
/** Freigestellter Schriftzug — SVG-Logos zeigt Chromium direkt. */
export function logoUrl(pfad: string): string {
return bildUrl(pfad, 'w300')
}
export function profilUrl(pfad: string): string {
return bildUrl(pfad, 'w185')
}
function bildUrl(pfad: string, groesse: string): string {
if (pfad.length === 0) return ''
if (pfad.startsWith('http')) return pfad
return `https://image.tmdb.org/t/p/${groesse}${pfad}`
}
/** „1988 · FSK 16 · 124 Min · Animation · ★ 7,9" — nur, was da ist. */
export function metaZeile(info: DiscInfoStand): string {
const teile: string[] = []
if (info.jahr !== null) teile.push(String(info.jahr))
if (info.fsk.length > 0) teile.push(`FSK ${info.fsk}`)
if (info.laufzeitMinuten > 0) teile.push(`${info.laufzeitMinuten} Min`)
if (info.genres.length > 0) teile.push(info.genres.slice(0, 3).join(' · '))
if (info.bewertung > 0) teile.push(`${info.bewertung.toFixed(1).replace('.', ',')}`)
return teile.join(' · ')
}
export 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'
}
/** 'Staffel 1 · Disc 2 · 2 Folgen' — was die Disc über ihre Lage in der
* Ausgabe sagt. Die Folgenzahl stammt aus ihrem eigenen
* Inhaltsverzeichnis, ist also gemessen und nicht geraten. '' wenn die
* Disc nichts davon verrät. */
export function discHerkunft(info: DiscInfoStand): string {
const teile: string[] = []
if (info.staffel !== null) teile.push(`Staffel ${info.staffel}`)
if (info.discNr !== null) teile.push(`Disc ${info.discNr}`)
if (info.folgen > 0) teile.push(info.folgen === 1 ? '1 Folge' : `${info.folgen} Folgen`)
return teile.join(' · ')
}
/** Vorbelegung der Sprachen je Titel: die Einstellung, soweit der Titel
* sie hat — sonst alles, was er hat (japanischer Ton statt gar keiner). */
export function sprachenVorbelegt(vorhanden: readonly SprachEintrag[], einstellung: string): string[] {
const da = vorhanden.map((s) => s.lang)
const wunsch = einstellung
.split(',')
.map((s) => s.trim().toLowerCase())
.filter((s) => s.length > 0)
const treffer = wunsch.filter((w) => da.includes(w))
return treffer.length > 0 ? treffer : da
}
export const TYP_NAMEN: Record<GeraeteInfo['typ'], string> = {
cd: 'Audio-CD',
dvd: 'DVD',
bluray: 'Blu-ray',
uhd: '4K-UHD',
unknown: 'unbekannt',
}
export const PHASEN_MONO: Record<RipStatus['phase'], string> = {
'liest-info': 'PHASE 1/4 — LIEST TITEL-INFO',
rettet: 'RETTUNGS-ABBILD — LIEST SEKTOR FÜR SEKTOR',
rippt: 'PHASE 2/4 — RIPPT (VERLUSTFREI)',
komprimiert: 'PHASE 3/4 — KOMPRIMIERT',
'legt-ab': 'PHASE 4/4 — LEGT AB',
fertig: 'FERTIG',
fehler: 'FEHLER',
abgebrochen: 'ABGEBROCHEN',
}
export const LAUFENDE_PHASEN: readonly string[] = ['liest-info', 'rettet', 'rippt', 'komprimiert', 'legt-ab']
export const FELD_KLASSE =
'mt-1 w-full rounded-lg border border-white/12 bg-samt px-3 py-2 font-mono text-xs text-schnee placeholder:text-nebel focus:border-gold/60 focus:outline-none'
export const KNOPF_LEISE = 'rounded-lg border border-white/15 bg-samt/60 px-3 py-1.5 text-xs text-dunst hover:text-schnee disabled:opacity-35'
export const KNOPF_GOLD =
'rounded-xl bg-gradient-to-b from-gold-hell to-gold-tief px-5 py-1.5 text-xs font-bold text-buehne shadow-[inset_0_1px_0_oklch(1_0_0/0.4)] hover:brightness-105 disabled:opacity-35'
// ── Ikonografie: eine Handvoll Strich-Symbole, alle aus demselben Guss ──
export function IconDisc({ groesse, farbe }: { groesse: number; farbe: string }) {
return (
<svg width={groesse} height={groesse} viewBox="0 0 24 24" fill="none" stroke={farbe} strokeWidth="1.6">
<circle cx="12" cy="12" r="9"></circle>
<circle cx="12" cy="12" r="2.6"></circle>
<path d="M12 3v3.4M12 17.6V21"></path>
</svg>
)
}
export function IconPlay() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<path d="M7 4.5v15l13-7.5z"></path>
</svg>
)
}
export function IconStop() {
return (
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor">
<rect x="6" y="6" width="12" height="12" rx="1.5"></rect>
</svg>
)
}
export function IconEject() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M5 17h14M12 4l6 9H6z"></path>
</svg>
)
}
export function IconStift() {
return (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M4 20l4.5-1L20 7.5 16.5 4 5 15.5 4 20z"></path>
</svg>
)
}
// ── Eigene Fensterknöpfe (§ 4.2, seit 5.1.3) ────────────────────────────
// Bewusst schlicht und in Windows-Maßen (46 × 32) — wer sie sucht, findet
// sie dort, wo er sie erwartet. Sie sitzen in der Ziehfläche und müssen
// deshalb `nicht-ziehbar` sein, sonst verschluckt der Fenstergriff den Klick.
export const IconMinimieren = (): React.JSX.Element => (
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true">
<path d="M0 5h10" stroke="currentColor" strokeWidth="1.1" />
</svg>
)
export const IconMaximieren = (): React.JSX.Element => (
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" aria-hidden="true">
<rect x="0.6" y="0.6" width="8.8" height="8.8" stroke="currentColor" strokeWidth="1.1" />
</svg>
)
export const IconWiederherstellen = (): React.JSX.Element => (
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" aria-hidden="true">
<rect x="0.6" y="2.6" width="6.8" height="6.8" stroke="currentColor" strokeWidth="1.1" />
<path d="M2.7 2.6V0.6h6.7v6.7h-2" stroke="currentColor" strokeWidth="1.1" />
</svg>
)
export const IconSchliessen = (): React.JSX.Element => (
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true">
<path d="M0.6 0.6l8.8 8.8M9.4 0.6l-8.8 8.8" stroke="currentColor" strokeWidth="1.1" />
</svg>
)
export function FensterKnopf({
titel,
aufKlick,
rot = false,
children,
}: {
titel: string
aufKlick: () => void
rot?: boolean
children: React.ReactNode
}) {
return (
<button
onClick={aufKlick}
title={titel}
aria-label={titel}
className={`flex h-8 w-[46px] items-center justify-center text-dunst transition-colors ${
rot ? 'hover:bg-rot hover:text-white' : 'hover:bg-white/10 hover:text-schnee'
}`}
>
{children}
</button>
)
}
// ── Kleine Bausteine des Schaltpults ────────────────────────────────────
export function Badge({ text, ton }: { text: string; ton: 'gruen' | 'gelb' | 'blau' | 'grau' }) {
const farben = {
gruen: 'border-gruen/40 bg-gruen/10 text-gruen',
gelb: 'border-warn/40 bg-warn/10 text-warn',
blau: 'border-gold/40 bg-gold/10 text-gold-hell',
grau: 'border-white/15 bg-white/5 text-dunst',
}
return (
<span className={`inline-block rounded-full border px-2.5 py-0.5 text-[11px] leading-4 ${farben[ton]}`}>
{text}
</span>
)
}
/** Die kleinen Mess-Kacheln aus dem Schaltpult (MEDIUM / GRÖSSE / …). */
export function InfoKachel({ label, wert, farbe }: { label: string; wert: string; farbe?: string }) {
return (
<div className="rounded-lg border border-white/10 bg-samt/70 px-3 py-2 min-w-0">
<div className="font-mono text-[9px] tracking-[0.14em] text-nebel">{label}</div>
<div className={`mt-0.5 truncate text-[13px] font-semibold ${farbe ?? 'text-schnee'}`} title={wert}>
{wert}
</div>
</div>
)
}
/** Die goldene Aussteuerungsanzeige — 20 Segmente, gefüllt nach Prozent. */
export function SegmentBalken({ prozent }: { prozent: number }) {
const segmente = 20
const anteil = Math.max(0, Math.min(100, prozent)) / 100
const voll = Math.floor(anteil * segmente)
const halb = anteil * segmente - voll >= 0.3
return (
<div className="flex gap-1">
{Array.from({ length: segmente }, (_, i) => (
<div
key={i}
className={`h-3.5 flex-1 rounded-[3px] ${
i < voll ? 'segment-gluehen' : i === voll && halb ? 'bg-gold/45' : 'bg-white/8'
}`}
/>
))}
</div>
)
}
/** Ein schmaler Füllbalken (Speicher): Anteil belegt, Farbe nach Lage. */
export function FuellBalken({ anteil, ton }: { anteil: number; ton: 'gold' | 'warn' | 'rot' }) {
const farbe = ton === 'gold' ? 'from-gold-tief to-gold-hell' : ton === 'warn' ? 'from-warn to-warn' : 'from-rot to-rot'
return (
<div className="h-1.5 w-full overflow-hidden rounded-full bg-white/8">
<div className={`h-full rounded-full bg-gradient-to-r ${farbe} transition-all`} style={{ width: `${Math.max(0, Math.min(100, anteil * 100))}%` }} />
</div>
)
}
export function Kicker({ text }: { text: string }) {
return <h2 className="font-mono text-[10px] font-semibold tracking-[0.22em] text-gold/80">{text}</h2>
}
/** Ein richtiger Schalter statt einer nackten Checkbox. */
export function Schalter({ an, aufWechsel, label }: { an: boolean; aufWechsel: (an: boolean) => void; label: string }) {
return (
<button onClick={() => aufWechsel(!an)} className="flex items-center gap-3 text-left" role="switch" aria-checked={an}>
<span
className={`relative inline-block h-5 w-9 flex-none rounded-full transition-colors ${
an ? 'bg-gradient-to-b from-gold-hell to-gold-tief' : 'bg-white/12'
}`}
>
<span className={`absolute top-0.5 h-4 w-4 rounded-full bg-schnee shadow transition-all ${an ? 'left-[18px]' : 'left-0.5'}`} />
</span>
<span className="text-xs leading-5 text-dunst">{label}</span>
</button>
)
}
/** Eine Einstellungs-Karte: Kicker, ein Satz Einordnung, dann die Felder. */
export function SektionsKarte({
id,
titel,
intro,
children,
}: {
id: string
titel: string
intro: string
children: React.ReactNode
}) {
return (
<section id={id} className="scroll-mt-4 rounded-2xl border border-white/10 bg-karte/50 p-5">
<Kicker text={titel} />
<p className="mt-1.5 max-w-xl text-xs leading-5 text-nebel">{intro}</p>
<div className="mt-4 space-y-3">{children}</div>
</section>
)
}
/** Kleiner Status-Punkt für Schlüssel-Felder: hinterlegt oder leer. */
export function HinterlegtPunkt({ wert }: { wert: string }) {
return (
<span className={`ml-2 font-mono text-[10px] ${wert.length > 0 ? 'text-gruen' : 'text-nebel'}`}>
{wert.length > 0 ? '● hinterlegt' : '○ fehlt'}
</span>
)
}
export function Feld({
name,
wert,
hinweis,
geheim,
platzhalter,
aufAendern,
}: {
name: string
wert: string
hinweis?: string
geheim?: boolean
platzhalter?: string
aufAendern: (wert: string) => void
}) {
const [entwurf, setEntwurf] = useState(wert)
useEffect(() => setEntwurf(wert), [wert])
return (
<label className="block">
<span className="text-xs font-semibold text-dunst">{name}</span>
{hinweis !== undefined && <span className="ml-2 text-xs text-nebel">{hinweis}</span>}
<input
type={geheim === true ? 'password' : 'text'}
value={entwurf}
placeholder={platzhalter}
onChange={(e) => setEntwurf(e.target.value)}
onBlur={() => {
if (entwurf !== wert) aufAendern(entwurf)
}}
className={FELD_KLASSE}
/>
</label>
)
}
/** Ein Pfad-Feld mit Durchsuchen (Ordner oder Programm) — die echten
* Windows-Dialoge, keine nachgebauten (§ 7). */
export function PfadFeld({
name,
wert,
hinweis,
art,
dialogTitel,
aufAendern,
}: {
name: string
wert: string
hinweis?: string
art: 'ordner' | 'programm'
dialogTitel: string
aufAendern: (wert: string) => void
}) {
const waehlen = async (): Promise<void> => {
const gewaehlt = art === 'ordner' ? await window.rippy.ordnerWaehlen(dialogTitel) : await window.rippy.dateiWaehlen(dialogTitel)
if (gewaehlt !== null) aufAendern(gewaehlt)
}
return (
<div className="flex items-end gap-2">
<div className="min-w-0 flex-1">
<Feld name={name} wert={wert} hinweis={hinweis} aufAendern={aufAendern} />
</div>
<button onClick={() => void waehlen()} className={`${KNOPF_LEISE} py-2`}>
Durchsuchen
</button>
</div>
)
}
/** Nur ein Klick, der WIRKLICH auf der Hintergrundfläche begann, schließt
* einen Dialog.
*
* Commander-Fund (01.09.2026): „es gibt einen kleinen bug im UI wenn man
* selbst den titel sucht das suchfenster schließt sich dann einfach."
* Der Grund ist eine bekannte Falle: Wer im Eingabefeld Text markiert und
* dabei über den Dialogrand hinauszieht, drückt INNEN und lässt AUSSEN
* los. Der Browser meldet als Ziel des Klicks dann den gemeinsamen
* Vorfahren — die Hintergrundfläche. Ein reiner onClick-Horcher darauf
* schließt den Dialog also mitten im Tippen.
*
* Deshalb wird der Anfang gemerkt: Nur wenn Drücken UND Loslassen auf der
* Fläche selbst passierten, ist es ein Klick daneben. */
export function useHintergrundKlick(schliessen: () => void): {
onMouseDown: (e: React.MouseEvent) => void
onClick: (e: React.MouseEvent) => void
} {
const begannDraussen = useRef(false)
return {
onMouseDown: (e) => {
begannDraussen.current = e.target === e.currentTarget
},
onClick: (e) => {
if (begannDraussen.current && e.target === e.currentTarget) schliessen()
},
}
}
/** Der Rahmen jedes Dialogs: abgedunkelte Bühne, Karte in der Mitte. */
export function DialogRahmen({
schliessen,
breite,
children,
}: {
schliessen: () => void
breite: string
children: React.ReactNode
}) {
const hintergrund = useHintergrundKlick(schliessen)
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-6 backdrop-blur-sm" {...hintergrund}>
<div
className={`max-h-[85vh] w-full ${breite} overflow-y-auto rounded-2xl border border-white/12 bg-buehne p-5 shadow-2xl shadow-black/60`}
onClick={(e) => e.stopPropagation()}
>
{children}
</div>
</div>
)
}
@@ -0,0 +1,82 @@
// § 5 Schritt 4: „nein → fragen" — Suche + Auswahl der richtigen Metadaten.
import { useEffect, useState } from 'react'
import type { MetaVorschlag } from '../../gemeinsam/nachrichten'
import { DialogRahmen, IconDisc, posterUrlKlein } from '../bausteine'
import { metadatenSuchen, zuordnungSetzen } from '../kernverbindung'
export function AendernDialog({
id,
startText,
vorschlaege,
schliessen,
}: {
id: string
startText: string
vorschlaege: MetaVorschlag[]
schliessen: () => void
}) {
const [text, setText] = useState(startText)
useEffect(() => {
metadatenSuchen(id, startText)
// Nur beim Öffnen — weitere Suchen löst der Knopf aus.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return (
<DialogRahmen schliessen={schliessen} breite="max-w-lg">
<h3 className="mb-3 font-display text-lg font-bold text-schnee">Was ist das wirklich?</h3>
<div className="mb-3 flex gap-2">
<input
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') metadatenSuchen(id, text)
}}
placeholder="Titel suchen …"
className="flex-1 rounded-lg border border-white/12 bg-samt px-3 py-2 font-mono text-xs text-schnee placeholder:text-nebel focus:border-gold/60 focus:outline-none"
/>
<button
onClick={() => metadatenSuchen(id, text)}
className="rounded-lg border border-gold/50 bg-gold/10 px-4 py-2 text-xs font-semibold text-gold-hell hover:bg-gold/20"
>
Suchen
</button>
</div>
{vorschlaege.length === 0 ? (
<p className="font-mono text-xs text-nebel">Keine Treffer anderen Suchtext probieren.</p>
) : (
<div className="space-y-1.5">
{vorschlaege.map((v) => (
<button
key={`${v.typ}-${v.tmdbId}`}
onClick={() => {
zuordnungSetzen(id, v)
schliessen()
}}
className="flex w-full items-center gap-3 rounded-xl border border-white/10 bg-karte/60 p-2 text-left hover:border-gold/50 hover:bg-karte"
>
{v.posterPfad.length > 0 ? (
<img src={posterUrlKlein(v.posterPfad)} alt="" className="h-16 w-11 rounded-md object-cover" />
) : (
<span className="flex h-16 w-11 items-center justify-center rounded-md bg-samt">
<IconDisc groesse={20} farbe="oklch(0.55 0.015 80)" />
</span>
)}
<div>
<p className="text-sm font-medium text-schnee">
{v.titel}
{v.jahr !== null ? ` (${v.jahr})` : ''}
</p>
<p className="font-mono text-[10px] tracking-wider text-nebel">{v.typ === 'tv' ? 'SERIE' : 'FILM'}</p>
</div>
</button>
))}
</div>
)}
<div className="mt-4 text-right">
<button onClick={schliessen} className="rounded-lg border border-white/15 px-4 py-1.5 text-xs text-dunst hover:bg-white/5">
Schließen
</button>
</div>
</DialogRahmen>
)
}
@@ -0,0 +1,331 @@
// ── Auswahl: WAS soll gerippt werden? (§ 6.2) ───────────────────────────
// Bis zum 01.09.2026 rippte Rippy IMMER alles — Trailer, Werbung und
// Alterskennzeichen inklusive. Der Info-Lauf dauert 20120 s, deshalb
// beginnt er erst hier und nicht schon beim Einlegen.
// 5.4.0: Sprachen je Titel, Staffel/Folge von Hand, Zielpfad-Vorschau.
// 5.5.0: Platz-Prüfung vor dem Rip und der Serien-Merker (Folge von Disc 1
// zählt auf Disc 2 weiter).
import { useState } from 'react'
import { zielVorschau } from '../../gemeinsam/ablagepfad'
import { platzUrteil } from '../../gemeinsam/platz'
import type {
DiscInfoStand,
DiscTitelVorschlag,
GeraeteInfo,
SpeicherStand,
SprachEintrag,
TitelRolle,
TitelStand,
} from '../../gemeinsam/nachrichten'
import { DialogRahmen, KNOPF_GOLD, dauerText, gb, gbKurz, sprachenVorbelegt } from '../bausteine'
import { ripStarten } from '../kernverbindung'
/** Sprach-Chips eines Titels — anklickbar, außerhalb des Auswahl-Labels
* (ein Knopf IM Label würde die Checkbox mit umschalten). */
function SprachChips({
name,
vorhanden,
gewaehlt,
aufWechsel,
}: {
name: string
vorhanden: readonly SprachEintrag[]
gewaehlt: readonly string[]
aufWechsel: (lang: string) => void
}) {
if (vorhanden.length === 0) return null
return (
<span className="inline-flex flex-wrap items-center gap-1">
<span className="mr-0.5 font-mono text-[10px] tracking-wider text-nebel">{name}</span>
{vorhanden.map((s) => {
const an = gewaehlt.includes(s.lang)
return (
<button
key={s.lang}
type="button"
onClick={() => aufWechsel(s.lang)}
className={`rounded-md border px-1.5 py-0.5 font-mono text-[10.5px] ${
an ? 'border-gold/50 bg-gold/15 text-gold-hell' : 'border-white/12 text-nebel hover:text-dunst'
}`}
title={`${s.sprache}${s.spuren > 1 ? ` (${s.spuren} Spuren)` : ''}`}
>
{an ? '✓ ' : ''}
{s.lang}
{s.spuren > 1 ? `×${s.spuren}` : ''}
</button>
)
})}
</span>
)
}
export function TitelWahlDialog({
id,
typ,
stand,
info,
einstellungen,
regel,
speicher,
schliessen,
}: {
id: string
typ: GeraeteInfo['typ']
stand: TitelStand | undefined
info: DiscInfoStand | undefined
einstellungen: Record<string, string>
/** Klartext der Aufbewahrungs-Regel — damit klar ist, was mit dem
* Roh-Material passiert. */
regel: string
speicher: SpeicherStand | null
schliessen: () => void
}) {
const [gewaehlt, setGewaehlt] = useState<Set<number> | null>(null)
// Eigene Rollen-Wahl je Titel; was hier nicht drinsteht, behaelt Rippys
// Vorschlag ('hauptfilm' beim Film, 'folge' bei der Serie, sonst 'extra').
const [rollen, setRollen] = useState<Record<number, TitelRolle>>({})
// Sprachen je Titel (5.4.0): Was hier nicht drinsteht, ist vorbelegt
// (Einstellung, soweit der Titel sie hat — sonst alles, was er hat).
const [sprachen, setSprachen] = useState<Record<number, { audio: string[]; untertitel: string[] }>>({})
// Staffel und erste Folge von Hand (5.4.0) — die Staffel kommt von der
// Disc, die erste Folge vom Serien-Merker (5.5.0: die vorige Disc dieser
// Serie) oder vom Nutzer („auf Disc 2 sind 34").
const merker = info?.serienMerker ?? null
const merkerGilt = merker !== null && (info?.discNr === null || info === undefined || (info.discNr ?? 1) > 1)
const [staffel, setStaffel] = useState<string>(
info !== undefined && info.staffel !== null ? String(info.staffel) : merker !== null ? String(merker.staffel) : '',
)
const [ersteFolge, setErsteFolge] = useState(merkerGilt && merker !== null ? String(merker.naechsteFolge) : '')
// Rippys Vorauswahl gilt, bis der Nutzer sie anfasst.
const aktiv = gewaehlt ?? new Set((stand?.titel ?? []).filter((t) => t.gewaehlt).map((t) => t.nr))
const rolleVon = (t: DiscTitelVorschlag): TitelRolle => rollen[t.nr] ?? t.rolle
const sprachenVon = (t: DiscTitelVorschlag): { audio: string[]; untertitel: string[] } =>
sprachen[t.nr] ?? {
audio: sprachenVorbelegt(t.sprachen.audio, einstellungen['audioSprachen'] ?? ''),
untertitel: sprachenVorbelegt(t.sprachen.untertitel, einstellungen['untertitelSprachen'] ?? ''),
}
const sprachWechsel = (t: DiscTitelVorschlag, art: 'audio' | 'untertitel', lang: string): void => {
const jetzt = sprachenVon(t)
const liste = jetzt[art].includes(lang) ? jetzt[art].filter((l) => l !== lang) : [...jetzt[art], lang]
setSprachen({ ...sprachen, [t.nr]: { ...jetzt, [art]: liste } })
}
const umschalten = (nr: number): void => {
const neu = new Set(aktiv)
if (neu.has(nr)) neu.delete(nr)
else neu.add(nr)
setGewaehlt(neu)
}
const alle = stand?.titel ?? []
const gewaehlteTitel = alle.filter((t) => aktiv.has(t.nr))
const gesamtBytes = gewaehlteTitel.reduce((s, t) => s + t.groesseBytes, 0)
const alsSerie = gewaehlteTitel.some((t) => rolleVon(t) === 'folge')
const hauptTitel = gewaehlteTitel.filter((t) => rolleVon(t) !== 'extra').length
const extras = gewaehlteTitel.filter((t) => rolleVon(t) === 'extra').length
const zahlOderNull = (text: string, mindestens: number): number | null => {
const n = Number(text.trim())
return text.trim().length > 0 && Number.isInteger(n) && n >= mindestens ? n : null
}
const staffelZahl = zahlOderNull(staffel, 0)
const folgeZahl = zahlOderNull(ersteFolge, 1)
const vorschau = zielVorschau({
ablage: einstellungen['ablage'] ?? 'Videos\\Rippy',
alsSerie,
titel: info?.titel ?? '',
jahr: info?.jahr ?? null,
staffel: staffelZahl,
ersteFolge: folgeZahl,
hauptTitel,
extras,
vorgangsName: `${typ}-<Zeitstempel>`,
})
// Platz-Prüfung (5.5.0): gegen den Arbeitsordner — dort landen Roh und Encode.
const komprimiert = einstellungen['transcodeEnabled'] !== 'false'
const platz = speicher !== null && speicher.arbeit.ok ? platzUrteil(speicher.arbeit.freiBytes, gesamtBytes, komprimiert) : { text: '', hart: false }
return (
<DialogRahmen schliessen={schliessen} breite="max-w-3xl">
<h3 className="font-display text-xl font-bold text-schnee">Was soll gerippt werden?</h3>
{stand === undefined || stand.laeuft ? (
<p className="mt-4 font-mono text-xs text-nebel">
Rippy liest die Titel der Disc das dauert bis zu zwei Minuten. Solange dreht das Laufwerk.
</p>
) : stand.fehler.length > 0 ? (
<p className="mt-4 font-mono text-xs text-rot"> {stand.fehler}</p>
) : (
<>
<p className={`mt-1 text-xs ${stand.unsicher ? 'text-warn' : 'text-dunst'}`}>
{stand.unsicher ? '⚠ ' : ''}
{stand.einordnung}
</p>
<div className="mt-3 flex flex-wrap gap-1.5">
<button
onClick={() => setGewaehlt(new Set(alle.filter((t) => t.gewaehlt).map((t) => t.nr)))}
className="rounded-lg border border-gold/40 bg-gold/12 px-2.5 py-1 text-[11px] text-gold-hell"
>
Rippys Vorschlag
</button>
<button
onClick={() =>
setGewaehlt(
// Alles Sehenswerte: Hauptinhalt und Zugaben, aber weder
// Sammeltitel noch Logos/Warnungen.
new Set(alle.filter((t) => !t.grund.startsWith('Sammeltitel') && !t.grund.startsWith('zu kurz')).map((t) => t.nr)),
)
}
className="rounded-lg border border-white/15 px-2.5 py-1 text-[11px] text-dunst hover:text-schnee"
>
Hauptinhalt + Extras
</button>
<button onClick={() => setGewaehlt(new Set(alle.map((t) => t.nr)))} className="rounded-lg border border-white/15 px-2.5 py-1 text-[11px] text-dunst hover:text-schnee">
Alles
</button>
<button onClick={() => setGewaehlt(new Set())} className="rounded-lg border border-white/15 px-2.5 py-1 text-[11px] text-dunst hover:text-schnee">
Nichts
</button>
</div>
<div className="mt-3 space-y-1">
{alle.map((t) => {
const an = aktiv.has(t.nr)
const wahl = sprachenVon(t)
return (
<div key={t.nr} className={`rounded-lg border ${an ? 'border-gold/40 bg-gold/8' : 'border-white/8 bg-samt/40'}`}>
<label className="flex cursor-pointer items-center gap-3 p-2">
<input type="checkbox" checked={an} onChange={() => umschalten(t.nr)} className="accent-[oklch(0.83_0.11_75)]" />
<span className="w-16 flex-none font-mono text-[11px] text-nebel">Titel {t.nr}</span>
<span className="w-24 flex-none font-mono text-xs text-schnee">{dauerText(t.dauerS)}</span>
<span className="w-20 flex-none font-mono text-[11px] text-dunst">{gb(t.groesseBytes)}</span>
<span className="hidden w-20 flex-none font-mono text-[11px] text-nebel sm:inline">{t.kapitel} Kap.</span>
{an ? (
<select
value={rolleVon(t)}
onChange={(e) => setRollen({ ...rollen, [t.nr]: e.target.value as TitelRolle })}
onClick={(e) => e.stopPropagation()}
className="flex-none rounded-lg border border-white/15 bg-samt/70 px-2 py-0.5 text-[11px] text-dunst"
title="Bestimmt, WOHIN der Titel abgelegt wird"
>
<option value="hauptfilm">Hauptfilm</option>
<option value="extra">Extra</option>
<option value="folge">Folge</option>
</select>
) : (
<span className="min-w-0 flex-1 truncate text-[11px] text-nebel">{t.grund}</span>
)}
</label>
{/* Sprachen je Titel (5.4.0): Was der Info-Lauf an DIESEM
Titel gemessen hat — anhaken, was in die Datei soll.
Der Commander-Fall: japanischer Ton, deutsche Untertitel. */}
{an && (t.sprachen.audio.length > 0 || t.sprachen.untertitel.length > 0) && (
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 border-t border-white/8 px-2 py-1.5 pl-9">
<SprachChips name="TON" vorhanden={t.sprachen.audio} gewaehlt={wahl.audio} aufWechsel={(lang) => sprachWechsel(t, 'audio', lang)} />
<SprachChips name="UNTERTITEL" vorhanden={t.sprachen.untertitel} gewaehlt={wahl.untertitel} aufWechsel={(lang) => sprachWechsel(t, 'untertitel', lang)} />
{wahl.audio.length === 0 && t.sprachen.audio.length > 0 && (
<span className="font-mono text-[10.5px] text-warn">kein Ton gewählt HandBrake nimmt die erste Spur</span>
)}
</div>
)}
</div>
)
})}
{alle.length === 0 && <p className="font-mono text-xs text-warn">Die Disc gab keine Titel her.</p>}
</div>
{alsSerie && (
<div className="mt-3 flex flex-wrap items-end gap-3 rounded-xl border border-white/8 bg-samt/40 p-3">
<label className="block">
<span className="font-mono text-[10px] tracking-wider text-nebel">STAFFEL</span>
<input
type="number"
min={0}
value={staffel}
onChange={(e) => setStaffel(e.target.value)}
placeholder={info !== undefined && info.staffel !== null ? String(info.staffel) : '1'}
className="mt-1 block w-20 rounded-lg border border-white/12 bg-buehne px-2 py-1 font-mono text-xs text-schnee"
/>
</label>
<label className="block">
<span className="font-mono text-[10px] tracking-wider text-nebel">AB FOLGE</span>
<input
type="number"
min={1}
value={ersteFolge}
onChange={(e) => setErsteFolge(e.target.value)}
placeholder="automatisch"
className="mt-1 block w-28 rounded-lg border border-white/12 bg-buehne px-2 py-1 font-mono text-xs text-schnee"
/>
</label>
<p className="min-w-[200px] flex-1 text-[11px] leading-4 text-nebel">
{merkerGilt && merker !== null
? `Serien-Merker: Die vorige Disc dieser Serie endete mit Folge ${merker.naechsteFolge - 1} (${merker.wann.slice(0, 10)}) — Rippy zählt ab ${merker.naechsteFolge} weiter. Stimmt das nicht, ändere die Zahl.`
: 'Leer heißt: Rippy ordnet die Folgen über die TMDb-Laufzeiten zu, und nur, wenn es eindeutig ist. Weißt du es besser („auf Disc 2 sind die Folgen 3 und 4"), trag es hier ein — dann zählt Rippy fortlaufend in Disc-Reihenfolge.'}
{info !== undefined && info.discNr !== null && ` Die Disc nennt sich selbst Disc ${info.discNr}.`}
</p>
</div>
)}
{/* Vorschau (5.4.0): Rolle, Serie, Staffel und Extras entscheiden
einiges — zwei Zeilen Zielpfad ersparen Überraschungen. */}
<div className="mt-3 rounded-xl border border-white/8 bg-buehne/70 p-3 font-mono text-[11px] leading-5">
<p className="text-[10px] tracking-wider text-nebel">LANDET IN</p>
<p className="break-all text-gold-hell">{vorschau[0]}</p>
<p className="text-dunst">{vorschau[1]}</p>
</div>
{/* Platz-Prüfung (5.5.0): 40 Minuten Lesen sollen nicht an einem
vollen Laufwerk scheitern. */}
{speicher !== null && speicher.arbeit.ok && (
<p className={`mt-2 font-mono text-[11px] ${platz.hart ? 'text-rot' : platz.text.length > 0 ? 'text-warn' : 'text-nebel'}`}>
{platz.text.length > 0
? `${platz.hart ? '✗' : '⚠'} ${platz.text}`
: `Platz: ${gbKurz(speicher.arbeit.freiBytes)} frei im Arbeitsordner — reicht für ${gb(gesamtBytes)} verlustfrei${komprimiert ? ' plus Kompression' : ''}.`}
</p>
)}
<p className="mt-3 font-mono text-[11px] text-nebel">
{aktiv.size} von {alle.length} gewählt · zusammen {gb(gesamtBytes)} verlustfrei
{regel.length > 0 ? ` · ${regel}` : ''}
</p>
<p className="mt-1 text-[11px] text-nebel">
{alsSerie
? 'Folgen landen unter Serien/… /Season NN. Ohne Vorgabe setzt Rippy die Episoden-Nummern nur, wenn die Laufzeiten eindeutig passen — sonst behalten die Dateien ihre Namen.'
: 'Der Hauptfilm landet in seinem eigenen Ordner, Extras daneben im Unterordner „extras" — so lesen Jellyfin, Emby und Kodi sie als Zugaben.'}{' '}
Das Laufwerk ist frei, sobald die Roh-Dateien liegen die Kompression läuft danach im Hintergrund.
</p>
</>
)}
<div className="mt-5 flex justify-between gap-2">
<button onClick={schliessen} className="rounded-lg border border-white/15 px-4 py-1.5 text-xs text-dunst hover:text-schnee">
Abbrechen
</button>
<button
onClick={() => {
ripStarten(
id,
gewaehlteTitel.map((t) => {
const wahl = sprachenVon(t)
return {
nr: t.nr,
rolle: rolleVon(t),
dauerS: t.dauerS,
groesseBytes: t.groesseBytes,
audioSprachen: wahl.audio,
untertitelSprachen: wahl.untertitel,
}
}),
{ staffel: alsSerie ? staffelZahl : null, ersteFolge: alsSerie ? folgeZahl : null },
)
schliessen()
}}
disabled={aktiv.size === 0 || stand === undefined || stand.laeuft || platz.hart}
className={KNOPF_GOLD}
title={platz.hart ? platz.text : ''}
>
{aktiv.size === 1 ? 'Diesen Titel rippen' : `${aktiv.size} Titel rippen`}
</button>
</div>
</DialogRahmen>
)
}
@@ -0,0 +1,262 @@
// ── Erst-Einrichtung (§ 6.9): nach der Installation, im Programm ────────
// Zwei Regeln aus dem alten Setup: NUR ein Fehler blockiert (kein
// Laufwerk ist eine WARNUNG — eine reine Komprimier-Maschine ist ein
// vorgesehener Betriebsfall), und Erfolg wird am BESTAND gemessen.
// 5.5.0: dieselben Bausteine wie die Einstellungen (Commander: „All diese
// Dinge müssen auch in den FirstRun mit rein") — Ablage und Arbeitsordner,
// Werkzeuge mit Durchsuchen, Kompression mit Sprach-Auswahl, TMDb,
// Benachrichtigung, Automatik und Roh-Regel. Dazu der Poster-Hintergrund.
import { useEffect, useState } from 'react'
import { DEFAULT_HB_PRESET, presetEmpfehlung } from '../../gemeinsam/preset-empfehlung'
import { Badge, FELD_KLASSE, Feld, KNOPF_GOLD, KNOPF_LEISE, PfadFeld, Schalter, gbKurz } from '../bausteine'
import { SprachAuswahl } from '../einstellungen/SprachAuswahl'
import { benachrichtigungTesten, einstellungSetzen, posterLaden, speicherLaden, type Verbindung } from '../kernverbindung'
import { PosterHintergrund } from './PosterHintergrund'
const SCHRITTE = ['Willkommen', 'Prüfung', 'Ordner', 'Qualität', 'Titel', 'Meldungen', 'Automatik', 'Fertig']
export function ErstEinrichtung({ verbindung }: { verbindung: Verbindung }) {
const [schritt, setSchritt] = useState(0)
const werte = verbindung.einstellungen
const werkzeuge = verbindung.werkzeuge
const speicher = verbindung.speicher
const setzen = einstellungSetzen
const makemkvFehlt = werkzeuge !== null && werkzeuge.makemkvcon.length === 0
const tmdbDa = (werte['tmdbApiKey'] ?? '').length > 0
// Poster, sobald ein TMDb-Schlüssel da ist — auch, wenn er gerade erst
// eingetragen wurde.
useEffect(() => {
if (tmdbDa) posterLaden()
}, [tmdbDa])
useEffect(() => {
speicherLaden()
}, [schritt])
const pruefzeilen: Array<{ ok: boolean | null; pflicht: boolean; text: string }> = [
{
ok: werkzeuge === null ? null : werkzeuge.makemkvcon.length > 0,
pflicht: true,
text:
werkzeuge === null
? 'MakeMKV wird gesucht …'
: werkzeuge.makemkvcon.length > 0
? `MakeMKV gefunden: ${werkzeuge.makemkvcon}`
: 'MakeMKV FEHLT — ohne makemkvcon kann Rippy keine Video-Disc lesen. Von makemkv.com/download installieren, dann findet Rippy es von selbst — oder unten den Pfad wählen.',
},
{
ok: werkzeuge === null ? null : werkzeuge.handbrake.length > 0,
pflicht: false,
text: werkzeuge === null ? 'HandBrakeCLI wird gesucht …' : werkzeuge.handbrake.length > 0 ? `HandBrakeCLI dabei: ${werkzeuge.handbrake}` : 'HandBrakeCLI fehlt — Rippen geht, Komprimieren nicht.',
},
{
ok: werkzeuge === null ? null : werkzeuge.flac.length > 0,
pflicht: false,
text: werkzeuge === null ? 'FLAC wird gesucht …' : werkzeuge.flac.length > 0 ? `FLAC dabei: ${werkzeuge.flac}` : 'FLAC fehlt — nur für Audio-CDs nötig.',
},
{
ok: verbindung.laufwerke.length > 0 ? true : null,
pflicht: false,
text:
verbindung.laufwerke.length > 0
? `${verbindung.laufwerke.length} optisches Laufwerk gefunden (${verbindung.laufwerke.map((l) => l.id + ':').join(', ')})`
: 'Kein optisches Laufwerk — nur eine Warnung: Eine reine Komprimier-Maschine ist ein vorgesehener Betriebsfall.',
},
]
// Was Rippy an DIESER Maschine gemessen hat — die Grundlage der
// Preset-Empfehlung (Commander-Fund 01.09.2026: sie war unsichtbar).
const backends = werkzeuge?.backends ?? []
const presets = werkzeuge?.presets ?? []
const nurCpu = backends.length > 0 && backends.every((b) => b.startsWith('cpu'))
const EMPFEHLUNGEN: ReadonlyArray<readonly [string, string]> = [
['DVD', 'dvd'],
['Blu-ray', 'bluray'],
['4K-UHD', 'uhd'],
]
return (
<>
<PosterHintergrund pfade={verbindung.poster} />
<div className="relative mx-auto max-w-2xl space-y-7 rounded-2xl border border-white/10 bg-buehne/80 p-6 pt-5 shadow-2xl shadow-black/60 backdrop-blur">
<div className="flex flex-wrap gap-2">
{SCHRITTE.map((name, i) => (
<span
key={name}
className={`rounded-full px-3 py-1 text-xs ${i === schritt ? 'border border-gold/40 bg-gold/15 text-gold-hell' : i < schritt ? 'text-gruen' : 'text-nebel'}`}
>
{i + 1} {name}
</span>
))}
</div>
{schritt === 0 && (
<div className="space-y-3 text-sm text-dunst">
<h2 className="font-display text-3xl font-bold text-schnee">Willkommen bei Rippy</h2>
<p>Rippy sichert deine DVDs, Blu-rays und Audio-CDs erst verlustfrei, dann komprimiert.</p>
<p>Disc rein genügt: Rippy erkennt, was drauf ist, und startet bei sicherer Erkennung von selbst.</p>
<p>Fertige Filme landen so abgelegt, dass Jellyfin, Emby, Kodi und Plex sie sofort erkennen.</p>
<p>Rippy prüft gleich seine Werkzeuge und fragt dich danach ein paar Dinge: wohin, wie schnell, woher die Titel kommen, wie es sich meldet. Dauert zwei Minuten ändern kannst du alles später.</p>
</div>
)}
{schritt === 1 && (
<div className="space-y-3">
<div className="space-y-2">
{pruefzeilen.map((zeile, i) => (
<p key={i} className={`font-mono text-xs ${zeile.ok === true ? 'text-gruen' : zeile.ok === null ? 'text-nebel' : zeile.pflicht ? 'text-rot' : 'text-warn'}`}>
{zeile.ok === true ? '✓' : zeile.ok === null ? '…' : zeile.pflicht ? '✗' : '⚠'} {zeile.text}
</p>
))}
</div>
{makemkvFehlt && (
<PfadFeld
name="makemkvcon.exe von Hand wählen"
hinweis="falls MakeMKV an einem ungewöhnlichen Ort liegt"
wert={werte['werkzeug.makemkv'] ?? ''}
art="programm"
dialogTitel="makemkvcon.exe wählen"
aufAendern={(w) => setzen('werkzeug.makemkv', w)}
/>
)}
</div>
)}
{schritt === 2 && (
<div className="space-y-4">
<p className="text-sm text-dunst">Wohin sollen fertige Filme, Musik und Abbilder? Ein Netzlaufwerk geht Rippy baut darin selbst die Struktur.</p>
<PfadFeld name="Ablage-Ordner" wert={werte['ablage'] ?? ''} art="ordner" dialogTitel="Ablage-Ordner wählen" aufAendern={(w) => setzen('ablage', w)} />
{speicher !== null && speicher.ablage.ok && <p className="font-mono text-[10.5px] text-nebel">{gbKurz(speicher.ablage.freiBytes)} frei von {gbKurz(speicher.ablage.gesamtBytes)}</p>}
<p className="text-sm text-dunst">
Liegt die Ablage auf einem Server, wähle einen <span className="text-schnee">Arbeitsordner auf einer lokalen Platte</span>: Dort liegen die Roh-Dateien
(30 GB und mehr je Blu-ray) und dort rechnet HandBrake. Nur das fertige Ergebnis wandert übers Netz.
</p>
<PfadFeld
name="Arbeitsordner (lokal, optional)"
hinweis="leer = unter der Ablage"
wert={werte['arbeitsordner'] ?? ''}
art="ordner"
dialogTitel="Arbeitsordner wählen (lokale Platte)"
aufAendern={(w) => setzen('arbeitsordner', w)}
/>
{speicher !== null && speicher.getrennt && speicher.arbeit.ok && (
<p className="font-mono text-[10.5px] text-nebel">{gbKurz(speicher.arbeit.freiBytes)} frei von {gbKurz(speicher.arbeit.gesamtBytes)} eine Blu-ray braucht rund 50 GB.</p>
)}
</div>
)}
{schritt === 3 && (
<div className="space-y-4">
<p className="text-sm text-dunst">Rippy sichert eine Disc immer erst verlustfrei. Danach kann es eine kleinere Fassung berechnen die kostet Zeit, spart aber viel Platz.</p>
<Schalter an={werte['transcodeEnabled'] !== 'false'} aufWechsel={(an) => setzen('transcodeEnabled', an ? 'true' : 'false')} label="Nach dem Rip automatisch komprimieren" />
<div className="rounded-xl border border-white/8 bg-samt/50 p-3">
{werkzeuge === null ? (
<p className="font-mono text-xs text-nebel">Encoder werden gemessen </p>
) : backends.length === 0 ? (
<p className="font-mono text-xs text-warn">Keine Encoder gemessen ohne HandBrakeCLI bleibt es beim verlustfreien Rip.</p>
) : (
<>
<div className="flex flex-wrap items-center gap-1.5">
<span className="font-mono text-[10px] tracking-wider text-nebel">AUF DIESER MASCHINE GEMESSEN:</span>
{backends.map((backend) => (
<Badge key={backend} text={backend} ton={backend.startsWith('cpu') ? 'grau' : 'blau'} />
))}
</div>
<p className="mt-2 text-xs text-dunst">Danach wählt Rippy von selbst Grafikkarte vor CPU, wo es eine gibt:</p>
<div className="mt-2 grid gap-2 sm:grid-cols-3">
{EMPFEHLUNGEN.map(([name, typ]) => (
<div key={typ} className="rounded-lg border border-white/8 bg-buehne/60 p-2">
<p className="font-mono text-[10px] tracking-wider text-nebel">{name}</p>
<p className="mt-0.5 font-mono text-xs text-gold-hell">{presetEmpfehlung(typ, backends, presets) || DEFAULT_HB_PRESET}</p>
</div>
))}
</div>
{nurCpu && <p className="mt-2 text-xs text-warn">Nur CPU-Encoder gefunden das Komprimieren dauert dann deutlich länger. Der verlustfreie Rip ist davon nicht betroffen.</p>}
</>
)}
</div>
<div className="grid gap-3 sm:grid-cols-2">
<SprachAuswahl name="Tonspuren" hinweis="die erste ist deine Sprache" wert={werte['audioSprachen'] ?? ''} aufAendern={(w) => setzen('audioSprachen', w)} />
<SprachAuswahl name="Untertitel" hinweis="vorgewählt bei fremdem Ton" wert={werte['untertitelSprachen'] ?? ''} aufAendern={(w) => setzen('untertitelSprachen', w)} />
</div>
<p className="text-xs text-nebel">Je Disc-Typ ein eigenes Preset geht später unter Einstellungen Kompression" — deine Wahl schlägt die Empfehlung immer.</p>
</div>
)}
{schritt === 4 && (
<div className="space-y-3">
<p className="text-sm text-dunst">Woher sollen Titel, Jahr, Poster und Beschreibung kommen? Dafür braucht Rippy einen kostenlosen TMDb-Schlüssel.</p>
<Feld name="TMDb-API-Key" hinweis="kostenlos auf themoviedb.org Einstellungen API; in zwei Minuten geholt" wert={werte['tmdbApiKey'] ?? ''} aufAendern={(w) => setzen('tmdbApiKey', w)} />
{tmdbDa && verbindung.poster.length > 0 && <p className="font-mono text-xs text-gruen">✓ Der Schlüssel funktioniert — die Poster im Hintergrund kommen von TMDb.</p>}
<p className="text-xs text-nebel">Ohne Schlüssel rippt Rippy trotzdem — die Filme heißen dann so, wie die Disc sich selbst nennt, und es gibt keine Poster. Nachtragen geht jederzeit unter „Einstellungen → Metadaten".</p>
</div>
)}
{schritt === 5 && (
<div className="space-y-3">
<p className="text-sm text-dunst">Soll Rippy sich aufs Handy melden, wenn ein Rip fertig ist oder etwas schiefgeht? Windows-Meldungen kommen immer das hier ist freiwillig.</p>
<Feld name="Discord-Webhook" hinweis="Servereinstellungen → Integrationen → Webhooks" geheim wert={werte['discordWebhook'] ?? ''} aufAendern={(w) => setzen('discordWebhook', w)} />
<div className="grid gap-3 sm:grid-cols-2">
<Feld name="Telegram-Bot-Token" hinweis="von @BotFather" geheim wert={werte['telegramToken'] ?? ''} aufAendern={(w) => setzen('telegramToken', w)} />
<Feld name="Telegram-Chat-ID" wert={werte['telegramChatId'] ?? ''} aufAendern={(w) => setzen('telegramChatId', w)} />
</div>
<button onClick={() => benachrichtigungTesten()} className={KNOPF_LEISE}>
Testnachricht schicken
</button>
</div>
)}
{schritt === 6 && (
<div className="space-y-4">
<p className="text-sm text-dunst">Wie selbstständig soll Rippy sein?</p>
<Schalter an={werte['automatik'] !== 'aus'} aufWechsel={(an) => setzen('automatik', an ? 'an' : 'aus')} label="Automatik: bei sicherer Erkennung (ab 90 %) nach einem Countdown von selbst rippen — mit Stopp-Knopf" />
<label className="block max-w-xs">
<span className="text-xs font-semibold text-dunst">Countdown</span>
<select value={werte['automatikSekunden'] ?? '30'} onChange={(e) => setzen('automatikSekunden', e.target.value)} className={FELD_KLASSE}>
<option value="10">10 Sekunden</option>
<option value="30">30 Sekunden (Vorgabe)</option>
<option value="60">1 Minute</option>
<option value="120">2 Minuten</option>
</select>
</label>
<Schalter an={werte['autoAuswurf'] !== 'false'} aufWechsel={(an) => setzen('autoAuswurf', an ? 'true' : 'false')} label="Disc nach dem Rip auswerfen — die nächste kann rein, während die Kompression läuft" />
<label className="block max-w-sm">
<span className="text-xs font-semibold text-dunst">Roh-Dateien nach gelungener Kompression</span>
<select value={werte['rohAufbewahrung'] ?? '7'} onChange={(e) => setzen('rohAufbewahrung', e.target.value)} className={FELD_KLASSE}>
<option value="sofort">sofort löschen</option>
<option value="7">nach 7 Tagen löschen (Vorgabe)</option>
<option value="14">nach 14 Tagen löschen</option>
<option value="30">nach 30 Tagen löschen</option>
<option value="behalten">behalten nur von Hand löschen</option>
</select>
</label>
<p className="text-xs text-nebel">Die Roh-Dateien sind der Grund, warum ein Preset-Wechsel kein Neulesen kostet und gelöscht wird nur, wenn das fertige Ergebnis nachweislich da ist.</p>
</div>
)}
{schritt === 7 && (
<div className="space-y-3 text-sm text-dunst">
<p>Das war's. Disc einlegen — Rippy meldet sich.</p>
<p className="text-xs text-nebel">Rippy lebt im Infobereich neben der Uhr: Fenster zu heißt weiterlaufen, beendet wird über das Tray-Menü.</p>
{makemkvFehlt && <p className="text-xs text-rot">Denk an MakeMKV: Ohne makemkvcon bleibt der Video-Weg zu (Einstellungen → Werkzeuge zeigt den Stand, sobald es installiert ist).</p>}
</div>
)}
<div className="flex justify-between">
<button onClick={() => setSchritt((s) => Math.max(0, s - 1))} disabled={schritt === 0} className="rounded-lg border border-white/15 px-4 py-1.5 text-xs text-dunst hover:text-schnee disabled:opacity-35">
Zurück
</button>
{schritt < SCHRITTE.length - 1 ? (
<button onClick={() => setSchritt((s) => s + 1)} className={KNOPF_GOLD}>
Weiter
</button>
) : (
<button onClick={() => setzen('setup.done', 'true')} className={KNOPF_GOLD}>
Los geht's
</button>
)}
</div>
</div>
</>
)
}
@@ -0,0 +1,51 @@
// Der Poster-Hintergrund der Erst-Einrichtung (5.5.0, Commander: „diverse
// Movie Cover die … im Hintergrund in nem Grid langsam von oben nach unten
// laufen"). Vor dem TMDb-Schlüssel ein ruhiges Raster leerer Film-Karten,
// danach echte Poster beliebter Filme (kern: erkennung.posterLaden). Die
// Spalten laufen langsam, jede in eigenem Tempo, die Liste ist verdoppelt,
// damit der Lauf nahtlos ist (Keyframes in stil.css: poster-lauf).
import { IconDisc, posterUrlGross } from '../bausteine'
const SPALTEN = 7
const JE_SPALTE = 6
export function PosterHintergrund({ pfade }: { pfade: string[] }) {
const spalten: string[][] = Array.from({ length: SPALTEN }, () => [])
pfade.forEach((p, i) => spalten[i % SPALTEN].push(p))
return (
<div className="pointer-events-none fixed inset-0 overflow-hidden" aria-hidden="true">
<div className="absolute inset-0 flex gap-4 px-4 opacity-[0.5]">
{spalten.map((spalte, i) => {
// Auffüllen mit leeren Karten, damit jede Spalte voll ist.
const karten: Array<string | null> = [...spalte]
while (karten.length < JE_SPALTE) karten.push(null)
const doppelt = [...karten, ...karten]
return (
<div
key={i}
className="poster-spalte flex flex-1 flex-col gap-4"
style={{
animationDuration: `${70 + (i % 3) * 25}s`,
animationDelay: `${-i * 9}s`,
marginTop: `${(i % 2) * -120}px`,
}}
>
{doppelt.map((p, j) =>
p !== null ? (
<img key={j} src={posterUrlGross(p)} alt="" className="aspect-[2/3] w-full rounded-xl object-cover ring-1 ring-gold/25" loading="lazy" />
) : (
<div key={j} className="flex aspect-[2/3] w-full items-center justify-center rounded-xl bg-gradient-to-br from-[oklch(0.27_0.03_75)] to-[oklch(0.19_0.02_75)] ring-1 ring-gold/30">
<IconDisc groesse={28} farbe="oklch(0.6 0.08 75)" />
</div>
),
)}
</div>
)
})}
</div>
{/* Der Verlauf ins Bühnen-Schwarz — die Poster bleiben Kulisse, der
Assistent bleibt lesbar. */}
<div className="absolute inset-0 bg-gradient-to-b from-buehne/35 via-buehne/55 to-buehne/90" />
</div>
)
}
@@ -0,0 +1,83 @@
// Die Einstellungen — Sektionswahl links (oder oben im schmalen Fenster),
// die Karten rechts. Jede Karte ist eine Datei in Karten.tsx (5.5.0).
import { useEffect, useState } from 'react'
import type { HauptStatus } from '../../gemeinsam/nachrichten'
import type { Verbindung } from '../kernverbindung'
import {
AblageKarte,
AllgemeinKarte,
AutomatikKarte,
BenachrichtigungKarte,
KompressionKarte,
MakemkvKarte,
MedienserverKarte,
MetadatenKarte,
RohDateienKarte,
UpdateKarte,
WerkzeugeKarte,
} from './Karten'
const SEKTIONEN: Array<[string, string]> = [
['ablage', 'Ablage'],
['kompression', 'Kompression'],
['roh', 'Roh-Dateien'],
['automatik', 'Automatik'],
['metadaten', 'Metadaten'],
['makemkv', 'MakeMKV'],
['medienserver', 'Medienserver'],
['benachrichtigung', 'Benachrichtigung'],
['allgemein', 'Allgemein'],
['update', 'Update'],
['werkzeuge', 'Werkzeuge'],
]
export function Einstellungen({ verbindung, haupt, start }: { verbindung: Verbindung; haupt: HauptStatus | null; start?: string }) {
const [aktiv, setAktiv] = useState(start ?? 'ablage')
const springe = (id: string): void => {
setAktiv(id)
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
// „alle unter Roh-Dateien →" aus der Übersicht springt direkt zur Karte.
useEffect(() => {
if (start !== undefined) setTimeout(() => springe(start), 50)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [start])
// Schmales Fenster: die Sektionswahl legt sich als Zeile über die Karten.
// Ab lg steht sie wieder als mitlaufende Spalte daneben (Commander-Fund
// 01.09.2026: „alles gequetscht").
return (
<div className="flex flex-col gap-5 lg:flex-row lg:gap-8">
<nav className="flex flex-row flex-wrap gap-1 lg:sticky lg:top-4 lg:h-fit lg:w-44 lg:flex-none lg:flex-col">
{SEKTIONEN.map(([id, name]) => (
<button
key={id}
onClick={() => springe(id)}
className={`rounded-lg px-3 py-1.5 text-left text-[13px] ${
aktiv === id ? 'border border-gold/40 bg-gold/12 font-semibold text-gold-hell' : 'border border-transparent text-dunst hover:bg-white/5 hover:text-schnee'
}`}
>
{name}
</button>
))}
</nav>
{/* Kein max-w mehr: Der 672-px-Deckel war der Grund, warum in einem
breiten Fenster alles links klebte und rechts nichts stand. */}
<div className="min-w-0 flex-1 space-y-5">
<AblageKarte verbindung={verbindung} />
<KompressionKarte verbindung={verbindung} />
<RohDateienKarte verbindung={verbindung} aktivFuer={start === 'roh'} />
<AutomatikKarte verbindung={verbindung} />
<MetadatenKarte verbindung={verbindung} />
<MakemkvKarte verbindung={verbindung} />
<MedienserverKarte verbindung={verbindung} />
<BenachrichtigungKarte verbindung={verbindung} />
<AllgemeinKarte verbindung={verbindung} haupt={haupt} />
<UpdateKarte verbindung={verbindung} haupt={haupt} />
<WerkzeugeKarte verbindung={verbindung} />
</div>
</div>
)
}
@@ -0,0 +1,656 @@
// Die Einstellungs-Karten (5.5.0, je Karte eine Funktion): Ablage,
// Kompression, Roh-Dateien, Automatik, Metadaten, MakeMKV, Medienserver,
// Benachrichtigung, Allgemein, Update, Werkzeuge. Alle schreiben über
// einstellungSetzen in die EINE Quelle (§ 6.7).
import { useState } from 'react'
import type { GeraeteInfo, HauptStatus, VorgangEintrag } from '../../gemeinsam/nachrichten'
import { presetEmpfehlung } from '../../gemeinsam/preset-empfehlung'
import {
Badge,
FELD_KLASSE,
Feld,
HinterlegtPunkt,
KNOPF_LEISE,
LAUFENDE_PHASEN,
PfadFeld,
Schalter,
SektionsKarte,
TYP_NAMEN,
datumKurz,
gb,
gbKurz,
} from '../bausteine'
import {
benachrichtigungTesten,
einstellungSetzen,
fremdenOrdnerLoeschen,
kompressionAbbrechen,
makemkvUpdate,
rohAufraeumen,
schluesselPruefen,
vorgaengeLaden,
vorgangNeuKomprimieren,
vorgangRohLoeschen,
type Verbindung,
} from '../kernverbindung'
import { phasenTon } from '../uebersicht/VerlaufKarte'
import { SprachAuswahl } from './SprachAuswahl'
const setzen = einstellungSetzen
export function AblageKarte({ verbindung }: { verbindung: Verbindung }) {
const werte = verbindung.einstellungen
const speicher = verbindung.speicher
return (
<SektionsKarte
id="ablage"
titel="ABLAGE"
intro="Die Ablage ist, wo Fertiges landet — auch ein Netzlaufwerk. Der Arbeitsordner ist, wo Rippy rechnet: Roh-Dateien und Kompression. Liegt die Ablage auf dem Server, gehört der Arbeitsordner auf eine lokale Platte."
>
<PfadFeld name="Ablage-Ordner" wert={werte['ablage'] ?? ''} art="ordner" dialogTitel="Ablage-Ordner wählen" aufAendern={(w) => setzen('ablage', w)} />
{speicher !== null && speicher.ablage.ok && (
<p className="font-mono text-[10.5px] text-nebel">
{gbKurz(speicher.ablage.freiBytes)} frei von {gbKurz(speicher.ablage.gesamtBytes)}
</p>
)}
<PfadFeld
name="Arbeitsordner (lokal, optional)"
hinweis="leer = unter der Ablage; Roh-Dateien und Kompression liegen dann dort"
wert={werte['arbeitsordner'] ?? ''}
art="ordner"
dialogTitel="Arbeitsordner wählen (lokale Platte)"
aufAendern={(w) => setzen('arbeitsordner', w)}
/>
{speicher !== null && speicher.getrennt && speicher.arbeit.ok && (
<p className="font-mono text-[10.5px] text-nebel">
{gbKurz(speicher.arbeit.freiBytes)} frei von {gbKurz(speicher.arbeit.gesamtBytes)} eine Blu-ray braucht rund 50 GB.
</p>
)}
<div className="rounded-xl border border-white/8 bg-buehne/60 p-3 font-mono text-[11px] leading-5 text-nebel">
<p className="text-dunst">So legt Rippy ab:</p>
<p>
Filme\Akira (1988)\ <span className="text-nebel/70"> Film, Poster und Infodatei beieinander</span>
</p>
<p>
Serien\Spartacus\Season 01\ <span className="text-nebel/70"> Folgen, wie Jellyfin sie erwartet</span>
</p>
<p>
Musik\ <span className="text-nebel/70"> Audio-CDs als FLAC, benannt nach MusicBrainz</span>
</p>
<p>
ISO\ <span className="text-nebel/70"> unerkannte Discs als 1:1-Abbild</span>
</p>
<p>
roh\ <span className="text-nebel/70"> im Arbeitsordner: verlustfreies Roh-Material, aufgeräumt nach der Regel unter Roh-Dateien"</span>
</p>
</div>
<Schalter
an={werte['autoAuswurf'] !== 'false'}
aufWechsel={(an) => setzen('autoAuswurf', an ? 'true' : 'false')}
label="Disc nach dem Rip auswerfen die nächste kann rein, während die Kompression läuft"
/>
</SektionsKarte>
)
}
function PresetWahl({
name,
schluessel,
wert,
presets,
empfehlung,
}: {
name: string
schluessel: string
wert: string
presets: string[]
empfehlung: string
}) {
return (
<label className="block">
<span className="text-xs font-semibold text-dunst">{name}</span>
<select value={wert} onChange={(e) => setzen(schluessel, e.target.value)} className={FELD_KLASSE}>
<option value="">{empfehlung.length > 0 ? `automatisch: ${empfehlung}` : 'automatisch'}</option>
<option value="keine">NICHT komprimieren (verlustfrei lassen)</option>
{presets.map((preset) => (
<option key={preset} value={preset}>
{preset}
</option>
))}
</select>
</label>
)
}
export function KompressionKarte({ verbindung }: { verbindung: Verbindung }) {
const werte = verbindung.einstellungen
const presets = verbindung.werkzeuge?.presets ?? []
const backends = verbindung.werkzeuge?.backends ?? []
const empfehlungFuer = (typ: string): string => presetEmpfehlung(typ, backends, presets)
return (
<SektionsKarte
id="kompression"
titel="KOMPRESSION"
intro="Nach dem verlustfreien Rip rechnet HandBrake die Filme auf Arbeitsgröße der Rohling bleibt bis zum Erfolg liegen, es geht nie etwas verloren."
>
{verbindung.werkzeuge !== null && verbindung.werkzeuge.handbrake.length === 0 && (
<p className="font-mono text-xs text-warn">⚠ HandBrakeCLI fehlt — Rippen geht, Komprimieren nicht (siehe Werkzeuge).</p>
)}
<Schalter an={werte['transcodeEnabled'] !== 'false'} aufWechsel={(an) => setzen('transcodeEnabled', an ? 'true' : 'false')} label="Nach dem Rip automatisch komprimieren" />
{backends.length > 0 && (
<div className="flex flex-wrap items-center gap-1.5">
<span className="font-mono text-[10px] tracking-wider text-nebel">GEMESSENE ENCODER:</span>
{backends.map((backend) => (
<Badge key={backend} text={backend} ton={backend.startsWith('cpu') ? 'grau' : 'blau'} />
))}
</div>
)}
<div className="grid gap-3 sm:grid-cols-3">
<PresetWahl name="DVD" schluessel="transcodePresetDvd" wert={werte['transcodePresetDvd'] ?? ''} presets={presets} empfehlung={empfehlungFuer('dvd')} />
<PresetWahl name="Blu-ray" schluessel="transcodePresetBluray" wert={werte['transcodePresetBluray'] ?? ''} presets={presets} empfehlung={empfehlungFuer('bluray')} />
<PresetWahl name="4K-UHD" schluessel="transcodePresetUhd" wert={werte['transcodePresetUhd'] ?? ''} presets={presets} empfehlung={empfehlungFuer('uhd')} />
</div>
<p className="text-[11px] leading-4 text-nebel">
„automatisch" nimmt die Empfehlung aus der gemessenen Liste deines HandBrake auf dieser Maschine die Grafikkarte, wo es sie gibt. Eine eigene Wahl
schlägt die Empfehlung immer.
</p>
<div className="grid gap-3 sm:grid-cols-2">
<SprachAuswahl
name="Tonspuren"
hinweis="Reihenfolge = Wunschreihenfolge; die erste ist deine Sprache"
wert={werte['audioSprachen'] ?? ''}
aufAendern={(w) => setzen('audioSprachen', w)}
/>
<SprachAuswahl name="Untertitel" hinweis="werden vorgewählt, wenn der Ton nicht deine Sprache ist" wert={werte['untertitelSprachen'] ?? ''} aufAendern={(w) => setzen('untertitelSprachen', w)} />
</div>
</SektionsKarte>
)
}
export function AutomatikKarte({ verbindung }: { verbindung: Verbindung }) {
const werte = verbindung.einstellungen
const an = werte['automatik'] !== 'aus'
return (
<SektionsKarte
id="automatik"
titel="AUTOMATIK"
intro="Disc rein genügt: Ist Rippy sicher, was das ist, liest es die Titel und startet nach einem Countdown von selbst — mit seinem Vorschlag. Wo es unsicher ist, wartet es auf dich."
>
<Schalter an={an} aufWechsel={(a) => setzen('automatik', a ? 'an' : 'aus')} label="Automatik: bei sicherer Erkennung (ab 90 %) von selbst rippen" />
<label className="block max-w-xs">
<span className="text-xs font-semibold text-dunst">Countdown</span>
<select value={werte['automatikSekunden'] ?? '30'} onChange={(e) => setzen('automatikSekunden', e.target.value)} disabled={!an} className={FELD_KLASSE}>
<option value="10">10 Sekunden</option>
<option value="30">30 Sekunden (Vorgabe)</option>
<option value="60">1 Minute</option>
<option value="120">2 Minuten</option>
</select>
</label>
<p className="text-[11px] leading-4 text-nebel">
Der Countdown steht in der Laufwerks-Kachel, mit Stopp" und „Jetzt". Eine Disc, die schon einmal gerippt wurde, startet nie von selbst.
</p>
</SektionsKarte>
)
}
export function RohDateienKarte({ verbindung, aktivFuer }: { verbindung: Verbindung; aktivFuer?: boolean }) {
const werte = verbindung.einstellungen
const v = verbindung.vorgaenge
const [presetWahl, setPresetWahl] = useState<Record<string, string>>({})
const [alleZeigen, setAlleZeigen] = useState(aktivFuer === true)
const presets = verbindung.werkzeuge?.presets ?? []
const regelWert = werte['rohAufbewahrung'] ?? '7'
const phasenText: Record<VorgangEintrag['phase'], string> = {
rippt: 'rippt',
wartet: 'wartet auf Kompression',
komprimiert: 'komprimiert',
'legt-ab': 'legt ab',
fertig: 'fertig',
fehler: 'Fehler',
abgebrochen: 'abgebrochen',
}
const liste = v?.vorgaenge ?? []
const sichtbar = alleZeigen ? liste : liste.slice(0, 12)
return (
<SektionsKarte
id="roh"
titel="ROH-DATEIEN"
intro="Jede Blu-ray lässt 30 GB und mehr verlustfreies Roh-Material liegen — genau dafür, dass ein Preset-Wechsel kein Neulesen kostet. Aufgeräumt wird nach Regel, und nur, wenn das fertige Ergebnis nachweislich da ist."
>
<div className="grid gap-3 sm:grid-cols-[1fr_auto] sm:items-end">
<label className="block">
<span className="text-xs font-semibold text-dunst">Nach gelungener Kompression</span>
<select value={regelWert} onChange={(e) => setzen('rohAufbewahrung', e.target.value)} className={FELD_KLASSE}>
<option value="sofort">sofort löschen</option>
<option value="7">nach 7 Tagen löschen (Vorgabe)</option>
<option value="14">nach 14 Tagen löschen</option>
<option value="30">nach 30 Tagen löschen</option>
<option value="behalten">behalten nur von Hand löschen</option>
</select>
</label>
<div className="flex gap-2">
<button onClick={() => rohAufraeumen()} className={`${KNOPF_LEISE} py-2`} title="Die Regel jetzt anwenden, statt auf den täglichen Takt zu warten">
Jetzt aufräumen
</button>
<button onClick={() => vorgaengeLaden()} className={`${KNOPF_LEISE} py-2`} title="Belegung neu messen">
Aktualisieren
</button>
</div>
</div>
<div className="rounded-xl border border-white/8 bg-buehne/60 p-3 font-mono text-[11px] leading-5 text-nebel">
{v === null ? (
<p>Belegung wird gemessen </p>
) : (
<>
<p>
<span className="text-dunst">Belegung jetzt:</span> <span className="text-gold-hell">{gb(v.rohGesamtBytes)}</span> in{' '}
{liste.filter((e) => !e.rohGeloescht).length + v.fremde.length} Roh-Ordnern
</p>
<p>{v.regel}</p>
<p className="text-nebel/70">Unkomprimierte Rips (z. B. 4K verlustfrei) liegen im Zielordner, nicht hier sie sind nie betroffen.</p>
</>
)}
</div>
{liste.length === 0 && v !== null && <p className="font-mono text-xs text-nebel">Noch kein Vorgang der erste Rip legt hier einen Eintrag an.</p>}
<div className="space-y-2">
{sichtbar.map((e) => {
const ruhig = e.phase === 'fertig' || e.phase === 'fehler' || e.phase === 'abgebrochen'
return (
<div key={e.id} className="rounded-xl border border-white/8 bg-buehne/60 p-3">
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<span className="font-display text-[14px] font-bold text-schnee">
{e.titel}
{e.jahr !== null && <span className="ml-1 font-medium text-dunst">({e.jahr})</span>}
</span>
<Badge text={phasenText[e.phase]} ton={phasenTon(e.phase)} />
<span className="font-mono text-[10.5px] text-nebel">
{(TYP_NAMEN[e.typ as GeraeteInfo['typ']] ?? e.typ).toUpperCase()} · {datumKurz(e.begonnen)}
{e.titelZahl > 0 && ` · ${e.titelZahl} Titel`}
{e.preset.length > 0 && ` · ${e.preset}`}
</span>
</div>
<p className="mt-1 font-mono text-[11px] leading-5 text-nebel">
<span className="text-dunst">Roh:</span> {e.rohGeloescht ? 'gelöscht' : gb(e.rohBytes)}
{e.faelligAm.length > 0 && ` · fällig ${datumKurz(e.faelligAm)}`}
{' · '}
<span className="text-dunst">Fertig:</span>{' '}
{e.phase === 'fertig' ? (
<span className="break-all">
{e.fertigBytes > 0 ? `${gb(e.fertigBytes)} · ` : ''}
{e.zielOrdner}
{!e.komprimiert && ' (verlustfrei, ohne Kompression)'}
</span>
) : (
'—'
)}
</p>
{e.fehler.length > 0 && <p className="mt-1 break-all font-mono text-[11px] text-warn"> {e.fehler}</p>}
<div className="mt-2 flex flex-wrap items-center gap-2">
{e.neuKomprimierbar && (
<>
<select
value={presetWahl[e.id] ?? ''}
onChange={(ev) => setPresetWahl({ ...presetWahl, [e.id]: ev.target.value })}
className="rounded-lg border border-white/15 bg-samt/70 px-2 py-1 text-[11px] text-dunst"
title="Preset für die Wiederholung — leer: wie eingestellt"
>
<option value="">Preset wie eingestellt</option>
{presets.map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
<button
onClick={() => vorgangNeuKomprimieren(e.id, presetWahl[e.id] ?? '')}
className="rounded-lg border border-gold/40 bg-gold/12 px-3 py-1 text-[11px] font-semibold text-gold-hell hover:bg-gold/20"
title="Die Roh-Dateien liegen genau dafür da — kein Neulesen der Disc"
>
Neu komprimieren
</button>
</>
)}
{!e.rohGeloescht && ruhig && (
<button onClick={() => vorgangRohLoeschen(e.id)} className="rounded-lg border border-rot/40 bg-rot/10 px-3 py-1 text-[11px] text-rot hover:bg-rot/20" title="Den Roh-Ordner dieses Vorgangs jetzt löschen">
Roh-Dateien löschen
</button>
)}
{e.phase === 'fertig' && e.zielOrdner.length > 0 && (
<button onClick={() => void window.rippy.ordnerOeffnen(e.zielOrdner)} className="rounded-lg border border-white/15 px-3 py-1 text-[11px] text-dunst hover:text-schnee">
Ordner öffnen
</button>
)}
{(e.phase === 'wartet' || e.phase === 'komprimiert' || e.phase === 'legt-ab') && (
<button onClick={() => kompressionAbbrechen(e.id)} className="rounded-lg border border-white/15 px-3 py-1 text-[11px] text-dunst hover:text-rot">
Kompression abbrechen
</button>
)}
</div>
</div>
)
})}
</div>
{liste.length > sichtbar.length && (
<button onClick={() => setAlleZeigen(true)} className="font-mono text-[10.5px] text-nebel hover:text-dunst">
alle {liste.length} Vorgänge zeigen
</button>
)}
{v !== null && v.fremde.length > 0 && (
<div className="rounded-xl border border-warn/30 bg-warn/5 p-3">
<p className="font-mono text-[10.5px] tracking-wider text-warn">ORDNER OHNE VORGANG</p>
<p className="mt-1 text-[11px] leading-4 text-nebel">Aus der Zeit vor 5.4.0 oder von Hand hineingelegt Rippy kennt sie nicht und löscht sie deshalb NIE von selbst.</p>
<div className="mt-2 space-y-1">
{v.fremde.map((f) => (
<div key={f.ordner} className="flex items-center justify-between gap-3">
<span className="min-w-0 break-all font-mono text-[11px] text-dunst">
{f.ordner} · {gb(f.bytes)}
</span>
<button onClick={() => fremdenOrdnerLoeschen(f.ordner)} className="flex-none rounded-lg border border-rot/40 bg-rot/10 px-2.5 py-0.5 text-[10.5px] text-rot hover:bg-rot/20">
Löschen
</button>
</div>
))}
</div>
</div>
)}
</SektionsKarte>
)
}
export function MetadatenKarte({ verbindung }: { verbindung: Verbindung }) {
const werte = verbindung.einstellungen
return (
<SektionsKarte
id="metadaten"
titel="METADATEN"
intro="TMDb erkennt Titel, Jahr, Poster, Logo und Beschreibung; OMDb hilft bei Ausreißern nach. Beide Schlüssel sind kostenlos und in zwei Minuten geholt."
>
<div className="grid gap-3 sm:grid-cols-2">
<div>
<Feld name="TMDb-API-Key" geheim wert={werte['tmdbApiKey'] ?? ''} aufAendern={(w) => setzen('tmdbApiKey', w)} />
<p className="mt-1 font-mono text-[10px] text-nebel">
www.themoviedb.org Einstellungen API
<HinterlegtPunkt wert={werte['tmdbApiKey'] ?? ''} />
</p>
</div>
<div>
<Feld name="OMDb-API-Key" geheim wert={werte['omdbApiKey'] ?? ''} aufAendern={(w) => setzen('omdbApiKey', w)} />
<p className="mt-1 font-mono text-[10px] text-nebel">
www.omdbapi.com/apikey.aspx
<HinterlegtPunkt wert={werte['omdbApiKey'] ?? ''} />
</p>
</div>
</div>
<p className="text-[11px] leading-4 text-nebel">
Ohne Schlüssel rippt Rippy trotzdem die Filme heißen dann wie das Disc-Label, und Ändern " an der Laufwerks-Kachel bleibt ohne Suche.
</p>
</SektionsKarte>
)
}
export function MakemkvKarte({ verbindung }: { verbindung: Verbindung }) {
const werte = verbindung.einstellungen
const s = verbindung.schluessel
const b = verbindung.beschaffung
return (
<SektionsKarte
id="makemkv"
titel="MAKEMKV"
intro="MakeMKV liest die Discs. Rippy holt den frei veröffentlichten Beta-Key automatisch aus dem Hersteller-Forum, hinterlegt ihn und fragt MakeMKV selbst, ob er angenommen wird täglich."
>
<div className="rounded-xl border border-white/8 bg-buehne/60 p-3">
{s === null ? (
<p className="font-mono text-xs text-nebel">Schlüssel wird geprüft …</p>
) : (
<>
<p className="flex items-center gap-2 font-mono text-[10.5px] tracking-wider">
<span className={`inline-block h-2 w-2 rounded-full ${s.angenommen === true ? 'bg-gruen' : s.weiterBetrieb ? 'bg-warn' : 'bg-rot'}`} />
<span className={s.angenommen === true ? 'text-gruen' : s.weiterBetrieb ? 'text-warn' : 'text-rot'}>
{s.angenommen === true ? 'SCHLÜSSEL ANGENOMMEN' : s.weiterBetrieb ? 'BETA-MODUS — RIPS LAUFEN' : 'AUSFALL'}
</span>
{s.version.length > 0 && <span className="ml-auto text-nebel">MakeMKV {s.version}</span>}
</p>
<p className="mt-1.5 text-xs leading-5 text-dunst">{s.text}</p>
<p className="mt-1 font-mono text-[10px] text-nebel">
zuletzt geprüft: {s.geholtAm.slice(0, 16).replace('T', ' ')}
{s.gueltigBis.length > 0 && ` · Key gültig bis ${s.gueltigBis}`}
</p>
</>
)}
</div>
<div className="flex flex-wrap gap-2">
{s !== null && s.angenommen === false && (
<button
onClick={() => makemkvUpdate()}
disabled={b !== null && b.laeuft}
className="rounded-lg bg-gradient-to-b from-warn to-[oklch(0.72_0.12_78)] px-3 py-1.5 text-xs font-bold text-buehne shadow-[inset_0_1px_0_oklch(1_0_0/0.35)] hover:brightness-105 disabled:opacity-40"
title="Holt den offiziellen Installer über die Quellenkette, prüft ihn und startet ihn Windows fragt einmal nach Adminrechten."
>
MakeMKV aktualisieren …
</button>
)}
<button onClick={() => schluesselPruefen()} disabled={b !== null && b.laeuft} className={KNOPF_LEISE} title="Forum-Key holen, hinterlegen und MakeMKV selbst fragen, ob er angenommen wird">
Schlüssel neu prüfen
</button>
</div>
{b !== null && (
<div className="rounded-lg border border-white/10 bg-buehne/70 p-2.5">
<p className={`font-mono text-[11px] leading-5 ${b.fehler ? 'text-rot' : b.laeuft ? 'text-gold-hell' : 'text-gruen'}`}>{b.text}</p>
{b.laeuft && b.anteil !== null && (
<div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-white/8">
<div className="h-full rounded-full bg-gradient-to-r from-gold-tief to-gold-hell transition-all" style={{ width: `${Math.round(b.anteil * 100)}%` }} />
</div>
)}
</div>
)}
<Feld
name="Eigener Schlüssel (Dauerlizenz)"
hinweis="schlägt den automatischen Beta-Key leer lassen, wenn keiner gekauft ist"
geheim
wert={werte['makemkvAppKey'] ?? ''}
aufAendern={(w) => setzen('makemkvAppKey', w)}
/>
</SektionsKarte>
)
}
export function MedienserverKarte({ verbindung }: { verbindung: Verbindung }) {
const werte = verbindung.einstellungen
return (
<SektionsKarte
id="medienserver"
titel="MEDIENSERVER"
intro="Nach jedem fertigen Rip sagt Rippy deinem Server Bescheid, damit der Film sofort auftaucht ohne Warten auf den nächsten Bibliotheks-Scan."
>
<div className="grid gap-3 sm:grid-cols-3">
<label className="block">
<span className="text-xs font-semibold text-dunst">System</span>
<select value={werte['mediaServer'] ?? 'none'} onChange={(e) => setzen('mediaServer', e.target.value)} className={FELD_KLASSE}>
<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>
<p className="text-[11px] leading-4 text-nebel">Bei Plex benennt Rippy nur nach dessen Regeln angestoßen wird dort nichts. Die Ordnerstruktur aus Ablage" passt für alle vier.</p>
</SektionsKarte>
)
}
export function BenachrichtigungKarte({ verbindung }: { verbindung: Verbindung }) {
const werte = verbindung.einstellungen
return (
<SektionsKarte
id="benachrichtigung"
titel="BENACHRICHTIGUNG"
intro="Rippy meldet sich aufs Handy, wenn ein Rip oder eine Kompression fertig ist oder etwas schiefgeht über einen Discord-Webhook oder einen Telegram-Bot. Windows-Meldungen kommen immer."
>
<Schalter an={werte['benachrichtigungen'] !== 'aus'} aufWechsel={(an) => setzen('benachrichtigungen', an ? 'an' : 'aus')} label="Benachrichtigungen verschicken" />
<Feld
name="Discord-Webhook"
hinweis="Discord Servereinstellungen Integrationen Webhooks URL kopieren"
geheim
wert={werte['discordWebhook'] ?? ''}
aufAendern={(w) => setzen('discordWebhook', w)}
/>
<div className="grid gap-3 sm:grid-cols-2">
<Feld name="Telegram-Bot-Token" hinweis="von @BotFather" geheim wert={werte['telegramToken'] ?? ''} aufAendern={(w) => setzen('telegramToken', w)} />
<Feld name="Telegram-Chat-ID" hinweis="deine ID, z. B. von @userinfobot" wert={werte['telegramChatId'] ?? ''} aufAendern={(w) => setzen('telegramChatId', w)} />
</div>
<button onClick={() => benachrichtigungTesten()} className={KNOPF_LEISE}>
Testnachricht schicken
</button>
</SektionsKarte>
)
}
export function AllgemeinKarte({ verbindung, haupt }: { verbindung: Verbindung; haupt: HauptStatus | null }) {
const werte = verbindung.einstellungen
const kern = verbindung.kern
const db = kern?.datenbank ?? null
const dbText = db === null ? '—' : db.ok ? db.pfad : `FEHLER — ${db.fehler}`
return (
<SektionsKarte id="allgemein" titel="ALLGEMEIN" intro="Rippy lebt im Infobereich neben der Uhr: Fenster zu heißt weiterlaufen, beendet wird über das Tray-Menü.">
<Schalter an={haupt?.autostart === true} aufWechsel={(an) => void window.rippy.autostartSetzen(an)} label="Rippy mit Windows starten (Autostart im Benutzerkonto kein Dienst, keine Adminrechte)" />
<div className="flex flex-wrap gap-2">
<button onClick={() => void window.rippy.protokollOeffnen()} className={KNOPF_LEISE} title="Eine Datei je Tag, 14 Tage aufgehoben Haupt und Kern schreiben hinein">
Protokoll öffnen
</button>
<button onClick={() => void window.rippy.ordnerOeffnen(werte['ablage'] ?? '')} className={KNOPF_LEISE}>
Ablage im Explorer
</button>
{(werte['arbeitsordner'] ?? '').length > 0 && (
<button onClick={() => void window.rippy.ordnerOeffnen(werte['arbeitsordner'] ?? '')} className={KNOPF_LEISE}>
Arbeitsordner im Explorer
</button>
)}
</div>
<div className="rounded-xl border border-white/8 bg-buehne/60 p-3 font-mono text-[11px] leading-5 text-nebel">
<p>
Rippy v{haupt?.version ?? '…'}
{kern !== null && ` · Kern: Node ${kern.nodeVersion} (PID ${kern.pid})`}
{haupt !== null && ` · Leine ${haupt.leine.ok ? 'gesetzt' : 'NICHT gesetzt'}`}
{verbindung.pongLatenzMs !== null && ` · Antwortzeit ${verbindung.pongLatenzMs} ms`}
</p>
<p className="break-all">Datenbank: {dbText}</p>
<p className="break-all">Protokoll: {haupt?.protokollOrdner ?? '—'}</p>
</div>
</SektionsKarte>
)
}
export function UpdateKarte({ verbindung, haupt }: { verbindung: Verbindung; haupt: HauptStatus | null }) {
// § 6.8: Der Prüf-Knopf ist während eines Rips aus — nicht, weil er
// nicht könnte, sondern weil Rippy dann bewusst nicht sucht.
const ripLaeuftGerade = Object.values(verbindung.rips).some((r) => LAUFENDE_PHASEN.includes(r.phase))
return (
<SektionsKarte id="update" titel="UPDATE" intro="Rippy holt neue Fassungen selbst aus dem Gitea geprüft, geladen, beim nächsten Beenden installiert. Nie mitten in einem Rip oder einer Kompression.">
{/* Das Feld „Update-Adresse" ist am 01.09.2026 auf Zuruf des
Commanders verschwunden: Updates kommen immer vom Gitea, ein
Eingabefeld dafür war nur Ballast. Der Einstellungs-Schlüssel
`updateUrl` gilt weiterhin wer ihn wirklich braucht, setzt
ihn in der Datenbank, und § 6.8 prüft ihn wie zuvor (nur
https://). */}
{haupt !== null && (
<div className="rounded-xl border border-white/8 bg-buehne/60 p-3 font-mono text-[11px] leading-5 text-nebel">
<p>
<span className="text-dunst">Stand:</span> {haupt.update.text}
</p>
{haupt.update.letzteErfolgreichePruefung.length > 0 && <p>letzte erfolgreiche Prüfung: {haupt.update.letzteErfolgreichePruefung.slice(0, 16).replace('T', ' ')}</p>}
{haupt.update.neueVersion.length > 0 && <p className="text-gold-hell">Version {haupt.update.neueVersion} gefunden die Karte in der Übersicht zeigt, was neu ist.</p>}
</div>
)}
<div className="flex flex-wrap items-center gap-2">
<button
onClick={() => void window.rippy.updatePruefen()}
disabled={ripLaeuftGerade || haupt?.update.laeuft === true}
className={KNOPF_LEISE}
title={ripLaeuftGerade ? 'Während eines Rips wird nicht gesucht — das ist Absicht (§ 6.8).' : 'Fragt sofort beim Update-Server nach.'}
>
{haupt?.update.laeuft === true ? 'sucht …' : 'Jetzt auf Updates prüfen'}
</button>
</div>
</SektionsKarte>
)
}
function WerkzeugZeile({
name,
pfad,
fehlt,
pflicht,
schluessel,
wert,
}: {
name: string
pfad: string | null
fehlt: string
pflicht: boolean
schluessel: string
wert: string
}) {
return (
<div className="rounded-xl border border-white/8 bg-samt/50 p-3">
<p className={`break-all font-mono text-xs ${pfad === null ? 'text-nebel' : pfad.length > 0 ? 'text-gruen' : pflicht ? 'text-rot' : 'text-warn'}`}>
{name}: {pfad === null ? 'wird gesucht …' : pfad.length > 0 ? pfad : fehlt}
</p>
<PfadFeld
name="Eigener Pfad"
hinweis="leer = automatische Suche; ein eingetragener Pfad gilt exakt"
wert={wert}
art="programm"
dialogTitel={`${name} wählen`}
aufAendern={(w) => setzen(schluessel, w)}
/>
</div>
)
}
export function WerkzeugeKarte({ verbindung }: { verbindung: Verbindung }) {
const werte = verbindung.einstellungen
const backends = verbindung.werkzeuge?.backends ?? []
return (
<SektionsKarte
id="werkzeuge"
titel="WERKZEUGE"
intro="Rippy findet seine Werkzeuge von allein: mitgeliefert, selbst geholt, installiert oder im PATH — in dieser Reihenfolge. Wer einen eigenen Pfad einträgt, bekommt genau den."
>
<WerkzeugZeile
name="makemkvcon"
pfad={verbindung.werkzeuge === null ? null : verbindung.werkzeuge.makemkvcon}
fehlt="NICHT GEFUNDEN — MakeMKV von makemkv.com installieren, Rippy findet es danach von allein"
pflicht={true}
schluessel="werkzeug.makemkv"
wert={werte['werkzeug.makemkv'] ?? ''}
/>
<WerkzeugZeile
name="HandBrakeCLI"
pfad={verbindung.werkzeuge === null ? null : verbindung.werkzeuge.handbrake}
fehlt="nicht gefunden — Rippen geht, Kompression nicht"
pflicht={false}
schluessel="werkzeug.handbrake"
wert={werte['werkzeug.handbrake'] ?? ''}
/>
<WerkzeugZeile
name="flac"
pfad={verbindung.werkzeuge === null ? null : verbindung.werkzeuge.flac}
fehlt="nicht gefunden — nur für Audio-CDs nötig"
pflicht={false}
schluessel="werkzeug.flac"
wert={werte['werkzeug.flac'] ?? ''}
/>
{verbindung.werkzeuge !== null && (
<p className="font-mono text-[10.5px] text-nebel">
HandBrake gemessen: {verbindung.werkzeuge.presets.length} Presets · Encoder: {backends.length > 0 ? backends.join(', ') : '—'}
</p>
)}
</SektionsKarte>
)
}
@@ -0,0 +1,68 @@
// Die Sprach-Auswahl (5.5.0, Commander: „bei Tonspuren und Untertiteln
// können Dropdowns hin") — gewählte Sprachen als Chips in Wunschreihenfolge,
// dazu ein Dropdown zum Hinzufügen. Die erste Tonsprache ist die
// Wunschsprache (auftrag.ts sprachwahl). Leer heißt „alle".
import { SPRACHEN, sprachName, sprachlisteAus, sprachlisteText } from '../../gemeinsam/sprachen'
export function SprachAuswahl({
name,
hinweis,
wert,
aufAendern,
}: {
name: string
hinweis: string
wert: string
aufAendern: (wert: string) => void
}) {
const gewaehlt = sprachlisteAus(wert)
const frei = SPRACHEN.filter((s) => !gewaehlt.includes(s.code))
const setzen = (codes: string[]): void => aufAendern(sprachlisteText(codes))
const verschieben = (code: string, richtung: -1 | 1): void => {
const i = gewaehlt.indexOf(code)
const j = i + richtung
if (i < 0 || j < 0 || j >= gewaehlt.length) return
const neu = [...gewaehlt]
;[neu[i], neu[j]] = [neu[j], neu[i]]
setzen(neu)
}
return (
<div>
<span className="text-xs font-semibold text-dunst">{name}</span>
<span className="ml-2 text-xs text-nebel">{hinweis}</span>
<div className="mt-1 flex min-h-[38px] flex-wrap items-center gap-1.5 rounded-lg border border-white/12 bg-samt px-2 py-1.5">
{gewaehlt.length === 0 && <span className="font-mono text-[11px] text-nebel">alle Sprachen der Disc</span>}
{gewaehlt.map((code, i) => (
<span key={code} className="inline-flex items-center gap-1 rounded-md border border-gold/40 bg-gold/12 px-2 py-0.5 text-[11px] text-gold-hell">
<span className="font-mono text-[10px] text-gold/70">{i + 1}.</span>
<span>{sprachName(code)}</span>
<span className="font-mono text-[10px] text-nebel">{code}</span>
<button type="button" onClick={() => verschieben(code, -1)} disabled={i === 0} className="ml-1 text-nebel hover:text-schnee disabled:opacity-30" title="nach vorn">
</button>
<button type="button" onClick={() => verschieben(code, 1)} disabled={i === gewaehlt.length - 1} className="text-nebel hover:text-schnee disabled:opacity-30" title="nach hinten">
</button>
<button type="button" onClick={() => setzen(gewaehlt.filter((c) => c !== code))} className="ml-0.5 text-nebel hover:text-rot" title="entfernen">
×
</button>
</span>
))}
<select
value=""
onChange={(e) => {
if (e.target.value.length > 0) setzen([...gewaehlt, e.target.value])
}}
className="ml-auto rounded-md border border-white/12 bg-buehne px-2 py-1 font-mono text-[11px] text-dunst"
>
<option value="">+ Sprache </option>
{frei.map((s) => (
<option key={s.code} value={s.code}>
{s.name} ({s.code})
</option>
))}
</select>
</div>
</div>
)
}
+3
View File
@@ -8,6 +8,9 @@ declare global {
aufStatus(horcher: (status: unknown) => void): void
melden(art: string): void
ordnerWaehlen(titel: string): Promise<string | null>
dateiWaehlen(titel: string): Promise<string | null>
ordnerOeffnen(pfad: string): Promise<string>
protokollOeffnen(): Promise<string>
autostartSetzen(an: boolean): Promise<boolean>
updatePruefen(): Promise<unknown>
updateInstallieren(): Promise<{ ok: boolean; grund: string }>
+58 -10
View File
@@ -8,8 +8,8 @@
// Laufwerksliste: Sie ändert sich NUR durch eine neue Liste vom Kern.
import {
istKernNachricht,
type AutomatikStand,
type BeschaffungStand,
type BibliothekEintrag,
type DiscInfoStand,
type FensterNachricht,
type GeraeteInfo,
@@ -19,6 +19,7 @@ import {
type RipAuftragOptionen,
type RipAuftragTitel,
type RipStatus,
type SpeicherStand,
type TitelStand,
type VorgaengeStand,
type WerkzeugAuskunft,
@@ -46,7 +47,6 @@ export interface Verbindung {
ripProtokoll: Record<string, string[]>
einstellungen: Record<string, string>
werkzeuge: WerkzeugAuskunft | null
bibliothek: BibliothekEintrag[]
schluessel: {
quelle: string
angenommen: boolean | null
@@ -63,6 +63,12 @@ export interface Verbindung {
vorgaenge: VorgaengeStand | null
/** Was das Windows-Protokoll je Laufwerk sagt (5.4.0) — bleibt stehen. */
gesundheit: Record<string, LaufwerkBericht>
/** Freier Platz an Arbeitsordner und Ablage (5.5.0). */
speicher: SpeicherStand | null
/** Automatik-Countdown je Laufwerk (5.5.0). */
automatik: Record<string, AutomatikStand>
/** Poster für den Hintergrund der Erst-Einrichtung (5.5.0). */
poster: string[]
/** Kurze Ereigniszeilen (neueste zuerst): Disc rein/raus, Auswurf, Fehler. */
meldungen: string[]
}
@@ -71,7 +77,7 @@ type Horcher = (stand: Verbindung) => void
const MAX_MELDUNGEN = 30
const stand: Verbindung = {
export const LEERE_VERBINDUNG: Verbindung = {
verbunden: false,
kern: null,
pongLatenzMs: null,
@@ -84,13 +90,17 @@ const stand: Verbindung = {
ripProtokoll: {},
einstellungen: {},
werkzeuge: null,
bibliothek: [],
schluessel: null,
beschaffung: null,
vorgaenge: null,
gesundheit: {},
speicher: null,
automatik: {},
poster: [],
meldungen: [],
}
const stand: Verbindung = { ...LEERE_VERBINDUNG }
const horcher = new Set<Horcher>()
let gestartet = false
let port: MessagePort | null = null
@@ -103,8 +113,9 @@ function kopie(): Verbindung {
discInfos: { ...stand.discInfos },
vorschlaege: { ...stand.vorschlaege },
einstellungen: { ...stand.einstellungen },
bibliothek: [...stand.bibliothek],
gesundheit: { ...stand.gesundheit },
automatik: { ...stand.automatik },
poster: [...stand.poster],
meldungen: [...stand.meldungen],
}
}
@@ -140,6 +151,8 @@ function kernNachricht(nachricht: KernNachricht): void {
meldung(`Disc erkannt in ${ereignis.geraet.name}${ereignis.geraet.typ}`)
} else if (ereignis.art === 'disc-entnommen') {
meldung(`Disc entnommen aus ${ereignis.geraet.name}`)
const { [ereignis.geraet.id]: _weg, ...rest } = stand.automatik
stand.automatik = rest
} else if (ereignis.art === 'laufwerk-weg') {
meldung(`Laufwerk ${ereignis.id}: nicht mehr da`)
}
@@ -184,9 +197,6 @@ function kernNachricht(nachricht: KernNachricht): void {
case 'werkzeuge':
stand.werkzeuge = nachricht.auskunft
break
case 'bibliothek':
stand.bibliothek = nachricht.eintraege
break
case 'schluessel-status':
stand.schluessel = nachricht.stand
if (nachricht.stand.angenommen === false) meldung(`⚠ MakeMKV-Schlüssel: ${nachricht.stand.text}`)
@@ -207,9 +217,18 @@ function kernNachricht(nachricht: KernNachricht): void {
meldung(`Laufwerk ${nachricht.id}: ${nachricht.bericht.text}`)
}
break
case 'speicher':
stand.speicher = nachricht.stand
break
case 'automatik':
stand.automatik = { ...stand.automatik, [nachricht.stand.id]: nachricht.stand }
break
case 'poster-vorschau':
stand.poster = nachricht.pfade
break
case 'rip-status': {
stand.rips = { ...stand.rips, [nachricht.status.id]: nachricht.status }
const laeuft = ['liest-info', 'rippt', 'komprimiert', 'legt-ab'].includes(nachricht.status.phase)
const laeuft = ['liest-info', 'rettet', 'rippt', 'komprimiert', 'legt-ab'].includes(nachricht.status.phase)
if (laeuft && stand.ripStart[nachricht.status.id] === undefined) {
stand.ripStart = { ...stand.ripStart, [nachricht.status.id]: Date.now() }
// Ein NEUER Vorgang beginnt — das Protokoll des alten weicht.
@@ -257,8 +276,8 @@ function portAnnehmen(neuerPort: MessagePort): void {
senden({ art: 'ping', zeit: Date.now() })
senden({ art: 'einstellungen-laden' })
senden({ art: 'werkzeuge-laden' })
senden({ art: 'bibliothek-laden' })
senden({ art: 'vorgaenge-laden' })
senden({ art: 'speicher-laden' })
melden()
}
@@ -324,6 +343,10 @@ export function ripAbbrechen(id: string): void {
anKern({ art: 'rip-abbruch', id }, `Rip-Abbruch ${id}`)
}
export function rettungStarten(id: string): void {
anKern({ art: 'rettung-start', id }, `Rettungs-Abbild ${id}`)
}
export function metadatenSuchen(id: string, text: string): void {
senden({ art: 'metadaten-suchen', id, text })
}
@@ -372,3 +395,28 @@ export function fremdenOrdnerLoeschen(ordner: string): void {
export function laufwerkPruefen(id: string): void {
anKern({ art: 'laufwerk-pruefen', id }, `Laufwerk ${id} prüfen`)
}
// ── 5.5.0 ───────────────────────────────────────────────────────────────
export function kompressionPause(an: boolean): void {
anKern({ art: 'kompression-pause', an }, an ? 'Kompression anhalten' : 'Kompression weiter')
}
export function automatikStopp(id: string): void {
senden({ art: 'automatik-stopp', id })
}
export function automatikJetzt(id: string): void {
anKern({ art: 'automatik-jetzt', id }, 'Automatik jetzt')
}
export function benachrichtigungTesten(): void {
anKern({ art: 'benachrichtigung-testen' }, 'Testnachricht')
}
export function posterLaden(): void {
senden({ art: 'poster-laden' })
}
export function speicherLaden(): void {
senden({ art: 'speicher-laden' })
}
+14
View File
@@ -76,3 +76,17 @@
::-webkit-scrollbar-thumb { background: oklch(1 0 0 / 0.12); border-radius: 999px; border: 2px solid transparent; background-clip: content-box; }
::-webkit-scrollbar-thumb:hover { background-color: oklch(1 0 0 / 0.2); }
::-webkit-scrollbar-track { background: transparent; }
/* Der Poster-Lauf der Erst-Einrichtung (5.5.0): Jede Spalte wandert langsam
nach oben; die Liste ist verdoppelt, bei -50 % beginnt sie nahtlos von
vorn. Dauer und Versatz setzt die Komponente je Spalte. */
@keyframes poster-lauf {
from { transform: translateY(0); }
to { transform: translateY(-50%); }
}
.poster-spalte {
animation-name: poster-lauf;
animation-timing-function: linear;
animation-iteration-count: infinite;
will-change: transform;
}
@@ -0,0 +1,78 @@
// Die Kompressions-Warteschlange in der Übersicht (5.4.0): Rip und
// Kompression sind entkoppelt — hier steht, was gerade rechnet und was
// noch wartet, mit Abbruch je Auftrag. 5.5.0: Pause — der laufende Film
// wird fertig, der nächste wartet.
import type { VorgaengeStand } from '../../gemeinsam/nachrichten'
import { SegmentBalken } from '../bausteine'
import { kompressionAbbrechen, kompressionPause } from '../kernverbindung'
export function KompressionsKarte({ stand }: { stand: VorgaengeStand['kompression'] }) {
if (stand.laeuft === null && stand.wartend.length === 0 && !stand.pausiert) return null
const l = stand.laeuft
return (
<div className="rounded-2xl border border-white/10 bg-karte/50 p-5">
<div className="flex flex-wrap items-center gap-2.5 font-mono text-[10.5px] tracking-[0.12em] text-nebel">
<span className={`inline-block h-2 w-2 rounded-full ${l !== null ? 'animate-pulse bg-gold' : stand.pausiert ? 'bg-warn' : 'bg-white/25'}`} />
<span className="text-dunst">KOMPRESSION</span>
<span>·</span>
<span>
{l !== null ? 'läuft' : stand.pausiert ? 'angehalten' : 'wartet'}
{stand.wartend.length > 0 && ` · ${stand.wartend.length} in der Warteschlange`}
</span>
<button
onClick={() => kompressionPause(!stand.pausiert)}
className={`ml-auto rounded-lg border px-3 py-1 text-[11px] ${
stand.pausiert ? 'border-gold/40 bg-gold/12 font-semibold text-gold-hell hover:bg-gold/20' : 'border-white/15 text-dunst hover:text-schnee'
}`}
title={stand.pausiert ? 'Die Warteschlange läuft weiter' : 'Der laufende Film wird fertig gerechnet, der nächste wartet — zum Beispiel beim Spielen'}
>
{stand.pausiert ? '▶ Weiter' : '⏸ Pausieren'}
</button>
</div>
{l !== null && (
<div className="mt-3 rounded-xl border border-white/10 bg-samt/80 p-4">
<div className="flex items-end justify-between gap-5">
<div className="min-w-0 flex-1">
<p className="truncate font-display text-[17px] font-bold text-schnee">{l.titel}</p>
<div className="mt-2">
<SegmentBalken prozent={l.prozent} />
</div>
<p className="mt-2 truncate font-mono text-[11px] text-nebel" title={l.text}>
&gt; {l.text}
</p>
{stand.pausiert && <p className="mt-1 font-mono text-[10.5px] text-warn">Pause: Dieser Film wird fertig, danach wartet die Schlange.</p>}
</div>
{l.prozent >= 0 && (
<div className="flex-none font-mono text-[42px] font-bold leading-none text-gold-hell">
{l.prozent}
<span className="text-lg text-gold/70">%</span>
</div>
)}
</div>
<button
onClick={() => kompressionAbbrechen(l.vorgangId)}
className="mt-3 rounded-lg border border-rot/50 bg-rot/10 px-3 py-1 text-[11px] font-semibold text-rot hover:bg-rot/20"
title="Die Roh-Dateien bleiben liegen — unter Einstellungen → Roh-Dateien geht es später weiter"
>
Kompression abbrechen
</button>
</div>
)}
{stand.wartend.length > 0 && (
<div className="mt-3 space-y-1">
{stand.wartend.map((w, i) => (
<div key={w.vorgangId} className="flex items-center justify-between gap-3 rounded-lg border border-white/8 bg-samt/40 px-3 py-1.5">
<span className="min-w-0 truncate font-mono text-[11px] text-dunst">
Platz {i + 1} · {w.titel}
{stand.pausiert ? ' · wartet auf Weiter' : ''}
</span>
<button onClick={() => kompressionAbbrechen(w.vorgangId)} className="flex-none font-mono text-[10.5px] text-nebel hover:text-rot">
aus der Schlange nehmen
</button>
</div>
))}
</div>
)}
</div>
)
}
@@ -0,0 +1,485 @@
// Die Bühne: Backdrop als Panorama, Poster mit Goldkante, Plakat-Titel —
// und beim Arbeiten die Aussteuerungsanzeige samt Mono-Protokoll.
// 5.4.0: Kino-Banner v2 (Logo, Tagline, Meta-Zeile, Darsteller), Laufwerks-
// Gesundheit, Stand der Kompression nach dem Rip. 5.5.0: Automatik-Countdown
// und das Rettungs-Abbild — nur, wenn Rippy Lesefehler gesehen hat.
import { useState } from 'react'
import type {
AutomatikStand,
DiscInfoStand,
GeraeteInfo,
KompressionStand,
LaufwerkBericht,
RipStatus,
VorgangEintrag,
} from '../../gemeinsam/nachrichten'
import { DEFAULT_HB_PRESET, presetEmpfehlung } from '../../gemeinsam/preset-empfehlung'
import {
Badge,
IconDisc,
IconEject,
IconPlay,
IconStift,
IconStop,
InfoKachel,
LAUFENDE_PHASEN,
PHASEN_MONO,
SegmentBalken,
TYP_NAMEN,
backdropUrl,
discHerkunft,
gb,
logoUrl,
metaZeile,
posterUrlGross,
profilUrl,
restzeitText,
sicherheitsWort,
} from '../bausteine'
import {
automatikJetzt,
automatikStopp,
auswerfen,
laufwerkPruefen,
rettungStarten,
ripAbbrechen,
ripUeberspringen,
type Verbindung,
} from '../kernverbindung'
/** Welches Preset diese Disc bekäme — dieselbe Kette wie im Kern
* (eigene Wahl → Empfehlung → Default). */
export function presetAnzeige(typ: GeraeteInfo['typ'], verbindung: Verbindung): string {
if (typ === 'cd') return 'FLAC (verlustfrei)'
if (typ === 'unknown') return 'ISO-Abbild'
const werte = verbindung.einstellungen
if (werte['transcodeEnabled'] === 'false') return 'ohne Kompression'
const schluessel = typ === 'dvd' ? 'transcodePresetDvd' : typ === 'uhd' ? 'transcodePresetUhd' : 'transcodePresetBluray'
const eigene = werte[schluessel] ?? ''
const empfehlung = presetEmpfehlung(typ, verbindung.werkzeuge?.backends ?? [], verbindung.werkzeuge?.presets ?? [])
const gewaehlt = eigene.length > 0 ? eigene : empfehlung.length > 0 ? empfehlung : DEFAULT_HB_PRESET
return gewaehlt === 'keine' ? 'ohne Kompression' : gewaehlt
}
export function LaufwerkKachel({
geraet,
rip,
info,
ripStartMs,
protokoll,
verbindung,
bericht,
vorgang,
kompression,
wartePlatz,
automatik,
aufRippen,
aufAendern,
}: {
geraet: GeraeteInfo
rip: RipStatus | undefined
info: DiscInfoStand | undefined
ripStartMs: number | undefined
protokoll: string[]
verbindung: Verbindung
/** Was das Windows-Protokoll zu diesem Laufwerk sagt (5.4.0). */
bericht: LaufwerkBericht | undefined
/** Der Vorgang des letzten Rips — für den Stand der Kompression (5.4.0). */
vorgang: VorgangEintrag | undefined
kompression: KompressionStand | null
wartePlatz: number
/** Der Automatik-Countdown (5.5.0). */
automatik: AutomatikStand | undefined
/** Die Dialoge leben im App-Zustand (5.4.0): Ein leerer Takt der Wache
* räumte sonst Kachel UND Dialog ab. */
aufRippen: () => void
aufAendern: () => void
}) {
const [protokollOffen, setProtokollOffen] = useState(false)
const [berichtOffen, setBerichtOffen] = useState(false)
const ripLaeuft = rip !== undefined && LAUFENDE_PHASEN.includes(rip.phase)
const discDa = geraet.status === 'ready'
const backdrop = discDa && info !== undefined ? backdropUrl(info.backdropPfad) : ''
const poster = discDa && info !== undefined ? posterUrlGross(info.posterPfad) : ''
const erkannt = discDa && info !== undefined && info.titel.length > 0
const logo = erkannt ? logoUrl(info.logoPfad) : ''
const rettungMoeglich = discDa && !ripLaeuft && rip !== undefined && rip.rettungMoeglich === true
const berichtTon =
bericht === undefined
? ''
: bericht.urteil === 'ruhig'
? 'text-gruen border-gruen/40 bg-gruen/10'
: bericht.urteil === 'disc'
? 'text-warn border-warn/45 bg-warn/10'
: bericht.urteil === 'hardware'
? 'text-rot border-rot/45 bg-rot/10'
: 'text-dunst border-white/12 bg-samt/60'
return (
<div className="relative overflow-hidden rounded-2xl border border-white/10 bg-karte/50">
{backdrop.length > 0 && (
<>
<img src={backdrop} alt="" className="absolute inset-0 h-full w-full object-cover" />
<div className="absolute inset-0 bg-gradient-to-r from-buehne via-buehne/85 to-buehne/45" />
<div className="absolute inset-0 bg-gradient-to-t from-buehne via-transparent to-transparent" />
</>
)}
<div className="relative p-5">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2.5 font-mono text-[10.5px] tracking-[0.12em] text-nebel">
<span
className={`inline-block h-2 w-2 rounded-full ${
ripLaeuft
? 'animate-pulse bg-rec shadow-[0_0_9px_oklch(0.72_0.17_45/0.9)]'
: discDa
? 'bg-gruen'
: geraet.status === 'empty'
? 'bg-white/25'
: 'bg-rot'
}`}
/>
<span className="text-dunst">LAUFWERK {geraet.id}:</span>
<span>·</span>
<span className="truncate">{geraet.modell.length > 0 ? geraet.modell : 'MODELL UNBEKANNT'}</span>
</div>
<div className="flex flex-none gap-2">
{ripLaeuft ? (
<button
onClick={() => ripAbbrechen(geraet.id)}
className="inline-flex items-center gap-2 rounded-xl border border-rot/60 bg-rot/10 px-4 py-1.5 text-xs font-semibold text-rot hover:bg-rot/20"
>
<IconStop />
Abbrechen
</button>
) : (
<button
onClick={aufRippen}
disabled={!discDa}
className="inline-flex items-center gap-2 rounded-xl bg-gradient-to-b from-gold-hell to-gold-tief px-5 py-1.5 text-xs font-bold text-buehne shadow-[inset_0_1px_0_oklch(1_0_0/0.4),0_8px_24px_oklch(0.75_0.13_60/0.3)] hover:from-gold-hell hover:to-gold disabled:cursor-not-allowed disabled:opacity-35 disabled:shadow-none"
title="Titel der Disc lesen und auswaehlen — danach verlustfrei rippen"
>
<IconPlay />
Rippen
</button>
)}
<button
onClick={() => auswerfen(geraet.id)}
disabled={!discDa || ripLaeuft}
className="inline-flex items-center gap-2 rounded-xl border border-white/15 bg-gradient-to-b from-karte/80 to-samt/80 px-4 py-1.5 text-xs text-dunst shadow-[inset_0_1px_0_oklch(1_0_0/0.07)] hover:text-schnee disabled:cursor-not-allowed disabled:opacity-35"
title="Entriegeln, auswerfen, nachsehen — in dieser Reihenfolge"
>
<IconEject />
Auswerfen
</button>
</div>
</div>
{!discDa && (
<div className="mt-5 flex items-center gap-5 py-5">
<IconDisc groesse={52} farbe={geraet.status === 'empty' ? 'oklch(0.4 0.02 75)' : 'oklch(0.68 0.19 25)'} />
<p className={`max-w-2xl font-mono text-xs leading-5 ${geraet.status === 'empty' ? 'text-nebel' : 'text-rot'}`}>
{geraet.status === 'empty' ? 'Keine Disc eingelegt — Disc rein genügt, Rippy meldet sich.' : geraet.grund}
</p>
</div>
)}
{discDa && (
<div className="mt-4 flex gap-5">
{poster.length > 0 ? (
<img src={poster} alt="" className="h-44 w-[118px] flex-none rounded-xl object-cover shadow-2xl shadow-black/60 ring-1 ring-gold/35" />
) : (
<div className="flex h-44 w-[118px] flex-none items-center justify-center rounded-xl bg-gradient-to-br from-karte to-samt ring-1 ring-white/10">
<IconDisc groesse={40} farbe="oklch(0.5 0.05 75)" />
</div>
)}
<div className="min-w-0 flex-1">
{/* Kino-Banner v2 (5.4.0): Logo statt Text, wo TMDb eines hat —
der größte optische Sprung. Titel und Jahr bleiben lesbar. */}
{erkannt ? (
logo.length > 0 ? (
<div className="flex flex-wrap items-end gap-x-3 gap-y-1">
<img src={logo} alt={info.titel} className="max-h-16 max-w-[380px] object-contain object-left drop-shadow-[0_2px_12px_rgba(0,0,0,0.75)]" />
<span className="pb-0.5 text-sm text-dunst">
{info.titel}
{info.jahr !== null && ` (${info.jahr})`}
</span>
</div>
) : (
<h3 className="truncate font-display text-[26px] font-bold leading-tight text-schnee drop-shadow">
{info.titel}
{info.jahr !== null && <span className="ml-2 font-medium text-dunst">({info.jahr})</span>}
</h3>
)
) : (
<h3 className="font-display text-[26px] font-bold leading-tight text-schnee">{TYP_NAMEN[geraet.typ]}</h3>
)}
{erkannt && info.tagline.length > 0 && <p className="mt-1 font-display text-[15px] italic text-gold-hell/90">{info.tagline}</p>}
{erkannt && metaZeile(info).length > 0 && <p className="mt-1 font-mono text-[11px] tracking-wide text-dunst">{metaZeile(info)}</p>}
{erkannt && info.beschreibung.length > 0 && <p className="mt-1.5 line-clamp-2 max-w-2xl text-xs leading-5 text-dunst">{info.beschreibung}</p>}
{!erkannt && (
<p className="mt-1.5 font-mono text-xs text-nebel">
{geraet.typ === 'cd' ? 'Titel kommen beim Rippen von MusicBrainz.' : 'Noch keine Zuordnung — „Ändern …" hilft nach.'}
</p>
)}
{/* Zwei Kacheln nebeneinander im schmalen Fenster, vier im
breiten — vorher immer vier, also im schmalen gequetscht. */}
<div className="mt-3 grid max-w-3xl grid-cols-2 gap-2.5 sm:grid-cols-4">
<InfoKachel label="MEDIUM" wert={TYP_NAMEN[geraet.typ]} />
<InfoKachel label="GRÖSSE" wert={geraet.groesseBytes > 0 ? gb(geraet.groesseBytes) : '—'} />
<InfoKachel
label="ERKENNUNG"
wert={erkannt ? `${Math.round(info.confidence * 100)} %${info.quelle.length > 0 ? ` · ${info.quelle}` : ''}` : '—'}
farbe={erkannt ? (info.confidence >= 0.8 ? 'text-gruen' : 'text-warn') : undefined}
/>
<InfoKachel label="PRESET" wert={presetAnzeige(geraet.typ, verbindung)} />
</div>
{/* Darsteller-Streifen (5.4.0) */}
{erkannt && info.besetzung.length > 0 && (
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-2">
{info.besetzung.map((d) => (
<div key={`${d.name}-${d.rolle}`} className="flex items-center gap-2" title={d.rolle}>
{d.bildPfad.length > 0 ? (
<img src={profilUrl(d.bildPfad)} alt="" className="h-9 w-9 rounded-full object-cover ring-1 ring-gold/30" />
) : (
<span className="flex h-9 w-9 items-center justify-center rounded-full bg-samt font-mono text-[11px] text-nebel ring-1 ring-white/10">
{d.name
.split(' ')
.map((w) => w[0] ?? '')
.join('')
.slice(0, 2)
.toUpperCase()}
</span>
)}
<span className="leading-tight">
<span className="block text-[11px] text-schnee">{d.name}</span>
{d.rolle.length > 0 && <span className="block text-[10px] text-nebel">{d.rolle}</span>}
</span>
</div>
))}
</div>
)}
<div className="mt-3 flex flex-wrap items-center gap-2">
<button
onClick={aufAendern}
disabled={ripLaeuft}
className="inline-flex items-center gap-2 rounded-lg border border-white/15 bg-samt/60 px-3 py-1 text-xs text-dunst hover:text-schnee disabled:opacity-35"
title="Falsch erkannt? Selbst suchen und wählen — die Ablage folgt deiner Wahl."
>
<IconStift />
Ändern
</button>
<button
onClick={() => {
setBerichtOffen(true)
laufwerkPruefen(geraet.id)
}}
className="inline-flex items-center gap-2 rounded-lg border border-white/15 bg-samt/60 px-3 py-1 text-xs text-dunst hover:text-schnee"
title="Liest die cdrom-Ereignisse aus dem Windows-Protokoll: Disc oder Hardware?"
>
Laufwerk prüfen
</button>
{rettungMoeglich && (
<button
onClick={() => rettungStarten(geraet.id)}
className="inline-flex items-center gap-2 rounded-lg border border-warn/50 bg-warn/12 px-3 py-1 text-xs font-semibold text-warn hover:bg-warn/20"
title="Rippy hat an dieser Disc Lesefehler gesehen: erst ein Abbild Sektor für Sektor (Unlesbares mit Nullen), dann derselbe Rip aus dem Abbild — es kann nicht mehr hängen. Braucht doppelten Platz."
>
Rettungs-Abbild
</button>
)}
{erkannt && <Badge text={`${sicherheitsWort(info.confidence)}`} ton={info.confidence >= 0.8 ? 'gruen' : 'gelb'} />}
{info !== undefined && discHerkunft(info).length > 0 && <Badge text={discHerkunft(info)} ton="blau" />}
{info !== undefined && info.schonGerippt !== null && <Badge text={`schon gerippt am ${info.schonGerippt.slice(0, 10)}`} ton="gelb" />}
</div>
{/* Was Rippy NACH dem Info-Lauf dazugelernt hat (Erkennung v2). */}
{erkannt && info.hinweise.length > 0 && (
<div className="mt-2 space-y-0.5">
{info.hinweise.map((h) => (
<p
key={h}
className={`font-mono text-[11px] ${h.includes('NICHT') ? 'text-warn' : h.includes('bestätigt') || h.includes('passt') ? 'text-gruen' : 'text-nebel'}`}
>
{h.includes('NICHT') ? '⚠ ' : h.includes('bestätigt') ? '✓ ' : '· '}
{h}
</p>
))}
</div>
)}
{geraet.grund.length > 0 && <p className="mt-2 font-mono text-xs text-warn">{geraet.grund}</p>}
</div>
</div>
)}
{/* Automatik (5.5.0): Countdown mit Stopp und Jetzt — oder der
Grund, warum Rippy wartet. */}
{discDa && !ripLaeuft && automatik !== undefined && automatik.text.length > 0 && (
<div className={`mt-4 flex flex-wrap items-center gap-3 rounded-xl border p-3 ${automatik.aktiv ? 'border-gold/45 bg-gold/10' : 'border-white/10 bg-samt/60'}`}>
{automatik.aktiv && (
<span className="font-mono text-[28px] font-bold leading-none text-gold-hell">
{automatik.sekunden}
<span className="text-sm text-gold/70"> s</span>
</span>
)}
<p className={`min-w-0 flex-1 font-mono text-[11px] leading-5 ${automatik.aktiv ? 'text-gold-hell' : 'text-nebel'}`}>{automatik.text}</p>
{automatik.aktiv && (
<div className="flex gap-2">
<button onClick={() => automatikJetzt(geraet.id)} className="rounded-lg border border-gold/40 bg-gold/15 px-3 py-1 text-[11px] font-semibold text-gold-hell hover:bg-gold/25">
Jetzt
</button>
<button onClick={() => automatikStopp(geraet.id)} className="rounded-lg border border-white/15 px-3 py-1 text-[11px] text-dunst hover:text-schnee">
Stopp
</button>
</div>
)}
</div>
)}
{/* Laufwerks-Gesundheit (5.4.0, Punkt 8): Disc oder Hardware? Kommt
von selbst nach einem gescheiterten Rip — oder auf Knopfdruck. */}
{bericht !== undefined && (berichtOffen || bericht.urteil !== 'ruhig') && (
<div className={`mt-4 rounded-xl border p-3 ${berichtTon}`}>
<div className="flex items-start justify-between gap-3">
<p className="font-mono text-[11px] leading-5">
<span className="font-semibold">
{bericht.urteil === 'ruhig' ? 'LAUFWERK RUHIG' : bericht.urteil === 'disc' ? 'VERDACHT: DIE DISC' : bericht.urteil === 'hardware' ? 'VERDACHT: DIE HARDWARE' : 'PROTOKOLL'}
</span>{' '}
{bericht.text}
</p>
<button onClick={() => setBerichtOffen(false)} className="flex-none font-mono text-[10.5px] text-nebel hover:text-dunst">
ausblenden
</button>
</div>
{bericht.letzte.length > 0 && (
<div className="mt-1.5 space-y-0.5">
{bericht.letzte.map((z, i) => (
<p key={i} className="font-mono text-[10.5px] text-nebel">
{z}
</p>
))}
</div>
)}
</div>
)}
{rip !== undefined && (
<div className="mt-4 rounded-xl border border-white/10 bg-samt/80 p-4 backdrop-blur">
<div className="flex items-end justify-between gap-5">
<div className="min-w-0 flex-1">
<div className="mb-2 flex items-baseline justify-between gap-3">
<span
className={`font-mono text-[10.5px] tracking-[0.12em] ${
rip.phase === 'fehler' ? 'text-rot' : rip.phase === 'fertig' ? (rip.lesefehler ? 'text-warn' : 'text-gruen') : 'text-gold-hell'
}`}
>
{PHASEN_MONO[rip.phase]}
{rip.lesefehler && rip.phase === 'fertig' ? ' — MIT LESEFEHLERN' : ''}
</span>
{ripLaeuft && <span className="font-mono text-[10.5px] text-nebel">{restzeitText(rip.prozent, ripStartMs)}</span>}
</div>
{ripLaeuft && <SegmentBalken prozent={rip.prozent} />}
{ripLaeuft && (rip.titelGesamt ?? 0) > 1 && (
<p className="mt-1.5 font-mono text-[10.5px] text-nebel">
Titel {rip.titelNr} von {rip.titelGesamt}
</p>
)}
{rip.rettung !== undefined && (
<p className="mt-1.5 font-mono text-[10.5px] text-warn">
Rettungs-Abbild: {rip.rettung.unlesbareSektoren} unlesbare Sektoren an {rip.rettung.stellen} Stellen mit Nullen gefüllt
</p>
)}
{/* Der Defekt-Waechter (§ 6.2): Dieselbe Stelle wieder und
wieder ist kein Fortschritt, sondern ein physischer
Defekt. Vorher lief MakeMKV dort stundenlang weiter,
waehrend identische Zeilen durchs Protokoll rauschten. */}
{rip.haengt != null && (
<div className="mt-3 rounded-xl border border-warn/45 bg-warn/10 p-3">
<p className="font-mono text-[11px] font-semibold text-warn">Die Disc hat an einer Stelle einen Defekt.</p>
<p className="mt-1 font-mono text-[11px] leading-5 text-dunst">
MakeMKV versucht es seit {rip.haengt.wiederholungen} Meldungen immer wieder an derselben Stelle (Byte {rip.haengt.offset}). Das wird nicht
besser der Rest der Disc ist davon nicht betroffen. Nach dem Abbruch bietet Rippy das Rettungs-Abbild an.
</p>
<div className="mt-2 flex flex-wrap items-center gap-2">
<button
onClick={() => ripUeberspringen(geraet.id)}
className="rounded-lg border border-warn/50 bg-warn/15 px-3 py-1 text-[11px] font-semibold text-warn hover:bg-warn/25"
title="Diesen Titel aufgeben und mit dem naechsten weitermachen"
>
Titel ueberspringen
</button>
<span className="font-mono text-[10.5px] text-nebel">oder weiter versuchen abbrechen geht oben.</span>
</div>
</div>
)}
<p className="mt-2 truncate font-mono text-[11px] text-nebel" title={rip.text}>
&gt; {rip.text}
</p>
{rip.dateien.map((datei) => (
<p key={datei} className="break-all font-mono text-[11px] text-nebel/80">
{datei}
</p>
))}
{/* Nach dem Rip (5.4.0): Wo steht die Kompression? Das
Laufwerk ist schon frei — die Arbeit läuft im Hintergrund. */}
{rip.phase === 'fertig' && vorgang !== undefined && (
<div className="mt-3 rounded-lg border border-white/8 bg-buehne/60 p-2.5">
{kompression !== null ? (
<>
<p className="font-mono text-[10.5px] tracking-[0.12em] text-gold-hell">
KOMPRESSION LÄUFT{kompression.prozent >= 0 ? `${kompression.prozent} %` : ''}
</p>
<div className="mt-1.5">
<SegmentBalken prozent={kompression.prozent} />
</div>
<p className="mt-1.5 truncate font-mono text-[11px] text-nebel" title={kompression.text}>
&gt; {kompression.text}
</p>
</>
) : wartePlatz > 0 ? (
<p className="font-mono text-[11px] text-dunst">Kompression wartet Platz {wartePlatz} in der Warteschlange.</p>
) : vorgang.phase === 'fertig' ? (
<p className="break-all font-mono text-[11px] text-gruen">
{vorgang.komprimiert ? 'Komprimiert und abgelegt' : 'Verlustfrei abgelegt'}: {vorgang.zielOrdner}
{vorgang.fertigBytes > 0 && ` (${gb(vorgang.fertigBytes)})`}
</p>
) : vorgang.phase === 'fehler' || vorgang.phase === 'abgebrochen' ? (
<p className="break-all font-mono text-[11px] text-warn"> {vorgang.fehler.length > 0 ? vorgang.fehler : `Kompression ${vorgang.phase}`}</p>
) : (
<p className="font-mono text-[11px] text-dunst">Kompression: {vorgang.phase}</p>
)}
</div>
)}
</div>
{ripLaeuft && rip.prozent >= 0 && (
<div className="flex-none font-mono text-[42px] font-bold leading-none text-gold-hell">
{rip.prozent}
<span className="text-lg text-gold/70">%</span>
</div>
)}
</div>
{protokoll.length > 1 && (
<button onClick={() => setProtokollOffen((o) => !o)} className="mt-2 font-mono text-[10.5px] text-nebel hover:text-dunst">
{protokollOffen ? '▾ PROTOKOLL AUSBLENDEN' : `▸ PROTOKOLL (${protokoll.length} ZEILEN)`}
</button>
)}
{protokollOffen && (
<div className="mt-1 max-h-40 space-y-0.5 overflow-y-auto rounded-lg bg-buehne p-2">
{protokoll.map((zeile, i) => (
<p key={i} className="break-all font-mono text-[10.5px] leading-4 text-nebel">
&gt; {zeile}
</p>
))}
</div>
)}
</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,86 @@
// Speicher und Statistik in der Übersicht (5.5.0, Commander: „Ist mir zu
// leer … wie viel Speicher noch auf dem Server oder auf dem Arbeitsordner
// frei ist"). Gemessen im Kern mit fs.statfs — lokal wie auf der Freigabe.
import type { SpeicherPlatz, SpeicherStand, Statistik } from '../../gemeinsam/nachrichten'
import { FuellBalken, dauerGrob, gbKurz } from '../bausteine'
import { speicherLaden } from '../kernverbindung'
/** Ab hier wird gewarnt: eine Blu-ray braucht rund 50 GB (Roh + Kompression). */
export const WENIG_FREI_BYTES = 60e9
export const SEHR_WENIG_FREI_BYTES = 20e9
function Platz({ name, platz, hinweis }: { name: string; platz: SpeicherPlatz; hinweis: string }) {
const ton = !platz.ok ? 'rot' : platz.freiBytes < SEHR_WENIG_FREI_BYTES ? 'rot' : platz.freiBytes < WENIG_FREI_BYTES ? 'warn' : 'gold'
const belegt = platz.ok && platz.gesamtBytes > 0 ? 1 - platz.freiBytes / platz.gesamtBytes : 0
return (
<div className="rounded-xl border border-white/8 bg-samt/50 p-3">
<div className="flex items-baseline justify-between gap-3">
<span className="font-mono text-[10px] tracking-[0.14em] text-nebel">{name}</span>
<span className={`font-mono text-[13px] font-semibold ${ton === 'rot' ? 'text-rot' : ton === 'warn' ? 'text-warn' : 'text-schnee'}`}>
{platz.ok ? `${gbKurz(platz.freiBytes)} frei` : 'nicht messbar'}
</span>
</div>
<div className="mt-2">
<FuellBalken anteil={belegt} ton={ton} />
</div>
<p className="mt-1.5 break-all font-mono text-[10.5px] text-nebel" title={platz.pfad}>
{platz.pfad}
{platz.ok && ` · ${gbKurz(platz.gesamtBytes)} gesamt`}
{!platz.ok && platz.fehler.length > 0 && ` · ${platz.fehler}`}
</p>
<div className="mt-1.5 flex flex-wrap items-center gap-2">
<span className="text-[10.5px] text-nebel/80">{hinweis}</span>
{platz.ok && (
<button onClick={() => void window.rippy.ordnerOeffnen(platz.pfad)} className="ml-auto font-mono text-[10.5px] text-nebel hover:text-dunst" title="Im Explorer zeigen">
Ordner öffnen
</button>
)}
</div>
</div>
)
}
export function SpeicherKarte({ speicher, statistik }: { speicher: SpeicherStand | null; statistik: Statistik | null }) {
return (
<div className="rounded-2xl border border-white/10 bg-karte/50 p-5">
<div className="flex items-center gap-2.5 font-mono text-[10.5px] tracking-[0.12em] text-nebel">
<span className="text-dunst">SPEICHER UND BILANZ</span>
<button onClick={() => speicherLaden()} className="ml-auto font-mono text-[10.5px] text-nebel hover:text-dunst" title="Jetzt neu messen">
neu messen
</button>
</div>
{speicher === null ? (
<p className="mt-3 font-mono text-xs text-nebel">wird gemessen </p>
) : (
<div className={`mt-3 grid gap-3 ${speicher.getrennt ? 'sm:grid-cols-2' : ''}`}>
{speicher.getrennt ? (
<>
<Platz name="ARBEITSORDNER (LOKAL)" platz={speicher.arbeit} hinweis="Roh-Dateien und Kompression — eine Blu-ray braucht rund 50 GB." />
<Platz name="ABLAGE (SERVER)" platz={speicher.ablage} hinweis="Fertige Filme, Serien und Musik." />
</>
) : (
<Platz name="ABLAGE UND ARBEIT" platz={speicher.ablage} hinweis="Roh-Dateien, Kompression und Fertiges am selben Ort — ein eigener Arbeitsordner geht unter Einstellungen → Ablage." />
)}
</div>
)}
{statistik !== null && statistik.discs > 0 && (
<div className="mt-3 grid grid-cols-2 gap-2.5 sm:grid-cols-4">
<Zahl label="DISCS" wert={String(statistik.discs)} zusatz={`${statistik.fertige} fertig`} />
<Zahl label="VERLUSTFREI GESICHERT" wert={gbKurz(statistik.rohBytes)} zusatz="Summe aller Rips" />
<Zahl label="FERTIG ABGELEGT" wert={gbKurz(statistik.fertigBytes)} zusatz="komprimiert oder verschoben" />
<Zahl label="JE DISC" wert={statistik.fertige > 0 ? dauerGrob(statistik.dauerS / statistik.fertige) : '—'} zusatz="Rip bis Ablage, im Schnitt" />
</div>
)}
</div>
)
}
function Zahl({ label, wert, zusatz }: { label: string; wert: string; zusatz: string }) {
return (
<div className="rounded-lg border border-white/10 bg-samt/70 px-3 py-2">
<div className="font-mono text-[9px] tracking-[0.14em] text-nebel">{label}</div>
<div className="mt-0.5 font-display text-[19px] font-bold leading-tight text-gold-hell">{wert}</div>
<div className="font-mono text-[10px] text-nebel/80">{zusatz}</div>
</div>
)
}
@@ -0,0 +1,183 @@
// Die Übersicht — Laufwerke, Warteschlange, Speicher und Bilanz, Verlauf,
// Ereignisse. Und die Dialoge: Sie leben im Zustand DIESES Bereichs, nicht
// in der Kachel (5.4.0: ein leerer Takt der Wache räumte sonst Kachel UND
// Dialog ab).
import { useState } from 'react'
import type { HauptStatus } from '../../gemeinsam/nachrichten'
import { IconDisc, LAUFENDE_PHASEN } from '../bausteine'
import { AendernDialog } from '../dialoge/AendernDialog'
import { TitelWahlDialog } from '../dialoge/TitelWahlDialog'
import { automatikStopp, titelLesen, type Verbindung } from '../kernverbindung'
import { KompressionsKarte } from './KompressionsKarte'
import { LaufwerkKachel } from './LaufwerkKachel'
import { SpeicherKarte } from './SpeicherKarte'
import { VerlaufKarte } from './VerlaufKarte'
export function Uebersicht({
verbindung,
haupt,
systemFehler,
aufRohDateien,
}: {
verbindung: Verbindung
haupt: HauptStatus | null
systemFehler: string[]
aufRohDateien: () => void
}) {
// Sagt ein Update-Knopf NEIN, steht der Grund daneben (R4) — ein Knopf,
// der nichts tut und nichts sagt, ist schlimmer als kein Knopf.
const [updateHinweis, setUpdateHinweis] = useState('')
const [dialog, setDialog] = useState<{ art: 'titel' | 'aendern'; id: string } | null>(null)
const einRipLaeuft = Object.values(verbindung.rips).some((r) => LAUFENDE_PHASEN.includes(r.phase))
return (
<>
{systemFehler.length > 0 && (
<div className="rounded-2xl border border-rot/40 bg-rot/10 p-4">
{systemFehler.map((zeile, i) => (
<p key={i} className="break-all font-mono text-xs text-rot">
{zeile}
</p>
))}
</div>
)}
{/* § 6.8: Ein verfügbares Update ist eine SICHTBARE Karte, kein
Satz in den Einstellungen. Der Commander musste es am
01.09.2026 suchen — gefunden hat er es nicht. */}
{haupt !== null && haupt.update.neueVersion.length > 0 && (
<div className="rounded-2xl border border-gold/40 bg-gold/10 p-4">
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<span className="font-display text-lg text-gold-hell">Update verfügbar Version {haupt.update.neueVersion}</span>
<span className="font-mono text-xs text-nebel">du hast {haupt.version}</span>
</div>
<p className="mt-1 font-mono text-xs text-dunst">{haupt.update.text}</p>
{haupt.update.fortschritt >= 0 && !haupt.update.bereit && (
<div className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-white/10">
<div className="h-full rounded-full bg-gold transition-all" style={{ width: `${Math.max(0, Math.min(100, haupt.update.fortschritt))}%` }} />
</div>
)}
{haupt.update.neuerungen.length > 0 && (
<div className="mt-3 rounded-xl border border-white/8 bg-buehne/60 p-3">
<p className="text-[11px] uppercase tracking-wider text-nebel">Was neu ist</p>
<p className="mt-1 whitespace-pre-wrap font-mono text-xs leading-5 text-dunst">{haupt.update.neuerungen}</p>
</div>
)}
{haupt.update.bereit && (
<div className="mt-3 flex flex-wrap items-center gap-2">
<button
onClick={() => {
setUpdateHinweis('')
void window.rippy.updateInstallieren().then((urteil) => {
if (!urteil.ok) setUpdateHinweis(urteil.grund)
})
}}
disabled={einRipLaeuft}
className="rounded-lg border border-gold/40 bg-gold/15 px-3 py-1.5 text-xs font-semibold text-gold-hell hover:bg-gold/25 disabled:opacity-35"
title={einRipLaeuft ? 'Erst wenn der Rip fertig ist — Rippy startet dafür neu.' : 'Rippy beendet sich, installiert und startet wieder.'}
>
Jetzt neu starten und installieren
</button>
<span className="font-mono text-[11px] text-nebel"> oder einfach nichts tun: Beim nächsten Beenden passiert es von selbst.</span>
</div>
)}
{updateHinweis.length > 0 && <p className="mt-2 font-mono text-xs text-warn">{updateHinweis}</p>}
</div>
)}
<section>
{verbindung.laufwerke.length === 0 ? (
<div className="flex items-center gap-5 rounded-2xl border border-white/10 bg-karte/50 p-6">
<IconDisc groesse={44} farbe="oklch(0.4 0.02 75)" />
<p className="font-mono text-xs text-nebel">{verbindung.verbunden ? 'Kein optisches Laufwerk gefunden.' : 'Warte auf den Kern …'}</p>
</div>
) : (
<div className="space-y-4">
{verbindung.laufwerke.map((geraet) => {
const ripVorgangId = verbindung.rips[geraet.id]?.vorgangId ?? ''
const laeuft = verbindung.vorgaenge?.kompression.laeuft ?? null
const wartend = verbindung.vorgaenge?.kompression.wartend ?? []
return (
<LaufwerkKachel
key={geraet.id}
geraet={geraet}
rip={verbindung.rips[geraet.id]}
info={verbindung.discInfos[geraet.id]}
ripStartMs={verbindung.ripStart[geraet.id]}
protokoll={verbindung.ripProtokoll[geraet.id] ?? []}
verbindung={verbindung}
bericht={verbindung.gesundheit[geraet.id]}
vorgang={verbindung.vorgaenge?.vorgaenge.find((v) => v.id === ripVorgangId)}
kompression={laeuft !== null && laeuft.vorgangId === ripVorgangId ? laeuft : null}
wartePlatz={wartend.findIndex((w) => w.vorgangId === ripVorgangId) + 1}
automatik={verbindung.automatik[geraet.id]}
aufRippen={() => {
// Der Nutzer entscheidet selbst — ein Countdown endet.
automatikStopp(geraet.id)
// Erst die Titel lesen (20-120 s), dann waehlen lassen.
setDialog({ art: 'titel', id: geraet.id })
titelLesen(geraet.id)
}}
aufAendern={() => {
automatikStopp(geraet.id)
setDialog({ art: 'aendern', id: geraet.id })
}}
/>
)
})}
</div>
)}
</section>
{verbindung.vorgaenge !== null && (
<section>
<KompressionsKarte stand={verbindung.vorgaenge.kompression} />
</section>
)}
<section>
<SpeicherKarte speicher={verbindung.speicher} statistik={verbindung.vorgaenge?.statistik ?? null} />
</section>
{verbindung.vorgaenge !== null && (
<section>
<VerlaufKarte vorgaenge={verbindung.vorgaenge.vorgaenge} aufAlle={aufRohDateien} />
</section>
)}
{dialog !== null && dialog.art === 'titel' && (
<TitelWahlDialog
id={dialog.id}
typ={verbindung.laufwerke.find((l) => l.id === dialog.id)?.typ ?? 'bluray'}
stand={verbindung.discTitel[dialog.id]}
info={verbindung.discInfos[dialog.id]}
einstellungen={verbindung.einstellungen}
regel={verbindung.vorgaenge?.regel ?? ''}
speicher={verbindung.speicher}
schliessen={() => setDialog(null)}
/>
)}
{dialog !== null && dialog.art === 'aendern' && (
<AendernDialog
id={dialog.id}
startText={verbindung.discInfos[dialog.id]?.titel ?? ''}
vorschlaege={verbindung.vorschlaege[dialog.id] ?? []}
schliessen={() => setDialog(null)}
/>
)}
{verbindung.meldungen.length > 0 && (
<section>
<div className="mb-2.5 font-mono text-[10px] tracking-[0.22em] text-nebel">EREIGNISSE</div>
<div className="max-h-56 space-y-1 overflow-y-auto rounded-2xl border border-white/10 bg-karte/50 p-4">
{verbindung.meldungen.map((zeile, i) => (
<p key={i} className="break-all font-mono text-[11px] text-dunst">
{zeile}
</p>
))}
</div>
</section>
)}
</>
)
}
@@ -0,0 +1,61 @@
// Der Verlauf (5.5.0) — die letzten Vorgänge auf einen Blick, statt der
// Bibliothek (Commander: „Brauchen wir die wirklich?" — nein: Jellyfin IST
// die Bibliothek; die Wiedererkennung braucht keine Ansicht).
import type { GeraeteInfo, VorgangEintrag } from '../../gemeinsam/nachrichten'
import { Badge, TYP_NAMEN, datumKurz, gb } from '../bausteine'
const PHASEN_TEXT: Record<VorgangEintrag['phase'], string> = {
rippt: 'rippt',
wartet: 'wartet',
komprimiert: 'komprimiert',
'legt-ab': 'legt ab',
fertig: 'fertig',
fehler: 'Fehler',
abgebrochen: 'abgebrochen',
}
export function phasenTon(p: VorgangEintrag['phase']): 'gruen' | 'gelb' | 'blau' | 'grau' {
return p === 'fertig' ? 'gruen' : p === 'fehler' || p === 'abgebrochen' ? 'gelb' : p === 'wartet' ? 'grau' : 'blau'
}
export function VerlaufKarte({ vorgaenge, aufAlle }: { vorgaenge: VorgangEintrag[]; aufAlle: () => void }) {
if (vorgaenge.length === 0) return null
const letzte = vorgaenge.slice(0, 6)
return (
<div className="rounded-2xl border border-white/10 bg-karte/50 p-5">
<div className="flex items-center gap-2.5 font-mono text-[10.5px] tracking-[0.12em] text-nebel">
<span className="text-dunst">VERLAUF</span>
<span>·</span>
<span>die letzten {letzte.length} Vorgänge</span>
<button onClick={aufAlle} className="ml-auto font-mono text-[10.5px] text-nebel hover:text-dunst">
alle unter Roh-Dateien
</button>
</div>
<div className="mt-3 space-y-1">
{letzte.map((v) => (
<div key={v.id} className="flex flex-wrap items-center gap-x-3 gap-y-1 rounded-lg border border-white/8 bg-samt/40 px-3 py-2">
<span className="min-w-0 flex-1 truncate font-display text-[14px] font-bold text-schnee" title={v.titel}>
{v.titel}
{v.jahr !== null && <span className="ml-1 font-medium text-dunst">({v.jahr})</span>}
</span>
<Badge text={PHASEN_TEXT[v.phase]} ton={phasenTon(v.phase)} />
<span className="font-mono text-[10.5px] text-nebel">
{(TYP_NAMEN[v.typ as GeraeteInfo['typ']] ?? v.typ).toUpperCase()} · {datumKurz(v.begonnen)}
{v.phase === 'fertig' && v.fertigBytes > 0 && ` · ${gb(v.fertigBytes)}`}
{!v.rohGeloescht && v.rohBytes > 0 && ` · Roh ${gb(v.rohBytes)}`}
</span>
{v.phase === 'fertig' && v.zielOrdner.length > 0 && (
<button
onClick={() => void window.rippy.ordnerOeffnen(v.zielOrdner)}
className="font-mono text-[10.5px] text-nebel hover:text-dunst"
title={v.zielOrdner}
>
Ordner öffnen
</button>
)}
</div>
))}
</div>
</div>
)
}
+110 -12
View File
@@ -45,6 +45,8 @@ export type AuswurfGrund = 'ok' | 'kein-zugriff' | 'abgelehnt' | 'bleibt-drin'
// ── Rippen (Etappe W-2) ─────────────────────────────────────────────────
export type RipPhase =
| 'liest-info'
/** 5.5.0: Das Rettungs-Abbild wird gelesen — Sektor für Sektor, Nullen für Unlesbares. */
| 'rettet'
| 'rippt'
| 'komprimiert'
| 'legt-ab'
@@ -72,6 +74,12 @@ export interface RipStatus {
/** Der Vorgang, zu dem dieser Rip gehört (5.4.0) — die Kompression läuft
* danach unter dieser Kennung in der Warteschlange weiter. */
vorgangId?: string
/** 5.5.0: Rippy hat an dieser Disc Lesefehler GESEHEN (Defekt-Wächter,
* Lesefehler-Meldung, Leseabbruch) — erst dann wird das Rettungs-Abbild
* angeboten. Eine heile Disc braucht es nicht (Commander 01.09.2026). */
rettungMoeglich?: boolean
/** 5.5.0: Stand des Rettungs-Abbilds — unlesbare Sektoren und Stellen. */
rettung?: { unlesbareSektoren: number; stellen: number }
}
/** Was ein Titel IST — davon hängt ab, WOHIN er abgelegt wird:
@@ -195,9 +203,13 @@ export interface DiscInfoStand {
/** Was Rippy NACH dem Info-Lauf noch dazugelernt hat („Laufzeit
* bestätigt: 124 min"), Klartext-Zeilen. */
hinweise: string[]
/** 5.5.0 Serien-Merker: Was Rippy von der VORIGEN Disc dieser Serie weiß —
* Staffel und die Folge, mit der es weitergeht. null, wenn nichts. */
serienMerker: { staffel: number; naechsteFolge: number; wann: string } | null
}
/** Ein Eintrag der Bibliothek (§ 6.6). */
/** Ein Eintrag der Bibliothek (§ 6.6) — seit 5.5.0 nur noch die
* Wiedererkennung („schon gerippt am …"), keine eigene Ansicht mehr. */
export interface BibliothekEintrag {
fingerprint: string
titel: string
@@ -207,7 +219,7 @@ export interface BibliothekEintrag {
ablageort: string
datum: string
dauerS: number
/** TMDb-Posterpfad fürs Bibliotheks-Regal — '' wenn keiner bekannt. */
/** TMDb-Posterpfad — '' wenn keiner bekannt. */
posterPfad: string
}
@@ -247,7 +259,7 @@ export interface BeschaffungStand {
export type VorgangPhase = 'rippt' | 'wartet' | 'komprimiert' | 'legt-ab' | 'fertig' | 'fehler' | 'abgebrochen'
/** Ein Vorgang, wie das Fenster ihn zeigt (Einstellungen → Roh-Dateien,
* Übersicht → Kompression). */
* Übersicht → Verlauf). */
export interface VorgangEintrag {
id: string
laufwerk: string
@@ -289,6 +301,20 @@ export interface FremderOrdner {
bytes: number
}
/** 5.5.0: Was Rippy insgesamt geschafft hat (aus den Vorgängen). */
export interface Statistik {
/** Vorgänge insgesamt (Video-Discs). */
discs: number
/** Davon fertig abgelegt. */
fertige: number
/** Verlustfrei gesichert, Summe aller Rips (auch wenn Roh inzwischen weg ist). */
rohBytes: number
/** Fertig abgelegt, Summe. */
fertigBytes: number
/** Summe der Dauer je fertigem Vorgang (Rip-Beginn bis Ablage-Ende) in Sekunden. */
dauerS: number
}
export interface VorgaengeStand {
vorgaenge: VorgangEintrag[]
/** Summe aller Roh-Ordner, die noch da sind. */
@@ -296,7 +322,8 @@ export interface VorgaengeStand {
fremde: FremderOrdner[]
/** Die Aufbewahrungs-Regel, die gerade gilt (Einstellung rohAufbewahrung). */
regel: string
kompression: { laeuft: KompressionStand | null; wartend: KompressionStand[] }
kompression: { laeuft: KompressionStand | null; wartend: KompressionStand[]; pausiert: boolean }
statistik: Statistik
}
/** Was das Windows-Ereignisprotokoll über ein Laufwerk sagt (5.4.0). */
@@ -313,6 +340,33 @@ export interface LaufwerkBericht {
stunden: number
}
/** 5.5.0: Freier Platz an EINEM Ort — gemessen mit fs.statfs (Node 24). */
export interface SpeicherPlatz {
pfad: string
ok: boolean
freiBytes: number
gesamtBytes: number
/** '' wenn ok — sonst, warum nicht messbar (Pfad fehlt, Netz weg). */
fehler: string
}
export interface SpeicherStand {
/** Wo Roh-Dateien und Kompression liegen (Arbeitsordner oder Ablage). */
arbeit: SpeicherPlatz
/** Wo Fertiges landet (die Ablage — beim Commander der Server). */
ablage: SpeicherPlatz
/** true, wenn ein eigener Arbeitsordner gesetzt ist. */
getrennt: boolean
}
/** 5.5.0 Automatik: Countdown bis zum Rip ohne Klick (KONZEPT § 5 Zero-Click). */
export interface AutomatikStand {
id: string
aktiv: boolean
sekunden: number
text: string
}
/** Nachrichten, die der Kern verschickt. */
export type KernNachricht =
| { art: 'kern-bereit'; pid: number; nodeVersion: string; datenbank: DbStatus }
@@ -329,7 +383,6 @@ export type KernNachricht =
| { art: 'metadaten-vorschlaege'; id: string; vorschlaege: MetaVorschlag[] }
| { art: 'einstellungen'; werte: Record<string, string> }
| { art: 'werkzeuge'; auskunft: WerkzeugAuskunft }
| { art: 'bibliothek'; eintraege: BibliothekEintrag[] }
| { art: 'beschaffung-status'; stand: BeschaffungStand }
/** Nur fürs Haupt: Der Kern hat einen geprüften Installer abgelegt —
* starten kann ihn nur das Haupt (shell/UAC, § 9). */
@@ -354,6 +407,12 @@ export type KernNachricht =
/** Eine Kompression ist zu Ende — fürs Toast und die Ereignis-Zeile. */
| { art: 'kompression-ergebnis'; vorgangId: string; titel: string; ok: boolean; text: string }
| { art: 'laufwerk-gesundheit'; id: string; bericht: LaufwerkBericht }
/** 5.5.0: freier Platz an Arbeitsordner und Ablage. */
| { art: 'speicher'; stand: SpeicherStand }
/** 5.5.0: Automatik-Countdown je Laufwerk. */
| { art: 'automatik'; stand: AutomatikStand }
/** 5.5.0: Poster beliebter Filme für den Hintergrund der Erst-Einrichtung. */
| { art: 'poster-vorschau'; pfade: string[] }
/** Nachrichten, die das Fenster über den MessagePort an den Kern schickt. */
export type FensterNachricht =
@@ -374,7 +433,6 @@ export type FensterNachricht =
| { art: 'zuordnung-setzen'; id: string; vorschlag: MetaVorschlag }
| { art: 'einstellungen-laden' }
| { art: 'werkzeuge-laden' }
| { art: 'bibliothek-laden' }
/** § 9: MakeMKV über die Quellenkette holen und installieren lassen. */
| { art: 'makemkv-update' }
/** Schlüsselkette sofort neu laufen lassen (z. B. nach einer Installation). */
@@ -394,6 +452,20 @@ export type FensterNachricht =
| { art: 'fremden-ordner-loeschen'; ordner: string }
/** Das Windows-Ereignisprotokoll zu diesem Laufwerk lesen. */
| { art: 'laufwerk-pruefen'; id: string }
// ── 5.5.0 ──
/** Die Kompressions-Warteschlange anhalten (an) oder weiterlaufen lassen. */
| { art: 'kompression-pause'; an: boolean }
/** Den Automatik-Countdown dieses Laufwerks stoppen bzw. sofort rippen. */
| { art: 'automatik-stopp'; id: string }
| { art: 'automatik-jetzt'; id: string }
/** Testnachricht über die eingerichteten Kanäle (Discord, Telegram). */
| { art: 'benachrichtigung-testen' }
/** Poster beliebter Filme laden (Erst-Einrichtung). */
| { art: 'poster-laden' }
/** Rettungs-Abbild für eine Disc mit GESEHENEN Lesefehlern. */
| { art: 'rettung-start'; id: string }
/** Freien Platz neu messen. */
| { art: 'speicher-laden' }
/** Nachrichten, die das Haupt an den Kern schickt. */
export type HauptNachricht =
@@ -413,6 +485,8 @@ export interface HauptStatus {
/** Für die eigene Titelleiste (§ 4.2, seit 5.1.3): Ist das Fenster
* gerade maximiert? Entscheidet nur, welches Symbol der Knopf zeigt. */
fensterMaximiert: boolean
/** 5.5.0: Wo die Protokoll-Dateien liegen — für „Protokoll öffnen". */
protokollOrdner: string
}
/** Der Stand des Selbst-Updates (§ 6.8) — EINE Definition, die sich Haupt
@@ -489,8 +563,6 @@ export function istKernNachricht(wert: unknown): wert is KernNachricht {
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)
case 'schluessel-status':
return istObjekt(wert.stand) && typeof (wert.stand as Record<string, unknown>).text === 'string'
case 'beschaffung-status':
@@ -509,7 +581,8 @@ export function istKernNachricht(wert: unknown): wert is KernNachricht {
istObjekt(wert.stand) &&
Array.isArray((wert.stand as Record<string, unknown>).vorgaenge) &&
typeof (wert.stand as Record<string, unknown>).rohGesamtBytes === 'number' &&
istObjekt((wert.stand as Record<string, unknown>).kompression)
istObjekt((wert.stand as Record<string, unknown>).kompression) &&
istObjekt((wert.stand as Record<string, unknown>).statistik)
)
case 'kompression-ergebnis':
return (
@@ -526,6 +599,23 @@ export function istKernNachricht(wert: unknown): wert is KernNachricht {
typeof (wert.bericht as Record<string, unknown>).text === 'string' &&
Array.isArray((wert.bericht as Record<string, unknown>).letzte)
)
case 'speicher':
return (
istObjekt(wert.stand) &&
istObjekt((wert.stand as Record<string, unknown>).arbeit) &&
istObjekt((wert.stand as Record<string, unknown>).ablage) &&
typeof (wert.stand as Record<string, unknown>).getrennt === 'boolean'
)
case 'automatik':
return (
istObjekt(wert.stand) &&
istKennung((wert.stand as Record<string, unknown>).id) &&
typeof (wert.stand as Record<string, unknown>).aktiv === 'boolean' &&
typeof (wert.stand as Record<string, unknown>).sekunden === 'number' &&
typeof (wert.stand as Record<string, unknown>).text === 'string'
)
case 'poster-vorschau':
return Array.isArray(wert.pfade) && wert.pfade.every(istText)
default:
return false
}
@@ -597,10 +687,16 @@ export function istFensterNachricht(wert: unknown): wert is FensterNachricht {
wert.art === 'titel-lesen' ||
wert.art === 'rip-abbruch' ||
wert.art === 'rip-ueberspringen' ||
wert.art === 'laufwerk-pruefen'
wert.art === 'laufwerk-pruefen' ||
wert.art === 'automatik-stopp' ||
wert.art === 'automatik-jetzt' ||
wert.art === 'rettung-start'
) {
return istKennung(wert.id)
}
if (wert.art === 'kompression-pause') {
return typeof wert.an === 'boolean'
}
if (wert.art === 'einstellung-setzen') {
return istKennung(wert.schluessel) && typeof wert.wert === 'string'
}
@@ -622,11 +718,13 @@ export function istFensterNachricht(wert: unknown): wert is FensterNachricht {
return (
wert.art === 'einstellungen-laden' ||
wert.art === 'werkzeuge-laden' ||
wert.art === 'bibliothek-laden' ||
wert.art === 'makemkv-update' ||
wert.art === 'schluessel-pruefen' ||
wert.art === 'vorgaenge-laden' ||
wert.art === 'roh-aufraeumen'
wert.art === 'roh-aufraeumen' ||
wert.art === 'benachrichtigung-testen' ||
wert.art === 'poster-laden' ||
wert.art === 'speicher-laden'
)
}
+33
View File
@@ -0,0 +1,33 @@
// Die Platz-Prüfung vor dem Rip (5.5.0) — EINE Rechnung für Kern und
// Fenster: Roh plus geschätzt 25 % für die Kompression, plus 2 GB Luft.
// Der Kern urteilt beim Start (index.ts ripMitPlatz — auch für die
// Automatik), der Dialog zeigt dasselbe Urteil schon vor dem Klick
// (TitelWahlDialog).
export const PLATZ_LUFT_BYTES = 2e9
export const KOMPRESSION_ANTEIL = 0.25
export interface PlatzUrteil {
/** Leer, wenn der Platz reicht — sonst der Grund in Klartext. */
text: string
/** true: nicht einmal der verlustfreie Rip passt — der Start wird verweigert. */
hart: boolean
}
export function gbText(bytes: number): string {
return `${(bytes / 1e9).toFixed(1).replace('.', ',')} GB`
}
export function platzUrteil(freiBytes: number, rohBytes: number, komprimiert: boolean): PlatzUrteil {
const noetig = rohBytes + (komprimiert ? rohBytes * KOMPRESSION_ANTEIL : 0) + PLATZ_LUFT_BYTES
if (freiBytes >= noetig) return { text: '', hart: false }
if (freiBytes < rohBytes + PLATZ_LUFT_BYTES) {
return {
text: `Zu wenig Platz: ${gbText(freiBytes)} frei, der Rip braucht ${gbText(rohBytes)} verlustfrei. Das geht schief — erst Platz schaffen (Einstellungen → Roh-Dateien).`,
hart: true,
}
}
return {
text: `Knapp: ${gbText(freiBytes)} frei, gebraucht werden etwa ${gbText(noetig)} (Roh plus Kompression). Aufräumen unter Roh-Dateien hilft.`,
hart: false,
}
}
+84
View File
@@ -0,0 +1,84 @@
// Die Protokoll-Datei (5.5.0, aus ARM: LOGPATH/LOGLIFE) — reines Node, damit
// Haupt UND Kern sie schreiben können. Eine Datei je Tag unter
// <Profil>\logs\rippy-JJJJ-MM-TT.log, ältere als 14 Tage werden beim Start
// entfernt. Der Grund: Der Datenbank-Befund vom 01.09.2026 (eine Datenbank,
// in die seit dem 30.08. niemand geschrieben hatte) wäre mit einer Zeile
// „Datenbank geöffnet: <Pfad>" in einer Datei sofort sichtbar gewesen —
// die Konsole eines Tray-Programms sieht niemand.
//
// Schreibfehler werden NICHT geworfen (das Protokoll darf nie den Betrieb
// kippen), aber gezählt und beim nächsten Erfolg vermerkt.
import { appendFileSync, existsSync, mkdirSync, readdirSync, unlinkSync, statSync } from 'node:fs'
import { join } from 'node:path'
export const PROTOKOLL_TAGE = 14
export function protokollDateiname(datum: Date): string {
const j = datum.getFullYear()
const m = String(datum.getMonth() + 1).padStart(2, '0')
const t = String(datum.getDate()).padStart(2, '0')
return `rippy-${j}-${m}-${t}.log`
}
/** Zeitstempel lokal, sekundengenau — so, wie der Commander die Uhr liest. */
export function zeitstempel(datum: Date): string {
const z = (n: number): string => String(n).padStart(2, '0')
return `${datum.getFullYear()}-${z(datum.getMonth() + 1)}-${z(datum.getDate())} ${z(datum.getHours())}:${z(datum.getMinutes())}:${z(datum.getSeconds())}`
}
/** Welche Dateien älter als `tage` sind (pur, über den Dateinamen). */
export function veraltet(namen: readonly string[], jetzt: Date, tage = PROTOKOLL_TAGE): string[] {
const grenze = jetzt.getTime() - tage * 24 * 60 * 60 * 1000
return namen.filter((n) => {
const treffer = /^rippy-(\d{4})-(\d{2})-(\d{2})\.log$/.exec(n)
if (treffer === null) return false
const datum = new Date(Number(treffer[1]), Number(treffer[2]) - 1, Number(treffer[3]))
return datum.getTime() < grenze
})
}
export class Protokoll {
private fehlschlaege = 0
constructor(
readonly ordner: string,
private readonly quelle: string,
private readonly jetzt: () => Date = () => new Date(),
) {}
/** Eine Zeile anhängen: „2026-09-02 21:13:05 [kern] Text". */
schreiben(text: string): void {
try {
if (!existsSync(this.ordner)) mkdirSync(this.ordner, { recursive: true })
const datum = this.jetzt()
const nachtrag = this.fehlschlaege > 0 ? ` (davor ${this.fehlschlaege} Zeilen nicht schreibbar)` : ''
this.fehlschlaege = 0
appendFileSync(join(this.ordner, protokollDateiname(datum)), `${zeitstempel(datum)} [${this.quelle}] ${text}${nachtrag}\n`)
} catch {
this.fehlschlaege += 1
}
}
/** Dateien älter als PROTOKOLL_TAGE entfernen — beim Start. */
aufraeumen(): number {
let weg = 0
try {
if (!existsSync(this.ordner)) return 0
for (const name of veraltet(readdirSync(this.ordner), this.jetzt())) {
try {
const pfad = join(this.ordner, name)
if (statSync(pfad).isFile()) {
unlinkSync(pfad)
weg += 1
}
} catch {
// Eine gesperrte Datei bleibt bis zum nächsten Start liegen.
}
}
} catch {
// Nicht lesbar: nichts zu tun, nichts zu melden — das Protokoll ist
// Komfort.
}
return weg
}
}
+56
View File
@@ -0,0 +1,56 @@
// Sprachen für die Auswahl-Felder (5.5.0, Commander: „bei Tonspuren und
// Untertiteln können Dropdowns hin") — ISO-639-2-Codes, wie MakeMKV
// (SINFO-Attribut 3) und HandBrake (--audio-lang-list) sie sprechen, mit
// deutschen Namen. Die REIHENFOLGE der Wahl ist die Wunschreihenfolge: die
// erste Tonsprache ist die, deren Fehlen die Untertitel vorwählt
// (kern/ablauf/auftrag.ts sprachwahl).
export interface Sprache {
code: string
name: string
}
export const SPRACHEN: readonly Sprache[] = [
{ code: 'deu', name: 'Deutsch' },
{ code: 'eng', name: 'Englisch' },
{ code: 'jpn', name: 'Japanisch' },
{ code: 'fra', name: 'Französisch' },
{ code: 'ita', name: 'Italienisch' },
{ code: 'spa', name: 'Spanisch' },
{ code: 'por', name: 'Portugiesisch' },
{ code: 'nld', name: 'Niederländisch' },
{ code: 'pol', name: 'Polnisch' },
{ code: 'tur', name: 'Türkisch' },
{ code: 'rus', name: 'Russisch' },
{ code: 'kor', name: 'Koreanisch' },
{ code: 'zho', name: 'Chinesisch' },
{ code: 'swe', name: 'Schwedisch' },
{ code: 'dan', name: 'Dänisch' },
{ code: 'nor', name: 'Norwegisch' },
{ code: 'fin', name: 'Finnisch' },
{ code: 'ces', name: 'Tschechisch' },
{ code: 'hun', name: 'Ungarisch' },
{ code: 'ell', name: 'Griechisch' },
{ code: 'heb', name: 'Hebräisch' },
{ code: 'ara', name: 'Arabisch' },
{ code: 'hin', name: 'Hindi' },
{ code: 'tha', name: 'Thai' },
]
/** „deu, eng , ,DEU" → ['deu','eng'] — dieselbe Regel wie im Kern (presets.ts). */
export function sprachlisteAus(wert: string): string[] {
const sauber: string[] = []
for (const teil of wert.split(',')) {
const code = teil.trim().toLowerCase()
if (code.length > 0 && !sauber.includes(code)) sauber.push(code)
}
return sauber
}
export function sprachName(code: string): string {
return SPRACHEN.find((s) => s.code === code)?.name ?? code
}
/** Die Liste als Einstellung zurück: „deu,eng". */
export function sprachlisteText(codes: readonly string[]): string {
return codes.join(',')
}
+43 -11
View File
@@ -1,17 +1,29 @@
// Fenster anlegen (KONZEPT § 4.2). Das Fenster zeigt an, sonst nichts —
// kein Node-Zugriff, contextIsolation an, alles läuft über die Brücke
// (src/bruecke) und den MessagePort zum Kern.
// „Zustand merken" (Position/Größe) kommt mit den Einstellungen im UI (W-5).
import { BrowserWindow } from 'electron'
//
// 5.5.0: Die Größe hängt am Bildschirm (fensterlage.ts) — 80 % des
// Arbeitsbereichs beim ersten Start, danach die zuletzt gemerkte Lage,
// sofern sie noch auf einem Bildschirm liegt.
import { BrowserWindow, screen } from 'electron'
import { join } from 'node:path'
import { fensterrandEntfernen } from './fensterrand'
import { anfangsLage, lageLesen, lageSchreiben, MINDEST_BREITE, MINDEST_HOEHE, type Lage } from './fensterlage'
export function fensterErstellen(): BrowserWindow {
export function fensterErstellen(profilOrdner: string): BrowserWindow {
const lageDatei = join(profilOrdner, 'fenster-lage.json')
const lage = anfangsLage(
lageLesen(lageDatei),
screen.getPrimaryDisplay().workArea,
screen.getAllDisplays().map((d) => d.workArea),
)
const fenster = new BrowserWindow({
width: 1280,
height: 820,
minWidth: 860,
minHeight: 560,
x: lage.x,
y: lage.y,
width: lage.width,
height: lage.height,
minWidth: Math.min(MINDEST_BREITE, 860),
minHeight: Math.min(MINDEST_HOEHE, 560),
show: true,
autoHideMenuBar: true,
// Eigene Titelleiste (Commander-Wunsch 01.09.2026): Der Windows-Rahmen
@@ -35,17 +47,37 @@ export function fensterErstellen(): BrowserWindow {
sandbox: true,
},
})
if (lage.maximiert) fenster.maximize()
// Lage merken — verzögert, damit ein Ziehen nicht hundertmal schreibt.
let zeiger: NodeJS.Timeout | null = null
const merken = (): void => {
if (zeiger !== null) clearTimeout(zeiger)
zeiger = setTimeout(() => {
if (fenster.isDestroyed()) return
const maximiert = fenster.isMaximized()
// Im maximierten Zustand die NORMALE Lage behalten (getNormalBounds),
// sonst würde beim nächsten Start ein riesiges Fenster entmaximiert.
const b = maximiert ? fenster.getNormalBounds() : fenster.getBounds()
const neu: Lage = { x: b.x, y: b.y, width: b.width, height: b.height, maximiert }
lageSchreiben(lageDatei, neu)
}, 400)
}
fenster.on('resize', merken)
fenster.on('move', merken)
fenster.on('maximize', merken)
fenster.on('unmaximize', merken)
// electron-vite: Im dev-Modus bedient ein Vite-Server das Fenster
// (ELECTRON_RENDERER_URL), gebaut wird aus out/fenster geladen.
// RIPPY_START_BEREICH öffnet einen bestimmten Bereich (#einstellungen /
// #bibliothek) — der Smoke-Beweis fotografiert damit auch diese Seiten.
// Der DWM-Rand von Windows 11 (die feine helle Linie ums Fenster) — er
// gehört nicht zum Fensterinhalt, mit CSS ist er nicht zu erreichen.
// Ein Fehlschlag ist kein Ausfall: Unter Windows 10 gibt es ihn nicht.
const rand = fensterrandEntfernen(fenster.getNativeWindowHandle())
if (!rand.ok) console.warn(`[haupt] Fensterrand bleibt: ${rand.fehler}`)
// electron-vite: Im dev-Modus bedient ein Vite-Server das Fenster
// (ELECTRON_RENDERER_URL), gebaut wird aus out/fenster geladen.
// RIPPY_START_BEREICH öffnet einen bestimmten Bereich (#einstellungen /
// #einrichtung) — der Smoke-Beweis fotografiert damit auch diese Seiten.
const devUrl = process.env['ELECTRON_RENDERER_URL']
const startBereich = process.env['RIPPY_START_BEREICH'] ?? ''
if (devUrl !== undefined && devUrl.length > 0) {
+85
View File
@@ -0,0 +1,85 @@
// Die Fensterlage (5.5.0, Commander: „Das Programm sollte immer in einer
// angenehmen Größe öffnen, abhängig von der Auflösung des Monitors") —
// PUR, damit die Ampel die Rechnung prüft:
// * Ohne gemerkte Lage: 80 % des nutzbaren Bildschirms (Arbeitsbereich
// ohne Taskleiste), mittig, mindestens 1100 × 720, nie größer als der
// Bildschirm.
// * Mit gemerkter Lage: wiederherstellen — aber nur, wenn sie auf einem
// heutigen Bildschirm liegt (ein abgestöpselter Zweitmonitor darf das
// Fenster nicht ins Nirgendwo schieben).
// Gemerkt wird in <Profil>\fenster-lage.json — keine Nutzer-Einstellung,
// sondern Geometrie; die EINE Einstellungs-Quelle (§ 6.7) bleibt der Kern.
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
export interface Lage {
x: number
y: number
width: number
height: number
maximiert: boolean
}
export interface Bereich {
x: number
y: number
width: number
height: number
}
export const MINDEST_BREITE = 1100
export const MINDEST_HOEHE = 720
export const ANTEIL = 0.8
/** Die Startlage aus dem Arbeitsbereich des Hauptbildschirms (pur). */
export function startLage(arbeitsbereich: Bereich): Lage {
const width = Math.max(Math.min(arbeitsbereich.width, MINDEST_BREITE), Math.round(arbeitsbereich.width * ANTEIL))
const height = Math.max(Math.min(arbeitsbereich.height, MINDEST_HOEHE), Math.round(arbeitsbereich.height * ANTEIL))
return {
x: arbeitsbereich.x + Math.round((arbeitsbereich.width - width) / 2),
y: arbeitsbereich.y + Math.round((arbeitsbereich.height - height) / 2),
width: Math.min(width, arbeitsbereich.width),
height: Math.min(height, arbeitsbereich.height),
maximiert: false,
}
}
/** Liegt die gemerkte Lage sichtbar auf einem der Bildschirme? (pur) */
export function liegtSichtbar(lage: Lage, bildschirme: readonly Bereich[]): boolean {
if (lage.width < 400 || lage.height < 300) return false
return bildschirme.some((b) => {
const rechts = Math.min(lage.x + lage.width, b.x + b.width)
const links = Math.max(lage.x, b.x)
const unten = Math.min(lage.y + lage.height, b.y + b.height)
const oben = Math.max(lage.y, b.y)
// Mindestens 200 × 100 sichtbar — genug, um das Fenster anzufassen.
return rechts - links >= 200 && unten - oben >= 100
})
}
/** Gemerkt oder Start — was das Fenster beim Öffnen bekommt (pur). */
export function anfangsLage(gemerkt: Lage | null, arbeitsbereich: Bereich, bildschirme: readonly Bereich[]): Lage {
if (gemerkt !== null && liegtSichtbar(gemerkt, bildschirme)) return gemerkt
return startLage(arbeitsbereich)
}
export function lageLesen(pfad: string): Lage | null {
try {
if (!existsSync(pfad)) return null
const roh: unknown = JSON.parse(readFileSync(pfad, 'utf8'))
if (typeof roh !== 'object' || roh === null) return null
const l = roh as Record<string, unknown>
if ([l.x, l.y, l.width, l.height].some((v) => typeof v !== 'number' || !Number.isFinite(v))) return null
return { x: l.x as number, y: l.y as number, width: l.width as number, height: l.height as number, maximiert: l.maximiert === true }
} catch {
// Eine kaputte Datei heißt: Startlage. Sie wird beim nächsten Speichern ersetzt.
return null
}
}
export function lageSchreiben(pfad: string, lage: Lage): void {
try {
writeFileSync(pfad, JSON.stringify(lage))
} catch {
// Nicht schreibbar: Das Fenster öffnet beim nächsten Mal in der Startlage — kein Grund für Lärm.
}
}
+48 -4
View File
@@ -13,6 +13,7 @@ import { writeFile } from 'node:fs/promises'
import { join, win32 } from 'node:path'
import type { HauptStatus, KernNachricht } from '../gemeinsam/nachrichten'
import { werkzeugOrdner } from '../kern/werkzeuge/katalog'
import { Protokoll } from '../gemeinsam/protokoll'
import { autostartAktiv, autostartSetzen } from './autostart'
import { fensterErstellen } from './fenster'
import { KernVerwaltung } from './kernstart'
@@ -47,12 +48,19 @@ if (!app.requestSingleInstanceLock()) {
}
function start(): void {
// 5.5.0: Die Protokoll-Datei (aus ARM) — Haupt und Kern schreiben in
// denselben Ordner, eine Datei je Tag, 14 Tage aufgehoben.
const protokollOrdner = join(app.getPath('userData'), 'logs')
const protokoll = new Protokoll(protokollOrdner, 'haupt')
protokoll.aufraeumen()
protokoll.schreiben(`Rippy ${app.getVersion()} startet (Electron ${process.versions.electron}, Profil ${app.getPath('userData')})`)
// Die Leine kommt VOR dem ersten Kindprozess — Mitgliedschaft in der
// Arbeitsgruppe wird nur beim Start eines Kindes vererbt (§ 3.3).
const leine: LeineStatus = leineSetzen()
if (!leine.ok) {
// R4: laut, nicht still — zusätzlich steht es im Fenster-Status.
console.error(`[haupt] Prozess-Leine NICHT gesetzt: ${leine.fehler}`)
protokoll.schreiben(`Prozess-Leine NICHT gesetzt: ${leine.fehler}`)
}
let fenster: BrowserWindow | null = null
@@ -80,6 +88,7 @@ function start(): void {
smoke.datenbank = nachricht.datenbank.ok
if (!nachricht.datenbank.ok) {
console.error(`[haupt] Kern meldet Datenbank-Fehler: ${nachricht.datenbank.fehler}`)
protokoll.schreiben(`Kern meldet Datenbank-Fehler: ${nachricht.datenbank.fehler}`)
}
statusSenden()
smokePruefen()
@@ -93,7 +102,10 @@ function start(): void {
updateEinrichten(
nachricht.werte['updateUrl'] ?? '',
() => aktiveVorgaenge.size > 0,
(text) => toast('Rippy-Update', text),
(text) => {
protokoll.schreiben(`Update: ${text}`)
toast('Rippy-Update', text)
},
// Jede Änderung am Update-Stand geht SOFORT ans Fenster —
// vorher lag sie still im Haupt und war erst beim nächsten
// Laden zu sehen (Commander-Fund 01.09.2026).
@@ -122,7 +134,10 @@ function start(): void {
)
}
},
() => statusSenden(),
(zustand) => {
if (!zustand.laeuft) protokoll.schreiben(`Kern nicht aktiv (Neustarts: ${zustand.neustarts})`)
statusSenden()
},
smokeModus,
)
@@ -222,6 +237,7 @@ function start(): void {
// Für die eigene Titelleiste: Das Knopf-Symbol muss wissen, ob das
// Fenster gerade maximiert ist.
fensterMaximiert: fenster !== null && !fenster.isDestroyed() && fenster.isMaximized(),
protokollOrdner,
}
fenster.webContents.send('haupt-status', status)
}
@@ -250,7 +266,7 @@ function start(): void {
function fensterZeigen(): void {
if (fenster === null || fenster.isDestroyed()) {
fenster = fensterErstellen()
fenster = fensterErstellen(app.getPath('userData'))
fensterVerdrahten()
return
}
@@ -327,6 +343,33 @@ function start(): void {
return ergebnis.canceled || ergebnis.filePaths.length === 0 ? null : ergebnis.filePaths[0]
})
// Echter Windows-Dateidialog (5.5.0: Werkzeuge → Durchsuchen) — nur
// Programme, sonst nichts.
ipcMain.handle('datei-waehlen', async (_ereignis, titel: unknown) => {
if (fenster === null) return null
const ergebnis = await dialog.showOpenDialog(fenster, {
title: typeof titel === 'string' && titel.length > 0 ? titel : 'Programm wählen',
properties: ['openFile'],
filters: [
{ name: 'Programme', extensions: ['exe'] },
{ name: 'Alle Dateien', extensions: ['*'] },
],
})
return ergebnis.canceled || ergebnis.filePaths.length === 0 ? null : ergebnis.filePaths[0]
})
// Einen Ordner im Explorer zeigen (5.5.0, aus ARM) — nur absolute Pfade,
// und shell.openPath sagt selbst, wenn es ihn nicht gibt.
ipcMain.handle('ordner-oeffnen', async (_ereignis, pfad: unknown) => {
if (typeof pfad !== 'string' || !win32.isAbsolute(pfad)) return 'Kein gültiger Pfad.'
protokoll.schreiben(`Ordner geöffnet: ${pfad}`)
return await shell.openPath(pfad)
})
ipcMain.handle('protokoll-oeffnen', async () => {
return await shell.openPath(protokollOrdner)
})
app.on('second-instance', () => {
fensterZeigen()
})
@@ -339,6 +382,7 @@ function start(): void {
app.on('before-quit', () => {
beendenGewollt = true
protokoll.schreiben('Rippy beendet sich')
kernVerwaltung.beenden()
})
@@ -347,7 +391,7 @@ function start(): void {
if (!ohneNebenwirkungen) {
trayErstellen(fensterZeigen, beenden)
}
fenster = fensterErstellen()
fenster = fensterErstellen(app.getPath('userData'))
fensterVerdrahten()
})
+83 -36
View File
@@ -17,12 +17,16 @@
// * Folgen von Hand („Staffel 1, ab Folge 3") schlagen die Laufzeit-Raterei;
// TMDb gibt dann nur noch eine Warnung.
// * Durchgehender Fortschritt, nach Dateigröße gewichtet.
// * 5.5.0 Arbeitsordner: Liegt die Ablage auf dem Server, rechnet HandBrake
// LOKAL (Arbeitsordner\fertig\<Vorgang>) und erst das Ergebnis wandert
// über das Netz — kein Encode gegen eine SMB-Freigabe.
import {
copyFileSync,
existsSync,
mkdirSync,
readdirSync,
renameSync,
rmSync,
statSync,
unlinkSync,
writeFileSync,
@@ -50,6 +54,9 @@ export interface AblageUmgebung {
handbrake: () => string
/** Wurzel der Ablage. */
ablage: () => string
/** 5.5.0: eigener lokaler Arbeitsordner — '' wenn keiner (dann wird
* direkt in die Ablage gerechnet, wie bisher). */
arbeitsordner?: () => string
einstellungen: () => Einstellungen
/** Gemessene Preset-Empfehlung (Hardware zuerst) für diesen Disc-Typ. */
presetEmpfehlung?: (discTyp: string) => string
@@ -60,7 +67,7 @@ export interface AblageUmgebung {
) => Promise<ReadonlyArray<readonly [number, number]>>
bibliothekEintragen?: (eintrag: BibliothekEintrag) => void
jetzt?: () => Date
/** Roh-Datei ins Ziel verschieben — injizierbar für Tests. */
/** Datei verschieben — injizierbar für Tests. */
verschieben?: (von: string, nach: string) => void
/** Spuren der Roh-Datei messen — injizierbar (Standard: HandBrake-Scan). */
spurenScannen?: (programm: string, datei: string) => Promise<HbSpuren | null>
@@ -75,6 +82,9 @@ export interface AblageErgebnis {
preset: string
fertigBytes: number
hinweise: string[]
/** 5.5.0: Welche Folgen-Nummern vergeben wurden (leer bei Filmen oder ohne
* Zuordnung) — der Serien-Merker zählt daran weiter. */
folgenVergeben: number[]
}
export type AblageMeldung = (stand: Pick<KompressionStand, 'phase' | 'prozent' | 'text'>) => void
@@ -119,6 +129,17 @@ export async function komprimierenUndAblegen(
const zuordnung = auftrag.zuordnung
const hinweise: string[] = [...auftrag.hinweise]
const dateien = [...auftrag.dateien].sort((a, b) => a.pfad.localeCompare(b.pfad))
const fehlschlag = (text: string, statusPreset: string, ist: boolean): AblageErgebnis => ({
status: 'fehler',
zielOrdner: fertigOrdner,
dateien: [],
text,
komprimiert: ist,
preset: statusPreset,
fertigBytes: 0,
hinweise,
folgenVergeben: [],
})
// ── WOHIN? (§ 4.2) ──────────────────────────────────────────────────
// Serien gehören NICHT unter Filme/ — sonst sieht Jellyfin lauter
@@ -135,7 +156,11 @@ export async function komprimierenUndAblegen(
: zuordnung !== null && zuordnung.titel.length > 0
? zielordner(join(umgebung.ablage(), 'Filme'), zuordnung.titel, zuordnung.jahr, auftrag.vorgangId)
: zielordner(join(umgebung.ablage(), 'Filme'), auftrag.vorgangId, null, auftrag.vorgangId)
mkdirSync(fertigOrdner, { recursive: true })
try {
mkdirSync(fertigOrdner, { recursive: true })
} catch (fehler) {
return fehlschlag(`Zielordner nicht anlegbar (${fertigOrdner}): ${String(fehler)} — ist die Ablage erreichbar?`, '', komprimiert)
}
// Extras liegen im Unterordner „extras" NEBEN dem Hauptfilm — so lesen
// Jellyfin, Emby und Kodi sie als Zugaben und nicht als zweiten Film.
@@ -143,6 +168,12 @@ export async function komprimierenUndAblegen(
const zielFuer = (rolle: string): string => (rolle === 'extra' ? extrasOrdner : fertigOrdner)
if (dateien.some((d) => d.rolle === 'extra')) mkdirSync(extrasOrdner, { recursive: true })
// 5.5.0: Eigener Arbeitsordner → HandBrake schreibt LOKAL, danach wird
// verschoben. Ohne ihn wie bisher direkt ins Ziel.
const arbeit = (umgebung.arbeitsordner?.() ?? '').trim()
const zwischen = komprimiert && arbeit.length > 0 ? join(arbeit, 'fertig', auftrag.vorgangId) : ''
if (zwischen.length > 0) mkdirSync(zwischen, { recursive: true })
const global = {
audio: sprachliste(String(einstellungen['audioSprachen'] ?? '')),
untertitel: sprachliste(String(einstellungen['untertitelSprachen'] ?? '')),
@@ -152,15 +183,15 @@ export async function komprimierenUndAblegen(
const verschieben = umgebung.verschieben ?? dateiVerschieben
const fertigeDateien: string[] = []
/** Lokal gerechnet, noch nicht im Ziel: [von, nach]. */
const nachzuziehen: Array<[string, string]> = []
// Laufzeiten der FOLGEN in Ablage-Reihenfolge — Grundlage der
// Episoden-Zuordnung weiter unten.
const folgenSekunden: number[] = []
for (let i = 0; i < dateien.length; i++) {
const datei = dateien[i]
const ausgabe = join(zielFuer(datei.rolle), basename(datei.pfad))
if (abbruch.aborted) {
return abgebrochen(fertigOrdner, fertigeDateien, preset, komprimiert, hinweise)
}
const zielDatei = join(zielFuer(datei.rolle), basename(datei.pfad))
if (abbruch.aborted) return abgebrochen(fertigOrdner, fertigeDateien, preset, komprimiert, hinweise)
if (!komprimiert) {
melden({
phase: 'legt-ab',
@@ -168,20 +199,15 @@ export async function komprimierenUndAblegen(
text: `Verschiebe ${i + 1}/${dateien.length}: ${basename(datei.pfad)} (ohne Kompression) …`,
})
try {
verschieben(datei.pfad, ausgabe)
verschieben(datei.pfad, zielDatei)
} catch (fehler) {
return {
status: 'fehler',
zielOrdner: fertigOrdner,
dateien: fertigeDateien,
text: `Verschieben fehlgeschlagen: ${String(fehler)} — die Roh-Dateien bleiben stehen (${auftrag.rohOrdner}).`,
komprimiert: false,
preset: '',
fertigBytes: 0,
hinweise,
}
return fehlschlag(
`Verschieben fehlgeschlagen: ${String(fehler)} — die Roh-Dateien bleiben stehen (${auftrag.rohOrdner}).`,
'',
false,
)
}
fertigeDateien.push(ausgabe)
fertigeDateien.push(zielDatei)
if (datei.rolle === 'folge') folgenSekunden.push(datei.dauerS)
continue
}
@@ -200,15 +226,14 @@ export async function komprimierenUndAblegen(
)
}
if (fehlt.untertitel.length > 0) {
hinweise.push(
`${basename(datei.pfad)}: Untertitel ${fehlt.untertitel.join(', ')} sind NICHT in der Roh-Datei.`,
)
hinweise.push(`${basename(datei.pfad)}: Untertitel ${fehlt.untertitel.join(', ')} sind NICHT in der Roh-Datei.`)
}
} else {
hinweise.push(`${basename(datei.pfad)}: Spuren der Roh-Datei ließen sich nicht messen — komprimiert wie gewünscht.`)
}
}
const ausgabe = zwischen.length > 0 ? join(zwischen, basename(datei.pfad)) : zielDatei
melden({
phase: 'komprimiert',
prozent: gewichteterFortschritt(gewichte, i, 0),
@@ -233,25 +258,44 @@ export async function komprimierenUndAblegen(
})
},
})
if (encode.status === 'cancelled') {
return abgebrochen(fertigOrdner, fertigeDateien, preset, komprimiert, hinweise)
}
if (encode.status === 'cancelled') return abgebrochen(fertigOrdner, fertigeDateien, preset, komprimiert, hinweise)
if (encode.status === 'error') {
return {
status: 'fehler',
zielOrdner: fertigOrdner,
dateien: fertigeDateien,
text: `Kompression fehlgeschlagen: ${encode.fehler} — der verlustfreie Rip bleibt stehen (${auftrag.rohOrdner}).`,
komprimiert: true,
return fehlschlag(
`Kompression fehlgeschlagen: ${encode.fehler} — der verlustfreie Rip bleibt stehen (${auftrag.rohOrdner}).`,
preset,
fertigBytes: 0,
hinweise,
}
true,
)
}
fertigeDateien.push(encode.ausgabe)
if (zwischen.length > 0) nachzuziehen.push([encode.ausgabe, zielDatei])
fertigeDateien.push(zielDatei)
if (datei.rolle === 'folge') folgenSekunden.push(datei.dauerS)
}
// 5.5.0: Lokal Gerechnetes auf die Ablage bringen — erst jetzt, da alle
// Encodes gelungen sind. Ein Netzfehler hier kostet keinen Encode: Die
// Dateien liegen im Arbeitsordner und der Vorgang sagt, wo.
for (let i = 0; i < nachzuziehen.length; i++) {
const [von, nach] = nachzuziehen[i]
if (abbruch.aborted) return abgebrochen(fertigOrdner, [], preset, komprimiert, hinweise)
melden({ phase: 'legt-ab', prozent: 100, text: `Verschiebe auf die Ablage ${i + 1}/${nachzuziehen.length}: ${basename(nach)}` })
try {
verschieben(von, nach)
} catch (fehler) {
return fehlschlag(
`Verschieben auf die Ablage fehlgeschlagen: ${String(fehler)} — die fertigen Dateien liegen unter ${zwischen}, die Roh-Dateien unter ${auftrag.rohOrdner}.`,
preset,
true,
)
}
}
if (zwischen.length > 0) {
try {
rmSync(zwischen, { recursive: true, force: true })
} catch {
// Ein leerer Zwischenordner, der stehen bleibt, stört nicht.
}
}
// ── Wiederholen: die alte Fassung weicht der neuen — JETZT erst, da alle
// neuen Dateien da sind. Vorher hätte ein gescheiterter Lauf die
// erste Fassung gekostet; danach stünde sie bei Serien der
@@ -271,10 +315,10 @@ export async function komprimierenUndAblegen(
// Zuordnung; TMDb darf dann nur noch warnen. Ohne Vorgabe wird
// umbenannt NUR bei EINDEUTIGER Zuordnung — lieber gar nicht als falsch
// (die offene Wunde #395 von ARM war genau dieses Ratespiel).
let folgenVergeben: number[] = []
if (alsSerie && folgenSekunden.length > 0) {
const tmdbId = typeof zuordnung?.meta.id === 'number' ? zuordnung.meta.id : null
const laufzeiten =
tmdbId !== null ? ((await umgebung.episodenLaufzeiten?.(tmdbId, staffel)) ?? []) : []
const laufzeiten = tmdbId !== null ? ((await umgebung.episodenLaufzeiten?.(tmdbId, staffel)) ?? []) : []
let episoden: number[] | null = null
if (auftrag.ersteFolge !== null && auftrag.ersteFolge > 0) {
episoden = folgenNummern(folgenSekunden.length, auftrag.ersteFolge)
@@ -285,6 +329,7 @@ export async function komprimierenUndAblegen(
}
if (episoden !== null) {
hinweise.push(...episodenUmbenennen(fertigOrdner, serienTitel, staffel, episoden))
folgenVergeben = episoden
} else {
hinweise.push(
laufzeiten.length === 0
@@ -368,6 +413,7 @@ export async function komprimierenUndAblegen(
preset: komprimiert ? preset : '',
fertigBytes,
hinweise,
folgenVergeben,
}
}
@@ -387,6 +433,7 @@ function abgebrochen(
preset,
fertigBytes: 0,
hinweise,
folgenVergeben: [],
}
}
+143 -32
View File
@@ -12,6 +12,10 @@
// (speicher/db.ts, Tabelle vorgaenge): So weiß Rippy hinterher, welcher
// Roh-Ordner zu welchem Film gehört — und kann aufräumen, wiederholen und
// nach einem Abbruch nachsehen.
//
// 5.5.0: das Rettungs-Abbild (rip/rettung.ts) für Discs, an denen Rippy
// Lesefehler GESEHEN hat — erst das Abbild mit Nullen, dann derselbe Rip
// aus dem Abbild (iso:), das nicht mehr hängen kann.
import { mkdirSync, readdirSync, rmSync, statSync, unlinkSync } from 'node:fs'
// Plattform-Pfade wie in struktur.ts: Laufzeit-Wurzeln, CI testet auf Linux.
import { join } from 'node:path'
@@ -29,6 +33,7 @@ import { abfragen, albumOrdner, kennungAusSpuren, tagsJeSpur } from '../metadate
import { dateiname, flacBefehl, sauberName, wavKopf, SEKTOR_BYTES, type Spur } from '../rip/audiocd'
import { isoSichern } from '../rip/iso'
import { rippen } from '../rip/makemkv'
import { rettungsAbbildDatei } from '../rip/rettung'
import { werkzeugSpawn } from '../werkzeuge/aufruf'
import { mkdtempSync, createWriteStream } from 'node:fs'
import { tmpdir } from 'node:os'
@@ -74,8 +79,10 @@ export interface PipelineUmgebung {
) => number
/** Aktueller Stand des Laufwerks (für Typ und Vorab-Blick). */
geraet: (id: string) => GeraeteInfo | null
/** Wurzel der Ablage (Roh-Ordner und fertige Filme liegen darunter). */
/** Wurzel der Ablage (fertige Filme, Musik, ISO liegen darunter). */
ablage: () => string
/** 5.5.0: Wo die Roh-Ordner liegen (Arbeitsordner\roh). Fehlt: Ablage\roh. */
rohWurzel?: () => string
einstellungen: () => Einstellungen
melden: (status: RipStatus) => void
/** Die letzte Metadaten-Zuordnung dieses Laufwerks (W-4) — oder null. */
@@ -99,10 +106,22 @@ export interface PipelineUmgebung {
/** 5.4.0: Nach dem Rip auswerfen (Einstellung autoAuswurf) die nächste
* Disc soll rein, während die Kompression läuft. */
nachRipAuswerfen?: (id: string) => void
/** 5.5.0: Das Rettungs-Abbild lesen — injizierbar für Tests. */
rettungsAbbild?: typeof rettungsAbbildDatei
}
/** Der letzte Auftrag je Laufwerk — die Rettung wiederholt genau ihn. */
interface LetzterAuftrag {
auftrag: RipAuftragTitel[]
optionen: RipAuftragOptionen
}
export class Pipeline {
private laufend = new Map<string, LaufenderVorgang>()
/** 5.5.0: Rippy hat an diesem Laufwerk Lesefehler GESEHEN nur dann
* wird das Rettungs-Abbild angeboten (Commander: nicht bei heilen Discs). */
private fehlerGesehen = new Map<string, string>()
private letzterAuftrag = new Map<string, LetzterAuftrag>()
constructor(private readonly umgebung: PipelineUmgebung) {}
@@ -110,10 +129,22 @@ export class Pipeline {
return this.laufend.has(id)
}
/** Warum eine Rettung angeboten wird — '' wenn keine Fehler gesehen wurden. */
rettungGrund(id: string): string {
return this.fehlerGesehen.get(id) ?? ''
}
/** Beim Entnehmen der Disc: Was gesehen wurde, gilt nicht mehr. */
discEntnommen(id: string): void {
this.fehlerGesehen.delete(id)
this.letzterAuftrag.delete(id)
}
private melden(status: RipStatus): void {
const eintrag = this.laufend.get(status.id)
if (eintrag !== undefined) eintrag.status = status
this.umgebung.melden(status)
const mitRettung: RipStatus = { ...status, rettungMoeglich: this.fehlerGesehen.has(status.id) }
if (eintrag !== undefined) eintrag.status = mitRettung
this.umgebung.melden(mitRettung)
}
private fehler(id: string, text: string): RipStatus {
@@ -131,11 +162,26 @@ export class Pipeline {
vorgang.titelAbbruch.abort()
}
/** 5.5.0: Rettungs-Abbild + Rip daraus mit dem letzten Auftrag dieses
* Laufwerks. Nur, wenn Rippy vorher Lesefehler gesehen hat. */
async rettungStarten(id: string): Promise<void> {
const grund = this.fehlerGesehen.get(id)
if (grund === undefined) {
this.umgebung.melden(
this.fehler(id, 'Rettungs-Abbild nur bei GESEHENEN Lesefehlern — an dieser Disc hat Rippy keine bemerkt. Erst normal rippen.'),
)
return
}
const letzter = this.letzterAuftrag.get(id)
await this.starten(id, letzter?.auftrag ?? [], letzter?.optionen ?? {}, true)
}
/** Startet die Kette. `auftrag` leer heißt: alles rippen (wie früher). */
async starten(
id: string,
auftrag: readonly RipAuftragTitel[] = [],
optionen: RipAuftragOptionen = {},
rettung = false,
): Promise<void> {
if (this.laufend.has(id)) {
this.umgebung.melden(this.fehler(id, `Auf Laufwerk ${id}: läuft schon ein Vorgang.`))
@@ -190,7 +236,8 @@ export class Pipeline {
return
}
const rohOrdner = join(this.umgebung.ablage(), 'roh', vorgangsName)
this.letzterAuftrag.set(id, { auftrag: [...auftrag], optionen: { ...optionen } })
const rohOrdner = join(this.umgebung.rohWurzel?.() ?? join(this.umgebung.ablage(), 'roh'), vorgangsName)
mkdirSync(rohOrdner, { recursive: true })
const zuordnung = this.umgebung.zuordnung?.(id) ?? null
const anzeigeTitel = zuordnung !== null && zuordnung.titel.length > 0 ? zuordnung.titel : vorgangsName
@@ -204,25 +251,9 @@ export class Pipeline {
begonnen: startZeit.toISOString(),
})
// TITEL FÜR TITEL statt einem 'all'-Aufruf (Commander-Wunsch
// 01.09.2026). Zwei Gründe, und beide brauchen denselben Umbau:
// * Auswählen geht nur so — vorher rippte Rippy IMMER alles, samt
// Trailer, Werbung und Alterskennzeichen.
// * Ein Titel, der an einem Disc-Defekt hängen bleibt, reißt nicht
// mehr den ganzen Auftrag mit. Bei der Spartacus-Disc lag der
// Defekt in Folge 1 — Folge 2 war heil und ging trotzdem verloren.
const laeufe = auftrag.length > 0 ? auftrag.map((a) => String(a.nr)) : ['all']
// Durchgehender Balken (5.4.0): nach der Größe der Titel gewichtet —
// vorher sprang er je Titel zurück auf null.
const gewichte = auftrag.length > 0 ? auftrag.map((a) => a.groesseBytes ?? 0) : [1]
// Welche Rolle bekam welche Datei? Die Zuordnung ist EXAKT, weil je
// Lauf genau ein Titel dazukommt — nicht aus Dateinamen geraten.
const auftragJeDatei = new Map<string, RipAuftragTitel | undefined>()
const alleDateien = new Set<string>()
let lesefehlerGesamt = false
const gescheitert: string[] = []
const uebersprungen: string[] = []
const aufgeraeumt: string[] = []
const hinweise: string[] = []
this.laufend.set(id, {
abbruch,
@@ -252,6 +283,81 @@ export class Pipeline {
})
}
// ── 5.5.0: Rettungs-Abbild — die Quelle des Rips wird die ISO ────────
let quellePfad = geraet.pfad
if (rettung) {
const isoPfad = join(rohOrdner, 'rettung.iso')
this.melden({
id,
phase: 'rettet',
prozent: 0,
text: `Rettungs-Abbild: lese die Disc Sektor für Sektor nach ${isoPfad} — Unlesbares wird mit Nullen gefüllt …`,
dateien: [],
lesefehler: false,
vorgangId: vorgangsName,
rettung: { unlesbareSektoren: 0, stellen: 0 },
})
const lesen = this.umgebung.rettungsAbbild ?? rettungsAbbildDatei
const ergebnis = await lesen(
geraet.pfad,
isoPfad,
geraet.groesseBytes,
(prozent, unlesbar, stellen) => {
const alt = this.laufend.get(id)?.status
if (alt !== undefined) {
this.melden({
...alt,
phase: 'rettet',
prozent,
text: `Rettungs-Abbild: ${prozent} % — ${unlesbar} unlesbare Sektoren an ${stellen} Stellen (mit Nullen gefüllt)`,
rettung: { unlesbareSektoren: unlesbar, stellen },
})
}
},
abbruch.signal,
)
if (ergebnis.status === 'cancelled') {
beenden('abgebrochen', 'Rettungs-Abbild abgebrochen.')
return
}
if (ergebnis.status === 'error') {
beenden('fehler', `Rettungs-Abbild gescheitert: ${ergebnis.fehler}`)
return
}
hinweise.push(
`Rettungs-Abbild: ${ergebnis.unlesbareSektoren} unlesbare Sektoren an ${ergebnis.stellen.length} Stellen mit Nullen gefüllt (Bericht: rettung.iso.rettung.txt).`,
)
quellePfad = isoPfad
this.melden({
id,
phase: 'rippt',
prozent: -1,
text: `Rettungs-Abbild fertig (${ergebnis.unlesbareSektoren} Sektoren genullt) — rippe jetzt aus dem Abbild …`,
dateien: [],
lesefehler: false,
vorgangId: vorgangsName,
rettung: { unlesbareSektoren: ergebnis.unlesbareSektoren, stellen: ergebnis.stellen.length },
})
}
// TITEL FÜR TITEL statt einem 'all'-Aufruf (Commander-Wunsch
// 01.09.2026). Zwei Gründe, und beide brauchen denselben Umbau:
// * Auswählen geht nur so — vorher rippte Rippy IMMER alles, samt
// Trailer, Werbung und Alterskennzeichen.
// * Ein Titel, der an einem Disc-Defekt hängen bleibt, reißt nicht
// mehr den ganzen Auftrag mit. Bei der Spartacus-Disc lag der
// Defekt in Folge 1 — Folge 2 war heil und ging trotzdem verloren.
const laeufe = auftrag.length > 0 ? auftrag.map((a) => String(a.nr)) : ['all']
// Durchgehender Balken (5.4.0): nach der Größe der Titel gewichtet —
// vorher sprang er je Titel zurück auf null.
const gewichte = auftrag.length > 0 ? auftrag.map((a) => a.groesseBytes ?? 0) : [1]
// Welche Rolle bekam welche Datei? Die Zuordnung ist EXAKT, weil je
// Lauf genau ein Titel dazukommt — nicht aus Dateinamen geraten.
const auftragJeDatei = new Map<string, RipAuftragTitel | undefined>()
const gescheitert: string[] = []
const uebersprungen: string[] = []
const aufgeraeumt: string[] = []
for (let i = 0; i < laeufe.length; i++) {
const welcher = laeufe[i]
// Eigener Abbruch je Titel: „überspringen" beendet NUR diesen Lauf,
@@ -271,7 +377,7 @@ export class Pipeline {
id,
phase: 'rippt',
prozent: gewichteterFortschritt(gewichte, i, -1),
text: `Rippe ${beschriftung} verlustfrei nach ${rohOrdner}`,
text: `Rippe ${beschriftung} verlustfrei nach ${rohOrdner}${rettung ? ' (aus dem Rettungs-Abbild)' : ''}`,
dateien: [...alleDateien],
lesefehler: lesefehlerGesamt,
titelNr: i + 1,
@@ -285,7 +391,7 @@ export class Pipeline {
const vorher = dateienIn(rohOrdner)
const rip = await rippen({
programm,
geraetepfad: geraet.pfad,
geraetepfad: quellePfad,
zielOrdner: rohOrdner,
titel: welcher,
abbruch: titelAbbruch.signal,
@@ -298,6 +404,9 @@ export class Pipeline {
if (alt !== undefined) this.melden({ ...alt, text })
},
aufHaengt: (klemmt) => {
// Der Defekt-Wächter hat angeschlagen: Ab jetzt ist die Rettung
// eine Option (5.5.0).
if (klemmt !== null && !rettung) this.fehlerGesehen.set(id, `Defekt-Wächter: hängt an Byte ${klemmt.offset}`)
const alt = this.laufend.get(id)?.status
if (alt !== undefined) this.melden({ ...alt, haengt: klemmt })
},
@@ -305,7 +414,7 @@ export class Pipeline {
abbruch.signal.removeEventListener('abort', weiterreichen)
if (rip.status === 'cancelled') {
aufgeraeumt.push(...halbeDateienEntfernen(rohOrdner, vorher))
aufgeraeumt.push(...halbeDateienEntfernen(rohOrdner, vorher, rettung ? ['rettung.iso', 'rettung.iso.rettung.txt'] : []))
// Nur dieser Titel — oder der ganze Auftrag?
if (this.laufend.get(id)?.uebersprungen !== true) {
beenden(
@@ -321,18 +430,20 @@ export class Pipeline {
}
lesefehlerGesamt = lesefehlerGesamt || rip.lesefehler
if (rip.lesefehler && !rettung) this.fehlerGesehen.set(id, 'MakeMKV meldete Lesefehler')
if (rip.status === 'error') {
// EIN kaputter Titel beendet den Auftrag nicht — die anderen sind
// vielleicht heil. Der Grund wird gesammelt und am Ende genannt.
// Was dieser Lauf hinterlassen hat, ist nicht vertrauenswürdig.
aufgeraeumt.push(...halbeDateienEntfernen(rohOrdner, vorher))
if (!rettung) this.fehlerGesehen.set(id, `Titel gescheitert: ${rip.fehler.slice(0, 80)}`)
aufgeraeumt.push(...halbeDateienEntfernen(rohOrdner, vorher, rettung ? ['rettung.iso', 'rettung.iso.rettung.txt'] : []))
gescheitert.push(`${beschriftung}: ${rip.fehler}`)
continue
}
// rippen() liefert den ganzen Zielordner zurück, also auch die
// Dateien der vorigen Titel — neu ist, was noch nicht bekannt war.
const neu = rip.dateien.filter((d) => !alleDateien.has(d))
for (const d of rip.dateien) alleDateien.add(d)
const neu = rip.dateien.filter((d) => !alleDateien.has(d) && d.toLowerCase().endsWith('.mkv'))
for (const d of rip.dateien) if (d.toLowerCase().endsWith('.mkv')) alleDateien.add(d)
for (const d of neu) auftragJeDatei.set(d, auftrag[i])
if (neu.length === 0) {
// MakeMKV kann mit Code 0 enden und trotzdem nichts abgelegt haben
@@ -349,7 +460,6 @@ export class Pipeline {
return
}
const hinweise: string[] = []
if (uebersprungen.length > 0) hinweise.push(`Übersprungen: ${uebersprungen.join(', ')}.`)
if (gescheitert.length > 0) hinweise.push(`Nicht gesichert: ${gescheitert.join(' · ')}`)
if (aufgeraeumt.length > 0) hinweise.push(`Halbe Dateien entfernt: ${aufgeraeumt.join(', ')}.`)
@@ -397,7 +507,7 @@ export class Pipeline {
phase: 'fertig',
prozent: 100,
text:
`Verlustfrei gesichert: ${dateien.length} ${dateien.length === 1 ? 'Titel' : 'Titel'}, ${(rohBytes / 1e9).toFixed(2).replace('.', ',')} GB — ` +
`Verlustfrei gesichert: ${dateien.length} Titel, ${(rohBytes / 1e9).toFixed(2).replace('.', ',')} GB — ` +
(platz > 0
? `Kompression eingereiht${platz > 1 ? ` (Platz ${platz})` : ''}, das Laufwerk ist frei.`
: 'Kompression nicht eingereiht (kein Empfänger) — die Roh-Dateien liegen unter ' + rohOrdner + '.') +
@@ -587,11 +697,12 @@ function dateienIn(ordner: string): Set<string> {
}
/** Entfernt, was seit `vorher` im Ordner dazukam die halbe Datei eines
* abgebrochenen oder gescheiterten Titels. Liefert die Namen. */
export function halbeDateienEntfernen(ordner: string, vorher: ReadonlySet<string>): string[] {
* abgebrochenen oder gescheiterten Titels. `schonen` bleibt immer liegen
* (das Rettungs-Abbild). Liefert die Namen. */
export function halbeDateienEntfernen(ordner: string, vorher: ReadonlySet<string>, schonen: readonly string[] = []): string[] {
const entfernt: string[] = []
for (const name of dateienIn(ordner)) {
if (vorher.has(name)) continue
if (vorher.has(name) || schonen.includes(name)) continue
try {
unlinkSync(join(ordner, name))
entfernt.push(name)
+19 -2
View File
@@ -6,6 +6,9 @@
// EINE zur Zeit, denn zwei HandBrakes teilen sich dieselbe Grafikkarte und
// werden zusammen nicht schneller.
//
// 5.5.0: Pause (Commander: „zum Beispiel beim Spielen") — der laufende
// Film wird fertig gerechnet, der nächste wartet, bis es weitergeht.
//
// PUR: Was ausgeführt wird, kommt injiziert (ablegen.ts) — die Ampel
// prüft das Verhalten der Schlange auf Linux ohne HandBrake.
import type { KompressionStand } from '../../gemeinsam/nachrichten'
@@ -34,6 +37,7 @@ export class KompressionsWarteschlange {
private laufend: Eintrag | null = null
private laufendStand: KompressionStand | null = null
private arbeitet = false
private pausiert = false
constructor(
private readonly ausfuehren: Ausfuehren,
@@ -71,6 +75,7 @@ export class KompressionsWarteschlange {
preset: '',
fertigBytes: 0,
hinweise: [],
folgenVergeben: [],
})
return true
}
@@ -81,6 +86,17 @@ export class KompressionsWarteschlange {
return false
}
/** Pause: Der laufende Auftrag wird fertig, der nächste startet erst nach
* `pausieren(false)`. */
pausieren(an: boolean): void {
this.pausiert = an
if (!an) void this.schleife()
}
istPausiert(): boolean {
return this.pausiert
}
stand(): WarteschlangenStand {
return {
laeuft: this.laufendStand === null ? null : { ...this.laufendStand },
@@ -88,7 +104,7 @@ export class KompressionsWarteschlange {
vorgangId: e.auftrag.vorgangId,
titel: e.titel,
prozent: -1,
text: 'wartet',
text: this.pausiert ? 'wartet (Pause)' : 'wartet',
phase: 'wartet',
})),
}
@@ -107,7 +123,7 @@ export class KompressionsWarteschlange {
if (this.arbeitet) return
this.arbeitet = true
try {
while (this.wartend.length > 0) {
while (this.wartend.length > 0 && !this.pausiert) {
const eintrag = this.wartend.shift()!
this.laufend = eintrag
this.laufendStand = {
@@ -141,6 +157,7 @@ export class KompressionsWarteschlange {
preset: '',
fertigBytes: 0,
hinweise: [],
folgenVergeben: [],
}
}
this.laufend = null
+178
View File
@@ -0,0 +1,178 @@
// Die Automatik (5.5.0, aus ARM: Auto-Modus; KONZEPT § 5 Schritt 4:
// „sicher genug → sofort losrippen, nur Bescheid sagen — Zero-Click").
//
// Ablauf: Disc rein → Erkennung sicher (≥ 90 %) und nicht schon gerippt →
// Rippy liest die Titel von selbst → ist die Vorauswahl eindeutig, läuft
// ein Countdown (Vorgabe 30 s, Einstellung automatikSekunden) → dann der
// Rip mit Rippys Vorschlag. Ein Klick auf „Stopp" oder das Öffnen des
// Dialogs beendet den Countdown; „Jetzt" überspringt ihn. Wo Rippy unsicher
// ist, passiert NICHTS von selbst — es sagt nur, warum.
//
// PUR bis auf die Uhr: Die Abhängigkeiten kommen injiziert, die Ampel
// prüft den Ablauf mit einer Fake-Uhr.
import type { AutomatikStand, GeraeteInfo, RipAuftragOptionen, RipAuftragTitel, TitelStand } from '../../gemeinsam/nachrichten'
import type { KernKontext } from '../kontext'
import { ABGELOEST } from '../rip/makemkv'
import type { Zuordnung } from '../metadaten/zuordnung'
export const AUTOMATIK_SICHER = 0.9
export interface AutomatikAbhaengigkeiten {
an(): boolean
sekunden(): number
ripLaeuft(id: string): boolean
titelLesen(id: string): Promise<TitelStand>
ripStarten(id: string, auftrag: RipAuftragTitel[], optionen: RipAuftragOptionen): void
discAngaben(id: string): { staffel: number | null; discNr: number | null } | null
serienMerker(zuordnung: Zuordnung): { staffel: number; naechsteFolge: number } | null
benachrichtigen(titel: string, text: string): void
/** Uhr — injizierbar. */
takt?: (fn: () => void, ms: number) => NodeJS.Timeout
taktStoppen?: (zeiger: NodeJS.Timeout) => void
}
/** Rippys Vorschlag als Rip-Auftrag (pur): die vorausgewählten Titel mit
* ihren Rollen; Sprachen leer = die globale Einstellung gilt. */
export function auftragAusVorschlag(stand: TitelStand): RipAuftragTitel[] {
return stand.titel
.filter((t) => t.gewaehlt)
.map((t) => ({ nr: t.nr, rolle: t.rolle, dauerS: t.dauerS, groesseBytes: t.groesseBytes }))
}
/** Staffel und erste Folge für den Automatik-Rip (pur): Disc-Angabe vor
* Merker; ab Folge" nur vom Merker, und nur, wenn die Disc nicht die
* erste ihrer Ausgabe ist (sonst würde Disc 1 bei Folge 3 anfangen). */
export function optionenAus(
angaben: { staffel: number | null; discNr: number | null } | null,
merker: { staffel: number; naechsteFolge: number } | null,
): RipAuftragOptionen {
const staffel = angaben?.staffel ?? merker?.staffel ?? null
const ersteFolge = merker !== null && (angaben?.discNr === null || angaben === null || angaben.discNr > 1) ? merker.naechsteFolge : null
return { staffel, ersteFolge }
}
export class AutomatikDienst {
private zeiger = new Map<string, NodeJS.Timeout>()
private staende = new Map<string, AutomatikStand>()
constructor(
private readonly kontext: KernKontext,
private readonly abh: AutomatikAbhaengigkeiten,
) {}
stand(id: string): AutomatikStand | null {
return this.staende.get(id) ?? null
}
private melden(id: string, aktiv: boolean, sekunden: number, text: string): void {
const stand = { id, aktiv, sekunden, text }
this.staende.set(id, stand)
this.kontext.anFenster({ art: 'automatik', stand })
}
/** Disc rein, Erkennung fertig — soll die Automatik übernehmen? */
async pruefen(geraet: GeraeteInfo, zuordnung: Zuordnung | null, schonGerippt: string | null): Promise<void> {
const id = geraet.id
if (!this.abh.an()) return
if (geraet.typ === 'cd' || geraet.typ === 'unknown') return
if (this.abh.ripLaeuft(id)) return
if (zuordnung === null || zuordnung.confidence < AUTOMATIK_SICHER) {
this.melden(id, false, 0, 'Automatik wartet: Die Erkennung ist nicht sicher genug — bitte selbst starten oder „Ändern …".')
return
}
if (schonGerippt !== null) {
this.melden(id, false, 0, `Automatik wartet: Diese Disc wurde schon gerippt (${schonGerippt.slice(0, 10)}).`)
return
}
this.melden(id, false, 0, 'Automatik: liest die Titel …')
const titel = await this.abh.titelLesen(id)
if (titel.fehler === ABGELOEST) return
if (this.abh.ripLaeuft(id)) return
if (titel.fehler.length > 0) {
this.melden(id, false, 0, `Automatik wartet: ${titel.fehler}`)
return
}
if (titel.unsicher || !titel.titel.some((t) => t.gewaehlt)) {
this.melden(id, false, 0, `Automatik wartet: ${titel.einordnung}`)
return
}
this.countdown(id, titel)
}
private countdown(id: string, titel: TitelStand): void {
this.stopp(id, '')
let rest = this.abh.sekunden()
const n = titel.titel.filter((t) => t.gewaehlt).length
const text = (s: number): string => `Automatik: Rip von ${n} ${n === 1 ? 'Titel' : 'Titeln'} startet in ${s} s — Rippys Vorschlag.`
this.melden(id, true, rest, text(rest))
const takt = this.abh.takt ?? ((fn, ms) => setInterval(fn, ms))
const zeiger = takt(() => {
rest -= 1
if (rest > 0) {
this.melden(id, true, rest, text(rest))
return
}
this.stopp(id, '')
this.starten(id, titel)
}, 1000)
this.zeiger.set(id, zeiger)
}
private starten(id: string, titel: TitelStand): void {
if (this.abh.ripLaeuft(id)) return
const auftrag = auftragAusVorschlag(titel)
if (auftrag.length === 0) return
const zuordnungTitel = titel.titel.some((t) => t.gewaehlt && t.rolle === 'folge')
const optionen = optionenAus(this.abh.discAngaben(id), zuordnungTitel ? this.merkerFuer(id) : null)
this.melden(id, false, 0, `Automatik: Rip gestartet (${auftrag.length} ${auftrag.length === 1 ? 'Titel' : 'Titel'}).`)
this.kontext.hinweis(`Automatik: Rip auf ${id} gestartet — Rippys Vorschlag, ${auftrag.length} Titel.`)
this.abh.benachrichtigen('Rippy rippt', `Automatik hat auf Laufwerk ${id} gestartet: ${auftrag.length} Titel.`)
this.abh.ripStarten(id, auftrag, optionen)
}
private merkerZuordnung = new Map<string, Zuordnung>()
/** Die Zuordnung merken, damit der Serien-Merker beim Start gefragt werden kann. */
zuordnungMerken(id: string, zuordnung: Zuordnung | null): void {
if (zuordnung === null) this.merkerZuordnung.delete(id)
else this.merkerZuordnung.set(id, zuordnung)
}
private merkerFuer(id: string): { staffel: number; naechsteFolge: number } | null {
const z = this.merkerZuordnung.get(id)
return z === undefined ? null : this.abh.serienMerker(z)
}
/** Countdown beenden — vom Nutzer (Grund wird gesagt) oder intern (leer). */
stopp(id: string, grund: string): void {
const zeiger = this.zeiger.get(id)
if (zeiger !== undefined) {
;(this.abh.taktStoppen ?? ((z) => clearInterval(z)))(zeiger)
this.zeiger.delete(id)
}
if (grund.length > 0) this.melden(id, false, 0, grund)
}
/** „Jetzt": den Countdown überspringen. */
jetzt(id: string): void {
const stand = this.staende.get(id)
if (stand === undefined || !stand.aktiv) return
this.stopp(id, '')
const titel = this.letzteTitel.get(id)
if (titel !== undefined) this.starten(id, titel)
}
private letzteTitel = new Map<string, TitelStand>()
/** Der Titel-Stand, aus dem gestartet wird — die Erkennung reicht ihn durch. */
titelMerken(id: string, stand: TitelStand): void {
this.letzteTitel.set(id, stand)
}
discEntnommen(id: string): void {
this.stopp(id, '')
this.staende.delete(id)
this.letzteTitel.delete(id)
this.merkerZuordnung.delete(id)
}
}
@@ -0,0 +1,94 @@
// Benachrichtigung aufs Handy (5.5.0, aus ARM: Apprise/Pushover/…) — zwei
// Wege, beide ohne Bibliothek, beide dokumentiert (Regel D):
// Discord-Webhook: POST <webhook-url> mit JSON { content } —
// discord.com/developers/docs/resources/webhook („Execute Webhook":
// content ist eine Zeichenkette bis 2000 Zeichen).
// Telegram-Bot: POST https://api.telegram.org/bot<TOKEN>/sendMessage mit
// JSON { chat_id, text } — core.telegram.org/bots/api#sendmessage.
// Ein Fehlschlag wird GESAGT (Ereignis-Zeile), kippt aber nie den Vorgang,
// der ihn ausgelöst hat.
import type { KernKontext } from '../kontext'
import type { EinstellungsDienst } from './einstellungen'
export interface Kanaele {
discordWebhook: string
telegramToken: string
telegramChatId: string
}
/** Was an Discord geht (pur, testbar). */
export function discordAnfrage(webhook: string, titel: string, text: string): { url: string; body: string } {
const inhalt = `**${titel}**\n${text}`.slice(0, 1990)
return { url: webhook, body: JSON.stringify({ content: inhalt, username: 'Rippy' }) }
}
/** Was an Telegram geht (pur, testbar). */
export function telegramAnfrage(token: string, chatId: string, titel: string, text: string): { url: string; body: string } {
return {
url: `https://api.telegram.org/bot${token}/sendMessage`,
body: JSON.stringify({ chat_id: chatId, text: `${titel}\n${text}`.slice(0, 4000) }),
}
}
export function kanaeleAus(lesen: (schluessel: string) => string): Kanaele {
return {
discordWebhook: lesen('discordWebhook').trim(),
telegramToken: lesen('telegramToken').trim(),
telegramChatId: lesen('telegramChatId').trim(),
}
}
export function kanaeleEingerichtet(k: Kanaele): boolean {
return k.discordWebhook.length > 0 || (k.telegramToken.length > 0 && k.telegramChatId.length > 0)
}
export class BenachrichtigungsDienst {
constructor(
private readonly kontext: KernKontext,
private readonly einstellungen: EinstellungsDienst,
private readonly holen: typeof fetch = fetch,
) {}
eingeschaltet(): boolean {
return this.einstellungen.lesen('benachrichtigungen', 'an') !== 'aus'
}
/** Schickt über alle eingerichteten Kanäle. Liefert die Fehler (leer = alles gut). */
async senden(titel: string, text: string, erzwingen = false): Promise<string[]> {
if (!erzwingen && !this.eingeschaltet()) return []
const k = kanaeleAus((s) => this.einstellungen.lesen(s))
const fehler: string[] = []
const anfragen: Array<{ name: string; url: string; body: string }> = []
if (k.discordWebhook.length > 0) anfragen.push({ name: 'Discord', ...discordAnfrage(k.discordWebhook, titel, text) })
if (k.telegramToken.length > 0 && k.telegramChatId.length > 0) {
anfragen.push({ name: 'Telegram', ...telegramAnfrage(k.telegramToken, k.telegramChatId, titel, text) })
}
for (const a of anfragen) {
try {
const antwort = await this.holen(a.url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: a.body,
signal: AbortSignal.timeout(15_000),
})
if (antwort.status >= 300) fehler.push(`${a.name}: HTTP ${antwort.status}`)
} catch (e) {
fehler.push(`${a.name}: ${String((e as Error).message ?? e).slice(0, 120)}`)
}
}
for (const f of fehler) this.kontext.fehler(`Benachrichtigung ${f}`)
if (anfragen.length > 0 && fehler.length === 0) this.kontext.protokoll.schreiben(`Benachrichtigt (${anfragen.map((a) => a.name).join(', ')}): ${titel}`)
return fehler
}
/** Der Knopf „Testnachricht" — antwortet IMMER etwas. */
async testen(): Promise<void> {
const k = kanaeleAus((s) => this.einstellungen.lesen(s))
if (!kanaeleEingerichtet(k)) {
this.kontext.fehler('Benachrichtigung: kein Kanal eingerichtet — Discord-Webhook oder Telegram-Bot eintragen.')
return
}
const fehler = await this.senden('Rippy meldet sich', 'Testnachricht — die Verbindung steht.', true)
if (fehler.length === 0) this.kontext.hinweis('Testnachricht verschickt.')
}
}
@@ -0,0 +1,137 @@
// Einstellungen (§ 6.7): Das Fenster liest und schreibt NUR über den Kern;
// erlaubt sind genau die Schlüssel hier. Dazu die Pfad-Regeln: Ablage
// (Fertiges — beim Commander der Server) und seit 5.5.0 der Arbeitsordner
// (Roh-Dateien und Kompression — lokal, schnell; Vorgabe: unter der Ablage,
// also wie bisher).
import { join } from 'node:path'
import { AUFBEWAHRUNG_SCHLUESSEL } from '../ablage/rohaufraeumen'
import { einstellung, type KernKontext } from '../kontext'
export const ERLAUBTE_EINSTELLUNGEN: ReadonlySet<string> = new Set([
'ablage',
'arbeitsordner',
'transcodeEnabled',
'transcodePresetDvd',
'transcodePresetBluray',
'transcodePresetUhd',
'audioSprachen',
'untertitelSprachen',
'mediaServer',
'mediaServerUrl',
'mediaServerApiKey',
'tmdbApiKey',
'omdbApiKey',
'makemkvAppKey',
'updateUrl',
'setup.done',
'autoAuswurf',
'werkzeug.makemkv',
'werkzeug.handbrake',
'werkzeug.flac',
AUFBEWAHRUNG_SCHLUESSEL,
// 5.5.0
'automatik',
'automatikSekunden',
'benachrichtigungen',
'discordWebhook',
'telegramToken',
'telegramChatId',
])
/** Die Schlüssel, die Rip und Kompression lesen (Pipeline/Ablage). Das
* frühere Allgemeine Preset" (transcodePreset) ist seit 5.5.0 raus
* Commander-Wunsch: je Disc-Typ reicht, ein unsichtbarer Übersteuerer
* verwirrt nur. */
const PIPELINE_EINSTELLUNGEN = [
'transcodeEnabled',
'transcodePresetDvd',
'transcodePresetBluray',
'transcodePresetUhd',
'audioSprachen',
'untertitelSprachen',
'mediaServer',
'mediaServerUrl',
'mediaServerApiKey',
] as const
export const AUTOMATIK_SEKUNDEN_VORGABE = 30
export class EinstellungsDienst {
constructor(private readonly kontext: KernKontext) {}
lesen(schluessel: string, vorgabe = ''): string {
return einstellung(this.kontext, schluessel, vorgabe)
}
/** Wurzel der Ablage — Fertiges, Musik, ISO. */
ablageOrdner(): string {
const eingestellt = this.lesen('ablage')
if (eingestellt.length > 0) return eingestellt
return join(process.env['USERPROFILE'] ?? '.', 'Videos', 'Rippy')
}
/** Ein eigener Arbeitsordner — '' wenn keiner gesetzt ist. */
arbeitsordnerEigen(): string {
return this.lesen('arbeitsordner').trim()
}
/** Wo gearbeitet wird: der eigene Arbeitsordner, sonst die Ablage. */
arbeitsordner(): string {
const eigen = this.arbeitsordnerEigen()
return eigen.length > 0 ? eigen : this.ablageOrdner()
}
/** Wo die Roh-Ordner liegen. */
rohWurzel(): string {
return join(this.arbeitsordner(), 'roh')
}
/** Was Pipeline und Ablage brauchen. */
fuerPipeline(): Record<string, string> {
const werte: Record<string, string> = {}
for (const schluessel of PIPELINE_EINSTELLUNGEN) {
const wert = this.kontext.db?.einstellungLesen(schluessel)
if (wert !== null && wert !== undefined) werte[schluessel] = wert
}
return werte
}
automatikAn(): boolean {
return this.lesen('automatik', 'an') !== 'aus'
}
automatikSekunden(): number {
const n = Number(this.lesen('automatikSekunden', String(AUTOMATIK_SEKUNDEN_VORGABE)))
return Number.isInteger(n) && n >= 5 && n <= 600 ? n : AUTOMATIK_SEKUNDEN_VORGABE
}
/** Alle erlaubten Werte ans Fenster — AUCH ans Haupt (Update-Adresse, § 6.8). */
senden(): void {
const werte: Record<string, string> = { ablage: this.ablageOrdner() }
for (const schluessel of ERLAUBTE_EINSTELLUNGEN) {
const wert = this.kontext.db?.einstellungLesen(schluessel)
if (wert !== null && wert !== undefined) werte[schluessel] = wert
}
this.kontext.anHauptUndFenster({ art: 'einstellungen', werte })
}
/** Schreiben — nur erlaubte Schlüssel, nur mit Datenbank. Liefert, ob es klappte. */
setzen(schluessel: string, wert: string): boolean {
if (!ERLAUBTE_EINSTELLUNGEN.has(schluessel)) {
this.kontext.fehler(`Unbekannter Einstellungs-Schlüssel: ${schluessel}`)
return false
}
if (this.kontext.db === null) {
this.kontext.fehler('Einstellung nicht gespeichert — die Datenbank ist nicht da.')
return false
}
this.kontext.db.einstellungSchreiben(schluessel, wert)
this.kontext.protokoll.schreiben(`Einstellung ${schluessel} gesetzt${istGeheim(schluessel) ? '' : `: ${wert.slice(0, 120)}`}`)
this.senden()
return true
}
}
function istGeheim(schluessel: string): boolean {
return /key|token|webhook|passwort/i.test(schluessel)
}
+408
View File
@@ -0,0 +1,408 @@
// Die Erkennung (W-4, § 5 Schritt 3): Was liegt da drin? Disc-Merkmale,
// TMDb/OMDb-Zuordnung mit Sicherheitsgrad, die Nachprüfung nach dem
// Info-Lauf (Erkennung v2, 5.4.0), der Titel-Lauf für den Auswahl-Dialog,
// die Metadaten-Suche des Ändern-Dialogs — und seit 5.5.0 der Serien-Merker
// (welche Folge auf der nächsten Disc weitergeht) und die Poster für den
// Hintergrund der Erst-Einrichtung.
import type {
DiscInfoStand,
DiscTitelVorschlag,
GeraeteInfo,
MetaVorschlag,
TitelStand,
} from '../../gemeinsam/nachrichten'
import { einstellung, type KernKontext } from '../kontext'
import { discFingerprint, discTitel, type DiscTitel } from '../metadaten/discmerkmale'
import { OmdbClient } from '../metadaten/omdb'
import { TmdbClient } from '../metadaten/tmdb'
import { LEERER_TREFFER, ausDetails, nachpruefen, zuordnen, type Zuordnung } from '../metadaten/zuordnung'
import { ABGELOEST, titelInfoLesen } from '../rip/makemkv'
import { sprachenFuerTitel, type TitelInfo } from '../rip/parser'
import { strukturHinweis, titelVorauswahl } from '../rip/titelwahl'
export interface ErkennungAbhaengigkeiten {
ripLaeuft(id: string): boolean
geraetInfoVon(id: string): GeraeteInfo | null
makemkvcon(): string
}
/** Der Serien-Merker (5.5.0): je TMDb-Serie, was die vorige Disc hinterließ. */
export interface SerienMerker {
titel: string
staffel: number
naechsteFolge: number
wann: string
}
export const SERIEN_MERKER_SCHLUESSEL = 'serienMerker'
/** Liest den Merker-Speicher (pur, robust gegen Unsinn). */
export function serienMerkerAusJson(text: string | null): Record<string, SerienMerker> {
if (text === null || text.length === 0) return {}
try {
const roh: unknown = JSON.parse(text)
if (typeof roh !== 'object' || roh === null) return {}
const ergebnis: Record<string, SerienMerker> = {}
for (const [id, wert] of Object.entries(roh as Record<string, unknown>)) {
if (typeof wert !== 'object' || wert === null) continue
const m = wert as Record<string, unknown>
if (typeof m.staffel !== 'number' || typeof m.naechsteFolge !== 'number') continue
ergebnis[id] = {
titel: typeof m.titel === 'string' ? m.titel : '',
staffel: m.staffel,
naechsteFolge: m.naechsteFolge,
wann: typeof m.wann === 'string' ? m.wann : '',
}
}
return ergebnis
} catch {
// Ein kaputter Merker ist kein Grund, nicht zu erkennen — er ist dann leer.
return {}
}
}
function jahrAus(datum: string | undefined): number | null {
if (datum === undefined || datum.length < 4) return null
const jahr = Number(datum.slice(0, 4))
return Number.isFinite(jahr) && jahr > 0 ? jahr : null
}
export class ErkennungDienst {
private zuordnungen = new Map<string, Zuordnung>()
private fingerprints = new Map<string, string>()
/** Was die Disc über sich selbst sagt (Disc-Nummer, Staffel, Folgenzahl)
* das bleibt wahr, auch wenn der Commander den Titel selbst neu wählt. */
private merkmale = new Map<string, DiscTitel>()
/** Der letzte Titel-Stand je Laufwerk — die Automatik (5.5.0) rippt daraus. */
readonly letzteTitel = new Map<string, TitelStand>()
// Je Laufwerk höchstens EIN Titel-Lauf, und nur der NEUESTE darf
// antworten. Der Commander-Fund vom 01.09.2026: Die Titelliste kam
// korrekt an — und 300 s später legte ein ÄLTERER, längst überholter Lauf
// seinen Timeout darüber („keine Antwort nach 300 s"). Zwei makemkvcon
// auf derselben Disc bremsen sich außerdem gegenseitig aus.
private titelAbbrueche = new Map<string, AbortController>()
private titelLaufZaehler = 0
private titelLaufNummern = new Map<string, number>()
constructor(
private readonly kontext: KernKontext,
private readonly abh: ErkennungAbhaengigkeiten,
) {}
private tmdb(): TmdbClient {
return new TmdbClient(einstellung(this.kontext, 'tmdbApiKey'))
}
private omdb(): OmdbClient {
return new OmdbClient(einstellung(this.kontext, 'omdbApiKey'))
}
zuordnung(id: string): Zuordnung | null {
return this.zuordnungen.get(id) ?? null
}
fingerprint(id: string): string | null {
return this.fingerprints.get(id) ?? null
}
discAngaben(id: string): { staffel: number | null; discNr: number | null } | null {
const m = this.merkmale.get(id)
return m === undefined ? null : { staffel: m.staffel, discNr: m.discNr }
}
// ── Serien-Merker (5.5.0) ───────────────────────────────────────────────
private alleMerker(): Record<string, SerienMerker> {
return serienMerkerAusJson(this.kontext.db?.einstellungLesen(SERIEN_MERKER_SCHLUESSEL) ?? null)
}
serienMerkerFuer(tmdbId: number | string | null): SerienMerker | null {
if (tmdbId === null) return null
return this.alleMerker()[String(tmdbId)] ?? null
}
/** Nach einer fertigen Serien-Ablage: Mit welcher Folge geht es weiter? */
serienMerkerSetzen(tmdbId: number | string, titel: string, staffel: number, naechsteFolge: number): void {
if (this.kontext.db === null) return
const alle = this.alleMerker()
alle[String(tmdbId)] = { titel, staffel, naechsteFolge, wann: new Date().toISOString() }
this.kontext.db.einstellungSchreiben(SERIEN_MERKER_SCHLUESSEL, JSON.stringify(alle))
this.kontext.protokoll.schreiben(`Serien-Merker: „${titel}" Staffel ${staffel}, weiter mit Folge ${naechsteFolge}`)
}
/** EINE Stelle, die aus Zuordnung + Disc-Merkmalen den Stand fürs Fenster
* baut (5.4.0) vorher stand dieselbe Struktur dreimal im Code. */
discInfoStand(id: string, zuordnung: Zuordnung | null, quelle?: string): DiscInfoStand {
const m = this.merkmale.get(id)
const meta = zuordnung?.meta
const merker = meta?.typ === 'tv' ? this.serienMerkerFuer(meta.id) : null
return {
id,
titel: zuordnung?.titel ?? '',
jahr: zuordnung?.jahr ?? null,
confidence: zuordnung?.confidence ?? 0,
quelle: quelle ?? meta?.quelle ?? '',
posterPfad: meta?.posterPfad ?? '',
backdropPfad: meta?.backdropPfad ?? '',
beschreibung: meta?.beschreibung ?? '',
// § 6.6: Rippy erkennt beim Einlegen, wenn eine Disc schon einmal
// durchgelaufen ist — und fragt nach, statt stumm zu doppeln.
schonGerippt:
zuordnung === null ? null : (this.kontext.db?.bibliothekSchonGerippt(this.fingerprints.get(id) ?? '') ?? null),
discNr: m?.discNr ?? null,
staffel: m?.staffel ?? null,
folgen: m?.folgen ?? 0,
typ: meta?.typ === 'movie' || meta?.typ === 'tv' ? meta.typ : '',
logoPfad: meta?.logoPfad ?? '',
tagline: meta?.tagline ?? '',
bewertung: meta?.bewertung ?? 0,
fsk: meta?.fsk ?? '',
laufzeitMinuten: meta?.laufzeitMinuten ?? 0,
genres: meta?.genres ?? [],
besetzung: meta?.besetzung ?? [],
hinweise: zuordnung?.hinweise ?? [],
serienMerker: merker === null ? null : { staffel: merker.staffel, naechsteFolge: merker.naechsteFolge, wann: merker.wann },
}
}
schonGerippt(id: string): string | null {
return this.kontext.db?.bibliothekSchonGerippt(this.fingerprints.get(id) ?? '') ?? null
}
/** § 5 Schritt 3: Disc rein was ist das? Liefert die Zuordnung (für die
* Automatik) null, wenn die Erkennung scheiterte. */
async discErmitteln(geraet: GeraeteInfo): Promise<Zuordnung | null> {
try {
const gefunden = discTitel(geraet.pfad)
this.fingerprints.set(geraet.id, discFingerprint(geraet.pfad, geraet.groesseBytes))
this.merkmale.set(geraet.id, gefunden)
// Der Hinweis kommt von der Disc selbst (bdmt-Inhaltsverzeichnis) —
// trägt sie Folgen, kann kein Filmtreffer mehr gewinnen. Und jede
// Titel-Variante der Disc (bdmt_deu) wird mitgefragt (5.4.0).
const zuordnung = await zuordnen(gefunden.titel, this.tmdb(), this.omdb(), {
istSerie: gefunden.istSerie,
alternativen: gefunden.alternativen,
})
this.zuordnungen.set(geraet.id, zuordnung)
this.kontext.protokoll.schreiben(
`Erkennung ${geraet.id}: „${gefunden.titel}" (${gefunden.quelle || 'kein Titel'}) → „${zuordnung.titel}" ${Math.round(zuordnung.confidence * 100)} % ${zuordnung.meta.typ}`,
)
this.kontext.anHauptUndFenster({ art: 'disc-info', info: this.discInfoStand(geraet.id, zuordnung) })
return zuordnung
} catch (fehler) {
// R4: Eine gescheiterte Erkennung ist eine laute Zeile — gerippt
// werden kann trotzdem (die Zuordnung ist Komfort, kein Torwächter).
this.kontext.fehler(`Disc-Erkennung ${geraet.id}: ${String(fehler)}`)
return null
}
}
discVergessen(id: string): void {
this.zuordnungen.delete(id)
this.fingerprints.delete(id)
this.merkmale.delete(id)
this.letzteTitel.delete(id)
this.titelAbbrueche.get(id)?.abort()
this.kontext.anHauptUndFenster({ art: 'disc-info', info: this.discInfoStand(id, null) })
}
// § 5 Schritt 4: „nein → fragen" — der Nutzer sucht und WÄHLT selbst.
async metadatenSuchen(id: string, text: string): Promise<void> {
try {
const tmdb = this.tmdb()
if (!tmdb.verfuegbar) {
this.kontext.fehler('Metadaten-Suche braucht einen TMDb-Key (Einstellungen → Metadaten).')
this.kontext.anFenster({ art: 'metadaten-vorschlaege', id, vorschlaege: [] })
return
}
const [filme, serien] = await Promise.all([tmdb.searchMovie(text), tmdb.searchTv(text)])
const vorschlaege: MetaVorschlag[] = [
...(filme ?? []).slice(0, 6).map((f) => ({
typ: 'movie' as const,
tmdbId: f.id,
titel: f.title ?? '',
jahr: jahrAus(f.release_date),
posterPfad: f.poster_path ?? '',
})),
...(serien ?? []).slice(0, 6).map((s) => ({
typ: 'tv' as const,
tmdbId: s.id,
titel: s.name ?? '',
jahr: jahrAus(s.first_air_date),
posterPfad: s.poster_path ?? '',
})),
].filter((v) => v.titel.length > 0)
this.kontext.anFenster({ art: 'metadaten-vorschlaege', id, vorschlaege })
} catch (fehler) {
this.kontext.fehler(`Metadaten-Suche: ${String(fehler)}`)
this.kontext.anFenster({ art: 'metadaten-vorschlaege', id, vorschlaege: [] })
}
}
async zuordnungSetzen(id: string, vorschlag: MetaVorschlag): Promise<void> {
try {
const tmdb = this.tmdb()
const details =
vorschlag.typ === 'movie' ? await tmdb.movieDetails(vorschlag.tmdbId) : await tmdb.tvDetails(vorschlag.tmdbId)
const meta =
details !== null
? ausDetails(vorschlag.typ, vorschlag.tmdbId, details)
: {
...LEERER_TREFFER,
typ: vorschlag.typ,
id: vorschlag.tmdbId,
titel: vorschlag.titel,
jahr: vorschlag.jahr,
posterPfad: vorschlag.posterPfad,
quelle: 'tmdb' as const,
}
const zuordnung: Zuordnung = {
titel: meta.titel.length > 0 ? meta.titel : vorschlag.titel,
jahr: meta.jahr ?? vorschlag.jahr,
// Vom Nutzer gewählt = sicher. Genau dafür ist die Frage da (§ 5).
confidence: 1,
meta,
}
this.zuordnungen.set(id, zuordnung)
this.kontext.protokoll.schreiben(`Zuordnung ${id} vom Nutzer: „${zuordnung.titel}" (${meta.typ} ${String(meta.id)})`)
this.kontext.anHauptUndFenster({ art: 'disc-info', info: this.discInfoStand(id, zuordnung, 'deine Wahl') })
} catch (fehler) {
this.kontext.fehler(`Zuordnung übernehmen: ${String(fehler)}`)
}
}
// ── Titel der Disc lesen (§ 6.2) ────────────────────────────────────────
// Der Info-Lauf dauert 20120 s — deshalb geschieht er erst auf Zuruf und
// nicht schon beim Einlegen (der 30.08.-Fund: „das Erkennen dauert sehr
// sehr lange"). Das Fenster bekommt sofort `laeuft: true`, damit es in der
// Zeit nicht stumm dasteht (R2). Liefert den End-Stand zurück (Automatik).
async titelLesen(id: string): Promise<TitelStand> {
const leer: TitelStand = { id, laeuft: false, titel: [], einordnung: '', unsicher: false, fehler: '' }
const senden = (stand: TitelStand): TitelStand => {
if (!stand.laeuft) this.letzteTitel.set(id, stand)
this.kontext.anFenster({ art: 'disc-titel', stand })
return stand
}
if (this.abh.ripLaeuft(id)) return senden({ ...leer, fehler: 'Auf diesem Laufwerk läuft schon ein Vorgang.' })
const geraet = this.abh.geraetInfoVon(id)
if (geraet === null) return senden({ ...leer, fehler: `Laufwerk ${id}: gibt es nicht.` })
const programm = this.abh.makemkvcon()
if (programm.length === 0) {
return senden({ ...leer, fehler: 'makemkvcon wurde nicht gefunden — ist MakeMKV installiert?' })
}
// Einen noch laufenden Lauf ablösen: Er gibt das Laufwerk sofort frei,
// und seine Antwort wird verworfen (nur die neueste Nummer zählt).
this.titelAbbrueche.get(id)?.abort()
const abbruch = new AbortController()
this.titelAbbrueche.set(id, abbruch)
const meineNummer = ++this.titelLaufZaehler
this.titelLaufNummern.set(id, meineNummer)
const nochAktuell = (): boolean => this.titelLaufNummern.get(id) === meineNummer
senden({ ...leer, laeuft: true })
const start = Date.now()
try {
const info = await titelInfoLesen(programm, geraet.pfad, 300_000, abbruch.signal)
if (!nochAktuell()) return { ...leer, fehler: ABGELOEST }
const merkmale = this.merkmale.get(id)
const wahl = titelVorauswahl(info.titel, {
istSerie: merkmale?.istSerie ?? false,
folgen: merkmale?.folgen ?? 0,
})
// Kam nichts heraus UND MakeMKV hat einen Grund genannt, dann ist der
// Grund die Nachricht — nicht „keine Titel gefunden". Am 01.09.2026
// gemessen: Beim 5042-Zustand antwortet der Info-Lauf nach 0,7 s mit
// TCOUNT:0, und ohne diesen Zweig stünde im Fenster, die DISC gäbe
// nichts her — dabei sieht MakeMKV das LAUFWERK nicht.
const ohneErgebnis = info.titel.length === 0 && info.ursachen.length > 0
this.kontext.protokoll.schreiben(
`Titel-Lauf ${id}: ${((Date.now() - start) / 1000).toFixed(1)} s, ${info.titel.length} Titel — ${wahl.einordnung}${info.ursachen.length > 0 ? ` · ${info.ursachen.join(' · ')}` : ''}`,
)
const titel: DiscTitelVorschlag[] = wahl.titel.map((t) => ({
...t,
// Die Sprachen JE TITEL reisen mit (5.4.0) — der Dialog bietet sie zum Anhaken an.
sprachen: sprachenFuerTitel(info.streams.get(t.nr)),
}))
const stand = senden({
id,
laeuft: false,
titel,
einordnung: wahl.einordnung,
unsicher: wahl.unsicher,
fehler: ohneErgebnis ? info.ursachen.join(' · ') : '',
})
// Erkennung v2 (5.4.0): Jetzt liegt die Laufzeit vor — nachprüfen.
if (info.titel.length > 0) await this.nachpruefen(id, titel, info.titel)
return stand
} catch (fehler) {
// Ein ABGELÖSTER Lauf schweigt: Ihn hat ein neuerer ersetzt, das ist
// Absicht und kein Fehlschlag. Genau hier lag der Commander-Fund —
// der alte Lauf überschrieb sonst die längst gelieferte Titelliste.
if (String(fehler).includes(ABGELOEST) || !nochAktuell()) return { ...leer, fehler: ABGELOEST }
// R4: sonst laut scheitern. „Konnte nicht nachsehen" ist etwas anderes
// als „die Disc hat keine Titel" — beides zu vermischen erzeugt Unsinn.
const grund = String(fehler).includes('keine Antwort nach')
? 'MakeMKV hat die Titel nach fünf Minuten nicht gelesen. Bei einer beschädigten Disc kann das passieren — Rippy hat den Versuch beendet und das Laufwerk freigegeben.'
: `Titel lesen: ${String(fehler).slice(0, 200)}`
this.kontext.protokoll.schreiben(`Titel-Lauf ${id} gescheitert: ${grund}`)
return senden({ ...leer, fehler: grund })
} finally {
// Nur aufräumen, wenn ICH noch der aktuelle Lauf bin.
if (nochAktuell()) {
this.titelAbbrueche.delete(id)
this.titelLaufNummern.delete(id)
}
}
}
/** Erkennung v2 (5.4.0): Erkannt wird beim EINLEGEN, vor jeder Messung.
* Nach dem Info-Lauf wird noch einmal gerechnet Laufzeit gegen TMDb,
* Titel-Struktur gegen den Treffer-Typ. Aus wahrscheinlich" wird
* bestätigt", oder Rippy merkt selbst, dass es danebenlag. */
private async nachpruefen(id: string, vorschlag: readonly DiscTitelVorschlag[], titel: readonly TitelInfo[]): Promise<void> {
const bisher = this.zuordnungen.get(id)
if (bisher === undefined) return
const gewaehlt = vorschlag.filter((t) => t.gewaehlt)
const folgen = gewaehlt.filter((t) => t.rolle === 'folge').map((t) => t.dauerS)
const laengster = [...titel].sort((a, b) => b.dauerS - a.dauerS)[0]?.dauerS ?? 0
const messung = {
hauptS: folgen.length > 0 ? 0 : (gewaehlt[0]?.dauerS ?? laengster),
folgenS: folgen,
struktur: strukturHinweis(titel),
}
let zuordnung = bisher
// Sagt die Struktur „Serie", der Treffer aber „Film" (und der Nutzer hat
// nicht selbst gewählt): einmal als Serie fragen — das ist der
// Fingerabdruck, der Serien auch ohne bdmt erkennt.
if (messung.struktur === 'serie' && bisher.meta.typ !== 'tv' && bisher.confidence < 1) {
const merkmale = this.merkmale.get(id)
if (merkmale !== undefined && merkmale.titel.length > 0) {
const nochmal = await zuordnen(merkmale.titel, this.tmdb(), this.omdb(), {
istSerie: true,
alternativen: merkmale.alternativen,
})
if (nochmal.meta.typ === 'tv') {
zuordnung = { ...nochmal, hinweise: ['Serie erkannt an der Titel-Struktur (mehrere gleich lange Titel).'] }
}
}
}
const geprueft = nachpruefen(zuordnung, messung)
const hinweise = [...(zuordnung.hinweise ?? []), ...(geprueft.hinweise ?? [])]
const neu = { ...geprueft, hinweise }
this.zuordnungen.set(id, neu)
if (hinweise.length > 0) this.kontext.protokoll.schreiben(`Nachprüfung ${id}: ${hinweise.join(' · ')}${Math.round(neu.confidence * 100)} %`)
this.kontext.anHauptUndFenster({ art: 'disc-info', info: this.discInfoStand(id, neu) })
}
// ── Poster für die Erst-Einrichtung (5.5.0, Commander-Wunsch) ──────────
// TMDb /movie/popular (de-DE) — dieselbe Schnittstelle wie die Suche,
// die Antwort trägt poster_path je Film (gemessen: results[].poster_path).
async posterLaden(): Promise<void> {
const tmdb = this.tmdb()
if (!tmdb.verfuegbar) {
this.kontext.anFenster({ art: 'poster-vorschau', pfade: [] })
return
}
const pfade = await tmdb.beliebtePoster(40)
this.kontext.anFenster({ art: 'poster-vorschau', pfade })
}
}
+202
View File
@@ -0,0 +1,202 @@
// Die Laufwerke (W-1, § 5 Schritt 1): Disc-Wache im 3-Sekunden-Takt,
// Auswerfen mit Nachsehen (R3), Audio-CD-Zugriffe, die Laufwerks-Gesundheit
// aus dem Windows-Protokoll (5.4.0) — und das nachgebaute Laufwerk des
// Klick-Beweises.
//
// koffi wird erst HIER geladen, über einen echten dynamischen import():
// Rollup legt win32.ts damit als eigenen Chunk neben kern.js (ein
// wörtliches require überlebte das Bündeln nicht — im Smoke-Lauf am
// 30.08.2026 sichtbar geworden). Scheitert das Laden (falsche Plattform,
// kaputtes Paket), läuft der Kern weiter und der Grund steht im Fenster (R4).
import type { GeraeteInfo, WacheEreignis } from '../../gemeinsam/nachrichten'
import type { KernKontext } from '../kontext'
import type { LaufwerkApi } from '../laufwerk/codes'
import {
audioSpurLesen,
audioToc,
auswerfenMitGrund,
geraeteInfo,
kennung,
listOptischeLaufwerke,
} from '../laufwerk/disc'
import { beurteilen, ereignisseLesen } from '../laufwerk/ereignisse'
import { DiscWache } from '../laufwerk/wache'
import type { Spur } from '../rip/audiocd'
const GESUNDHEIT_STUNDEN = 24
export interface LaufwerkHorcher {
discEingelegt(geraet: GeraeteInfo): void
discEntnommen(geraet: GeraeteInfo): void
}
export class LaufwerkDienst {
private api: LaufwerkApi | null = null
private wache: DiscWache | null = null
fehler: string | null = null
/** Das nachgebaute Laufwerk des Klick-Beweises (bau/klick-smoke.mjs): Ein
* Buchstabe, den es nicht gibt (Q) die Disc-Merkmale finden dann
* nichts, und makemkvcon/HandBrake sind Node-Skripte (werkzeuge/aufruf.ts).
* Leer im normalen Betrieb; die Win32-Schicht bleibt dann der einzige Weg. */
readonly fake: string
readonly bereit: Promise<void>
constructor(
private readonly kontext: KernKontext,
private readonly horcher: LaufwerkHorcher,
/** Läuft auf diesem Laufwerk gerade ein Rip? (Auswurf wird dann abgelehnt.) */
private readonly ripLaeuft: (id: string) => boolean,
umgebung: NodeJS.ProcessEnv = process.env,
) {
this.fake = (umgebung['RIPPY_FAKE_LAUFWERK'] ?? '').replace(/:$/, '').toUpperCase()
this.bereit = this.starten()
}
private fakeGeraet(): GeraeteInfo {
return {
id: this.fake,
name: `Laufwerk ${this.fake}:`,
typ: 'bluray',
pfad: `\\\\.\\${this.fake}:`,
status: 'ready',
modell: 'NACHGEBAUT (KLICK-BEWEIS)',
seriennummer: 'KLICK',
groesseBytes: 33_759_100_928,
grund: '',
}
}
private wacheMeldung(meldung: { laufwerke: GeraeteInfo[]; ereignisse: WacheEreignis[] }): void {
this.kontext.anHauptUndFenster({ art: 'laufwerke', liste: meldung.laufwerke, ereignisse: meldung.ereignisse })
for (const ereignis of meldung.ereignisse) {
if (ereignis.art === 'disc-eingelegt') {
this.kontext.protokoll.schreiben(`Disc eingelegt in ${ereignis.geraet.id}: ${ereignis.geraet.typ}, ${ereignis.geraet.groesseBytes} Bytes`)
this.horcher.discEingelegt(ereignis.geraet)
} else if (ereignis.art === 'disc-entnommen') {
this.kontext.protokoll.schreiben(`Disc entnommen aus ${ereignis.geraet.id}`)
this.horcher.discEntnommen(ereignis.geraet)
} else if (ereignis.art === 'laufwerk-weg') {
this.kontext.protokoll.schreiben(`Laufwerk ${ereignis.id} nicht mehr da`)
}
}
}
private async starten(): Promise<void> {
const wacheFehler = (fehler: unknown): void => {
// R4: Eine gescheiterte Runde ist eine laute Zeile — der letzte
// Stand im Fenster bleibt stehen (R2), die Wache läuft weiter.
this.kontext.fehler(`Disc-Wache: ${String(fehler)}`)
}
try {
if (this.fake.length > 0) {
this.wache = new DiscWache(() => [this.fakeGeraet()], (m) => this.wacheMeldung(m), wacheFehler)
this.wache.starten()
return
}
if (process.platform !== 'win32') {
throw new Error('Die Laufwerks-Schicht läuft nur unter Windows (KONZEPT § 14).')
}
const { win32Api } = await import('../laufwerk/win32')
this.api = win32Api()
this.wache = new DiscWache(
() => listOptischeLaufwerke(this.api!).map((g) => geraeteInfo(this.api!, g)),
(m) => this.wacheMeldung(m),
wacheFehler,
)
this.wache.starten()
} catch (fehler) {
this.fehler = String(fehler)
this.kontext.protokoll.schreiben(`Laufwerke: ${this.fehler}`)
console.error(`[kern] Laufwerke: ${this.fehler}`)
}
}
aktuellerStand(): GeraeteInfo[] {
return this.wache?.aktuellerStand() ?? []
}
/** Der Geräte-Name zu einer Kennung — oder null. */
geraetVon(id: string): string | null {
if (this.api === null) return null
return listOptischeLaufwerke(this.api).find((g) => kennung(g) === id) ?? null
}
/** Der Geräte-Stand zu einer Kennung — echt oder nachgebaut. */
geraetInfoVon(id: string): GeraeteInfo | null {
if (this.fake.length > 0) return id === this.fake ? this.fakeGeraet() : null
const geraet = this.geraetVon(id)
if (geraet === null || this.api === null) return null
return geraeteInfo(this.api, geraet)
}
audioToc(id: string): Spur[] {
const geraet = this.geraetVon(id)
if (this.api === null || geraet === null) throw new Error(`Laufwerk ${id}: gibt es nicht`)
return audioToc(this.api, geraet)
}
audioSpurLesen(
id: string,
start: number,
sektoren: number,
schreiben: (daten: Buffer) => void,
fortschritt?: (anteil: number) => void,
): number {
const geraet = this.geraetVon(id)
if (this.api === null || geraet === null) throw new Error(`Laufwerk ${id}: gibt es nicht`)
return audioSpurLesen(this.api, geraet, start, sektoren, schreiben, fortschritt)
}
async auswerfen(id: string): Promise<void> {
await this.bereit
if (this.ripLaeuft(id)) {
this.kontext.anFenster({
art: 'auswurf-ergebnis',
id,
grund: 'abgelehnt',
fehler: `Auf Laufwerk ${id} läuft ein Rip — erst abbrechen oder abwarten.`,
})
return
}
if (this.api === null) {
this.kontext.anFenster({ art: 'auswurf-ergebnis', id, grund: 'kein-zugriff', fehler: this.fehler })
return
}
const geraet = this.geraetVon(id)
if (geraet === null) {
this.kontext.anFenster({ art: 'auswurf-ergebnis', id, grund: 'kein-zugriff', fehler: `Laufwerk ${id}: gibt es nicht` })
return
}
const { grund, fehler } = await auswerfenMitGrund(this.api, geraet)
this.kontext.protokoll.schreiben(`Auswurf ${id}: ${grund}${fehler !== null ? ` (${fehler})` : ''}`)
this.kontext.anFenster({ art: 'auswurf-ergebnis', id, grund, fehler })
// Die Wache merkt den neuen Zustand spätestens in der nächsten Runde;
// eine sofortige Runde macht das Fenster ohne 3-Sekunden-Wartezeit frisch.
this.wache?.runde()
}
// ── Laufwerks-Gesundheit (5.4.0, Punkt 8) ─────────────────────────────
async pruefen(id: string): Promise<void> {
if (process.platform !== 'win32') return
const ergebnis = await ereignisseLesen(GESUNDHEIT_STUNDEN)
if ('fehler' in ergebnis) {
this.kontext.anFenster({
art: 'laufwerk-gesundheit',
id,
bericht: {
urteil: 'nicht-lesbar',
text: `Das Windows-Protokoll ließ sich nicht lesen: ${ergebnis.fehler}`,
fehlerhafteBloecke: 0,
controllerfehler: 0,
sonstige: 0,
letzte: [],
stunden: GESUNDHEIT_STUNDEN,
},
})
return
}
const bericht = beurteilen(ergebnis.ereignisse, GESUNDHEIT_STUNDEN)
this.kontext.protokoll.schreiben(`Laufwerk ${id} geprüft: ${bericht.urteil}${bericht.text}`)
this.kontext.anFenster({ art: 'laufwerk-gesundheit', id, bericht })
}
}
@@ -0,0 +1,87 @@
// Freier Platz (5.5.0, Commander: „Wie viel Speicher noch auf dem Server
// oder auf dem Arbeitsordner frei ist") — GEMESSEN am 01.09.2026 mit Node
// 24 (Regel D): fs.statfsSync liefert für Laufwerke (C:\, F:\), Ordner
// und UNC-Freigaben (\\192.168.178.62\rippy) bavail/bsize/blocks; ein
// Pfad, den es nicht gibt (Q:\), wirft ENOENT. Deshalb wird zum nächsten
// vorhandenen Elternpfad hochgegangen: Die Ablage darf noch fehlen, ihr
// Laufwerk gibt es trotzdem.
import { existsSync, statfsSync } from 'node:fs'
import { dirname } from 'node:path'
import type { SpeicherPlatz, SpeicherStand } from '../../gemeinsam/nachrichten'
import type { KernKontext } from '../kontext'
import type { EinstellungsDienst } from './einstellungen'
export type Messen = (pfad: string) => { bavail: number; bsize: number; blocks: number }
const echtMessen: Messen = (pfad) => {
const s = statfsSync(pfad)
return { bavail: Number(s.bavail), bsize: Number(s.bsize), blocks: Number(s.blocks) }
}
/** Der nächste Pfad, den es gibt — der Ordner selbst, sonst sein Elternpfad. */
export function vorhandenerPfad(pfad: string, existiert: (p: string) => boolean = existsSync): string {
let p = pfad
for (let i = 0; i < 40; i++) {
if (existiert(p)) return p
const eltern = dirname(p)
if (eltern === p) return p
p = eltern
}
return p
}
export function platzMessen(pfad: string, messen: Messen = echtMessen, existiert: (p: string) => boolean = existsSync): SpeicherPlatz {
if (pfad.trim().length === 0) return { pfad, ok: false, freiBytes: 0, gesamtBytes: 0, fehler: 'kein Pfad' }
try {
const s = messen(vorhandenerPfad(pfad, existiert))
return { pfad, ok: true, freiBytes: s.bavail * s.bsize, gesamtBytes: s.blocks * s.bsize, fehler: '' }
} catch (fehler) {
// R2: „konnte nicht messen" ist etwas anderes als „0 frei" — das
// Fenster zeigt den Grund, nie eine erfundene Null.
return { pfad, ok: false, freiBytes: 0, gesamtBytes: 0, fehler: String((fehler as Error).message ?? fehler).slice(0, 120) }
}
}
export { platzUrteil } from '../../gemeinsam/platz'
export class SpeicherDienst {
private zeiger: NodeJS.Timeout | null = null
private letzter: SpeicherStand | null = null
constructor(
private readonly kontext: KernKontext,
private readonly einstellungen: EinstellungsDienst,
private readonly messen: Messen = echtMessen,
) {}
stand(): SpeicherStand {
const arbeit = this.einstellungen.arbeitsordner()
const ablage = this.einstellungen.ablageOrdner()
const s: SpeicherStand = {
arbeit: platzMessen(arbeit, this.messen),
ablage: platzMessen(ablage, this.messen),
getrennt: this.einstellungen.arbeitsordnerEigen().length > 0,
}
this.letzter = s
return s
}
letzterStand(): SpeicherStand | null {
return this.letzter
}
senden(): void {
try {
this.kontext.anFenster({ art: 'speicher', stand: this.stand() })
} catch (fehler) {
this.kontext.fehler(`Speicher messen: ${String(fehler)}`)
}
}
/** Jede Minute neu messen — ohne den Prozess-Exit aufzuhalten. */
taktStarten(ms = 60_000): void {
if (this.zeiger !== null) return
this.zeiger = setInterval(() => this.senden(), ms)
this.zeiger.unref?.()
}
}
+420
View File
@@ -0,0 +1,420 @@
// Vorgänge (5.4.0): jeder Rip als Datensatz, die Kompressions-Warteschlange,
// das Roh-Aufräumen, Wiederholen ab Kompression, Wiederaufnahme nach dem
// Neustart — und seit 5.5.0: Pause, Statistik, Serien-Merker und die
// Benachrichtigung, wenn eine Kompression zu Ende ist.
import { existsSync, rmSync } from 'node:fs'
import type { KompressionStand, Statistik, VorgaengeStand, VorgangEintrag } from '../../gemeinsam/nachrichten'
import { komprimierenUndAblegen, type AblageErgebnis, type AblageUmgebung } from '../ablauf/ablegen'
import { auftragAusJson, auftragZuJson, type KompressionsAuftrag } from '../ablauf/auftrag'
import type { VorgangBeginn } from '../ablauf/pipeline'
import { KompressionsWarteschlange } from '../ablauf/warteschlange'
import {
AUFBEWAHRUNG_SCHLUESSEL,
aufraeumen,
faelligAm,
fremdeOrdner,
istUnterRohWurzel,
ordnerGroesse,
regelLesen,
regelText,
} from '../ablage/rohaufraeumen'
import { presetEmpfehlung } from '../../gemeinsam/preset-empfehlung'
import type { KernKontext } from '../kontext'
import { TmdbClient } from '../metadaten/tmdb'
import type { Vorgang } from '../speicher/db'
import type { BenachrichtigungsDienst } from './benachrichtigung'
import type { EinstellungsDienst } from './einstellungen'
import type { ErkennungDienst } from './erkennung'
import type { WerkzeugDienst } from './werkzeuge'
/** Statistik aus den Vorgängen (pur). */
export function statistikAus(liste: readonly Pick<Vorgang, 'phase' | 'rohBytes' | 'fertigBytes' | 'begonnen' | 'beendet'>[]): Statistik {
let fertige = 0
let rohBytes = 0
let fertigBytes = 0
let dauerS = 0
for (const v of liste) {
rohBytes += v.rohBytes
if (v.phase !== 'fertig') continue
fertige += 1
fertigBytes += v.fertigBytes
if (v.beendet !== null) {
const d = (new Date(v.beendet).getTime() - new Date(v.begonnen).getTime()) / 1000
if (Number.isFinite(d) && d > 0) dauerS += d
}
}
return { discs: liste.length, fertige, rohBytes, fertigBytes, dauerS: Math.round(dauerS) }
}
export function dateienAusJson(text: string, melden?: (grund: string) => void): string[] {
const liste: string[] = []
try {
const roh: unknown = JSON.parse(text)
if (Array.isArray(roh)) liste.push(...roh.map(String))
} catch (fehler) {
// R4: Ein unlesbarer Eintrag wird gesagt — und zählt als „keine
// fertigen Dateien bekannt", womit das Aufräumen ihn nie anfasst.
melden?.(`fertige Dateien nicht lesbar: ${String(fehler)}`)
}
return liste
}
export class VorgangDienst {
readonly warteschlange: KompressionsWarteschlange
/** Nur bei einer PHASEN-Änderung in die Datenbank — nicht bei jedem Prozent-Tick. */
private letztePhase = new Map<string, string>()
constructor(
private readonly kontext: KernKontext,
private readonly einstellungen: EinstellungsDienst,
private readonly werkzeuge: WerkzeugDienst,
private readonly erkennung: ErkennungDienst,
private readonly benachrichtigung: BenachrichtigungsDienst,
/** Nach Änderungen an Vorgängen auch den Speicher neu messen. */
private readonly speicherSenden: () => void,
) {
this.warteschlange = new KompressionsWarteschlange(
(auftrag, melden, abbruch) => komprimierenUndAblegen(auftrag, this.ablageUmgebung(), melden, abbruch),
(stand) => this.standGeaendert(stand.laeuft),
(auftrag, ergebnis) => this.ergebnis(auftrag, ergebnis),
)
}
private get db() {
return this.kontext.db
}
ablageUmgebung(): AblageUmgebung {
return {
handbrake: () => this.werkzeuge.handbrake(),
ablage: () => this.einstellungen.ablageOrdner(),
arbeitsordner: () => this.einstellungen.arbeitsordnerEigen(),
einstellungen: () => this.einstellungen.fuerPipeline(),
presetEmpfehlung: (typ) => presetEmpfehlung(typ, this.werkzeuge.auskunft.backends, this.werkzeuge.auskunft.presets),
// Staffel-Laufzeiten von TMDb — Grundlage der Episoden-Zuordnung (§ 4.2).
episodenLaufzeiten: async (tmdbId, staffel) => {
const tmdb = new TmdbClient(this.einstellungen.lesen('tmdbApiKey'))
if (!tmdb.verfuegbar) return []
const antwort = await tmdb.tvStaffel(tmdbId, staffel)
const paare: Array<[number, number]> = []
for (const ep of antwort?.episodes ?? []) {
if (typeof ep.runtime === 'number' && ep.runtime > 0) paare.push([ep.episode_number, ep.runtime * 60])
}
return paare
},
bibliothekEintragen: (eintrag) => this.bibliothekEintragen(eintrag),
}
}
bibliothekEintragen(eintrag: Parameters<NonNullable<AblageUmgebung['bibliothekEintragen']>>[0]): void {
try {
this.db?.bibliothekEintragen(eintrag)
} catch (fehler) {
// R4: Ein Bibliotheks-Fehler kippt keinen gelungenen Rip, wird aber gesagt.
this.kontext.fehler(`Bibliothek: ${String(fehler)}`)
}
}
private standGeaendert(laeuft: KompressionStand | null): void {
if (laeuft !== null && this.letztePhase.get(laeuft.vorgangId) !== laeuft.phase) {
this.letztePhase.set(laeuft.vorgangId, laeuft.phase)
this.db?.vorgangAktualisieren(laeuft.vorgangId, { phase: laeuft.phase === 'legt-ab' ? 'legt-ab' : 'komprimiert' })
}
this.senden()
}
private async ergebnis(auftrag: KompressionsAuftrag, ergebnis: AblageErgebnis): Promise<void> {
this.letztePhase.delete(auftrag.vorgangId)
const jetzt = new Date().toISOString()
this.db?.vorgangAktualisieren(auftrag.vorgangId, {
phase: ergebnis.status,
beendet: jetzt,
zielOrdner: ergebnis.zielOrdner,
fertigBytes: ergebnis.fertigBytes,
komprimiert: ergebnis.komprimiert,
preset: ergebnis.preset,
fehler: ergebnis.status === 'fertig' ? '' : ergebnis.text,
fertigeDateien: JSON.stringify(ergebnis.dateien),
})
const vorgang = this.db?.vorgangLesen(auftrag.vorgangId)
const titel = vorgang?.titel ?? auftrag.vorgangId
const text = ergebnis.text + (ergebnis.hinweise.length > 0 ? ` · ${ergebnis.hinweise.join(' · ')}` : '')
this.kontext.protokoll.schreiben(`Kompression „${titel}": ${ergebnis.status}${text}`)
this.kontext.anHauptUndFenster({
art: 'kompression-ergebnis',
vorgangId: auftrag.vorgangId,
titel,
ok: ergebnis.status === 'fertig',
text,
})
// Serien-Merker (5.5.0): Mit welcher Folge geht die nächste Disc weiter?
const tv = auftrag.zuordnung?.meta.typ === 'tv' ? auftrag.zuordnung.meta : null
if (ergebnis.status === 'fertig' && tv !== null && tv.id !== null && ergebnis.folgenVergeben.length > 0) {
const letzte = Math.max(...ergebnis.folgenVergeben)
this.erkennung.serienMerkerSetzen(tv.id, auftrag.zuordnung?.titel ?? titel, auftrag.staffel ?? 1, letzte + 1)
}
if (ergebnis.status !== 'abgebrochen') {
void this.benachrichtigung.senden(
ergebnis.status === 'fertig' ? 'Rippy: fertig' : 'Rippy: Kompression fehlgeschlagen',
`${titel}: ${ergebnis.text.slice(0, 300)}`,
)
}
// Regel „sofort": direkt nach der gelungenen Kompression aufräumen.
if (ergebnis.status === 'fertig') this.rohAufraeumenLauf()
else this.senden()
}
// ── Pipeline-Haken ──────────────────────────────────────────────────────
vorgangBeginnt(v: VorgangBeginn): void {
const vorgang: Vorgang = {
id: v.id,
laufwerk: v.laufwerk,
typ: v.typ,
titel: v.titel,
jahr: v.jahr,
phase: 'rippt',
begonnen: v.begonnen,
beendet: null,
rohOrdner: v.rohOrdner,
rohBytes: 0,
rohGeloeschtAm: null,
zielOrdner: '',
fertigBytes: 0,
komprimiert: false,
preset: '',
fehler: '',
auftrag: '{}',
lesefehler: false,
fertigeDateien: '[]',
}
this.db?.vorgangAnlegen(vorgang)
this.kontext.protokoll.schreiben(`Vorgang ${v.id} beginnt: „${v.titel}" auf ${v.laufwerk}${v.rohOrdner}`)
this.senden()
}
vorgangEinreihen(auftrag: KompressionsAuftrag, titel: string): number {
this.db?.vorgangAktualisieren(auftrag.vorgangId, {
phase: 'wartet',
titel,
jahr: auftrag.zuordnung?.jahr ?? null,
rohBytes: auftrag.dateien.reduce((s, d) => s + d.groesseBytes, 0),
lesefehler: auftrag.lesefehler,
auftrag: auftragZuJson(auftrag),
})
const platz = this.warteschlange.einreihen(auftrag, titel)
this.kontext.protokoll.schreiben(`Vorgang ${auftrag.vorgangId}: ${auftrag.dateien.length} Roh-Dateien, Kompression eingereiht (Platz ${platz})`)
this.senden()
return platz
}
vorgangEndet(id: string, phase: 'fehler' | 'abgebrochen', text: string): void {
this.db?.vorgangAktualisieren(id, { phase, beendet: new Date().toISOString(), fehler: text })
this.kontext.protokoll.schreiben(`Vorgang ${id} ${phase}: ${text}`)
this.senden()
}
// ── Stand fürs Fenster ──────────────────────────────────────────────────
stand(): VorgaengeStand {
const regel = regelLesen(this.einstellungen.lesen(AUFBEWAHRUNG_SCHLUESSEL, ''))
const liste = this.db?.vorgangListe() ?? []
let rohGesamtBytes = 0
const vorgaenge: VorgangEintrag[] = liste.map((v) => {
const rohDa = v.rohGeloeschtAm === null && existsSync(v.rohOrdner)
const rohBytes = rohDa ? ordnerGroesse(v.rohOrdner) : 0
rohGesamtBytes += rohBytes
const auftrag = auftragAusJson(v.auftrag)
const ruhig = v.phase === 'fertig' || v.phase === 'fehler' || v.phase === 'abgebrochen'
return {
id: v.id,
laufwerk: v.laufwerk,
typ: v.typ,
titel: v.titel,
jahr: v.jahr,
phase: v.phase,
begonnen: v.begonnen,
beendet: v.beendet,
rohOrdner: v.rohOrdner,
rohBytes,
rohGeloescht: !rohDa,
zielOrdner: v.zielOrdner,
fertigBytes: v.fertigBytes,
komprimiert: v.komprimiert,
preset: v.preset,
fehler: v.fehler,
titelZahl: auftrag?.dateien.length ?? 0,
neuKomprimierbar: rohDa && ruhig && auftrag !== null && auftrag.dateien.length > 0 && !this.warteschlange.kennt(v.id),
faelligAm: v.phase === 'fertig' && v.komprimiert && rohDa ? faelligAm(v.beendet, regel) : '',
}
})
const bekannte = new Set(liste.map((v) => v.rohOrdner))
const fremde = fremdeOrdner(this.einstellungen.rohWurzel(), bekannte)
for (const f of fremde) rohGesamtBytes += f.bytes
return {
vorgaenge,
rohGesamtBytes,
fremde,
regel: regelText(regel),
kompression: { ...this.warteschlange.stand(), pausiert: this.warteschlange.istPausiert() },
statistik: statistikAus(liste),
}
}
senden(): void {
try {
this.kontext.anHauptUndFenster({ art: 'vorgaenge', stand: this.stand() })
} catch (fehler) {
this.kontext.fehler(`Vorgänge: ${String(fehler)}`)
}
this.speicherSenden()
}
// ── Pause (5.5.0) ───────────────────────────────────────────────────────
pause(an: boolean): void {
this.warteschlange.pausieren(an)
this.kontext.hinweis(an ? 'Kompression angehalten — der laufende Film wird fertig, danach wartet die Schlange.' : 'Kompression läuft weiter.')
this.senden()
}
// ── Roh-Aufräumen ───────────────────────────────────────────────────────
rohAufraeumenLauf(): void {
if (this.db === null) return
try {
const regel = regelLesen(this.einstellungen.lesen(AUFBEWAHRUNG_SCHLUESSEL, ''))
const kandidaten = this.db.vorgangListe().map((v) => ({
id: v.id,
phase: v.phase,
komprimiert: v.komprimiert,
rohOrdner: v.rohOrdner,
rohGeloeschtAm: v.rohGeloeschtAm,
beendet: v.beendet,
fertigeDateien: dateienAusJson(v.fertigeDateien, (g) => this.kontext.protokoll.schreiben(`${v.id}: ${g}`)),
}))
const ergebnis = aufraeumen(kandidaten, regel, new Date())
const jetzt = new Date().toISOString()
for (const id of ergebnis.geloescht) {
this.db.vorgangAktualisieren(id, { rohGeloeschtAm: jetzt, rohBytes: this.db.vorgangLesen(id)?.rohBytes ?? 0 })
this.kontext.hinweis(`Roh-Dateien von „${this.db.vorgangLesen(id)?.titel ?? id}" gelöscht (${regelText(regel)})`)
}
for (const f of ergebnis.fehler) this.kontext.fehler(`Roh-Ordner von ${f.id} nicht gelöscht: ${f.grund}`)
} catch (fehler) {
this.kontext.fehler(`Roh-Aufräumen: ${String(fehler)}`)
}
this.senden()
}
rohLoeschen(vorgangId: string): void {
const v = this.db?.vorgangLesen(vorgangId)
if (v === null || v === undefined) {
this.kontext.fehler(`Vorgang ${vorgangId}: unbekannt.`)
return
}
if (v.phase === 'rippt' || v.phase === 'wartet' || v.phase === 'komprimiert' || v.phase === 'legt-ab' || this.warteschlange.kennt(vorgangId)) {
this.kontext.fehler(`${v.titel}" läuft noch (${v.phase}) — erst abbrechen, dann löschen.`)
return
}
// Ein alter Roh-Ordner darf auch unter einer FRÜHEREN Roh-Wurzel liegen
// (Arbeitsordner gewechselt) — geprüft wird nur, dass er ein Ordner
// direkt unter irgendeinem roh\ ist.
if (!/[\\/]roh[\\/][^\\/]+$/i.test(v.rohOrdner)) {
this.kontext.fehler(`Roh-Ordner von „${v.titel}" liegt nicht unter einem roh\\-Ordner — nicht angefasst.`)
return
}
try {
rmSync(v.rohOrdner, { recursive: true, force: true })
this.db?.vorgangAktualisieren(vorgangId, { rohGeloeschtAm: new Date().toISOString() })
this.kontext.hinweis(`Roh-Dateien von „${v.titel}" gelöscht (von Hand).`)
} catch (fehler) {
this.kontext.fehler(`Roh-Ordner von „${v.titel}" nicht gelöscht: ${String(fehler)}`)
}
this.senden()
}
fremdenOrdnerLoeschen(ordner: string): void {
if (!istUnterRohWurzel(this.einstellungen.rohWurzel(), ordner)) {
this.kontext.fehler(`${ordner}" liegt nicht direkt unter ${this.einstellungen.rohWurzel()} — nicht angefasst.`)
return
}
const bekannt = (this.db?.vorgangListe() ?? []).some((v) => v.rohOrdner.toLowerCase() === ordner.toLowerCase())
if (bekannt) {
this.kontext.fehler(`${ordner}" gehört zu einem Vorgang — dort löschen.`)
return
}
try {
rmSync(ordner, { recursive: true, force: true })
this.kontext.hinweis(`Ordner „${ordner}" gelöscht (von Hand).`)
} catch (fehler) {
this.kontext.fehler(`${ordner}" nicht gelöscht: ${String(fehler)}`)
}
this.senden()
}
/** „Wiederholen ab Kompression": Die Roh-Dateien liegen genau dafür da. */
neuKomprimieren(vorgangId: string, preset: string): void {
const v = this.db?.vorgangLesen(vorgangId)
if (v === null || v === undefined) {
this.kontext.fehler(`Vorgang ${vorgangId}: unbekannt.`)
return
}
if (this.warteschlange.kennt(vorgangId)) {
this.kontext.fehler(`${v.titel}" ist schon in der Warteschlange.`)
return
}
const auftrag = auftragAusJson(v.auftrag)
if (auftrag === null) {
this.kontext.fehler(`${v.titel}": Der Auftrag ist nicht mehr lesbar — nur ein neuer Rip hilft.`)
return
}
const vorhandene = auftrag.dateien.filter((d) => existsSync(d.pfad))
if (vorhandene.length === 0) {
this.kontext.fehler(`${v.titel}": Die Roh-Dateien sind nicht mehr da (${v.rohOrdner}).`)
return
}
if (vorhandene.length < auftrag.dateien.length) {
this.kontext.hinweis(`${v.titel}": ${auftrag.dateien.length - vorhandene.length} Roh-Datei(en) fehlen — der Rest wird komprimiert.`)
}
const alteFassung = v.phase === 'fertig' && v.zielOrdner.length > 0 && existsSync(v.zielOrdner)
const neu: KompressionsAuftrag = {
...auftrag,
dateien: vorhandene,
preset,
// Die neue Fassung ersetzt die alte im selben Ordner — die alten
// Dateien fallen erst, wenn die neuen fertig sind (ablegen.ts).
zielOrdner: alteFassung ? v.zielOrdner : '',
ersetzt: alteFassung ? dateienAusJson(v.fertigeDateien) : [],
}
this.db?.vorgangAktualisieren(vorgangId, { phase: 'wartet', fehler: '', auftrag: auftragZuJson(neu) })
const platz = this.warteschlange.einreihen(neu, v.titel)
this.kontext.hinweis(`${v.titel}": Kompression eingereiht${platz > 1 ? ` (Platz ${platz})` : ''}${preset.length > 0 ? ` mit Preset „${preset}"` : ''}.`)
this.senden()
}
/** Nach einem Neustart: Was wartete noch? Was war mitten im Rip? */
wiederaufnehmen(): void {
if (this.db === null) return
const jetzt = new Date().toISOString()
for (const v of this.db.vorgangListe()) {
if (v.phase === 'rippt') {
this.db.vorgangAktualisieren(v.id, {
phase: 'abgebrochen',
beendet: jetzt,
fehler: 'Rippy wurde beendet, bevor der Rip fertig war — die Roh-Dateien können unvollständig sein.',
})
this.kontext.protokoll.schreiben(`Vorgang ${v.id}: war beim letzten Beenden mitten im Rip — als abgebrochen vermerkt`)
continue
}
if (v.phase === 'wartet' || v.phase === 'komprimiert' || v.phase === 'legt-ab') {
const auftrag = auftragAusJson(v.auftrag)
if (auftrag !== null && existsSync(v.rohOrdner) && auftrag.dateien.some((d) => existsSync(d.pfad))) {
this.warteschlange.einreihen({ ...auftrag, dateien: auftrag.dateien.filter((d) => existsSync(d.pfad)) }, v.titel)
this.kontext.hinweis(`${v.titel}": Kompression nach dem Neustart wieder aufgenommen.`)
} else {
this.db.vorgangAktualisieren(v.id, {
phase: 'fehler',
beendet: jetzt,
fehler: 'Nach dem Neustart fehlen die Roh-Dateien — die Kompression kann nicht wieder aufgenommen werden.',
})
}
}
}
}
}
+198
View File
@@ -0,0 +1,198 @@
// Die Werkzeuge (§ 9): wo makemkvcon, HandBrakeCLI und flac liegen, die
// gemessenen HandBrake-Listen (Presets, Encoder), die MakeMKV-Schlüsselkette
// (§ 9.1) samt Auswahlregel (5.4.0) und das MakeMKV-Selbst-Update.
import type { KernNachricht } from '../../gemeinsam/nachrichten'
import type { KernKontext } from '../kontext'
import { finden } from '../werkzeuge/katalog'
import type { EinstellungsDienst } from './einstellungen'
export type SchluesselStand = Extract<KernNachricht, { art: 'schluessel-status' }>['stand']
export class WerkzeugDienst {
/** Die gemessene Auskunft bleibt liegen die Pipeline braucht sie für die
* Preset-Empfehlung, auch wenn gerade kein Fenster offen ist. */
auskunft: { presets: string[]; backends: string[] } = { presets: [], backends: [] }
schluesselStand: SchluesselStand | null = null
private beschaffungLaeuft = false
private installerAntwort: ((fehler: string | null) => void) | null = null
constructor(
private readonly kontext: KernKontext,
private readonly einstellungen: EinstellungsDienst,
) {}
makemkvcon(): string {
return finden('makemkv', this.einstellungen.lesen('werkzeug.makemkv'))
}
handbrake(): string {
return finden('handbrake', this.einstellungen.lesen('werkzeug.handbrake'))
}
flac(): string {
return finden('flac', this.einstellungen.lesen('werkzeug.flac'))
}
/** Werkzeuge suchen, HandBrake messen, ans Fenster. */
async senden(): Promise<void> {
const makemkvcon = this.makemkvcon()
const handbrake = this.handbrake()
const flac = this.flac()
const { encoderMessen } = await import('../komprimieren/encoder')
const gemessen = await encoderMessen(handbrake)
this.auskunft = { presets: gemessen.presets, backends: gemessen.backends }
this.kontext.anFenster({
art: 'werkzeuge',
auskunft: { makemkvcon, handbrake, flac, presets: gemessen.presets, backends: gemessen.backends },
})
this.kontext.protokoll.schreiben(
`Werkzeuge: makemkvcon=${makemkvcon || 'FEHLT'} handbrake=${handbrake || 'FEHLT'} flac=${flac || 'FEHLT'} · ${gemessen.presets.length} Presets · Encoder ${gemessen.backends.join(',') || '—'}`,
)
}
// ── MakeMKV-Schlüsselkette (§ 9.1): beim Start und täglich ──────────────
async schluesselLauf(): Promise<void> {
if (process.platform !== 'win32') return
try {
const { schluesselKette } = await import('../werkzeuge/schluessel')
this.schluesselStand = await schluesselKette(
this.makemkvcon(),
this.einstellungen.lesen('makemkvAppKey'),
new Date().toISOString(),
)
this.kontext.anHauptUndFenster({ art: 'schluessel-status', stand: this.schluesselStand })
this.kontext.protokoll.schreiben(`Schlüssel: ${this.schluesselStand.text}`)
} catch (fehler) {
// R4: Der Schlüssel-Lauf ist Komfort — sein Scheitern wird gesagt.
this.kontext.fehler(`Schlüsselkette: ${String(fehler)}`)
}
}
/** MakeMKVs Spur-Auswahlregel sicherstellen (5.4.0) sonst fehlen
* Sprachen in der Roh-Datei, die der Dialog anbot. */
async spurauswahlLauf(): Promise<void> {
if (process.platform !== 'win32') return
try {
const { spurauswahlSicherstellen } = await import('../werkzeuge/spurauswahl')
const stand = spurauswahlSicherstellen()
if (stand.stand === 'eigen' || stand.stand === 'fehler') {
this.kontext.fehler(`MakeMKV-Auswahlregel: ${stand.text}`)
} else if (stand.stand === 'gesetzt') {
this.kontext.hinweis(stand.text)
} else {
this.kontext.protokoll.schreiben(stand.text)
}
} catch (fehler) {
this.kontext.fehler(`MakeMKV-Auswahlregel: ${String(fehler)}`)
}
}
/** Antwort des Haupts auf 'installer-starten'. */
installerErgebnis(fehler: string | null): void {
this.installerAntwort?.(fehler ?? '')
this.installerAntwort = null
}
private beschaffungMelden(text: string, anteil: number | null, laeuft: boolean, fehler = false): void {
this.kontext.anFenster({ art: 'beschaffung-status', stand: { werkzeug: 'makemkv', laeuft, text, anteil, fehler } })
this.kontext.protokoll.schreiben(`MakeMKV-Update: ${text}`)
}
// ── MakeMKV-Update (§ 9): holen, prüfen, installieren lassen ────────────
// Der Kern lädt und PRÜFT die Datei; STARTEN kann sie nur das Haupt
// (shell → UAC-Abfrage; der erhöhte Installer hängt dadurch nicht an der
// Prozess-Leine und überlebt ein geschlossenes Rippy).
async makemkvUpdateLauf(): Promise<void> {
if (this.beschaffungLaeuft) {
this.beschaffungMelden('Ein MakeMKV-Update läuft bereits.', null, true)
return
}
this.beschaffungLaeuft = true
try {
const { istNeuer, makemkvNeuesteVersion, makemkvInstallerHolen, versionsangaben } = await import(
'../werkzeuge/beschaffen'
)
this.beschaffungMelden('Nach der neuesten MakeMKV-Fassung wird gesucht …', null, true)
const neueste = await makemkvNeuesteVersion()
if (neueste.length === 0) {
this.beschaffungMelden(
'Keine der Quellen konnte sagen, welche Fassung aktuell ist (Hersteller-Seite, Forum, Archiv). ' +
'Ausweg: MakeMKV von Hand von makemkv.com laden und installieren; Rippy findet es danach von allein.',
null,
false,
true,
)
this.beschaffungLaeuft = false
return
}
const installiert = this.schluesselStand?.version ?? ''
if (installiert.length > 0 && !istNeuer(neueste, installiert)) {
// Ehrlich bleiben (31.08.2026 gemessen: 1.18.4 ist installiert UND
// die neueste angekündigte — die Beta ist nur abgelaufen): Ein
// Download derselben Fassung würde nichts lösen.
this.beschaffungMelden(
`MakeMKV ${installiert} ist bereits die neueste angekündigte Fassung. ` +
'Sobald der Hersteller eine neuere veröffentlicht, holt dieser Knopf sie — ' +
'bis dahin rippt der Beta-Modus weiter.',
null,
false,
)
this.beschaffungLaeuft = false
return
}
// Merken, woran eine gelungene Installation zu erkennen ist: Die
// Versions-Ressource der gefundenen EXE ändert sich (oder es gibt
// erstmals eine).
const alteExe = this.makemkvcon()
const alteVersion = alteExe.length > 0 ? versionsangaben(alteExe).version : ''
const ergebnis = await makemkvInstallerHolen(neueste, (text, anteil) => this.beschaffungMelden(text, anteil, true))
this.beschaffungMelden(`Geprüft (Quelle: ${ergebnis.quelle}) — der Installer wird gestartet …`, null, true)
const startFehler = await new Promise<string | null>((fertig) => {
this.installerAntwort = fertig
process.parentPort.postMessage({ art: 'installer-starten', pfad: ergebnis.pfad })
// Falls das Haupt nie antwortet, bleibt der Lauf nicht ewig hängen.
setTimeout(() => fertig('Keine Antwort vom Haupt-Prozess'), 30_000).unref()
})
this.installerAntwort = null
if (startFehler !== null && startFehler.length > 0) {
this.beschaffungMelden(`Der Installer wurde nicht gestartet: ${startFehler}`, null, false, true)
this.beschaffungLaeuft = false
return
}
this.beschaffungMelden(
`Der MakeMKV-Installer ${ergebnis.version} läuft — bitte die Windows-Nachfrage bestätigen und durchklicken. ` +
'Rippy merkt die fertige Installation von allein und prüft den Schlüssel neu.',
null,
true,
)
// Warten, bis die Installation durch ist: Die Versions-Ressource der
// EXE ändert sich. Alle 15 s nachsehen, höchstens 20 Minuten.
let runden = 0
const zeiger = setInterval(() => {
runden += 1
const exe = this.makemkvcon()
const jetztVersion = exe.length > 0 ? versionsangaben(exe).version : ''
if (jetztVersion.length > 0 && jetztVersion !== alteVersion) {
clearInterval(zeiger)
this.beschaffungLaeuft = false
this.beschaffungMelden(`MakeMKV ${jetztVersion} ist installiert — der Schlüssel wird neu geprüft.`, null, false)
void this.schluesselLauf().then(() => this.senden())
} else if (runden >= 80) {
clearInterval(zeiger)
this.beschaffungLaeuft = false
this.beschaffungMelden(
'Die fertige Installation wurde nicht bemerkt — nach dem Durchklicken hilft „Schlüssel neu prüfen".',
null,
false,
)
}
}, 15_000)
zeiger.unref()
} catch (fehler) {
this.beschaffungMelden(fehler instanceof Error ? fehler.message : String(fehler), null, false, true)
this.beschaffungLaeuft = false
}
}
}
File diff suppressed because it is too large Load Diff
+42
View File
@@ -0,0 +1,42 @@
// Der gemeinsame Kontext der Kern-Dienste (5.5.0, Separation of Concerns):
// Jeder Dienst bekommt genau das hier — die Datenbank, die Wege nach
// draußen und das Protokoll — statt auf Modul-Globale zu greifen. So ist
// jeder Dienst für sich lesbar und mit einem Fake-Kontext testbar.
import type { DbStatus, KernNachricht } from '../gemeinsam/nachrichten'
import type { Protokoll } from '../gemeinsam/protokoll'
import type { Datenbank } from './speicher/db'
export interface KernKontext {
/** null, wenn die Datenbank nicht zu öffnen war — der Kern läuft trotzdem (R4). */
readonly db: Datenbank | null
readonly dbStatus: DbStatus
/** Nur ans Fenster (wenn eins verbunden ist). */
anFenster(nachricht: KernNachricht): void
/** Ans Haupt UND ans Fenster — für alles, was Tray/Taskleiste/Updater brauchen. */
anHauptUndFenster(nachricht: KernNachricht): void
/** Eine Zeile ohne Warnzeichen fürs Ereignis-Protokoll — und in die Datei. */
hinweis(text: string): void
/** Eine Zeile MIT Warnzeichen (kern-fehler) — und in die Datei. */
fehler(text: string): void
readonly protokoll: Protokoll
/** Der Gerüst-Beweis (--smoke-kern): ohne Netz, ohne MakeMKV, ohne Takte. */
readonly smoke: boolean
}
/** --datenbank=<pfad> kommt vom Haupt (der Kern kennt Electrons app.getPath
* nicht er ist ein reiner Node-Prozess). */
export function datenbankPfadAusArgv(argv: readonly string[]): string | null {
for (const arg of argv) {
if (arg.startsWith('--datenbank=')) {
const pfad = arg.slice('--datenbank='.length)
if (pfad.length > 0) return pfad
}
}
return null
}
/** Einstellung lesen mit Vorgabe — die eine Quelle (§ 6.7). */
export function einstellung(kontext: Pick<KernKontext, 'db'>, schluessel: string, vorgabe = ''): string {
const wert = kontext.db?.einstellungLesen(schluessel)
return wert === null || wert === undefined ? vorgabe : wert
}
+16
View File
@@ -185,6 +185,22 @@ export class TmdbClient {
})) as FilmDetails | null
}
/** Poster beliebter Filme (5.5.0, Hintergrund der Erst-Einrichtung):
* /movie/popular liefert results[].poster_path dieselbe Form wie die
* Suche (FilmTreffer). Zwei Seiten à 20 reichen für ein volles Raster. */
async beliebtePoster(hoechstens = 40): Promise<string[]> {
const pfade: string[] = []
for (let seite = 1; seite <= 2 && pfade.length < hoechstens; seite++) {
const daten = (await this.anfrage('movie/popular', { language: 'de-DE', page: String(seite) })) as {
results?: FilmTreffer[]
} | null
for (const f of daten?.results ?? []) {
if (typeof f.poster_path === 'string' && f.poster_path.length > 0) pfade.push(f.poster_path)
}
}
return pfade.slice(0, hoechstens)
}
/** Staffel-Details mit Episoden-Laufzeiten — für die Serien-Zuordnung. */
async tvStaffel(id: number, staffel: number): Promise<{ episodes?: Array<{ episode_number: number; runtime?: number }> } | null> {
return (await this.anfrage(`tv/${id}/season/${staffel}`, { language: 'de-DE' })) as {
+3
View File
@@ -10,6 +10,9 @@
// MakeMKV will den Laufwerksbuchstaben. Entschieden wird nach der FORM des
// Pfades, nicht nach dem Rechner.
export function quelle(geraetepfad: string): string {
// 5.5.0: Das Rettungs-Abbild ist eine ISO-Datei — makemkvcon liest sie
// als `iso:<pfad>` (Manpage makemkvcon: „iso:<FileName>").
if (/\.iso$/i.test(geraetepfad)) return `iso:${geraetepfad}`
let pfad = (geraetepfad ?? '').trim()
if (pfad.length === 0) return ''
for (const vorsatz of ['\\\\.\\', '\\\\?\\']) {
+185
View File
@@ -0,0 +1,185 @@
// Das Rettungs-Abbild (5.5.0, Commander: „korrupte Discs … selbst
// reparieren, mit 0 füllen") — Rippys eigener Sektor-Leser, wie beim
// ISO-Backup (iso.ts), aber mit Wiederholung in immer kleineren Stücken und
// Nullen für das, was die Disc nicht mehr hergibt:
//
// 1 MiB am Stück → scheitert: 64-KiB-Stücke, je zwei Versuche →
// scheitert: einzelne 2048-Byte-Sektoren, je zwei Versuche → Nullen.
//
// Das Ergebnis ist ein Abbild ohne Loch, das MakeMKV als `iso:` lesen kann
// (Manpage makemkvcon; MakeMKV-FAQ „decrypted backup"): Ein genullter
// Sektor ist für MakeMKV kein Lesefehler mehr, an dem es hängen bleibt —
// nur eine Bildstörung von Sekundenbruchteilen an dieser Stelle. Was
// wirklich unlesbar ist, holt niemand zurück; aber der Rest der Disc geht
// nicht mehr verloren. Läuft NUR, wenn Rippy vorher Lesefehler gesehen
// hat (Defekt-Wächter, Lesefehler-Meldung) — eine heile Disc braucht das
// nicht (Commander 01.09.2026).
//
// PUR bis auf die injizierten Lese-/Schreibfunktionen: Die Ampel prüft die
// Wiederholungs- und Nullfüll-Logik mit einem nachgebauten Gerät.
import { open } from 'node:fs/promises'
import { writeFileSync } from 'node:fs'
export const BLOCK_BYTES = 1024 * 1024
export const FEIN_BYTES = 64 * 1024
export const SEKTOR_BYTES = 2048
export interface UnlesbareStelle {
offset: number
bytes: number
}
export type RettungsErgebnis =
| { status: 'success'; bytes: number; unlesbareSektoren: number; stellen: UnlesbareStelle[] }
| { status: 'cancelled'; bytes: number; unlesbareSektoren: number; stellen: UnlesbareStelle[] }
| { status: 'error'; fehler: string; bytes: number; unlesbareSektoren: number; stellen: UnlesbareStelle[] }
/** Liest `laenge` Bytes ab `position` in `puffer` (ab 0) und liefert, wie
* viele es wurden. Wirft bei einem Lesefehler. */
export type Leser = (puffer: Buffer, laenge: number, position: number) => Promise<number>
export type Schreiber = (puffer: Buffer, laenge: number) => Promise<void>
export interface RettungsOptionen {
leser: Leser
schreiber: Schreiber
erwarteteBytes: number
aufFortschritt?: (prozent: number, unlesbareSektoren: number, stellen: number) => void
abbruch?: AbortSignal
/** Versuche je Stück — Vorgabe 2. */
versuche?: number
}
async function lesenMitVersuchen(leser: Leser, puffer: Buffer, laenge: number, position: number, versuche: number): Promise<boolean> {
for (let i = 0; i < versuche; i++) {
try {
const n = await leser(puffer, laenge, position)
if (n === laenge) return true
} catch {
// Ein Fehler ist ein Versuch weniger — mehr sagt das Gerät nicht.
}
}
return false
}
/** Zusammenhängende unlesbare Bereiche zu Stellen zusammenfassen (pur). */
export function stelleHinzu(stellen: UnlesbareStelle[], offset: number, bytes: number): void {
const letzte = stellen[stellen.length - 1]
if (letzte !== undefined && letzte.offset + letzte.bytes === offset) {
letzte.bytes += bytes
return
}
stellen.push({ offset, bytes })
}
export async function rettungsAbbild(o: RettungsOptionen): Promise<RettungsErgebnis> {
const versuche = o.versuche ?? 2
const puffer = Buffer.alloc(BLOCK_BYTES)
const feinPuffer = Buffer.alloc(FEIN_BYTES)
const sektorPuffer = Buffer.alloc(SEKTOR_BYTES)
const stellen: UnlesbareStelle[] = []
let unlesbareSektoren = 0
let gelesen = 0
let letzterProzent = -1
const melden = (): void => {
const prozent = o.erwarteteBytes > 0 ? Math.floor((gelesen * 100) / o.erwarteteBytes) : 0
if (prozent !== letzterProzent) {
letzterProzent = prozent
o.aufFortschritt?.(prozent, unlesbareSektoren, stellen.length)
}
}
if (o.erwarteteBytes <= 0) {
return { status: 'error', fehler: 'Die Größe der Disc ist unbekannt — ohne sie gibt es kein Abbild.', bytes: 0, unlesbareSektoren, stellen }
}
try {
while (gelesen < o.erwarteteBytes) {
if (o.abbruch?.aborted === true) return { status: 'cancelled', bytes: gelesen, unlesbareSektoren, stellen }
const n = Math.min(BLOCK_BYTES, o.erwarteteBytes - gelesen)
if (!(await lesenMitVersuchen(o.leser, puffer, n, gelesen, 1))) {
// Der Block klemmt — feiner lesen, nur das wirklich Kaputte nullen.
for (let teil = 0; teil < n; teil += FEIN_BYTES) {
const m = Math.min(FEIN_BYTES, n - teil)
if (await lesenMitVersuchen(o.leser, feinPuffer, m, gelesen + teil, versuche)) {
feinPuffer.copy(puffer, teil, 0, m)
continue
}
for (let s = 0; s < m; s += SEKTOR_BYTES) {
const k = Math.min(SEKTOR_BYTES, m - s)
if (await lesenMitVersuchen(o.leser, sektorPuffer, k, gelesen + teil + s, versuche)) {
sektorPuffer.copy(puffer, teil + s, 0, k)
} else {
puffer.fill(0, teil + s, teil + s + k)
unlesbareSektoren += Math.ceil(k / SEKTOR_BYTES)
stelleHinzu(stellen, gelesen + teil + s, k)
}
}
}
}
await o.schreiber(puffer, n)
gelesen += n
melden()
}
} catch (fehler) {
return { status: 'error', fehler: `Schreiben brach bei ${gelesen} Bytes ab: ${String(fehler)}`, bytes: gelesen, unlesbareSektoren, stellen }
}
return { status: 'success', bytes: gelesen, unlesbareSektoren, stellen }
}
/** Der Bericht neben dem Abbild — Klartext für den Commander. */
export function rettungsBericht(quelle: string, ergebnis: RettungsErgebnis, erwarteteBytes: number): string {
const zeilen = [
`Rettungs-Abbild von ${quelle}`,
`Stand: ${ergebnis.status === 'success' ? 'vollständig' : ergebnis.status === 'cancelled' ? 'abgebrochen' : 'Fehler'}`,
`Gelesen: ${ergebnis.bytes} von ${erwarteteBytes} Bytes`,
`Unlesbare Sektoren: ${ergebnis.unlesbareSektoren} (${((ergebnis.unlesbareSektoren * SEKTOR_BYTES * 100) / Math.max(1, erwarteteBytes)).toFixed(4)} %) an ${ergebnis.stellen.length} Stellen`,
]
if (ergebnis.status === 'error') zeilen.push(`Fehler: ${ergebnis.fehler}`)
for (const s of ergebnis.stellen.slice(0, 200)) zeilen.push(` ab Byte ${s.offset}: ${s.bytes} Bytes genullt`)
if (ergebnis.stellen.length > 200) zeilen.push(` … und ${ergebnis.stellen.length - 200} weitere Stellen`)
return zeilen.join('\n') + '\n'
}
/** Vom Gerät in eine Datei — mit Bericht daneben (<ziel>.rettung.txt). */
export async function rettungsAbbildDatei(
geraetepfad: string,
zielDatei: string,
erwarteteBytes: number,
aufFortschritt?: (prozent: number, unlesbareSektoren: number, stellen: number) => void,
abbruch?: AbortSignal,
): Promise<RettungsErgebnis> {
let quelle
try {
quelle = await open(geraetepfad, 'r')
} catch (fehler) {
return { status: 'error', fehler: `Gerät nicht lesbar: ${String(fehler)}`, bytes: 0, unlesbareSektoren: 0, stellen: [] }
}
let ziel
try {
ziel = await open(zielDatei, 'w')
} catch (fehler) {
await quelle.close()
return { status: 'error', fehler: `Zieldatei nicht anlegbar: ${String(fehler)}`, bytes: 0, unlesbareSektoren: 0, stellen: [] }
}
const q = quelle
const z = ziel
let ergebnis: RettungsErgebnis
try {
ergebnis = await rettungsAbbild({
leser: async (puffer, laenge, position) => (await q.read(puffer, 0, laenge, position)).bytesRead,
schreiber: async (puffer, laenge) => {
await z.write(puffer, 0, laenge)
},
erwarteteBytes,
aufFortschritt,
abbruch,
})
} finally {
await q.close()
await z.close()
}
try {
writeFileSync(`${zielDatei}.rettung.txt`, rettungsBericht(geraetepfad, ergebnis, erwarteteBytes))
} catch {
// Der Bericht ist Beigabe — das Abbild zählt.
}
return ergebnis
}
+198
View File
@@ -0,0 +1,198 @@
// Tests der Automatik (kern/dienste/automatik.ts, 5.5.0): Rippys Vorschlag
// als Auftrag, Staffel/Folge aus Disc und Serien-Merker, und der Ablauf
// mit Fake-Uhr — sicher → Titel lesen → Countdown → Rip; unsicher → nur
// sagen, warum; Stopp und Jetzt.
import { describe, expect, it } from 'vitest'
import type { AutomatikStand, GeraeteInfo, KernNachricht, RipAuftragOptionen, RipAuftragTitel, TitelStand } from '../src/gemeinsam/nachrichten'
import type { Protokoll } from '../src/gemeinsam/protokoll'
import { AUTOMATIK_SICHER, AutomatikDienst, auftragAusVorschlag, optionenAus, type AutomatikAbhaengigkeiten } from '../src/kern/dienste/automatik'
import type { KernKontext } from '../src/kern/kontext'
import type { Zuordnung } from '../src/kern/metadaten/zuordnung'
function titel(nr: number, gewaehlt: boolean, rolle: 'hauptfilm' | 'extra' | 'folge' = 'hauptfilm'): TitelStand['titel'][number] {
return { nr, dauerS: 3000 + nr, groesseBytes: 1e9 * (nr + 1), kapitel: 12, gewaehlt, rolle, grund: gewaehlt ? 'Hauptfilm' : 'zu kurz', sprachen: { audio: [], untertitel: [] } }
}
function stand(teile: TitelStand['titel'], unsicher = false, fehler = ''): TitelStand {
return { id: 'G', laeuft: false, titel: teile, einordnung: unsicher ? 'zwei fast gleich lange Titel — bitte nachsehen' : 'Film: ein Hauptfilm', unsicher, fehler }
}
function geraet(typ: GeraeteInfo['typ'] = 'bluray'): GeraeteInfo {
return { id: 'G', name: 'G:', typ, pfad: 'G:\\', status: 'ready', modell: 'Fake', seriennummer: '', groesseBytes: 4e10, grund: '' }
}
function zuordnung(confidence: number): Zuordnung {
return { titel: 'Akira', jahr: 1988, confidence, meta: {} as never }
}
describe('auftragAusVorschlag', () => {
it('nimmt nur die vorausgewählten Titel, mit Rolle, Dauer und Größe — Sprachen leer (Einstellung gilt)', () => {
const auftrag = auftragAusVorschlag(stand([titel(0, true), titel(1, false), titel(2, true, 'extra')]))
expect(auftrag).toEqual<RipAuftragTitel[]>([
{ nr: 0, rolle: 'hauptfilm', dauerS: 3000, groesseBytes: 1e9 },
{ nr: 2, rolle: 'extra', dauerS: 3002, groesseBytes: 3e9 },
])
})
})
describe('optionenAus', () => {
it('ohne alles: nichts vorgeben', () => {
expect(optionenAus(null, null)).toEqual<RipAuftragOptionen>({ staffel: null, ersteFolge: null })
})
it('die Staffel der Disc schlägt den Merker', () => {
expect(optionenAus({ staffel: 2, discNr: 2 }, { staffel: 1, naechsteFolge: 5 })).toEqual({ staffel: 2, ersteFolge: 5 })
})
it('Disc 1 fängt nie beim Merker an — sonst begänne die neue Ausgabe bei Folge 5', () => {
expect(optionenAus({ staffel: null, discNr: 1 }, { staffel: 1, naechsteFolge: 5 })).toEqual({ staffel: 1, ersteFolge: null })
})
it('Disc-Nummer unbekannt oder > 1: ab der gemerkten Folge weiter', () => {
expect(optionenAus({ staffel: null, discNr: null }, { staffel: 1, naechsteFolge: 3 })).toEqual({ staffel: 1, ersteFolge: 3 })
expect(optionenAus({ staffel: null, discNr: 3 }, { staffel: 1, naechsteFolge: 9 })).toEqual({ staffel: 1, ersteFolge: 9 })
})
})
function aufbau(sekunden = 3) {
const nachrichten: AutomatikStand[] = []
const hinweise: string[] = []
const kontext = {
db: null,
dbStatus: { ok: false, fehler: 'Test' },
anFenster: (n: KernNachricht) => {
if (n.art === 'automatik') nachrichten.push(n.stand)
},
anHauptUndFenster: () => {},
hinweis: (t: string) => hinweise.push(t),
fehler: () => {},
protokoll: { schreiben: () => {} } as unknown as Protokoll,
smoke: true,
} as unknown as KernKontext
const gestartet: Array<{ id: string; auftrag: RipAuftragTitel[]; optionen: RipAuftragOptionen }> = []
const benachrichtigt: string[] = []
let tick: (() => void) | null = null
let gestoppt = 0
let an = true
let ripLaeuft = false
let titelStand: TitelStand = stand([titel(0, true), titel(1, false)])
let angaben: { staffel: number | null; discNr: number | null } | null = null
let merker: { staffel: number; naechsteFolge: number } | null = null
const abh: AutomatikAbhaengigkeiten = {
an: () => an,
sekunden: () => sekunden,
ripLaeuft: () => ripLaeuft,
titelLesen: async () => titelStand,
ripStarten: (id, auftrag, optionen) => gestartet.push({ id, auftrag, optionen }),
discAngaben: () => angaben,
serienMerker: () => merker,
benachrichtigen: (t, x) => benachrichtigt.push(`${t}: ${x}`),
takt: (fn) => {
tick = fn
return 1 as unknown as NodeJS.Timeout
},
taktStoppen: () => {
gestoppt += 1
tick = null
},
}
const dienst = new AutomatikDienst(kontext, abh)
return {
dienst,
nachrichten,
hinweise,
gestartet,
benachrichtigt,
tick: () => {
if (tick === null) throw new Error('kein Takt')
tick()
},
gestopptZahl: () => gestoppt,
setAn: (w: boolean) => (an = w),
setRipLaeuft: (w: boolean) => (ripLaeuft = w),
setTitel: (s: TitelStand) => (titelStand = s),
setAngaben: (a: typeof angaben) => (angaben = a),
setMerker: (m: typeof merker) => (merker = m),
}
}
describe('AutomatikDienst', () => {
it('sicher erkannt, nicht gerippt: liest Titel, zählt herunter, startet mit Rippys Vorschlag', async () => {
const a = aufbau(3)
await a.dienst.pruefen(geraet(), zuordnung(0.95), null)
expect(a.nachrichten[0].text).toContain('liest die Titel')
expect(a.nachrichten[1]).toMatchObject({ aktiv: true, sekunden: 3 })
a.tick()
a.tick()
expect(a.nachrichten[a.nachrichten.length - 1]).toMatchObject({ aktiv: true, sekunden: 1 })
expect(a.gestartet).toHaveLength(0)
a.tick()
expect(a.gestartet).toHaveLength(1)
expect(a.gestartet[0].auftrag).toEqual([{ nr: 0, rolle: 'hauptfilm', dauerS: 3000, groesseBytes: 1e9 }])
expect(a.nachrichten[a.nachrichten.length - 1]).toMatchObject({ aktiv: false, text: 'Automatik: Rip gestartet (1 Titel).' })
expect(a.benachrichtigt[0]).toContain('Automatik hat auf Laufwerk G gestartet')
expect(a.gestopptZahl()).toBe(1)
})
it('unsichere Erkennung: nichts passiert von selbst — nur der Grund', async () => {
const a = aufbau()
await a.dienst.pruefen(geraet(), zuordnung(AUTOMATIK_SICHER - 0.01), null)
expect(a.nachrichten).toHaveLength(1)
expect(a.nachrichten[0]).toMatchObject({ aktiv: false })
expect(a.nachrichten[0].text).toContain('nicht sicher genug')
expect(a.gestartet).toHaveLength(0)
})
it('schon gerippt: wartet und nennt das Datum', async () => {
const a = aufbau()
await a.dienst.pruefen(geraet(), zuordnung(0.99), '2026-08-30T10:00:00Z')
expect(a.nachrichten[0].text).toContain('schon gerippt (2026-08-30)')
expect(a.gestartet).toHaveLength(0)
})
it('aus, CD, unbekannte Disc oder laufender Rip: gar nichts', async () => {
const a = aufbau()
a.setAn(false)
await a.dienst.pruefen(geraet(), zuordnung(0.99), null)
a.setAn(true)
await a.dienst.pruefen(geraet('cd'), zuordnung(0.99), null)
await a.dienst.pruefen(geraet('unknown'), zuordnung(0.99), null)
a.setRipLaeuft(true)
await a.dienst.pruefen(geraet(), zuordnung(0.99), null)
expect(a.nachrichten).toHaveLength(0)
})
it('unsicherer Titel-Lauf (zwei fast gleich lange Titel): kein Countdown, die Einordnung wird gesagt', async () => {
const a = aufbau()
a.setTitel(stand([titel(0, true), titel(1, false)], true))
await a.dienst.pruefen(geraet(), zuordnung(0.99), null)
expect(a.nachrichten[a.nachrichten.length - 1].text).toContain('bitte nachsehen')
expect(a.gestartet).toHaveLength(0)
})
it('Stopp beendet den Countdown mit Grund; Jetzt überspringt ihn', async () => {
const a = aufbau(30)
await a.dienst.pruefen(geraet(), zuordnung(0.99), null)
a.dienst.stopp('G', 'Automatik gestoppt — du entscheidest.')
expect(a.gestopptZahl()).toBe(1)
expect(a.nachrichten[a.nachrichten.length - 1]).toMatchObject({ aktiv: false, text: 'Automatik gestoppt — du entscheidest.' })
expect(() => a.tick()).toThrow('kein Takt')
// Jetzt braucht einen aktiven Countdown und den gemerkten Titel-Stand.
a.dienst.jetzt('G')
expect(a.gestartet).toHaveLength(0)
await a.dienst.pruefen(geraet(), zuordnung(0.99), null)
a.dienst.titelMerken('G', stand([titel(0, true), titel(1, false)]))
a.dienst.jetzt('G')
expect(a.gestartet).toHaveLength(1)
})
it('Serie: Staffel/Folge aus Disc und Merker, Merker nur mit gemerkter Zuordnung', async () => {
const a = aufbau(1)
a.setTitel(stand([titel(0, true, 'folge'), titel(1, true, 'folge')]))
a.setAngaben({ staffel: null, discNr: 2 })
a.setMerker({ staffel: 1, naechsteFolge: 3 })
a.dienst.zuordnungMerken('G', zuordnung(0.99))
await a.dienst.pruefen(geraet(), zuordnung(0.99), null)
a.tick()
expect(a.gestartet[0].optionen).toEqual({ staffel: 1, ersteFolge: 3 })
})
it('Disc raus: Countdown weg, Stand weg', async () => {
const a = aufbau(5)
await a.dienst.pruefen(geraet(), zuordnung(0.99), null)
expect(a.dienst.stand('G')).not.toBeNull()
a.dienst.discEntnommen('G')
expect(a.dienst.stand('G')).toBeNull()
expect(a.gestopptZahl()).toBe(1)
})
})
+112
View File
@@ -0,0 +1,112 @@
// Tests der Benachrichtigung (kern/dienste/benachrichtigung.ts, 5.5.0):
// Was an Discord und Telegram geht (Form laut deren Doku), welche Kanäle
// eingerichtet sind, und dass ein Fehlschlag gesagt wird, aber nichts
// kippt.
import { describe, expect, it } from 'vitest'
import type { KernNachricht } from '../src/gemeinsam/nachrichten'
import type { Protokoll } from '../src/gemeinsam/protokoll'
import { BenachrichtigungsDienst, discordAnfrage, kanaeleAus, kanaeleEingerichtet, telegramAnfrage } from '../src/kern/dienste/benachrichtigung'
import type { EinstellungsDienst } from '../src/kern/dienste/einstellungen'
import type { KernKontext } from '../src/kern/kontext'
function fakeKontext() {
const fehler: string[] = []
const hinweise: string[] = []
const zeilen: string[] = []
const nachrichten: KernNachricht[] = []
const kontext = {
db: null,
dbStatus: { ok: false, fehler: 'Test' },
anFenster: (n: KernNachricht) => nachrichten.push(n),
anHauptUndFenster: (n: KernNachricht) => nachrichten.push(n),
hinweis: (t: string) => hinweise.push(t),
fehler: (t: string) => fehler.push(t),
protokoll: { schreiben: (t: string) => zeilen.push(t) } as unknown as Protokoll,
smoke: true,
} as unknown as KernKontext
return { kontext, fehler, hinweise, zeilen, nachrichten }
}
function fakeEinstellungen(werte: Record<string, string>): EinstellungsDienst {
return { lesen: (s: string, vorgabe = '') => werte[s] ?? vorgabe } as unknown as EinstellungsDienst
}
describe('discordAnfrage / telegramAnfrage', () => {
it('Discord: POST an den Webhook mit { content, username } — Titel fett, dann Text', () => {
const a = discordAnfrage('https://discord.com/api/webhooks/1/abc', 'Rip fertig', 'Akira (1988)')
expect(a.url).toBe('https://discord.com/api/webhooks/1/abc')
const body = JSON.parse(a.body) as { content: string; username: string }
expect(body.content).toBe('**Rip fertig**\nAkira (1988)')
expect(body.username).toBe('Rippy')
})
it('Discord: content bleibt unter 2000 Zeichen', () => {
const a = discordAnfrage('https://x', 'T', 'x'.repeat(5000))
expect((JSON.parse(a.body) as { content: string }).content.length).toBeLessThanOrEqual(2000)
})
it('Telegram: bot<TOKEN>/sendMessage mit { chat_id, text }', () => {
const a = telegramAnfrage('123:ABC', '42', 'Rip fertig', 'Akira')
expect(a.url).toBe('https://api.telegram.org/bot123:ABC/sendMessage')
expect(JSON.parse(a.body)).toEqual({ chat_id: '42', text: 'Rip fertig\nAkira' })
})
})
describe('kanaeleAus / kanaeleEingerichtet', () => {
it('trimmt und erkennt: Discord allein reicht, Telegram nur mit Token UND Chat-ID', () => {
expect(kanaeleEingerichtet(kanaeleAus((s) => (s === 'discordWebhook' ? ' https://d ' : '')))).toBe(true)
expect(kanaeleEingerichtet(kanaeleAus((s) => (s === 'telegramToken' ? 't' : '')))).toBe(false)
expect(kanaeleEingerichtet(kanaeleAus((s) => (s === 'telegramToken' ? 't' : s === 'telegramChatId' ? '1' : '')))).toBe(true)
expect(kanaeleEingerichtet(kanaeleAus(() => ''))).toBe(false)
})
})
describe('BenachrichtigungsDienst', () => {
it('schickt über jeden eingerichteten Kanal und schreibt ins Protokoll', async () => {
const { kontext, fehler, zeilen } = fakeKontext()
const aufrufe: Array<{ url: string; body: string }> = []
const holen = (async (url: string | URL | Request, init?: RequestInit) => {
aufrufe.push({ url: String(url), body: String(init?.body) })
return { status: 204 }
}) as unknown as typeof fetch
const d = new BenachrichtigungsDienst(kontext, fakeEinstellungen({ discordWebhook: 'https://d', telegramToken: 't', telegramChatId: '9' }), holen)
expect(await d.senden('Titel', 'Text')).toEqual([])
expect(aufrufe.map((a) => a.url)).toEqual(['https://d', 'https://api.telegram.org/bott/sendMessage'])
expect(fehler).toEqual([])
expect(zeilen[0]).toContain('Benachrichtigt (Discord, Telegram): Titel')
})
it('ein HTTP-Fehler wird gesagt (kern-fehler), aber nicht geworfen', async () => {
const { kontext, fehler } = fakeKontext()
const holen = (async () => ({ status: 404 })) as unknown as typeof fetch
const d = new BenachrichtigungsDienst(kontext, fakeEinstellungen({ discordWebhook: 'https://d' }), holen)
expect(await d.senden('T', 'x')).toEqual(['Discord: HTTP 404'])
expect(fehler).toEqual(['Benachrichtigung Discord: HTTP 404'])
})
it('ein Netzfehler ebenso', async () => {
const { kontext } = fakeKontext()
const holen = (async () => {
throw new Error('fetch failed')
}) as unknown as typeof fetch
const d = new BenachrichtigungsDienst(kontext, fakeEinstellungen({ discordWebhook: 'https://d' }), holen)
expect(await d.senden('T', 'x')).toEqual(['Discord: fetch failed'])
})
it('ausgeschaltet: nichts geht raus — außer erzwungen (Testnachricht)', async () => {
const { kontext } = fakeKontext()
let aufrufe = 0
const holen = (async () => {
aufrufe += 1
return { status: 200 }
}) as unknown as typeof fetch
const d = new BenachrichtigungsDienst(kontext, fakeEinstellungen({ benachrichtigungen: 'aus', discordWebhook: 'https://d' }), holen)
expect(await d.senden('T', 'x')).toEqual([])
expect(aufrufe).toBe(0)
expect(await d.senden('T', 'x', true)).toEqual([])
expect(aufrufe).toBe(1)
})
it('testen ohne Kanal: sagt, was fehlt; mit Kanal: „Testnachricht verschickt."', async () => {
const ohne = fakeKontext()
await new BenachrichtigungsDienst(ohne.kontext, fakeEinstellungen({}), (async () => ({ status: 200 })) as unknown as typeof fetch).testen()
expect(ohne.fehler[0]).toContain('kein Kanal eingerichtet')
const mit = fakeKontext()
await new BenachrichtigungsDienst(mit.kontext, fakeEinstellungen({ discordWebhook: 'https://d' }), (async () => ({ status: 200 })) as unknown as typeof fetch).testen()
expect(mit.hinweise).toEqual(['Testnachricht verschickt.'])
})
})
+62
View File
@@ -0,0 +1,62 @@
// Tests der Fensterlage (haupt/fensterlage.ts, 5.5.0): 80 % des
// Arbeitsbereichs, mittig, Mindestmaß; gemerkte Lage nur, wenn sie noch auf
// einem Bildschirm liegt; Lesen/Schreiben der Datei.
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterAll, describe, expect, it } from 'vitest'
import { anfangsLage, lageLesen, lageSchreiben, liegtSichtbar, startLage } from '../src/haupt/fensterlage'
const ordner = mkdtempSync(join(tmpdir(), 'rippy-lage-'))
afterAll(() => rmSync(ordner, { recursive: true, force: true }))
describe('startLage', () => {
it('Full HD ohne Taskleiste: 80 % und mittig', () => {
expect(startLage({ x: 0, y: 0, width: 1920, height: 1040 })).toEqual({ x: 192, y: 104, width: 1536, height: 832, maximiert: false })
})
it('kleiner Bildschirm: Mindestmaß 1100 × 720, aber nie größer als der Bildschirm', () => {
expect(startLage({ x: 0, y: 0, width: 1280, height: 720 })).toEqual({ x: 90, y: 0, width: 1100, height: 720, maximiert: false })
expect(startLage({ x: 0, y: 0, width: 1024, height: 600 })).toEqual({ x: 0, y: 0, width: 1024, height: 600, maximiert: false })
})
it('Zweitmonitor rechts: der Versatz des Arbeitsbereichs zählt mit', () => {
const l = startLage({ x: 1920, y: 0, width: 2560, height: 1400 })
expect(l.x).toBe(1920 + 256)
expect(l.width).toBe(2048)
})
})
describe('liegtSichtbar / anfangsLage', () => {
const bildschirme = [
{ x: 0, y: 0, width: 1920, height: 1080 },
{ x: 1920, y: 0, width: 2560, height: 1440 },
]
it('auf einem Bildschirm: ja; im Nirgendwo eines abgestöpselten Monitors: nein', () => {
expect(liegtSichtbar({ x: 100, y: 100, width: 1200, height: 800, maximiert: false }, bildschirme)).toBe(true)
expect(liegtSichtbar({ x: 2500, y: 200, width: 1200, height: 800, maximiert: false }, bildschirme)).toBe(true)
expect(liegtSichtbar({ x: -3000, y: 200, width: 1200, height: 800, maximiert: false }, bildschirme)).toBe(false)
expect(liegtSichtbar({ x: 100, y: 100, width: 300, height: 200, maximiert: false }, bildschirme)).toBe(false)
})
it('anfangsLage: gemerkt, wenn sichtbar — sonst Start', () => {
const arbeit = { x: 0, y: 0, width: 1920, height: 1040 }
const gemerkt = { x: 2000, y: 50, width: 1400, height: 900, maximiert: true }
expect(anfangsLage(gemerkt, arbeit, bildschirme)).toBe(gemerkt)
expect(anfangsLage({ ...gemerkt, x: 9000 }, arbeit, bildschirme)).toEqual(startLage(arbeit))
expect(anfangsLage(null, arbeit, bildschirme)).toEqual(startLage(arbeit))
})
})
describe('lageLesen / lageSchreiben', () => {
it('Runde durch die Datei', () => {
const pfad = join(ordner, 'fenster-lage.json')
lageSchreiben(pfad, { x: 10, y: 20, width: 1300, height: 850, maximiert: true })
expect(lageLesen(pfad)).toEqual({ x: 10, y: 20, width: 1300, height: 850, maximiert: true })
})
it('fehlende oder kaputte Datei heißt Startlage (null)', () => {
expect(lageLesen(join(ordner, 'gibt-es-nicht.json'))).toBeNull()
const kaputt = join(ordner, 'kaputt.json')
writeFileSync(kaputt, '{"x": "zehn"}')
expect(lageLesen(kaputt)).toBeNull()
writeFileSync(kaputt, 'kein json')
expect(lageLesen(kaputt)).toBeNull()
})
})
+62
View File
@@ -0,0 +1,62 @@
// Kleine pure Helfer aus 5.5.0: Sprachlisten fürs Dropdown, die Bilanz in
// der Übersicht, der Serien-Merker aus der Einstellung, die Platz-Prüfung
// im Dialog (dieselbe Rechnung wie im Kern).
import { describe, expect, it } from 'vitest'
import { platzUrteil as platzUrteilKern } from '../src/kern/dienste/speicher'
import { platzUrteil } from '../src/gemeinsam/platz'
import { serienMerkerAusJson } from '../src/kern/dienste/erkennung'
import { statistikAus } from '../src/kern/dienste/vorgaenge'
import { sprachName, sprachlisteAus, sprachlisteText } from '../src/gemeinsam/sprachen'
describe('sprachen', () => {
it('sprachlisteAus: trimmt, kleinschreibt, entdoppelt, hält die Reihenfolge', () => {
expect(sprachlisteAus('deu, ENG ,, jpn,deu')).toEqual(['deu', 'eng', 'jpn'])
expect(sprachlisteAus('')).toEqual([])
})
it('sprachName kennt die üblichen, fällt sonst auf den Code zurück', () => {
expect(sprachName('deu')).toBe('Deutsch')
expect(sprachName('jpn')).toBe('Japanisch')
expect(sprachName('xyz')).toBe('xyz')
})
it('sprachlisteText ist die Einstellung, wie der Kern sie liest', () => {
expect(sprachlisteText(['deu', 'eng'])).toBe('deu,eng')
})
})
describe('statistikAus', () => {
it('zählt Discs, Fertige, Bytes und die Dauer nur der Fertigen', () => {
const s = statistikAus([
{ phase: 'fertig', rohBytes: 30e9, fertigBytes: 8e9, begonnen: '2026-09-01T10:00:00Z', beendet: '2026-09-01T11:30:00Z' },
{ phase: 'fertig', rohBytes: 20e9, fertigBytes: 5e9, begonnen: '2026-09-01T12:00:00Z', beendet: '2026-09-01T12:30:00Z' },
{ phase: 'fehler', rohBytes: 10e9, fertigBytes: 0, begonnen: '2026-09-01T13:00:00Z', beendet: '2026-09-01T13:10:00Z' },
{ phase: 'komprimiert', rohBytes: 25e9, fertigBytes: 0, begonnen: '2026-09-01T14:00:00Z', beendet: null },
])
expect(s).toEqual({ discs: 4, fertige: 2, rohBytes: 85e9, fertigBytes: 13e9, dauerS: 7200 })
})
it('leer ist leer', () => {
expect(statistikAus([])).toEqual({ discs: 0, fertige: 0, rohBytes: 0, fertigBytes: 0, dauerS: 0 })
})
})
describe('serienMerkerAusJson', () => {
it('liest gültige Einträge, überspringt kaputte, verträgt Müll', () => {
const m = serienMerkerAusJson('{"tv:46296":{"titel":"Spartacus","staffel":1,"naechsteFolge":5,"wann":"2026-09-01T10:00:00Z"},"tv:1":{"staffel":"eins"},"x":5}')
expect(m).toEqual({ 'tv:46296': { titel: 'Spartacus', staffel: 1, naechsteFolge: 5, wann: '2026-09-01T10:00:00Z' } })
expect(serienMerkerAusJson(null)).toEqual({})
expect(serienMerkerAusJson('')).toEqual({})
expect(serienMerkerAusJson('nicht json')).toEqual({})
expect(serienMerkerAusJson('[1,2]')).toEqual({})
})
})
describe('Platz-Prüfung: eine Rechnung für Kern und Dialog', () => {
it('der Kern reicht dieselbe Funktion durch', () => {
expect(platzUrteilKern).toBe(platzUrteil)
})
it('reicht / knapp / zu wenig', () => {
expect(platzUrteil(60e9, 40e9, true)).toEqual({ text: '', hart: false })
expect(platzUrteil(45e9, 40e9, true).hart).toBe(false)
expect(platzUrteil(45e9, 40e9, true).text.startsWith('Knapp')).toBe(true)
expect(platzUrteil(30e9, 40e9, false).hart).toBe(true)
})
})
+1 -1
View File
@@ -105,7 +105,7 @@ describe('istFensterNachricht — rip-start mit Sprachen und Optionen (5.4.0)',
describe('istKernNachricht — Vorgänge, Kompression, Gesundheit (5.4.0)', () => {
it('vorgaenge braucht Liste, Belegung und Warteschlange', () => {
expect(istKernNachricht({ art: 'vorgaenge', stand: { vorgaenge: [], rohGesamtBytes: 0, fremde: [], regel: '', kompression: { laeuft: null, wartend: [] } } })).toBe(true)
expect(istKernNachricht({ art: 'vorgaenge', stand: { vorgaenge: [], rohGesamtBytes: 0, fremde: [], regel: '', kompression: { laeuft: null, wartend: [], pausiert: false }, statistik: { discs: 0, fertige: 0, rohBytes: 0, fertigBytes: 0, dauerS: 0 } } })).toBe(true)
expect(istKernNachricht({ art: 'vorgaenge', stand: { vorgaenge: [] } })).toBe(false)
})
it('kompression-ergebnis und laufwerk-gesundheit', () => {
+53
View File
@@ -0,0 +1,53 @@
// Tests der Protokoll-Datei (gemeinsam/protokoll.ts, 5.5.0): eine Datei je
// Tag, Zeile mit Zeitstempel und Quelle, Aufräumen nach 14 Tagen — und
// fremde Dateien im Ordner bleiben unangetastet.
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterAll, describe, expect, it } from 'vitest'
import { Protokoll, protokollDateiname, veraltet, zeitstempel } from '../src/gemeinsam/protokoll'
const wurzel = mkdtempSync(join(tmpdir(), 'rippy-protokoll-'))
afterAll(() => rmSync(wurzel, { recursive: true, force: true }))
describe('protokollDateiname / zeitstempel / veraltet', () => {
it('Name und Stempel sind lokal und zweistellig', () => {
const d = new Date(2026, 8, 2, 7, 5, 9)
expect(protokollDateiname(d)).toBe('rippy-2026-09-02.log')
expect(zeitstempel(d)).toBe('2026-09-02 07:05:09')
})
it('veraltet: älter als 14 Tage ja, 13 Tage nein, fremde Namen nie', () => {
const jetzt = new Date(2026, 8, 20)
const namen = ['rippy-2026-09-01.log', 'rippy-2026-09-07.log', 'rippy-2026-09-19.log', 'notizen.txt', 'rippy-alt.log']
expect(veraltet(namen, jetzt)).toEqual(['rippy-2026-09-01.log'])
expect(veraltet(namen, jetzt, 5)).toEqual(['rippy-2026-09-01.log', 'rippy-2026-09-07.log'])
})
})
describe('Protokoll', () => {
it('legt den Ordner an, schreibt Zeilen mit Stempel und Quelle, räumt Altes weg', () => {
const ordner = join(wurzel, 'logs')
let uhr = new Date(2026, 8, 2, 21, 13, 5)
const p = new Protokoll(ordner, 'kern', () => uhr)
p.schreiben('Datenbank geöffnet: C:\\x\\rippy.db')
p.schreiben('zweite Zeile')
const datei = join(ordner, 'rippy-2026-09-02.log')
expect(existsSync(datei)).toBe(true)
const zeilen = readFileSync(datei, 'utf8').trimEnd().split('\n')
expect(zeilen).toEqual(['2026-09-02 21:13:05 [kern] Datenbank geöffnet: C:\\x\\rippy.db', '2026-09-02 21:13:05 [kern] zweite Zeile'])
writeFileSync(join(ordner, 'rippy-2026-08-01.log'), 'alt\n')
writeFileSync(join(ordner, 'fremd.txt'), 'bleibt\n')
uhr = new Date(2026, 8, 3)
expect(p.aufraeumen()).toBe(1)
expect(existsSync(join(ordner, 'rippy-2026-08-01.log'))).toBe(false)
expect(existsSync(join(ordner, 'fremd.txt'))).toBe(true)
expect(existsSync(datei)).toBe(true)
})
it('ein nicht schreibbarer Ordner kippt nichts — die Fehlschläge werden beim nächsten Erfolg vermerkt', () => {
const sperre = join(wurzel, 'datei-statt-ordner')
writeFileSync(sperre, 'x')
const p = new Protokoll(join(sperre, 'logs'), 'haupt', () => new Date(2026, 8, 2))
expect(() => p.schreiben('geht nicht')).not.toThrow()
})
})
+120
View File
@@ -0,0 +1,120 @@
// Tests des Rettungs-Abbilds (kern/rip/rettung.ts, 5.5.0) mit einem
// nachgebauten Gerät: heile Bereiche kommen 1:1, ein Sektor, der einmal
// zickt, wird beim zweiten Versuch gelesen, was dauerhaft unlesbar ist,
// wird mit Nullen gefüllt — und NUR das. Dazu Abbruch, unbekannte Größe,
// der Bericht.
import { describe, expect, it } from 'vitest'
import { BLOCK_BYTES, SEKTOR_BYTES, rettungsAbbild, rettungsBericht, stelleHinzu, type UnlesbareStelle } from '../src/kern/rip/rettung'
/** Ein Gerät mit `groesse` Bytes (Muster: Byte = Position mod 251), dauerhaft
* kaputten Sektoren und solchen, die genau `zickt`-mal scheitern. */
function geraet(groesse: number, kaputt: number[], zickt: Record<number, number> = {}) {
const daten = Buffer.alloc(groesse)
for (let i = 0; i < groesse; i++) daten[i] = i % 251
const versuche: Array<[number, number]> = []
const restZicken = { ...zickt }
const leser = async (puffer: Buffer, laenge: number, position: number): Promise<number> => {
versuche.push([position, laenge])
const ersterSektor = Math.floor(position / SEKTOR_BYTES)
const letzterSektor = Math.floor((position + laenge - 1) / SEKTOR_BYTES)
for (let s = ersterSektor; s <= letzterSektor; s++) {
if (kaputt.includes(s)) throw new Error(`I/O-Fehler Sektor ${s}`)
if ((restZicken[s] ?? 0) > 0) {
restZicken[s] -= 1
throw new Error(`zickt Sektor ${s}`)
}
}
daten.copy(puffer, 0, position, position + laenge)
return laenge
}
const teile: Buffer[] = []
const schreiber = async (puffer: Buffer, laenge: number): Promise<void> => {
teile.push(Buffer.from(puffer.subarray(0, laenge)))
}
return { daten, leser, schreiber, ausgabe: () => Buffer.concat(teile), versuche }
}
describe('rettungsAbbild', () => {
it('eine heile Disc kommt 1:1 — ohne feines Lesen, ohne Nullen', async () => {
const g = geraet(2 * BLOCK_BYTES + 5000, [])
const e = await rettungsAbbild({ leser: g.leser, schreiber: g.schreiber, erwarteteBytes: g.daten.length })
expect(e.status).toBe('success')
expect(e.bytes).toBe(g.daten.length)
expect(e.unlesbareSektoren).toBe(0)
expect(g.ausgabe().equals(g.daten)).toBe(true)
// Drei Lesevorgänge: zwei volle Blöcke und der Rest.
expect(g.versuche).toHaveLength(3)
})
it('zwei dauerhaft kaputte Sektoren werden genullt — der Rest bleibt', async () => {
const kaputt = [600, 601] // im zweiten Block (Block = 512 Sektoren)
const g = geraet(3 * BLOCK_BYTES, kaputt)
const fortschritte: number[] = []
const e = await rettungsAbbild({ leser: g.leser, schreiber: g.schreiber, erwarteteBytes: g.daten.length, aufFortschritt: (p) => fortschritte.push(p) })
expect(e.status).toBe('success')
expect(e.unlesbareSektoren).toBe(2)
expect(e.stellen).toEqual<UnlesbareStelle[]>([{ offset: 600 * SEKTOR_BYTES, bytes: 2 * SEKTOR_BYTES }])
const erwartet = Buffer.from(g.daten)
erwartet.fill(0, 600 * SEKTOR_BYTES, 602 * SEKTOR_BYTES)
expect(g.ausgabe().equals(erwartet)).toBe(true)
expect(fortschritte[fortschritte.length - 1]).toBe(100)
})
it('ein Sektor, der einmal zickt, wird beim zweiten Versuch gelesen — keine Null', async () => {
const g = geraet(BLOCK_BYTES, [], { 100: 1 })
const e = await rettungsAbbild({ leser: g.leser, schreiber: g.schreiber, erwarteteBytes: g.daten.length })
expect(e.status).toBe('success')
expect(e.unlesbareSektoren).toBe(0)
expect(g.ausgabe().equals(g.daten)).toBe(true)
})
it('zickt er öfter als es Versuche gibt, wird er genullt', async () => {
const g = geraet(BLOCK_BYTES, [], { 100: 10 })
const e = await rettungsAbbild({ leser: g.leser, schreiber: g.schreiber, erwarteteBytes: g.daten.length, versuche: 2 })
expect(e.unlesbareSektoren).toBe(1)
expect(e.stellen).toEqual([{ offset: 100 * SEKTOR_BYTES, bytes: SEKTOR_BYTES }])
})
it('Abbruch: cancelled mit dem Stand bis dahin', async () => {
const g = geraet(4 * BLOCK_BYTES, [])
const steuerung = new AbortController()
let geschrieben = 0
const schreiber = async (p: Buffer, n: number): Promise<void> => {
await g.schreiber(p, n)
geschrieben += 1
if (geschrieben === 2) steuerung.abort()
}
const e = await rettungsAbbild({ leser: g.leser, schreiber, erwarteteBytes: g.daten.length, abbruch: steuerung.signal })
expect(e.status).toBe('cancelled')
expect(e.bytes).toBe(2 * BLOCK_BYTES)
})
it('unbekannte Größe: Fehler, kein Abbild', async () => {
const g = geraet(10, [])
const e = await rettungsAbbild({ leser: g.leser, schreiber: g.schreiber, erwarteteBytes: 0 })
expect(e.status).toBe('error')
})
it('ein Schreibfehler bricht mit Stand ab', async () => {
const g = geraet(2 * BLOCK_BYTES, [])
const schreiber = async (): Promise<void> => {
throw new Error('ENOSPC')
}
const e = await rettungsAbbild({ leser: g.leser, schreiber, erwarteteBytes: g.daten.length })
expect(e.status).toBe('error')
if (e.status === 'error') expect(e.fehler).toContain('ENOSPC')
})
})
describe('stelleHinzu / rettungsBericht', () => {
it('fasst angrenzende Bereiche zusammen', () => {
const stellen: UnlesbareStelle[] = []
stelleHinzu(stellen, 0, 2048)
stelleHinzu(stellen, 2048, 2048)
stelleHinzu(stellen, 8192, 2048)
expect(stellen).toEqual([
{ offset: 0, bytes: 4096 },
{ offset: 8192, bytes: 2048 },
])
})
it('der Bericht nennt Stand, Sektoren, Anteil und die Stellen', () => {
const text = rettungsBericht('G:', { status: 'success', bytes: 1_000_000, unlesbareSektoren: 2, stellen: [{ offset: 4096, bytes: 4096 }] }, 1_000_000)
expect(text).toContain('Stand: vollständig')
expect(text).toContain('Unlesbare Sektoren: 2 (0.4096 %) an 1 Stellen')
expect(text).toContain('ab Byte 4096: 4096 Bytes genullt')
})
})
+67
View File
@@ -0,0 +1,67 @@
// Tests des freien Platzes (kern/dienste/speicher.ts, 5.5.0): der nächste
// vorhandene Elternpfad, die Messung mit nachgebautem statfs, das Urteil
// vor dem Rip. Der echte statfs-Aufruf ist am 01.09.2026 gemessen (siehe
// Kopf der Datei) — hier zählt die Rechnung. Die Pfade stehen mit
// Schrägstrichen: dirname versteht sie auf Windows UND auf dem Linux-Runner
// der CI gleich.
import { describe, expect, it } from 'vitest'
import { platzMessen, platzUrteil, vorhandenerPfad } from '../src/kern/dienste/speicher'
describe('vorhandenerPfad', () => {
it('nimmt den Ordner selbst, wenn es ihn gibt', () => {
expect(vorhandenerPfad('X:/rippy/ablage', (p) => p === 'X:/rippy/ablage')).toBe('X:/rippy/ablage')
})
it('geht hoch bis zum ersten vorhandenen Elternpfad — die Ablage darf noch fehlen', () => {
const da = new Set(['X:/', 'X:/rippy'])
expect(vorhandenerPfad('X:/rippy/ablage/Filme', (p) => da.has(p))).toBe('X:/rippy')
})
it('bleibt an der Wurzel stehen, wenn nichts existiert (Windows: Q:/ — auf dem Linux-Runner endet dirname bei „.")', () => {
expect(vorhandenerPfad('Q:/gibt/es/nicht', () => false)).toBe(process.platform === 'win32' ? 'Q:/' : '.')
})
})
describe('platzMessen', () => {
it('rechnet bavail × bsize und blocks × bsize', () => {
const platz = platzMessen('X:/rippy', () => ({ bavail: 1000, bsize: 4096, blocks: 5000 }), () => true)
expect(platz).toEqual({ pfad: 'X:/rippy', ok: true, freiBytes: 4_096_000, gesamtBytes: 20_480_000, fehler: '' })
})
it('ein leerer Pfad ist nicht messbar — und sagt es (R2)', () => {
const platz = platzMessen(' ', () => ({ bavail: 1, bsize: 1, blocks: 1 }))
expect(platz.ok).toBe(false)
expect(platz.fehler).toBe('kein Pfad')
})
it('ein Fehler der Messung wird zum Grund, nie zu einer erfundenen Null', () => {
const platz = platzMessen(
'Z:/weg',
() => {
throw new Error('ENOENT: no such file or directory')
},
() => true,
)
expect(platz.ok).toBe(false)
expect(platz.freiBytes).toBe(0)
expect(platz.fehler).toContain('ENOENT')
})
})
describe('platzUrteil', () => {
const GB = 1e9
it('reicht: Roh plus 25 % plus 2 GB Luft passen', () => {
expect(platzUrteil(60 * GB, 40 * GB, true)).toEqual({ text: '', hart: false })
})
it('knapp: der Rip passt, die Kompression nicht mehr sicher', () => {
const urteil = platzUrteil(45 * GB, 40 * GB, true)
expect(urteil.hart).toBe(false)
expect(urteil.text.startsWith('Knapp')).toBe(true)
expect(urteil.text).toContain('45,0 GB frei')
})
it('zu wenig: nicht einmal der verlustfreie Rip passt', () => {
const urteil = platzUrteil(30 * GB, 40 * GB, false)
expect(urteil.hart).toBe(true)
expect(urteil.text.startsWith('Zu wenig Platz')).toBe(true)
})
it('ohne Kompression zählt nur Roh plus Luft', () => {
expect(platzUrteil(43 * GB, 40 * GB, false).text).toBe('')
expect(platzUrteil(43 * GB, 40 * GB, true).text.startsWith('Knapp')).toBe(true)
})
})
+1 -1
View File
@@ -27,7 +27,7 @@ function auftrag(id: string): KompressionsAuftrag {
}
function fertig(text = 'ok'): AblageErgebnis {
return { status: 'fertig', zielOrdner: 'Z', dateien: [], text, komprimiert: true, preset: 'p', fertigBytes: 1, hinweise: [] }
return { status: 'fertig', zielOrdner: 'Z', dateien: [], text, komprimiert: true, preset: 'p', fertigBytes: 1, hinweise: [], folgenVergeben: [] }
}
/** Ein Encode, den der Test von außen beendet. */