diff --git a/beweise/klick/01-uebersicht.png b/beweise/klick/01-uebersicht.png index 04f07f3..4840697 100644 Binary files a/beweise/klick/01-uebersicht.png and b/beweise/klick/01-uebersicht.png differ diff --git a/beweise/klick/02-dialog.png b/beweise/klick/02-dialog.png index cb35ceb..61f101f 100644 Binary files a/beweise/klick/02-dialog.png and b/beweise/klick/02-dialog.png differ diff --git a/beweise/klick/03-fertig.png b/beweise/klick/03-fertig.png index 3432ab0..e34025d 100644 Binary files a/beweise/klick/03-fertig.png and b/beweise/klick/03-fertig.png differ diff --git a/beweise/klick/04-roh-dateien.png b/beweise/klick/04-roh-dateien.png index 731e657..3875276 100644 Binary files a/beweise/klick/04-roh-dateien.png and b/beweise/klick/04-roh-dateien.png differ diff --git a/beweise/klick/05-einstellungen.png b/beweise/klick/05-einstellungen.png new file mode 100644 index 0000000..8692f22 Binary files /dev/null and b/beweise/klick/05-einstellungen.png differ diff --git a/beweise/klick/05-ereignisse.png b/beweise/klick/05-ereignisse.png deleted file mode 100644 index ad9efb9..0000000 Binary files a/beweise/klick/05-ereignisse.png and /dev/null differ diff --git a/beweise/klick/06-ereignisse.png b/beweise/klick/06-ereignisse.png new file mode 100644 index 0000000..5278d68 Binary files /dev/null and b/beweise/klick/06-ereignisse.png differ diff --git a/beweise/klick/07-einrichtung-willkommen.png b/beweise/klick/07-einrichtung-willkommen.png new file mode 100644 index 0000000..d6cf048 Binary files /dev/null and b/beweise/klick/07-einrichtung-willkommen.png differ diff --git a/beweise/klick/08-einrichtung-qualitaet.png b/beweise/klick/08-einrichtung-qualitaet.png new file mode 100644 index 0000000..6cfa81e Binary files /dev/null and b/beweise/klick/08-einrichtung-qualitaet.png differ diff --git a/beweise/klick/09-einrichtung-fertig.png b/beweise/klick/09-einrichtung-fertig.png new file mode 100644 index 0000000..c2edb26 Binary files /dev/null and b/beweise/klick/09-einrichtung-fertig.png differ diff --git a/beweise/klick/fehler-einstellungen-roh-dateien-zeigt-den-vorgang-mit-belegung.png b/beweise/klick/fehler-einstellungen-roh-dateien-zeigt-den-vorgang-mit-belegung.png deleted file mode 100644 index 44a5d8a..0000000 Binary files a/beweise/klick/fehler-einstellungen-roh-dateien-zeigt-den-vorgang-mit-belegung.png and /dev/null differ diff --git a/beweise/klick/fehler-rollen-auf-folge-staffel-folge-felder-erscheinen.png b/beweise/klick/fehler-rollen-auf-folge-staffel-folge-felder-erscheinen.png deleted file mode 100644 index 1ccd8a1..0000000 Binary files a/beweise/klick/fehler-rollen-auf-folge-staffel-folge-felder-erscheinen.png and /dev/null differ diff --git a/rippy-windows/BAUEN.md b/rippy-windows/BAUEN.md index ff59a19..160be10 100644 --- a/rippy-windows/BAUEN.md +++ b/rippy-windows/BAUEN.md @@ -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=` 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. diff --git a/rippy-windows/bau/klick-smoke.mjs b/rippy-windows/bau/klick-smoke.mjs index 3e99328..c6fcbc4 100644 --- a/rippy-windows/bau/klick-smoke.mjs +++ b/rippy-windows/bau/klick-smoke.mjs @@ -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 geht’s', 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) diff --git a/rippy-windows/bau/release-notes.md b/rippy-windows/bau/release-notes.md index 0c7daa7..94448db 100644 --- a/rippy-windows/bau/release-notes.md +++ b/rippy-windows/bau/release-notes.md @@ -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. diff --git a/rippy-windows/package-lock.json b/rippy-windows/package-lock.json index 24cbe17..9fe2848 100644 --- a/rippy-windows/package-lock.json +++ b/rippy-windows/package-lock.json @@ -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", diff --git a/rippy-windows/package.json b/rippy-windows/package.json index d0e09d3..849f2c7 100644 --- a/rippy-windows/package.json +++ b/rippy-windows/package.json @@ -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", diff --git a/rippy-windows/src/bruecke/index.ts b/rippy-windows/src/bruecke/index.ts index f228894..970027a 100644 --- a/rippy-windows/src/bruecke/index.ts +++ b/rippy-windows/src/bruecke/index.ts @@ -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 { return ipcRenderer.invoke('ordner-waehlen', String(titel)) as Promise }, + /** Echter Windows-Dateidialog für Programme (5.5.0: Werkzeuge → Durchsuchen). */ + dateiWaehlen(titel: string): Promise { + return ipcRenderer.invoke('datei-waehlen', String(titel)) as Promise + }, + /** Einen Ordner im Explorer zeigen (5.5.0). Liefert '' oder den Fehler. */ + ordnerOeffnen(pfad: string): Promise { + return ipcRenderer.invoke('ordner-oeffnen', String(pfad)) as Promise + }, + /** Den Protokoll-Ordner im Explorer zeigen (5.5.0). */ + protokollOeffnen(): Promise { + return ipcRenderer.invoke('protokoll-oeffnen') as Promise + }, /** Autostart an/aus (§ 8) — liefert den tatsächlichen Zustand zurück. */ autostartSetzen(an: boolean): Promise { return ipcRenderer.invoke('autostart-setzen', an === true) as Promise diff --git a/rippy-windows/src/fenster/App.tsx b/rippy-windows/src/fenster/App.tsx index b77f573..bf70f79 100644 --- a/rippy-windows/src/fenster/App.tsx +++ b/rippy-windows/src/fenster/App.tsx @@ -1,2686 +1,176 @@ -// Die Oberfläche im Kino-Mix (Design-Entscheid des Commanders, 31.08.2026, -// beweise/…-Canvas „Rippy Redesign"): die Kinosaal-Bühne — warmes Schwarz, -// Saallicht, Filmkorn, Gold, Poster mit Goldkante, Plakat-Titel — plus die -// Schaltpult-Präzision: REC-Glühen, Info-Kacheln, Segment-Anzeige mit -// großer Prozentzahl, Mono-Protokoll, LED-Systemleiste. Der Look zieht -// sich durch ALLE Bereiche (Übersicht, Einstellungen, Bibliothek, -// Erst-Einrichtung, Dialoge). Die Regeln bleiben: EINE Einstellungs-Quelle -// über den Kern (§ 6.7), R2 (nichts verschwindet ohne Auskunft), R4 -// (Fehler stehen im Fenster — bei Systemfehlern als rote Karte). -import { useEffect, useRef, useState } from 'react' -import type { - DiscInfoStand, - DiscTitelVorschlag, - GeraeteInfo, - HauptStatus, - KompressionStand, - LaufwerkBericht, - RipStatus, - SprachEintrag, - TitelRolle, - TitelStand, - VorgaengeStand, - VorgangEintrag, -} from '../gemeinsam/nachrichten' -import { zielVorschau } from '../gemeinsam/ablagepfad' -import { DEFAULT_HB_PRESET, presetEmpfehlung } from '../gemeinsam/preset-empfehlung' -import { - aufVerbindung, - auswerfen, - einstellungSetzen, - fremdenOrdnerLoeschen, - kompressionAbbrechen, - laufwerkPruefen, - makemkvUpdate, - metadatenSuchen, - ripAbbrechen, - ripStarten, - ripUeberspringen, - rohAufraeumen, - schluesselPruefen, - titelLesen, - verbindungStarten, - vorgaengeLaden, - vorgangNeuKomprimieren, - vorgangRohLoeschen, - zuordnungSetzen, - type Verbindung, -} from './kernverbindung' -import type { MetaVorschlag } from '../gemeinsam/nachrichten' - -/** TMDb-Logo (freigestellter Schriftzug) — Größen aus /configuration - * gemessen: w300 reicht für die Kachel. SVG-Logos zeigt Chromium direkt. */ -function logoUrl(pfad: string): string { - if (pfad.length === 0) return '' - if (pfad.startsWith('http')) return pfad - return `https://image.tmdb.org/t/p/w300${pfad}` -} - -function profilUrl(pfad: string): string { - if (pfad.length === 0) return '' - if (pfad.startsWith('http')) return pfad - return `https://image.tmdb.org/t/p/w185${pfad}` -} - -function datumKurz(iso: string | null): string { - if (iso === null || iso.length < 16) return '—' - return iso.slice(0, 16).replace('T', ' ') -} - -/** „1988 · FSK 16 · 124 Min · Animation · ★ 7,9" — nur, was da ist. */ -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(' · ') -} - -/** Vorbelegung der Sprachen je Titel: die Einstellung, soweit der Titel - * sie hat — sonst alles, was er hat (japanischer Ton statt gar keiner). */ -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 -} - -function posterUrlKlein(pfad: string): string { - if (pfad.length === 0) return '' - if (pfad.startsWith('http')) return pfad - return `https://image.tmdb.org/t/p/w154${pfad}` -} - -function posterUrlGross(pfad: string): string { - if (pfad.length === 0) return '' - if (pfad.startsWith('http')) return pfad - return `https://image.tmdb.org/t/p/w342${pfad}` -} - -function backdropUrl(pfad: string): string { - if (pfad.length === 0) return '' - if (pfad.startsWith('http')) return pfad - return `https://image.tmdb.org/t/p/w780${pfad}` -} - -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` -} - -function istHauptStatus(wert: unknown): wert is HauptStatus { - return ( - typeof wert === 'object' && - wert !== null && - typeof (wert as Record).version === 'string' - ) -} - -function gb(bytes: number): string { - return `${(bytes / 1e9).toFixed(2).replace('.', ',')} GB` -} - -const TYP_NAMEN: Record = { - cd: 'Audio-CD', - dvd: 'DVD', - bluray: 'Blu-ray', - uhd: '4K-UHD', - unknown: 'unbekannt', -} - -const PHASEN_MONO: Record = { - 'liest-info': 'PHASE 1/4 — LIEST TITEL-INFO', - rippt: 'PHASE 2/4 — RIPPT (VERLUSTFREI)', - komprimiert: 'PHASE 3/4 — KOMPRIMIERT', - 'legt-ab': 'PHASE 4/4 — LEGT AB', - fertig: 'FERTIG', - fehler: 'FEHLER', - abgebrochen: 'ABGEBROCHEN', -} - -const LAUFENDE_PHASEN = ['liest-info', 'rippt', 'komprimiert', 'legt-ab'] - -// ── 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. -const IconMinimieren = (): React.JSX.Element => ( - -) - -const IconMaximieren = (): React.JSX.Element => ( - -) - -const IconWiederherstellen = (): React.JSX.Element => ( - -) - -const IconSchliessen = (): React.JSX.Element => ( - -) - -function FensterKnopf({ - titel, - aufKlick, - rot = false, - children, -}: { - titel: string - aufKlick: () => void - rot?: boolean - children: React.ReactNode -}) { - return ( - - ) -} - -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. */ -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(' · ') -} - -/** Welches Preset diese Disc bekäme — dieselbe Kette wie im Kern - * (eigene Wahl → allgemeines Preset → Empfehlung → Default). */ -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 allgemein = werte['transcodePreset'] ?? '' - const empfehlung = presetEmpfehlung(typ, verbindung.werkzeuge?.backends ?? [], verbindung.werkzeuge?.presets ?? []) - const gewaehlt = eigene.length > 0 ? eigene : allgemein.length > 0 ? allgemein : empfehlung.length > 0 ? empfehlung : DEFAULT_HB_PRESET - return gewaehlt === 'keine' ? 'ohne Kompression' : gewaehlt -} - -// ── Ikonografie: eine Handvoll Strich-Symbole, alle aus demselben Guss ── -function IconDisc({ groesse, farbe }: { groesse: number; farbe: string }) { - return ( - - - - - - ) -} - -function IconPlay() { - return ( - - - - ) -} - -function IconStop() { - return ( - - - - ) -} - -function IconEject() { - return ( - - - - ) -} - -function IconStift() { - return ( - - - - ) -} - -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 ( - - {text} - - ) -} - -/** Die kleinen Mess-Kacheln aus dem Schaltpult (MEDIUM / GRÖSSE / …). */ -function InfoKachel({ label, wert, farbe }: { label: string; wert: string; farbe?: string }) { - return ( -
-
{label}
-
- {wert} -
-
- ) -} - -/** Die goldene Aussteuerungsanzeige — 20 Segmente, gefüllt nach Prozent. */ -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 ( -
- {Array.from({ length: segmente }, (_, i) => ( -
- ))} -
- ) -} - -// § 5 Schritt 4: „nein → fragen" — Suche + Auswahl der richtigen Metadaten. - -/** 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. */ -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() - }, - } -} - -function AendernDialog({ - id, - startText, - vorschlaege, - schliessen, -}: { - id: string - startText: string - vorschlaege: MetaVorschlag[] - schliessen: () => void -}) { - const [text, setText] = useState(startText) - const hintergrund = useHintergrundKlick(schliessen) - useEffect(() => { - metadatenSuchen(id, startText) - // Nur beim Öffnen — weitere Suchen löst der Knopf aus. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) - return ( -
-
e.stopPropagation()} - > -

