Compare commits

2 Commits
Author SHA1 Message Date
HitonabiandClaude Fable 5.1 512b38cced docs: SAVEPOINT — 5.6.0 gebaut und lokal installiert, NICHT veröffentlicht; Übergabe
Ampel / ampel (push) Successful in 1m25s
Prüfsumme, Klick-Beweis (33 Schritte), Tests (446), die acht Punkte von 5.6.0
(inkl. Vorgangs-Leiste), was am Gerät noch zu messen ist, bekannte Schwächen.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 19:19:11 +02:00
HitonabiandClaude Fable 5.1 f5dab5d1c1 feat(v5): 5.6.0 — Vorgangs-Leiste, mehrere Filme je Disc, Filmnamen für Ordner und Dateien, Untertitel-Vorwahl je Extra, Restzeit, Hell-Modus
Die sechs Punkte des Commanders vom 02.09.2026 abends und seine Entscheide:

* Restzeit der Kompression aus HandBrakes eigener ETA-Klammer (Formatstring
  aus dem Binary 1.11.2 gelesen), KompressionStand.restS.
* Kachel zeigt nach dem Rip nur noch eine Zeile — Balken, Prozent, Ziel in
  der Kompressions-Karte und im Verlauf ("nur in der Kompressions-Karte").
* Kopfzeile bleibt beim Scrollen stehen (sticky), Sektionswahl darunter.
* Untertitel-Vorwahl je Extra ("NUR für Extras, IMMER OPTIONAL"): welche
  Untertitel-Spur beim Abspielen an ist — weiche Spur, nie eingebrannt;
  RipAuftragTitel/RohDatei.untertitelVorwahl, sprachwahl.
* Mehrere Filme je Disc: "anderer Film …" je Titel mit TMDb-Suche;
  filme.ts filmGruppen trennt am Rip-Ende in eigene Roh-Ordner, Vorgänge
  und Kompressions-Aufträge mit voller Zuordnung (erkennung.zuordnungAus).
* Roh-Ordner und Dateien tragen den Filmnamen (filme.ts rohOrdnerName,
  ablage/dateinamen.ts: Akira (1988).mkv, - Fassung n, - Extra nn).
* Hell-Modus: Einstellung design (dunkel/hell/system), data-design auf
  <html>, Token --color-tinte statt white/-Alpha-Klassen.