Was ist das wirklich?

-
- 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" - /> - -
- {vorschlaege.length === 0 ? ( -

Keine Treffer — anderen Suchtext probieren.

- ) : ( -
- {vorschlaege.map((v) => ( - - ))} -
- )} -
- -
-
-
- ) -} - -// Die Bühne: Backdrop als Panorama, Poster mit Goldkante, Plakat-Titel — -// und beim Arbeiten die Aussteuerungsanzeige samt Mono-Protokoll. - -// ── 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 20–120 s, deshalb -// beginnt er erst hier und nicht schon beim Einlegen. -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` -} - -/** 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 ( - - {name} - {vorhanden.map((s) => { - const an = gewaehlt.includes(s.lang) - return ( - - ) - })} - - ) -} - -function TitelWahlDialog({ - id, - typ, - stand, - info, - einstellungen, - regel, - schliessen, -}: { - id: string - typ: GeraeteInfo['typ'] - stand: TitelStand | undefined - info: DiscInfoStand | undefined - einstellungen: Record - /** Klartext der Aufbewahrungs-Regel — damit klar ist, was mit dem - * Roh-Material passiert. */ - regel: string - schliessen: () => void -}) { - const hintergrund = useHintergrundKlick(schliessen) - const [gewaehlt, setGewaehlt] = useState | 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>({}) - // 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>({}) - // Staffel und erste Folge von Hand (5.4.0) — die Staffel kommt von der - // Disc, die erste Folge weiß nur der Nutzer („auf Disc 2 sind 3–4"). - const [staffel, setStaffel] = useState( - info !== undefined && info.staffel !== null ? String(info.staffel) : '', - ) - const [ersteFolge, setErsteFolge] = useState('') - // 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}-`, - }) - - return ( -
-
e.stopPropagation()} - > -

Was soll gerippt werden?