* Vorgangs-Leiste (Commander: "Phase 1–4 plus Komprimierung — zu einem
  machen"): gemeinsam/vorgangsleiste.ts + uebersicht/VorgangsLeiste.tsx,
  vier Stationen (Rettung als Zwischenstation), Segment-Balken in der
  aktiven Station; Kachel, Kompressions-Karte, Verlauf. PHASE-x/4-Texte weg.
* Automatik-Kasten leert sich, sobald ein Rip startet.

Beweise: 446 Tests (neu: filme, dateinamen, vorgangsleiste; erweitert:
auftrag, handbrake, automatik, ablegen), drei Typprüfungen, Klick-Beweis
33 Schritte inkl. Vorwahl, anderer Film, Vorgangs-Leiste, Restzeit,
Hell-Modus (Bilder beweise/klick/01…11).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 19:17:37 +02:00
60 changed files with 1429 additions and 183 deletions
+145 -1
View File
@@ -1,6 +1,150 @@
# SAVEPOINT — Rippy
## ÜBERGABE an die nächste Session (02.09.2026 abends — 5.5.0 gebaut, lokal installiert und im Tag `aktuell` VERÖFFENTLICHT)
## ÜBERGABE an die nächste Session (03.09.2026 abends — 5.6.0 gebaut und lokal installiert, NICHT veröffentlicht)
### Wo die nächste Session startet
* **Arbeitsort:** `F:\Coding Stuff\rippy`, Branch `worktree-windows-electron`
(Spitze siehe `git log`). v5-Code in `rippy-windows/`; `main` ist reines
Docker-Rippy.
* **Bau:** wie in BAUEN.md — `npm install` + `node node_modules/electron/install.js`,
Playwright mit `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1`, `vendor/` aus
`%LOCALAPPDATA%\Rippy\tools`.
* **Die drei Läufe:** `npm test` (446 Tests in 44 Dateien),
`npm run smoke` (Gerüst), `npm run klick` (33 Klick-Schritte, Bilder
`beweise/klick/*.png`, darunter `07-hell-modus.png` und `02-dialog-vorwahl.png`).
Alle drei in dieser Session grün.
* **Release veröffentlichen — NUR nach dem Wort des Commanders:** die
Dateien im Gitea-Tag `aktuell` ERSETZEN (`RippySetup-5.6.0.exe`,
`.blockmap`, `latest.yml`; `w0.yml` bleibt liegen). Dateien liegen in
`rippy-windows/dist-setup/` (Kopie) und `rippy-windows/dist-setup-560/`
(Original). Weg wie bei 5.5.0 (siehe unten „Veröffentlicht"): Token
`F:\Coding Stuff\Gittea Token\rippy.txt` über das PowerShell-Werkzeug
lesen (nie ausgeben), LAN-Adresse
`http://192.168.178.153:3000/api/v1/repos/Hitonabi/rippy/releases/1`,
ERST Setup und Blockmap hochladen, DANN `latest.yml` löschen (204) und
neu hochladen (201), Release-Name/Notizen PATCHen, anonym über
`https://git.tobisniceshomelab.ddnsfree.com/Hitonabi/rippy/releases/download/aktuell/`
gegenmessen. Das PowerShell-Werkzeug blockt Befehle mit `Remove-Item`
Skript ohne schreiben. **Im Kanal liegt 5.5.0.**
### Stand
**5.6.0 ist gebaut, am ausgepackten Paket bewiesen und auf DIESEM PC
installiert (03.09.2026 19:17, Installer-Exit 0, Rippy lief dabei nicht), NICHT im Kanal.** Der Commander testet erst.
* Setup: `RippySetup-5.6.0.exe`, **131 566 582 Bytes**, SHA-256
`4392CA5D36E165491085810D4181B22594BB29D62334ED1CDD0D2171264DA1FD` (Bau in `dist-setup-560/`).
* Paket-Smoke: `SMOKE: OK — fenster=geladen kern=pid:15172,node:24.18.1 datenbank=ok leine=gesetzt pong=ok version=5.6.0`.
* **446 Tests** in 44 Dateien (5.5.0: 413), drei
Typprüfungen und Bau grün.
* Klick-Beweis: 33 Schritte grün, Ausgang 0 — neu gegenüber 5.5.0:
beide Titel auf Hauptfilm → keine Vorwahl; Titel 1 auf Extra → „BEIM
ABSPIELEN AN" erscheint, `deu` gewählt; zwei „anderer Film …"-Knöpfe,
Suchfeld öffnet; Kompressions-Karte zeigt „noch … s" (ETA aus der
nachgebauten HandBrake-Zeile), die Kachel zeigt KEINEN vollen Block mehr,
sondern „Kompression fertig — das Ergebnis steht im Verlauf"; Hell-Modus
über Einstellungen → Allgemein (`html[data-design=hell]`, Bild), zurück
auf Dunkel; Vorgangs-Leiste in der Kachel (TITEL LESEN ✓, RIPPEN aktiv,
KOMPRIMIEREN/ABLEGEN offen, keine „PHASE x/4"-Texte) und in der
Kompressions-Karte (KOMPRIMIEREN aktiv).
* Commits dieser Session: `f5dab5d` (Kern, Fenster, Tests, Klick-Beweis, Aenderungsnotizen, Version 5.6.0 — 59 Dateien) und dieser SAVEPOINT.
### Was 5.6.0 enthält — die sechs Punkte des Commanders vom 03.09.2026
Seine Worte: „Bei Kompression ETA anzeigen / Wenn ein Job abgeschlossen
wird, wird Kompression beim Hero-Header und im Kompression angezeigt — nur
nötig bei Kompression, oder? / Beim Scrollen in den Einstellungen muss die
Bar oben sinnvoll mitscrollen / Untertitel müssen separat auswählbar sein
(das hatten wir besprochen) / White Mode / Verhalten, wenn eine Disc mehr
als einen Film hat, besprechen." Seine Entscheide in den Rückfragen: **nur
in der Kompressions-Karte; Untertitel-Vorwahl je Inhalt — „NUR für Extras
und IMMER OPTIONAL", weiche Spur, nie eingebrannt; je Titel eigener Film
im Dialog; Roh-Ordner UND Dateien tragen den Filmnamen.**
1. **Restzeit (ETA):** `kern/komprimieren/handbrake.ts etaAusZeile` liest
HandBrakes eigene Klammer — Formatstring aus dem Binary (1.11.2):
`Encoding: task %d of %d, %.2f %% (%.2f fps, avg %.2f fps, ETA %02dh%02dm%02ds)`.
`KompressionStand.restS` → Kompressions-Karte „noch ~12 min"
(`bausteine.restText`). In den ersten Sekunden ohne Klammer: „Restzeit
wird berechnet …".
2. **Kachel-Kurzzeile:** `LaufwerkKachel` zeigt nach dem Rip nur noch eine
Zeile (läuft / Platz n / fertig → Verlauf / Fehler bleibt sichtbar, R4).
3. **Kopfzeile bleibt stehen:** `App.tsx` header `sticky top-0 z-40` mit
`bg-buehne/85 backdrop-blur-md`; die Sektionswahl der Einstellungen
sitzt darunter (`lg:top-[76px]`).
4. **Untertitel-Vorwahl je Extra:** `RipAuftragTitel.untertitelVorwahl`
(`string` = diese Sprache vorgewählt, `null` = keine, fehlend = Rippys
Regel) → `RohDatei.untertitelVorwahl``auftrag.ts sprachwahl` stellt
sie nach vorn und setzt `untertitelStandard` (HandBrake
`--subtitle-default=1`, weiche Spur). Dialog: Select „BEIM ABSPIELEN AN"
nur bei Rolle Extra, Vorgabe „Rippys Regel (deu)".
5. **Mehrere Filme je Disc:** `RipAuftragTitel.film` (MetaVorschlag) aus dem
Dialog („anderer Film …" mit TMDb-Suche über `metadaten-suchen`);
`kern/ablauf/filme.ts filmGruppen` teilt am Rip-Ende: Titel eines
anderen Films ziehen per `renameSync` in einen eigenen Roh-Ordner
(`rohOrdnerName`), bekommen eigenen Vorgang (`<vorgang>-2`, `-3` …) und
eigenen Kompressions-Auftrag mit voller Zuordnung
(`erkennung.zuordnungAus` → TMDb-Details; Notnagel
`zuordnungAusVorschlag`). Behält KEIN Titel die Disc-Zuordnung, wird die
erste Gruppe zum Hauptvorgang. Die Vorschau im Dialog zeigt je Film
seine Zeile.
6. **Ordner und Dateien mit Filmnamen:** Roh-Ordner
`roh\Akira (1988)` bzw. `roh\Spartacus - Staffel 1 Disc 2`, bei
Wiederholung „(2)"; ohne Zuordnung weiter der Zeitstempel. Dateien
(`kern/ablage/dateinamen.ts`): `Akira (1988).mkv`, zwei Fassungen
`… - Fassung 1.mkv`, Extras `… - Extra 01.mkv` in `extras\`; Folgen
behalten den Namen bis zur Episoden-Zuordnung. Der Vorgangs-Name
(Zeitstempel) bleibt die Kennung — `Vorgang.rohOrdner` trägt den Pfad.
7. **Hell-Modus:** Einstellung `design` (dunkel / hell / system);
`App.tsx` setzt `data-design` auf `<html>`, `stil.css` überschreibt die
Farb-Tokens (`:root[data-design="hell"]`: warmes Papier, dunkleres Gold,
Tinte statt Weiß). Neuer Token `--color-tinte` ersetzt alle
`white/`-Alpha-Klassen (`bg-tinte/…`, `border-tinte/…`).
8. **Vorgangs-Leiste** (Commander mitten in der Session: „Phase 14 plus
Komprimierung — zu einem machen, der Phasen-Balken gefällt mir"; sein
Ja zu Variante A): `gemeinsam/vorgangsleiste.ts` (pur: Stationen
Titel lesen → [Rettungs-Abbild] → Rippen → Komprimieren → Ablegen,
`stationsZustand`, `leisteAusRip/-Vorgang/-Kompression`) und
`fenster/uebersicht/VorgangsLeiste.tsx` (Segment-Balken in der aktiven
Station, kompakt als Punkte). Kachel: Details der Laufwerks-Stationen,
nach dem Rip nur der Stand; Kompressions-Karte: Stationen 34 mit
Restzeit; Verlauf und Warteschlange: Punkte. Die Texte „PHASE x/4"
sind weg (`PHASEN_MONO`).
### ⚠ Am Gerät noch zu messen (nichts davon war in dieser Session möglich)
* **Zwei Filme auf einer echten Disc** (Double Feature): Roh-Umzug per
`renameSync` im Arbeitsordner, zwei Vorgänge, zwei Zielordner mit Poster
und NFO — Unit-Tests decken die Gruppen-Logik (`filme.test.ts`), nicht
den Lauf mit MakeMKV.
* **Untertitel-Vorwahl im Spieler:** Ob Jellyfin/VLC die als Default
markierte Spur bei einem Extra wirklich einblendet — HandBrake-Flag ist
am 01.09. gemessen (setzt default UND forced), das Abspielen nicht.
* **Restzeit an einem echten Encode** (die Klammer erscheint nach den
ersten Sekunden; im Klick-Beweis kommt sie vom Nachbau).
* **Hell-Modus mit Poster-Kulisse und echten Postern.**
* Weiter offen aus 5.5.0: Rettungs-Abbild an Spartacus Disc 2, Automatik an
echter Disc, Benachrichtigung live, Datenbank-Befund vom 01.09. (siehe
5.5.0-Übergabe unten).
### Bekannte Schwächen, bewusst in Kauf genommen
* Ein Roh-Ordner mit Filmnamen ist auch bei „Neu komprimieren" derselbe —
die Regel „ein Ordner je Vorgang" gilt weiter, nur der Name ist lesbar.
* Der zweite Film einer Disc bekommt Staffel/Folge nie (nur Filme, keine
Serien) und keinen Fingerprint (die Disc als Ganzes ist beim Hauptvorgang
gemerkt).
* Ohne Arbeitsordner schreibt HandBrake weiterhin direkt ins Ziel — bei
„Neu komprimieren" in denselben Ordner überschreibt der neue Encode die
alte Datei gleichen Namens erst am Ende des Laufs (Umzug), aber ohne
Arbeitsordner während des Laufs. Wer wiederholt, sollte den Arbeitsordner
gesetzt haben (5.5.0-Empfehlung).
* Das Fenster startet mit dunklem Hintergrund (`backgroundColor` in
`haupt/fenster.ts`) und schaltet erst mit den Einstellungen auf Hell —
ein kurzes Aufblitzen beim Start.
## Übergabe vom 02.09.2026 abends (5.5.0 — veröffentlicht im Tag `aktuell`; überholt durch 5.6.0)
### Wo die nächste Session startet
Binary file not shown.

Before

Width:  |  Height:  |  Size: 317 KiB

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 368 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 730 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 731 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 711 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 712 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 369 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 503 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 749 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 341 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 485 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 465 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 458 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 447 KiB

+1 -1
View File
@@ -34,7 +34,7 @@ als npm-Skript hinterlegt: `npm run werkzeug:electron`.
| `npm run build` | Typprüfung (beide tsconfig) + Bau nach `out/` |
| `npm test` | Vitest: Wächter-Tests (§ 4.3), Datenbank, Nachrichten-Schema |
| `npm run smoke` | **Der W-0-Beweis:** baut, startet das echte Programm mit `--smoke`, prüft Fenster + Kern + Datenbank + Leine + Ping/Pong über den MessagePort und endet mit Code 0 (grün) oder 2. `RIPPY_SMOKE_BILD=<pfad.png>` legt zusätzlich einen Screenshot ab. Läuft nur unter Windows (Electron-Binary + koffi). |
| `npm run klick` | **Der Klick-Beweis (5.4.0):** baut, startet das echte Programm mit eigenem Profil (`--profil=…`, `--klick-smoke`: ohne Tray, Updater, Toasts), einem nachgebauten Laufwerk (`RIPPY_FAKE_LAUFWERK=Q`) und nachgebauten Werkzeugen (`bau/klick/fake-*.cjs`, laufen über `kern/werkzeuge/aufruf.ts`) — und klickt per Playwright durch 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). |
| `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). 5.6.0: dazu die Untertitel-Vorwahl (nur bei Extras), „anderer Film …" je Titel, die Restzeit in der Kompressions-Karte (das nachgebaute HandBrake schreibt die ETA-Klammer wie das Binary), die Kachel-Kurzzeile, die Vorgangs-Leiste (TITEL LESEN ✓ → RIPPEN aktiv → KOMPRIMIEREN → ABLEGEN, keine „PHASE x/4"-Texte mehr) und der Hell-Modus (Bild `07-hell-modus.png`). 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.
+49 -4
View File
@@ -143,6 +143,27 @@ await schritt('„Hauptinhalt + Extras" wählt beide langen Titel, der Schnipsel
await dialog().getByText(/2 von 3 gewählt/).waitFor()
})
await schritt('5.6.0: Untertitel-Vorwahl erscheint NUR bei Extras, „anderer Film …" je Titel', async () => {
// Rippys Vorschlag macht bei zwei langen Titeln den zweiten zum Extra —
// erst beide auf Hauptfilm, dann ist klar: keine Vorwahl bei Hauptfilmen.
const rollen = dialog().locator('select')
await rollen.nth(0).selectOption('hauptfilm')
await rollen.nth(1).selectOption('hauptfilm')
if ((await dialog().getByText('BEIM ABSPIELEN AN').count()) !== 0) throw new Error('Vorwahl schon bei Hauptfilmen sichtbar')
await rollen.nth(1).selectOption('extra')
await dialog().getByText('BEIM ABSPIELEN AN').waitFor()
const vorwahl = dialog().locator('select').nth(2)
await vorwahl.selectOption('deu')
const knoepfe = await dialog().getByRole('button', { name: 'anderer Film …' }).count()
if (knoepfe < 2) throw new Error(`nur ${knoepfe} „anderer Film"-Knöpfe`)
await dialog().getByRole('button', { name: 'anderer Film …' }).first().click()
await dialog().getByPlaceholder('Filmtitel suchen …').waitFor()
await dialog().getByRole('button', { name: 'anderer Film …' }).first().click()
await rollen.nth(1).selectOption('hauptfilm')
if ((await dialog().getByText('BEIM ABSPIELEN AN').count()) !== 0) throw new Error('Vorwahl bleibt nach Rollenwechsel sichtbar')
})
await bild('dialog-vorwahl')
await schritt('Rollen auf Folge → Staffel/Folge-Felder erscheinen', async () => {
const rollen = dialog().locator('select')
await rollen.nth(0).selectOption('folge')
@@ -162,9 +183,18 @@ await schritt('Platz-Prüfung im Dialog: der Arbeitsordner reicht', async () =>
})
await bild('dialog')
await schritt('Rip starten → Phase RIPPT', async () => {
await schritt('Rip starten → Phase RIPPT, der Automatik-Kasten ist weg', async () => {
await dialog().getByRole('button', { name: /Titel rippen/ }).click()
await seite.getByText(/RIPPT/).first().waitFor()
if ((await seite.getByText('Automatik gestoppt').count()) !== 0) throw new Error('„Automatik gestoppt" steht noch unter dem laufenden Rip')
})
await schritt('5.6.0: Vorgangs-Leiste in der Kachel — TITEL LESEN fertig, RIPPEN aktiv, KOMPRIMIEREN und ABLEGEN offen', async () => {
await seite.getByText('TITEL LESEN', { exact: true }).first().waitFor()
await seite.getByText('RIPPEN', { exact: true }).first().waitFor()
await seite.getByText('KOMPRIMIEREN', { exact: true }).first().waitFor()
await seite.getByText('ABLEGEN', { exact: true }).first().waitFor()
if ((await seite.getByText(/PHASE [0-9]\/4/).count()) !== 0) throw new Error('alte PHASE-x/4-Texte sind noch da')
})
await schritt('Rip fertig, Kompression eingereiht, Laufwerk frei', async () => {
@@ -172,9 +202,13 @@ await schritt('Rip fertig, Kompression eingereiht, Laufwerk frei', async () => {
await seite.getByRole('button', { name: 'Rippen', exact: true }).waitFor()
})
await schritt('Kompressions-Karte läuft und die Ablage endet', async () => {
await schritt('Kompressions-Karte läuft mit Restzeit (ETA aus HandBrakes Zeile), die Kachel zeigt nur eine Kurzzeile', async () => {
await seite.getByText('KOMPRESSION', { exact: true }).waitFor({ timeout: 30_000 })
await seite.getByText(/Komprimiert und abgelegt/).waitFor({ timeout: 60_000 })
await seite.getByText(/^noch /).first().waitFor({ timeout: 20_000 })
// Die Leiste in der Karte: zwei Stationen fertig (✓), KOMPRIMIEREN aktiv.
if ((await seite.getByText('KOMPRIMIEREN', { exact: true }).count()) < 1) throw new Error('keine Leiste in der Kompressions-Karte')
if ((await seite.getByText(/KOMPRESSION LÄUFT/).count()) !== 0) throw new Error('Kachel zeigt noch den vollen Kompressions-Block')
await seite.getByText(/Kompression fertig — das Ergebnis steht im Verlauf/).waitFor({ timeout: 60_000 })
})
await bild('fertig')
@@ -250,6 +284,17 @@ await schritt('Roh-Dateien löschen → „gelöscht"', async () => {
await seite.getByText(/Roh: gelöscht/).first().waitFor()
})
await schritt('5.6.0: Hell-Modus über Einstellungen → Allgemein, data-design wechselt', async () => {
await seite.locator('#allgemein').locator('select').first().selectOption('hell')
await seite.locator('html[data-design="hell"]').waitFor()
})
await bild('hell-modus')
await schritt('zurück in den Kinosaal (dunkel)', async () => {
await seite.locator('#allgemein').locator('select').first().selectOption('dunkel')
await seite.locator('html[data-design="dunkel"]').waitFor()
})
await schritt('Ereignisse nennen die Kompression', async () => {
await seite.getByRole('button', { name: 'Übersicht' }).click()
await seite.getByText(/Kompression „/).first().waitFor()
@@ -312,6 +357,6 @@ await schritt('Fertig: der Knopf Los gehts', async () => {
})
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.')
console.log('KLICK: OK — Speicher, Rippen, Dialog, Sprachen, Folgen, Platz, Rip, Warteschlange, Kompression, Arbeitsordner, Ablage, Verlauf, Bilanz, Roh-Dateien, Einstellungen, Vorwahl, anderer Film, Vorgangs-Leiste, Restzeit, Hell-Modus, Erst-Einrichtung: alles geklickt.')
await app.close().catch(() => {})
process.exit(0)
+11 -3
View File
@@ -4,7 +4,9 @@
// --help → Encoder-Block „-e, --encoder" bis zur nächsten Option
// --preset-list → Preset-Namen mit genau vier Leerzeichen Einrückung
// --scan --json → „JSON Title Set:" + Objekt + „HandBrake has exited."
// Encode → „Encoding: task 1 of 1, x %" in Stufen, Datei kopiert
// Encode → „Encoding: task 1 of 1, x %" in Stufen, Datei kopiert;
// ab der zweiten Stufe mit der fps/ETA-Klammer, wie das
// Binary sie formatiert (5.6.0, Restzeit).
const fs = require('node:fs')
const args = process.argv.slice(2)
@@ -74,10 +76,16 @@ out('Scanning title 1 of 1, preview 10, 100.00 %')
let schritt = 0
const stufen = [5, 30, 55, 80, 100]
const tick = () => {
out(`Encoding: task 1 of 1, ${stufen[schritt].toFixed(2)} %`)
const prozent = stufen[schritt].toFixed(2)
if (schritt === 0) out(`Encoding: task 1 of 1, ${prozent} %`)
else {
// Formatstring aus dem Binary (1.11.2): „%.2f %% (%.2f fps, avg %.2f fps, ETA %02dh%02dm%02ds)"
const rest = (stufen.length - 1 - schritt) * 300
out(`Encoding: task 1 of 1, ${prozent} % (48.00 fps, avg 47.50 fps, ETA 00h${String(Math.floor(rest / 60)).padStart(2, '0')}m${String(rest % 60).padStart(2, '0')}s)`)
}
schritt += 1
if (schritt < stufen.length) {
setTimeout(tick, 300)
setTimeout(tick, 500)
return
}
fs.copyFileSync(eingabe, ausgabe)
+37
View File
@@ -1,3 +1,40 @@
Version 5.6.0
Mehrere Filme auf einer Disc.
Im Auswahl-Dialog bekommt jeder Titel "anderer Film ..." mit TMDb-Suche
(Double Feature, Trilogie-Disc, Bonus-Film). Jeder Film erhaelt seinen
eigenen Roh-Ordner, Vorgang und Zielordner mit Poster und Infodatei; der
Verlauf zeigt beide.
Ordner und Dateien tragen den Filmnamen.
Der Roh-Ordner heisst wie der Film ("Akira (1988)", bei Wiederholung mit
Zusatz), die Dateien auch: "Akira (1988).mkv", zwei Fassungen "Akira (1988)
- Fassung 1.mkv", Zugaben "Akira (1988) - Extra 01.mkv" im Unterordner
extras. Folgen behalten ihren Namen, bis die Episoden-Zuordnung sie
benennt; unerkannte Discs behalten den Zeitstempel-Namen.
Untertitel-Vorwahl je Extra.
Bei Zugaben (z. B. japanisches Bonus-Material) legst du im Dialog fest,
welche Untertitel-Spur beim Abspielen schon an ist - als weiche Spur,
nichts wird eingebrannt, immer optional. Rippys Regel (fremder Ton ->
Wunschsprache) bleibt die Vorgabe.
Eine Vorgangs-Leiste statt "Phase 1-4 plus Komprimierung".
Jede Disc hat EINEN Weg mit vier Stationen: Titel lesen, Rippen,
Komprimieren, Ablegen (das Rettungs-Abbild schiebt sich bei Bedarf davor).
Die Leiste zeigt immer den ganzen Weg, der Segment-Balken sitzt in der
aktiven Station: in der Laufwerks-Kachel, solange das Laufwerk arbeitet,
danach in der Kompressions-Karte mit HandBrakes eigener Restzeit ("noch
~12 min"), im Verlauf als vier Punkte. Die Kachel zeigt nach dem Rip nur
noch eine Zeile, wo es weitergeht.
Hell-Modus.
Einstellungen -> Allgemein -> Erscheinungsbild: Dunkel (der Kinosaal),
Hell (Papier und Gold) oder wie Windows.
Dazu: Die Kopfzeile mit Minimieren, Maximieren und Schliessen bleibt beim
Scrollen stehen.
Version 5.5.0
Uebersicht und Einstellungen neu sortiert.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "rippy",
"version": "5.5.0",
"version": "5.6.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "rippy",
"version": "5.5.0",
"version": "5.6.0",
"license": "UNLICENSED",
"dependencies": {
"electron-updater": "^6.8.9",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "rippy",
"productName": "Rippy",
"version": "5.5.0",
"version": "5.6.0",
"description": "Rippy v5 — Disc-Ripping als eigenständiges Windows-Programm (KONZEPT-WINDOWS.md)",
"main": "out/haupt/index.js",
"author": "KrBrZ",
+23 -5
View File
@@ -37,7 +37,7 @@ function istHauptStatus(wert: unknown): wert is HauptStatus {
/** 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'
const farbe = ton === 'gruen' ? 'bg-gruen' : ton === 'warn' ? 'bg-warn' : ton === 'rot' ? 'bg-rot' : 'bg-tinte/25'
return (
<span className="inline-flex items-center gap-1.5" title={titel}>
<span className={`inline-block h-1.5 w-1.5 rounded-full ${farbe}`} />
@@ -65,6 +65,22 @@ export default function App() {
return aufVerbindung(setVerbindung)
}, [])
// 5.6.0 Hell-Modus: die Einstellung „design" schaltet data-design auf
// <html> (stil.css überschreibt dann die Farb-Tokens); „system" folgt
// Windows.
const design = verbindung.einstellungen['design'] ?? 'dunkel'
useEffect(() => {
const anwenden = (): void => {
const hell = design === 'hell' || (design === 'system' && window.matchMedia('(prefers-color-scheme: light)').matches)
document.documentElement.dataset['design'] = hell ? 'hell' : 'dunkel'
}
anwenden()
if (design !== 'system') return
const mq = window.matchMedia('(prefers-color-scheme: light)')
mq.addEventListener('change', anwenden)
return () => mq.removeEventListener('change', anwenden)
}, [design])
const db = verbindung.kern?.datenbank ?? null
const einRipLaeuft = Object.values(verbindung.rips).some((r) => LAUFENDE_PHASEN.includes(r.phase))
@@ -85,7 +101,7 @@ export default function App() {
<button
onClick={() => setBereich({ name: ziel })}
className={`rounded-full px-4 py-1.5 text-[13px] ${
bereich.name === ziel ? 'border border-gold/40 bg-gold/15 font-semibold text-gold-hell' : 'border border-transparent text-dunst hover:bg-white/5 hover:text-schnee'
bereich.name === ziel ? 'border border-gold/40 bg-gold/15 font-semibold text-gold-hell' : 'border border-transparent text-dunst hover:bg-tinte/5 hover:text-schnee'
}`}
>
{text}
@@ -109,12 +125,14 @@ export default function App() {
{/* 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. */}
<header className="ziehbar relative flex-none border-b border-white/8">
{/* 5.6.0: Die Kopfzeile bleibt beim Scrollen stehen (Commander) —
sticky statt relative, mit Bühnen-Hintergrund und Weichzeichner. */}
<header className="ziehbar sticky top-0 z-40 flex-none border-b border-tinte/8 bg-buehne/85 backdrop-blur-md">
<div className="mx-auto flex w-full max-w-[1600px] items-center gap-3 py-2 pl-5 pr-0 sm:pl-7">
<div className="flex min-w-0 items-center gap-3">
<IconDisc groesse={28} farbe="oklch(0.83 0.11 75)" />
<h1 className="font-display text-[22px] font-bold tracking-tight sm:text-[25px]">Rippy</h1>
<span className="hidden rounded-md border border-white/12 px-2 py-0.5 font-mono text-[10px] text-nebel md:inline">v{haupt?.version ?? '…'}</span>
<span className="hidden rounded-md border border-tinte/12 px-2 py-0.5 font-mono text-[10px] text-nebel md:inline">v{haupt?.version ?? '…'}</span>
{einRipLaeuft && (
<span className="inline-flex flex-none items-center gap-2 rounded-md border border-rec/45 bg-rec/12 px-2.5 py-0.5 font-mono text-[10px] tracking-[0.15em] text-[oklch(0.83_0.12_50)]">
<span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-rec shadow-[0_0_9px_oklch(0.72_0.17_45/0.9)]" />
@@ -150,7 +168,7 @@ export default function App() {
{!setupOffen && bereich.name === 'einstellungen' && <Einstellungen verbindung={verbindung} haupt={haupt} start={bereich.start} />}
</main>
<footer className="relative flex flex-wrap items-center gap-x-6 gap-y-1 border-t border-white/8 px-7 py-3 font-mono text-[10px] tracking-[0.08em] text-nebel">
<footer className="relative flex flex-wrap items-center gap-x-6 gap-y-1 border-t border-tinte/8 px-7 py-3 font-mono text-[10px] tracking-[0.08em] text-nebel">
<Led ton={verbindung.kern !== null ? 'gruen' : haupt?.kern.laeuft === true ? 'warn' : 'rot'} text="KERN" titel={verbindung.kern !== null ? `läuft (PID ${verbindung.kern.pid})` : 'wartet'} />
<Led ton={db === null ? 'grau' : db.ok ? 'gruen' : 'rot'} text="DATENBANK" titel={db !== null && db.ok ? db.pfad : db?.fehler} />
<Led
+25 -14
View File
@@ -37,6 +37,17 @@ export function datumKurz(iso: string | null): string {
return iso.slice(0, 16).replace('T', ' ')
}
/** Restzeit in Sekunden als Klartext (5.6.0, HandBrakes ETA): -1/fehlend =
* noch keine Angabe. */
export function restText(restS: number | undefined): string {
if (restS === undefined || restS < 0) return 'Restzeit wird berechnet …'
if (restS < 60) return `noch ${Math.max(1, Math.round(restS))} s`
if (restS < 3600) return `noch ~${Math.ceil(restS / 60)} min`
const h = Math.floor(restS / 3600)
const m = Math.round((restS - h * 3600) / 60)
return `noch ~${h} h ${String(m).padStart(2, '0')} min`
}
export function restzeitText(prozent: number, startMs: number | undefined): string {
if (startMs === undefined || prozent <= 2 || prozent >= 100) return ''
const verstrichen = (Date.now() - startMs) / 1000
@@ -119,11 +130,11 @@ export const TYP_NAMEN: Record<GeraeteInfo['typ'], string> = {
}
export const PHASEN_MONO: Record<RipStatus['phase'], string> = {
'liest-info': 'PHASE 1/4 — LIEST TITEL-INFO',
'liest-info': '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',
rippt: 'RIPPT (VERLUSTFREI)',
komprimiert: 'KOMPRIMIERT',
'legt-ab': 'LEGT AB',
fertig: 'FERTIG',
fehler: 'FEHLER',
abgebrochen: 'ABGEBROCHEN',
@@ -132,9 +143,9 @@ export const PHASEN_MONO: Record<RipStatus['phase'], string> = {
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'
'mt-1 w-full rounded-lg border border-tinte/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_LEISE = 'rounded-lg border border-tinte/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'
@@ -227,7 +238,7 @@ export function FensterKnopf({
title={titel}
aria-label={titel}
className={`flex h-8 w-[46px] items-center justify-center text-dunst transition-colors ${
rot ? 'hover:bg-rot hover:text-white' : 'hover:bg-white/10 hover:text-schnee'
rot ? 'hover:bg-rot hover:text-[#fff]' : 'hover:bg-tinte/10 hover:text-schnee'
}`}
>
{children}
@@ -241,7 +252,7 @@ export function Badge({ text, ton }: { text: string; ton: 'gruen' | 'gelb' | 'bl
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',
grau: 'border-tinte/15 bg-tinte/5 text-dunst',
}
return (
<span className={`inline-block rounded-full border px-2.5 py-0.5 text-[11px] leading-4 ${farben[ton]}`}>
@@ -253,7 +264,7 @@ export function Badge({ text, ton }: { text: string; ton: 'gruen' | 'gelb' | 'bl
/** Die kleinen Mess-Kacheln aus dem Schaltpult (MEDIUM / GRÖSSE / …). */
export function InfoKachel({ label, wert, farbe }: { label: string; wert: string; farbe?: string }) {
return (
<div className="rounded-lg border border-white/10 bg-samt/70 px-3 py-2 min-w-0">
<div className="rounded-lg border border-tinte/10 bg-samt/70 px-3 py-2 min-w-0">
<div className="font-mono text-[9px] tracking-[0.14em] text-nebel">{label}</div>
<div className={`mt-0.5 truncate text-[13px] font-semibold ${farbe ?? 'text-schnee'}`} title={wert}>
{wert}
@@ -274,7 +285,7 @@ export function SegmentBalken({ prozent }: { prozent: number }) {
<div
key={i}
className={`h-3.5 flex-1 rounded-[3px] ${
i < voll ? 'segment-gluehen' : i === voll && halb ? 'bg-gold/45' : 'bg-white/8'
i < voll ? 'segment-gluehen' : i === voll && halb ? 'bg-gold/45' : 'bg-tinte/8'
}`}
/>
))}
@@ -286,7 +297,7 @@ export function SegmentBalken({ prozent }: { prozent: number }) {
export function FuellBalken({ anteil, ton }: { anteil: number; ton: 'gold' | 'warn' | 'rot' }) {
const farbe = ton === 'gold' ? 'from-gold-tief to-gold-hell' : ton === 'warn' ? 'from-warn to-warn' : 'from-rot to-rot'
return (
<div className="h-1.5 w-full overflow-hidden rounded-full bg-white/8">
<div className="h-1.5 w-full overflow-hidden rounded-full bg-tinte/8">
<div className={`h-full rounded-full bg-gradient-to-r ${farbe} transition-all`} style={{ width: `${Math.max(0, Math.min(100, anteil * 100))}%` }} />
</div>
)
@@ -302,7 +313,7 @@ export function Schalter({ an, aufWechsel, label }: { an: boolean; aufWechsel: (
<button onClick={() => aufWechsel(!an)} className="flex items-center gap-3 text-left" role="switch" aria-checked={an}>
<span
className={`relative inline-block h-5 w-9 flex-none rounded-full transition-colors ${
an ? 'bg-gradient-to-b from-gold-hell to-gold-tief' : 'bg-white/12'
an ? 'bg-gradient-to-b from-gold-hell to-gold-tief' : 'bg-tinte/12'
}`}
>
<span className={`absolute top-0.5 h-4 w-4 rounded-full bg-schnee shadow transition-all ${an ? 'left-[18px]' : 'left-0.5'}`} />
@@ -325,7 +336,7 @@ export function SektionsKarte({
children: React.ReactNode
}) {
return (
<section id={id} className="scroll-mt-4 rounded-2xl border border-white/10 bg-karte/50 p-5">
<section id={id} className="scroll-mt-4 rounded-2xl border border-tinte/10 bg-karte/50 p-5">
<Kicker text={titel} />
<p className="mt-1.5 max-w-xl text-xs leading-5 text-nebel">{intro}</p>
<div className="mt-4 space-y-3">{children}</div>
@@ -452,7 +463,7 @@ export function DialogRahmen({
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-6 backdrop-blur-sm" {...hintergrund}>
<div
className={`max-h-[85vh] w-full ${breite} overflow-y-auto rounded-2xl border border-white/12 bg-buehne p-5 shadow-2xl shadow-black/60`}
className={`max-h-[85vh] w-full ${breite} overflow-y-auto rounded-2xl border border-tinte/12 bg-buehne p-5 shadow-2xl shadow-black/60`}
onClick={(e) => e.stopPropagation()}
>
{children}
@@ -32,7 +32,7 @@ export function AendernDialog({
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"
className="flex-1 rounded-lg border border-tinte/12 bg-samt px-3 py-2 font-mono text-xs text-schnee placeholder:text-nebel focus:border-gold/60 focus:outline-none"
/>
<button
onClick={() => metadatenSuchen(id, text)}
@@ -52,7 +52,7 @@ export function AendernDialog({
zuordnungSetzen(id, v)
schliessen()
}}
className="flex w-full items-center gap-3 rounded-xl border border-white/10 bg-karte/60 p-2 text-left hover:border-gold/50 hover:bg-karte"
className="flex w-full items-center gap-3 rounded-xl border border-tinte/10 bg-karte/60 p-2 text-left hover:border-gold/50 hover:bg-karte"
>
{v.posterPfad.length > 0 ? (
<img src={posterUrlKlein(v.posterPfad)} alt="" className="h-16 w-11 rounded-md object-cover" />
@@ -73,7 +73,7 @@ export function AendernDialog({
</div>
)}
<div className="mt-4 text-right">
<button onClick={schliessen} className="rounded-lg border border-white/15 px-4 py-1.5 text-xs text-dunst hover:bg-white/5">
<button onClick={schliessen} className="rounded-lg border border-tinte/15 px-4 py-1.5 text-xs text-dunst hover:bg-tinte/5">
Schließen
</button>
</div>
@@ -5,20 +5,25 @@
// 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).
// 5.6.0 (Commander): Je Extra WELCHE Untertitel-Spur beim Abspielen
// vorgewählt ist (weiche Spur, nie eingebrannt, immer optional) — und je
// Titel „anderer Film …": Double Feature, Trilogie-Disc, Bonus-Film bekommen
// eigenen Roh-Ordner, Vorgang und Zielordner.
import { useState } from 'react'
import { zielVorschau } from '../../gemeinsam/ablagepfad'
import { sichererName, zielVorschau } from '../../gemeinsam/ablagepfad'
import { platzUrteil } from '../../gemeinsam/platz'
import type {
DiscInfoStand,
DiscTitelVorschlag,
GeraeteInfo,
MetaVorschlag,
SpeicherStand,
SprachEintrag,
TitelRolle,
TitelStand,
} from '../../gemeinsam/nachrichten'
import { DialogRahmen, KNOPF_GOLD, dauerText, gb, gbKurz, sprachenVorbelegt } from '../bausteine'
import { ripStarten } from '../kernverbindung'
import { DialogRahmen, IconDisc, KNOPF_GOLD, dauerText, gb, gbKurz, posterUrlKlein, sprachenVorbelegt } from '../bausteine'
import { metadatenSuchen, ripStarten } from '../kernverbindung'
/** Sprach-Chips eines Titels — anklickbar, außerhalb des Auswahl-Labels
* (ein Knopf IM Label würde die Checkbox mit umschalten). */
@@ -45,7 +50,7 @@ function SprachChips({
type="button"
onClick={() => aufWechsel(s.lang)}
className={`rounded-md border px-1.5 py-0.5 font-mono text-[10.5px] ${
an ? 'border-gold/50 bg-gold/15 text-gold-hell' : 'border-white/12 text-nebel hover:text-dunst'
an ? 'border-gold/50 bg-gold/15 text-gold-hell' : 'border-tinte/12 text-nebel hover:text-dunst'
}`}
title={`${s.sprache}${s.spuren > 1 ? ` (${s.spuren} Spuren)` : ''}`}
>
@@ -59,6 +64,13 @@ function SprachChips({
)
}
/** Was Rippys Regel für diesen Titel vorwählen würde (dieselbe Regel wie
* kern/ablauf/auftrag.ts sprachwahl) — '' wenn nichts. */
function regelVorwahl(audio: readonly string[], untertitel: readonly string[], wunsch: string): string {
if (wunsch.length === 0 || audio.length === 0 || audio[0] === wunsch) return ''
return untertitel.includes(wunsch) ? wunsch : ''
}
export function TitelWahlDialog({
id,
typ,
@@ -67,6 +79,7 @@ export function TitelWahlDialog({
einstellungen,
regel,
speicher,
vorschlaege,
schliessen,
}: {
id: string
@@ -78,6 +91,8 @@ export function TitelWahlDialog({
* Roh-Material passiert. */
regel: string
speicher: SpeicherStand | null
/** Treffer der Metadaten-Suche (für „anderer Film …"). */
vorschlaege: MetaVorschlag[]
schliessen: () => void
}) {
const [gewaehlt, setGewaehlt] = useState<Set<number> | null>(null)
@@ -87,6 +102,12 @@ export function TitelWahlDialog({
// Sprachen je Titel (5.4.0): Was hier nicht drinsteht, ist vorbelegt
// (Einstellung, soweit der Titel sie hat — sonst alles, was er hat).
const [sprachen, setSprachen] = useState<Record<number, { audio: string[]; untertitel: string[] }>>({})
// 5.6.0: Untertitel-Vorwahl je Extra — fehlt: Rippys Regel; null: keine;
// sonst die Sprache. Immer optional, nie eingebrannt.
const [vorwahl, setVorwahl] = useState<Record<number, string | null>>({})
// 5.6.0: Titel, die zu einem anderen Film gehören.
const [filme, setFilme] = useState<Record<number, MetaVorschlag>>({})
const [filmSuche, setFilmSuche] = useState<{ nr: number; text: string } | null>(null)
// Staffel und erste Folge von Hand (5.4.0) — die Staffel kommt von der
// Disc, die erste Folge vom Serien-Merker (5.5.0: die vorige Disc dieser
// Serie) oder vom Nutzer („auf Disc 2 sind 34").
@@ -115,20 +136,32 @@ export function TitelWahlDialog({
else neu.add(nr)
setGewaehlt(neu)
}
const wunschSprache = (einstellungen['audioSprachen'] ?? '').split(',')[0]?.trim().toLowerCase() ?? ''
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 eigeneTitel = gewaehlteTitel.filter((t) => filme[t.nr] === undefined)
const hauptTitel = eigeneTitel.filter((t) => rolleVon(t) !== 'extra').length
const extras = eigeneTitel.filter((t) => rolleVon(t) === 'extra').length
// Andere Filme (5.6.0): je Film die Zahl der Titel, für die Vorschau.
const andereFilme = new Map<number, { film: MetaVorschlag; titel: number }>()
for (const t of gewaehlteTitel) {
const f = filme[t.nr]
if (f === undefined) continue
const e = andereFilme.get(f.tmdbId)
if (e !== undefined) e.titel += 1
else andereFilme.set(f.tmdbId, { film: f, titel: 1 })
}
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 ablage = einstellungen['ablage'] ?? 'Videos\\Rippy'
const vorschau = zielVorschau({
ablage: einstellungen['ablage'] ?? 'Videos\\Rippy',
ablage,
alsSerie,
titel: info?.titel ?? '',
jahr: info?.jahr ?? null,
@@ -141,6 +174,7 @@ export function TitelWahlDialog({
// 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 }
const filmTreffer = vorschlaege.filter((v) => v.typ === 'movie')
return (
<DialogRahmen schliessen={schliessen} breite="max-w-3xl">
@@ -174,14 +208,14 @@ export function TitelWahlDialog({
new Set(alle.filter((t) => !t.grund.startsWith('Sammeltitel') && !t.grund.startsWith('zu kurz')).map((t) => t.nr)),
)
}
className="rounded-lg border border-white/15 px-2.5 py-1 text-[11px] text-dunst hover:text-schnee"
className="rounded-lg border border-tinte/15 px-2.5 py-1 text-[11px] text-dunst hover:text-schnee"
>
Hauptinhalt + Extras
</button>
<button onClick={() => setGewaehlt(new Set(alle.map((t) => t.nr)))} className="rounded-lg border border-white/15 px-2.5 py-1 text-[11px] text-dunst hover:text-schnee">
<button onClick={() => setGewaehlt(new Set(alle.map((t) => t.nr)))} className="rounded-lg border border-tinte/15 px-2.5 py-1 text-[11px] text-dunst hover:text-schnee">
Alles
</button>
<button onClick={() => setGewaehlt(new Set())} className="rounded-lg border border-white/15 px-2.5 py-1 text-[11px] text-dunst hover:text-schnee">
<button onClick={() => setGewaehlt(new Set())} className="rounded-lg border border-tinte/15 px-2.5 py-1 text-[11px] text-dunst hover:text-schnee">
Nichts
</button>
</div>
@@ -190,8 +224,12 @@ export function TitelWahlDialog({
{alle.map((t) => {
const an = aktiv.has(t.nr)
const wahl = sprachenVon(t)
const rolle = rolleVon(t)
const film = filme[t.nr]
const regelWahl = regelVorwahl(wahl.audio.length > 0 ? wahl.audio : t.sprachen.audio.map((s) => s.lang), wahl.untertitel, wunschSprache)
const vorwahlWert = t.nr in vorwahl ? (vorwahl[t.nr] === null ? 'keine' : vorwahl[t.nr]!) : 'regel'
return (
<div key={t.nr} className={`rounded-lg border ${an ? 'border-gold/40 bg-gold/8' : 'border-white/8 bg-samt/40'}`}>
<div key={t.nr} className={`rounded-lg border ${an ? 'border-gold/40 bg-gold/8' : 'border-tinte/8 bg-samt/40'}`}>
<label className="flex cursor-pointer items-center gap-3 p-2">
<input type="checkbox" checked={an} onChange={() => umschalten(t.nr)} className="accent-[oklch(0.83_0.11_75)]" />
<span className="w-16 flex-none font-mono text-[11px] text-nebel">Titel {t.nr}</span>
@@ -200,10 +238,10 @@ export function TitelWahlDialog({
<span className="hidden w-20 flex-none font-mono text-[11px] text-nebel sm:inline">{t.kapitel} Kap.</span>
{an ? (
<select
value={rolleVon(t)}
value={rolle}
onChange={(e) => setRollen({ ...rollen, [t.nr]: e.target.value as TitelRolle })}
onClick={(e) => e.stopPropagation()}
className="flex-none rounded-lg border border-white/15 bg-samt/70 px-2 py-0.5 text-[11px] text-dunst"
className="flex-none rounded-lg border border-tinte/15 bg-samt/70 px-2 py-0.5 text-[11px] text-dunst"
title="Bestimmt, WOHIN der Titel abgelegt wird"
>
<option value="hauptfilm">Hauptfilm</option>
@@ -218,12 +256,126 @@ export function TitelWahlDialog({
Titel gemessen hat — anhaken, was in die Datei soll.
Der Commander-Fall: japanischer Ton, deutsche Untertitel. */}
{an && (t.sprachen.audio.length > 0 || t.sprachen.untertitel.length > 0) && (
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 border-t border-white/8 px-2 py-1.5 pl-9">
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 border-t border-tinte/8 px-2 py-1.5 pl-9">
<SprachChips name="TON" vorhanden={t.sprachen.audio} gewaehlt={wahl.audio} aufWechsel={(lang) => sprachWechsel(t, 'audio', lang)} />
<SprachChips name="UNTERTITEL" vorhanden={t.sprachen.untertitel} gewaehlt={wahl.untertitel} aufWechsel={(lang) => sprachWechsel(t, 'untertitel', lang)} />
{wahl.audio.length === 0 && t.sprachen.audio.length > 0 && (
<span className="font-mono text-[10.5px] text-warn">kein Ton gewählt HandBrake nimmt die erste Spur</span>
)}
{/* 5.6.0: Vorwahl NUR bei Extras, IMMER optional — eine
weiche Spur, die der Spieler von selbst einblendet;
eingebrannt wird nie. */}
{rolle === 'extra' && wahl.untertitel.length > 0 && (
<label className="inline-flex items-center gap-1.5" title="Weiche Untertitel-Spur, die beim Abspielen schon an ist — nichts wird eingebrannt. Leer lassen ist immer erlaubt.">
<span className="font-mono text-[10px] tracking-wider text-nebel">BEIM ABSPIELEN AN</span>
<select
value={vorwahlWert}
onChange={(e) => {
const w = e.target.value
const neu = { ...vorwahl }
if (w === 'regel') delete neu[t.nr]
else neu[t.nr] = w === 'keine' ? null : w
setVorwahl(neu)
}}
className="rounded-md border border-tinte/15 bg-samt/70 px-1.5 py-0.5 font-mono text-[10.5px] text-dunst"
>
<option value="regel">{regelWahl.length > 0 ? `Rippys Regel (${regelWahl})` : 'Rippys Regel (keine)'}</option>
<option value="keine">keine</option>
{wahl.untertitel.map((u) => (
<option key={u} value={u}>
{u}
</option>
))}
</select>
</label>
)}
</div>
)}
{/* 5.6.0: „anderer Film …" — Double Feature, Trilogie-Disc,
Bonus-Film: eigener Roh-Ordner, Vorgang und Zielordner. */}
{an && rolle !== 'folge' && (
<div className="flex flex-wrap items-center gap-2 border-t border-tinte/8 px-2 py-1.5 pl-9">
{film !== undefined ? (
<>
<span className="inline-flex items-center gap-1.5 rounded-md border border-gold/40 bg-gold/12 px-2 py-0.5 text-[11px] text-gold-hell">
eigener Film: {film.titel}
{film.jahr !== null ? ` (${film.jahr})` : ''}
<button
type="button"
onClick={() => {
const neu = { ...filme }
delete neu[t.nr]
setFilme(neu)
}}
className="ml-1 text-nebel hover:text-rot"
title="wieder zur Disc-Zuordnung"
>
×
</button>
</span>
</>
) : (
<button
type="button"
onClick={() => setFilmSuche(filmSuche?.nr === t.nr ? null : { nr: t.nr, text: '' })}
className="rounded-md border border-tinte/15 px-2 py-0.5 text-[11px] text-nebel hover:text-schnee"
title="Dieser Titel ist ein anderer Film (Double Feature, Bonus-Film): eigener Ordner mit Poster und Infodatei"
>
anderer Film
</button>
)}
{filmSuche?.nr === t.nr && (
<div className="w-full rounded-lg border border-tinte/10 bg-buehne/70 p-2">
<div className="flex gap-2">
<input
value={filmSuche.text}
autoFocus
onChange={(e) => setFilmSuche({ nr: t.nr, text: e.target.value })}
onKeyDown={(e) => {
if (e.key === 'Enter') metadatenSuchen(id, filmSuche.text)
}}
placeholder="Filmtitel suchen …"
className="flex-1 rounded-lg border border-tinte/12 bg-samt px-3 py-1.5 font-mono text-xs text-schnee placeholder:text-nebel focus:border-gold/60 focus:outline-none"
/>
<button
type="button"
onClick={() => metadatenSuchen(id, filmSuche.text)}
className="rounded-lg border border-gold/50 bg-gold/10 px-3 py-1.5 text-xs font-semibold text-gold-hell hover:bg-gold/20"
>
Suchen
</button>
</div>
{filmTreffer.length === 0 ? (
<p className="mt-2 font-mono text-[10.5px] text-nebel">Noch keine Treffer Titel eingeben und suchen (braucht den TMDb-Schlüssel).</p>
) : (
<div className="mt-2 max-h-48 space-y-1 overflow-y-auto">
{filmTreffer.map((v) => (
<button
key={v.tmdbId}
type="button"
onClick={() => {
setFilme({ ...filme, [t.nr]: v })
setFilmSuche(null)
}}
className="flex w-full items-center gap-2 rounded-lg border border-tinte/10 bg-karte/60 p-1.5 text-left hover:border-gold/50"
>
{v.posterPfad.length > 0 ? (
<img src={posterUrlKlein(v.posterPfad)} alt="" className="h-10 w-7 rounded object-cover" />
) : (
<span className="flex h-10 w-7 items-center justify-center rounded bg-samt">
<IconDisc groesse={14} farbe="oklch(0.55 0.015 80)" />
</span>
)}
<span className="text-xs text-schnee">
{v.titel}
{v.jahr !== null ? ` (${v.jahr})` : ''}
</span>
</button>
))}
</div>
)}
</div>
)}
</div>
)}
</div>
@@ -233,7 +385,7 @@ export function TitelWahlDialog({
</div>
{alsSerie && (
<div className="mt-3 flex flex-wrap items-end gap-3 rounded-xl border border-white/8 bg-samt/40 p-3">
<div className="mt-3 flex flex-wrap items-end gap-3 rounded-xl border border-tinte/8 bg-samt/40 p-3">
<label className="block">
<span className="font-mono text-[10px] tracking-wider text-nebel">STAFFEL</span>
<input
@@ -242,7 +394,7 @@ export function TitelWahlDialog({
value={staffel}
onChange={(e) => setStaffel(e.target.value)}
placeholder={info !== undefined && info.staffel !== null ? String(info.staffel) : '1'}
className="mt-1 block w-20 rounded-lg border border-white/12 bg-buehne px-2 py-1 font-mono text-xs text-schnee"
className="mt-1 block w-20 rounded-lg border border-tinte/12 bg-buehne px-2 py-1 font-mono text-xs text-schnee"
/>
</label>
<label className="block">
@@ -253,7 +405,7 @@ export function TitelWahlDialog({
value={ersteFolge}
onChange={(e) => setErsteFolge(e.target.value)}
placeholder="automatisch"
className="mt-1 block w-28 rounded-lg border border-white/12 bg-buehne px-2 py-1 font-mono text-xs text-schnee"
className="mt-1 block w-28 rounded-lg border border-tinte/12 bg-buehne px-2 py-1 font-mono text-xs text-schnee"
/>
</label>
<p className="min-w-[200px] flex-1 text-[11px] leading-4 text-nebel">
@@ -266,11 +418,25 @@ export function TitelWahlDialog({
)}
{/* Vorschau (5.4.0): Rolle, Serie, Staffel und Extras entscheiden
einiges — zwei Zeilen Zielpfad ersparen Überraschungen. */}
<div className="mt-3 rounded-xl border border-white/8 bg-buehne/70 p-3 font-mono text-[11px] leading-5">
einiges — zwei Zeilen Zielpfad ersparen Überraschungen. 5.6.0:
jeder weitere Film bekommt seine Zeile. */}
<div className="mt-3 rounded-xl border border-tinte/8 bg-buehne/70 p-3 font-mono text-[11px] leading-5">
<p className="text-[10px] tracking-wider text-nebel">LANDET IN</p>
<p className="break-all text-gold-hell">{vorschau[0]}</p>
<p className="text-dunst">{vorschau[1]}</p>
{eigeneTitel.length > 0 && (
<>
<p className="break-all text-gold-hell">{vorschau[0]}</p>
<p className="text-dunst">{vorschau[1]}</p>
</>
)}
{[...andereFilme.values()].map(({ film, titel }) => (
<p key={film.tmdbId} className="break-all text-gold-hell">
{`${ablage.replace(/[\\/]+$/, '')}\\Filme\\${sichererName(film.titel, film.jahr) || film.titel}\\`}
<span className="text-dunst">
{' '}
{titel} {titel === 1 ? 'Titel' : 'Titel'}, eigener Vorgang mit Poster und Infodatei
</span>
</p>
))}
</div>
{/* Platz-Prüfung (5.5.0): 40 Minuten Lesen sollen nicht an einem
@@ -297,7 +463,7 @@ export function TitelWahlDialog({
)}
<div className="mt-5 flex justify-between gap-2">
<button onClick={schliessen} className="rounded-lg border border-white/15 px-4 py-1.5 text-xs text-dunst hover:text-schnee">
<button onClick={schliessen} className="rounded-lg border border-tinte/15 px-4 py-1.5 text-xs text-dunst hover:text-schnee">
Abbrechen
</button>
<button
@@ -306,13 +472,16 @@ export function TitelWahlDialog({
id,
gewaehlteTitel.map((t) => {
const wahl = sprachenVon(t)
const rolle = rolleVon(t)
return {
nr: t.nr,
rolle: rolleVon(t),
rolle,
dauerS: t.dauerS,
groesseBytes: t.groesseBytes,
audioSprachen: wahl.audio,
untertitelSprachen: wahl.untertitel,
...(rolle === 'extra' && t.nr in vorwahl ? { untertitelVorwahl: vorwahl[t.nr] } : {}),
...(filme[t.nr] !== undefined ? { film: filme[t.nr] } : {}),
}
}),
{ staffel: alsSerie ? staffelZahl : null, ersteFolge: alsSerie ? folgeZahl : null },
@@ -78,7 +78,7 @@ export function ErstEinrichtung({ verbindung }: { verbindung: Verbindung }) {
return (
<>
<PosterHintergrund pfade={verbindung.poster} />
<div className="relative mx-auto max-w-2xl space-y-7 rounded-2xl border border-white/10 bg-buehne/80 p-6 pt-5 shadow-2xl shadow-black/60 backdrop-blur">
<div className="relative mx-auto max-w-2xl space-y-7 rounded-2xl border border-tinte/10 bg-buehne/80 p-6 pt-5 shadow-2xl shadow-black/60 backdrop-blur">
<div className="flex flex-wrap gap-2">
{SCHRITTE.map((name, i) => (
<span
@@ -149,7 +149,7 @@ export function ErstEinrichtung({ verbindung }: { verbindung: Verbindung }) {
<div className="space-y-4">
<p className="text-sm text-dunst">Rippy sichert eine Disc immer erst verlustfrei. Danach kann es eine kleinere Fassung berechnen die kostet Zeit, spart aber viel Platz.</p>
<Schalter an={werte['transcodeEnabled'] !== 'false'} aufWechsel={(an) => setzen('transcodeEnabled', an ? 'true' : 'false')} label="Nach dem Rip automatisch komprimieren" />
<div className="rounded-xl border border-white/8 bg-samt/50 p-3">
<div className="rounded-xl border border-tinte/8 bg-samt/50 p-3">
{werkzeuge === null ? (
<p className="font-mono text-xs text-nebel">Encoder werden gemessen </p>
) : backends.length === 0 ? (
@@ -165,7 +165,7 @@ export function ErstEinrichtung({ verbindung }: { verbindung: Verbindung }) {
<p className="mt-2 text-xs text-dunst">Danach wählt Rippy von selbst Grafikkarte vor CPU, wo es eine gibt:</p>
<div className="mt-2 grid gap-2 sm:grid-cols-3">
{EMPFEHLUNGEN.map(([name, typ]) => (
<div key={typ} className="rounded-lg border border-white/8 bg-buehne/60 p-2">
<div key={typ} className="rounded-lg border border-tinte/8 bg-buehne/60 p-2">
<p className="font-mono text-[10px] tracking-wider text-nebel">{name}</p>
<p className="mt-0.5 font-mono text-xs text-gold-hell">{presetEmpfehlung(typ, backends, presets) || DEFAULT_HB_PRESET}</p>
</div>
@@ -243,7 +243,7 @@ export function ErstEinrichtung({ verbindung }: { verbindung: Verbindung }) {
)}
<div className="flex justify-between">
<button onClick={() => setSchritt((s) => Math.max(0, s - 1))} disabled={schritt === 0} className="rounded-lg border border-white/15 px-4 py-1.5 text-xs text-dunst hover:text-schnee disabled:opacity-35">
<button onClick={() => setSchritt((s) => Math.max(0, s - 1))} disabled={schritt === 0} className="rounded-lg border border-tinte/15 px-4 py-1.5 text-xs text-dunst hover:text-schnee disabled:opacity-35">
Zurück
</button>
{schritt < SCHRITTE.length - 1 ? (
@@ -49,13 +49,13 @@ export function Einstellungen({ verbindung, haupt, start }: { verbindung: Verbin
// 01.09.2026: „alles gequetscht").
return (
<div className="flex flex-col gap-5 lg:flex-row lg:gap-8">
<nav className="flex flex-row flex-wrap gap-1 lg:sticky lg:top-4 lg:h-fit lg:w-44 lg:flex-none lg:flex-col">
<nav className="flex flex-row flex-wrap gap-1 lg:sticky lg:top-[76px] lg:h-fit lg:w-44 lg:flex-none lg:flex-col">
{SEKTIONEN.map(([id, name]) => (
<button
key={id}
onClick={() => springe(id)}
className={`rounded-lg px-3 py-1.5 text-left text-[13px] ${
aktiv === id ? 'border border-gold/40 bg-gold/12 font-semibold text-gold-hell' : 'border border-transparent text-dunst hover:bg-white/5 hover:text-schnee'
aktiv === id ? 'border border-gold/40 bg-gold/12 font-semibold text-gold-hell' : 'border border-transparent text-dunst hover:bg-tinte/5 hover:text-schnee'
}`}
>
{name}
@@ -66,7 +66,7 @@ export function AblageKarte({ verbindung }: { verbindung: Verbindung }) {
{gbKurz(speicher.arbeit.freiBytes)} frei von {gbKurz(speicher.arbeit.gesamtBytes)} eine Blu-ray braucht rund 50 GB.
</p>
)}
<div className="rounded-xl border border-white/8 bg-buehne/60 p-3 font-mono text-[11px] leading-5 text-nebel">
<div className="rounded-xl border border-tinte/8 bg-buehne/60 p-3 font-mono text-[11px] leading-5 text-nebel">
<p className="text-dunst">So legt Rippy ab:</p>
<p>
Filme\Akira (1988)\ <span className="text-nebel/70"> Film, Poster und Infodatei beieinander</span>
@@ -238,7 +238,7 @@ export function RohDateienKarte({ verbindung, aktivFuer }: { verbindung: Verbind
</button>
</div>
</div>
<div className="rounded-xl border border-white/8 bg-buehne/60 p-3 font-mono text-[11px] leading-5 text-nebel">
<div className="rounded-xl border border-tinte/8 bg-buehne/60 p-3 font-mono text-[11px] leading-5 text-nebel">
{v === null ? (
<p>Belegung wird gemessen </p>
) : (
@@ -258,7 +258,7 @@ export function RohDateienKarte({ verbindung, aktivFuer }: { verbindung: Verbind
{sichtbar.map((e) => {
const ruhig = e.phase === 'fertig' || e.phase === 'fehler' || e.phase === 'abgebrochen'
return (
<div key={e.id} className="rounded-xl border border-white/8 bg-buehne/60 p-3">
<div key={e.id} className="rounded-xl border border-tinte/8 bg-buehne/60 p-3">
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<span className="font-display text-[14px] font-bold text-schnee">
{e.titel}
@@ -293,7 +293,7 @@ export function RohDateienKarte({ verbindung, aktivFuer }: { verbindung: Verbind
<select
value={presetWahl[e.id] ?? ''}
onChange={(ev) => setPresetWahl({ ...presetWahl, [e.id]: ev.target.value })}
className="rounded-lg border border-white/15 bg-samt/70 px-2 py-1 text-[11px] text-dunst"
className="rounded-lg border border-tinte/15 bg-samt/70 px-2 py-1 text-[11px] text-dunst"
title="Preset für die Wiederholung — leer: wie eingestellt"
>
<option value="">Preset wie eingestellt</option>
@@ -318,12 +318,12 @@ export function RohDateienKarte({ verbindung, aktivFuer }: { verbindung: Verbind
</button>
)}
{e.phase === 'fertig' && e.zielOrdner.length > 0 && (
<button onClick={() => void window.rippy.ordnerOeffnen(e.zielOrdner)} className="rounded-lg border border-white/15 px-3 py-1 text-[11px] text-dunst hover:text-schnee">
<button onClick={() => void window.rippy.ordnerOeffnen(e.zielOrdner)} className="rounded-lg border border-tinte/15 px-3 py-1 text-[11px] text-dunst hover:text-schnee">
Ordner öffnen
</button>
)}
{(e.phase === 'wartet' || e.phase === 'komprimiert' || e.phase === 'legt-ab') && (
<button onClick={() => kompressionAbbrechen(e.id)} className="rounded-lg border border-white/15 px-3 py-1 text-[11px] text-dunst hover:text-rot">
<button onClick={() => kompressionAbbrechen(e.id)} className="rounded-lg border border-tinte/15 px-3 py-1 text-[11px] text-dunst hover:text-rot">
Kompression abbrechen
</button>
)}
@@ -401,7 +401,7 @@ export function MakemkvKarte({ verbindung }: { verbindung: Verbindung }) {
titel="MAKEMKV"
intro="MakeMKV liest die Discs. Rippy holt den frei veröffentlichten Beta-Key automatisch aus dem Hersteller-Forum, hinterlegt ihn und fragt MakeMKV selbst, ob er angenommen wird täglich."
>
<div className="rounded-xl border border-white/8 bg-buehne/60 p-3">
<div className="rounded-xl border border-tinte/8 bg-buehne/60 p-3">
{s === null ? (
<p className="font-mono text-xs text-nebel">Schlüssel wird geprüft …</p>
) : (
@@ -437,10 +437,10 @@ export function MakemkvKarte({ verbindung }: { verbindung: Verbindung }) {
</button>
</div>
{b !== null && (
<div className="rounded-lg border border-white/10 bg-buehne/70 p-2.5">
<div className="rounded-lg border border-tinte/10 bg-buehne/70 p-2.5">
<p className={`font-mono text-[11px] leading-5 ${b.fehler ? 'text-rot' : b.laeuft ? 'text-gold-hell' : 'text-gruen'}`}>{b.text}</p>
{b.laeuft && b.anteil !== null && (
<div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-white/8">
<div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-tinte/8">
<div className="h-full rounded-full bg-gradient-to-r from-gold-tief to-gold-hell transition-all" style={{ width: `${Math.round(b.anteil * 100)}%` }} />
</div>
)}
@@ -519,6 +519,14 @@ export function AllgemeinKarte({ verbindung, haupt }: { verbindung: Verbindung;
return (
<SektionsKarte id="allgemein" titel="ALLGEMEIN" intro="Rippy lebt im Infobereich neben der Uhr: Fenster zu heißt weiterlaufen, beendet wird über das Tray-Menü.">
<Schalter an={haupt?.autostart === true} aufWechsel={(an) => void window.rippy.autostartSetzen(an)} label="Rippy mit Windows starten (Autostart im Benutzerkonto kein Dienst, keine Adminrechte)" />
<label className="block max-w-sm">
<span className="text-xs font-semibold text-dunst">Erscheinungsbild</span>
<select value={werte['design'] ?? 'dunkel'} onChange={(e) => setzen('design', e.target.value)} className={FELD_KLASSE}>
<option value="dunkel">Dunkel — der Kinosaal (Vorgabe)</option>
<option value="hell">Hell — Papier und Gold</option>
<option value="system">Wie Windows eingestellt</option>
</select>
</label>
<div className="flex flex-wrap gap-2">
<button onClick={() => void window.rippy.protokollOeffnen()} className={KNOPF_LEISE} title="Eine Datei je Tag, 14 Tage aufgehoben Haupt und Kern schreiben hinein">
Protokoll öffnen
@@ -532,7 +540,7 @@ export function AllgemeinKarte({ verbindung, haupt }: { verbindung: Verbindung;
</button>
)}
</div>
<div className="rounded-xl border border-white/8 bg-buehne/60 p-3 font-mono text-[11px] leading-5 text-nebel">
<div className="rounded-xl border border-tinte/8 bg-buehne/60 p-3 font-mono text-[11px] leading-5 text-nebel">
<p>
Rippy v{haupt?.version ?? '…'}
{kern !== null && ` · Kern: Node ${kern.nodeVersion} (PID ${kern.pid})`}
@@ -559,7 +567,7 @@ export function UpdateKarte({ verbindung, haupt }: { verbindung: Verbindung; hau
ihn in der Datenbank, und § 6.8 prüft ihn wie zuvor (nur
https://). */}
{haupt !== null && (
<div className="rounded-xl border border-white/8 bg-buehne/60 p-3 font-mono text-[11px] leading-5 text-nebel">
<div className="rounded-xl border border-tinte/8 bg-buehne/60 p-3 font-mono text-[11px] leading-5 text-nebel">
<p>
<span className="text-dunst">Stand:</span> {haupt.update.text}
</p>
@@ -597,7 +605,7 @@ function WerkzeugZeile({
wert: string
}) {
return (
<div className="rounded-xl border border-white/8 bg-samt/50 p-3">
<div className="rounded-xl border border-tinte/8 bg-samt/50 p-3">
<p className={`break-all font-mono text-xs ${pfad === null ? 'text-nebel' : pfad.length > 0 ? 'text-gruen' : pflicht ? 'text-rot' : 'text-warn'}`}>
{name}: {pfad === null ? 'wird gesucht …' : pfad.length > 0 ? pfad : fehlt}
</p>
@@ -30,7 +30,7 @@ export function SprachAuswahl({
<div>
<span className="text-xs font-semibold text-dunst">{name}</span>
<span className="ml-2 text-xs text-nebel">{hinweis}</span>
<div className="mt-1 flex min-h-[38px] flex-wrap items-center gap-1.5 rounded-lg border border-white/12 bg-samt px-2 py-1.5">
<div className="mt-1 flex min-h-[38px] flex-wrap items-center gap-1.5 rounded-lg border border-tinte/12 bg-samt px-2 py-1.5">
{gewaehlt.length === 0 && <span className="font-mono text-[11px] text-nebel">alle Sprachen der Disc</span>}
{gewaehlt.map((code, i) => (
<span key={code} className="inline-flex items-center gap-1 rounded-md border border-gold/40 bg-gold/12 px-2 py-0.5 text-[11px] text-gold-hell">
@@ -53,7 +53,7 @@ export function SprachAuswahl({
onChange={(e) => {
if (e.target.value.length > 0) setzen([...gewaehlt, e.target.value])
}}
className="ml-auto rounded-md border border-white/12 bg-buehne px-2 py-1 font-mono text-[11px] text-dunst"
className="ml-auto rounded-md border border-tinte/12 bg-buehne px-2 py-1 font-mono text-[11px] text-dunst"
>
<option value="">+ Sprache </option>
{frei.map((s) => (
+36 -2
View File
@@ -31,6 +31,9 @@
--color-gruen: oklch(0.78 0.13 150);
--color-warn: oklch(0.8 0.12 85);
--color-rot: oklch(0.68 0.19 25);
/* Linien und Flächen mit Alpha: im Dunkeln Weiß, im Hellen Tinte (5.6.0). */
--color-tinte: oklch(1 0 0);
}
:root {
@@ -73,8 +76,8 @@
/* Dezente Scrollbalken — helle Standardbalken zerschneiden die Bühne. */
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-thumb { background: oklch(1 0 0 / 0.12); border-radius: 999px; border: 2px solid transparent; background-clip: content-box; }
::-webkit-scrollbar-thumb:hover { background-color: oklch(1 0 0 / 0.2); }
::-webkit-scrollbar-thumb { background: color-mix(in oklch, var(--color-tinte) 14%, transparent); border-radius: 999px; border: 2px solid transparent; background-clip: content-box; }
::-webkit-scrollbar-thumb:hover { background-color: color-mix(in oklch, var(--color-tinte) 24%, transparent); }
::-webkit-scrollbar-track { background: transparent; }
/* Der Poster-Lauf der Erst-Einrichtung (5.5.0): Jede Spalte wandert langsam
@@ -90,3 +93,34 @@
animation-iteration-count: infinite;
will-change: transform;
}
/* ── Hell-Modus (5.6.0, Commander: „White Mode") ─────────────────────────
Dieselben Tokens, andere Werte: warmes Papier statt Kinosaal, Gold als
dunklerer Akzent, Tinte statt Weiß für Linien und Flächen. Geschaltet
über data-design auf <html> (App.tsx, Einstellung „design": dunkel /
hell / system). */
:root[data-design="hell"] {
color-scheme: light;
--color-buehne: oklch(0.965 0.008 80);
--color-karte: oklch(0.99 0.004 80);
--color-samt: oklch(0.94 0.01 80);
--color-gold-hell: oklch(0.6 0.12 72);
--color-gold: oklch(0.66 0.13 70);
--color-gold-tief: oklch(0.56 0.13 58);
--color-rec: oklch(0.6 0.19 40);
--color-schnee: oklch(0.2 0.012 60);
--color-dunst: oklch(0.4 0.015 60);
--color-nebel: oklch(0.55 0.015 60);
--color-gruen: oklch(0.52 0.15 150);
--color-warn: oklch(0.58 0.13 80);
--color-rot: oklch(0.55 0.2 25);
--color-tinte: oklch(0.15 0.01 60);
}
:root[data-design="hell"] .buehnenlicht {
background:
radial-gradient(900px 520px at 78% 0%, oklch(0.9 0.06 80 / 0.6), transparent 70%),
radial-gradient(1100px 700px at 0% 100%, oklch(0.9 0.05 40 / 0.5), transparent 65%);
}
:root[data-design="hell"] .filmkorn {
opacity: 0.03;
}
@@ -3,16 +3,18 @@
// noch wartet, mit Abbruch je Auftrag. 5.5.0: Pause — der laufende Film
// wird fertig, der nächste wartet.
import type { VorgaengeStand } from '../../gemeinsam/nachrichten'
import { SegmentBalken } from '../bausteine'
import { leisteAusKompression } from '../../gemeinsam/vorgangsleiste'
import { restText } from '../bausteine'
import { VorgangsLeiste } from './VorgangsLeiste'
import { kompressionAbbrechen, kompressionPause } from '../kernverbindung'
export function KompressionsKarte({ stand }: { stand: VorgaengeStand['kompression'] }) {
if (stand.laeuft === null && stand.wartend.length === 0 && !stand.pausiert) return null
const l = stand.laeuft
return (
<div className="rounded-2xl border border-white/10 bg-karte/50 p-5">
<div className="rounded-2xl border border-tinte/10 bg-karte/50 p-5">
<div className="flex flex-wrap items-center gap-2.5 font-mono text-[10.5px] tracking-[0.12em] text-nebel">
<span className={`inline-block h-2 w-2 rounded-full ${l !== null ? 'animate-pulse bg-gold' : stand.pausiert ? 'bg-warn' : 'bg-white/25'}`} />
<span className={`inline-block h-2 w-2 rounded-full ${l !== null ? 'animate-pulse bg-gold' : stand.pausiert ? 'bg-warn' : 'bg-tinte/25'}`} />
<span className="text-dunst">KOMPRESSION</span>
<span>·</span>
<span>
@@ -22,7 +24,7 @@ export function KompressionsKarte({ stand }: { stand: VorgaengeStand['kompressio
<button
onClick={() => kompressionPause(!stand.pausiert)}
className={`ml-auto rounded-lg border px-3 py-1 text-[11px] ${
stand.pausiert ? 'border-gold/40 bg-gold/12 font-semibold text-gold-hell hover:bg-gold/20' : 'border-white/15 text-dunst hover:text-schnee'
stand.pausiert ? 'border-gold/40 bg-gold/12 font-semibold text-gold-hell hover:bg-gold/20' : 'border-tinte/15 text-dunst hover:text-schnee'
}`}
title={stand.pausiert ? 'Die Warteschlange läuft weiter' : 'Der laufende Film wird fertig gerechnet, der nächste wartet — zum Beispiel beim Spielen'}
>
@@ -30,12 +32,12 @@ export function KompressionsKarte({ stand }: { stand: VorgaengeStand['kompressio
</button>
</div>
{l !== null && (
<div className="mt-3 rounded-xl border border-white/10 bg-samt/80 p-4">
<div className="mt-3 rounded-xl border border-tinte/10 bg-samt/80 p-4">
<div className="flex items-end justify-between gap-5">
<div className="min-w-0 flex-1">
<p className="truncate font-display text-[17px] font-bold text-schnee">{l.titel}</p>
<div className="mt-2">
<SegmentBalken prozent={l.prozent} />
<VorgangsLeiste stand={leisteAusKompression(l)} restText={restText(l.restS)} />
</div>
<p className="mt-2 truncate font-mono text-[11px] text-nebel" title={l.text}>
&gt; {l.text}
@@ -61,8 +63,9 @@ export function KompressionsKarte({ stand }: { stand: VorgaengeStand['kompressio
{stand.wartend.length > 0 && (
<div className="mt-3 space-y-1">
{stand.wartend.map((w, i) => (
<div key={w.vorgangId} className="flex items-center justify-between gap-3 rounded-lg border border-white/8 bg-samt/40 px-3 py-1.5">
<span className="min-w-0 truncate font-mono text-[11px] text-dunst">
<div key={w.vorgangId} className="flex items-center justify-between gap-3 rounded-lg border border-tinte/8 bg-samt/40 px-3 py-1.5">
<VorgangsLeiste stand={leisteAusKompression(w)} kompakt />
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-dunst">
Platz {i + 1} · {w.titel}
{stand.pausiert ? ' · wartet auf Weiter' : ''}
</span>
@@ -24,7 +24,6 @@ import {
InfoKachel,
LAUFENDE_PHASEN,
PHASEN_MONO,
SegmentBalken,
TYP_NAMEN,
backdropUrl,
discHerkunft,
@@ -46,6 +45,8 @@ import {
ripUeberspringen,
type Verbindung,
} from '../kernverbindung'
import { leisteAusRip } from '../../gemeinsam/vorgangsleiste'
import { VorgangsLeiste } from './VorgangsLeiste'
/** Welches Preset diese Disc bekäme — dieselbe Kette wie im Kern
* (eigene Wahl → Empfehlung → Default). */
@@ -113,10 +114,10 @@ export function LaufwerkKachel({
? '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'
: 'text-dunst border-tinte/12 bg-samt/60'
return (
<div className="relative overflow-hidden rounded-2xl border border-white/10 bg-karte/50">
<div className="relative overflow-hidden rounded-2xl border border-tinte/10 bg-karte/50">
{backdrop.length > 0 && (
<>
<img src={backdrop} alt="" className="absolute inset-0 h-full w-full object-cover" />
@@ -135,7 +136,7 @@ export function LaufwerkKachel({
: discDa
? 'bg-gruen'
: geraet.status === 'empty'
? 'bg-white/25'
? 'bg-tinte/25'
: 'bg-rot'
}`}
/>
@@ -166,7 +167,7 @@ export function LaufwerkKachel({
<button
onClick={() => auswerfen(geraet.id)}
disabled={!discDa || ripLaeuft}
className="inline-flex items-center gap-2 rounded-xl border border-white/15 bg-gradient-to-b from-karte/80 to-samt/80 px-4 py-1.5 text-xs text-dunst shadow-[inset_0_1px_0_oklch(1_0_0/0.07)] hover:text-schnee disabled:cursor-not-allowed disabled:opacity-35"
className="inline-flex items-center gap-2 rounded-xl border border-tinte/15 bg-gradient-to-b from-karte/80 to-samt/80 px-4 py-1.5 text-xs text-dunst shadow-[inset_0_1px_0_oklch(1_0_0/0.07)] hover:text-schnee disabled:cursor-not-allowed disabled:opacity-35"
title="Entriegeln, auswerfen, nachsehen — in dieser Reihenfolge"
>
<IconEject />
@@ -189,7 +190,7 @@ export function LaufwerkKachel({
{poster.length > 0 ? (
<img src={poster} alt="" className="h-44 w-[118px] flex-none rounded-xl object-cover shadow-2xl shadow-black/60 ring-1 ring-gold/35" />
) : (
<div className="flex h-44 w-[118px] flex-none items-center justify-center rounded-xl bg-gradient-to-br from-karte to-samt ring-1 ring-white/10">
<div className="flex h-44 w-[118px] flex-none items-center justify-center rounded-xl bg-gradient-to-br from-karte to-samt ring-1 ring-tinte/10">
<IconDisc groesse={40} farbe="oklch(0.5 0.05 75)" />
</div>
)}
@@ -244,7 +245,7 @@ export function LaufwerkKachel({
{d.bildPfad.length > 0 ? (
<img src={profilUrl(d.bildPfad)} alt="" className="h-9 w-9 rounded-full object-cover ring-1 ring-gold/30" />
) : (
<span className="flex h-9 w-9 items-center justify-center rounded-full bg-samt font-mono text-[11px] text-nebel ring-1 ring-white/10">
<span className="flex h-9 w-9 items-center justify-center rounded-full bg-samt font-mono text-[11px] text-nebel ring-1 ring-tinte/10">
{d.name
.split(' ')
.map((w) => w[0] ?? '')
@@ -266,7 +267,7 @@ export function LaufwerkKachel({
<button
onClick={aufAendern}
disabled={ripLaeuft}
className="inline-flex items-center gap-2 rounded-lg border border-white/15 bg-samt/60 px-3 py-1 text-xs text-dunst hover:text-schnee disabled:opacity-35"
className="inline-flex items-center gap-2 rounded-lg border border-tinte/15 bg-samt/60 px-3 py-1 text-xs text-dunst hover:text-schnee disabled:opacity-35"
title="Falsch erkannt? Selbst suchen und wählen — die Ablage folgt deiner Wahl."
>
<IconStift />
@@ -277,7 +278,7 @@ export function LaufwerkKachel({
setBerichtOffen(true)
laufwerkPruefen(geraet.id)
}}
className="inline-flex items-center gap-2 rounded-lg border border-white/15 bg-samt/60 px-3 py-1 text-xs text-dunst hover:text-schnee"
className="inline-flex items-center gap-2 rounded-lg border border-tinte/15 bg-samt/60 px-3 py-1 text-xs text-dunst hover:text-schnee"
title="Liest die cdrom-Ereignisse aus dem Windows-Protokoll: Disc oder Hardware?"
>
Laufwerk prüfen
@@ -317,7 +318,7 @@ export function LaufwerkKachel({
{/* Automatik (5.5.0): Countdown mit Stopp und Jetzt — oder der
Grund, warum Rippy wartet. */}
{discDa && !ripLaeuft && automatik !== undefined && automatik.text.length > 0 && (
<div className={`mt-4 flex flex-wrap items-center gap-3 rounded-xl border p-3 ${automatik.aktiv ? 'border-gold/45 bg-gold/10' : 'border-white/10 bg-samt/60'}`}>
<div className={`mt-4 flex flex-wrap items-center gap-3 rounded-xl border p-3 ${automatik.aktiv ? 'border-gold/45 bg-gold/10' : 'border-tinte/10 bg-samt/60'}`}>
{automatik.aktiv && (
<span className="font-mono text-[28px] font-bold leading-none text-gold-hell">
{automatik.sekunden}
@@ -330,7 +331,7 @@ export function LaufwerkKachel({
<button onClick={() => automatikJetzt(geraet.id)} className="rounded-lg border border-gold/40 bg-gold/15 px-3 py-1 text-[11px] font-semibold text-gold-hell hover:bg-gold/25">
Jetzt
</button>
<button onClick={() => automatikStopp(geraet.id)} className="rounded-lg border border-white/15 px-3 py-1 text-[11px] text-dunst hover:text-schnee">
<button onClick={() => automatikStopp(geraet.id)} className="rounded-lg border border-tinte/15 px-3 py-1 text-[11px] text-dunst hover:text-schnee">
Stopp
</button>
</div>
@@ -366,7 +367,7 @@ export function LaufwerkKachel({
)}
{rip !== undefined && (
<div className="mt-4 rounded-xl border border-white/10 bg-samt/80 p-4 backdrop-blur">
<div className="mt-4 rounded-xl border border-tinte/10 bg-samt/80 p-4 backdrop-blur">
<div className="flex items-end justify-between gap-5">
<div className="min-w-0 flex-1">
<div className="mb-2 flex items-baseline justify-between gap-3">
@@ -380,7 +381,9 @@ export function LaufwerkKachel({
</span>
{ripLaeuft && <span className="font-mono text-[10.5px] text-nebel">{restzeitText(rip.prozent, ripStartMs)}</span>}
</div>
{ripLaeuft && <SegmentBalken prozent={rip.prozent} />}
{/* 5.6.0: die Vorgangs-Leiste — der ganze Weg, hier mit den
Details der Laufwerks-Stationen; nach dem Rip nur der Stand. */}
<VorgangsLeiste stand={leisteAusRip(rip, vorgang, wartePlatz)} />
{ripLaeuft && (rip.titelGesamt ?? 0) > 1 && (
<p className="mt-1.5 font-mono text-[10.5px] text-nebel">
Titel {rip.titelNr} von {rip.titelGesamt}
@@ -425,35 +428,22 @@ export function LaufwerkKachel({
</p>
))}
{/* Nach dem Rip (5.4.0): Wo steht die Kompression? Das
Laufwerk ist schon frei — die Arbeit läuft im Hintergrund. */}
{/* Nach dem Rip (5.6.0, Commander: „Nur in der Kompressions-Karte"):
hier nur noch EINE Zeile, wo es weitergeht — Balken, Prozent,
Restzeit und Ziel stehen in der Kompressions-Karte, danach im
Verlauf. Fehler bleiben sichtbar (R4). */}
{rip.phase === 'fertig' && vorgang !== undefined && (
<div className="mt-3 rounded-lg border border-white/8 bg-buehne/60 p-2.5">
{kompression !== null ? (
<>
<p className="font-mono text-[10.5px] tracking-[0.12em] text-gold-hell">
KOMPRESSION LÄUFT{kompression.prozent >= 0 ? ` ${kompression.prozent} %` : ''}
</p>
<div className="mt-1.5">
<SegmentBalken prozent={kompression.prozent} />
</div>
<p className="mt-1.5 truncate font-mono text-[11px] text-nebel" title={kompression.text}>
&gt; {kompression.text}
</p>
</>
) : wartePlatz > 0 ? (
<p className="font-mono text-[11px] text-dunst">Kompression wartet Platz {wartePlatz} in der Warteschlange.</p>
) : vorgang.phase === 'fertig' ? (
<p className="break-all font-mono text-[11px] text-gruen">
{vorgang.komprimiert ? 'Komprimiert und abgelegt' : 'Verlustfrei abgelegt'}: {vorgang.zielOrdner}
{vorgang.fertigBytes > 0 && ` (${gb(vorgang.fertigBytes)})`}
</p>
) : vorgang.phase === 'fehler' || vorgang.phase === 'abgebrochen' ? (
<p className="break-all font-mono text-[11px] text-warn"> {vorgang.fehler.length > 0 ? vorgang.fehler : `Kompression ${vorgang.phase}`}</p>
) : (
<p className="font-mono text-[11px] text-dunst">Kompression: {vorgang.phase}</p>
)}
</div>
<p className={`mt-2 font-mono text-[11px] ${vorgang.phase === 'fehler' || vorgang.phase === 'abgebrochen' ? 'text-warn' : 'text-nebel'}`}>
{kompression !== null
? 'Kompression läuft — Stand in der Kompressions-Karte unten.'
: wartePlatz > 0
? `Kompression wartet — Platz ${wartePlatz} in der Warteschlange (Karte unten).`
: vorgang.phase === 'fertig'
? 'Kompression fertig — das Ergebnis steht im Verlauf.'
: vorgang.phase === 'fehler' || vorgang.phase === 'abgebrochen'
? `${vorgang.fehler.length > 0 ? vorgang.fehler : `Kompression ${vorgang.phase}`}`
: `Kompression: ${vorgang.phase}`}
</p>
)}
</div>
{ripLaeuft && rip.prozent >= 0 && (
@@ -13,7 +13,7 @@ function Platz({ name, platz, hinweis }: { name: string; platz: SpeicherPlatz; h
const ton = !platz.ok ? 'rot' : platz.freiBytes < SEHR_WENIG_FREI_BYTES ? 'rot' : platz.freiBytes < WENIG_FREI_BYTES ? 'warn' : 'gold'
const belegt = platz.ok && platz.gesamtBytes > 0 ? 1 - platz.freiBytes / platz.gesamtBytes : 0
return (
<div className="rounded-xl border border-white/8 bg-samt/50 p-3">
<div className="rounded-xl border border-tinte/8 bg-samt/50 p-3">
<div className="flex items-baseline justify-between gap-3">
<span className="font-mono text-[10px] tracking-[0.14em] text-nebel">{name}</span>
<span className={`font-mono text-[13px] font-semibold ${ton === 'rot' ? 'text-rot' : ton === 'warn' ? 'text-warn' : 'text-schnee'}`}>
@@ -42,7 +42,7 @@ function Platz({ name, platz, hinweis }: { name: string; platz: SpeicherPlatz; h
export function SpeicherKarte({ speicher, statistik }: { speicher: SpeicherStand | null; statistik: Statistik | null }) {
return (
<div className="rounded-2xl border border-white/10 bg-karte/50 p-5">
<div className="rounded-2xl border border-tinte/10 bg-karte/50 p-5">
<div className="flex items-center gap-2.5 font-mono text-[10.5px] tracking-[0.12em] text-nebel">
<span className="text-dunst">SPEICHER UND BILANZ</span>
<button onClick={() => speicherLaden()} className="ml-auto font-mono text-[10.5px] text-nebel hover:text-dunst" title="Jetzt neu messen">
@@ -77,7 +77,7 @@ export function SpeicherKarte({ speicher, statistik }: { speicher: SpeicherStand
function Zahl({ label, wert, zusatz }: { label: string; wert: string; zusatz: string }) {
return (
<div className="rounded-lg border border-white/10 bg-samt/70 px-3 py-2">
<div className="rounded-lg border border-tinte/10 bg-samt/70 px-3 py-2">
<div className="font-mono text-[9px] tracking-[0.14em] text-nebel">{label}</div>
<div className="mt-0.5 font-display text-[19px] font-bold leading-tight text-gold-hell">{wert}</div>
<div className="font-mono text-[10px] text-nebel/80">{zusatz}</div>
@@ -53,12 +53,12 @@ export function Uebersicht({
</div>
<p className="mt-1 font-mono text-xs text-dunst">{haupt.update.text}</p>
{haupt.update.fortschritt >= 0 && !haupt.update.bereit && (
<div className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-white/10">
<div className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-tinte/10">
<div className="h-full rounded-full bg-gold transition-all" style={{ width: `${Math.max(0, Math.min(100, haupt.update.fortschritt))}%` }} />
</div>
)}
{haupt.update.neuerungen.length > 0 && (
<div className="mt-3 rounded-xl border border-white/8 bg-buehne/60 p-3">
<div className="mt-3 rounded-xl border border-tinte/8 bg-buehne/60 p-3">
<p className="text-[11px] uppercase tracking-wider text-nebel">Was neu ist</p>
<p className="mt-1 whitespace-pre-wrap font-mono text-xs leading-5 text-dunst">{haupt.update.neuerungen}</p>
</div>
@@ -87,7 +87,7 @@ export function Uebersicht({
<section>
{verbindung.laufwerke.length === 0 ? (
<div className="flex items-center gap-5 rounded-2xl border border-white/10 bg-karte/50 p-6">
<div className="flex items-center gap-5 rounded-2xl border border-tinte/10 bg-karte/50 p-6">
<IconDisc groesse={44} farbe="oklch(0.4 0.02 75)" />
<p className="font-mono text-xs text-nebel">{verbindung.verbunden ? 'Kein optisches Laufwerk gefunden.' : 'Warte auf den Kern …'}</p>
</div>
@@ -154,6 +154,7 @@ export function Uebersicht({
einstellungen={verbindung.einstellungen}
regel={verbindung.vorgaenge?.regel ?? ''}
speicher={verbindung.speicher}
vorschlaege={verbindung.vorschlaege[dialog.id] ?? []}
schliessen={() => setDialog(null)}
/>
)}
@@ -169,7 +170,7 @@ export function Uebersicht({
{verbindung.meldungen.length > 0 && (
<section>
<div className="mb-2.5 font-mono text-[10px] tracking-[0.22em] text-nebel">EREIGNISSE</div>
<div className="max-h-56 space-y-1 overflow-y-auto rounded-2xl border border-white/10 bg-karte/50 p-4">
<div className="max-h-56 space-y-1 overflow-y-auto rounded-2xl border border-tinte/10 bg-karte/50 p-4">
{verbindung.meldungen.map((zeile, i) => (
<p key={i} className="break-all font-mono text-[11px] text-dunst">
{zeile}
@@ -2,7 +2,9 @@
// Bibliothek (Commander: „Brauchen wir die wirklich?" — nein: Jellyfin IST
// die Bibliothek; die Wiedererkennung braucht keine Ansicht).
import type { GeraeteInfo, VorgangEintrag } from '../../gemeinsam/nachrichten'
import { leisteAusVorgang } from '../../gemeinsam/vorgangsleiste'
import { Badge, TYP_NAMEN, datumKurz, gb } from '../bausteine'
import { VorgangsLeiste } from './VorgangsLeiste'
const PHASEN_TEXT: Record<VorgangEintrag['phase'], string> = {
rippt: 'rippt',
@@ -22,7 +24,7 @@ export function VerlaufKarte({ vorgaenge, aufAlle }: { vorgaenge: VorgangEintrag
if (vorgaenge.length === 0) return null
const letzte = vorgaenge.slice(0, 6)
return (
<div className="rounded-2xl border border-white/10 bg-karte/50 p-5">
<div className="rounded-2xl border border-tinte/10 bg-karte/50 p-5">
<div className="flex items-center gap-2.5 font-mono text-[10.5px] tracking-[0.12em] text-nebel">
<span className="text-dunst">VERLAUF</span>
<span>·</span>
@@ -33,11 +35,12 @@ export function VerlaufKarte({ vorgaenge, aufAlle }: { vorgaenge: VorgangEintrag
</div>
<div className="mt-3 space-y-1">
{letzte.map((v) => (
<div key={v.id} className="flex flex-wrap items-center gap-x-3 gap-y-1 rounded-lg border border-white/8 bg-samt/40 px-3 py-2">
<div key={v.id} className="flex flex-wrap items-center gap-x-3 gap-y-1 rounded-lg border border-tinte/8 bg-samt/40 px-3 py-2">
<span className="min-w-0 flex-1 truncate font-display text-[14px] font-bold text-schnee" title={v.titel}>
{v.titel}
{v.jahr !== null && <span className="ml-1 font-medium text-dunst">({v.jahr})</span>}
</span>
<VorgangsLeiste stand={leisteAusVorgang(v)} kompakt />
<Badge text={PHASEN_TEXT[v.phase]} ton={phasenTon(v.phase)} />
<span className="font-mono text-[10.5px] text-nebel">
{(TYP_NAMEN[v.typ as GeraeteInfo['typ']] ?? v.typ).toUpperCase()} · {datumKurz(v.begonnen)}
@@ -0,0 +1,90 @@
// Die Vorgangs-Leiste (5.6.0) — der ganze Weg einer Disc in Stationen, mit
// dem Segment-Balken in der aktiven Station. Logik in
// gemeinsam/vorgangsleiste.ts; hier nur das Zeichnen. `kompakt` = Punkte
// (Verlauf, Warteschlange).
import { Fragment } from 'react'
import { STATIONS_NAMEN, stationen, stationsZustand, type LeisteStand } from '../../gemeinsam/vorgangsleiste'
import { SegmentBalken } from '../bausteine'
export function VorgangsLeiste({ stand, restText, kompakt }: { stand: LeisteStand; restText?: string; kompakt?: boolean }) {
const liste = stationen(stand.rettung)
const zustaende = stationsZustand(liste, stand.station, stand.zustand)
const titel = `${STATIONS_NAMEN[stand.station]}: ${
stand.zustand === 'laeuft' ? 'läuft' : stand.zustand === 'wartet' ? 'wartet' : stand.zustand === 'fertig' ? 'fertig' : stand.zustand === 'fehler' ? 'Fehler' : 'abgebrochen'
}`
if (kompakt) {
return (
<span className="inline-flex flex-none items-center gap-1" title={titel} aria-label={titel}>
{liste.map((s, i) => (
<span
key={s}
className={`inline-block h-1.5 w-1.5 rounded-full ${
zustaende[i] === 'fertig'
? 'bg-gold'
: zustaende[i] === 'aktiv'
? stand.zustand === 'wartet'
? 'bg-gold/50'
: 'animate-pulse bg-gold-hell'
: zustaende[i] === 'fehler'
? 'bg-rot'
: 'bg-tinte/20'
}`}
/>
))}
</span>
)
}
return (
<div className="flex flex-wrap items-center gap-1.5" title={titel}>
{liste.map((s, i) => {
const z = zustaende[i]
const aktiv = z === 'aktiv'
const rahmen =
z === 'fehler'
? 'border-rot/50 bg-rot/10'
: aktiv
? 'min-w-[200px] flex-1 border-gold/50 bg-gold/10'
: z === 'fertig'
? 'border-gold/30 bg-gold/5'
: 'border-tinte/10'
return (
<Fragment key={s}>
{i > 0 && <span className={`h-px w-3 flex-none ${zustaende[i - 1] === 'fertig' ? 'bg-gold/60' : 'bg-tinte/15'}`} />}
<div className={`flex items-center gap-2 rounded-lg border px-2 py-1 ${rahmen}`}>
<span
className={`flex h-4 w-4 flex-none items-center justify-center rounded-full font-mono text-[9px] ${
z === 'fertig'
? 'bg-gold text-buehne'
: aktiv
? 'bg-gold-hell text-buehne'
: z === 'fehler'
? 'bg-rot text-buehne'
: 'border border-tinte/20 text-nebel'
}`}
>
{z === 'fertig' ? '✓' : z === 'fehler' ? '!' : String(i + 1)}
</span>
<span className={`font-mono text-[10px] tracking-[0.12em] ${aktiv ? 'text-gold-hell' : z === 'fertig' ? 'text-gold/80' : z === 'fehler' ? 'text-rot' : 'text-nebel'}`}>
{STATIONS_NAMEN[s].toUpperCase()}
</span>
{aktiv && stand.zustand === 'laeuft' && stand.prozent >= 0 && (
<div className="min-w-[80px] flex-1">
<SegmentBalken prozent={stand.prozent} />
</div>
)}
{aktiv && stand.zustand === 'laeuft' && stand.prozent >= 0 && <span className="font-mono text-[10.5px] text-gold-hell">{stand.prozent} %</span>}
{aktiv && stand.zustand === 'laeuft' && stand.prozent < 0 && stand.hinweis.length === 0 && (
<span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-gold-hell" />
)}
{aktiv && restText !== undefined && restText.length > 0 && <span className="font-mono text-[10px] text-nebel">{restText}</span>}
{aktiv && stand.hinweis.length > 0 && <span className="font-mono text-[10px] text-nebel">{stand.hinweis}</span>}
{z === 'fehler' && <span className="font-mono text-[10px] text-rot">{stand.zustand === 'abgebrochen' ? 'abgebrochen' : 'Fehler'}</span>}
</div>
</Fragment>
)
})}
</div>
)
}
@@ -113,6 +113,13 @@ export interface RipAuftragTitel {
* Untertitel). Leer/fehlend heißt: die globale Einstellung gilt. */
audioSprachen?: string[]
untertitelSprachen?: string[]
/** 5.6.0, nur für Extras: WELCHE Untertitel-Sprache beim Abspielen
* vorgewählt ist (weiche Spur, nie eingebrannt). null = keine Vorwahl;
* fehlend = Rippys Regel (fremder Ton → Wunschsprache). */
untertitelVorwahl?: string | null
/** 5.6.0: Dieser Titel gehört zu einem ANDEREN Film als die Disc-Zuordnung
* (Double Feature) — er bekommt eigenen Roh-Ordner, Vorgang und Zielordner. */
film?: MetaVorschlag
}
/** Was der Nutzer im Auswahl-Dialog über die GANZE Disc sagt (5.4.0):
@@ -292,6 +299,9 @@ export interface KompressionStand {
prozent: number
text: string
phase: VorgangPhase
/** 5.6.0: Restzeit der laufenden Datei in Sekunden, wie HandBrake sie
* meldet (ETA); fehlend/-1 = noch keine Angabe. */
restS?: number
}
/** Ein Ordner unter roh\, den kein Vorgang kennt (Rest aus der Zeit vor
@@ -0,0 +1,143 @@
// Die Vorgangs-Leiste (5.6.0, Commander: „Phase 14 plus Komprimierung —
// irgendwie zu einem machen, der Phasen-Balken gefällt mir") — EIN Weg für
// jede Disc, vier Stationen: Titel lesen → Rippen → Komprimieren → Ablegen,
// bei Bedarf das Rettungs-Abbild als Zwischenstation vor dem Rippen. Die
// Leiste zeigt immer den GANZEN Weg, aber nur an einer Stelle die Details:
// in der Laufwerks-Kachel, solange das Laufwerk arbeitet (Stationen 12),
// in der Kompressions-Karte danach (34), im Verlauf als Punkte.
//
// Hier liegt die REINE Logik (welche Station ist fertig, aktiv, offen,
// gescheitert) — testbar ohne React; gezeichnet wird in
// fenster/uebersicht/VorgangsLeiste.tsx.
import type { KompressionStand, RipStatus, VorgangEintrag } from './nachrichten'
export type Station = 'titel' | 'rettung' | 'rip' | 'kompression' | 'ablage'
export type LeisteZustand = 'laeuft' | 'wartet' | 'fertig' | 'fehler' | 'abgebrochen'
export type StationsZustand = 'fertig' | 'aktiv' | 'offen' | 'fehler'
export interface LeisteStand {
station: Station
zustand: LeisteZustand
/** 0100 in der aktiven Station, -1 = keine Angabe. */
prozent: number
/** Das Rettungs-Abbild als Zwischenstation zeigen. */
rettung: boolean
/** Kurztext in der aktiven Station („Platz 2", „läuft — Karte unten"). */
hinweis: string
}
export const STATIONS_NAMEN: Record<Station, string> = {
titel: 'Titel lesen',
rettung: 'Rettungs-Abbild',
rip: 'Rippen',
kompression: 'Komprimieren',
ablage: 'Ablegen',
}
export function stationen(rettung: boolean): Station[] {
return rettung ? ['titel', 'rettung', 'rip', 'kompression', 'ablage'] : ['titel', 'rip', 'kompression', 'ablage']
}
/** Zustand je Station: alles vor der aktiven ist fertig, die aktive läuft
* (oder ist gescheitert), alles danach ist offen. `fertig` = alle gold. */
export function stationsZustand(liste: readonly Station[], station: Station, zustand: LeisteZustand): StationsZustand[] {
const i = liste.indexOf(station)
return liste.map((_, k) => {
if (zustand === 'fertig') return 'fertig'
if (k < i) return 'fertig'
if (k === i) return zustand === 'fehler' || zustand === 'abgebrochen' ? 'fehler' : 'aktiv'
return 'offen'
})
}
function stand(station: Station, zustand: LeisteZustand, prozent = -1, rettung = false, hinweis = ''): LeisteStand {
return { station, zustand, prozent, rettung, hinweis }
}
/** Die Leiste in der Laufwerks-Kachel: aus dem Rip-Stand und — nach dem
* Rip — aus dem Vorgang (ohne Prozent: die Details stehen in der
* Kompressions-Karte). */
export function leisteAusRip(
rip: Pick<RipStatus, 'phase' | 'prozent' | 'rettung'>,
vorgang: Pick<VorgangEintrag, 'phase'> | undefined,
wartePlatz: number,
): LeisteStand {
const rettung = rip.phase === 'rettet' || rip.rettung !== undefined
switch (rip.phase) {
case 'liest-info':
return stand('titel', 'laeuft', -1, rettung)
case 'rettet':
return stand('rettung', 'laeuft', rip.prozent, true)
case 'rippt':
return stand('rip', 'laeuft', rip.prozent, rettung)
case 'komprimiert':
return stand('kompression', 'laeuft', rip.prozent, rettung)
case 'legt-ab':
return stand('ablage', 'laeuft', rip.prozent, rettung)
case 'fehler':
return stand('rip', 'fehler', -1, rettung)
case 'abgebrochen':
return stand('rip', 'abgebrochen', -1, rettung)
case 'fertig':
break
}
// Der Rip ist durch — wo steht der Vorgang?
if (vorgang === undefined) return stand('kompression', 'wartet', -1, rettung, 'eingereiht')
switch (vorgang.phase) {
case 'rippt':
return stand('rip', 'laeuft', -1, rettung)
case 'wartet':
return stand('kompression', 'wartet', -1, rettung, wartePlatz > 0 ? `Platz ${wartePlatz}` : 'wartet')
case 'komprimiert':
return stand('kompression', 'laeuft', -1, rettung, 'läuft — Karte unten')
case 'legt-ab':
return stand('ablage', 'laeuft', -1, rettung, 'läuft — Karte unten')
case 'fertig':
return stand('ablage', 'fertig', 100, rettung)
case 'fehler':
return stand('kompression', 'fehler', -1, rettung)
case 'abgebrochen':
return stand('kompression', 'abgebrochen', -1, rettung)
}
}
/** Die Leiste eines Vorgangs im Verlauf (kompakt). Bei Fehlern ohne
* Roh-Bytes scheiterte schon der Rip, sonst die Kompression. */
export function leisteAusVorgang(v: Pick<VorgangEintrag, 'phase' | 'rohBytes'>): LeisteStand {
switch (v.phase) {
case 'rippt':
return stand('rip', 'laeuft')
case 'wartet':
return stand('kompression', 'wartet', -1, false, 'wartet')
case 'komprimiert':
return stand('kompression', 'laeuft')
case 'legt-ab':
return stand('ablage', 'laeuft')
case 'fertig':
return stand('ablage', 'fertig', 100)
case 'fehler':
return stand(v.rohBytes > 0 ? 'kompression' : 'rip', 'fehler')
case 'abgebrochen':
return stand(v.rohBytes > 0 ? 'kompression' : 'rip', 'abgebrochen')
}
}
/** Die Leiste in der Kompressions-Karte: Stationen 12 sind fertig. */
export function leisteAusKompression(k: Pick<KompressionStand, 'phase' | 'prozent'>): LeisteStand {
switch (k.phase) {
case 'legt-ab':
return stand('ablage', 'laeuft', k.prozent)
case 'wartet':
return stand('kompression', 'wartet', -1, false, 'wartet')
case 'fertig':
return stand('ablage', 'fertig', 100)
case 'fehler':
return stand('kompression', 'fehler')
case 'abgebrochen':
return stand('kompression', 'abgebrochen')
case 'rippt':
return stand('rip', 'laeuft', k.prozent)
case 'komprimiert':
return stand('kompression', 'laeuft', k.prozent)
}
}
@@ -0,0 +1,47 @@
// Dateinamen in der Ablage (5.6.0, Commander: „die Ordner … müssen direkt
// den Filmnamen haben" — und die Dateien darin auch): Statt MakeMKVs
// title_t00.mkv heißt der Hauptfilm „Akira (1988).mkv", zwei Fassungen
// „Akira (1988) - Fassung 1.mkv" und „… - Fassung 2.mkv", Zugaben „Akira
// (1988) - Extra 01.mkv" im Unterordner extras. Folgen behalten ihren
// Namen, bis die Episoden-Zuordnung sie umbenennt (struktur.ts) — ohne
// Zuordnung bleiben sie, wie sie sind (lieber gar nicht als falsch). Ohne
// erkannten Titel bleibt alles beim MakeMKV-Namen. PUR.
import { extname } from 'node:path'
import { sichererName } from '../../gemeinsam/ablagepfad'
import type { TitelRolle } from '../../gemeinsam/nachrichten'
export interface NamensEingabe {
titel: string
jahr: number | null
alsSerie: boolean
rolle: TitelRolle
/** 1-basierte Nummer unter den Dateien GLEICHER Rolle (Ablage-Reihenfolge). */
nummer: number
/** Wie viele Dateien dieser Rolle es gibt. */
anzahl: number
/** MakeMKVs Dateiname (mit Endung). */
original: string
}
export function zielDateiname(e: NamensEingabe): string {
const endung = extname(e.original) || '.mkv'
const name = e.alsSerie ? sichererName(e.titel) : sichererName(e.titel, e.jahr)
if (e.titel.trim().length === 0 || name.length === 0) return e.original
const nn = String(e.nummer).padStart(2, '0')
if (e.rolle === 'extra') return `${name} - Extra ${nn}${endung}`
if (e.alsSerie) return e.original
if (e.rolle === 'hauptfilm') return e.anzahl > 1 ? `${name} - Fassung ${e.nummer}${endung}` : `${name}${endung}`
return e.original
}
/** Nummern je Rolle in Ablage-Reihenfolge (pur): [nummer, anzahl] je Index. */
export function rollenNummern(rollen: readonly TitelRolle[]): Array<[number, number]> {
const zaehler = new Map<TitelRolle, number>()
const gesamt = new Map<TitelRolle, number>()
for (const r of rollen) gesamt.set(r, (gesamt.get(r) ?? 0) + 1)
return rollen.map((r) => {
const n = (zaehler.get(r) ?? 0) + 1
zaehler.set(r, n)
return [n, gesamt.get(r) ?? 1]
})
}
+21 -3
View File
@@ -34,6 +34,7 @@ import {
// Plattform-Pfade wie in struktur.ts: Laufzeit-Wurzeln, CI testet auf Linux.
import { basename, join } from 'node:path'
import type { BibliothekEintrag, KompressionStand } from '../../gemeinsam/nachrichten'
import { rollenNummern, zielDateiname } from '../ablage/dateinamen'
import { baueNfo } from '../ablage/nfo'
import { bibliothekRefresh } from '../ablage/medienserver'
import { posterSpeichern } from '../ablage/poster'
@@ -87,7 +88,7 @@ export interface AblageErgebnis {
folgenVergeben: number[]
}
export type AblageMeldung = (stand: Pick<KompressionStand, 'phase' | 'prozent' | 'text'>) => void
export type AblageMeldung = (stand: Pick<KompressionStand, 'phase' | 'prozent' | 'text' | 'restS'>) => void
/** Verschieben auf demselben Datenträger ist ein Umbenennen; über
* Datenträger-Grenzen (EXDEV) hilft nur Kopieren + Löschen. */
@@ -188,9 +189,24 @@ export async function komprimierenUndAblegen(
// Laufzeiten der FOLGEN in Ablage-Reihenfolge — Grundlage der
// Episoden-Zuordnung weiter unten.
const folgenSekunden: number[] = []
// 5.6.0: Dateien heißen wie der Film (dateinamen.ts) — Folgen behalten
// ihren Namen bis zur Episoden-Zuordnung.
const nummern = rollenNummern(dateien.map((d) => d.rolle))
for (let i = 0; i < dateien.length; i++) {
const datei = dateien[i]
const zielDatei = join(zielFuer(datei.rolle), basename(datei.pfad))
const [nummer, anzahl] = nummern[i]
const zielDatei = join(
zielFuer(datei.rolle),
zielDateiname({
titel: zuordnung?.titel ?? '',
jahr: zuordnung?.jahr ?? null,
alsSerie,
rolle: datei.rolle,
nummer,
anzahl,
original: basename(datei.pfad),
}),
)
if (abbruch.aborted) return abgebrochen(fertigOrdner, fertigeDateien, preset, komprimiert, hinweise)
if (!komprimiert) {
melden({
@@ -237,6 +253,7 @@ export async function komprimierenUndAblegen(
melden({
phase: 'komprimiert',
prozent: gewichteterFortschritt(gewichte, i, 0),
restS: -1,
text: `Komprimiere ${i + 1}/${dateien.length}: ${basename(datei.pfad)} („${preset}")${
wahl.audio.length > 0 ? ` · Ton ${wahl.audio.join(',')}` : ''
}${wahl.untertitel.length > 0 ? ` · Untertitel ${wahl.untertitel.join(',')}${wahl.untertitelStandard ? ' (vorgewählt)' : ''}` : ''}`,
@@ -250,10 +267,11 @@ export async function komprimierenUndAblegen(
untertitelSprachen: wahl.untertitel,
untertitelStandard: wahl.untertitelStandard,
abbruch,
aufFortschritt: (prozent) => {
aufFortschritt: (prozent, restS) => {
melden({
phase: 'komprimiert',
prozent: gewichteterFortschritt(gewichte, i, prozent),
restS,
text: `Komprimiere ${i + 1}/${dateien.length}: ${basename(datei.pfad)} („${preset}") …`,
})
},
+29 -2
View File
@@ -6,7 +6,7 @@
// nur noch diese Beschreibung. Deshalb wird sie als JSON in der Datenbank
// gemerkt — so kann „Wiederholen ab Kompression" sie Tage später wieder
// aufnehmen, ohne die Disc noch einmal zu lesen (40 Minuten je Blu-ray).
import type { TitelRolle } from '../../gemeinsam/nachrichten'
import type { MetaVorschlag, TitelRolle } from '../../gemeinsam/nachrichten'
import type { Zuordnung } from '../metadaten/zuordnung'
export interface RohDatei {
@@ -19,6 +19,10 @@ export interface RohDatei {
/** Sprachen je Datei (leer = die globale Einstellung gilt). */
audioSprachen: string[]
untertitelSprachen: string[]
/** 5.6.0 (Extras): vorgewählte Untertitel-Sprache; null = keine; fehlend = Regel. */
untertitelVorwahl?: string | null
/** 5.6.0: der Film, zu dem diese Datei gehört, wenn nicht die Disc-Zuordnung. */
film?: MetaVorschlag
}
export interface KompressionsAuftrag {
@@ -81,6 +85,8 @@ export function auftragAusJson(text: string): KompressionsAuftrag | null {
groesseBytes: typeof e.groesseBytes === 'number' ? e.groesseBytes : 0,
audioSprachen: Array.isArray(e.audioSprachen) ? e.audioSprachen.map(String) : [],
untertitelSprachen: Array.isArray(e.untertitelSprachen) ? e.untertitelSprachen.map(String) : [],
...(e.untertitelVorwahl === null || typeof e.untertitelVorwahl === 'string' ? { untertitelVorwahl: e.untertitelVorwahl } : {}),
...(filmAus(e.film) !== null ? { film: filmAus(e.film)! } : {}),
})
}
return {
@@ -103,6 +109,20 @@ export function auftragAusJson(text: string): KompressionsAuftrag | null {
}
}
/** Ein Film-Vorschlag aus dem JSON — null, wenn er nicht die Form hat. */
export function filmAus(wert: unknown): MetaVorschlag | null {
if (typeof wert !== 'object' || wert === null) return null
const f = wert as Record<string, unknown>
if (typeof f.tmdbId !== 'number' || typeof f.titel !== 'string') return null
return {
typ: f.typ === 'tv' ? 'tv' : 'movie',
tmdbId: f.tmdbId,
titel: f.titel,
jahr: typeof f.jahr === 'number' ? f.jahr : null,
posterPfad: typeof f.posterPfad === 'string' ? f.posterPfad : '',
}
}
/** Was HandBrake für EINE Datei bekommt. */
export interface SprachWahl {
audio: string[]
@@ -123,11 +143,18 @@ export interface SprachWahl {
* komprimieren/handbrake.ts).
*/
export function sprachwahl(
datei: { audioSprachen: readonly string[]; untertitelSprachen: readonly string[] },
datei: { audioSprachen: readonly string[]; untertitelSprachen: readonly string[]; untertitelVorwahl?: string | null },
global: { audio: readonly string[]; untertitel: readonly string[] },
): SprachWahl {
const audio = datei.audioSprachen.length > 0 ? [...datei.audioSprachen] : [...global.audio]
let untertitel = datei.untertitelSprachen.length > 0 ? [...datei.untertitelSprachen] : [...global.untertitel]
// 5.6.0: Die Vorwahl des Nutzers (nur Extras, immer optional) schlägt die
// Regel — null heißt ausdrücklich „keine Vorwahl".
if (datei.untertitelVorwahl === null) return { audio, untertitel, untertitelStandard: false }
if (typeof datei.untertitelVorwahl === 'string' && datei.untertitelVorwahl.length > 0) {
const v = datei.untertitelVorwahl
return { audio, untertitel: [v, ...untertitel.filter((u) => u !== v)], untertitelStandard: true }
}
const wunsch = global.audio[0] ?? ''
const tonFremd = wunsch.length > 0 && audio.length > 0 && audio[0] !== wunsch
const untertitelStandard = tonFremd && untertitel.includes(wunsch)
+89
View File
@@ -0,0 +1,89 @@
// Mehrere Filme auf einer Disc und Ordner mit Filmnamen (5.6.0, Commander:
// „je Titel eigener Film im Dialog" und „die Ordner, die Rippy generiert,
// müssen direkt den Filmnamen haben, z. B. Akira (Jahr)") — PUR, damit die
// Ampel die Regeln prüft:
// * rohOrdnerName: Der Roh-Ordner heißt wie der Film (oder die Serie mit
// Staffel und Disc), bei Wiederholung mit „(2)", „(3)" …; ohne Zuordnung
// bleibt der Zeitstempel-Name des Vorgangs.
// * filmGruppen: Titel, die der Nutzer einem ANDEREN Film zugeordnet hat,
// werden zu eigenen Gruppen — jede bekommt eigenen Roh-Ordner, Vorgang
// und Zielordner. Hat kein Titel die Disc-Zuordnung behalten, wird die
// erste Gruppe zum Hauptvorgang (der Roh-Ordner ist schon da).
// * zuordnungAusVorschlag: Notnagel ohne TMDb-Details.
import type { MetaVorschlag } from '../../gemeinsam/nachrichten'
import { sichererName } from '../../gemeinsam/ablagepfad'
import { LEERER_TREFFER, type Zuordnung } from '../metadaten/zuordnung'
import type { RohDatei } from './auftrag'
export interface DiscAngaben {
staffel: number | null
discNr: number | null
}
export function rohOrdnerName(
zuordnung: Zuordnung | null,
angaben: DiscAngaben | null,
vorgangsName: string,
existiert: (name: string) => boolean,
): string {
if (zuordnung === null || zuordnung.titel.trim().length === 0) return vorgangsName
let basis: string
if (zuordnung.meta.typ === 'tv') {
basis = sichererName(zuordnung.titel)
if (basis.length > 0 && angaben?.staffel !== null && angaben?.staffel !== undefined) basis += ` - Staffel ${angaben.staffel}`
if (basis.length > 0 && angaben?.discNr !== null && angaben?.discNr !== undefined) basis += ` Disc ${angaben.discNr}`
} else {
basis = sichererName(zuordnung.titel, zuordnung.jahr)
}
if (basis.length === 0) return vorgangsName
if (!existiert(basis)) return basis
for (let n = 2; n < 100; n++) {
const kandidat = `${basis} (${n})`
if (!existiert(kandidat)) return kandidat
}
return vorgangsName
}
export interface FilmGruppe {
film: MetaVorschlag
dateien: RohDatei[]
}
export interface FilmGruppen {
/** Die Dateien des Hauptvorgangs (Disc-Zuordnung). */
primaer: RohDatei[]
/** Gesetzt, wenn KEIN Titel die Disc-Zuordnung behielt: Der Hauptvorgang
* übernimmt diesen Film. */
primaerFilm: MetaVorschlag | null
weitere: FilmGruppe[]
}
export function filmGruppen(dateien: readonly RohDatei[], discFilmId: number | null): FilmGruppen {
const primaer: RohDatei[] = []
const weitere: FilmGruppe[] = []
for (const d of dateien) {
if (d.film === undefined || (d.film.typ === 'movie' && d.film.tmdbId === discFilmId)) {
primaer.push(d)
continue
}
const gruppe = weitere.find((g) => g.film.tmdbId === d.film!.tmdbId && g.film.typ === d.film!.typ)
if (gruppe !== undefined) gruppe.dateien.push(d)
else weitere.push({ film: d.film, dateien: [d] })
}
if (primaer.length === 0 && weitere.length > 0) {
const erste = weitere.shift()!
return { primaer: erste.dateien, primaerFilm: erste.film, weitere }
}
return { primaer, primaerFilm: null, weitere }
}
/** Eine Zuordnung aus dem Vorschlag allein — wenn TMDb-Details nicht zu
* holen sind (kein Netz, kein Schlüssel). Vom Nutzer gewählt = sicher. */
export function zuordnungAusVorschlag(v: MetaVorschlag): Zuordnung {
return {
titel: v.titel,
jahr: v.jahr,
confidence: 1,
meta: { ...LEERER_TREFFER, typ: v.typ, id: v.tmdbId, titel: v.titel, jahr: v.jahr, posterPfad: v.posterPfad, quelle: 'tmdb' },
}
}
+90 -7
View File
@@ -16,9 +16,9 @@
// 5.5.0: das Rettungs-Abbild (rip/rettung.ts) für Discs, an denen Rippy
// Lesefehler GESEHEN hat — erst das Abbild mit Nullen, dann derselbe Rip
// aus dem Abbild (iso:), das nicht mehr hängen kann.
import { mkdirSync, readdirSync, rmSync, statSync, unlinkSync } from 'node:fs'
import { existsSync, mkdirSync, readdirSync, renameSync, rmSync, statSync, unlinkSync } from 'node:fs'
// Plattform-Pfade wie in struktur.ts: Laufzeit-Wurzeln, CI testet auf Linux.
import { join } from 'node:path'
import { basename, join } from 'node:path'
import type {
BibliothekEintrag,
GeraeteInfo,
@@ -38,6 +38,7 @@ import { werkzeugSpawn } from '../werkzeuge/aufruf'
import { mkdtempSync, createWriteStream } from 'node:fs'
import { tmpdir } from 'node:os'
import { gewichteterFortschritt, type KompressionsAuftrag, type RohDatei } from './auftrag'
import { filmGruppen, rohOrdnerName, zuordnungAusVorschlag } from './filme'
interface LaufenderVorgang {
/** Beendet den GANZEN Auftrag. */
@@ -99,6 +100,9 @@ export interface PipelineUmgebung {
/** 5.4.0: Der Rip ist durch — der Kern merkt sich den Vorgang und reiht
* die Kompression ein. Liefert den Platz in der Schlange (1 = jetzt). */
vorgangEinreihen?: (auftrag: KompressionsAuftrag, titel: string) => number
/** 5.6.0: die volle Zuordnung zu einem TMDb-Vorschlag (zweiter Film einer
* Disc) — null, wenn nichts zu holen ist. */
zuordnungFuer?: (vorschlag: NonNullable<RohDatei['film']>) => Promise<Zuordnung | null>
/** 5.4.0: Ein Vorgang beginnt — Datensatz anlegen. */
vorgangBeginnt?: (vorgang: VorgangBeginn) => void
/** 5.4.0: Der Rip endete ohne Auftrag — Fehler oder Abbruch. */
@@ -237,9 +241,21 @@ export class Pipeline {
}
this.letzterAuftrag.set(id, { auftrag: [...auftrag], optionen: { ...optionen } })
const rohOrdner = join(this.umgebung.rohWurzel?.() ?? join(this.umgebung.ablage(), 'roh'), vorgangsName)
mkdirSync(rohOrdner, { recursive: true })
// 5.6.0: Der Roh-Ordner heißt wie der Film (filme.ts) — der Vorgang
// behält seinen Zeitstempel-Namen als Kennung.
const rohWurzel = this.umgebung.rohWurzel?.() ?? join(this.umgebung.ablage(), 'roh')
const zuordnung = this.umgebung.zuordnung?.(id) ?? null
const startAngaben = this.umgebung.discAngaben?.(id) ?? null
const rohOrdner = join(
rohWurzel,
rohOrdnerName(
zuordnung,
{ staffel: optionen.staffel ?? startAngaben?.staffel ?? null, discNr: startAngaben?.discNr ?? null },
vorgangsName,
(name) => existsSync(join(rohWurzel, name)),
),
)
mkdirSync(rohOrdner, { recursive: true })
const anzeigeTitel = zuordnung !== null && zuordnung.titel.length > 0 ? zuordnung.titel : vorgangsName
this.umgebung.vorgangBeginnt?.({
id: vorgangsName,
@@ -476,16 +492,77 @@ export class Pipeline {
groesseBytes: groesseVon(pfad),
audioSprachen: a?.audioSprachen ?? [],
untertitelSprachen: a?.untertitelSprachen ?? [],
...(a?.untertitelVorwahl === null || typeof a?.untertitelVorwahl === 'string' ? { untertitelVorwahl: a.untertitelVorwahl } : {}),
...(a?.film !== undefined ? { film: a.film } : {}),
}
})
const angaben = this.umgebung.discAngaben?.(id) ?? null
// ── 5.6.0: Titel eines ANDEREN Films bekommen eigenen Roh-Ordner und
// Vorgang — jeder Film seinen Ordner (Commander, Double Feature). ──
const gruppen = filmGruppen(dateien, zuordnung !== null && zuordnung.meta.typ === 'movie' ? Number(zuordnung.meta.id) : null)
let hauptDateien = gruppen.primaer
let hauptZuordnung = zuordnung
if (gruppen.primaerFilm !== null) {
hauptZuordnung = (await this.umgebung.zuordnungFuer?.(gruppen.primaerFilm)) ?? zuordnungAusVorschlag(gruppen.primaerFilm)
}
const weitereAuftraege: Array<{ auftrag: Omit<KompressionsAuftrag, 'vorgangId' | 'rohOrdner' | 'dateien' | 'zuordnung'> & Pick<KompressionsAuftrag, 'vorgangId' | 'rohOrdner' | 'dateien' | 'zuordnung'>; titel: string }> = []
for (let n = 0; n < gruppen.weitere.length; n++) {
const gruppe = gruppen.weitere[n]
const gruppenZuordnung = (await this.umgebung.zuordnungFuer?.(gruppe.film)) ?? zuordnungAusVorschlag(gruppe.film)
const gruppenId = `${vorgangsName}-${n + 2}`
const gruppenOrdner = join(rohWurzel, rohOrdnerName(gruppenZuordnung, null, gruppenId, (name) => existsSync(join(rohWurzel, name))))
try {
mkdirSync(gruppenOrdner, { recursive: true })
for (const d of gruppe.dateien) {
const neu = join(gruppenOrdner, basename(d.pfad))
renameSync(d.pfad, neu)
d.pfad = neu
}
} catch (fehler) {
hinweise.push(`${gruppe.film.titel}" bekam keinen eigenen Roh-Ordner (${String(fehler)}) — die Titel bleiben beim ersten Film.`)
hauptDateien = [...hauptDateien, ...gruppe.dateien]
continue
}
this.umgebung.vorgangBeginnt?.({
id: gruppenId,
laufwerk: id,
typ: geraet.typ,
titel: gruppenZuordnung.titel,
jahr: gruppenZuordnung.jahr,
rohOrdner: gruppenOrdner,
begonnen: startZeit.toISOString(),
})
weitereAuftraege.push({
titel: gruppenZuordnung.titel,
auftrag: {
vorgangId: gruppenId,
laufwerk: id,
discTyp: geraet.typ,
rohOrdner: gruppenOrdner,
dateien: gruppe.dateien,
zuordnung: gruppenZuordnung,
staffel: null,
ersteFolge: null,
discNr: angaben?.discNr ?? null,
fingerprint: null,
lesefehler: lesefehlerGesamt,
hinweise: [],
begonnen: startZeit.toISOString(),
preset: '',
zielOrdner: '',
},
})
}
const hauptTitel = hauptZuordnung !== null && hauptZuordnung.titel.length > 0 ? hauptZuordnung.titel : anzeigeTitel
const kompression: KompressionsAuftrag = {
vorgangId: vorgangsName,
laufwerk: id,
discTyp: geraet.typ,
rohOrdner,
dateien,
zuordnung,
dateien: hauptDateien,
zuordnung: hauptZuordnung,
staffel: optionen.staffel ?? angaben?.staffel ?? null,
ersteFolge: optionen.ersteFolge ?? null,
discNr: angaben?.discNr ?? null,
@@ -496,7 +573,12 @@ export class Pipeline {
preset: '',
zielOrdner: '',
}
const platz = this.umgebung.vorgangEinreihen?.(kompression, anzeigeTitel) ?? 0
const platz = this.umgebung.vorgangEinreihen?.(kompression, hauptTitel) ?? 0
const weitereTitel: string[] = []
for (const w of weitereAuftraege) {
this.umgebung.vorgangEinreihen?.(w.auftrag, w.titel)
weitereTitel.push(w.titel)
}
const rohBytes = dateien.reduce((s, d) => s + d.groesseBytes, 0)
// Das Laufwerk ist FREI — die Kompression läuft ohne es weiter.
@@ -511,6 +593,7 @@ export class Pipeline {
(platz > 0
? `Kompression eingereiht${platz > 1 ? ` (Platz ${platz})` : ''}, das Laufwerk ist frei.`
: 'Kompression nicht eingereiht (kein Empfänger) — die Roh-Dateien liegen unter ' + rohOrdner + '.') +
(weitereTitel.length > 0 ? ` Weitere${weitereTitel.length === 1 ? 'r' : ''} Film${weitereTitel.length === 1 ? '' : 'e'} mit eigenem Vorgang: ${weitereTitel.join(', ')}.` : '') +
(lesefehlerGesamt
? ' ⚠ Die Disc ließ sich stellenweise nicht lesen — mindestens ein Titel könnte fehlen (makemkv.com/errors/read).'
: '') +
@@ -153,6 +153,14 @@ export class AutomatikDienst {
if (grund.length > 0) this.melden(id, false, 0, grund)
}
/** Der Kasten in der Kachel verschwindet — ein Rip hat begonnen (5.6.0):
* Countdown weg, Text weg. Ohne das stand „Automatik gestoppt — du
* entscheidest." noch unter dem fertigen Rip. */
leeren(id: string): void {
this.stopp(id, '')
if (this.staende.has(id)) this.melden(id, false, 0, '')
}
/** „Jetzt": den Countdown überspringen. */
jetzt(id: string): void {
const stand = this.staende.get(id)
@@ -36,6 +36,7 @@ export const ERLAUBTE_EINSTELLUNGEN: ReadonlySet<string> = new Set([
'discordWebhook',
'telegramToken',
'telegramChatId',
'design',
])
/** Die Schlüssel, die Rip und Kompression lesen (Pipeline/Ablage). Das
+34 -23
View File
@@ -238,32 +238,43 @@ export class ErkennungDienst {
}
}
/** Aus einem TMDb-Vorschlag die volle Zuordnung (Details holen) — für
* „Ändern …" und seit 5.6.0 für den zweiten Film einer Disc. Vom Nutzer
* gewählt = sicher (confidence 1). Ohne Details bleibt der Vorschlag. */
async zuordnungAus(vorschlag: MetaVorschlag): Promise<Zuordnung> {
const tmdb = this.tmdb()
let details = null
try {
details = vorschlag.typ === 'movie' ? await tmdb.movieDetails(vorschlag.tmdbId) : await tmdb.tvDetails(vorschlag.tmdbId)
} catch (fehler) {
this.kontext.fehler(`TMDb-Details für „${vorschlag.titel}": ${String(fehler)} — Zuordnung ohne Details.`)
}
const meta =
details !== null
? ausDetails(vorschlag.typ, vorschlag.tmdbId, details)
: {
...LEERER_TREFFER,
typ: vorschlag.typ,
id: vorschlag.tmdbId,
titel: vorschlag.titel,
jahr: vorschlag.jahr,
posterPfad: vorschlag.posterPfad,
quelle: 'tmdb' as const,
}
return {
titel: meta.titel.length > 0 ? meta.titel : vorschlag.titel,
jahr: meta.jahr ?? vorschlag.jahr,
// Vom Nutzer gewählt = sicher. Genau dafür ist die Frage da (§ 5).
confidence: 1,
meta,
}
}
async zuordnungSetzen(id: string, vorschlag: MetaVorschlag): Promise<void> {
try {
const tmdb = this.tmdb()
const details =
vorschlag.typ === 'movie' ? await tmdb.movieDetails(vorschlag.tmdbId) : await tmdb.tvDetails(vorschlag.tmdbId)
const meta =
details !== null
? ausDetails(vorschlag.typ, vorschlag.tmdbId, details)
: {
...LEERER_TREFFER,
typ: vorschlag.typ,
id: vorschlag.tmdbId,
titel: vorschlag.titel,
jahr: vorschlag.jahr,
posterPfad: vorschlag.posterPfad,
quelle: 'tmdb' as const,
}
const zuordnung: Zuordnung = {
titel: meta.titel.length > 0 ? meta.titel : vorschlag.titel,
jahr: meta.jahr ?? vorschlag.jahr,
// Vom Nutzer gewählt = sicher. Genau dafür ist die Frage da (§ 5).
confidence: 1,
meta,
}
const zuordnung = await this.zuordnungAus(vorschlag)
this.zuordnungen.set(id, zuordnung)
this.kontext.protokoll.schreiben(`Zuordnung ${id} vom Nutzer: „${zuordnung.titel}" (${meta.typ} ${String(meta.id)})`)
this.kontext.protokoll.schreiben(`Zuordnung ${id} vom Nutzer: „${zuordnung.titel}" (${zuordnung.meta.typ} ${String(zuordnung.meta.id)})`)
this.kontext.anHauptUndFenster({ art: 'disc-info', info: this.discInfoStand(id, zuordnung, 'deine Wahl') })
} catch (fehler) {
this.kontext.fehler(`Zuordnung übernehmen: ${String(fehler)}`)
+4 -1
View File
@@ -147,6 +147,7 @@ ripVerwaltung = new Pipeline({
zuordnung: (id) => erkennung.zuordnung(id),
discAngaben: (id) => erkennung.discAngaben(id),
fingerprint: (id) => erkennung.fingerprint(id),
zuordnungFuer: (vorschlag) => erkennung.zuordnungAus(vorschlag),
bibliothekEintragen: (eintrag) => vorgaenge.bibliothekEintragen(eintrag),
vorgangBeginnt: (v) => vorgaenge.vorgangBeginnt(v),
vorgangEinreihen: (auftrag, titel) => vorgaenge.vorgangEinreihen(auftrag, titel),
@@ -192,6 +193,8 @@ function ripMitPlatz(id: string, auftrag: RipAuftragTitel[], optionen: RipAuftra
}
if (urteil.text.length > 0) kontext.hinweis(`Platz im Arbeitsordner: ${urteil.text}`)
}
// Der Rip beginnt — der Automatik-Kasten in der Kachel hat ausgedient.
automatik.leeren(id)
void laufwerke.bereit.then(() => ripVerwaltung.starten(id, auftrag, optionen))
}
@@ -229,7 +232,7 @@ function fensterNachricht(frage: unknown): void {
ripVerwaltung.ueberspringen(frage.id)
return
case 'rettung-start':
automatik.stopp(frage.id, '')
automatik.leeren(frage.id)
void laufwerke.bereit.then(() => ripVerwaltung.rettungStarten(frage.id))
return
case 'metadaten-suchen':
@@ -72,6 +72,20 @@ export function buildHandbrakeArgs(optionen: HandbrakeArgsOptionen): string[] {
// Encoding: … Searching for start time, %.2f → Vorlauf, NICHT Encode
const HB_ENCODE = /^Encoding:\s*task\s+(\d+)\s+of\s+(\d+),\s*(\d+(?:\.\d+)?)\s*%/
// 5.6.0 ETA — Formatstring aus dem Binary gelesen (HandBrakeCLI 1.11.2,
// grep -a, 02.09.2026):
// Encoding: task %d of %d, %.2f %% (%.2f fps, avg %.2f fps, ETA %02dh%02dm%02ds)
// Die Klammer fehlt in den ersten Sekunden (noch keine fps) — dann -1.
const HB_ETA = /ETA ([0-9]+)h([0-9]+)m([0-9]+)s/
/** Restzeit in Sekunden aus einer Encoding-Zeile, -1 ohne Angabe. */
export function etaAusZeile(zeile: string): number {
if (!HB_ENCODE.test(zeile.trim())) return -1
const t = HB_ETA.exec(zeile)
if (t === null) return -1
return Number(t[1]) * 3600 + Number(t[2]) * 60 + Number(t[3])
}
/** Encode-Fortschritt 0100 oder -1 („keine Angabe" — ein echtes 0 % muss
* von „nichts" unterscheidbar bleiben). Mehrere Durchläufe (task N of M)
* werden zusammengerechnet, sonst springt die Anzeige zurück auf 0. */
@@ -122,11 +136,12 @@ export function dateiDaneben(erwartet: string, existiert: (p: string) => boolean
/** Auswertung des Laufs — pur, zeilenweise, testbar ohne HandBrake. */
export class HbAuswertung {
prozent = -1
restS = -1
falschesPreset = ''
falscherSchalter = ''
letzteZeilen: string[] = []
verarbeite(zeile: string): { prozent?: number } {
verarbeite(zeile: string): { prozent?: number; restS?: number } {
if (zeile.trim().length > 0) {
this.letzteZeilen.push(zeile)
if (this.letzteZeilen.length > HB_ZEILEN_PUFFER) this.letzteZeilen.shift()
@@ -134,9 +149,13 @@ export class HbAuswertung {
if (this.falschesPreset.length === 0) this.falschesPreset = unbekanntesPreset(zeile)
if (this.falscherSchalter.length === 0) this.falscherSchalter = unbekannterSchalter(zeile)
const p = progressAusZeile(zeile)
if (p >= 0 && p !== this.prozent) {
this.prozent = p
return { prozent: p }
if (p >= 0) {
const rest = etaAusZeile(zeile)
if (p !== this.prozent || (rest >= 0 && rest !== this.restS)) {
this.prozent = p
this.restS = rest
return rest >= 0 ? { prozent: p, restS: rest } : { prozent: p }
}
}
return {}
}
@@ -245,7 +264,8 @@ export interface HbLaufOptionen {
untertitelStandard?: boolean
/** Zusätzliche Schalter ANS ENDE (z. B. --stop-at duration:5 im Messlauf). */
extraArgs?: readonly string[]
aufFortschritt?: (prozent: number) => void
/** Fortschritt 0100 und Restzeit in Sekunden (-1 = noch keine Angabe). */
aufFortschritt?: (prozent: number, restS: number) => void
abbruch?: AbortSignal
}
@@ -267,7 +287,7 @@ export function komprimieren(optionen: HbLaufOptionen): Promise<HbErgebnis> {
const zeile = (z: string): void => {
const ergebnis = auswertung.verarbeite(z)
if (ergebnis.prozent !== undefined) optionen.aufFortschritt?.(ergebnis.prozent)
if (ergebnis.prozent !== undefined) optionen.aufFortschritt?.(ergebnis.prozent, ergebnis.restS ?? -1)
}
kind.stdout.on('data', (stueck: Buffer) => leser.hinzu(stueck).forEach(zeile))
kind.stderr.on('data', (stueck: Buffer) => leser.hinzu(stueck).forEach(zeile))
+4 -4
View File
@@ -121,7 +121,7 @@ describe('komprimierenUndAblegen', () => {
expect(ergebnis.komprimiert).toBe(true)
expect(ergebnis.zielOrdner).toBe(join(ablage, 'Filme', 'Akira (1988)'))
expect(existsSync(join(ergebnis.zielOrdner, 'movie.nfo'))).toBe(true)
expect(ergebnis.dateien).toEqual([join(ergebnis.zielOrdner, 'title_t00.mkv')])
expect(ergebnis.dateien).toEqual([join(ergebnis.zielOrdner, 'Akira (1988).mkv')])
expect(phasen).toContain('komprimiert')
expect(phasen.at(-1)).toBe('legt-ab')
expect(bibliothek).toEqual(['Akira'])
@@ -139,7 +139,7 @@ describe('komprimierenUndAblegen', () => {
expect(ergebnis.status).toBe('fertig')
expect(ergebnis.komprimiert).toBe(false)
expect(ergebnis.text).toContain('ohne Kompression')
expect(existsSync(join(ergebnis.zielOrdner, 'title_t00.mkv'))).toBe(true)
expect(existsSync(join(ergebnis.zielOrdner, 'Akira (1988).mkv'))).toBe(true)
expect(existsSync(join(ergebnis.zielOrdner, 'movie.nfo'))).toBe(true)
expect(readdirSync(roh)).toEqual([])
expect(hb.aufrufe).toHaveLength(0)
@@ -269,8 +269,8 @@ describe('komprimierenUndAblegen', () => {
() => {},
KEIN_ABBRUCH,
)
expect(existsSync(join(ergebnis.zielOrdner, 'title_t00.mkv'))).toBe(true)
expect(existsSync(join(ergebnis.zielOrdner, 'extras', 'title_t05.mkv'))).toBe(true)
expect(existsSync(join(ergebnis.zielOrdner, 'Akira (1988).mkv'))).toBe(true)
expect(existsSync(join(ergebnis.zielOrdner, 'extras', 'Akira (1988) - Extra 01.mkv'))).toBe(true)
})
it('Wiederholen: der vorgegebene Zielordner wird benutzt, kein zweiter angelegt', async () => {
+20
View File
@@ -117,3 +117,23 @@ describe('Auftrag als JSON — und wieder zurück', () => {
expect(b.preset).toBe('')
})
})
describe('sprachwahl — Untertitel-Vorwahl je Extra (5.6.0, immer optional)', () => {
const global = { audio: ['deu', 'eng'], untertitel: ['deu'] }
it('eine gesetzte Vorwahl schlägt die Regel: nach vorn und vorgewählt', () => {
expect(sprachwahl({ audioSprachen: ['deu'], untertitelSprachen: ['eng', 'deu'], untertitelVorwahl: 'deu' }, global)).toEqual({
audio: ['deu'],
untertitel: ['deu', 'eng'],
untertitelStandard: true,
})
})
it('null heißt ausdrücklich keine Vorwahl — auch bei fremdem Ton', () => {
expect(sprachwahl({ audioSprachen: ['jpn'], untertitelSprachen: ['deu'], untertitelVorwahl: null }, global).untertitelStandard).toBe(false)
})
it('fehlend heißt Regel wie bisher', () => {
expect(sprachwahl({ audioSprachen: ['jpn'], untertitelSprachen: ['deu'] }, global).untertitelStandard).toBe(true)
})
it('eine Vorwahl, die nicht in der Liste steht, wird ergänzt', () => {
expect(sprachwahl({ audioSprachen: ['jpn'], untertitelSprachen: ['eng'], untertitelVorwahl: 'deu' }, global).untertitel).toEqual(['deu', 'eng'])
})
})
+12
View File
@@ -187,6 +187,18 @@ describe('AutomatikDienst', () => {
a.tick()
expect(a.gestartet[0].optionen).toEqual({ staffel: 1, ersteFolge: 3 })
})
it('leeren: Countdown weg und ein leerer Stand ans Fenster — der Kasten verschwindet (5.6.0)', async () => {
const a = aufbau(5)
await a.dienst.pruefen(geraet(), zuordnung(0.99), null)
a.dienst.leeren('G')
expect(a.gestopptZahl()).toBe(1)
expect(a.nachrichten[a.nachrichten.length - 1]).toMatchObject({ aktiv: false, sekunden: 0, text: '' })
// Ohne bekannten Stand wird nichts gesendet — kein leerer Kasten aus dem Nichts.
const b = aufbau(5)
b.dienst.leeren('G')
expect(b.nachrichten).toHaveLength(0)
})
it('Disc raus: Countdown weg, Stand weg', async () => {
const a = aufbau(5)
await a.dienst.pruefen(geraet(), zuordnung(0.99), null)
+42
View File
@@ -0,0 +1,42 @@
// Tests der Dateinamen in der Ablage (kern/ablage/dateinamen.ts, 5.6.0).
import { describe, expect, it } from 'vitest'
import { rollenNummern, zielDateiname } from '../src/kern/ablage/dateinamen'
describe('zielDateiname', () => {
const film = { titel: 'Akira', jahr: 1988, alsSerie: false }
it('ein Hauptfilm heißt wie der Film', () => {
expect(zielDateiname({ ...film, rolle: 'hauptfilm', nummer: 1, anzahl: 1, original: 'title_t00.mkv' })).toBe('Akira (1988).mkv')
})
it('zwei Hauptfilme werden Fassungen', () => {
expect(zielDateiname({ ...film, rolle: 'hauptfilm', nummer: 1, anzahl: 2, original: 'title_t00.mkv' })).toBe('Akira (1988) - Fassung 1.mkv')
expect(zielDateiname({ ...film, rolle: 'hauptfilm', nummer: 2, anzahl: 2, original: 'title_t01.mkv' })).toBe('Akira (1988) - Fassung 2.mkv')
})
it('Extras werden nummeriert', () => {
expect(zielDateiname({ ...film, rolle: 'extra', nummer: 3, anzahl: 5, original: 'title_t07.mkv' })).toBe('Akira (1988) - Extra 03.mkv')
})
it('die Endung bleibt, was MakeMKV schrieb', () => {
expect(zielDateiname({ ...film, rolle: 'hauptfilm', nummer: 1, anzahl: 1, original: 'x.mp4' })).toBe('Akira (1988).mp4')
})
it('ohne Jahr ohne Klammer', () => {
expect(zielDateiname({ ...film, jahr: null, rolle: 'hauptfilm', nummer: 1, anzahl: 1, original: 'a.mkv' })).toBe('Akira.mkv')
})
it('Serie: Folgen behalten den Namen (die Episoden-Zuordnung benennt später), Extras heißen nach der Serie', () => {
const serie = { titel: 'Spartacus', jahr: 2010, alsSerie: true }
expect(zielDateiname({ ...serie, rolle: 'folge', nummer: 1, anzahl: 4, original: 'title_t02.mkv' })).toBe('title_t02.mkv')
expect(zielDateiname({ ...serie, rolle: 'extra', nummer: 1, anzahl: 1, original: 'title_t09.mkv' })).toBe('Spartacus - Extra 01.mkv')
})
it('ohne erkannten Titel bleibt der MakeMKV-Name', () => {
expect(zielDateiname({ titel: '', jahr: null, alsSerie: false, rolle: 'hauptfilm', nummer: 1, anzahl: 1, original: 'title_t00.mkv' })).toBe('title_t00.mkv')
})
})
describe('rollenNummern', () => {
it('zählt je Rolle in Reihenfolge und kennt die Gesamtzahl', () => {
expect(rollenNummern(['hauptfilm', 'extra', 'extra', 'hauptfilm'])).toEqual([
[1, 2],
[1, 2],
[2, 2],
[2, 2],
])
})
})
+73
View File
@@ -0,0 +1,73 @@
// Tests zu mehreren Filmen je Disc und Ordnern mit Filmnamen
// (kern/ablauf/filme.ts, 5.6.0).
import { describe, expect, it } from 'vitest'
import type { MetaVorschlag } from '../src/gemeinsam/nachrichten'
import type { RohDatei } from '../src/kern/ablauf/auftrag'
import { filmGruppen, rohOrdnerName, zuordnungAusVorschlag } from '../src/kern/ablauf/filme'
import { LEERER_TREFFER, type Zuordnung } from '../src/kern/metadaten/zuordnung'
function zuordnung(titel: string, jahr: number | null, typ: 'movie' | 'tv' = 'movie', id = 1): Zuordnung {
return { titel, jahr, confidence: 0.95, meta: { ...LEERER_TREFFER, typ, id, titel, jahr, quelle: 'tmdb' } }
}
function datei(pfad: string, film?: MetaVorschlag): RohDatei {
return { pfad, titelNr: 0, rolle: 'hauptfilm', dauerS: 6000, groesseBytes: 1e9, audioSprachen: [], untertitelSprachen: [], ...(film !== undefined ? { film } : {}) }
}
const akira: MetaVorschlag = { typ: 'movie', tmdbId: 149, titel: 'Akira', jahr: 1988, posterPfad: '/a.jpg' }
const ghost: MetaVorschlag = { typ: 'movie', tmdbId: 9323, titel: 'Ghost in the Shell', jahr: 1995, posterPfad: '' }
describe('rohOrdnerName', () => {
it('Film: „Titel (Jahr)"', () => {
expect(rohOrdnerName(zuordnung('Akira', 1988), null, 'bluray-2026', () => false)).toBe('Akira (1988)')
})
it('Serie: Titel, Staffel und Disc', () => {
expect(rohOrdnerName(zuordnung('Spartacus', 2010, 'tv'), { staffel: 1, discNr: 2 }, 'bluray-2026', () => false)).toBe('Spartacus - Staffel 1 Disc 2')
expect(rohOrdnerName(zuordnung('Spartacus', 2010, 'tv'), { staffel: null, discNr: null }, 'bluray-2026', () => false)).toBe('Spartacus')
})
it('bei Wiederholung mit Zusatz (2), (3) …', () => {
const da = new Set(['Akira (1988)', 'Akira (1988) (2)'])
expect(rohOrdnerName(zuordnung('Akira', 1988), null, 'bluray-2026', (n) => da.has(n))).toBe('Akira (1988) (3)')
})
it('ohne Zuordnung oder ohne brauchbaren Namen bleibt der Vorgangsname', () => {
expect(rohOrdnerName(null, null, 'bluray-2026', () => false)).toBe('bluray-2026')
expect(rohOrdnerName(zuordnung(' ', null), null, 'bluray-2026', () => false)).toBe('bluray-2026')
expect(rohOrdnerName(zuordnung('???', null), null, 'bluray-2026', () => false)).toBe('bluray-2026')
})
it('Sonderzeichen werden entschärft', () => {
expect(rohOrdnerName(zuordnung('Alien: Covenant', 2017), null, 'x', () => false)).not.toContain(':')
})
})
describe('filmGruppen', () => {
it('ohne Film-Zuordnung je Titel bleibt alles beim Hauptvorgang', () => {
const g = filmGruppen([datei('a'), datei('b')], 149)
expect(g.primaer).toHaveLength(2)
expect(g.primaerFilm).toBeNull()
expect(g.weitere).toHaveLength(0)
})
it('ein Titel, der zur Disc-Zuordnung passt, zählt als Hauptvorgang; ein anderer Film wird eigene Gruppe', () => {
const g = filmGruppen([datei('a', akira), datei('b', ghost), datei('c', ghost)], 149)
expect(g.primaer.map((d) => d.pfad)).toEqual(['a'])
expect(g.weitere).toHaveLength(1)
expect(g.weitere[0].film.titel).toBe('Ghost in the Shell')
expect(g.weitere[0].dateien.map((d) => d.pfad)).toEqual(['b', 'c'])
})
it('hat kein Titel die Disc-Zuordnung behalten, wird die erste Gruppe zum Hauptvorgang', () => {
const g = filmGruppen([datei('a', ghost), datei('b', akira)], 42)
expect(g.primaerFilm?.titel).toBe('Ghost in the Shell')
expect(g.primaer.map((d) => d.pfad)).toEqual(['a'])
expect(g.weitere.map((w) => w.film.titel)).toEqual(['Akira'])
})
})
describe('zuordnungAusVorschlag', () => {
it('ist sicher (vom Nutzer) und trägt Titel, Jahr, Poster', () => {
const z = zuordnungAusVorschlag(akira)
expect(z.confidence).toBe(1)
expect(z.titel).toBe('Akira')
expect(z.meta.id).toBe(149)
expect(z.meta.posterPfad).toBe('/a.jpg')
expect(z.meta.typ).toBe('movie')
})
})
+19
View File
@@ -10,6 +10,7 @@ import {
hbSchluss,
HbZeilenLeser,
progressAusZeile,
etaAusZeile,
unbekannterSchalter,
unbekanntesPreset,
} from '../src/kern/komprimieren/handbrake'
@@ -201,3 +202,21 @@ describe('buildHandbrakeArgs — Untertitel vorwählen (5.4.0, am HandBrake 1.11
expect(args).not.toContain('--subtitle-default=1')
})
})
describe('etaAusZeile — Restzeit aus der Encoding-Zeile (5.6.0, Format aus dem Binary)', () => {
it('liest Stunden, Minuten, Sekunden', () => {
expect(etaAusZeile('Encoding: task 1 of 1, 42.57 % (89.31 fps, avg 90.05 fps, ETA 00h12m34s)')).toBe(12 * 60 + 34)
expect(etaAusZeile('Encoding: task 2 of 2, 10.00 % (5.00 fps, avg 5.00 fps, ETA 01h00m05s)')).toBe(3605)
})
it('ohne Klammer (erste Sekunden) oder außerhalb der Encoding-Zeile: -1', () => {
expect(etaAusZeile('Encoding: task 1 of 1, 0.87 %')).toBe(-1)
expect(etaAusZeile('Scanning title 1 of 1, preview 7, 70.00 % ETA 00h00m01s')).toBe(-1)
})
it('HbAuswertung meldet die Restzeit mit, auch wenn der Prozentwert gleich bleibt', () => {
const a = new HbAuswertung()
expect(a.verarbeite('Encoding: task 1 of 1, 10.00 % (50.00 fps, avg 50.00 fps, ETA 00h10m00s)')).toEqual({ prozent: 10, restS: 600 })
expect(a.verarbeite('Encoding: task 1 of 1, 10.40 % (50.00 fps, avg 50.00 fps, ETA 00h09m59s)')).toEqual({ prozent: 10, restS: 599 })
expect(a.verarbeite('Encoding: task 1 of 1, 10.40 % (50.00 fps, avg 50.00 fps, ETA 00h09m59s)')).toEqual({})
expect(a.verarbeite('Encoding: task 1 of 1, 11.00 %')).toEqual({ prozent: 11 })
})
})
+59
View File
@@ -0,0 +1,59 @@
// Tests der Vorgangs-Leiste (gemeinsam/vorgangsleiste.ts, 5.6.0): welche
// Station fertig, aktiv, offen oder gescheitert ist — aus Rip-Stand,
// Vorgang und Kompressions-Stand.
import { describe, expect, it } from 'vitest'
import { leisteAusKompression, leisteAusRip, leisteAusVorgang, stationen, stationsZustand } from '../src/gemeinsam/vorgangsleiste'
describe('stationen / stationsZustand', () => {
it('vier Stationen, mit Rettung fünf', () => {
expect(stationen(false)).toEqual(['titel', 'rip', 'kompression', 'ablage'])
expect(stationen(true)).toEqual(['titel', 'rettung', 'rip', 'kompression', 'ablage'])
})
it('vor der aktiven fertig, die aktive läuft, danach offen', () => {
expect(stationsZustand(stationen(false), 'rip', 'laeuft')).toEqual(['fertig', 'aktiv', 'offen', 'offen'])
expect(stationsZustand(stationen(false), 'kompression', 'wartet')).toEqual(['fertig', 'fertig', 'aktiv', 'offen'])
})
it('fertig heißt alle gold; Fehler markiert die aktive', () => {
expect(stationsZustand(stationen(false), 'ablage', 'fertig')).toEqual(['fertig', 'fertig', 'fertig', 'fertig'])
expect(stationsZustand(stationen(false), 'kompression', 'fehler')).toEqual(['fertig', 'fertig', 'fehler', 'offen'])
expect(stationsZustand(stationen(true), 'rettung', 'abgebrochen')).toEqual(['fertig', 'fehler', 'offen', 'offen', 'offen'])
})
})
describe('leisteAusRip — die Kachel', () => {
it('Titel-Info, Rip mit Prozent, Rettung als Zwischenstation', () => {
expect(leisteAusRip({ phase: 'liest-info', prozent: -1 }, undefined, 0)).toMatchObject({ station: 'titel', zustand: 'laeuft', rettung: false })
expect(leisteAusRip({ phase: 'rippt', prozent: 62 }, undefined, 0)).toMatchObject({ station: 'rip', zustand: 'laeuft', prozent: 62 })
expect(leisteAusRip({ phase: 'rettet', prozent: 10 }, undefined, 0)).toMatchObject({ station: 'rettung', rettung: true, prozent: 10 })
expect(leisteAusRip({ phase: 'rippt', prozent: 5, rettung: { unlesbareSektoren: 2, stellen: 1 } }, undefined, 0).rettung).toBe(true)
})
it('nach dem Rip zeigt der Vorgang, wo es steht — ohne Prozent (Details in der Karte)', () => {
expect(leisteAusRip({ phase: 'fertig', prozent: 100 }, { phase: 'wartet' }, 2)).toMatchObject({ station: 'kompression', zustand: 'wartet', hinweis: 'Platz 2' })
expect(leisteAusRip({ phase: 'fertig', prozent: 100 }, { phase: 'komprimiert' }, 0)).toMatchObject({ station: 'kompression', zustand: 'laeuft', prozent: -1 })
expect(leisteAusRip({ phase: 'fertig', prozent: 100 }, { phase: 'legt-ab' }, 0)).toMatchObject({ station: 'ablage', zustand: 'laeuft' })
expect(leisteAusRip({ phase: 'fertig', prozent: 100 }, { phase: 'fertig' }, 0)).toMatchObject({ station: 'ablage', zustand: 'fertig' })
expect(leisteAusRip({ phase: 'fertig', prozent: 100 }, { phase: 'fehler' }, 0)).toMatchObject({ station: 'kompression', zustand: 'fehler' })
expect(leisteAusRip({ phase: 'fertig', prozent: 100 }, undefined, 0)).toMatchObject({ station: 'kompression', zustand: 'wartet' })
})
it('Rip-Fehler und Abbruch sitzen an der Rip-Station', () => {
expect(leisteAusRip({ phase: 'fehler', prozent: -1 }, undefined, 0)).toMatchObject({ station: 'rip', zustand: 'fehler' })
expect(leisteAusRip({ phase: 'abgebrochen', prozent: -1 }, undefined, 0)).toMatchObject({ station: 'rip', zustand: 'abgebrochen' })
})
})
describe('leisteAusVorgang — der Verlauf', () => {
it('bildet die Vorgangs-Phase ab; ein Fehler ohne Roh-Bytes war der Rip', () => {
expect(leisteAusVorgang({ phase: 'fertig', rohBytes: 5 })).toMatchObject({ station: 'ablage', zustand: 'fertig' })
expect(leisteAusVorgang({ phase: 'wartet', rohBytes: 5 })).toMatchObject({ station: 'kompression', zustand: 'wartet' })
expect(leisteAusVorgang({ phase: 'fehler', rohBytes: 0 })).toMatchObject({ station: 'rip', zustand: 'fehler' })
expect(leisteAusVorgang({ phase: 'fehler', rohBytes: 9 })).toMatchObject({ station: 'kompression', zustand: 'fehler' })
})
})
describe('leisteAusKompression — die Karte', () => {
it('Komprimieren mit Prozent, Ablegen, wartend', () => {
expect(leisteAusKompression({ phase: 'komprimiert', prozent: 43 })).toMatchObject({ station: 'kompression', zustand: 'laeuft', prozent: 43 })
expect(leisteAusKompression({ phase: 'legt-ab', prozent: 100 })).toMatchObject({ station: 'ablage', zustand: 'laeuft' })
expect(leisteAusKompression({ phase: 'wartet', prozent: -1 })).toMatchObject({ station: 'kompression', zustand: 'wartet' })
})
})