- - {stand === undefined || stand.laeuft ? ( -

- Rippy liest die Titel der Disc … das dauert bis zu zwei Minuten. Solange dreht das Laufwerk. -

- ) : stand.fehler.length > 0 ? ( -

✗ {stand.fehler}

- ) : ( - <> -

- {stand.unsicher ? '⚠ ' : ''} - {stand.einordnung} -

- -
- - - - -
- -
- {alle.map((t) => { - const an = aktiv.has(t.nr) - const wahl = sprachenVon(t) - return ( -
- - {/* 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) && ( -
- sprachWechsel(t, 'audio', lang)} - /> - sprachWechsel(t, 'untertitel', lang)} - /> - {wahl.audio.length === 0 && t.sprachen.audio.length > 0 && ( - kein Ton gewählt — HandBrake nimmt die erste Spur - )} -
- )} -
- ) - })} - {alle.length === 0 && ( -

Die Disc gab keine Titel her.

- )} -
- - {alsSerie && ( -
- - -

- 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}.`} -

-
- )} - - {/* Vorschau (5.4.0): Rolle, Serie, Staffel und Extras entscheiden - einiges — zwei Zeilen Zielpfad ersparen Überraschungen. */} -
-

LANDET IN

-

{vorschau[0]}

-

{vorschau[1]}

-
- -

- {aktiv.size} von {alle.length} gewählt · zusammen {gb(gesamtBytes)} verlustfrei - {regel.length > 0 ? ` · ${regel}` : ''} -

-

- {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. -

- - )} - -
- - -
-
-
- ) -} - -function LaufwerkKachel({ - geraet, - rip, - info, - ripStartMs, - protokoll, - verbindung, - bericht, - vorgang, - kompression, - wartePlatz, - 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 - /** 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 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 ( -
- {backdrop.length > 0 && ( - <> - -
-
- - )} - -
-
-
- - LAUFWERK {geraet.id}: - · - {geraet.modell.length > 0 ? geraet.modell : 'MODELL UNBEKANNT'} -
-
- {ripLaeuft ? ( - - ) : ( - - )} - -
-
- - {!discDa && ( -
- -

- {geraet.status === 'empty' ? 'Keine Disc eingelegt — Disc rein genügt, Rippy meldet sich.' : geraet.grund} -

-
- )} - - {discDa && ( -
- {poster.length > 0 ? ( - - ) : ( -
- -
- )} -
- {/* 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 ? ( -
- {info.titel} - - {info.titel} - {info.jahr !== null && ` (${info.jahr})`} - -
- ) : ( -

- {info.titel} - {info.jahr !== null && ({info.jahr})} -

- ) - ) : ( -

- {TYP_NAMEN[geraet.typ]} -

- )} - {erkannt && info.tagline.length > 0 && ( -

{info.tagline}

- )} - {erkannt && metaZeile(info).length > 0 && ( -

{metaZeile(info)}

- )} - {erkannt && info.beschreibung.length > 0 && ( -

{info.beschreibung}

- )} - {!erkannt && ( -

- {geraet.typ === 'cd' - ? 'Titel kommen beim Rippen von MusicBrainz.' - : 'Noch keine Zuordnung — „Ändern …" hilft nach.'} -

- )} - - {/* Zwei Kacheln nebeneinander im schmalen Fenster, vier im - breiten — vorher immer vier, also im schmalen gequetscht. */} -
- - 0 ? gb(geraet.groesseBytes) : '—'} /> - 0 ? ` · ${info.quelle}` : ''}` - : '—' - } - farbe={erkannt ? (info.confidence >= 0.8 ? 'text-gruen' : 'text-warn') : undefined} - /> - -
- - {/* Darsteller-Streifen (5.4.0) */} - {erkannt && info.besetzung.length > 0 && ( -
- {info.besetzung.map((d) => ( -
- {d.bildPfad.length > 0 ? ( - - ) : ( - - {d.name - .split(' ') - .map((w) => w[0] ?? '') - .join('') - .slice(0, 2) - .toUpperCase()} - - )} - - {d.name} - {d.rolle.length > 0 && {d.rolle}} - -
- ))} -
- )} - -
- - - {erkannt && ( - = 0.8 ? 'gruen' : 'gelb'} - /> - )} - {info !== undefined && discHerkunft(info).length > 0 && ( - - )} - {info !== undefined && info.schonGerippt !== null && ( - - )} -
- {/* Was Rippy NACH dem Info-Lauf dazugelernt hat (Erkennung v2). */} - {erkannt && info.hinweise.length > 0 && ( -
- {info.hinweise.map((h) => ( -

- {h.includes('NICHT') ? '⚠ ' : h.includes('bestätigt') ? '✓ ' : '· '} - {h} -

- ))} -
- )} - {geraet.grund.length > 0 &&

{geraet.grund}

} -
-
- )} - - {/* 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') && ( -
-
-

- - {bericht.urteil === 'ruhig' - ? 'LAUFWERK RUHIG' - : bericht.urteil === 'disc' - ? 'VERDACHT: DIE DISC' - : bericht.urteil === 'hardware' - ? 'VERDACHT: DIE HARDWARE' - : 'PROTOKOLL'} - {' '} - — {bericht.text} -

- -
- {bericht.letzte.length > 0 && ( -
- {bericht.letzte.map((z, i) => ( -

- {z} -

- ))} -
- )} -
- )} - - {rip !== undefined && ( -
-
-
-
- - {PHASEN_MONO[rip.phase]} - {rip.lesefehler && rip.phase === 'fertig' ? ' — MIT LESEFEHLERN' : ''} - - {ripLaeuft && ( - {restzeitText(rip.prozent, ripStartMs)} - )} -
- {ripLaeuft && } - {ripLaeuft && (rip.titelGesamt ?? 0) > 1 && ( -

- Titel {rip.titelNr} von {rip.titelGesamt} -

- )} - - {/* 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 && ( -
-

- Die Disc hat an einer Stelle einen Defekt. -

-

- 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. -

-
- - - oder weiter versuchen — abbrechen geht oben. - -
-
- )} - -

- > {rip.text} -

- {rip.dateien.map((datei) => ( -

- {datei} -

- ))} - - {/* 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 && ( -
- {kompression !== null ? ( - <> -

- KOMPRESSION LÄUFT{kompression.prozent >= 0 ? ` — ${kompression.prozent} %` : ''} -

-
- -
-

- > {kompression.text} -

- - ) : wartePlatz > 0 ? ( -

- Kompression wartet — Platz {wartePlatz} in der Warteschlange. -

- ) : vorgang.phase === 'fertig' ? ( -

- ✓ {vorgang.komprimiert ? 'Komprimiert und abgelegt' : 'Verlustfrei abgelegt'}: {vorgang.zielOrdner} - {vorgang.fertigBytes > 0 && ` (${gb(vorgang.fertigBytes)})`} -

- ) : vorgang.phase === 'fehler' || vorgang.phase === 'abgebrochen' ? ( -

⚠ {vorgang.fehler.length > 0 ? vorgang.fehler : `Kompression ${vorgang.phase}`}

- ) : ( -

Kompression: {vorgang.phase}

- )} -
- )} -
- {ripLaeuft && rip.prozent >= 0 && ( -
- {rip.prozent} - % -
- )} -
- {protokoll.length > 1 && ( - - )} - {protokollOffen && ( -
- {protokoll.map((zeile, i) => ( -

- > {zeile} -

- ))} -
- )} -
- )} -
-
- ) -} - -// 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. -function KompressionsKarte({ stand }: { stand: VorgaengeStand['kompression'] }) { - if (stand.laeuft === null && stand.wartend.length === 0) return null - const l = stand.laeuft - return ( -
-
- - KOMPRESSION - · - - {l !== null ? 'läuft' : 'wartet'} - {stand.wartend.length > 0 && ` · ${stand.wartend.length} in der Warteschlange`} - -
- {l !== null && ( -
-
-
-

{l.titel}

-
- -
-

- > {l.text} -

-
- {l.prozent >= 0 && ( -
- {l.prozent} - % -
- )} -
- -
- )} - {stand.wartend.length > 0 && ( -
- {stand.wartend.map((w, i) => ( -
- - Platz {i + 1} · {w.titel} - - -
- ))} -
- )} -
- ) -} - -// Roh-Dateien (5.4.0): Belegung, Regel, jeder Vorgang mit „Neu -// komprimieren" und „Roh löschen", dazu Ordner, die kein Vorgang kennt. -function RohDateienKarte({ verbindung, presets }: { verbindung: Verbindung; presets: string[] }) { - const werte = verbindung.einstellungen - const v = verbindung.vorgaenge - const [presetWahl, setPresetWahl] = useState>({}) - const [alleZeigen, setAlleZeigen] = useState(false) - const regelWert = werte['rohAufbewahrung'] ?? '7' - const phasenText: Record = { - rippt: 'rippt', - wartet: 'wartet auf Kompression', - komprimiert: 'komprimiert', - 'legt-ab': 'legt ab', - fertig: 'fertig', - fehler: 'Fehler', - abgebrochen: 'abgebrochen', - } - const phasenTon = (p: VorgangEintrag['phase']): 'gruen' | 'gelb' | 'blau' | 'grau' => - p === 'fertig' ? 'gruen' : p === 'fehler' || p === 'abgebrochen' ? 'gelb' : p === 'wartet' ? 'grau' : 'blau' - const liste = v?.vorgaenge ?? [] - const sichtbar = alleZeigen ? liste : liste.slice(0, 12) - - return ( - -
- -
- - -
-
-
- {v === null ? ( -

Belegung wird gemessen …

- ) : ( - <> -

- Belegung jetzt:{' '} - {gb(v.rohGesamtBytes)} in{' '} - {liste.filter((e) => !e.rohGeloescht).length + v.fremde.length} Roh-Ordnern unter {werte['ablage'] ?? ''}\roh -

-

{v.regel}

-

- Unkomprimierte Rips (z. B. 4K verlustfrei) liegen im Zielordner, nicht hier — sie sind nie betroffen. -

- - )} -
- - {liste.length === 0 && v !== null && ( -

Noch kein Vorgang — der erste Rip legt hier einen Eintrag an.

- )} -
- {sichtbar.map((e) => { - const ruhig = e.phase === 'fertig' || e.phase === 'fehler' || e.phase === 'abgebrochen' - return ( -
-
- - {e.titel} - {e.jahr !== null && ({e.jahr})} - - - - {(TYP_NAMEN[e.typ as GeraeteInfo['typ']] ?? e.typ).toUpperCase()} · {datumKurz(e.begonnen)} - {e.titelZahl > 0 && ` · ${e.titelZahl} ${e.titelZahl === 1 ? 'Titel' : 'Titel'}`} - {e.preset.length > 0 && ` · ${e.preset}`} - -
-

- Roh:{' '} - {e.rohGeloescht ? 'gelöscht' : gb(e.rohBytes)} - {e.faelligAm.length > 0 && ` · fällig ${datumKurz(e.faelligAm)}`} - {' · '} - Fertig:{' '} - {e.phase === 'fertig' ? ( - - {e.fertigBytes > 0 ? `${gb(e.fertigBytes)} · ` : ''} - {e.zielOrdner} - {!e.komprimiert && ' (verlustfrei, ohne Kompression)'} - - ) : ( - '—' - )} -

- {e.fehler.length > 0 &&

⚠ {e.fehler}

} -
- {e.neuKomprimierbar && ( - <> - - - - )} - {!e.rohGeloescht && ruhig && ( - - )} - {(e.phase === 'wartet' || e.phase === 'komprimiert' || e.phase === 'legt-ab') && ( - - )} -
-
- ) - })} -
- {liste.length > sichtbar.length && ( - - )} - - {v !== null && v.fremde.length > 0 && ( -
-

ORDNER OHNE VORGANG

-

- Aus der Zeit vor 5.4.0 oder von Hand hineingelegt — Rippy kennt sie nicht und löscht sie deshalb NIE von selbst. -

-
- {v.fremde.map((f) => ( -
- - {f.ordner} · {gb(f.bytes)} - - -
- ))} -
-
- )} -
- ) -} - -// Die MakeMKV-Karte (§ 9.1) — amber Glas mit dem Update-Weg aus § 9. -function SchluesselKarte({ verbindung }: { verbindung: Verbindung }) { - const s = verbindung.schluessel - const b = verbindung.beschaffung - const punktFarbe = - s === null - ? 'bg-warn' - : s.angenommen === true - ? 'bg-gruen' - : s.weiterBetrieb - ? 'bg-warn' - : 'bg-rot' - return ( -
-
- - - MAKEMKV-SCHLÜSSEL - -
- {s !== null && s.version.length > 0 && ( - MakeMKV {s.version} - )} - {s !== null && s.angenommen === false && ( - - )} - -
-
- {s === null ? ( -

wird geprüft …

- ) : ( - <> -

{s.text}

-

- zuletzt geprüft: {s.geholtAm.slice(0, 16).replace('T', ' ')} - {s.gueltigBis.length > 0 && ` · Key gültig bis ${s.gueltigBis}`} -

- - )} - {b !== null && ( -
-

- {b.text} -

- {b.laeuft && b.anteil !== null && ( -
-
-
- )} -
- )} -
- ) -} - -// ── Einstellungen ─────────────────────────────────────────────────────── -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' - -function Kicker({ text }: { text: string }) { - return

{text}

-} - -/** Ein richtiger Schalter statt einer nackten Checkbox. */ -function Schalter({ an, aufWechsel, label }: { an: boolean; aufWechsel: (an: boolean) => void; label: string }) { - return ( - - ) -} - -/** Eine Einstellungs-Karte: Kicker, ein Satz Einordnung, dann die Felder. */ -function SektionsKarte({ - id, - titel, - intro, - children, -}: { - id: string - titel: string - intro: string - children: React.ReactNode -}) { - return ( -
- -

{intro}

-
{children}
-
- ) -} - -/** Kleiner Status-Punkt für Schlüssel-Felder: hinterlegt oder leer. */ -function HinterlegtPunkt({ wert }: { wert: string }) { - return ( - 0 ? 'text-gruen' : 'text-nebel'}`}> - {wert.length > 0 ? '● hinterlegt' : '○ fehlt'} - - ) -} - -function Feld({ - name, - wert, - hinweis, - geheim, - aufAendern, -}: { - name: string - wert: string - hinweis?: string - geheim?: boolean - aufAendern: (wert: string) => void -}) { - const [entwurf, setEntwurf] = useState(wert) - useEffect(() => setEntwurf(wert), [wert]) - return ( - - ) -} - -function PresetWahl({ - name, - schluessel, - wert, - presets, - empfehlung, - aufAendern, -}: { - name: string - schluessel: string - wert: string - presets: string[] - empfehlung: string - aufAendern: (schluessel: string, wert: string) => void -}) { - return ( - - ) -} - -/** Eine Werkzeug-Zeile: Fundort in Klartext plus der eigene Pfad, der - * die automatische Suche übersteuert (Katalog-Schritt 1). */ -function WerkzeugZeile({ - name, - pfad, - fehlt, - pflicht, - schluessel, - wert, - aufAendern, -}: { - name: string - pfad: string | null - fehlt: string - pflicht: boolean - schluessel: string - wert: string - aufAendern: (schluessel: string, wert: string) => void -}) { - return ( -
-

0 ? 'text-gruen' : pflicht ? 'text-rot' : 'text-warn'}`}> - {name}: {pfad === null ? 'wird gesucht …' : pfad.length > 0 ? pfad : fehlt} -

- aufAendern(schluessel, w)} - /> -
- ) -} - -function Einstellungen({ verbindung, haupt }: { verbindung: Verbindung; haupt: HauptStatus | null }) { - const werte = verbindung.einstellungen - const presets = verbindung.werkzeuge?.presets ?? [] - const backends = verbindung.werkzeuge?.backends ?? [] - const setzen = einstellungSetzen - const s = verbindung.schluessel - const b = verbindung.beschaffung - const empfehlungFuer = (typ: string): string => presetEmpfehlung(typ, backends, presets) - // § 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)) - const [aktiv, setAktiv] = useState('ablage') - - const SEKTIONEN: Array<[string, string]> = [ - ['ablage', 'Ablage'], - ['kompression', 'Kompression'], - ['roh', 'Roh-Dateien'], - ['metadaten', 'Metadaten'], - ['makemkv', 'MakeMKV'], - ['medienserver', 'Medienserver'], - ['programm', 'Programm'], - ['werkzeuge', 'Werkzeuge'], - ] - - const springe = (id: string): void => { - setAktiv(id) - document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' }) - } - - const ordnerWaehlen = async (): Promise => { - const ordner = await window.rippy.ordnerWaehlen('Ablage-Ordner wählen') - if (ordner !== null) setzen('ablage', ordner) - } - - // 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 ( -
- - - {/* Kein max-w mehr: Der 672-px-Deckel war der Grund, warum in einem - breiten Fenster alles links klebte und rechts nichts stand. */} -
- -
-
- setzen('ablage', w)} /> -
- -
-
-

So legt Rippy ab:

-

├─ Filme\Akira (1988)\ — Film, Poster und Infodatei beieinander

-

├─ Musik\ — Audio-CDs als FLAC, benannt nach MusicBrainz

-

├─ ISO\ — unerkannte Discs als 1:1-Abbild

-

├─ Serien\Spartacus\Season 01\ — Folgen, wie Jellyfin sie erwartet

-

└─ roh\ — verlustfreies Roh-Material, aufgeräumt nach der Regel unter „Roh-Dateien"

-
- setzen('autoAuswurf', an ? 'true' : 'false')} - label="Disc nach dem Rip auswerfen — die nächste kann rein, während die Kompression läuft" - /> -
- - - {verbindung.werkzeuge !== null && verbindung.werkzeuge.handbrake.length === 0 && ( -

⚠ HandBrakeCLI fehlt — Rippen geht, Komprimieren nicht (siehe Werkzeuge).

- )} - setzen('transcodeEnabled', an ? 'true' : 'false')} - label="Nach dem Rip automatisch komprimieren" - /> - {backends.length > 0 && ( -
- GEMESSENE ENCODER: - {backends.map((backend) => ( - - ))} -
- )} -
- - - -
-

- „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. -

- setzen('transcodePreset', w)} - /> -
- setzen('audioSprachen', w)} - /> - setzen('untertitelSprachen', w)} - /> -
-
- - - - -
-
- setzen('tmdbApiKey', w)} /> -

- www.themoviedb.org → Einstellungen → API - -

-
-
- setzen('omdbApiKey', w)} /> -

- www.omdbapi.com/apikey.aspx - -

-
-
-

- Ohne Schlüssel rippt Rippy trotzdem — die Filme heißen dann wie das Disc-Label, und „Ändern …" - an der Laufwerks-Kachel bleibt ohne Suche. -

-
- - - {s !== null && ( -
-

- - - {s.angenommen === true ? 'SCHLÜSSEL ANGENOMMEN' : s.weiterBetrieb ? 'BETA-MODUS — RIPS LAUFEN' : 'AUSFALL'} - - {s.version.length > 0 && MakeMKV {s.version}} -

-

{s.text}

-

- zuletzt geprüft: {s.geholtAm.slice(0, 16).replace('T', ' ')} - {s.gueltigBis.length > 0 && ` · Key gültig bis ${s.gueltigBis}`} -

-
- )} -
- {s !== null && s.angenommen === false && ( - - )} - -
- {b !== null && ( -

- {b.text} -

- )} - setzen('makemkvAppKey', w)} - /> -
- - -
- - setzen('mediaServerUrl', w)} /> - setzen('mediaServerApiKey', w)} /> -
-

- Bei Plex benennt Rippy nur nach dessen Regeln — angestoßen wird dort nichts. Die Ordnerstruktur - aus „Ablage" passt für alle vier. -

-
- - - void window.rippy.autostartSetzen(an)} - label="Rippy mit Windows starten (Autostart im Benutzerkonto — kein Dienst, keine Adminrechte)" - /> - {/* 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 && ( -
-

Selbst-Update: {haupt.update.text}

- {haupt.update.letzteErfolgreichePruefung.length > 0 && ( -

letzte erfolgreiche Prüfung: {haupt.update.letzteErfolgreichePruefung.slice(0, 16).replace('T', ' ')}

- )} -

- Rippy v{haupt.version} - {verbindung.kern !== null && ` · Kern: Node ${verbindung.kern.nodeVersion} (PID ${verbindung.kern.pid})`} -

-

- Datenbank: {verbindung.kern !== null && verbindung.kern.datenbank.ok ? verbindung.kern.datenbank.pfad : '—'} -

-

Updates warten, solange ein Rip oder eine Kompression läuft — nie mitten im Vorgang.

-
- - {haupt.update.neueVersion.length > 0 && ( - - Version {haupt.update.neueVersion} gefunden — siehe Übersicht. - - )} -
-
- )} -
- - - - - - {verbindung.werkzeuge !== null && ( -

- HandBrake gemessen: {verbindung.werkzeuge.presets.length} Presets · Encoder:{' '} - {backends.length > 0 ? backends.join(', ') : '—'} -

- )} -
-
-
- ) -} - -// ── Bibliothek: das Poster-Regal (§ 6.6) ──────────────────────────────── -function Bibliothek({ verbindung }: { verbindung: Verbindung }) { - if (verbindung.bibliothek.length === 0) { - return ( -

- Noch nichts gerippt — die Bibliothek füllt sich mit jedem fertigen Vorgang. -

- ) - } - const gesamt = verbindung.bibliothek.reduce((summe, e) => summe + e.groesseBytes, 0) - return ( -
-

- BIBLIOTHEK — {verbindung.bibliothek.length} {verbindung.bibliothek.length === 1 ? 'EINTRAG' : 'EINTRÄGE'} ·{' '} - {gb(gesamt)} -

-
- {verbindung.bibliothek.map((eintrag) => ( -
- {eintrag.posterPfad.length > 0 ? ( - - ) : ( -
- -
- )} -

- {eintrag.titel} -

-

- {eintrag.jahr !== null ? `${eintrag.jahr} · ` : ''} - {(TYP_NAMEN[eintrag.typ as GeraeteInfo['typ']] ?? eintrag.typ).toUpperCase()} · {gb(eintrag.groesseBytes)} -

-
- ))} -
-
- ) -} - -// ── 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. -function ErstEinrichtung({ verbindung }: { verbindung: Verbindung }) { - const [schritt, setSchritt] = useState(0) - const werkzeuge = verbindung.werkzeuge - const makemkvFehlt = werkzeuge !== null && werkzeuge.makemkvcon.length === 0 - - 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.', - }, - { - 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. Stand bisher nur in den Einstellungen und war - // deshalb unsichtbar (Commander-Fund 01.09.2026: „die alte Version konnte - // automatisch anhand der gescannten hardware encoder das beste preset - // wählen"). Sie konnte es, diese auch — man sah es nur nirgends. - const backends = werkzeuge?.backends ?? [] - const presets = werkzeuge?.presets ?? [] - const nurCpu = backends.length > 0 && backends.every((b) => b.startsWith('cpu')) - const EMPFEHLUNGEN: ReadonlyArray = [ - ['DVD', 'dvd'], - ['Blu-ray', 'bluray'], - ['4K-UHD', 'uhd'], - ] - - const schritte = ['Willkommen', 'Prüfung', 'Ordner', 'Qualität', 'Titel', 'Fertig'] - - return ( -
-
- {schritte.map((name, i) => ( - - {i + 1} {name} - - ))} -
- - {schritt === 0 && ( -
-

Willkommen bei Rippy

-

Rippy sichert deine DVDs, Blu-rays und Audio-CDs — erst verlustfrei, dann komprimiert.

-

Disc rein genügt: Rippy erkennt, was drauf ist, und sagt Bescheid.

-

Fertige Filme landen so abgelegt, dass Jellyfin, Emby, Kodi und Plex sie sofort erkennen.

-

- Rippy prüft gleich seine Werkzeuge und fragt dich danach drei Dinge: wohin, wie schnell, und - woher die Titel kommen. Dauert eine Minute — ändern kannst du alles später. -

-
- )} - - {schritt === 1 && ( -
- {pruefzeilen.map((zeile, i) => ( -

- {zeile.ok === true ? '✓' : zeile.ok === null ? '…' : zeile.pflicht ? '✗' : '⚠'} {zeile.text} -

- ))} -
- )} - - {schritt === 2 && ( -
-

Wohin sollen fertige Filme, Musik und Abbilder?

- {(verbindung.einstellungen['ablage'] ?? '').length > 0 ? ( -

{verbindung.einstellungen['ablage']}

- ) : ( - // Vorher stand hier eine LEERE Zeile — man sah nicht, wohin - // Rippy legt, wenn man nichts wählt. Es gibt aber eine Vorgabe - // (kern/index.ts: Videos\Rippy im Benutzerprofil). -

- noch nichts gewählt — dann nimmt Rippy „Videos\Rippy" in deinem Benutzerordner -

- )} - -

- Rippy baut darin selbst die Struktur: Filme, Musik, ISO und roh. Ein Netzlaufwerk geht auch. -

-
- )} - - {schritt === 3 && ( -
-

- Rippy sichert eine Disc immer erst verlustfrei. Danach kann es eine kleinere Fassung - berechnen — die kostet Zeit, spart aber viel Platz. -

- einstellungSetzen('transcodeEnabled', an ? 'true' : 'false')} - label="Nach dem Rip automatisch komprimieren" - /> - - {/* Die Hardware-Empfehlung — gemessen, nicht geraten. */} -
- {werkzeuge === null ? ( -

Encoder werden gemessen …

- ) : backends.length === 0 ? ( -

- Keine Encoder gemessen — ohne HandBrakeCLI bleibt es beim verlustfreien Rip. -

- ) : ( - <> -
- - AUF DIESER MASCHINE GEMESSEN: - - {backends.map((backend) => ( - - ))} -
-

- Danach wählt Rippy von selbst — Grafikkarte vor CPU, wo es eine gibt: -

-
- {EMPFEHLUNGEN.map(([name, typ]) => ( -
-

{name}

-

- {presetEmpfehlung(typ, backends, presets) || DEFAULT_HB_PRESET} -

-
- ))} -
- {nurCpu && ( -

- Nur CPU-Encoder gefunden — das Komprimieren dauert dann deutlich länger. Der - verlustfreie Rip ist davon nicht betroffen. -

- )} - - )} -
-

- Du musst hier nichts wählen. Unter „Einstellungen → Kompression" kannst du je Disc-Typ ein - eigenes Preset setzen — deine Wahl schlägt die Empfehlung immer. -

-
- )} - - {schritt === 4 && ( -
-

- Woher sollen Titel, Jahr, Poster und Beschreibung kommen? Dafür braucht Rippy einen - kostenlosen TMDb-Schlüssel. -

- einstellungSetzen('tmdbApiKey', w)} - /> -

- 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". -

-
- )} - - {schritt === 5 && ( -
-

Das war's. Disc einlegen — Rippy meldet sich.

-

- Rippy lebt im Infobereich neben der Uhr: Fenster zu heißt weiterlaufen, beendet wird über - das Tray-Menü. -

- {makemkvFehlt && ( -

- Denk an MakeMKV: Ohne makemkvcon bleibt der Video-Weg zu (Einstellungen → Werkzeuge zeigt - den Stand, sobald es installiert ist). -

- )} -
- )} - -
- - {schritt < schritte.length - 1 ? ( - - ) : ( - - )} -
-
- ) -} - -type Bereich = 'uebersicht' | 'einstellungen' | 'bibliothek' - -export default function App() { - const [haupt, setHaupt] = useState(null) - // 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('') - // Die Dialoge leben HIER, nicht in der Laufwerks-Kachel (5.4.0): Die - // Kachelliste hängt an der Laufwerksliste der Wache — ein einziger leerer - // Takt räumte Kachel UND Dialog ab (der zweite Verdacht vom 01.09.2026). - const [dialog, setDialog] = useState<{ art: 'titel' | 'aendern'; id: string } | null>(null) - // #einstellungen / #bibliothek als Startanker — der Smoke-Beweis - // fotografiert damit auch die anderen Bereiche. - const [bereich, setBereich] = useState(() => { - const anker = window.location.hash.replace('#', '') - return anker === 'einstellungen' || anker === 'bibliothek' ? anker : 'uebersicht' - }) - const [verbindung, setVerbindung] = useState({ - verbunden: false, - kern: null, - pongLatenzMs: null, - laufwerke: [], - rips: {}, - discInfos: {}, - discTitel: {}, - vorschlaege: {}, - ripStart: {}, - ripProtokoll: {}, - einstellungen: {}, - werkzeuge: null, - bibliothek: [], - schluessel: null, - beschaffung: null, - vorgaenge: null, - gesundheit: {}, - meldungen: [], - }) - - useEffect(() => { - window.rippy.aufStatus((status) => { - if (istHauptStatus(status)) setHaupt(status) - }) - verbindungStarten() - return aufVerbindung(setVerbindung) - }, []) - - const db = verbindung.kern?.datenbank ?? null - const einRipLaeuft = Object.values(verbindung.rips).some((r) => LAUFENDE_PHASEN.includes(r.phase)) - - // R4: Systemfehler stehen als rote Karte in der Übersicht — die - // LED-Leiste unten zeigt nur den Daumenwert. - const systemFehler: string[] = [] - if (haupt !== null && !haupt.leine.ok) systemFehler.push(`Prozess-Leine nicht gesetzt: ${haupt.leine.fehler ?? '?'}`) - if (db !== null && !db.ok) systemFehler.push(`Datenbank: ${db.fehler}`) - if (haupt !== null && !haupt.kern.laeuft && verbindung.kern === null) systemFehler.push('Der Kern läuft nicht.') - - // § 6.9: Die Erst-Einrichtung läuft NACH der Installation, im Programm — - // solange sie nicht abgeschlossen ist, ist sie die ganze Ansicht. - // - // `#einrichtung` erzwingt sie (RIPPY_START_BEREICH) — wie #einstellungen - // und #bibliothek. Ohne diesen Anker wäre der Wizard nach dem ersten - // Start nicht mehr zu fotografieren und damit nicht mehr zu prüfen. - const setupOffen = - window.location.hash === '#einrichtung' || - (Object.keys(verbindung.einstellungen).length > 0 && verbindung.einstellungen['setup.done'] !== 'true') - - const knopf = (ziel: Bereich, text: string): React.JSX.Element => ( - - ) - - function LedPunkt({ ok, text }: { ok: boolean | null; text: string }) { - return ( - - - {text} - - ) - } - - return ( -
-
-
- - {/* Die Kopfzeile IST die Titelleiste: Sie ist die Ziehfläche des - rahmenlosen Fensters (§ 4.2). Alles Bedienbare darin trägt - `nicht-ziehbar`, sonst schluckt der Griff den Klick. */} -
-
-
- -

Rippy

- - v{haupt?.version ?? '…'} - - {einRipLaeuft && ( - - - REC - - )} -
- -
- void window.rippy.fensterSteuern('minimieren')}> - - - void window.rippy.fensterSteuern('maximieren')} - > - {haupt?.fensterMaximiert === true ? : } - - {/* Schließen versteckt nur — Rippy lebt im Tray weiter, ein - laufender Rip merkt davon nichts (§ 4.1 Punkt 3). */} - void window.rippy.fensterSteuern('schliessen')} - > - - -
-
-
- -
- {setupOffen && } - {!setupOffen && bereich === 'uebersicht' && ( - <> - {systemFehler.length > 0 && ( -
- {systemFehler.map((zeile, i) => ( -

- ✗ {zeile} -

- ))} -
- )} - - {/* § 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 && ( -
-
- - Update verfügbar — Version {haupt.update.neueVersion} - - du hast {haupt.version} -
-

{haupt.update.text}

- {haupt.update.fortschritt >= 0 && !haupt.update.bereit && ( -
-
-
- )} - {haupt.update.neuerungen.length > 0 && ( -
-

Was neu ist

-

- {haupt.update.neuerungen} -

-
- )} - {haupt.update.bereit && ( -
- - - … oder einfach nichts tun: Beim nächsten Beenden passiert es von selbst. - -
- )} - {updateHinweis.length > 0 &&

{updateHinweis}

} -
- )} - -
- {verbindung.laufwerke.length === 0 ? ( -
- -

- {verbindung.verbunden ? 'Kein optisches Laufwerk gefunden.' : 'Warte auf den Kern …'} -

-
- ) : ( -
- {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 ( - v.id === ripVorgangId)} - kompression={laeuft !== null && laeuft.vorgangId === ripVorgangId ? laeuft : null} - wartePlatz={wartend.findIndex((w) => w.vorgangId === ripVorgangId) + 1} - aufRippen={() => { - // Erst die Titel lesen (20-120 s), dann waehlen lassen. - setDialog({ art: 'titel', id: geraet.id }) - titelLesen(geraet.id) - }} - aufAendern={() => setDialog({ art: 'aendern', id: geraet.id })} - /> - ) - })} -
- )} -
- - {verbindung.vorgaenge !== null && ( -
- -
- )} - -
- -
- - {dialog !== null && dialog.art === 'titel' && ( - l.id === dialog.id)?.typ ?? 'bluray'} - stand={verbindung.discTitel[dialog.id]} - info={verbindung.discInfos[dialog.id]} - einstellungen={verbindung.einstellungen} - regel={verbindung.vorgaenge?.regel ?? ''} - schliessen={() => setDialog(null)} - /> - )} - {dialog !== null && dialog.art === 'aendern' && ( - setDialog(null)} - /> - )} - - {verbindung.meldungen.length > 0 && ( -
-
EREIGNISSE
-
- {verbindung.meldungen.map((zeile, i) => ( -

- {zeile} -

- ))} -
-
- )} - - )} - - {!setupOffen && bereich === 'einstellungen' && } - {!setupOffen && bereich === 'bibliothek' && } -
- -
- - - - - Rippy v5 · LucyAI · Claude · KrBrZ -
-
- ) -} +// Die Oberfläche im Kino-Mix (Design-Entscheid des Commanders, 31.08.2026, +// beweise/…-Canvas „Rippy Redesign"): die Kinosaal-Bühne — warmes Schwarz, +// Saallicht, Filmkorn, Gold, Poster mit Goldkante, Plakat-Titel — plus die +// Schaltpult-Präzision: REC-Glühen, Info-Kacheln, Segment-Anzeige mit +// großer Prozentzahl, Mono-Protokoll, LED-Fußleiste. +// +// Seit 5.5.0 ist diese Datei nur noch der RAHMEN (Separation of Concerns): +// Kopfzeile, Bereichswahl, Fußleiste — die Bereiche selbst liegen in +// uebersicht/, einstellungen/, einrichtung/, die Dialoge in dialoge/, das +// Gemeinsame in bausteine.tsx. Die Regeln bleiben: EINE Einstellungs-Quelle +// über den Kern (§ 6.7), R2 (nichts verschwindet ohne Auskunft), R4 (Fehler +// stehen im Fenster — bei Systemfehlern als rote Karte). +import { useEffect, useState } from 'react' +import type { HauptStatus } from '../gemeinsam/nachrichten' +import { + FensterKnopf, + IconDisc, + IconMaximieren, + IconMinimieren, + IconSchliessen, + IconWiederherstellen, + LAUFENDE_PHASEN, + gbKurz, +} from './bausteine' +import { ErstEinrichtung } from './einrichtung/ErstEinrichtung' +import { Einstellungen } from './einstellungen/Einstellungen' +import { LEERE_VERBINDUNG, aufVerbindung, verbindungStarten, type Verbindung } from './kernverbindung' +import { SEHR_WENIG_FREI_BYTES, WENIG_FREI_BYTES } from './uebersicht/SpeicherKarte' +import { Uebersicht } from './uebersicht/Uebersicht' + +type Bereich = 'uebersicht' | 'einstellungen' + +function istHauptStatus(wert: unknown): wert is HauptStatus { + return typeof wert === 'object' && wert !== null && typeof (wert as Record).version === 'string' +} + +/** Eine LED der Fußleiste (5.5.0): grün/gelb/rot/grau plus Klartext — + * statt PID und Ping-Millisekunden aus dem Gerüst-Beweis. */ +function Led({ ton, text, titel }: { ton: 'gruen' | 'warn' | 'rot' | 'grau'; text: string; titel?: string }) { + const farbe = ton === 'gruen' ? 'bg-gruen' : ton === 'warn' ? 'bg-warn' : ton === 'rot' ? 'bg-rot' : 'bg-white/25' + return ( + + + {text} + + ) +} + +export default function App() { + const [haupt, setHaupt] = useState(null) + // #einstellungen als Startanker — der Smoke-Beweis fotografiert damit + // auch die anderen Bereiche. `start` springt in den Einstellungen zu + // einer Karte („alle unter Roh-Dateien →"). + const [bereich, setBereich] = useState<{ name: Bereich; start?: string }>(() => { + const anker = window.location.hash.replace('#', '') + return { name: anker === 'einstellungen' ? 'einstellungen' : 'uebersicht' } + }) + const [verbindung, setVerbindung] = useState(LEERE_VERBINDUNG) + + useEffect(() => { + window.rippy.aufStatus((status) => { + if (istHauptStatus(status)) setHaupt(status) + }) + verbindungStarten() + return aufVerbindung(setVerbindung) + }, []) + + const db = verbindung.kern?.datenbank ?? null + const einRipLaeuft = Object.values(verbindung.rips).some((r) => LAUFENDE_PHASEN.includes(r.phase)) + + // R4: Systemfehler stehen als rote Karte in der Übersicht — die + // LED-Leiste unten zeigt nur den Daumenwert. + const systemFehler: string[] = [] + if (haupt !== null && !haupt.leine.ok) systemFehler.push(`Prozess-Leine nicht gesetzt: ${haupt.leine.fehler ?? '?'}`) + if (db !== null && !db.ok) systemFehler.push(`Datenbank: ${db.fehler}`) + if (haupt !== null && !haupt.kern.laeuft && verbindung.kern === null) systemFehler.push('Der Kern läuft nicht.') + + // § 6.9: Die Erst-Einrichtung läuft NACH der Installation, im Programm — + // solange sie nicht abgeschlossen ist, ist sie die ganze Ansicht. + // `#einrichtung` erzwingt sie (RIPPY_START_BEREICH). + const setupOffen = + window.location.hash === '#einrichtung' || (Object.keys(verbindung.einstellungen).length > 0 && verbindung.einstellungen['setup.done'] !== 'true') + + const knopf = (ziel: Bereich, text: string): React.JSX.Element => ( + + ) + + // ── Fußleiste (5.5.0): der Daumenwert des Systems in sechs LEDs ───── + const w = verbindung.werkzeuge + const werkzeugTon = w === null ? 'grau' : w.makemkvcon.length > 0 && w.handbrake.length > 0 ? 'gruen' : w.makemkvcon.length > 0 ? 'warn' : 'rot' + const s = verbindung.schluessel + const schluesselTon = s === null ? 'grau' : s.angenommen === true ? 'gruen' : s.weiterBetrieb ? 'warn' : 'rot' + const arbeit = verbindung.speicher?.arbeit ?? null + const speicherTon = arbeit === null ? 'grau' : !arbeit.ok ? 'rot' : arbeit.freiBytes < SEHR_WENIG_FREI_BYTES ? 'rot' : arbeit.freiBytes < WENIG_FREI_BYTES ? 'warn' : 'gruen' + const updateTon = haupt === null ? 'grau' : haupt.update.neueVersion.length > 0 ? 'warn' : haupt.update.letzteErfolgreichePruefung.length > 0 ? 'gruen' : 'grau' + + return ( +
+
+
+ + {/* Die Kopfzeile IST die Titelleiste: Sie ist die Ziehfläche des + rahmenlosen Fensters (§ 4.2). Alles Bedienbare darin trägt + `nicht-ziehbar`, sonst schluckt der Griff den Klick. */} +
+
+
+ +

Rippy

+ v{haupt?.version ?? '…'} + {einRipLaeuft && ( + + + REC + + )} +
+ +
+ void window.rippy.fensterSteuern('minimieren')}> + + + void window.rippy.fensterSteuern('maximieren')}> + {haupt?.fensterMaximiert === true ? : } + + {/* Schließen versteckt nur — Rippy lebt im Tray weiter, ein + laufender Rip merkt davon nichts (§ 4.1 Punkt 3). */} + void window.rippy.fensterSteuern('schliessen')}> + + +
+
+
+ +
+ {setupOffen && } + {!setupOffen && bereich.name === 'uebersicht' && ( + setBereich({ name: 'einstellungen', start: 'roh' })} /> + )} + {!setupOffen && bereich.name === 'einstellungen' && } +
+ +
+ + + 0 ? 'da' : 'FEHLT'} · HandBrake ${w.handbrake.length > 0 ? 'da' : 'fehlt'}`} + /> + + + 0 ? `UPDATE ${haupt.update.neueVersion}` : `v${haupt?.version ?? '…'}${haupt !== null && haupt.update.letzteErfolgreichePruefung.length > 0 ? ' AKTUELL' : ''}`} + titel={haupt?.update.text} + /> + Rippy v5 · LucyAI · Claude · KrBrZ +
+
+ ) +} diff --git a/rippy-windows/src/fenster/bausteine.tsx b/rippy-windows/src/fenster/bausteine.tsx new file mode 100644 index 0000000..f9d9932 --- /dev/null +++ b/rippy-windows/src/fenster/bausteine.tsx @@ -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 = { + cd: 'Audio-CD', + dvd: 'DVD', + bluray: 'Blu-ray', + uhd: '4K-UHD', + unknown: 'unbekannt', +} + +export const PHASEN_MONO: Record = { + '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 ( + + + + + + ) +} + +export function IconPlay() { + return ( + + + + ) +} + +export function IconStop() { + return ( + + + + ) +} + +export function IconEject() { + return ( + + + + ) +} + +export function IconStift() { + return ( + + + + ) +} + +// ── 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 => ( + +) + +export const IconMaximieren = (): React.JSX.Element => ( + +) + +export const IconWiederherstellen = (): React.JSX.Element => ( + +) + +export const IconSchliessen = (): React.JSX.Element => ( + +) + +export function FensterKnopf({ + titel, + aufKlick, + rot = false, + children, +}: { + titel: string + aufKlick: () => void + rot?: boolean + children: React.ReactNode +}) { + return ( + + ) +} + +// ── 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 ( + + {text} + + ) +} + +/** Die kleinen Mess-Kacheln aus dem Schaltpult (MEDIUM / GRÖSSE / …). */ +export function InfoKachel({ label, wert, farbe }: { label: string; wert: string; farbe?: string }) { + return ( +
+
{label}
+
+ {wert} +
+
+ ) +} + +/** 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 ( +
+ {Array.from({ length: segmente }, (_, i) => ( +
+ ))} +
+ ) +} + +/** 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 ( +
+
+
+ ) +} + +export function Kicker({ text }: { text: string }) { + return

{text}

+} + +/** Ein richtiger Schalter statt einer nackten Checkbox. */ +export function Schalter({ an, aufWechsel, label }: { an: boolean; aufWechsel: (an: boolean) => void; label: string }) { + return ( + + ) +} + +/** 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 ( +
+ +

{intro}

+
{children}
+
+ ) +} + +/** Kleiner Status-Punkt für Schlüssel-Felder: hinterlegt oder leer. */ +export function HinterlegtPunkt({ wert }: { wert: string }) { + return ( + 0 ? 'text-gruen' : 'text-nebel'}`}> + {wert.length > 0 ? '● hinterlegt' : '○ fehlt'} + + ) +} + +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 ( + + ) +} + +/** 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 => { + const gewaehlt = art === 'ordner' ? await window.rippy.ordnerWaehlen(dialogTitel) : await window.rippy.dateiWaehlen(dialogTitel) + if (gewaehlt !== null) aufAendern(gewaehlt) + } + return ( +
+
+ +
+ +
+ ) +} + +/** 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 ( +
+
e.stopPropagation()} + > + {children} +
+
+ ) +} diff --git a/rippy-windows/src/fenster/dialoge/AendernDialog.tsx b/rippy-windows/src/fenster/dialoge/AendernDialog.tsx new file mode 100644 index 0000000..475e5d6 --- /dev/null +++ b/rippy-windows/src/fenster/dialoge/AendernDialog.tsx @@ -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 ( + +

Was ist das wirklich?

+
+ 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" + /> + +
+ {vorschlaege.length === 0 ? ( +

Keine Treffer — anderen Suchtext probieren.

+ ) : ( +
+ {vorschlaege.map((v) => ( + + ))} +
+ )} +
+ +
+
+ ) +} diff --git a/rippy-windows/src/fenster/dialoge/TitelWahlDialog.tsx b/rippy-windows/src/fenster/dialoge/TitelWahlDialog.tsx new file mode 100644 index 0000000..951626c --- /dev/null +++ b/rippy-windows/src/fenster/dialoge/TitelWahlDialog.tsx @@ -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 20–120 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 ( + + {name} + {vorhanden.map((s) => { + const an = gewaehlt.includes(s.lang) + return ( + + ) + })} + + ) +} + +export function TitelWahlDialog({ + id, + typ, + stand, + info, + einstellungen, + regel, + speicher, + schliessen, +}: { + id: string + typ: GeraeteInfo['typ'] + stand: TitelStand | undefined + info: DiscInfoStand | undefined + einstellungen: Record + /** Klartext der Aufbewahrungs-Regel — damit klar ist, was mit dem + * Roh-Material passiert. */ + regel: string + speicher: SpeicherStand | null + schliessen: () => void +}) { + const [gewaehlt, setGewaehlt] = useState | 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>({}) + // 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>({}) + // 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 3–4"). + const merker = info?.serienMerker ?? null + const merkerGilt = merker !== null && (info?.discNr === null || info === undefined || (info.discNr ?? 1) > 1) + const [staffel, setStaffel] = useState( + 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}-`, + }) + // 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 ( + +

Was soll gerippt werden?

+ + {stand === undefined || stand.laeuft ? ( +

+ Rippy liest die Titel der Disc … das dauert bis zu zwei Minuten. Solange dreht das Laufwerk. +

+ ) : stand.fehler.length > 0 ? ( +

✗ {stand.fehler}

+ ) : ( + <> +

+ {stand.unsicher ? '⚠ ' : ''} + {stand.einordnung} +

+ +
+ + + + +
+ +
+ {alle.map((t) => { + const an = aktiv.has(t.nr) + const wahl = sprachenVon(t) + return ( +
+ + {/* 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) && ( +
+ sprachWechsel(t, 'audio', lang)} /> + sprachWechsel(t, 'untertitel', lang)} /> + {wahl.audio.length === 0 && t.sprachen.audio.length > 0 && ( + kein Ton gewählt — HandBrake nimmt die erste Spur + )} +
+ )} +
+ ) + })} + {alle.length === 0 &&

Die Disc gab keine Titel her.

} +
+ + {alsSerie && ( +
+ + +

+ {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}.`} +

+
+ )} + + {/* Vorschau (5.4.0): Rolle, Serie, Staffel und Extras entscheiden + einiges — zwei Zeilen Zielpfad ersparen Überraschungen. */} +
+

LANDET IN

+

{vorschau[0]}

+

{vorschau[1]}

+
+ + {/* Platz-Prüfung (5.5.0): 40 Minuten Lesen sollen nicht an einem + vollen Laufwerk scheitern. */} + {speicher !== null && speicher.arbeit.ok && ( +

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' : ''}.`} +

+ )} + +

+ {aktiv.size} von {alle.length} gewählt · zusammen {gb(gesamtBytes)} verlustfrei + {regel.length > 0 ? ` · ${regel}` : ''} +

+

+ {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. +

+ + )} + +
+ + +
+
+ ) +} diff --git a/rippy-windows/src/fenster/einrichtung/ErstEinrichtung.tsx b/rippy-windows/src/fenster/einrichtung/ErstEinrichtung.tsx new file mode 100644 index 0000000..2042116 --- /dev/null +++ b/rippy-windows/src/fenster/einrichtung/ErstEinrichtung.tsx @@ -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 = [ + ['DVD', 'dvd'], + ['Blu-ray', 'bluray'], + ['4K-UHD', 'uhd'], + ] + + return ( + <> + +
+
+ {SCHRITTE.map((name, i) => ( + + {i + 1} {name} + + ))} +
+ + {schritt === 0 && ( +
+

Willkommen bei Rippy

+

Rippy sichert deine DVDs, Blu-rays und Audio-CDs — erst verlustfrei, dann komprimiert.

+

Disc rein genügt: Rippy erkennt, was drauf ist, und startet bei sicherer Erkennung von selbst.

+

Fertige Filme landen so abgelegt, dass Jellyfin, Emby, Kodi und Plex sie sofort erkennen.

+

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.

+
+ )} + + {schritt === 1 && ( +
+
+ {pruefzeilen.map((zeile, i) => ( +

+ {zeile.ok === true ? '✓' : zeile.ok === null ? '…' : zeile.pflicht ? '✗' : '⚠'} {zeile.text} +

+ ))} +
+ {makemkvFehlt && ( + setzen('werkzeug.makemkv', w)} + /> + )} +
+ )} + + {schritt === 2 && ( +
+

Wohin sollen fertige Filme, Musik und Abbilder? Ein Netzlaufwerk geht — Rippy baut darin selbst die Struktur.

+ setzen('ablage', w)} /> + {speicher !== null && speicher.ablage.ok &&

{gbKurz(speicher.ablage.freiBytes)} frei von {gbKurz(speicher.ablage.gesamtBytes)}

} +

+ Liegt die Ablage auf einem Server, wähle einen Arbeitsordner auf einer lokalen Platte: Dort liegen die Roh-Dateien + (30 GB und mehr je Blu-ray) und dort rechnet HandBrake. Nur das fertige Ergebnis wandert übers Netz. +

+ setzen('arbeitsordner', w)} + /> + {speicher !== null && speicher.getrennt && speicher.arbeit.ok && ( +

{gbKurz(speicher.arbeit.freiBytes)} frei von {gbKurz(speicher.arbeit.gesamtBytes)} — eine Blu-ray braucht rund 50 GB.

+ )} +
+ )} + + {schritt === 3 && ( +
+

Rippy sichert eine Disc immer erst verlustfrei. Danach kann es eine kleinere Fassung berechnen — die kostet Zeit, spart aber viel Platz.

+ setzen('transcodeEnabled', an ? 'true' : 'false')} label="Nach dem Rip automatisch komprimieren" /> +
+ {werkzeuge === null ? ( +

Encoder werden gemessen …

+ ) : backends.length === 0 ? ( +

Keine Encoder gemessen — ohne HandBrakeCLI bleibt es beim verlustfreien Rip.

+ ) : ( + <> +
+ AUF DIESER MASCHINE GEMESSEN: + {backends.map((backend) => ( + + ))} +
+

Danach wählt Rippy von selbst — Grafikkarte vor CPU, wo es eine gibt:

+
+ {EMPFEHLUNGEN.map(([name, typ]) => ( +
+

{name}

+

{presetEmpfehlung(typ, backends, presets) || DEFAULT_HB_PRESET}

+
+ ))} +
+ {nurCpu &&

Nur CPU-Encoder gefunden — das Komprimieren dauert dann deutlich länger. Der verlustfreie Rip ist davon nicht betroffen.

} + + )} +
+
+ setzen('audioSprachen', w)} /> + setzen('untertitelSprachen', w)} /> +
+

Je Disc-Typ ein eigenes Preset geht später unter „Einstellungen → Kompression" — deine Wahl schlägt die Empfehlung immer.

+
+ )} + + {schritt === 4 && ( +
+

Woher sollen Titel, Jahr, Poster und Beschreibung kommen? Dafür braucht Rippy einen kostenlosen TMDb-Schlüssel.

+ setzen('tmdbApiKey', w)} /> + {tmdbDa && verbindung.poster.length > 0 &&

✓ Der Schlüssel funktioniert — die Poster im Hintergrund kommen von TMDb.

} +

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".

+
+ )} + + {schritt === 5 && ( +
+

Soll Rippy sich aufs Handy melden, wenn ein Rip fertig ist oder etwas schiefgeht? Windows-Meldungen kommen immer — das hier ist freiwillig.

+ setzen('discordWebhook', w)} /> +
+ setzen('telegramToken', w)} /> + setzen('telegramChatId', w)} /> +
+ +
+ )} + + {schritt === 6 && ( +
+

Wie selbstständig soll Rippy sein?

+ setzen('automatik', an ? 'an' : 'aus')} label="Automatik: bei sicherer Erkennung (ab 90 %) nach einem Countdown von selbst rippen — mit Stopp-Knopf" /> + + setzen('autoAuswurf', an ? 'true' : 'false')} label="Disc nach dem Rip auswerfen — die nächste kann rein, während die Kompression läuft" /> + +

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.

+
+ )} + + {schritt === 7 && ( +
+

Das war's. Disc einlegen — Rippy meldet sich.

+

Rippy lebt im Infobereich neben der Uhr: Fenster zu heißt weiterlaufen, beendet wird über das Tray-Menü.

+ {makemkvFehlt &&

Denk an MakeMKV: Ohne makemkvcon bleibt der Video-Weg zu (Einstellungen → Werkzeuge zeigt den Stand, sobald es installiert ist).

} +
+ )} + +
+ + {schritt < SCHRITTE.length - 1 ? ( + + ) : ( + + )} +
+
+ + ) +} diff --git a/rippy-windows/src/fenster/einrichtung/PosterHintergrund.tsx b/rippy-windows/src/fenster/einrichtung/PosterHintergrund.tsx new file mode 100644 index 0000000..234859b --- /dev/null +++ b/rippy-windows/src/fenster/einrichtung/PosterHintergrund.tsx @@ -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 ( +