Compare commits

5 Commits
Author SHA1 Message Date
HitonabiandClaude Fable 5.1 6555562a4c docs: SAVEPOINT — 5.4.0 gebaut und lokal installiert, NICHT veroeffentlicht; Uebergabe
Ampel / ampel (push) Successful in 1m54s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-01 22:27:55 +02:00
HitonabiandClaude Fable 5.1 c46a280b1f chore(v5): Version 5.4.0 — Aenderungsnotizen fuer latest.yml
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-01 22:24:19 +02:00
HitonabiandClaude Fable 5.1 6dd1b0bc7a feat(v5): Ein Smoke-Beweis, der KLICKT — Playwright durch das echte Programm
WAS: `npm run klick` startet das ECHTE Rippy (Electron, Haupt, Kern,
Fenster, Datenbank) mit eigenem Profil (--profil, --klick-smoke: ohne
Tray, Updater, Toasts), einem nachgebauten Laufwerk (RIPPY_FAKE_LAUFWERK)
und nachgebauten Werkzeugen (bau/klick/fake-*.cjs) — und klickt sich
durch: Rippen → Dialog → Sprachen je Titel (jpn statt deu) → Rollen auf
Folge → Staffel 1, ab Folge 3 → Vorschau → Rip → Warteschlange →
Kompression → Ablage als S01E03/S01E04 unter Serien\…\Season 01 →
Einstellungen/Roh-Dateien → Roh loeschen → Ereignisse. Bilder liegen in
beweise/klick/. Die Naht dafuer (kern/werkzeuge/aufruf.ts): Zeigt ein
Werkzeug-Pfad auf ein Node-Skript, laeuft es ueber die eigene Laufzeit
(ELECTRON_RUN_AS_NODE) — fuer EXE-Pfade aendert sich nichts.

WARUM: Beide Fehler vom 01.09.2026 waren Verkabelung; 261 Unit-Tests
sahen keinen. Der Klick-Beweis haette beide gefunden.

NEBENBEFUND: Die CSP blockte eingebettete Schrift-Teile (data:-Fonts der
fontsource-Pakete) — Konsolenfehler im gebauten Fenster, jetzt font-src.
Dazu test/messung.spuren.test.ts: HandBrake-Scan einer echten Roh-Datei
auf Verlangen (RIPPY_MESSUNG_MKV).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-01 22:22:04 +02:00
HitonabiandClaude Fable 5.1 e3ce8220f1 feat(v5): Oberflaeche 5.4.0 — Sprachen je Titel, Staffel/Folge, Vorschau, Kino-Banner v2, Roh-Dateien, Warteschlange
WAS: Der Auswahl-Dialog zeigt je Titel die gemessenen Ton- und
Untertitelspuren als Chips (vorbelegt aus der Einstellung, sonst alles,
was der Titel hat), Felder fuer Staffel und erste Folge, und zwei Zeilen
Zielpfad-Vorschau (gemeinsam/ablagepfad.ts — dieselbe Namensregel wie der
Kern). Die Dialoge leben jetzt im App-Zustand statt in der Kachel: ein
leerer Takt der Wache raeumte sonst Kachel UND Dialog ab.
Die Laufwerks-Kachel ist das Kino-Banner v2: Logo statt Text, Tagline,
Zeile Jahr/FSK/Laufzeit/Genre/Bewertung, Darsteller-Streifen, die
Hinweise der Nachpruefung, ein Knopf „Laufwerk pruefen" mit Urteil aus
dem Windows-Protokoll, und nach dem Rip der Stand der Kompression.
Neu in der Uebersicht: die Kompressions-Karte (laeuft/wartet, Abbruch).
Neu in den Einstellungen: „Roh-Dateien" mit Regel, Belegung, jedem
Vorgang (Neu komprimieren mit Preset, Roh loeschen) und Ordnern ohne
Vorgang; der Auswurf-Schalter in „Ablage"; der Datenbank-Pfad unter
„Programm". Das Haupt zeigt die Kompression in Taskleiste und Tray und
laesst Updates auch auf sie warten. Playwright kommt als Bau-Werkzeug
dazu (Commander-Ja nach Regel B; MIT, wird nicht ausgeliefert).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-01 22:13:22 +02:00
HitonabiandClaude Fable 5.1 3f7f962dff feat(v5): Kern 5.4.0 — Vorgaenge, Kompressions-Warteschlange, Roh-Aufraeumen, Sprachen je Titel, Erkennung v2
WAS: Der Rip endet, sobald die Roh-Dateien liegen; die Kompression laeuft
danach in einer Warteschlange (eine zur Zeit), das Laufwerk ist frei fuer
die naechste Disc. Jeder Rip ist jetzt ein Datensatz (Tabelle vorgaenge,
Schema 4): Roh-Ordner, Zielordner, Phase, Auftrag als JSON. Darauf bauen:
Roh-Aufraeumen nach Regel (Vorgabe 7 Tage, nur bei gelungener Kompression
und nur wenn jede fertige Datei da und nicht leer ist), Wiederholen ab
Kompression, Aufraeumen halber Dateien nach Abbruch/Fehler, Wiederaufnahme
nach Neustart. Unkomprimierte Rips (4K, kein HandBrake) werden in den
Zielordner VERSCHOBEN statt unter roh\ liegen zu bleiben.
Sprachen je Titel reisen vom Info-Lauf (SINFO) bis in HandBrake; fremder
Ton schaltet den deutschen Untertitel vor (--subtitle-default=1). Vor der
Kompression prueft ein HandBrake-Scan die Roh-Datei. MakeMKVs
Auswahlregel wird gesetzt, wenn keine steht, damit alle Sprachen in die
Roh-Datei kommen. Staffel und erste Folge von Hand schlagen die
Laufzeit-Raterei. Durchgehender Fortschritt, nach Groesse gewichtet.
Erkennung v2: leere TMDb-Eintraege (Stubs) zaehlen nicht, Laufzeit wird
nach dem Info-Lauf gegen TMDb gehalten, Titel-Struktur als Fingerabdruck,
bdmt_deu als zweite Titel-Variante. TMDb liefert Logo, Tagline,
Bewertung, FSK und Besetzung in derselben Anfrage. Laufwerks-Gesundheit
aus dem Windows-Ereignisprotokoll (wevtutil): Disc oder Hardware.

WARUM: Uebergabe-Punkte 1-5, 8-12 des SAVEPOINT vom 01.09.2026 — vom
Commander gutgeheissen (7 Tage / Untertitel vorgewaehlt / entkoppelte
Kompression waren seine Wahl).

GEMESSEN (Regel D): HandBrake 1.11 Sprach-Schalter an einer ffmpeg-
Testdatei (jpn/deu/deu/eng + deu/eng), HandBrake --scan --json Format,
TMDb movie/149 + tv/46296 mit append_to_response, wevtutil /f:xml an
diesem PC (338x cdrom 7, 8x cdrom 11 in 30 Tagen), makemkvcon --minlength,
MakeMKV-Auswahlregel laut forum.makemkv.com t=4386.

348 Tests gruen (vorher 261), Typpruefung und Bau gruen. Oberflaeche
folgt im naechsten Schritt.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-01 22:02:03 +02:00
55 changed files with 6934 additions and 1633 deletions
+187 -1
View File
@@ -1,6 +1,192 @@
# SAVEPOINT — Rippy
## ÜBERGABE an die nächste Session (01.09.2026, Sessionende)
## ÜBERGABE an die nächste Session (01.09.2026, spät — 5.4.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:** `npm install` + `node node_modules/electron/install.js` (npm 11
blockt Electrons Install-Skript). **Playwright ist seit 5.4.0 devDependency**
(Commander-Ja nach Regel B; MIT; beim Installieren
`PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1` setzen — Electron braucht keinen
Browser-Download). `vendor/` aus `%LOCALAPPDATA%\Rippy\tools` befüllen.
* **Die drei Läufe:** `npm test` (351 Tests), `npm run smoke` (Gerüst),
**`npm run klick`** (NEU: der Klick-Beweis durch das echte Programm mit
nachgebauten Werkzeugen, Bilder in `beweise/klick/`). Alle drei sind
in dieser Session grün gewesen.
* **Release veröffentlichen — NUR nach dem Wort des Commanders
(„Update freigegeben"):** die VIER Dateien im Gitea-Tag `aktuell`
ERSETZEN (`RippySetup-5.4.0.exe`, `.blockmap`, `latest.yml`; `w0.yml`
bleibt liegen). Dateien liegen in `rippy-windows/dist-setup/` (Kopie)
und `rippy-windows/dist-setup-540/` (Original). Token:
`F:\Coding Stuff\Gittea Token\rippy.txt` (40 Hex; über das
PowerShell-Werkzeug lesen). Weg wie am 01.09.: LAN-Adresse
`http://192.168.178.153:3000/api/v1/…/releases/1`, `latest.yml` erst
löschen (204), dann hochladen (201), danach ANONYM über die externe
HTTPS-Adresse gegenmessen.
### Stand
**5.4.0 ist gebaut, am ausgepackten Paket bewiesen und auf DIESEM PC
installiert (22:23), NICHT im Kanal.** Der Commander wollte es ausdrücklich
so: erst lokal, dann testen, dann veröffentlichen.
* Setup: `RippySetup-5.4.0.exe`, **131 546 293 Bytes**, SHA-256
`18A7BE8F6197D0933BF9AC8FA18C463474D99EE5B259DF960EC277CD135346C1`.
* Paket-Smoke: `SMOKE: OK — fenster=geladen kern=pid:10740,node:24.18.1
datenbank=ok leine=gesetzt pong=ok version=5.4.0`.
* **351 Tests** in 34 Dateien (vorher 261), Typprüfung und Bau grün.
* Klick-Beweis: 15 Schritte grün, Ausgang 0 — Rippen → Dialog → drei
Titel → Sprach-Chips → jpn statt deu → „Hauptinhalt + Extras" → Rollen
auf Folge → Staffel 1, ab Folge 3 → Vorschau `Serien\…\Season 01` →
RIPPT → „Kompression eingereiht, das Laufwerk ist frei" → Kompressions-
Karte → „Komprimiert und abgelegt" → Dateien `… S01E03.mkv` und
`… S01E04.mkv` unter `Season 01` → Roh-Dateien-Karte → Roh löschen →
Ereignisse. Bilder: `beweise/klick/01…05*.png`.
* Commits dieser Session: `3f7f962` (Kern), `e3ce822` (Oberfläche),
`6dd1b0b` (Klick-Beweis), `c46a280` (Version 5.4.0), dazu der SAVEPOINT.
### Was 5.4.0 enthält — alle zwölf Punkte der vorigen Übergabe
Der Commander hat gefragt „Traust du dir alles auf einmal zu?" und die
Fassung als EINE gewollt. Seine Entscheidungen: Roh-Dateien **nach 7 Tagen,
nur bei gelungener Kompression**; Untertitel bei fremdem Ton **als Spur,
beim Abspielen vorgewählt**; Warteschlange = **nächste Disc rippen, während
die vorige komprimiert**; **Playwright ja**.
1. **Roh-Dateien aufräumen** — `kern/ablage/rohaufraeumen.ts`: Regel
(`rohAufbewahrung`: sofort / 7 / 14 / 30 Tage / behalten), Frist ab
Ende der Kompression, gelöscht wird NUR bei Phase fertig + komprimiert
+ jede fertige Datei vorhanden und nicht leer. Ordner unter `roh\`, die
kein Vorgang kennt (alles aus der Zeit vor 5.4.0!), werden nur gezeigt.
Belegung in Einstellungen → Roh-Dateien. Takt: 60 s nach Start, täglich,
und nach jeder gelungenen Kompression.
2. **Staffel und Folge von Hand** — Felder im Dialog, sobald eine Rolle
„Folge" ist; `ersteFolge` zählt fortlaufend in Disc-Reihenfolge, TMDb
warnt nur noch (`folgenPlausibel`).
3. **Wiederholen ab Kompression** — jeder Rip ist ein Datensatz
(Tabelle `vorgaenge`, Schema 4, Auftrag als JSON). „Neu komprimieren"
mit Preset-Wahl reiht den alten Auftrag wieder ein, die neue Fassung
ersetzt die alte im selben Zielordner.
4. + 12. **Sprachen je Titel** — SINFO-Sprachen reisen je Titel in den
Dialog (Chips, vorbelegt aus der Einstellung, sonst alles Vorhandene),
von dort in `RipAuftragTitel.audioSprachen/untertitelSprachen`, in den
Kompressions-Auftrag und in HandBrake. `sprachwahl()`: erste Tonspur
nicht in der Wunschsprache → Untertitel der Wunschsprache nach vorn und
`--subtitle-default=1`. Vor der Kompression prüft ein HandBrake-Scan
(`komprimieren/scan.ts`) die Roh-Datei; MakeMKVs Auswahlregel wird
gesetzt, wenn keine steht (`werkzeuge/spurauswahl.ts`).
5. **Vorschau**`gemeinsam/ablagepfad.ts`, zwei Zeilen im Dialog.
6. **Warteschlange**`kern/ablauf/warteschlange.ts` + `ablegen.ts`:
Rip endet mit den Roh-Dateien, Laufwerk frei, Auswurf (Einstellung
`autoAuswurf`, Vorgabe an), Kompression eine zur Zeit im Hintergrund,
Karte in der Übersicht, Abbruch je Auftrag, Wiederaufnahme nach Neustart.
Unkomprimierte Rips (4K, kein HandBrake) werden in den Zielordner
VERSCHOBEN — vorher blieben sie unter `roh\` liegen, ohne NFO, Poster,
Bibliothek.
7. **Klick-Beweis**`npm run klick` (siehe oben), Naht
`kern/werkzeuge/aufruf.ts`, Fakes `bau/klick/`.
8. **Laufwerks-Gesundheit**`kern/laufwerk/ereignisse.ts` liest
`wevtutil qe System /q:<XPath> /f:xml` (cdrom/disk, 24 h), Urteil
Disc/Hardware/ruhig; automatisch nach einem gescheiterten Rip, sonst
Knopf „Laufwerk prüfen".
9. **Durchgehender Fortschritt**`gewichteterFortschritt()` nach
Titel-/Dateigröße, Rip und Kompression.
10. **Aufräumen nach Abbruch** — halbe Dateien eines abgebrochenen ODER
gescheiterten Titels werden entfernt; ein Roh-Ordner ohne eine einzige
brauchbare Datei auch.
11. **Erkennung v2 + Kino-Banner v2**`zuordnung.nachpruefen()` nach dem
Info-Lauf (Laufzeit ±6 % bestätigt → 0,95; ≥ 25 % daneben → Vorschlag),
`strukturHinweis()` (≥ 3 gleich lange Titel = Serie), Stubs zählen nicht
(`istStub`), `bdmt_deu` als zweite Titel-Variante. TMDb-Details holen
per `append_to_response` Logo, Tagline, Bewertung, FSK (DE), Besetzung.
Kachel zeigt Logo statt Text, Meta-Zeile, Darsteller-Streifen, Hinweise.
Dazu aus der Liste „auch noch offen": Dialog-Zustand aus der Kachel
herausgehoben (Dialoge leben im App-Zustand), `7c489f8` ist mit drin,
Datenbank-Pfad steht unter Einstellungen → Programm, CSP `font-src data:`
(eingebettete Schrift-Teile waren blockiert — Konsolenfehler, unsichtbar).
### ⚠ Der Datenbank-Befund — ungeklärt, bitte als Erstes prüfen
Auf diesem PC (Konto TobisPC, einziges Konto) lag unter
`%APPDATA%\Rippy\rippy.db` bis 21:13 eine Datenbank von **Schema 1 vom
30.08. 16:50** ohne eine einzige Einstellung — obwohl 5.3.2 hier
installiert war, der Updater am 01.09. 20:01/20:27 Dateien lud und der
Commander Einrichtung, Erkennung und Rips bestätigt hat. Ein 10-s-Start
des installierten Rippy durch mich schrieb genau in diese Datei (Schema 3,
`letzter_start`). Kein anderes `rippy.db` auf C:/D:/E:/F: ist seit dem
30.08. geschrieben worden. Der Commander sagt: „Ja, alles bleibt
erhalten" (Einstellungen überleben den Neustart, Datenbank-LED grün).
Beides zusammen geht nicht auf — ich konnte es nicht auflösen.
**Was beim Commander-Test zu beobachten ist:** Erscheint beim Start von
5.4.0 die Erst-Einrichtung, dann IST die Roaming-Datenbank die lebende und
sie war leer — einmal neu einrichten (TMDb-Key liegt in
`F:\Coding Stuff\Gittea Token\tmdb.txt`), danach bleibt es. Erscheint sie
nicht, steht unter Einstellungen → Programm jetzt der echte Pfad
(„Datenbank: …") — den bitte notieren. Die Roh-Dateien-Karte zeigt
außerdem seine wirkliche Ablage und Belegung.
### Was am Gerät noch zu messen ist
* **MakeMKV-Spurauswahl an einer echten Disc:** nach dem nächsten Rip
`RIPPY_MESSUNG_MKV="<roh-datei>" npx vitest run test/messung.spuren.test.ts
--reporter=verbose` — zeigt, welche Sprachen wirklich in der Roh-Datei
sind. Die Regel `app_DefaultSelectionString` setzt Rippy beim Start
(Ereignis-Zeile „MakeMKV-Auswahlregel gesetzt"), wenn keine da war.
* Warteschlange mit zwei Discs hintereinander; Auswurf nach dem Rip.
* „Laufwerk prüfen" am BU40N (das Protokoll hat 338 × cdrom 7 und
8 × cdrom 11 in 30 Tagen — es wird etwas zeigen).
* Hero-Banner mit TMDb-Key (Logo, FSK, Besetzung) — im Klick-Beweis ohne
Key nicht sichtbar. Vorsicht bei „Spartacus: Gods of the Arena": TMDb
führt es als Extras-Staffel (S0) von „Spartacus" (46296); die direkten
Treffer sind leere Stubs und zählen seit 5.4.0 nicht mehr.
* Roh-Aufräumen nach 7 Tagen ist frühestens ab dem 08.09. beobachtbar;
die Regel „sofort" lässt sich sofort prüfen.
* Weiter offen wie zuvor: `extras/`-Ordner in Jellyfin bestätigen,
DVD-/Audio-CD-Durchlauf, main-Merge.
### Messungen dieser Session (Regel D)
* **HandBrake 1.11** (ffmpeg-Testdatei jpn/deu/deu/eng + deu/eng-Untertitel,
Ausgabe per ffprobe): `--audio-lang-list deu,eng` = je Sprache die
erste Spur; `--first-audio` dasselbe; `--all-audio` alle passenden;
Untertitel-Ausgabe in LISTEN-Reihenfolge; `--subtitle-default=1` setzt
default UND forced; `-N deu` allein fügt keine Spur hinzu.
`--scan --json` schreibt „JSON Title Set:" + Objekt + weiteren Text.
* **TMDb** (v4-Token): `movie/149?append_to_response=images,credits,
release_dates&include_image_language=de,en,null` liefert tagline,
runtime, vote_average, images.logos (iso_639_1 de/en/null, .png/.svg),
credits.cast (name, character, profile_path, order), release_dates DE
certification „16" (type 3 Kino, 5 Heimvideo, 6 TV). `tv/46296`:
episode_run_time [53], content_ratings DE „18", seasons. Logo-Größen
w45…w500, Profil w45/w185/h632.
* **wevtutil** (`/q:` XPath, `/f:xml`, `/rd:true`): cdrom 7 = fehlerhafter
Block, 11 = Controllerfehler, 51 = E/A-Fehler; TimeCreated UTC. Über Git
Bash zerlegt der Pfad-Konverter das `/q:`-Argument — im Kern geht es
ohne Shell (spawn), das ist der Grund.
* **makemkvcon** nimmt `--minlength=<s>` (nicht in der Kurzhilfe).
* **MakeMKV-Auswahlregel** (forum.makemkv.com, Thema 4386): Standard
`-sel:all,+sel:(favlang|nolang),-sel:(havemulti|havecore),=100:all,-10:favlang`;
favlang „always matches if no favorite language is set".
### Bekannte Schwächen, bewusst in Kauf genommen
* `--subtitle-default=1` markiert die Spur auch als forced (so schreibt es
HandBrakes Muxer). Jellyfin zeigt sie damit; wer sie abschaltet, kann das.
* Die Stub-Regel ist eng: nur Einträge OHNE Beschreibung, Backdrop UND
Bewertung zählen als leer. Als letzter Vorschlag zählt auch ein Stub.
* Die Belegung wird bei jedem Vorgänge-Stand durch einen Ordner-Walk
gemessen — bei sehr vielen Roh-Ordnern dauert das Sekunden.
* Der Klick-Beweis läuft nur unter Windows (Electron + koffi) und nicht in
der Linux-Ampel — wie der Smoke.
---
## Übergabe vom 01.09.2026 (Sessionende, VOR 5.4.0 — die zwölf Punkte sind seit 5.4.0 umgesetzt)
### Wo die nächste Session startet
Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 554 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 554 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

+1
View File
@@ -34,6 +34,7 @@ als npm-Skript hinterlegt: `npm run werkzeug:electron`.
| `npm run build` | Typprüfung (beide tsconfig) + Bau nach `out/` |
| `npm test` | Vitest: Wächter-Tests (§ 4.3), Datenbank, Nachrichten-Schema |
| `npm run smoke` | **Der W-0-Beweis:** baut, startet das echte Programm mit `--smoke`, prüft Fenster + Kern + Datenbank + Leine + Ping/Pong über den MessagePort und endet mit Code 0 (grün) oder 2. `RIPPY_SMOKE_BILD=<pfad.png>` legt zusätzlich einen Screenshot ab. Läuft nur unter Windows (Electron-Binary + koffi). |
| `npm run klick` | **Der Klick-Beweis (5.4.0):** baut, startet das echte Programm mit eigenem Profil (`--profil=…`, `--klick-smoke`: ohne Tray, Updater, Toasts), einem nachgebauten Laufwerk (`RIPPY_FAKE_LAUFWERK=Q`) und nachgebauten Werkzeugen (`bau/klick/fake-*.cjs`, laufen über `kern/werkzeuge/aufruf.ts`) — und klickt per Playwright durch Rippen → Dialog → Sprachen → Folgen → Rip → Warteschlange → Kompression → Ablage → Roh-Dateien. Bilder in `../beweise/klick/`, Code 0 oder 2. Grund: Beide Fehler vom 01.09.2026 waren Verkabelung, die kein Unit-Test sah. Playwright ist reines Bau-Werkzeug (MIT, `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1` beim Installieren — Electron braucht keinen Browser-Download). |
Der Smoke-Lauf benutzt ein Wegwerf-Profil unter `%TEMP%\rippy-smoke` — er
fasst die echten Einstellungen nicht an.
+192
View File
@@ -0,0 +1,192 @@
// Der Klick-Beweis (5.4.0, Punkt 7 der Übergabe vom 01.09.2026): startet
// das ECHTE Rippy (Electron, Haupt, Kern, Fenster, Datenbank) mit einem
// frischen Profil, einem nachgebauten Laufwerk und nachgebauten Werkzeugen
// — und KLICKT sich durch: Rippen → Titel-Dialog → Sprachen je Titel →
// Rollen auf Folge → Staffel/Folge → Rip → Warteschlange → Kompression →
// Ablage → Roh-Dateien → löschen.
//
// Warum: Beide Fehler vom 01.09.2026 waren Verkabelung (Nachrichtentyp
// nicht im Prüfer, zwei gleichzeitige Titel-Läufe). 261 Unit-Tests sahen
// keinen davon; ein Klick hätte beide gefunden. Playwright treibt Electron
// (Doku »Electron« bei playwright.dev, _electron.launch).
//
// npm run klick (baut vorher)
// Bilder: ../beweise/klick/*.png — Ausgang 0 (grün) oder 2.
import { _electron as electron } from 'playwright'
import { existsSync, mkdirSync, readdirSync, rmSync, statSync } from 'node:fs'
import { DatabaseSync } from 'node:sqlite'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const wurzel = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const profil = join(tmpdir(), 'rippy-klick')
const ablage = join(profil, 'ablage')
const bilder = resolve(wurzel, '..', 'beweise', 'klick')
rmSync(profil, { recursive: true, force: true })
mkdirSync(profil, { recursive: true })
mkdirSync(bilder, { recursive: true })
// Das Profil vorbereiten: Einrichtung erledigt, Ablage im Profil, die
// Werkzeuge sind die Nachbauten (kern/werkzeuge/aufruf.ts startet Skripte
// über die eigene Node-Laufzeit). Die Datenbank legt der Kern selbst an —
// hier nur die Einstellungen (Tabelle wie in kern/speicher/db.ts).
{
const db = new DatabaseSync(join(profil, 'rippy.db'))
db.exec('CREATE TABLE IF NOT EXISTS einstellungen (schluessel TEXT PRIMARY KEY, wert TEXT NOT NULL)')
const setzen = db.prepare('INSERT OR REPLACE INTO einstellungen (schluessel, wert) VALUES (?, ?)')
setzen.run('setup.done', 'true')
setzen.run('ablage', ablage)
setzen.run('werkzeug.makemkv', join(wurzel, 'bau', 'klick', 'fake-makemkvcon.cjs'))
setzen.run('werkzeug.handbrake', join(wurzel, 'bau', 'klick', 'fake-handbrake.cjs'))
setzen.run('audioSprachen', 'deu,eng')
setzen.run('untertitelSprachen', 'deu')
setzen.run('rohAufbewahrung', 'behalten')
setzen.run('autoAuswurf', 'false')
db.close()
}
const app = await electron.launch({
executablePath: join(wurzel, 'node_modules', 'electron', 'dist', 'electron.exe'),
args: [wurzel, `--profil=${profil}`, '--klick-smoke'],
env: { ...process.env, RIPPY_FAKE_LAUFWERK: 'Q', RIPPY_OHNE_SCHLUESSEL: '1' },
timeout: 60_000,
})
const seite = await app.firstWindow()
seite.setDefaultTimeout(30_000)
seite.on('console', (m) => {
if (m.type() === 'error') console.log(`KLICK fenster: ${m.text().slice(0, 200)}`)
})
let nr = 0
async function bild(name) {
nr += 1
const pfad = join(bilder, `${String(nr).padStart(2, '0')}-${name}.png`)
await seite.screenshot({ path: pfad, fullPage: true })
console.log(`KLICK bild: ${pfad}`)
}
async function schritt(name, tun) {
try {
await tun()
console.log(`KLICK ✓ ${name}`)
} catch (fehler) {
console.error(`KLICK ✗ ${name}: ${String(fehler.message ?? fehler).split('\n')[0]}`)
await seite.screenshot({ path: join(bilder, `fehler-${name.replace(/[^a-z0-9]+/gi, '-').toLowerCase()}.png`), fullPage: true }).catch(() => {})
await app.close().catch(() => {})
process.exit(2)
}
}
const dialog = () => seite.locator('div.fixed.inset-0').last()
await schritt('Fenster offen, nachgebautes Laufwerk Q: sichtbar', async () => {
await seite.getByText('LAUFWERK Q:').waitFor()
await seite.getByText('NACHGEBAUT (KLICK-BEWEIS)').waitFor()
})
await bild('uebersicht')
await schritt('Rippen → Dialog öffnet sich', async () => {
await seite.getByRole('button', { name: 'Rippen', exact: true }).click()
await seite.getByText('Was soll gerippt werden?').waitFor()
})
await schritt('Titel-Lauf liefert drei Titel', async () => {
await dialog().getByText('Titel 0', { exact: true }).waitFor()
await dialog().getByText('Titel 1', { exact: true }).waitFor()
await dialog().getByText('Titel 2', { exact: true }).waitFor()
})
await schritt('Sprachen je Titel: jpn/deu Ton, deu/eng Untertitel als Chips', async () => {
await dialog().getByRole('button', { name: /jpn/ }).first().waitFor()
await dialog().getByRole('button', { name: /✓ deu/ }).first().waitFor()
})
await schritt('Titel 0 bekommt japanischen Ton (deu abwählen, jpn anwählen)', async () => {
const chips = dialog().locator('button', { hasText: /^✓ deu$|^deu$/ })
await chips.first().click()
await dialog().getByRole('button', { name: /^jpn$/ }).first().click()
await dialog().getByRole('button', { name: /^✓ jpn$/ }).first().waitFor()
})
await schritt('„Hauptinhalt + Extras" wählt beide langen Titel, der Schnipsel bleibt draußen', async () => {
// Rippy wählt bei zwei fast gleich langen Titeln nur EINEN vor („bitte
// nachsehen") — der Knopf holt den zweiten dazu, Titel 2 (35 s) nicht.
await dialog().getByRole('button', { name: 'Hauptinhalt + Extras' }).click()
await dialog().getByText(/2 von 3 gewählt/).waitFor()
})
await schritt('Rollen auf Folge → Staffel/Folge-Felder erscheinen', async () => {
const rollen = dialog().locator('select')
await rollen.nth(0).selectOption('folge')
await rollen.nth(1).selectOption('folge')
await dialog().getByText('AB FOLGE').waitFor()
await dialog().getByPlaceholder('automatisch').fill('3')
})
await schritt('Vorschau nennt Serien\\…\\Season 01 und S01E03', async () => {
await dialog().getByText(/Serien\\/).waitFor()
await dialog().getByText(/Season 01/).waitFor()
await dialog().getByText(/S01E03/).waitFor()
})
await bild('dialog')
await schritt('Rip starten → Phase RIPPT', async () => {
await dialog().getByRole('button', { name: /Titel rippen/ }).click()
await seite.getByText(/RIPPT/).first().waitFor()
})
await schritt('Rip fertig, Kompression eingereiht, Laufwerk frei', async () => {
await seite.getByText(/Kompression eingereiht/).waitFor({ timeout: 60_000 })
await seite.getByRole('button', { name: 'Rippen', exact: true }).waitFor()
})
await schritt('Kompressions-Karte läuft und die Ablage endet', async () => {
await seite.getByText('KOMPRESSION', { exact: true }).waitFor({ timeout: 30_000 })
await seite.getByText(/Komprimiert und abgelegt/).waitFor({ timeout: 60_000 })
})
await bild('fertig')
await schritt('Ergebnis liegt als S01E03/S01E04 unter Serien\\…\\Season 01', async () => {
const serien = join(ablage, 'Serien')
if (!existsSync(serien)) throw new Error(`kein Ordner ${serien}`)
const gefunden = []
const gehen = (o) => {
for (const e of readdirSync(o, { withFileTypes: true })) {
const p = join(o, e.name)
if (e.isDirectory()) gehen(p)
else if (e.name.endsWith('.mkv') && statSync(p).size > 0) gefunden.push(p)
}
}
gehen(serien)
const namen = gefunden.map((p) => p.split(/[\\/]/).pop())
if (!namen.some((n) => n.endsWith('S01E03.mkv')) || !namen.some((n) => n.endsWith('S01E04.mkv'))) {
throw new Error(`gefunden: ${namen.join(', ')}`)
}
if (!gefunden[0].includes('Season 01')) throw new Error(`kein Season-01-Ordner: ${gefunden[0]}`)
console.log(`KLICK dateien: ${gefunden.join(' | ')}`)
})
await schritt('Einstellungen → Roh-Dateien zeigt den Vorgang mit Belegung', async () => {
await seite.getByRole('button', { name: 'Einstellungen' }).click()
await seite.locator('#roh').waitFor()
await seite.getByText(/Belegung jetzt/).waitFor()
await seite.getByText(/Roh: \d/).first().waitFor()
})
await bild('roh-dateien')
await schritt('Roh-Dateien löschen → „gelöscht"', async () => {
await seite.getByRole('button', { name: 'Roh-Dateien löschen' }).first().click()
await seite.getByText(/Roh: gelöscht/).first().waitFor()
})
await schritt('Ereignisse nennen die Kompression', async () => {
await seite.getByRole('button', { name: 'Übersicht' }).click()
await seite.getByText(/Kompression „/).first().waitFor()
})
await bild('ereignisse')
console.log('KLICK: OK — Rippen, Dialog, Sprachen, Folgen, Rip, Warteschlange, Kompression, Ablage, Roh-Dateien: alles geklickt.')
await app.close().catch(() => {})
process.exit(0)
@@ -0,0 +1,90 @@
// Nachgebautes HandBrakeCLI für den Klick-Beweis (bau/klick-smoke.mjs).
// Antwortet so, wie encoder.ts, scan.ts und handbrake.ts es am echten
// HandBrake 1.11 gemessen haben:
// --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
const fs = require('node:fs')
const args = process.argv.slice(2)
const out = (zeile) => process.stdout.write(zeile + '\n')
const wert = (schalter) => {
const i = args.indexOf(schalter)
return i >= 0 ? args[i + 1] : undefined
}
if (args.includes('--help') || args.includes('-h')) {
out('Usage: HandBrakeCLI [options] -i <source> -o <destination>')
out('')
out('Video Options -----------------------------------------------------------------')
out('')
out(' -e, --encoder <string> Select video encoder:')
out(' x264')
out(' x265')
out(' nvenc_h264')
out(' -q, --quality <float> Set video quality (e.g. 22.0)')
out('')
process.exit(0)
}
if (args.includes('--preset-list')) {
out('General/')
out(' Very Fast 1080p30')
out(' Fast 1080p30')
out(' HQ 1080p30 Surround')
out('')
process.exit(0)
}
if (args.includes('--scan')) {
const eingabe = wert('--input') ?? wert('-i') ?? 'quelle'
out('[12:00:00] hb_init: starting libhb thread')
out('Scanning title 1 of 1, preview 1, 100.00 %')
out('JSON Title Set: {')
out('"MainFeature": 0,')
out('"TitleList": [')
out('{')
out('"AudioList": [')
out('{ "Description": "Japanese (AAC LC, 2.0 ch)", "Language": "Japanese", "LanguageCode": "jpn", "TrackNumber": 1 },')
out('{ "Description": "Deutsch (AAC LC, 2.0 ch)", "Language": "Deutsch", "LanguageCode": "deu", "TrackNumber": 2 }')
out('],')
out('"Duration": { "Hours": 0, "Minutes": 52, "Seconds": 47, "Ticks": 542070 },')
out('"Index": 1,')
out(`"Name": ${JSON.stringify(eingabe)},`)
out('"SubtitleList": [')
out('{ "Format": "bitmap", "Language": "Deutsch", "LanguageCode": "deu", "SourceName": "PGS", "TrackNumber": 1 },')
out('{ "Format": "bitmap", "Language": "English", "LanguageCode": "eng", "SourceName": "PGS", "TrackNumber": 2 }')
out(']')
out('}')
out(']')
out('}')
out('HandBrake has exited.')
process.exit(0)
}
const eingabe = wert('--input') ?? wert('-i')
const ausgabe = wert('--output') ?? wert('-o')
if (eingabe === undefined || ausgabe === undefined) {
out('Missing input or output file name. Run HandBrakeCLI --help for syntax.')
process.exit(1)
}
out('[12:00:00] hb_init: starting libhb thread')
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)} %`)
schritt += 1
if (schritt < stufen.length) {
setTimeout(tick, 300)
return
}
fs.copyFileSync(eingabe, ausgabe)
out('[12:00:05] libhb: work result = 0')
out('')
out('Encode done!')
out('HandBrake has exited.')
process.exit(0)
}
setTimeout(tick, 200)
@@ -0,0 +1,79 @@
// Nachgebautes makemkvcon für den Klick-Beweis (bau/klick-smoke.mjs).
// Spricht den Robot-Modus so, wie der Parser (kern/rip/parser.ts) ihn an
// echten Discs gemessen hat: TINFO (8 Kapitel, 9 Dauer, 11 Bytes), SINFO
// (1 Typ, 3 Sprachcode, 4 Sprachname), PRGV (aktuell,gesamt,max), MSG.
//
// info dev:X: → drei Titel: zwei Folgen (52:47 / 54:09, jpn+deu
// Ton, deu+eng Untertitel) und ein Logo-Schnipsel
// mkv dev:X: <nr> <ziel> → Fortschritt in Stufen, dann title_t0N.mkv
//
// Läuft über die eigene Node-Laufzeit (kern/werkzeuge/aufruf.ts) — kein EXE.
const fs = require('node:fs')
const path = require('node:path')
const args = process.argv.slice(2)
const befehl = args.find((a) => a === 'info' || a === 'mkv')
const out = (zeile) => process.stdout.write(zeile + '\n')
out('MSG:1005,0,1,"MakeMKV v1.18.4 win(x86-release) gestartet","%1 gestartet","MakeMKV v1.18.4 win(x86-release)"')
const TITEL = [
{ nr: 0, dauer: '0:52:47', bytes: 16283000000, kapitel: 7 },
{ nr: 1, dauer: '0:54:09', bytes: 16670000000, kapitel: 7 },
{ nr: 2, dauer: '0:00:35', bytes: 20000000, kapitel: 1 },
]
if (befehl === 'info') {
out(`TCOUNT:${TITEL.length}`)
for (const t of TITEL) {
out(`TINFO:${t.nr},8,0,"${t.kapitel}"`)
out(`TINFO:${t.nr},9,0,"${t.dauer}"`)
out(`TINFO:${t.nr},11,0,"${t.bytes}"`)
out(`SINFO:${t.nr},0,1,6201,"Video"`)
if (t.nr < 2) {
out(`SINFO:${t.nr},1,1,6202,"Audio"`)
out(`SINFO:${t.nr},1,3,0,"jpn"`)
out(`SINFO:${t.nr},1,4,0,"Japanese"`)
out(`SINFO:${t.nr},2,1,6202,"Audio"`)
out(`SINFO:${t.nr},2,3,0,"deu"`)
out(`SINFO:${t.nr},2,4,0,"German"`)
out(`SINFO:${t.nr},3,1,6203,"Subtitles"`)
out(`SINFO:${t.nr},3,3,0,"deu"`)
out(`SINFO:${t.nr},3,4,0,"German"`)
out(`SINFO:${t.nr},4,1,6203,"Subtitles"`)
out(`SINFO:${t.nr},4,3,0,"eng"`)
out(`SINFO:${t.nr},4,4,0,"English"`)
}
}
process.exit(0)
}
if (befehl === 'mkv') {
const i = args.indexOf('mkv')
const nr = Number(args[i + 2])
const ziel = args[i + 3]
if (!Number.isInteger(nr) || typeof ziel !== 'string') {
out('MSG:5010,0,0,"Falscher Aufruf"')
process.exit(1)
}
let schritt = 0
const stufen = [10, 35, 60, 85, 100]
const tick = () => {
out(`PRGV:${stufen[schritt]},${stufen[schritt]},100`)
schritt += 1
if (schritt < stufen.length) {
setTimeout(tick, 250)
return
}
fs.mkdirSync(ziel, { recursive: true })
const datei = path.join(ziel, `title_t${String(nr).padStart(2, '0')}.mkv`)
// 1 MB „Roh-Material" — groß genug, dass Größen sichtbar sind.
fs.writeFileSync(datei, Buffer.alloc(1024 * 1024, nr + 1))
out(`MSG:5003,0,1,"1 Titel gespeichert","%1 Titel gespeichert","1"`)
process.exit(0)
}
setTimeout(tick, 200)
} else {
out('MSG:5010,0,0,"Unbekannter Befehl"')
process.exit(1)
}
+45
View File
@@ -1,3 +1,48 @@
Version 5.4.0
Das Laufwerk ist frei, sobald die Roh-Dateien liegen.
Rip und Kompression sind getrennt: Der Rip belegt das Laufwerk, danach
laeuft die Kompression in einer Warteschlange im Hintergrund (Karte in der
Uebersicht). Die naechste Disc kann rein, waehrend die vorige noch
rechnet; nach dem Rip wirft Rippy aus (abschaltbar unter Ablage).
Roh-Dateien raeumen sich auf.
Jeder Rip ist jetzt ein Vorgang mit Roh-Ordner und Zielordner. Unter
Einstellungen -> Roh-Dateien: Belegung, die Regel (Vorgabe: 7 Tage nach
gelungener Kompression, nur wenn jede fertige Datei nachweislich da ist),
"Neu komprimieren" mit anderem Preset ohne Neulesen der Disc, "Roh-Dateien
loeschen" von Hand. Ordner aus der Zeit vor 5.4.0 werden nur gezeigt, nie
von selbst geloescht. Halbe Dateien nach Abbruch oder Fehler verschwinden.
Unkomprimierte Rips (4K verlustfrei) landen im Zielordner statt unter roh.
Sprachen je Titel.
Der Auswahl-Dialog zeigt je Titel die gemessenen Ton- und Untertitelspuren
zum Anhaken. Japanischer Ton plus deutsche Untertitel: Die Untertitel sind
beim Abspielen vorgewaehlt. Vor der Kompression prueft Rippy die Roh-Datei,
ob die Sprachen wirklich drin sind, und setzt MakeMKVs Auswahlregel so,
dass alle Sprachen mitkommen.
Staffel und Folge von Hand.
Bei Folgen: "Staffel 1, ab Folge 3" im Dialog, dann zaehlt Rippy
fortlaufend in Disc-Reihenfolge; TMDb gibt nur noch eine Warnung. Zwei
Zeilen Vorschau zeigen, wohin was landet. Der Fortschrittsbalken laeuft
durch, nach Groesse gewichtet, statt je Titel auf null zu springen.
Erkennung v2 und Kino-Banner.
Nach dem Titel-Lauf haelt Rippy die gemessene Laufzeit gegen TMDb: aus
"wahrscheinlich" wird "bestaetigt", oder der Treffer faellt auf einen
Vorschlag zurueck. Leere TMDb-Eintraege zaehlen nicht mehr als Treffer.
Die Kachel zeigt Logo, Tagline, Jahr, FSK, Laufzeit, Genre, Bewertung
und Besetzung. "Laufwerk pruefen" liest das Windows-Protokoll: Disc oder
Hardware?
Dazu: Dialoge schliessen nicht mehr beim Markieren von Text; nach einem
Neustart nimmt Rippy wartende Kompressionen wieder auf; der Datenbank-
Pfad steht unter Programm; ein Klick-Beweis (Playwright) prueft den
ganzen Weg durch das echte Programm.
Unveraendert: Updates warten, bis kein Rip und keine Kompression mehr laeuft.
Version 5.3.2
Die Titelliste bleibt jetzt stehen.
+5 -2
View File
@@ -25,8 +25,11 @@ function cspNurImBau(): Plugin {
return html.replace(
'<head>',
// img-src erlaubt TMDb-Poster (Kachel + Ändern-Dialog) — sonst
// bleibt alles bei 'self'.
`<head>\n <meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src 'self' data: https://image.tmdb.org" />`,
// bleibt alles bei 'self'. font-src data: (5.4.0, im Klick-Beweis
// gesehen): Vite bettet kleine Schrift-Teile der fontsource-Pakete
// als data:-URI ein, und die blockte die CSP — als Fehler in der
// Konsole, unsichtbar im Fenster.
`<head>\n <meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src 'self' data: https://image.tmdb.org; font-src 'self' data:" />`,
)
},
}
+50 -2
View File
@@ -1,12 +1,12 @@
{
"name": "rippy",
"version": "5.3.2",
"version": "5.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "rippy",
"version": "5.3.2",
"version": "5.4.0",
"license": "UNLICENSED",
"dependencies": {
"electron-updater": "^6.8.9",
@@ -24,6 +24,7 @@
"electron": "44.0.0",
"electron-builder": "^26.15.3",
"electron-vite": "^5.0.0",
"playwright": "^1.62.1",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"tailwindcss": "^4.3.3",
@@ -5612,6 +5613,53 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/playwright": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.62.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/plist": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz",
+3 -1
View File
@@ -1,7 +1,7 @@
{
"name": "rippy",
"productName": "Rippy",
"version": "5.3.2",
"version": "5.4.0",
"description": "Rippy v5 — Disc-Ripping als eigenständiges Windows-Programm (KONZEPT-WINDOWS.md)",
"main": "out/haupt/index.js",
"author": "KrBrZ",
@@ -13,6 +13,7 @@
"build": "npm run typecheck && electron-vite build",
"test": "vitest run",
"smoke": "electron-vite build && electron . --smoke",
"klick": "electron-vite build && node bau/klick-smoke.mjs",
"paket": "npm run build && electron-builder --win --config electron-builder.yml",
"werkzeug:electron": "node node_modules/electron/install.js"
},
@@ -32,6 +33,7 @@
"electron": "44.0.0",
"electron-builder": "^26.15.3",
"electron-vite": "^5.0.0",
"playwright": "^1.62.1",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"tailwindcss": "^4.3.3",
File diff suppressed because it is too large Load Diff
+106 -52
View File
@@ -11,12 +11,16 @@ import {
type BeschaffungStand,
type BibliothekEintrag,
type DiscInfoStand,
type FensterNachricht,
type GeraeteInfo,
type KernNachricht,
type LaufwerkBericht,
type MetaVorschlag,
type TitelStand,
type RipAuftragOptionen,
type RipAuftragTitel,
type RipStatus,
type TitelStand,
type VorgaengeStand,
type WerkzeugAuskunft,
} from '../gemeinsam/nachrichten'
@@ -54,13 +58,18 @@ export interface Verbindung {
} | null
/** Stand der MakeMKV-Beschaffung (§ 9) — null, solange nie eine lief. */
beschaffung: BeschaffungStand | null
/** Vorgänge, Roh-Belegung und Kompressions-Warteschlange (5.4.0) null,
* solange der Kern noch nichts geschickt hat. */
vorgaenge: VorgaengeStand | null
/** Was das Windows-Protokoll je Laufwerk sagt (5.4.0) — bleibt stehen. */
gesundheit: Record<string, LaufwerkBericht>
/** Kurze Ereigniszeilen (neueste zuerst): Disc rein/raus, Auswurf, Fehler. */
meldungen: string[]
}
type Horcher = (stand: Verbindung) => void
const MAX_MELDUNGEN = 20
const MAX_MELDUNGEN = 30
const stand: Verbindung = {
verbunden: false,
@@ -78,27 +87,32 @@ const stand: Verbindung = {
bibliothek: [],
schluessel: null,
beschaffung: null,
vorgaenge: null,
gesundheit: {},
meldungen: [],
}
const horcher = new Set<Horcher>()
let gestartet = false
let port: MessagePort | null = null
function melden(): void {
for (const h of horcher) {
h({
...stand,
laufwerke: [...stand.laufwerke],
rips: { ...stand.rips },
discInfos: { ...stand.discInfos },
vorschlaege: { ...stand.vorschlaege },
einstellungen: { ...stand.einstellungen },
bibliothek: [...stand.bibliothek],
meldungen: [...stand.meldungen],
})
function kopie(): Verbindung {
return {
...stand,
laufwerke: [...stand.laufwerke],
rips: { ...stand.rips },
discInfos: { ...stand.discInfos },
vorschlaege: { ...stand.vorschlaege },
einstellungen: { ...stand.einstellungen },
bibliothek: [...stand.bibliothek],
gesundheit: { ...stand.gesundheit },
meldungen: [...stand.meldungen],
}
}
function melden(): void {
for (const h of horcher) h(kopie())
}
function meldung(text: string): void {
stand.meldungen = [text, ...stand.meldungen].slice(0, MAX_MELDUNGEN)
}
@@ -116,6 +130,9 @@ function kernNachricht(nachricht: KernNachricht): void {
case 'kern-fehler':
meldung(`${nachricht.text}`)
break
case 'kern-hinweis':
meldung(nachricht.text)
break
case 'laufwerke':
stand.laufwerke = nachricht.liste
for (const ereignis of nachricht.ereignisse) {
@@ -137,11 +154,20 @@ function kernNachricht(nachricht: KernNachricht): void {
break
}
case 'disc-info': {
const vorher = stand.discInfos[nachricht.info.id]
stand.discInfos = { ...stand.discInfos, [nachricht.info.id]: nachricht.info }
if (nachricht.info.titel.length > 0) {
meldung(
`Laufwerk ${nachricht.info.id}: erkannt als „${nachricht.info.titel}${nachricht.info.jahr !== null ? ` (${nachricht.info.jahr})` : ''}“ · ${Math.round(nachricht.info.confidence * 100)} %`,
)
// Dieselbe Erkennung nicht zweimal melden — die Nachprüfung nach
// dem Info-Lauf schickt den Stand erneut, mit Hinweisen.
const neuErkannt = vorher === undefined || vorher.titel !== nachricht.info.titel
if (neuErkannt) {
meldung(
`Laufwerk ${nachricht.info.id}: erkannt als „${nachricht.info.titel}${nachricht.info.jahr !== null ? ` (${nachricht.info.jahr})` : ''}“ · ${Math.round(nachricht.info.confidence * 100)} %`,
)
}
for (const h of nachricht.info.hinweise) {
if (vorher === undefined || !vorher.hinweise.includes(h)) meldung(`Laufwerk ${nachricht.info.id}: ${h}`)
}
}
break
}
@@ -169,6 +195,18 @@ function kernNachricht(nachricht: KernNachricht): void {
stand.beschaffung = nachricht.stand
if (nachricht.stand.fehler) meldung(`⚠ MakeMKV-Update: ${nachricht.stand.text}`)
break
case 'vorgaenge':
stand.vorgaenge = nachricht.stand
break
case 'kompression-ergebnis':
meldung(`${nachricht.ok ? '' : '⚠ '}Kompression „${nachricht.titel}": ${nachricht.text}`)
break
case 'laufwerk-gesundheit':
stand.gesundheit = { ...stand.gesundheit, [nachricht.id]: nachricht.bericht }
if (nachricht.bericht.urteil === 'hardware' || nachricht.bericht.urteil === 'disc') {
meldung(`Laufwerk ${nachricht.id}: ${nachricht.bericht.text}`)
}
break
case 'rip-status': {
stand.rips = { ...stand.rips, [nachricht.status.id]: nachricht.status }
const laeuft = ['liest-info', 'rippt', 'komprimiert', 'legt-ab'].includes(nachricht.status.phase)
@@ -201,6 +239,9 @@ function kernNachricht(nachricht: KernNachricht): void {
}
break
}
case 'installer-starten':
// Nur fürs Haupt gedacht — im Fenster nichts zu tun.
break
}
melden()
}
@@ -213,10 +254,11 @@ function portAnnehmen(neuerPort: MessagePort): void {
if (istKernNachricht(nachricht)) kernNachricht(nachricht)
}
neuerPort.start()
neuerPort.postMessage({ art: 'ping', zeit: Date.now() })
neuerPort.postMessage({ art: 'einstellungen-laden' })
neuerPort.postMessage({ art: 'werkzeuge-laden' })
neuerPort.postMessage({ art: 'bibliothek-laden' })
senden({ art: 'ping', zeit: Date.now() })
senden({ art: 'einstellungen-laden' })
senden({ art: 'werkzeuge-laden' })
senden({ art: 'bibliothek-laden' })
senden({ art: 'vorgaenge-laden' })
melden()
}
@@ -241,20 +283,16 @@ export function verbindungStarten(): void {
export function aufVerbindung(h: Horcher): () => void {
horcher.add(h)
h({
...stand,
laufwerke: [...stand.laufwerke],
rips: { ...stand.rips },
discInfos: { ...stand.discInfos },
vorschlaege: { ...stand.vorschlaege },
einstellungen: { ...stand.einstellungen },
bibliothek: [...stand.bibliothek],
meldungen: [...stand.meldungen],
})
h(kopie())
return () => horcher.delete(h)
}
function anKern(nachricht: { art: string; id: string; auftrag?: RipAuftragTitel[] }, wasWar: string): void {
function senden(nachricht: FensterNachricht): void {
port?.postMessage(nachricht)
}
/** Schickt eine Nachricht — und sagt es, wenn kein Kern da ist (R4). */
function anKern(nachricht: FensterNachricht, wasWar: string): void {
if (port === null) {
meldung(`${wasWar}: keine Verbindung zum Kern`)
melden()
@@ -273,8 +311,8 @@ export function titelLesen(id: string): void {
}
/** `auftrag` leer heißt: alles rippen (wie vor 5.2). */
export function ripStarten(id: string, auftrag: RipAuftragTitel[] = []): void {
anKern({ art: 'rip-start', id, auftrag }, `Rip ${id}`)
export function ripStarten(id: string, auftrag: RipAuftragTitel[] = [], optionen: RipAuftragOptionen = {}): void {
anKern({ art: 'rip-start', id, auftrag, optionen }, `Rip ${id}`)
}
/** Den laufenden Titel aufgeben und mit dem nächsten weitermachen. */
@@ -287,34 +325,50 @@ export function ripAbbrechen(id: string): void {
}
export function metadatenSuchen(id: string, text: string): void {
if (port === null) return
port.postMessage({ art: 'metadaten-suchen', id, text })
senden({ art: 'metadaten-suchen', id, text })
}
export function zuordnungSetzen(id: string, vorschlag: MetaVorschlag): void {
if (port === null) return
port.postMessage({ art: 'zuordnung-setzen', id, vorschlag })
senden({ art: 'zuordnung-setzen', id, vorschlag })
}
export function makemkvUpdate(): void {
if (port === null) {
meldung('⚠ MakeMKV-Update: keine Verbindung zum Kern')
melden()
return
}
port.postMessage({ art: 'makemkv-update' })
anKern({ art: 'makemkv-update' }, 'MakeMKV-Update')
}
export function schluesselPruefen(): void {
if (port === null) return
port.postMessage({ art: 'schluessel-pruefen' })
senden({ art: 'schluessel-pruefen' })
}
export function einstellungSetzen(schluessel: string, wert: string): void {
if (port === null) {
meldung(`⚠ Einstellung ${schluessel}: keine Verbindung zum Kern`)
melden()
return
}
port.postMessage({ art: 'einstellung-setzen', schluessel, wert })
anKern({ art: 'einstellung-setzen', schluessel, wert }, `Einstellung ${schluessel}`)
}
// ── 5.4.0: Vorgänge, Roh-Dateien, Warteschlange, Laufwerks-Gesundheit ──
export function vorgaengeLaden(): void {
senden({ art: 'vorgaenge-laden' })
}
export function vorgangRohLoeschen(vorgangId: string): void {
anKern({ art: 'vorgang-roh-loeschen', vorgangId }, 'Roh-Dateien löschen')
}
export function vorgangNeuKomprimieren(vorgangId: string, preset: string): void {
anKern({ art: 'vorgang-neu-komprimieren', vorgangId, preset }, 'Neu komprimieren')
}
export function kompressionAbbrechen(vorgangId: string): void {
anKern({ art: 'kompression-abbrechen', vorgangId }, 'Kompression abbrechen')
}
export function rohAufraeumen(): void {
anKern({ art: 'roh-aufraeumen' }, 'Roh-Dateien aufräumen')
}
export function fremdenOrdnerLoeschen(ordner: string): void {
anKern({ art: 'fremden-ordner-loeschen', ordner }, 'Ordner löschen')
}
export function laufwerkPruefen(id: string): void {
anKern({ art: 'laufwerk-pruefen', id }, `Laufwerk ${id} prüfen`)
}
+74
View File
@@ -0,0 +1,74 @@
// Ablage-Namen und die Zielpfad-Vorschau — PUR, ohne Dateisystem, damit
// Kern UND Fenster dieselbe Regel benutzen (5.4.0: „Vorschau, welche Datei
// landet wo" im Auswahl-Dialog). Die Kern-Seite (kern/ablage/struktur.ts)
// baut auf diesen Namen ihre echten Ordner.
// Zeichen, die in Ordnernamen auf SMB/NTFS/ext4 Ärger machen.
// eslint-Hinweis unnötig: bewusst inklusive Steuerzeichen.
// eslint-disable-next-line no-control-regex
const VERBOTEN = /[<>:"/\\|?*\x00-\x1f]/g
/** Titel → dateisystem-tauglicher Ordnername (pur). */
export function sichererName(titel: string, jahr?: number | string | null): string {
let name = (titel ?? '').replace(VERBOTEN, '').trim().replace(/\.+$/, '')
name = name.replace(/\s+/g, ' ').slice(0, 150).trim()
if (name.length === 0) return ''
// Kein doppeltes Jahr: OMDb-Titel tragen es teils schon im Namen.
if (jahr !== undefined && jahr !== null && String(jahr).length > 0 && !name.endsWith(`(${jahr})`)) {
name = `${name} (${jahr})`
}
return name
}
export function staffelOrdnerName(staffel: number): string {
return `Season ${String(Math.trunc(staffel)).padStart(2, '0')}`
}
export function folgenDateiname(serie: string, staffel: number, folge: number): string {
const s = String(Math.trunc(staffel)).padStart(2, '0')
const e = String(Math.trunc(folge)).padStart(2, '0')
return `${sichererName(serie) || 'Serie'} S${s}E${e}.mkv`
}
export interface VorschauEingabe {
ablage: string
alsSerie: boolean
titel: string
jahr: number | null
staffel: number | null
ersteFolge: number | null
/** Zahl der gewählten Folgen (Serie) bzw. Hauptfilme (Film). */
hauptTitel: number
extras: number
/** Der Vorgangsname — gilt, wenn kein Titel erkannt ist. */
vorgangsName: string
}
/** Zwei Zeilen: Zielordner und was darin landet (pur). Windows-Trenner,
* weil die Vorschau eine Windows-Ablage beschreibt. */
export function zielVorschau(e: VorschauEingabe): [string, string] {
const wurzel = e.ablage.replace(/[\\/]+$/, '')
const titel = e.titel.trim().length > 0 ? e.titel.trim() : e.vorgangsName
if (e.alsSerie) {
const staffel = e.staffel ?? 1
const ordner = `${wurzel}\\Serien\\${sichererName(titel) || 'Unbekannte Serie'}\\${staffelOrdnerName(staffel)}\\`
const n = Math.max(1, e.hauptTitel)
let inhalt: string
if (e.ersteFolge !== null && e.ersteFolge > 0) {
const erste = folgenDateiname(titel, staffel, e.ersteFolge)
inhalt =
n === 1
? erste
: `${erste}${folgenDateiname(titel, staffel, e.ersteFolge + n - 1)} (${n} Folgen, fortlaufend)`
} else {
inhalt = `${n} ${n === 1 ? 'Folge' : 'Folgen'} — Nummern aus den TMDb-Laufzeiten, sonst bleiben die MakeMKV-Namen`
}
if (e.extras > 0) inhalt += ` · extras\\ (${e.extras})`
return [ordner, inhalt]
}
const ordner = `${wurzel}\\Filme\\${sichererName(titel, e.jahr) || e.vorgangsName}\\`
const teile: string[] = []
if (e.hauptTitel > 0) teile.push(e.hauptTitel === 1 ? 'der Hauptfilm' : `${e.hauptTitel} Hauptfilme`)
if (e.extras > 0) teile.push(`extras\\ (${e.extras} ${e.extras === 1 ? 'Zugabe' : 'Zugaben'})`)
return [ordner, teile.length > 0 ? teile.join(' · ') : 'nichts gewählt']
}
File diff suppressed because it is too large Load Diff
+62 -22
View File
@@ -21,14 +21,25 @@ import { trayErstellen, trayHinweis } from './tray'
import { updateEinrichten, updateInstallierenJetzt, updatePruefenJetzt, updateStand } from './update'
const smokeModus = process.argv.includes('--smoke')
// Der Klick-Beweis (bau/klick-smoke.mjs, 5.4.0): das ECHTE Programm mit
// eigenem Profil und nachgebauten Werkzeugen — ohne Tray, Updater und
// Toasts, aber mit allem, was klickbar ist. Beendet wird er von außen.
const klickSmoke = process.argv.includes('--klick-smoke')
const profilArg = process.argv.find((a) => a.startsWith('--profil='))
/** Smoke UND Klick-Beweis: keine Nebenwirkungen auf dem Rechner. */
const ohneNebenwirkungen = smokeModus || klickSmoke
// Der Smoke-Lauf fasst das echte Profil nicht an — er hinterlässt nichts.
if (smokeModus) {
app.setPath('userData', join(app.getPath('temp'), 'rippy-smoke'))
} else if (profilArg !== undefined && profilArg.length > '--profil='.length) {
app.setPath('userData', profilArg.slice('--profil='.length))
}
// Nur EIN Rippy je Benutzer (§ 4.2: Einzelinstanz-Sperre). Die zweite
// Instanz beendet sich; die erste holt ihr Fenster nach vorn.
// Instanz beendet sich; die erste holt ihr Fenster nach vorn. Die Sperre
// hängt am Profil — ein Beweislauf mit eigenem Profil stört das laufende
// Rippy nicht und wird von ihm nicht gestört.
if (!app.requestSingleInstanceLock()) {
app.exit(0)
} else {
@@ -77,7 +88,7 @@ function start(): void {
} else if (nachricht.art === 'einstellungen') {
// Einmalig das Selbst-Update einrichten — die Adresse kommt aus
// der EINEN Einstellungs-Quelle im Kern (§ 6.7/§ 6.8).
if (!updateGestartet && !smokeModus) {
if (!updateGestartet && !ohneNebenwirkungen) {
updateGestartet = true
updateEinrichten(
nachricht.werte['updateUrl'] ?? '',
@@ -94,7 +105,13 @@ function start(): void {
}
} else if (nachricht.art === 'installer-starten') {
void installerStarten(nachricht.pfad)
} else if (nachricht.art === 'disc-info' && nachricht.info.titel.length > 0 && !smokeModus) {
} else if (nachricht.art === 'vorgaenge') {
// 5.4.0: Die Kompression läuft OHNE Laufwerk weiter — Updates
// warten auch auf sie, und die Taskleiste zeigt ihren Fortschritt.
kompressionVerarbeiten(nachricht.stand.kompression.laeuft)
} else if (nachricht.art === 'kompression-ergebnis' && !ohneNebenwirkungen) {
toast(nachricht.ok ? 'Rippy ist fertig' : 'Rippy: Kompression fehlgeschlagen', `${nachricht.titel}: ${nachricht.text}`)
} else if (nachricht.art === 'disc-info' && nachricht.info.titel.length > 0 && !ohneNebenwirkungen) {
// Windows-Toast beim Erkennen (§ 7) — Klick holt das Fenster hoch.
toast(
'Disc erkannt',
@@ -109,6 +126,43 @@ function start(): void {
smokeModus,
)
// Die laufende Kompression (5.4.0) — ein eigener Schlüssel in den
// aktiven Vorgängen, weil sie an keinem Laufwerk mehr hängt.
const KOMPRESSION = 'kompression'
let kompressionStand: { titel: string; prozent: number } | null = null
function kompressionVerarbeiten(laeuft: { titel: string; prozent: number } | null): void {
kompressionStand = laeuft === null ? null : { titel: laeuft.titel, prozent: laeuft.prozent }
if (laeuft === null) aktiveVorgaenge.delete(KOMPRESSION)
else aktiveVorgaenge.add(KOMPRESSION)
anzeigeAktualisieren()
}
/** Taskleiste und Tray aus dem, was gerade läuft: ein Rip zuerst, sonst
* die Kompression sonst nichts. */
let letzterRip: Extract<KernNachricht, { art: 'rip-status' }>['status'] | null = null
function anzeigeAktualisieren(): void {
const rip = letzterRip !== null && letzterRip.phase === 'rippt' ? letzterRip : null
if (fenster !== null && !fenster.isDestroyed()) {
if (rip !== null && rip.prozent >= 0) {
fenster.setProgressBar(rip.prozent / 100)
} else if (letzterRip !== null && letzterRip.phase === 'fehler') {
fenster.setProgressBar(1, { mode: 'error' })
} else if (kompressionStand !== null && kompressionStand.prozent >= 0) {
fenster.setProgressBar(kompressionStand.prozent / 100)
} else {
fenster.setProgressBar(-1)
}
}
if (rip !== null) {
trayHinweis(`rippt Laufwerk ${rip.id}${rip.prozent >= 0 ? ` (${rip.prozent} %)` : ''}`)
} else if (kompressionStand !== null) {
trayHinweis(`komprimiert „${kompressionStand.titel}"${kompressionStand.prozent >= 0 ? ` (${kompressionStand.prozent} %)` : ''}`)
} else {
trayHinweis('')
}
}
// § 7: Toasts, Taskleisten-Fortschritt und Tray-Zustand aus dem
// Rip-Status — die Information, die man will, ohne das Fenster zu öffnen.
function ripStatusVerarbeiten(status: Extract<KernNachricht, { art: 'rip-status' }>['status']): void {
@@ -117,25 +171,11 @@ function start(): void {
} else {
aktiveVorgaenge.delete(status.id)
}
if (fenster !== null && !fenster.isDestroyed()) {
if ((status.phase === 'rippt' || status.phase === 'komprimiert') && status.prozent >= 0) {
fenster.setProgressBar(status.prozent / 100)
} else if (status.phase === 'fehler') {
fenster.setProgressBar(1, { mode: 'error' })
} else {
fenster.setProgressBar(-1)
}
}
if (status.phase === 'rippt') {
trayHinweis(`rippt Laufwerk ${status.id}${status.prozent >= 0 ? ` (${status.prozent} %)` : ''}`)
} else if (status.phase === 'komprimiert') {
trayHinweis(`komprimiert Laufwerk ${status.id}${status.prozent >= 0 ? ` (${status.prozent} %)` : ''}`)
} else {
trayHinweis('')
}
if (smokeModus) return
letzterRip = status
anzeigeAktualisieren()
if (ohneNebenwirkungen) return
if (status.phase === 'fertig') {
toast('Rippy ist fertig', status.text)
toast('Rippy: Rip fertig', status.text)
} else if (status.phase === 'fehler') {
toast('Rippy: Fehler', status.text)
}
@@ -304,7 +344,7 @@ function start(): void {
void app.whenReady().then(() => {
kernVerwaltung.starten()
if (!smokeModus) {
if (!ohneNebenwirkungen) {
trayErstellen(fensterZeigen, beenden)
}
fenster = fensterErstellen()
@@ -0,0 +1,198 @@
// Roh-Dateien aufräumen (5.4.0) — PUR bis auf injizierte Dateizugriffe.
//
// Der Befund: Jede Blu-ray ließ 30+ GB unter roh\ liegen, niemand räumte
// auf. Seit W-3 stand im Code „Löschen bekommt erst mit der Einstellung im
// UI eine Stimme". Das ist diese Stimme. Die Regel des Commanders
// (01.09.2026): „Nach 7 Tagen löschen, nur bei gelungener Kompression".
//
// Drei Sicherungen, alle bewusst:
// 1. Gelöscht wird NUR ein Roh-Ordner, dessen Vorgang FERTIG und
// KOMPRIMIERT ist. Unkomprimierte Rips (4K verlustfrei) sind verschoben
// worden, ihr Roh-Ordner ist leer — es gibt dort nichts zu löschen.
// 2. Vorher wird nachgesehen, ob JEDE fertige Datei noch da ist und nicht
// leer — eine gelöschte Roh-Datei ohne heiles Ergebnis wäre der einzige
// Weg, wie Rippy Daten verlieren könnte.
// 3. Ordner unter roh\, die KEIN Vorgang kennt (aus der Zeit vor 5.4.0),
// werden nur gezeigt — löschen darf sie nur ein Klick des Nutzers.
import { existsSync, readdirSync, rmSync, statSync } from 'node:fs'
// Plattform-Pfade: Laufzeit-Wurzeln (Ablage-Einstellung), CI testet auf Linux.
import { join } from 'node:path'
import type { FremderOrdner } from '../../gemeinsam/nachrichten'
export type AufbewahrungsRegel = { art: 'behalten' } | { art: 'sofort' } | { art: 'tage'; tage: number }
/** Einstellungs-Schlüssel und Vorgabe (Commander-Wahl 01.09.2026). */
export const AUFBEWAHRUNG_SCHLUESSEL = 'rohAufbewahrung'
export const STANDARD_AUFBEWAHRUNG = '7'
/** 'behalten' | 'sofort' | '<Tage>' → Regel. Unbekanntes = Vorgabe. */
export function regelLesen(wert: string | null | undefined): AufbewahrungsRegel {
const text = (wert ?? '').trim().toLowerCase()
if (text === 'behalten' || text === 'nie') return { art: 'behalten' }
if (text === 'sofort' || text === '0') return { art: 'sofort' }
const tage = Number(text.length > 0 ? text : STANDARD_AUFBEWAHRUNG)
if (Number.isInteger(tage) && tage > 0 && tage <= 3650) return { art: 'tage', tage }
return { art: 'tage', tage: Number(STANDARD_AUFBEWAHRUNG) }
}
export function regelText(regel: AufbewahrungsRegel): string {
if (regel.art === 'behalten') return 'Roh-Dateien werden behalten — aufgeräumt wird nur von Hand.'
if (regel.art === 'sofort') return 'Roh-Dateien werden sofort nach gelungener Kompression gelöscht.'
return `Roh-Dateien werden ${regel.tage} Tage nach gelungener Kompression gelöscht.`
}
/** Wann die Frist eines fertigen, komprimierten Vorgangs abläuft — '' wenn nie. */
export function faelligAm(beendetIso: string | null, regel: AufbewahrungsRegel): string {
if (beendetIso === null || beendetIso.length === 0 || regel.art === 'behalten') return ''
const beendet = new Date(beendetIso)
if (Number.isNaN(beendet.getTime())) return ''
if (regel.art === 'sofort') return beendet.toISOString()
return new Date(beendet.getTime() + regel.tage * 24 * 60 * 60 * 1000).toISOString()
}
/** Das, was das Aufräumen über einen Vorgang wissen muss. */
export interface AufraeumKandidat {
id: string
phase: string
komprimiert: boolean
rohOrdner: string
rohGeloeschtAm: string | null
beendet: string | null
fertigeDateien: readonly string[]
}
export interface AufraeumZugriff {
existiert: (pfad: string) => boolean
/** Dateigröße in Bytes; 0 wenn nicht lesbar. */
groesse: (pfad: string) => number
ordnerLoeschen: (pfad: string) => void
}
export const ECHTER_ZUGRIFF: AufraeumZugriff = {
existiert: existsSync,
groesse: (pfad) => {
try {
return statSync(pfad).size
} catch {
// Nicht lesbar zählt wie „nicht da" — und dann wird NICHT gelöscht.
return 0
}
},
ordnerLoeschen: (pfad) => rmSync(pfad, { recursive: true, force: true }),
}
/** Warum ein Vorgang (noch) nicht gelöscht wird — '' heißt: löschbar. */
export function loeschHindernis(
v: AufraeumKandidat,
regel: AufbewahrungsRegel,
jetzt: Date,
zugriff: AufraeumZugriff = ECHTER_ZUGRIFF,
): string {
if (v.rohGeloeschtAm !== null) return 'schon gelöscht'
if (v.phase !== 'fertig') return `nicht fertig (${v.phase})`
if (!v.komprimiert) return 'nicht komprimiert — die Roh-Dateien SIND das Ergebnis'
if (!zugriff.existiert(v.rohOrdner)) return 'Roh-Ordner ist schon weg'
if (v.fertigeDateien.length === 0) return 'keine fertige Datei bekannt'
for (const datei of v.fertigeDateien) {
if (!zugriff.existiert(datei)) return `fertige Datei fehlt: ${datei}`
if (zugriff.groesse(datei) <= 0) return `fertige Datei ist leer: ${datei}`
}
if (regel.art === 'behalten') return 'Regel: behalten'
const frist = faelligAm(v.beendet, regel)
if (frist.length === 0) return 'kein Ende-Zeitpunkt bekannt'
if (new Date(frist).getTime() > jetzt.getTime()) return `Frist läuft bis ${frist.slice(0, 16).replace('T', ' ')}`
return ''
}
export interface AufraeumErgebnis {
geloescht: string[]
uebersprungen: Array<{ id: string; grund: string }>
fehler: Array<{ id: string; grund: string }>
}
/** Wendet die Regel an. Liefert, was gelöscht wurde und warum der Rest blieb. */
export function aufraeumen(
vorgaenge: readonly AufraeumKandidat[],
regel: AufbewahrungsRegel,
jetzt: Date,
zugriff: AufraeumZugriff = ECHTER_ZUGRIFF,
): AufraeumErgebnis {
const ergebnis: AufraeumErgebnis = { geloescht: [], uebersprungen: [], fehler: [] }
for (const v of vorgaenge) {
const hindernis = loeschHindernis(v, regel, jetzt, zugriff)
if (hindernis.length > 0) {
ergebnis.uebersprungen.push({ id: v.id, grund: hindernis })
continue
}
try {
zugriff.ordnerLoeschen(v.rohOrdner)
ergebnis.geloescht.push(v.id)
} catch (fehler) {
// R4: Ein Ordner, der sich nicht löschen lässt (offene Datei?), wird
// gesagt — beim nächsten Takt kommt er wieder dran.
ergebnis.fehler.push({ id: v.id, grund: String(fehler) })
}
}
return ergebnis
}
/** Belegung eines Ordners in Bytes (rekursiv). 0, wenn es ihn nicht gibt. */
export function ordnerGroesse(pfad: string): number {
let summe = 0
const gehen = (ordner: string): void => {
let eintraege: import('node:fs').Dirent[]
try {
eintraege = readdirSync(ordner, { withFileTypes: true })
} catch {
// Nicht lesbar zählt nicht mit — die Anzeige ist dann zu klein, nie
// erfunden.
return
}
for (const e of eintraege) {
const voll = join(ordner, e.name)
if (e.isDirectory()) gehen(voll)
else {
try {
summe += statSync(voll).size
} catch {
// siehe oben
}
}
}
}
if (existsSync(pfad)) gehen(pfad)
return summe
}
/** Ordner unter roh\, die kein Vorgang kennt — nur zeigen, nie von selbst löschen. */
export function fremdeOrdner(rohWurzel: string, bekannte: ReadonlySet<string>): FremderOrdner[] {
if (!existsSync(rohWurzel)) return []
const liste: FremderOrdner[] = []
let namen: string[]
try {
namen = readdirSync(rohWurzel, { withFileTypes: true })
.filter((e) => e.isDirectory())
.map((e) => e.name)
} catch (fehler) {
// R4: Ein nicht lesbarer Roh-Ordner ist eine laute Zeile — die Liste
// bleibt dann leer, weil wirklich nichts gesehen wurde.
console.error(`[roh] ${rohWurzel} nicht lesbar: ${String(fehler)}`)
return liste
}
for (const name of namen) {
const voll = join(rohWurzel, name)
if (bekannte.has(voll) || bekannte.has(name)) continue
liste.push({ ordner: voll, bytes: ordnerGroesse(voll) })
}
return liste.sort((a, b) => b.bytes - a.bytes)
}
/** Ist dieser Pfad ein Ordner DIREKT unter der Roh-Wurzel? Nur solche
* darf fremden Ordner löschen" anfassen nie etwas anderes. */
export function istUnterRohWurzel(rohWurzel: string, pfad: string): boolean {
const wurzel = rohWurzel.replace(/[\\/]+$/, '')
const kandidat = pfad.replace(/[\\/]+$/, '')
if (!kandidat.toLowerCase().startsWith(wurzel.toLowerCase())) return false
const rest = kandidat.slice(wurzel.length)
return /^[\\/][^\\/]+$/.test(rest)
}
+5 -17
View File
@@ -11,23 +11,11 @@ import { existsSync, readdirSync, renameSync } from 'node:fs'
// müssen dort physisch funktionieren. Feste Windows-Muster (Katalog,
// %ProgramFiles%) bleiben dagegen bei path.win32.
import { join } from 'node:path'
// EINE Namensregel für Kern und Fenster (5.4.0: die Zielpfad-Vorschau im
// Auswahl-Dialog rechnet mit derselben Funktion).
import { sichererName, staffelOrdnerName } from '../../gemeinsam/ablagepfad'
// Zeichen, die in Ordnernamen auf SMB/NTFS/ext4 Ärger machen.
// eslint-Hinweis unnötig: bewusst inklusive Steuerzeichen.
// eslint-disable-next-line no-control-regex
const VERBOTEN = /[<>:"/\\|?*\x00-\x1f]/g
/** Titel → dateisystem-tauglicher Ordnername (pur). */
export function sichererName(titel: string, jahr?: number | string | null): string {
let name = (titel ?? '').replace(VERBOTEN, '').trim().replace(/\.+$/, '')
name = name.replace(/\s+/g, ' ').slice(0, 150).trim()
if (name.length === 0) return ''
// Kein doppeltes Jahr: OMDb-Titel tragen es teils schon im Namen.
if (jahr !== undefined && jahr !== null && String(jahr).length > 0 && !name.endsWith(`(${jahr})`)) {
name = `${name} (${jahr})`
}
return name
}
export { sichererName }
/** Zielordner: sprechender Name; existiert er schon (Disc doppelt
* gerippt), wird die Job-Kennung angehängt statt zu überschreiben. */
@@ -48,7 +36,7 @@ export function zielordner(
/** Jellyfin-Konvention: <basis>/<Serie>/Season NN (pur). */
export function serienOrdner(basis: string, serie: string, staffel: number): string {
const name = sichererName(serie) || 'Unbekannte Serie'
return join(basis, name, `Season ${String(Math.trunc(staffel)).padStart(2, '0')}`)
return join(basis, name, staffelOrdnerName(staffel))
}
/** Ordnet Dateien Episoden zu über Laufzeiten, ordnungserhaltend.
+393
View File
@@ -0,0 +1,393 @@
// Kompression + Ablage EINES Vorgangs (5.4.0) — die zweite Hälfte der
// Kette aus pipeline.ts, herausgelöst, damit sie OHNE Laufwerk läuft:
// * in der Warteschlange, während das Laufwerk schon die nächste Disc
// liest (Commander-Wunsch: „Nächste Disc rippen, während die vorige
// noch komprimiert"),
// * und Tage später noch einmal — „Wiederholen ab Kompression": Die
// Roh-Dateien liegen genau dafür da, ein Preset-Wechsel kostet kein
// Neulesen mehr.
//
// Was hier NEU ist gegenüber 5.3.x:
// * Ohne Kompression (4K verlustfrei, kein HandBrake) werden die Roh-
// Dateien in den Zielordner VERSCHOBEN — mit NFO, Poster, Bibliothek.
// Vorher blieben sie unter roh\ liegen, und nichts wusste von ihnen.
// * Sprachen JE DATEI (auftrag.ts sprachwahl) statt einer globalen Vorgabe.
// * Vor der Kompression wird die Roh-Datei GEFRAGT (HandBrake-Scan), ob
// die gewünschten Sprachen drin sind — fehlt eine, steht es im Protokoll.
// * Folgen von Hand („Staffel 1, ab Folge 3") schlagen die Laufzeit-Raterei;
// TMDb gibt dann nur noch eine Warnung.
// * Durchgehender Fortschritt, nach Dateigröße gewichtet.
import {
copyFileSync,
existsSync,
mkdirSync,
readdirSync,
renameSync,
statSync,
unlinkSync,
writeFileSync,
} from 'node:fs'
// 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 { baueNfo } from '../ablage/nfo'
import { bibliothekRefresh } from '../ablage/medienserver'
import { posterSpeichern } from '../ablage/poster'
import { episodenUmbenennen, matcheEpisoden, serienOrdner, zielordner } from '../ablage/struktur'
import { komprimieren } from '../komprimieren/handbrake'
import { komprimierenFuer, presetFuer, sprachliste, type Einstellungen } from '../komprimieren/presets'
import { fehlendeSprachen, spurenScannen, type HbSpuren } from '../komprimieren/scan'
import {
folgenNummern,
folgenPlausibel,
gewichteterFortschritt,
sprachwahl,
type KompressionsAuftrag,
} from './auftrag'
export interface AblageUmgebung {
/** Pfad zu HandBrakeCLI — oder '' (dann werden die Roh-Dateien verschoben). */
handbrake: () => string
/** Wurzel der Ablage. */
ablage: () => string
einstellungen: () => Einstellungen
/** Gemessene Preset-Empfehlung (Hardware zuerst) für diesen Disc-Typ. */
presetEmpfehlung?: (discTyp: string) => string
/** Laufzeiten einer Staffel von TMDb: [Episodennummer, Sekunden]. */
episodenLaufzeiten?: (
tmdbId: number,
staffel: number,
) => Promise<ReadonlyArray<readonly [number, number]>>
bibliothekEintragen?: (eintrag: BibliothekEintrag) => void
jetzt?: () => Date
/** Roh-Datei ins Ziel verschieben — injizierbar für Tests. */
verschieben?: (von: string, nach: string) => void
/** Spuren der Roh-Datei messen — injizierbar (Standard: HandBrake-Scan). */
spurenScannen?: (programm: string, datei: string) => Promise<HbSpuren | null>
}
export interface AblageErgebnis {
status: 'fertig' | 'fehler' | 'abgebrochen'
zielOrdner: string
dateien: string[]
text: string
komprimiert: boolean
preset: string
fertigBytes: number
hinweise: string[]
}
export type AblageMeldung = (stand: Pick<KompressionStand, 'phase' | 'prozent' | 'text'>) => void
/** Verschieben auf demselben Datenträger ist ein Umbenennen; über
* Datenträger-Grenzen (EXDEV) hilft nur Kopieren + Löschen. */
export function dateiVerschieben(von: string, nach: string): void {
try {
renameSync(von, nach)
} catch (fehler) {
const code = (fehler as NodeJS.ErrnoException).code
if (code !== 'EXDEV') throw fehler
copyFileSync(von, nach)
unlinkSync(von)
}
}
function groesseVon(pfad: string): number {
try {
return statSync(pfad).size
} catch {
// Größe ist Anzeige-Komfort — eine unlesbare Datei ändert nichts am
// Ergebnis selbst.
return 0
}
}
export async function komprimierenUndAblegen(
auftrag: KompressionsAuftrag,
umgebung: AblageUmgebung,
melden: AblageMeldung,
abbruch: AbortSignal,
): Promise<AblageErgebnis> {
const einstellungen = umgebung.einstellungen()
const handbrake = umgebung.handbrake()
const soll = komprimierenFuer(auftrag.discTyp, einstellungen)
const komprimiert = soll && handbrake.length > 0
const preset =
auftrag.preset.length > 0
? auftrag.preset
: presetFuer(auftrag.discTyp, einstellungen, umgebung.presetEmpfehlung?.(auftrag.discTyp) ?? '')
const zuordnung = auftrag.zuordnung
const hinweise: string[] = [...auftrag.hinweise]
const dateien = [...auftrag.dateien].sort((a, b) => a.pfad.localeCompare(b.pfad))
// ── WOHIN? (§ 4.2) ──────────────────────────────────────────────────
// Serien gehören NICHT unter Filme/ — sonst sieht Jellyfin lauter
// Einzelfilme statt einer Staffel. Serie ist es, wenn der Auftrag
// Folgen enthält (der Nutzer hat es im Dialog so bestätigt).
const alsSerie = dateien.some((d) => d.rolle === 'folge')
const staffel = auftrag.staffel ?? 1
const serienTitel = zuordnung !== null && zuordnung.titel.length > 0 ? zuordnung.titel : auftrag.vorgangId
const fertigOrdner =
auftrag.zielOrdner.length > 0
? auftrag.zielOrdner
: alsSerie
? serienOrdner(join(umgebung.ablage(), 'Serien'), serienTitel, staffel)
: zuordnung !== null && zuordnung.titel.length > 0
? zielordner(join(umgebung.ablage(), 'Filme'), zuordnung.titel, zuordnung.jahr, auftrag.vorgangId)
: zielordner(join(umgebung.ablage(), 'Filme'), auftrag.vorgangId, null, auftrag.vorgangId)
mkdirSync(fertigOrdner, { recursive: true })
// Extras liegen im Unterordner „extras" NEBEN dem Hauptfilm — so lesen
// Jellyfin, Emby und Kodi sie als Zugaben und nicht als zweiten Film.
const extrasOrdner = join(fertigOrdner, 'extras')
const zielFuer = (rolle: string): string => (rolle === 'extra' ? extrasOrdner : fertigOrdner)
if (dateien.some((d) => d.rolle === 'extra')) mkdirSync(extrasOrdner, { recursive: true })
const global = {
audio: sprachliste(String(einstellungen['audioSprachen'] ?? '')),
untertitel: sprachliste(String(einstellungen['untertitelSprachen'] ?? '')),
}
const gewichte = dateien.map((d) => d.groesseBytes)
const scannen = umgebung.spurenScannen ?? spurenScannen
const verschieben = umgebung.verschieben ?? dateiVerschieben
const fertigeDateien: string[] = []
// Laufzeiten der FOLGEN in Ablage-Reihenfolge — Grundlage der
// Episoden-Zuordnung weiter unten.
const folgenSekunden: number[] = []
for (let i = 0; i < dateien.length; i++) {
const datei = dateien[i]
const ausgabe = join(zielFuer(datei.rolle), basename(datei.pfad))
if (abbruch.aborted) {
return abgebrochen(fertigOrdner, fertigeDateien, preset, komprimiert, hinweise)
}
if (!komprimiert) {
melden({
phase: 'legt-ab',
prozent: gewichteterFortschritt(gewichte, i, 0),
text: `Verschiebe ${i + 1}/${dateien.length}: ${basename(datei.pfad)} (ohne Kompression) …`,
})
try {
verschieben(datei.pfad, ausgabe)
} catch (fehler) {
return {
status: 'fehler',
zielOrdner: fertigOrdner,
dateien: fertigeDateien,
text: `Verschieben fehlgeschlagen: ${String(fehler)} — die Roh-Dateien bleiben stehen (${auftrag.rohOrdner}).`,
komprimiert: false,
preset: '',
fertigBytes: 0,
hinweise,
}
}
fertigeDateien.push(ausgabe)
if (datei.rolle === 'folge') folgenSekunden.push(datei.dauerS)
continue
}
const wahl = sprachwahl(datei, global)
// Die Roh-Datei fragen, ob die gewünschten Sprachen wirklich drin sind
// (scan.ts). Nur wenn der Nutzer etwas JE TITEL gewählt hat — die
// globale Vorgabe war schon immer „was da ist".
if (datei.audioSprachen.length > 0 || datei.untertitelSprachen.length > 0) {
const spuren = await scannen(handbrake, datei.pfad)
if (spuren !== null) {
const fehlt = fehlendeSprachen(spuren, wahl)
if (fehlt.audio.length > 0) {
hinweise.push(
`${basename(datei.pfad)}: Tonspur ${fehlt.audio.join(', ')} ist NICHT in der Roh-Datei — MakeMKV hat sie nicht mitgenommen (Auswahlregel prüfen).`,
)
}
if (fehlt.untertitel.length > 0) {
hinweise.push(
`${basename(datei.pfad)}: Untertitel ${fehlt.untertitel.join(', ')} sind NICHT in der Roh-Datei.`,
)
}
} else {
hinweise.push(`${basename(datei.pfad)}: Spuren der Roh-Datei ließen sich nicht messen — komprimiert wie gewünscht.`)
}
}
melden({
phase: 'komprimiert',
prozent: gewichteterFortschritt(gewichte, i, 0),
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)' : ''}` : ''} `,
})
const encode = await komprimieren({
programm: handbrake,
eingabe: datei.pfad,
ausgabe,
preset,
audioSprachen: wahl.audio,
untertitelSprachen: wahl.untertitel,
untertitelStandard: wahl.untertitelStandard,
abbruch,
aufFortschritt: (prozent) => {
melden({
phase: 'komprimiert',
prozent: gewichteterFortschritt(gewichte, i, prozent),
text: `Komprimiere ${i + 1}/${dateien.length}: ${basename(datei.pfad)} („${preset}") …`,
})
},
})
if (encode.status === 'cancelled') {
return abgebrochen(fertigOrdner, fertigeDateien, preset, komprimiert, hinweise)
}
if (encode.status === 'error') {
return {
status: 'fehler',
zielOrdner: fertigOrdner,
dateien: fertigeDateien,
text: `Kompression fehlgeschlagen: ${encode.fehler} — der verlustfreie Rip bleibt stehen (${auftrag.rohOrdner}).`,
komprimiert: true,
preset,
fertigBytes: 0,
hinweise,
}
}
fertigeDateien.push(encode.ausgabe)
if (datei.rolle === 'folge') folgenSekunden.push(datei.dauerS)
}
// ── Serien-Staffelablage (§ 4.2) ────────────────────────────────────
// Von Hand gesetzte Folgen („ab Folge 3") schlagen die Laufzeit-
// Zuordnung; TMDb darf dann nur noch warnen. Ohne Vorgabe wird
// umbenannt NUR bei EINDEUTIGER Zuordnung — lieber gar nicht als falsch
// (die offene Wunde #395 von ARM war genau dieses Ratespiel).
if (alsSerie && folgenSekunden.length > 0) {
const tmdbId = typeof zuordnung?.meta.id === 'number' ? zuordnung.meta.id : null
const laufzeiten =
tmdbId !== null ? ((await umgebung.episodenLaufzeiten?.(tmdbId, staffel)) ?? []) : []
let episoden: number[] | null = null
if (auftrag.ersteFolge !== null && auftrag.ersteFolge > 0) {
episoden = folgenNummern(folgenSekunden.length, auftrag.ersteFolge)
hinweise.push(`Folgen von Hand: Staffel ${staffel}, ab Folge ${auftrag.ersteFolge}.`)
hinweise.push(...folgenPlausibel(folgenSekunden, episoden, laufzeiten))
} else {
episoden = laufzeiten.length > 0 ? matcheEpisoden(folgenSekunden, laufzeiten) : null
}
if (episoden !== null) {
hinweise.push(...episodenUmbenennen(fertigOrdner, serienTitel, staffel, episoden))
} else {
hinweise.push(
laufzeiten.length === 0
? 'Episoden-Nummern unbekannt (keine Staffel-Laufzeiten von TMDb) — die Dateien behalten ihre Namen. Tipp: „Staffel und Folge von Hand" im Auswahl-Dialog.'
: 'Episoden ließen sich nicht EINDEUTIG zuordnen — die Dateien behalten ihre Namen, lieber gar nicht als falsch.',
)
}
}
// ── Ablage: NFO und Poster daneben ───────────────────────────────────
melden({ phase: 'legt-ab', prozent: 100, text: 'Lege ab …' })
const anzeigeTitel = zuordnung !== null && zuordnung.titel.length > 0 ? zuordnung.titel : auftrag.vorgangId
try {
const nfoName = zuordnung?.meta.typ === 'tv' ? 'tvshow.nfo' : 'movie.nfo'
writeFileSync(
join(fertigOrdner, nfoName),
baueNfo(
{
typ: zuordnung?.meta.typ === 'tv' ? 'serie' : 'film',
beschreibung: zuordnung?.meta.beschreibung,
genres: zuordnung?.meta.genres,
laufzeitMinuten: zuordnung?.meta.laufzeitMinuten,
quelle:
zuordnung !== null && zuordnung.meta.quelle.length > 0 ? `Rippy v5 (${zuordnung.meta.quelle})` : 'Rippy v5',
},
anzeigeTitel,
zuordnung?.jahr ?? null,
),
)
} catch (fehler) {
// Ein fehlendes NFO darf einen gelungenen Rip nicht kippen — aber es
// wird GESAGT (R4).
hinweise.push(`NFO fehlgeschlagen: ${String(fehler)}`)
}
if (zuordnung !== null && zuordnung.meta.posterPfad.length > 0) {
await posterSpeichern(zuordnung.meta.posterPfad, join(fertigOrdner, 'poster.jpg'))
}
const server = String(einstellungen['mediaServer'] ?? '')
const serverUrl = String(einstellungen['mediaServerUrl'] ?? '')
const serverKey = String(einstellungen['mediaServerApiKey'] ?? '')
const refresh = await bibliothekRefresh(server, serverUrl, serverKey)
// Umbenannte Folgen: Die Liste der fertigen Dateien muss den NEUEN
// Namen tragen — sonst prüft das Aufräumen später auf Dateien, die es
// unter dem alten Namen nicht mehr gibt.
const fertigJetzt = fertigeDateien.map((d) => (existsSync(d) ? d : '')).filter((d) => d.length > 0)
const endgueltig = fertigJetzt.length === fertigeDateien.length ? fertigeDateien : vorhandeneMkv(fertigOrdner, extrasOrdner)
let fertigBytes = 0
for (const datei of endgueltig) fertigBytes += groesseVon(datei)
// § 6.6: Merken — beim nächsten Einlegen wird die Disc wiedererkannt.
const jetzt = umgebung.jetzt?.() ?? new Date()
if (auftrag.fingerprint !== null && umgebung.bibliothekEintragen !== undefined) {
const begonnen = auftrag.begonnen.length > 0 ? new Date(auftrag.begonnen) : jetzt
umgebung.bibliothekEintragen({
fingerprint: auftrag.fingerprint,
titel: anzeigeTitel,
jahr: zuordnung?.jahr ?? null,
typ: auftrag.discTyp,
groesseBytes: fertigBytes,
ablageort: fertigOrdner,
datum: begonnen.toISOString(),
dauerS: Math.max(0, Math.round((jetzt.getTime() - begonnen.getTime()) / 1000)),
posterPfad: zuordnung?.meta.posterPfad ?? '',
})
}
const text =
(komprimiert ? `Fertig — komprimiert nach ${fertigOrdner}.` : `Fertig — verlustfrei abgelegt unter ${fertigOrdner} (ohne Kompression).`) +
(refresh.length > 0 ? ` ${refresh}` : '') +
(auftrag.lesefehler
? ' ⚠ Die Disc ließ sich stellenweise nicht lesen — mindestens ein Titel könnte fehlen (makemkv.com/errors/read).'
: '')
return {
status: 'fertig',
zielOrdner: fertigOrdner,
dateien: endgueltig,
text,
komprimiert,
preset: komprimiert ? preset : '',
fertigBytes,
hinweise,
}
}
function abgebrochen(
zielOrdner: string,
dateien: string[],
preset: string,
komprimiert: boolean,
hinweise: string[],
): AblageErgebnis {
return {
status: 'abgebrochen',
zielOrdner,
dateien,
text: 'Abgebrochen — der verlustfreie Rip bleibt stehen.',
komprimiert,
preset,
fertigBytes: 0,
hinweise,
}
}
/** Alle MKVs im Zielordner (und in extras\) nach dem Umbenennen der Folgen
* ist das die einzige verlässliche Liste. */
function vorhandeneMkv(ordner: string, extras: string): string[] {
const liste: string[] = []
for (const o of [ordner, extras]) {
if (!existsSync(o)) continue
try {
for (const name of readdirSync(o)) if (name.toLowerCase().endsWith('.mkv')) liste.push(join(o, name))
} catch {
// Ein nicht lesbarer Ordner liefert keine Dateien — die Bibliothek
// zeigt dann 0 Bytes, das Ergebnis selbst bleibt ein Erfolg.
}
}
return liste.sort()
}
+177
View File
@@ -0,0 +1,177 @@
// Der Kompressions-Auftrag (5.4.0) — PUR, ohne Laufwerk und ohne Netz.
//
// Er ist das Bindeglied zwischen zwei Phasen, die seit 5.4.0 getrennt
// laufen: Der RIP belegt das Laufwerk und endet, sobald die Roh-Dateien
// liegen; die KOMPRESSION läuft danach in einer Warteschlange und braucht
// 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 { Zuordnung } from '../metadaten/zuordnung'
export interface RohDatei {
pfad: string
titelNr: number
rolle: TitelRolle
dauerS: number
/** Größe der Roh-Datei — gewichtet den Fortschrittsbalken. */
groesseBytes: number
/** Sprachen je Datei (leer = die globale Einstellung gilt). */
audioSprachen: string[]
untertitelSprachen: string[]
}
export interface KompressionsAuftrag {
vorgangId: string
laufwerk: string
discTyp: string
rohOrdner: string
dateien: RohDatei[]
/** Schnappschuss der Metadaten-Zuordnung zum Zeitpunkt des Rips. */
zuordnung: Zuordnung | null
/** Staffel (Nutzer > Disc > 1) und erste Folge (nur vom Nutzer). */
staffel: number | null
ersteFolge: number | null
discNr: number | null
fingerprint: string | null
lesefehler: boolean
/** Klartext aus dem Rip („Übersprungen: …", „Nicht gesichert: …"). */
hinweise: string[]
/** Beginn des Rips (ISO) — die Bibliothek merkt sich diesen Zeitpunkt. */
begonnen: string
/** Preset-Vorgabe beim Wiederholen; '' = die Einstellung gilt. */
preset: string
/** Beim Wiederholen: der Zielordner des ERSTEN Durchlaufs die neue
* Fassung ersetzt die alte, statt einen zweiten Ordner anzulegen.
* '' = Zielordner wird frisch bestimmt. */
zielOrdner: string
}
export function auftragZuJson(auftrag: KompressionsAuftrag): string {
return JSON.stringify(auftrag)
}
/** Liest einen gemerkten Auftrag zurück null, wenn er nicht die Form hat
* (eine Datenbank aus einer späteren Fassung, ein Fremdeintrag). */
export function auftragAusJson(text: string): KompressionsAuftrag | null {
let roh: unknown
try {
roh = JSON.parse(text)
} catch {
return null
}
if (typeof roh !== 'object' || roh === null) return null
const a = roh as Record<string, unknown>
if (typeof a.vorgangId !== 'string' || typeof a.rohOrdner !== 'string' || !Array.isArray(a.dateien)) return null
const dateien: RohDatei[] = []
for (const d of a.dateien) {
if (typeof d !== 'object' || d === null) return null
const e = d as Record<string, unknown>
if (typeof e.pfad !== 'string') return null
dateien.push({
pfad: e.pfad,
titelNr: typeof e.titelNr === 'number' ? e.titelNr : 0,
rolle: e.rolle === 'extra' || e.rolle === 'folge' ? e.rolle : 'hauptfilm',
dauerS: typeof e.dauerS === 'number' ? e.dauerS : 0,
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) : [],
})
}
return {
vorgangId: a.vorgangId,
laufwerk: typeof a.laufwerk === 'string' ? a.laufwerk : '',
discTyp: typeof a.discTyp === 'string' ? a.discTyp : 'bluray',
rohOrdner: a.rohOrdner,
dateien,
zuordnung: typeof a.zuordnung === 'object' && a.zuordnung !== null ? (a.zuordnung as Zuordnung) : null,
staffel: typeof a.staffel === 'number' ? a.staffel : null,
ersteFolge: typeof a.ersteFolge === 'number' ? a.ersteFolge : null,
discNr: typeof a.discNr === 'number' ? a.discNr : null,
fingerprint: typeof a.fingerprint === 'string' ? a.fingerprint : null,
lesefehler: a.lesefehler === true,
hinweise: Array.isArray(a.hinweise) ? a.hinweise.map(String) : [],
begonnen: typeof a.begonnen === 'string' ? a.begonnen : '',
preset: typeof a.preset === 'string' ? a.preset : '',
zielOrdner: typeof a.zielOrdner === 'string' ? a.zielOrdner : '',
}
}
/** Was HandBrake für EINE Datei bekommt. */
export interface SprachWahl {
audio: string[]
untertitel: string[]
/** Die erste Untertitelspur vorwählen weil der Ton nicht in der
* Wunschsprache ist. */
untertitelStandard: boolean
}
/**
* Sprachwahl je Datei (pur): Was der Nutzer am Titel gewählt hat, schlägt
* die globale Einstellung. Und die Regel des Commanders (01.09.2026):
* japanischer Ton und braucht dringend die deutschen untertitel"
* spielt die ERSTE Tonspur nicht in der Wunschsprache (der ersten
* globalen Tonsprache), wird die Untertitelspur der Wunschsprache nach
* vorn gestellt und als Standard markiert. HandBrake ordnet die Spuren in
* Listen-Reihenfolge und `--subtitle-default=1` trifft die erste (gemessen,
* komprimieren/handbrake.ts).
*/
export function sprachwahl(
datei: { audioSprachen: readonly string[]; untertitelSprachen: readonly string[] },
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]
const wunsch = global.audio[0] ?? ''
const tonFremd = wunsch.length > 0 && audio.length > 0 && audio[0] !== wunsch
const untertitelStandard = tonFremd && untertitel.includes(wunsch)
if (untertitelStandard) untertitel = [wunsch, ...untertitel.filter((u) => u !== wunsch)]
return { audio, untertitel, untertitelStandard }
}
/** Folgen-Nummern von Hand: fortlaufend ab der ersten, in Disc-Reihenfolge. */
export function folgenNummern(anzahl: number, ersteFolge: number): number[] {
const start = Math.max(1, Math.trunc(ersteFolge))
return Array.from({ length: Math.max(0, anzahl) }, (_, i) => start + i)
}
/**
* Durchgehender Fortschritt über mehrere Teile, nach Gewicht (pur).
* Vorher sprang der Balken je Titel auf null zurück bei 2 × 16 GB stand
* er nach der Hälfte wieder bei 0 %. Fehlt ein Gewicht (0), zählen alle
* Teile gleich. `prozent` < 0 heißt vom laufenden Teil noch keine Angabe".
*/
export function gewichteterFortschritt(gewichte: readonly number[], index: number, prozent: number): number {
if (gewichte.length === 0) return -1
const gleich = gewichte.some((g) => !(g > 0))
const g = gleich ? gewichte.map(() => 1) : [...gewichte]
const gesamt = g.reduce((s, w) => s + w, 0)
if (gesamt <= 0) return -1
const i = Math.min(Math.max(0, index), g.length - 1)
const erledigt = g.slice(0, i).reduce((s, w) => s + w, 0)
const laufend = prozent < 0 ? 0 : (g[i] * Math.min(100, prozent)) / 100
return Math.max(0, Math.min(100, Math.floor(((erledigt + laufend) / gesamt) * 100)))
}
/** Passen die von Hand gesetzten Folgen zu den TMDb-Laufzeiten? Nur eine
* Warnung der Nutzer weiß, was auf seiner Disc ist. Rückgabe: Klartext
* je Abweichung (leer = alles plausibel oder nichts zum Vergleichen). */
export function folgenPlausibel(
dateiSekunden: readonly number[],
nummern: readonly number[],
laufzeiten: ReadonlyArray<readonly [nummer: number, sekunden: number]>,
toleranz = 0.15,
): string[] {
const warnungen: string[] = []
for (let i = 0; i < nummern.length; i++) {
const soll = laufzeiten.find(([nr]) => nr === nummern[i])
const ist = dateiSekunden[i] ?? 0
if (soll === undefined || soll[1] <= 0 || ist <= 0) continue
if (Math.abs(ist - soll[1]) / soll[1] > toleranz) {
warnungen.push(
`Folge ${nummern[i]}: Datei ${Math.round(ist / 60)} min, TMDb ${Math.round(soll[1] / 60)} min — bitte nachsehen.`,
)
}
}
return warnungen
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,159 @@
// Die Kompressions-Warteschlange (5.4.0) — KONZEPT § 4.2 nannte sie
// `auftraege.ts`: Warteschlange, Slots. Seit 5.4.0 sind Rip und Kompression
// entkoppelt (Commander-Wahl 01.09.2026: „Nächste Disc rippen, während
// die vorige noch komprimiert"). Das Laufwerk ist frei, sobald die
// Roh-Dateien liegen; die Kompressionen laufen hier der Reihe nach —
// EINE zur Zeit, denn zwei HandBrakes teilen sich dieselbe Grafikkarte und
// werden zusammen nicht schneller.
//
// PUR: Was ausgeführt wird, kommt injiziert (ablegen.ts) — die Ampel
// prüft das Verhalten der Schlange auf Linux ohne HandBrake.
import type { KompressionStand } from '../../gemeinsam/nachrichten'
import type { KompressionsAuftrag } from './auftrag'
import type { AblageErgebnis, AblageMeldung } from './ablegen'
export type Ausfuehren = (
auftrag: KompressionsAuftrag,
melden: AblageMeldung,
abbruch: AbortSignal,
) => Promise<AblageErgebnis>
interface Eintrag {
auftrag: KompressionsAuftrag
titel: string
abbruch: AbortController
}
export interface WarteschlangenStand {
laeuft: KompressionStand | null
wartend: KompressionStand[]
}
export class KompressionsWarteschlange {
private wartend: Eintrag[] = []
private laufend: Eintrag | null = null
private laufendStand: KompressionStand | null = null
private arbeitet = false
constructor(
private readonly ausfuehren: Ausfuehren,
/** Jede Änderung — Einreihen, Fortschritt, Ende — geht hier hinaus. */
private readonly beiStand: (stand: WarteschlangenStand) => void,
/** Ein Auftrag ist zu Ende (auch abgebrochen/gescheitert). */
private readonly beiErgebnis: (auftrag: KompressionsAuftrag, ergebnis: AblageErgebnis) => void | Promise<void>,
) {}
/** Reiht ein und liefert den Platz (1 = läuft jetzt oder als Nächstes).
* Ein Vorgang, der schon drin ist, wird nicht doppelt eingereiht. */
einreihen(auftrag: KompressionsAuftrag, titel: string): number {
if (this.laufend?.auftrag.vorgangId === auftrag.vorgangId) return 1
const schon = this.wartend.findIndex((e) => e.auftrag.vorgangId === auftrag.vorgangId)
if (schon >= 0) return schon + (this.laufend === null ? 1 : 2)
this.wartend.push({ auftrag, titel, abbruch: new AbortController() })
const platz = this.wartend.length + (this.laufend === null ? 0 : 1)
this.beiStand(this.stand())
void this.schleife()
return platz
}
/** Bricht einen wartenden (entfernt) oder laufenden (Signal) Auftrag ab. */
abbrechen(vorgangId: string): boolean {
const i = this.wartend.findIndex((e) => e.auftrag.vorgangId === vorgangId)
if (i >= 0) {
const [weg] = this.wartend.splice(i, 1)
this.beiStand(this.stand())
void this.beiErgebnis(weg.auftrag, {
status: 'abgebrochen',
zielOrdner: '',
dateien: [],
text: 'Aus der Warteschlange genommen — die Roh-Dateien bleiben stehen.',
komprimiert: false,
preset: '',
fertigBytes: 0,
hinweise: [],
})
return true
}
if (this.laufend?.auftrag.vorgangId === vorgangId) {
this.laufend.abbruch.abort()
return true
}
return false
}
stand(): WarteschlangenStand {
return {
laeuft: this.laufendStand === null ? null : { ...this.laufendStand },
wartend: this.wartend.map((e) => ({
vorgangId: e.auftrag.vorgangId,
titel: e.titel,
prozent: -1,
text: 'wartet',
phase: 'wartet',
})),
}
}
laeuftEtwas(): boolean {
return this.laufend !== null || this.wartend.length > 0
}
/** Liegt dieser Vorgang gerade in der Schlange (wartend oder laufend)? */
kennt(vorgangId: string): boolean {
return this.laufend?.auftrag.vorgangId === vorgangId || this.wartend.some((e) => e.auftrag.vorgangId === vorgangId)
}
private async schleife(): Promise<void> {
if (this.arbeitet) return
this.arbeitet = true
try {
while (this.wartend.length > 0) {
const eintrag = this.wartend.shift()!
this.laufend = eintrag
this.laufendStand = {
vorgangId: eintrag.auftrag.vorgangId,
titel: eintrag.titel,
prozent: -1,
text: 'Beginne …',
phase: 'komprimiert',
}
this.beiStand(this.stand())
let ergebnis: AblageErgebnis
try {
ergebnis = await this.ausfuehren(
eintrag.auftrag,
(stand) => {
if (this.laufendStand === null) return
this.laufendStand = { ...this.laufendStand, ...stand }
this.beiStand(this.stand())
},
eintrag.abbruch.signal,
)
} catch (fehler) {
// R4: Eine Ausnahme mitten im Encode ist ein lauter Fehlschlag,
// kein stilles Ende der Schlange.
ergebnis = {
status: 'fehler',
zielOrdner: '',
dateien: [],
text: `Kompression abgestürzt: ${String(fehler)} — die Roh-Dateien bleiben stehen.`,
komprimiert: false,
preset: '',
fertigBytes: 0,
hinweise: [],
}
}
this.laufend = null
this.laufendStand = null
try {
await this.beiErgebnis(eintrag.auftrag, ergebnis)
} catch (fehler) {
console.error(`[warteschlange] Ergebnis-Verarbeitung: ${String(fehler)}`)
}
this.beiStand(this.stand())
}
} finally {
this.arbeitet = false
}
}
}
+599 -127
View File
@@ -3,19 +3,44 @@
// Datenbank (node:sqlite, § 3.4), Disc-Wache im 3-Sekunden-Takt (§ 5),
// Auswerfen mit Nachsehen (R3), Ping/Pong über den direkten MessagePort.
//
// Seit 5.4.0 dazu: Vorgänge als Datensätze (welcher Roh-Ordner gehört zu
// welchem Film), die Kompressions-Warteschlange (das Laufwerk ist frei,
// sobald die Roh-Dateien liegen), das Roh-Aufräumen im Takt, die
// Wiederaufnahme nach einem Neustart, die Laufwerks-Prüfung aus dem
// Windows-Protokoll und die Nachprüfung der Erkennung nach dem Info-Lauf.
//
// parentPort/MessagePortMain: Schnittstelle laut Electron-Doku »utilityProcess«
// und »MessagePorts in Electron« — dieselbe Verdrahtung wie im gemessenen
// Beweis beweise/sqlite-main.js.
import type { MessagePortMain } from 'electron'
import { existsSync, rmSync } from 'node:fs'
import { join } from 'node:path'
import {
istFensterNachricht,
istHauptNachricht,
type DbStatus,
type DiscInfoStand,
type GeraeteInfo,
type KernNachricht,
type MetaVorschlag,
type VorgaengeStand,
type VorgangEintrag,
type WacheEreignis,
} from '../gemeinsam/nachrichten'
import { komprimierenUndAblegen, type AblageUmgebung } from './ablauf/ablegen'
import { auftragAusJson, auftragZuJson } from './ablauf/auftrag'
import { Pipeline } from './ablauf/pipeline'
import { KompressionsWarteschlange } from './ablauf/warteschlange'
import {
AUFBEWAHRUNG_SCHLUESSEL,
aufraeumen,
faelligAm,
fremdeOrdner,
istUnterRohWurzel,
ordnerGroesse,
regelLesen,
regelText,
} from './ablage/rohaufraeumen'
import type { LaufwerkApi } from './laufwerk/codes'
import {
audioSpurLesen,
@@ -25,15 +50,17 @@ import {
kennung,
listOptischeLaufwerke,
} from './laufwerk/disc'
import { beurteilen, ereignisseLesen } from './laufwerk/ereignisse'
import { DiscWache } from './laufwerk/wache'
import { discFingerprint, discTitel, type DiscTitel } from './metadaten/discmerkmale'
import { OmdbClient } from './metadaten/omdb'
import { TmdbClient } from './metadaten/tmdb'
import { zuordnen, type Zuordnung } from './metadaten/zuordnung'
import { LEERER_TREFFER, ausDetails, nachpruefen, zuordnen, type Zuordnung } from './metadaten/zuordnung'
import { presetEmpfehlung } from '../gemeinsam/preset-empfehlung'
import { Datenbank } from './speicher/db'
import { Datenbank, type Vorgang } from './speicher/db'
import { titelInfoLesen, ABGELOEST } from './rip/makemkv'
import { titelVorauswahl } from './rip/titelwahl'
import { sprachenFuerTitel } from './rip/parser'
import { strukturHinweis, titelVorauswahl } from './rip/titelwahl'
import { finden } from './werkzeuge/katalog'
// --datenbank=<pfad> kommt vom Haupt (der Kern kennt Electrons app.getPath
@@ -72,6 +99,8 @@ if (pfad === null) {
}
}
const smokeKern = process.argv.includes('--smoke-kern')
let fensterPort: MessagePortMain | null = null
function anFenster(nachricht: KernNachricht): void {
@@ -83,6 +112,11 @@ function anHauptUndFenster(nachricht: KernNachricht): void {
anFenster(nachricht)
}
function hinweis(text: string): void {
console.log(`[kern] ${text}`)
anFenster({ art: 'kern-hinweis', text })
}
// ── Laufwerke (W-1) ─────────────────────────────────────────────────────
// koffi wird erst HIER geladen, über einen echten dynamischen import():
// Rollup legt win32.ts damit als eigenen Chunk neben kern.js (ein
@@ -100,6 +134,39 @@ const discFingerprints = new Map<string, string>()
// das bleibt wahr, auch wenn der Commander den Titel selbst neu wählt.
const discMerkmale = new Map<string, DiscTitel>()
/** EINE Stelle, die aus Zuordnung + Disc-Merkmalen den Stand fürs Fenster
* baut (5.4.0) vorher stand dieselbe Struktur dreimal im Code, und jedes
* neue Feld hätte dreimal nachgezogen werden müssen. */
function discInfoStand(id: string, zuordnung: Zuordnung | null, quelle?: string): DiscInfoStand {
const m = discMerkmale.get(id)
const meta = zuordnung?.meta
return {
id,
titel: zuordnung?.titel ?? '',
jahr: zuordnung?.jahr ?? null,
confidence: zuordnung?.confidence ?? 0,
quelle: quelle ?? meta?.quelle ?? '',
posterPfad: meta?.posterPfad ?? '',
backdropPfad: meta?.backdropPfad ?? '',
beschreibung: meta?.beschreibung ?? '',
// § 6.6: Rippy erkennt beim Einlegen, wenn eine Disc schon einmal
// durchgelaufen ist — und fragt nach, statt stumm zu doppeln.
schonGerippt: zuordnung === null ? null : (db?.bibliothekSchonGerippt(discFingerprints.get(id) ?? '') ?? null),
discNr: m?.discNr ?? null,
staffel: m?.staffel ?? null,
folgen: m?.folgen ?? 0,
typ: meta?.typ === 'movie' || meta?.typ === 'tv' ? meta.typ : '',
logoPfad: meta?.logoPfad ?? '',
tagline: meta?.tagline ?? '',
bewertung: meta?.bewertung ?? 0,
fsk: meta?.fsk ?? '',
laufzeitMinuten: meta?.laufzeitMinuten ?? 0,
genres: meta?.genres ?? [],
besetzung: meta?.besetzung ?? [],
hinweise: zuordnung?.hinweise ?? [],
}
}
async function discErmitteln(id: string, geraetepfad: string, groesseBytes: number): Promise<void> {
try {
const gefunden = discTitel(geraetepfad)
@@ -109,28 +176,14 @@ async function discErmitteln(id: string, geraetepfad: string, groesseBytes: numb
const tmdb = new TmdbClient(db?.einstellungLesen('tmdbApiKey') ?? '')
const omdb = new OmdbClient(db?.einstellungLesen('omdbApiKey') ?? '')
// Der Hinweis kommt von der Disc selbst (bdmt-Inhaltsverzeichnis) —
// trägt sie Folgen, kann kein Filmtreffer mehr gewinnen.
const zuordnung = await zuordnen(gefunden.titel, tmdb, omdb, { istSerie: gefunden.istSerie })
discZuordnungen.set(id, zuordnung)
anHauptUndFenster({
art: 'disc-info',
info: {
id,
titel: zuordnung.titel,
jahr: zuordnung.jahr,
confidence: zuordnung.confidence,
quelle: zuordnung.meta.quelle,
posterPfad: zuordnung.meta.posterPfad,
backdropPfad: zuordnung.meta.backdropPfad,
beschreibung: zuordnung.meta.beschreibung,
// § 6.6: Rippy erkennt beim Einlegen, wenn eine Disc schon einmal
// durchgelaufen ist — und fragt nach, statt stumm zu doppeln.
schonGerippt: db?.bibliothekSchonGerippt(fingerprint) ?? null,
discNr: gefunden.discNr,
staffel: gefunden.staffel,
folgen: gefunden.folgen,
},
// trägt sie Folgen, kann kein Filmtreffer mehr gewinnen. Und jede
// Titel-Variante der Disc (bdmt_deu) wird mitgefragt (5.4.0).
const zuordnung = await zuordnen(gefunden.titel, tmdb, omdb, {
istSerie: gefunden.istSerie,
alternativen: gefunden.alternativen,
})
discZuordnungen.set(id, zuordnung)
anHauptUndFenster({ art: 'disc-info', info: discInfoStand(id, zuordnung) })
} catch (fehler) {
// R4: Eine gescheiterte Erkennung ist eine laute Zeile — gerippt
// werden kann trotzdem (die Zuordnung ist Komfort, kein Torwächter).
@@ -183,50 +236,80 @@ async function zuordnungSetzen(id: string, vorschlag: MetaVorschlag): Promise<vo
const tmdb = new TmdbClient(db?.einstellungLesen('tmdbApiKey') ?? '')
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: (vorschlag.typ === 'movie' ? details?.title : details?.name) ?? vorschlag.titel,
jahr: jahrAus(vorschlag.typ === 'movie' ? details?.release_date : details?.first_air_date) ?? vorschlag.jahr,
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: {
typ: vorschlag.typ,
id: vorschlag.tmdbId,
titel: (vorschlag.typ === 'movie' ? details?.title : details?.name) ?? vorschlag.titel,
jahr: jahrAus(vorschlag.typ === 'movie' ? details?.release_date : details?.first_air_date) ?? vorschlag.jahr,
beschreibung: details?.overview ?? '',
posterPfad: details?.poster_path ?? vorschlag.posterPfad,
backdropPfad: details?.backdrop_path ?? '',
laufzeitMinuten: details?.runtime ?? 0,
genres: (details?.genres ?? []).map((g) => g.name),
quelle: 'tmdb',
},
meta,
}
discZuordnungen.set(id, zuordnung)
anHauptUndFenster({
art: 'disc-info',
info: {
id,
titel: zuordnung.titel,
jahr: zuordnung.jahr,
confidence: 1,
quelle: 'deine Wahl',
posterPfad: zuordnung.meta.posterPfad,
backdropPfad: zuordnung.meta.backdropPfad,
beschreibung: zuordnung.meta.beschreibung,
schonGerippt: db?.bibliothekSchonGerippt(discFingerprints.get(id) ?? '') ?? null,
// Eigene Titelwahl ändert nichts daran, welche Disc der Ausgabe hier liegt.
discNr: discMerkmale.get(id)?.discNr ?? null,
staffel: discMerkmale.get(id)?.staffel ?? null,
folgen: discMerkmale.get(id)?.folgen ?? 0,
},
})
anHauptUndFenster({ art: 'disc-info', info: discInfoStand(id, zuordnung, 'deine Wahl') })
} catch (fehler) {
anFenster({ art: 'kern-fehler', text: `Zuordnung übernehmen: ${String(fehler)}` })
}
}
// Das nachgebaute Laufwerk des Klick-Beweises (bau/klick-smoke.mjs): Ein
// Buchstabe, den es nicht gibt (Q) — die Disc-Merkmale finden dann nichts,
// und makemkvcon/HandBrake sind Node-Skripte (werkzeuge/aufruf.ts). Leer im
// normalen Betrieb; die Win32-Schicht bleibt dann der einzige Weg.
const FAKE_LAUFWERK = (process.env['RIPPY_FAKE_LAUFWERK'] ?? '').replace(/:$/, '').toUpperCase()
function fakeGeraet(): GeraeteInfo {
return {
id: FAKE_LAUFWERK,
name: `Laufwerk ${FAKE_LAUFWERK}:`,
typ: 'bluray',
pfad: `\\\\.\\${FAKE_LAUFWERK}:`,
status: 'ready',
modell: 'NACHGEBAUT (KLICK-BEWEIS)',
seriennummer: 'KLICK',
groesseBytes: 33_759_100_928,
grund: '',
}
}
function wacheMeldung(meldung: { laufwerke: GeraeteInfo[]; ereignisse: WacheEreignis[] }): void {
anHauptUndFenster({ art: 'laufwerke', liste: meldung.laufwerke, ereignisse: meldung.ereignisse })
for (const ereignis of meldung.ereignisse) {
if (ereignis.art === 'disc-eingelegt') {
void discErmitteln(ereignis.geraet.id, ereignis.geraet.pfad, ereignis.geraet.groesseBytes)
} else if (ereignis.art === 'disc-entnommen') {
discZuordnungen.delete(ereignis.geraet.id)
discFingerprints.delete(ereignis.geraet.id)
discMerkmale.delete(ereignis.geraet.id)
anHauptUndFenster({ art: 'disc-info', info: discInfoStand(ereignis.geraet.id, null) })
}
}
}
function wacheFehler(fehler: unknown): void {
// R4: Eine gescheiterte Runde ist eine laute Zeile — der letzte
// Stand im Fenster bleibt stehen (R2), die Wache läuft weiter.
console.error(`[kern] Disc-Wache: Runde fehlgeschlagen: ${String(fehler)}`)
anFenster({ art: 'kern-fehler', text: `Disc-Wache: ${String(fehler)}` })
}
const laufwerkeBereit: Promise<void> = (async () => {
try {
if (FAKE_LAUFWERK.length > 0) {
wache = new DiscWache(() => [fakeGeraet()], wacheMeldung, wacheFehler)
wache.starten()
return
}
if (process.platform !== 'win32') {
throw new Error('Die Laufwerks-Schicht läuft nur unter Windows (KONZEPT § 14).')
}
@@ -234,41 +317,8 @@ const laufwerkeBereit: Promise<void> = (async () => {
laufwerkApi = win32Api()
wache = new DiscWache(
() => listOptischeLaufwerke(laufwerkApi!).map((g) => geraeteInfo(laufwerkApi!, g)),
(meldung) => {
anHauptUndFenster({ art: 'laufwerke', liste: meldung.laufwerke, ereignisse: meldung.ereignisse })
for (const ereignis of meldung.ereignisse) {
if (ereignis.art === 'disc-eingelegt') {
void discErmitteln(ereignis.geraet.id, ereignis.geraet.pfad, ereignis.geraet.groesseBytes)
} else if (ereignis.art === 'disc-entnommen') {
discZuordnungen.delete(ereignis.geraet.id)
discFingerprints.delete(ereignis.geraet.id)
discMerkmale.delete(ereignis.geraet.id)
anHauptUndFenster({
art: 'disc-info',
info: {
id: ereignis.geraet.id,
titel: '',
jahr: null,
confidence: 0,
quelle: '',
posterPfad: '',
backdropPfad: '',
beschreibung: '',
schonGerippt: null,
discNr: null,
staffel: null,
folgen: 0,
},
})
}
}
},
(fehler) => {
// R4: Eine gescheiterte Runde ist eine laute Zeile — der letzte
// Stand im Fenster bleibt stehen (R2), die Wache läuft weiter.
console.error(`[kern] Disc-Wache: Runde fehlgeschlagen: ${String(fehler)}`)
anFenster({ art: 'kern-fehler', text: `Disc-Wache: ${String(fehler)}` })
},
wacheMeldung,
wacheFehler,
)
wache.starten()
} catch (fehler) {
@@ -277,9 +327,14 @@ const laufwerkeBereit: Promise<void> = (async () => {
}
})()
// ── Rippen (W-2) ────────────────────────────────────────────────────────
// Die Ablage kommt aus der EINEN Einstellungsquelle (§ 6.7); solange dort
// nichts steht, gilt Videos\Rippy im Benutzerprofil.
/** Der Geräte-Stand zu einer Kennung — echt oder nachgebaut. */
function geraetInfoVon(id: string): GeraeteInfo | null {
if (FAKE_LAUFWERK.length > 0) return id === FAKE_LAUFWERK ? fakeGeraet() : null
const geraet = geraetVon(id)
if (geraet === null || laufwerkApi === null) return null
return geraeteInfo(laufwerkApi, geraet)
}
// ── Titel der Disc lesen (§ 6.2) ────────────────────────────────────────
// Der Info-Lauf dauert 20120 s — deshalb geschieht er erst auf Zuruf und
// nicht schon beim Einlegen (der 30.08.-Fund: „das Erkennen dauert sehr
@@ -300,12 +355,12 @@ async function titelLesen(id: string): Promise<void> {
anFenster({ art: 'disc-titel', stand: { ...leer, laeuft: false, fehler: 'Auf diesem Laufwerk läuft schon ein Vorgang.' } })
return
}
const geraet = geraetVon(id)
if (geraet === null || laufwerkApi === null) {
const geraet = geraetInfoVon(id)
if (geraet === null) {
anFenster({ art: 'disc-titel', stand: { ...leer, laeuft: false, fehler: `Laufwerk ${id}: gibt es nicht.` } })
return
}
const geraetepfad = geraeteInfo(laufwerkApi, geraet).pfad
const geraetepfad = geraet.pfad
const programm = finden('makemkv', db?.einstellungLesen('werkzeug.makemkv') ?? '')
if (programm.length === 0) {
anFenster({
@@ -343,12 +398,16 @@ async function titelLesen(id: string): Promise<void> {
stand: {
id,
laeuft: false,
titel: wahl.titel,
// Die Sprachen JE TITEL reisen mit (5.4.0) — der Dialog bietet sie
// zum Anhaken an.
titel: wahl.titel.map((t) => ({ ...t, sprachen: sprachenFuerTitel(info.streams.get(t.nr)) })),
einordnung: wahl.einordnung,
unsicher: wahl.unsicher,
fehler: ohneErgebnis ? info.ursachen.join(' · ') : '',
},
})
// Erkennung v2 (5.4.0): Jetzt liegt die Laufzeit vor — nachprüfen.
if (info.titel.length > 0) await erkennungNachpruefen(id, wahl, info.titel)
} catch (fehler) {
// Ein ABGELÖSTER Lauf schweigt: Ihn hat ein neuerer ersetzt, das ist
// Absicht und kein Fehlschlag. Genau hier lag der Commander-Fund —
@@ -369,14 +428,59 @@ async function titelLesen(id: string): Promise<void> {
}
}
/** Erkennung v2 (5.4.0): Erkannt wird beim EINLEGEN, vor jeder Messung.
* Nach dem Info-Lauf wird noch einmal gerechnet Laufzeit gegen TMDb,
* Titel-Struktur gegen den Treffer-Typ. Aus wahrscheinlich" wird
* bestätigt", oder Rippy merkt selbst, dass es danebenlag. */
async function erkennungNachpruefen(
id: string,
wahl: ReturnType<typeof titelVorauswahl>,
titel: Parameters<typeof strukturHinweis>[0],
): Promise<void> {
const bisher = discZuordnungen.get(id)
if (bisher === undefined) return
const gewaehlt = wahl.titel.filter((t) => t.gewaehlt)
const folgen = gewaehlt.filter((t) => t.rolle === 'folge').map((t) => t.dauerS)
const laengster = [...titel].sort((a, b) => b.dauerS - a.dauerS)[0]?.dauerS ?? 0
const messung = {
hauptS: folgen.length > 0 ? 0 : (gewaehlt[0]?.dauerS ?? laengster),
folgenS: folgen,
struktur: strukturHinweis(titel),
}
let zuordnung = bisher
// Sagt die Struktur „Serie", der Treffer aber „Film" (und der Nutzer hat
// nicht selbst gewählt): einmal als Serie fragen — das ist der
// Fingerabdruck, der Serien auch ohne bdmt erkennt.
if (messung.struktur === 'serie' && bisher.meta.typ !== 'tv' && bisher.confidence < 1) {
const merkmale = discMerkmale.get(id)
if (merkmale !== undefined && merkmale.titel.length > 0) {
const tmdb = new TmdbClient(db?.einstellungLesen('tmdbApiKey') ?? '')
const omdb = new OmdbClient(db?.einstellungLesen('omdbApiKey') ?? '')
const nochmal = await zuordnen(merkmale.titel, tmdb, omdb, { istSerie: true, alternativen: merkmale.alternativen })
if (nochmal.meta.typ === 'tv') {
zuordnung = { ...nochmal, hinweise: ['Serie erkannt an der Titel-Struktur (mehrere gleich lange Titel).'] }
}
}
}
const geprueft = nachpruefen(zuordnung, messung)
const hinweise = [...(zuordnung.hinweise ?? []), ...(geprueft.hinweise ?? [])]
const neu = { ...geprueft, hinweise }
discZuordnungen.set(id, neu)
anHauptUndFenster({ art: 'disc-info', info: discInfoStand(id, neu) })
}
function ablageOrdner(): string {
const eingestellt = db?.einstellungLesen('ablage')
if (eingestellt !== null && eingestellt !== undefined && eingestellt.length > 0) return eingestellt
return join(process.env['USERPROFILE'] ?? '.', 'Videos', 'Rippy')
}
function rohWurzel(): string {
return join(ablageOrdner(), 'roh')
}
// Die Einstellungs-Schlüssel, die die Pipeline liest — alle aus der EINEN
// Quelle (§ 6.7). Das UI dafür kommt mit W-5; bis dahin gelten Defaults.
// Quelle (§ 6.7).
const PIPELINE_EINSTELLUNGEN = [
'transcodeEnabled',
'transcodePreset',
@@ -404,6 +508,274 @@ function geraetVon(id: string): string | null {
return listOptischeLaufwerke(laufwerkApi).find((g) => kennung(g) === id) ?? null
}
// ── Vorgänge (5.4.0): Datensatz je Rip, Warteschlange, Aufräumen ────────
function dateienAusJson(text: string): string[] {
const liste: string[] = []
try {
const roh: unknown = JSON.parse(text)
if (Array.isArray(roh)) liste.push(...roh.map(String))
} catch (fehler) {
// R4: Ein unlesbarer Eintrag wird gesagt — und zählt als „keine
// fertigen Dateien bekannt", womit das Aufräumen ihn nie anfasst.
console.error(`[kern] fertige Dateien nicht lesbar: ${String(fehler)}`)
}
return liste
}
const ablageUmgebung: AblageUmgebung = {
handbrake: () => finden('handbrake', db?.einstellungLesen('werkzeug.handbrake') ?? ''),
ablage: ablageOrdner,
einstellungen: einstellungenLaden,
presetEmpfehlung: (typ) => presetEmpfehlung(typ, werkzeugAuskunft.backends, werkzeugAuskunft.presets),
// Staffel-Laufzeiten von TMDb — Grundlage der Episoden-Zuordnung (§ 4.2).
episodenLaufzeiten: async (tmdbId, staffel) => {
const tmdb = new TmdbClient(db?.einstellungLesen('tmdbApiKey') ?? '')
if (!tmdb.verfuegbar) return []
const antwort = await tmdb.tvStaffel(tmdbId, staffel)
const paare: Array<[number, number]> = []
for (const ep of antwort?.episodes ?? []) {
if (typeof ep.runtime === 'number' && ep.runtime > 0) paare.push([ep.episode_number, ep.runtime * 60])
}
return paare
},
bibliothekEintragen: (eintrag) => {
try {
db?.bibliothekEintragen(eintrag)
anFenster({ art: 'bibliothek', eintraege: db?.bibliothekListe() ?? [] })
} catch (fehler) {
// R4: Ein Bibliotheks-Fehler kippt keinen gelungenen Rip, wird aber
// gesagt.
anFenster({ art: 'kern-fehler', text: `Bibliothek: ${String(fehler)}` })
}
},
}
/** Nur bei einer PHASEN-Änderung in die Datenbank nicht bei jedem
* Prozent-Tick. */
const letztePhase = new Map<string, string>()
const warteschlange = new KompressionsWarteschlange(
(auftrag, melden, abbruch) => komprimierenUndAblegen(auftrag, ablageUmgebung, melden, abbruch),
(stand) => {
const l = stand.laeuft
if (l !== null && letztePhase.get(l.vorgangId) !== l.phase) {
letztePhase.set(l.vorgangId, l.phase)
db?.vorgangAktualisieren(l.vorgangId, { phase: l.phase === 'legt-ab' ? 'legt-ab' : 'komprimiert' })
}
vorgaengeSenden()
},
(auftrag, ergebnis) => {
letztePhase.delete(auftrag.vorgangId)
const jetzt = new Date().toISOString()
db?.vorgangAktualisieren(auftrag.vorgangId, {
phase: ergebnis.status,
beendet: jetzt,
zielOrdner: ergebnis.zielOrdner,
fertigBytes: ergebnis.fertigBytes,
komprimiert: ergebnis.komprimiert,
preset: ergebnis.preset,
fehler: ergebnis.status === 'fertig' ? '' : ergebnis.text,
fertigeDateien: JSON.stringify(ergebnis.dateien),
})
const vorgang = db?.vorgangLesen(auftrag.vorgangId)
const titel = vorgang?.titel ?? auftrag.vorgangId
const text = ergebnis.text + (ergebnis.hinweise.length > 0 ? ` · ${ergebnis.hinweise.join(' · ')}` : '')
anHauptUndFenster({
art: 'kompression-ergebnis',
vorgangId: auftrag.vorgangId,
titel,
ok: ergebnis.status === 'fertig',
text,
})
// Regel „sofort": direkt nach der gelungenen Kompression aufräumen.
if (ergebnis.status === 'fertig') rohAufraeumenLauf()
else vorgaengeSenden()
},
)
function vorgaengeStand(): VorgaengeStand {
const regel = regelLesen(db?.einstellungLesen(AUFBEWAHRUNG_SCHLUESSEL))
const liste = db?.vorgangListe() ?? []
let rohGesamtBytes = 0
const vorgaenge: VorgangEintrag[] = liste.map((v) => {
const rohDa = v.rohGeloeschtAm === null && existsSync(v.rohOrdner)
const rohBytes = rohDa ? ordnerGroesse(v.rohOrdner) : 0
rohGesamtBytes += rohBytes
const auftrag = auftragAusJson(v.auftrag)
const ruhig = v.phase === 'fertig' || v.phase === 'fehler' || v.phase === 'abgebrochen'
return {
id: v.id,
laufwerk: v.laufwerk,
typ: v.typ,
titel: v.titel,
jahr: v.jahr,
phase: v.phase,
begonnen: v.begonnen,
beendet: v.beendet,
rohOrdner: v.rohOrdner,
rohBytes,
rohGeloescht: !rohDa,
zielOrdner: v.zielOrdner,
fertigBytes: v.fertigBytes,
komprimiert: v.komprimiert,
preset: v.preset,
fehler: v.fehler,
titelZahl: auftrag?.dateien.length ?? 0,
neuKomprimierbar: rohDa && ruhig && auftrag !== null && auftrag.dateien.length > 0 && !warteschlange.kennt(v.id),
faelligAm: v.phase === 'fertig' && v.komprimiert && rohDa ? faelligAm(v.beendet, regel) : '',
}
})
const bekannte = new Set(liste.map((v) => v.rohOrdner))
const fremde = fremdeOrdner(rohWurzel(), bekannte)
for (const f of fremde) rohGesamtBytes += f.bytes
return { vorgaenge, rohGesamtBytes, fremde, regel: regelText(regel), kompression: warteschlange.stand() }
}
function vorgaengeSenden(): void {
try {
anHauptUndFenster({ art: 'vorgaenge', stand: vorgaengeStand() })
} catch (fehler) {
anFenster({ art: 'kern-fehler', text: `Vorgänge: ${String(fehler)}` })
}
}
function rohAufraeumenLauf(): void {
if (db === null) return
try {
const regel = regelLesen(db.einstellungLesen(AUFBEWAHRUNG_SCHLUESSEL))
const kandidaten = db.vorgangListe().map((v) => ({
id: v.id,
phase: v.phase,
komprimiert: v.komprimiert,
rohOrdner: v.rohOrdner,
rohGeloeschtAm: v.rohGeloeschtAm,
beendet: v.beendet,
fertigeDateien: dateienAusJson(v.fertigeDateien),
}))
const ergebnis = aufraeumen(kandidaten, regel, new Date())
const jetzt = new Date().toISOString()
for (const id of ergebnis.geloescht) {
db.vorgangAktualisieren(id, { rohGeloeschtAm: jetzt, rohBytes: 0 })
hinweis(`Roh-Dateien von „${db.vorgangLesen(id)?.titel ?? id}" gelöscht (${regelText(regel)})`)
}
for (const f of ergebnis.fehler) {
anFenster({ art: 'kern-fehler', text: `Roh-Ordner von ${f.id} nicht gelöscht: ${f.grund}` })
}
} catch (fehler) {
anFenster({ art: 'kern-fehler', text: `Roh-Aufräumen: ${String(fehler)}` })
}
vorgaengeSenden()
}
function vorgangRohLoeschen(vorgangId: string): void {
const v = db?.vorgangLesen(vorgangId)
if (v === null || v === undefined) {
anFenster({ art: 'kern-fehler', text: `Vorgang ${vorgangId}: unbekannt.` })
return
}
if (v.phase === 'rippt' || v.phase === 'wartet' || v.phase === 'komprimiert' || v.phase === 'legt-ab' || warteschlange.kennt(vorgangId)) {
anFenster({ art: 'kern-fehler', text: `${v.titel}" läuft noch (${v.phase}) — erst abbrechen, dann löschen.` })
return
}
if (!istUnterRohWurzel(rohWurzel(), v.rohOrdner)) {
anFenster({ art: 'kern-fehler', text: `Roh-Ordner von „${v.titel}" liegt nicht unter ${rohWurzel()} — nicht angefasst.` })
return
}
try {
rmSync(v.rohOrdner, { recursive: true, force: true })
db?.vorgangAktualisieren(vorgangId, { rohGeloeschtAm: new Date().toISOString(), rohBytes: 0 })
hinweis(`Roh-Dateien von „${v.titel}" gelöscht (von Hand).`)
} catch (fehler) {
anFenster({ art: 'kern-fehler', text: `Roh-Ordner von „${v.titel}" nicht gelöscht: ${String(fehler)}` })
}
vorgaengeSenden()
}
function fremdenOrdnerLoeschen(ordner: string): void {
if (!istUnterRohWurzel(rohWurzel(), ordner)) {
anFenster({ art: 'kern-fehler', text: `${ordner}" liegt nicht direkt unter ${rohWurzel()} — nicht angefasst.` })
return
}
const bekannt = (db?.vorgangListe() ?? []).some((v) => v.rohOrdner.toLowerCase() === ordner.toLowerCase())
if (bekannt) {
anFenster({ art: 'kern-fehler', text: `${ordner}" gehört zu einem Vorgang — dort löschen.` })
return
}
try {
rmSync(ordner, { recursive: true, force: true })
hinweis(`Ordner „${ordner}" gelöscht (von Hand).`)
} catch (fehler) {
anFenster({ art: 'kern-fehler', text: `${ordner}" nicht gelöscht: ${String(fehler)}` })
}
vorgaengeSenden()
}
/** „Wiederholen ab Kompression": Die Roh-Dateien liegen genau dafür da. */
function vorgangNeuKomprimieren(vorgangId: string, preset: string): void {
const v = db?.vorgangLesen(vorgangId)
if (v === null || v === undefined) {
anFenster({ art: 'kern-fehler', text: `Vorgang ${vorgangId}: unbekannt.` })
return
}
if (warteschlange.kennt(vorgangId)) {
anFenster({ art: 'kern-fehler', text: `${v.titel}" ist schon in der Warteschlange.` })
return
}
const auftrag = auftragAusJson(v.auftrag)
if (auftrag === null) {
anFenster({ art: 'kern-fehler', text: `${v.titel}": Der Auftrag ist nicht mehr lesbar — nur ein neuer Rip hilft.` })
return
}
const vorhandene = auftrag.dateien.filter((d) => existsSync(d.pfad))
if (vorhandene.length === 0) {
anFenster({ art: 'kern-fehler', text: `${v.titel}": Die Roh-Dateien sind nicht mehr da (${v.rohOrdner}).` })
return
}
if (vorhandene.length < auftrag.dateien.length) {
hinweis(`${v.titel}": ${auftrag.dateien.length - vorhandene.length} Roh-Datei(en) fehlen — der Rest wird komprimiert.`)
}
const neu = {
...auftrag,
dateien: vorhandene,
preset,
// Die neue Fassung ersetzt die alte im selben Ordner.
zielOrdner: v.phase === 'fertig' && v.zielOrdner.length > 0 && existsSync(v.zielOrdner) ? v.zielOrdner : '',
}
db?.vorgangAktualisieren(vorgangId, { phase: 'wartet', fehler: '', auftrag: auftragZuJson(neu) })
const platz = warteschlange.einreihen(neu, v.titel)
hinweis(`${v.titel}": Kompression eingereiht${platz > 1 ? ` (Platz ${platz})` : ''}${preset.length > 0 ? ` mit Preset „${preset}"` : ''}.`)
vorgaengeSenden()
}
/** Nach einem Neustart: Was wartete noch? Was war mitten im Rip? */
function vorgaengeWiederaufnehmen(): void {
if (db === null) return
const jetzt = new Date().toISOString()
for (const v of db.vorgangListe()) {
if (v.phase === 'rippt') {
db.vorgangAktualisieren(v.id, {
phase: 'abgebrochen',
beendet: jetzt,
fehler: 'Rippy wurde beendet, bevor der Rip fertig war — die Roh-Dateien können unvollständig sein.',
})
continue
}
if (v.phase === 'wartet' || v.phase === 'komprimiert' || v.phase === 'legt-ab') {
const auftrag = auftragAusJson(v.auftrag)
if (auftrag !== null && existsSync(v.rohOrdner) && auftrag.dateien.some((d) => existsSync(d.pfad))) {
warteschlange.einreihen({ ...auftrag, dateien: auftrag.dateien.filter((d) => existsSync(d.pfad)) }, v.titel)
hinweis(`${v.titel}": Kompression nach dem Neustart wieder aufgenommen.`)
} else {
db.vorgangAktualisieren(v.id, {
phase: 'fehler',
beendet: jetzt,
fehler: 'Nach dem Neustart fehlen die Roh-Dateien — die Kompression kann nicht wieder aufgenommen werden.',
})
}
}
}
}
const ripVerwaltung = new Pipeline({
makemkvcon: () => finden('makemkv', db?.einstellungLesen('werkzeug.makemkv') ?? ''),
handbrake: () => finden('handbrake', db?.einstellungLesen('werkzeug.handbrake') ?? ''),
@@ -418,44 +790,96 @@ const ripVerwaltung = new Pipeline({
if (laufwerkApi === null || geraet === null) throw new Error(`Laufwerk ${id}: gibt es nicht`)
return audioSpurLesen(laufwerkApi, geraet, start, sektoren, schreiben, fortschritt)
},
geraet: (id) => {
if (laufwerkApi === null) return null
const geraet = listOptischeLaufwerke(laufwerkApi).find((g) => kennung(g) === id)
return geraet === undefined ? null : geraeteInfo(laufwerkApi, geraet)
},
geraet: geraetInfoVon,
ablage: ablageOrdner,
einstellungen: einstellungenLaden,
presetEmpfehlung: (typ) => presetEmpfehlung(typ, werkzeugAuskunft.backends, werkzeugAuskunft.presets),
melden: (status) => anHauptUndFenster({ art: 'rip-status', status }),
melden: (status) => {
anHauptUndFenster({ art: 'rip-status', status })
// Nach einem gescheiterten Rip (oder Lesefehlern) gleich das Windows-
// Protokoll fragen: Disc oder Hardware? (5.4.0, Punkt 8)
if (status.phase === 'fehler' || (status.phase === 'fertig' && status.lesefehler)) void laufwerkPruefen(status.id)
},
zuordnung: (id) => discZuordnungen.get(id) ?? null,
discAngaben: (id) => {
const m = discMerkmale.get(id)
return m === undefined ? null : { staffel: m.staffel, discNr: m.discNr }
},
// Staffel-Laufzeiten von TMDb — Grundlage der Episoden-Zuordnung (§ 4.2).
episodenLaufzeiten: async (tmdbId, staffel) => {
const tmdb = new TmdbClient(db?.einstellungLesen('tmdbApiKey') ?? '')
if (!tmdb.verfuegbar) return []
const antwort = await tmdb.tvStaffel(tmdbId, staffel)
const paare: Array<[number, number]> = []
for (const ep of antwort?.episodes ?? []) {
if (typeof ep.runtime === 'number' && ep.runtime > 0) paare.push([ep.episode_number, ep.runtime * 60])
}
return paare
},
fingerprint: (id) => discFingerprints.get(id) ?? null,
bibliothekEintragen: (eintrag) => {
try {
db?.bibliothekEintragen(eintrag)
anFenster({ art: 'bibliothek', eintraege: db?.bibliothekListe() ?? [] })
} catch (fehler) {
// R4: Ein Bibliotheks-Fehler kippt keinen gelungenen Rip, wird aber
// gesagt.
anFenster({ art: 'kern-fehler', text: `Bibliothek: ${String(fehler)}` })
bibliothekEintragen: ablageUmgebung.bibliothekEintragen,
vorgangBeginnt: (v) => {
const vorgang: Vorgang = {
id: v.id,
laufwerk: v.laufwerk,
typ: v.typ,
titel: v.titel,
jahr: v.jahr,
phase: 'rippt',
begonnen: v.begonnen,
beendet: null,
rohOrdner: v.rohOrdner,
rohBytes: 0,
rohGeloeschtAm: null,
zielOrdner: '',
fertigBytes: 0,
komprimiert: false,
preset: '',
fehler: '',
auftrag: '{}',
lesefehler: false,
fertigeDateien: '[]',
}
db?.vorgangAnlegen(vorgang)
vorgaengeSenden()
},
vorgangEinreihen: (auftrag, titel) => {
db?.vorgangAktualisieren(auftrag.vorgangId, {
phase: 'wartet',
titel,
jahr: auftrag.zuordnung?.jahr ?? null,
rohBytes: auftrag.dateien.reduce((s, d) => s + d.groesseBytes, 0),
lesefehler: auftrag.lesefehler,
auftrag: auftragZuJson(auftrag),
})
const platz = warteschlange.einreihen(auftrag, titel)
vorgaengeSenden()
return platz
},
vorgangEndet: (id, phase, text) => {
db?.vorgangAktualisieren(id, { phase, beendet: new Date().toISOString(), fehler: text })
vorgaengeSenden()
},
nachRipAuswerfen: (id) => {
// Vorgabe: auswerfen — die Warteschlange will ein freies Laufwerk.
if (db?.einstellungLesen('autoAuswurf') === 'false') return
void auswerfen(id)
},
})
// ── Laufwerks-Gesundheit (5.4.0, Punkt 8) ───────────────────────────────
const GESUNDHEIT_STUNDEN = 24
async function laufwerkPruefen(id: string): Promise<void> {
if (process.platform !== 'win32') return
const ergebnis = await ereignisseLesen(GESUNDHEIT_STUNDEN)
if ('fehler' in ergebnis) {
anFenster({
art: 'laufwerk-gesundheit',
id,
bericht: {
urteil: 'nicht-lesbar',
text: `Das Windows-Protokoll ließ sich nicht lesen: ${ergebnis.fehler}`,
fehlerhafteBloecke: 0,
controllerfehler: 0,
sonstige: 0,
letzte: [],
stunden: GESUNDHEIT_STUNDEN,
},
})
return
}
anFenster({ art: 'laufwerk-gesundheit', id, bericht: beurteilen(ergebnis.ereignisse, GESUNDHEIT_STUNDEN) })
}
// ── Einstellungen (§ 6.7): das Fenster liest und schreibt NUR über den
// Kern; erlaubt sind genau diese Schlüssel ───────────────────────────
const ERLAUBTE_EINSTELLUNGEN = new Set([
@@ -479,6 +903,7 @@ const ERLAUBTE_EINSTELLUNGEN = new Set([
'werkzeug.makemkv',
'werkzeug.handbrake',
'werkzeug.flac',
AUFBEWAHRUNG_SCHLUESSEL,
])
function einstellungenSenden(): void {
@@ -515,6 +940,25 @@ async function schluesselLauf(): Promise<void> {
// Täglich neu prüfen (§ 9.1) — ohne den Prozess-Exit aufzuhalten.
setInterval(() => void schluesselLauf(), 24 * 60 * 60 * 1000).unref()
/** MakeMKVs Spur-Auswahlregel sicherstellen (5.4.0) sonst fehlen
* Sprachen in der Roh-Datei, die der Dialog anbot. */
async function spurauswahlLauf(): Promise<void> {
if (process.platform !== 'win32') return
try {
const { spurauswahlSicherstellen } = await import('./werkzeuge/spurauswahl')
const stand = spurauswahlSicherstellen()
if (stand.stand === 'eigen' || stand.stand === 'fehler') {
anFenster({ art: 'kern-fehler', text: `MakeMKV-Auswahlregel: ${stand.text}` })
} else if (stand.stand === 'gesetzt') {
hinweis(stand.text)
} else {
console.log(`[kern] ${stand.text}`)
}
} catch (fehler) {
anFenster({ art: 'kern-fehler', text: `MakeMKV-Auswahlregel: ${String(fehler)}` })
}
}
// ── MakeMKV-Update (§ 9): holen, prüfen, installieren lassen ────────────
// Der Kern lädt und PRÜFT die Datei; STARTEN kann sie nur das Haupt
// (shell → UAC-Abfrage; der erhöhte Installer hängt dadurch nicht an der
@@ -702,7 +1146,7 @@ process.parentPort.on('message', (ereignis) => {
} else if (frage.art === 'titel-lesen') {
void titelLesen(frage.id)
} else if (frage.art === 'rip-start') {
void laufwerkeBereit.then(() => ripVerwaltung.starten(frage.id, frage.auftrag ?? []))
void laufwerkeBereit.then(() => ripVerwaltung.starten(frage.id, frage.auftrag ?? [], frage.optionen ?? {}))
} else if (frage.art === 'rip-abbruch') {
ripVerwaltung.abbrechen(frage.id)
} else if (frage.art === 'rip-ueberspringen') {
@@ -719,6 +1163,8 @@ process.parentPort.on('message', (ereignis) => {
} else {
db.einstellungSchreiben(frage.schluessel, frage.wert)
einstellungenSenden()
// Eine neue Aufbewahrungs-Regel wirkt sofort auf die Anzeige.
if (frage.schluessel === AUFBEWAHRUNG_SCHLUESSEL) vorgaengeSenden()
}
} else if (frage.art === 'einstellungen-laden') {
einstellungenSenden()
@@ -730,6 +1176,22 @@ process.parentPort.on('message', (ereignis) => {
void makemkvUpdateLauf()
} else if (frage.art === 'schluessel-pruefen') {
void schluesselLauf().then(() => werkzeugeSenden())
} else if (frage.art === 'vorgaenge-laden') {
vorgaengeSenden()
} else if (frage.art === 'vorgang-roh-loeschen') {
vorgangRohLoeschen(frage.vorgangId)
} else if (frage.art === 'vorgang-neu-komprimieren') {
vorgangNeuKomprimieren(frage.vorgangId, frage.preset ?? '')
} else if (frage.art === 'kompression-abbrechen') {
if (!warteschlange.abbrechen(frage.vorgangId)) {
anFenster({ art: 'kern-fehler', text: `Vorgang ${frage.vorgangId} ist nicht in der Warteschlange.` })
}
} else if (frage.art === 'roh-aufraeumen') {
rohAufraeumenLauf()
} else if (frage.art === 'fremden-ordner-loeschen') {
fremdenOrdnerLoeschen(frage.ordner)
} else if (frage.art === 'laufwerk-pruefen') {
void laufwerkPruefen(frage.id)
}
})
fensterPort.start()
@@ -744,6 +1206,7 @@ process.parentPort.on('message', (ereignis) => {
anFenster({ art: 'schluessel-status', stand: schluesselStand })
}
})
vorgaengeSenden()
}
})
@@ -759,11 +1222,20 @@ function bereitNachricht(): KernNachricht {
process.parentPort.postMessage(bereitNachricht())
// Das Haupt braucht die Einstellungen (Update-Adresse) auch ohne Fenster.
einstellungenSenden()
// Was nach dem letzten Beenden noch offen war (5.4.0).
vorgaengeWiederaufnehmen()
// Nicht im Smoke: Der Schlüssel-Lauf spricht Forum + makemkvcon an —
// der Gerüst-Beweis soll ohne Netz und ohne MakeMKV grün sein.
if (!process.argv.includes('--smoke-kern') && process.env['RIPPY_OHNE_SCHLUESSEL'] !== '1') {
if (!smokeKern && process.env['RIPPY_OHNE_SCHLUESSEL'] !== '1') {
void schluesselLauf()
void spurauswahlLauf()
// Werkzeuge einmal beim Start messen — die Preset-Empfehlung der
// Pipeline braucht Backends und Preset-Liste auch ohne offenes Fenster.
void werkzeugeSenden()
}
// Roh-Aufräumen (5.4.0): eine Minute nach dem Start, dann täglich — und
// nach jeder gelungenen Kompression (siehe Warteschlange).
if (!smokeKern) {
setTimeout(() => rohAufraeumenLauf(), 60_000).unref()
setInterval(() => rohAufraeumenLauf(), 24 * 60 * 60 * 1000).unref()
}
@@ -7,7 +7,7 @@
// Einrückung — Namen stehen mit genau vier Leerzeichen).
// - Hardware nach FAMILIE unterscheiden (nvenc/qsv/vce/vaapi), sonst
// läuft AMD unter einem Intel-Namen (Commander-PC: RX 9070 XT).
import { spawn } from 'node:child_process'
import { werkzeugSpawn } from '../werkzeuge/aufruf'
import { textVon } from '../rip/parser'
const HB_ENCODER_KOPF = /^-e,\s*--encoder\b/
@@ -67,7 +67,7 @@ export function parsePresetListe(text: string): string[] {
function lauf(programm: string, args: string[], timeoutMs: number): Promise<string> {
return new Promise((fertig) => {
const kind = spawn(programm, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
const kind = werkzeugSpawn(programm, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
const stuecke: Buffer[] = []
kind.stdout.on('data', (s: Buffer) => stuecke.push(s))
kind.stderr.on('data', (s: Buffer) => stuecke.push(s))
@@ -15,7 +15,7 @@
// im Fehlerfall die einzige Auskunft.
// - Abbruch bei JEDER Zeile prüfen, nicht nur bei Prozent-Änderung —
// sonst sieht „Abbrechen" bei einem 4K-Encode minutenlang tot aus.
import { spawn } from 'node:child_process'
import { werkzeugSpawn } from '../werkzeuge/aufruf'
import { existsSync } from 'node:fs'
// win32-Pfadregeln ausdrücklich — die CI prüft diese Logik auf Linux.
import { win32 as pfad } from 'node:path'
@@ -30,8 +30,23 @@ export interface HandbrakeArgsOptionen {
preset: string
audioSprachen?: readonly string[]
untertitelSprachen?: readonly string[]
/** Die ERSTE gewählte Untertitelspur beim Abspielen vorwählen (5.4.0):
* japanischer Ton, deutsche Untertitel sollen von selbst an sein. */
untertitelStandard?: boolean
}
// Sprach-Schalter am mitgelieferten HandBrake 1.11 GEMESSEN (01.09.2026,
// Testdatei mit jpn/deu/deu/eng-Ton und deu/eng-Untertiteln, Ausgabe per
// ffprobe geprüft — Regel D):
// --audio-lang-list deu,eng → je Sprache die ERSTE Spur (2 Spuren)
// --audio-lang-list deu,eng --first-audio → dasselbe (je Sprache eine)
// --audio-lang-list deu,eng --all-audio → ALLE passenden Spuren (3)
// --subtitle-lang-list eng,deu --all-subtitles → Ausgabe-Reihenfolge = LISTEN-
// Reihenfolge (eng zuerst), NICHT Quell-Reihenfolge
// --subtitle-default=1 → die erste Untertitelspur trägt default=1 — und
// zusätzlich forced=1 (so schreibt es HandBrakes Muxer; Jellyfin zeigt
// sie damit von selbst)
// -N deu allein fügt KEINE Untertitelspur hinzu (nur die Vorwahl-Logik)
export function buildHandbrakeArgs(optionen: HandbrakeArgsOptionen): string[] {
const args = ['--input', optionen.eingabe, '--output', optionen.ausgabe, '--preset', optionen.preset]
const format = FORMATE[extname(optionen.ausgabe).toLowerCase()]
@@ -45,6 +60,9 @@ export function buildHandbrakeArgs(optionen: HandbrakeArgsOptionen): string[] {
const untertitel = (optionen.untertitelSprachen ?? []).filter((s) => s.length > 0)
if (untertitel.length > 0) args.push('--subtitle-lang-list', untertitel.join(','))
args.push('--all-subtitles')
// Nur mit einer Sprachliste ist klar, WELCHE Spur die erste ist — die
// Liste bestimmt die Reihenfolge (gemessen, siehe oben).
if (optionen.untertitelStandard === true && untertitel.length > 0) args.push('--subtitle-default=1')
return args
}
@@ -224,6 +242,7 @@ export interface HbLaufOptionen {
preset: string
audioSprachen?: readonly string[]
untertitelSprachen?: readonly string[]
untertitelStandard?: boolean
/** Zusätzliche Schalter ANS ENDE (z. B. --stop-at duration:5 im Messlauf). */
extraArgs?: readonly string[]
aufFortschritt?: (prozent: number) => void
@@ -234,7 +253,7 @@ export function komprimieren(optionen: HbLaufOptionen): Promise<HbErgebnis> {
return new Promise((fertig) => {
const args = buildHandbrakeArgs(optionen)
args.push(...(optionen.extraArgs ?? []))
const kind = spawn(optionen.programm, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
const kind = werkzeugSpawn(optionen.programm, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
const auswertung = new HbAuswertung()
const leser = new HbZeilenLeser()
+118
View File
@@ -0,0 +1,118 @@
// HandBrakes Scan einer Datei — welche Ton- und Untertitelspuren stecken
// WIRKLICH in der Roh-Datei? (5.4.0)
//
// Warum das nötig ist: Der Auswahl-Dialog zeigt die Sprachen, die MakeMKV
// im Info-Lauf an der DISC gemessen hat. Ob sie alle in der MKV landen,
// entscheidet MakeMKVs Auswahlregel (siehe werkzeuge/spurauswahl.ts). Vor
// der Kompression wird deshalb die Datei selbst gefragt — fehlt eine
// gewünschte Sprache, steht das im Protokoll, statt dass ein leerer
// Wunsch still durchrutscht.
//
// Ausgabeformat am mitgelieferten HandBrake 1.11 gemessen (01.09.2026,
// Regel D): `HandBrakeCLI --scan --json -i <datei> -t 1` schreibt ein
// Textprotokoll, darin EINMAL „JSON Title Set:" gefolgt von einem
// JSON-Objekt, und danach weitere Zeilen („HandBrake has exited."). Das
// Objekt endet also NICHT am Textende — es wird über die Klammern
// ausgeschnitten. Darin: TitleList[0].AudioList[].LanguageCode (ISO-639-2,
// z. B. „jpn"), .SubtitleList[].LanguageCode, .Duration.{Hours,Minutes,Seconds}.
import { werkzeugSpawn } from '../werkzeuge/aufruf'
import { textVon } from '../rip/parser'
export interface HbSpuren {
audio: Array<{ nr: number; lang: string; beschreibung: string }>
untertitel: Array<{ nr: number; lang: string; beschreibung: string }>
dauerS: number
}
export function buildScanArgs(datei: string): string[] {
return ['--scan', '--json', '--input', datei, '--title', '1']
}
/** Das JSON-Objekt hinter „JSON Title Set:" ausschneiden (pur, testbar). */
export function spurenAusScan(ausgabe: string): HbSpuren | null {
const marke = ausgabe.indexOf('JSON Title Set:')
if (marke < 0) return null
const start = ausgabe.indexOf('{', marke)
if (start < 0) return null
let tiefe = 0
let ende = -1
for (let i = start; i < ausgabe.length; i++) {
const zeichen = ausgabe[i]
if (zeichen === '{') tiefe += 1
else if (zeichen === '}') {
tiefe -= 1
if (tiefe === 0) {
ende = i
break
}
}
}
if (ende < 0) return null
let daten: unknown
try {
daten = JSON.parse(ausgabe.slice(start, ende + 1))
} catch {
// Kein gültiges JSON heißt „nicht auswertbar" — das ist ein null, kein
// erfundener leerer Befund (R2).
return null
}
if (typeof daten !== 'object' || daten === null) return null
const liste = (daten as { TitleList?: unknown }).TitleList
if (!Array.isArray(liste) || liste.length === 0) return null
const titel = liste[0] as Record<string, unknown>
const spuren = (feld: unknown): Array<{ nr: number; lang: string; beschreibung: string }> => {
if (!Array.isArray(feld)) return []
return feld.map((s, i) => {
const eintrag = (typeof s === 'object' && s !== null ? s : {}) as Record<string, unknown>
return {
nr: i + 1,
lang: String(eintrag.LanguageCode ?? '').toLowerCase(),
beschreibung: String(eintrag.Description ?? eintrag.Language ?? ''),
}
})
}
const dauer = (titel.Duration ?? {}) as Record<string, unknown>
const zahl = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
return {
audio: spuren(titel.AudioList),
untertitel: spuren(titel.SubtitleList),
dauerS: zahl(dauer.Hours) * 3600 + zahl(dauer.Minutes) * 60 + zahl(dauer.Seconds),
}
}
/** Fragt die Datei — null heißt „konnte nicht nachsehen", nie „keine Spuren". */
export function spurenScannen(programm: string, datei: string, timeoutMs = 120_000): Promise<HbSpuren | null> {
return new Promise((fertig) => {
if (programm.length === 0) {
fertig(null)
return
}
const kind = werkzeugSpawn(programm, buildScanArgs(datei), { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
const stuecke: Buffer[] = []
kind.stdout.on('data', (s: Buffer) => stuecke.push(s))
kind.stderr.on('data', (s: Buffer) => stuecke.push(s))
const zeiger = setTimeout(() => kind.kill(), timeoutMs)
kind.on('error', () => {
clearTimeout(zeiger)
fertig(null)
})
kind.on('close', () => {
clearTimeout(zeiger)
fertig(spurenAusScan(textVon(Buffer.concat(stuecke))))
})
})
}
/** Welche gewünschten Sprachen FEHLEN in der Datei? (pur) */
export function fehlendeSprachen(
spuren: HbSpuren,
gewuenscht: { audio: readonly string[]; untertitel: readonly string[] },
): { audio: string[]; untertitel: string[] } {
const da = (liste: Array<{ lang: string }>): Set<string> => new Set(liste.map((s) => s.lang))
const audioDa = da(spuren.audio)
const untertitelDa = da(spuren.untertitel)
return {
audio: gewuenscht.audio.filter((l) => !audioDa.has(l)),
untertitel: gewuenscht.untertitel.filter((l) => !untertitelDa.has(l)),
}
}
@@ -0,0 +1,172 @@
// Laufwerks-Gesundheit aus dem Windows-Ereignisprotokoll (5.4.0).
//
// Der Anlass (01.09.2026): Beim Spartacus-Defekt und beim 5042-Zustand
// des BU40N war die entscheidende Frage „Disc oder Hardware?" — und die
// Antwort stand im System-Protokoll, von Hand gelesen. Jetzt liest Rippy
// sie selbst.
//
// GEMESSEN (01.09.2026, Regel D, an diesem PC):
// wevtutil qe System "/q:<XPath>" /f:xml /c:200 /rd:true
// Provider 'cdrom', EventID 7 → „Fehlerhafter Block bei Gerät \Device\CdRom0."
// EventID 11 → „Der Treiber hat einen Controllerfehler auf \Device\CdRom0 gefunden."
// EventID 51 → Auslagerungs-/E/A-Fehler (bei 7 dabei)
// In 30 Tagen: 338 × 7, 8 × 11, 46 × 51 — die 7er häuften sich exakt um
// den Spartacus-Rip (20:32), die 11er beim USB-Aussetzer (20:17).
// TimeCreated SystemTime ist UTC. Die XML-Ereignisse stehen OHNE
// Zeilenumbruch hintereinander; ein Regex je <Event>…</Event> reicht.
// Die Argumente gehen ohne Shell an wevtutil — ein „/q:"-Argument mit
// Leerzeichen bleibt so EIN Argument (über Git Bash zerlegt es der
// Pfad-Konverter, über spawn nicht).
import { spawn } from 'node:child_process'
import type { LaufwerkBericht } from '../../gemeinsam/nachrichten'
import { textVon } from '../rip/parser'
export interface LaufwerkEreignis {
/** ISO-Zeit (UTC). */
zeit: string
provider: string
id: number
geraet: string
}
/** Die Ereignis-IDs des cdrom-/disk-Treibers, wie am PC gemessen. */
export const EREIGNIS_TEXTE: Record<number, string> = {
7: 'fehlerhafter Block',
11: 'Controllerfehler',
15: 'Gerät nicht bereit',
51: 'E/A-Fehler beim Lesen',
153: 'E/A-Vorgang wiederholt',
}
export function xpathFuer(stunden: number): string {
const ms = Math.max(1, Math.round(stunden)) * 60 * 60 * 1000
return `*[System[Provider[@Name='cdrom' or @Name='disk'] and TimeCreated[timediff(@SystemTime) <= ${ms}]]]`
}
export function buildWevtutilArgs(stunden: number, hoechstens = 200): string[] {
return ['qe', 'System', `/q:${xpathFuer(stunden)}`, '/f:xml', `/c:${hoechstens}`, '/rd:true']
}
/** Die XML-Ausgabe von wevtutil → Ereignisse (pur, testbar). */
export function ereignisseAusXml(xml: string): LaufwerkEreignis[] {
const liste: LaufwerkEreignis[] = []
for (const treffer of xml.matchAll(/<Event\b[\s\S]*?<\/Event>/g)) {
const block = treffer[0]
const provider = /<Provider Name='([^']*)'/.exec(block)?.[1] ?? ''
const id = Number(/<EventID[^>]*>(\d+)<\/EventID>/.exec(block)?.[1] ?? '')
const zeit = /<TimeCreated SystemTime='([^']*)'/.exec(block)?.[1] ?? ''
const geraet = /<Data>([^<]*)<\/Data>/.exec(block)?.[1] ?? ''
if (!Number.isInteger(id) || provider.length === 0) continue
liste.push({ zeit, provider, id, geraet })
}
return liste
}
/** Urteil aus den Ereignissen (pur). `seit` grenzt auf einen Zeitraum ein
* (z. B. den laufenden Rip); ohne `seit` zählt alles, was da ist. */
export function beurteilen(ereignisse: readonly LaufwerkEreignis[], stunden: number, seit?: Date): LaufwerkBericht {
const relevant = seit === undefined ? [...ereignisse] : ereignisse.filter((e) => new Date(e.zeit).getTime() >= seit.getTime())
let fehlerhafteBloecke = 0
let controllerfehler = 0
let sonstige = 0
for (const e of relevant) {
if (e.id === 7) fehlerhafteBloecke += 1
else if (e.id === 11 || e.id === 15) controllerfehler += 1
else sonstige += 1
}
const letzte = relevant.slice(0, 8).map((e) => {
const lokal = e.zeit.length > 0 ? new Date(e.zeit) : null
const zeit =
lokal !== null && !Number.isNaN(lokal.getTime())
? `${lokal.getFullYear()}-${String(lokal.getMonth() + 1).padStart(2, '0')}-${String(lokal.getDate()).padStart(2, '0')} ${String(lokal.getHours()).padStart(2, '0')}:${String(lokal.getMinutes()).padStart(2, '0')}`
: '?'
return `${zeit} · ${e.provider} ${e.id} · ${EREIGNIS_TEXTE[e.id] ?? 'Ereignis'} (${e.geraet})`
})
const zeitraum = seit === undefined ? `in den letzten ${stunden} Stunden` : `seit ${seit.toISOString().slice(11, 16)} UTC`
if (relevant.length === 0) {
return {
urteil: 'ruhig',
text: `Das Windows-Protokoll meldet ${zeitraum} nichts zum Laufwerk — weder Lesefehler noch Hardware-Fehler.`,
fehlerhafteBloecke,
controllerfehler,
sonstige,
letzte,
stunden,
}
}
if (controllerfehler > 0) {
return {
urteil: 'hardware',
text:
`${controllerfehler} Controllerfehler ${zeitraum}` +
(fehlerhafteBloecke > 0 ? ` und ${fehlerhafteBloecke} fehlerhafte Blöcke` : '') +
'. Ein Controllerfehler kommt vom Laufwerk oder seiner Verbindung, nicht von der Disc: ' +
'Bei einem USB-Laufwerk zuerst einen Port direkt am Mainboard probieren, Kabel und Netzteil prüfen.',
fehlerhafteBloecke,
controllerfehler,
sonstige,
letzte,
stunden,
}
}
if (fehlerhafteBloecke > 0) {
return {
urteil: 'disc',
text:
`${fehlerhafteBloecke} fehlerhafte Blöcke ${zeitraum}, keine Controllerfehler. ` +
'Das spricht für die DISC (Kratzer, Fertigungsfehler), nicht für das Laufwerk: ' +
'Disc reinigen, den betroffenen Titel überspringen oder eine andere Ausgabe versuchen.',
fehlerhafteBloecke,
controllerfehler,
sonstige,
letzte,
stunden,
}
}
return {
urteil: 'unklar',
text: `${sonstige} Laufwerks-Ereignisse ${zeitraum}, aber weder fehlerhafte Blöcke noch Controllerfehler — kein klares Bild.`,
fehlerhafteBloecke,
controllerfehler,
sonstige,
letzte,
stunden,
}
}
/** Liest das Protokoll — null heißt „konnte nicht lesen", mit Grund. */
export function ereignisseLesen(
stunden: number,
timeoutMs = 30_000,
): Promise<{ ereignisse: LaufwerkEreignis[] } | { fehler: string }> {
return new Promise((fertig) => {
let kind: ReturnType<typeof spawn>
try {
kind = spawn('wevtutil', buildWevtutilArgs(stunden), { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
} catch (fehler) {
fertig({ fehler: `wevtutil nicht startbar: ${String(fehler)}` })
return
}
const stuecke: Buffer[] = []
const fehlerStuecke: Buffer[] = []
kind.stdout?.on('data', (s: Buffer) => stuecke.push(s))
kind.stderr?.on('data', (s: Buffer) => fehlerStuecke.push(s))
const zeiger = setTimeout(() => kind.kill(), timeoutMs)
kind.on('error', (fehler) => {
clearTimeout(zeiger)
fertig({ fehler: `wevtutil nicht startbar: ${String(fehler)}` })
})
kind.on('close', (code) => {
clearTimeout(zeiger)
const xml = textVon(Buffer.concat(stuecke))
// Ohne Treffer endet wevtutil mit 0 und leerer Ausgabe; ein Fehler
// steht auf stderr mit Code ≠ 0.
if (code !== 0 && xml.trim().length === 0) {
fertig({ fehler: `wevtutil endete mit Code ${code}: ${textVon(Buffer.concat(fehlerStuecke)).trim().slice(0, 200)}` })
return
}
fertig({ ereignisse: ereignisseAusXml(xml) })
})
})
}
@@ -121,14 +121,30 @@ export interface BdmtInhalt {
titel: string
/** <di:titleName> — was die Disc trägt: Folgen, Tonspuren, Trailer. */
eintraege: string[]
/** Weitere Titel derselben Disc aus den anderen bdmt-Sprachdateien
* (bdmt_deu neben bdmt_eng, 5.4.0) ohne den Haupttitel, ohne Doppel. */
alternativen: string[]
}
/** Liest die bdmt-Datei EINMAL bdmt_eng bevorzugt. Regex statt
* XML-Parser (Namespaces variieren je Authoring-Werkzeug). */
/** Den Studio-Titel und das Inhaltsverzeichnis aus EINER bdmt-Datei ziehen
* (pur, testbar). Regex statt XML-Parser (Namespaces variieren je
* Authoring-Werkzeug). */
export function bdmtZerlegen(inhalt: string): { titel: string; eintraege: string[] } {
const name = /<di:name>([^<]{2,120})<\/di:name>/.exec(inhalt)
const eintraege: string[] = []
for (const t of inhalt.matchAll(/<di:titleName[^>]*>([^<]{1,120})<\/di:titleName>/g)) {
const text = t[1].trim()
if (text.length > 0) eintraege.push(text)
}
return { titel: name === null ? '' : name[1].trim(), eintraege }
}
/** Liest die bdmt-Dateien bdmt_eng zuerst, die anderen Sprachen liefern
* Titel-Varianten (5.4.0). */
export function inhaltAusBdmt(wurzel: string): BdmtInhalt {
// Nicht lesbar heißt „nichts erfahren" — dasselbe Ergebnis wie „keine
// Datei da". Eine Erkennung scheitert daran nie, sie wird nur ärmer.
const leer: BdmtInhalt = { titel: '', eintraege: [] }
const leer: BdmtInhalt = { titel: '', eintraege: [], alternativen: [] }
const metaOrdner = join(wurzel, 'BDMV', 'META', 'DL')
if (!existsSync(metaOrdner)) return leer
let kandidaten: string[]
@@ -137,23 +153,38 @@ export function inhaltAusBdmt(wurzel: string): BdmtInhalt {
} catch {
return leer
}
const bdmt =
kandidaten.find((k) => k === 'bdmt_eng.xml') ??
kandidaten.find((k) => k.startsWith('bdmt_') && k.endsWith('.xml'))
if (bdmt === undefined) return leer
let inhalt: string
try {
inhalt = readFileSync(join(metaOrdner, bdmt)).subarray(0, 65536).toString('utf8')
} catch {
return leer
const bdmts = kandidaten.filter((k) => k.startsWith('bdmt_') && k.endsWith('.xml'))
if (bdmts.length === 0) return leer
// Englisch zuerst (das ist der Titel, den die Suche bisher kannte), dann
// Deutsch, dann der Rest in Dateireihenfolge.
const rang = (k: string): number => (k === 'bdmt_eng.xml' ? 0 : k === 'bdmt_deu.xml' ? 1 : 2)
bdmts.sort((a, b) => rang(a) - rang(b) || a.localeCompare(b))
let titel = ''
let eintraege: string[] = []
const alternativen: string[] = []
for (const bdmt of bdmts) {
let inhalt: string
try {
inhalt = readFileSync(join(metaOrdner, bdmt)).subarray(0, 65536).toString('utf8')
} catch {
continue
}
const zerlegt = bdmtZerlegen(inhalt)
if (titel.length === 0 && zerlegt.titel.length > 0) {
titel = zerlegt.titel
eintraege = zerlegt.eintraege
continue
}
if (eintraege.length === 0 && zerlegt.eintraege.length > 0) eintraege = zerlegt.eintraege
if (
zerlegt.titel.length > 0 &&
zerlegt.titel.toLowerCase() !== titel.toLowerCase() &&
!alternativen.some((a) => a.toLowerCase() === zerlegt.titel.toLowerCase())
) {
alternativen.push(zerlegt.titel)
}
}
const name = /<di:name>([^<]{2,120})<\/di:name>/.exec(inhalt)
const eintraege: string[] = []
for (const t of inhalt.matchAll(/<di:titleName[^>]*>([^<]{1,120})<\/di:titleName>/g)) {
const text = t[1].trim()
if (text.length > 0) eintraege.push(text)
}
return { titel: name === null ? '' : name[1].trim(), eintraege }
return { titel, eintraege, alternativen }
}
/** Der Studio-Titel aus BDMV/META/DL/bdmt_*.xml — '' wenn keiner dasteht. */
@@ -240,13 +271,15 @@ export interface DiscTitel {
/** Die Disc bezeugt selbst, dass sie Folgen trägt ab zwei ist es
* keine Zufalls-Benennung mehr, sondern eine Serien-Disc. */
istSerie: boolean
/** Weitere Titel-Varianten (bdmt_deu, 5.4.0) — die Suche fragt jede. */
alternativen: string[]
}
/** Mindestens so viele Folgen im Inhaltsverzeichnis, damit Serie" als
* bewiesen gilt eine einzelne könnte auch eine Zugabe sein. */
export const FOLGEN_FUER_SERIE = 2
function mitZusatz(titel: string, quelle: 'bdmt' | 'label', folgen: number): DiscTitel {
function mitZusatz(titel: string, quelle: 'bdmt' | 'label', folgen: number, alternativen: string[] = []): DiscTitel {
const zusatz = discZusatzAbtrennen(titel)
return {
titel,
@@ -255,6 +288,7 @@ function mitZusatz(titel: string, quelle: 'bdmt' | 'label', folgen: number): Dis
staffel: zusatz.staffel,
folgen,
istSerie: folgen >= FOLGEN_FUER_SERIE,
alternativen,
}
}
@@ -266,11 +300,11 @@ export function discTitel(geraetepfad: string): DiscTitel {
if (wurzel.length > 0) {
const bdmt = inhaltAusBdmt(wurzel)
folgen = folgenZahl(bdmt.eintraege)
if (bdmt.titel.length > 0) return mitZusatz(bdmt.titel, 'bdmt', folgen)
if (bdmt.titel.length > 0) return mitZusatz(bdmt.titel, 'bdmt', folgen, bdmt.alternativen)
}
const label = isoVolumeLabel(geraetepfad) || udfVolumeLabel(geraetepfad)
if (label.length > 0) return mitZusatz(normalizeDiscLabel(label), 'label', folgen)
return { titel: '', quelle: '', discNr: null, staffel: null, folgen, istSerie: folgen >= FOLGEN_FUER_SERIE }
return { titel: '', quelle: '', discNr: null, staffel: null, folgen, istSerie: folgen >= FOLGEN_FUER_SERIE, alternativen: [] }
}
/** Billiger, stabiler Disc-Fingerabdruck (Label|Größe) Basis der
+99 -5
View File
@@ -2,14 +2,50 @@
// ein v3-Key (32 Hex, api_key-Query) ODER ein v4-Token ('eyJ…',
// Bearer-Header) sein — der Befund vom 24.07.: der alte Client konnte nur
// eine Form, und die andere sah aus wie „falscher Key". Sprache de-DE.
//
// 5.4.0 — Kino-Banner v2: Details holen in DERSELBEN Anfrage per
// `append_to_response` Logo, Besetzung und Freigabe mit. Die Feldnamen
// sind GEMESSEN (01.09.2026 mit dem hinterlegten v4-Token, Regel D):
// movie/149?append_to_response=images,credits,release_dates
// &include_image_language=de,en,null
// → tagline „Neo-Tokyo ist am EXPLODIEREN", runtime 124,
// vote_average 7.94, images.logos[{iso_639_1:'de'|'en'|null,
// file_path:'/….png'|'.svg', width, height, vote_average}],
// credits.cast[{name, character, profile_path, order}],
// release_dates.results[{iso_3166_1:'DE', release_dates:[{certification:'16', type:3|5|6}]}]
// tv/46296?append_to_response=images,credits,content_ratings
// → episode_run_time [53], content_ratings.results[{iso_3166_1:'DE', rating:'18'}],
// seasons[{season_number, name, episode_count}], number_of_episodes
// configuration.images: logo_sizes w45…w500, profile_sizes w45,w185,h632
// Und der Stub-Fund: „Spartacus: Gods of the Arena" gibt es als drei
// leere Serien-Einträge (popularity 1, kein Backdrop, 1 Folge) — der
// echte Inhalt ist die Extras-Staffel von „Spartacus" (46296).
export interface FilmTreffer {
id: number
title?: string
name?: string
poster_path?: string
poster_path?: string | null
backdrop_path?: string | null
release_date?: string
first_air_date?: string
overview?: string
popularity?: number
}
export interface TmdbLogo {
iso_639_1: string | null
file_path: string
width?: number
height?: number
vote_average?: number
}
export interface TmdbCast {
name: string
character?: string
profile_path?: string | null
order?: number
}
export interface FilmDetails {
@@ -19,10 +55,22 @@ export interface FilmDetails {
release_date?: string
first_air_date?: string
overview?: string
poster_path?: string
backdrop_path?: string
poster_path?: string | null
backdrop_path?: string | null
runtime?: number
genres?: Array<{ name: string }>
tagline?: string
vote_average?: number
vote_count?: number
popularity?: number
episode_run_time?: number[]
number_of_episodes?: number
number_of_seasons?: number
seasons?: Array<{ season_number: number; name?: string; episode_count?: number }>
images?: { logos?: TmdbLogo[] }
credits?: { cast?: TmdbCast[] }
release_dates?: { results?: Array<{ iso_3166_1: string; release_dates?: Array<{ certification?: string; type?: number }> }> }
content_ratings?: { results?: Array<{ iso_3166_1: string; rating?: string }> }
}
export function istV4Token(key: string): boolean {
@@ -31,6 +79,44 @@ export function istV4Token(key: string): boolean {
const BASIS = 'https://api.themoviedb.org/3'
/** Das beste Logo (pur): deutsch vor englisch vor sprachlos, darin das
* bestbewertete. '' wenn keins. */
export function logoAus(logos: readonly TmdbLogo[] | undefined): string {
if (logos === undefined || logos.length === 0) return ''
const rang = (l: TmdbLogo): number => (l.iso_639_1 === 'de' ? 0 : l.iso_639_1 === 'en' ? 1 : l.iso_639_1 === null ? 2 : 3)
const sortiert = [...logos]
.filter((l) => typeof l.file_path === 'string' && l.file_path.length > 0)
.sort((a, b) => rang(a) - rang(b) || (b.vote_average ?? 0) - (a.vote_average ?? 0))
return sortiert[0]?.file_path ?? ''
}
/** Die deutsche Freigabe (pur): Film aus release_dates (Kino vor
* Heimvideo vor TV), Serie aus content_ratings. '' wenn unbekannt. */
export function fskAus(details: FilmDetails, typ: 'movie' | 'tv'): string {
if (typ === 'tv') {
const de = details.content_ratings?.results?.find((r) => r.iso_3166_1 === 'DE')
return (de?.rating ?? '').trim()
}
const de = details.release_dates?.results?.find((r) => r.iso_3166_1 === 'DE')
const eintraege = (de?.release_dates ?? []).filter((e) => (e.certification ?? '').trim().length > 0)
if (eintraege.length === 0) return ''
const rang = (typ2: number | undefined): number => (typ2 === 3 ? 0 : typ2 === 5 ? 1 : typ2 === 4 ? 2 : typ2 === 6 ? 3 : 4)
return [...eintraege].sort((a, b) => rang(a.type) - rang(b.type))[0].certification!.trim()
}
/** Die ersten Darsteller mit Bild (pur). */
export function besetzungAus(
cast: readonly TmdbCast[] | undefined,
hoechstens = 6,
): Array<{ name: string; rolle: string; bildPfad: string }> {
if (cast === undefined) return []
return [...cast]
.filter((c) => typeof c.name === 'string' && c.name.length > 0)
.sort((a, b) => (a.order ?? 999) - (b.order ?? 999))
.slice(0, hoechstens)
.map((c) => ({ name: c.name, rolle: (c.character ?? '').trim(), bildPfad: c.profile_path ?? '' }))
}
export class TmdbClient {
constructor(
private readonly apiKey: string,
@@ -84,11 +170,19 @@ export class TmdbClient {
}
async movieDetails(id: number): Promise<FilmDetails | null> {
return (await this.anfrage(`movie/${id}`, { language: 'de-DE' })) as FilmDetails | null
return (await this.anfrage(`movie/${id}`, {
language: 'de-DE',
append_to_response: 'images,credits,release_dates',
include_image_language: 'de,en,null',
})) as FilmDetails | null
}
async tvDetails(id: number): Promise<FilmDetails | null> {
return (await this.anfrage(`tv/${id}`, { language: 'de-DE' })) as FilmDetails | null
return (await this.anfrage(`tv/${id}`, {
language: 'de-DE',
append_to_response: 'images,credits,content_ratings',
include_image_language: 'de,en,null',
})) as FilmDetails | null
}
/** Staffel-Details mit Episoden-Laufzeiten — für die Serien-Zuordnung. */
+215 -51
View File
@@ -12,9 +12,24 @@
//
// Rippy sagt nicht „das ist Akira", sondern „sehr wahrscheinlich Akira
// (1988)" — und fragt nur, wenn es das nicht sagen kann (§ 5).
//
// 5.4.0 — Erkennung v2, drei Ergänzungen, alle an echten Daten bezahlt:
// * STUBS: TMDb hat „Spartacus: Gods of the Arena" dreimal als leeren
// Serien-Eintrag (kein Backdrop, keine Beschreibung, 1 Folge,
// Popularität 1). Ein wörtlicher Treffer darauf sah 5.1.1 als 90 %
// sicher an — es war ein Eintrag ohne Inhalt. Leere Einträge zählen
// nicht mehr als Treffer.
// * LAUFZEIT: Nach dem Info-Lauf wird die gemessene Länge des Hauptinhalts
// gegen TMDb gehalten (nachpruefen). Passt sie, wird aus „wahrscheinlich"
// „bestätigt"; passt sie gar nicht (95 vs. 158 min), fällt der Treffer
// auf einen Vorschlag zurück.
// * VARIANTEN: Die Disc trägt oft zwei Titel (bdmt_eng UND bdmt_deu) —
// jede Variante wird gefragt, und die Abdeckung wird an IHRER Vorlage
// gemessen, nicht an der englischen.
import { discZusatzAbtrennen, titelKandidaten } from './discmerkmale'
import type { FilmDetails, FilmTreffer, TmdbClient } from './tmdb'
import { besetzungAus, fskAus, logoAus, type FilmDetails, type FilmTreffer, type TmdbClient } from './tmdb'
import type { OmdbClient, OmdbTreffer } from './omdb'
import type { Darsteller } from '../../gemeinsam/nachrichten'
export const WENIGE_TREFFER = 3
@@ -30,6 +45,14 @@ export interface MetaTreffer {
laufzeitMinuten: number
genres: string[]
quelle: 'tmdb' | 'omdb' | ''
// ── Kino-Banner v2 (5.4.0) ──
logoPfad: string
tagline: string
bewertung: number
fsk: string
besetzung: Darsteller[]
/** Folgen laut TMDb (Serie) — 0 wenn unbekannt/Film. */
folgenGesamt: number
}
export interface Zuordnung {
@@ -37,6 +60,8 @@ export interface Zuordnung {
jahr: number | null
confidence: number
meta: MetaTreffer
/** Was Rippy nach dem Info-Lauf noch dazugelernt hat (nachpruefen). */
hinweise?: string[]
}
function jahrAus(datum: string | undefined): number | null {
@@ -45,7 +70,11 @@ function jahrAus(datum: string | undefined): number | null {
return Number.isFinite(jahr) && jahr > 0 ? jahr : null
}
function ausDetails(typ: 'movie' | 'tv', id: number, details: FilmDetails): MetaTreffer {
export function ausDetails(typ: 'movie' | 'tv', id: number, details: FilmDetails): MetaTreffer {
const laufzeit =
typ === 'movie'
? (details.runtime ?? 0)
: ((details.episode_run_time ?? []).find((m) => typeof m === 'number' && m > 0) ?? 0)
return {
typ,
id,
@@ -54,9 +83,15 @@ function ausDetails(typ: 'movie' | 'tv', id: number, details: FilmDetails): Meta
beschreibung: details.overview ?? '',
posterPfad: details.poster_path ?? '',
backdropPfad: details.backdrop_path ?? '',
laufzeitMinuten: details.runtime ?? 0,
laufzeitMinuten: laufzeit,
genres: (details.genres ?? []).map((g) => g.name),
quelle: 'tmdb',
logoPfad: logoAus(details.images?.logos),
tagline: (details.tagline ?? '').trim(),
bewertung: typeof details.vote_average === 'number' ? Math.round(details.vote_average * 10) / 10 : 0,
fsk: fskAus(details, typ),
besetzung: besetzungAus(details.credits?.cast),
folgenGesamt: typ === 'tv' ? (details.number_of_episodes ?? 0) : 0,
}
}
@@ -73,27 +108,51 @@ function ausOmdb(treffer: OmdbTreffer): MetaTreffer {
laufzeitMinuten: treffer.runtime,
genres: treffer.genres,
quelle: 'omdb',
logoPfad: '',
tagline: '',
bewertung: 0,
fsk: '',
besetzung: [],
folgenGesamt: 0,
}
}
export const LEERER_TREFFER: MetaTreffer = {
typ: 'unknown',
id: null,
titel: '',
jahr: null,
beschreibung: '',
posterPfad: '',
backdropPfad: '',
laufzeitMinuten: 0,
genres: [],
quelle: '',
logoPfad: '',
tagline: '',
bewertung: 0,
fsk: '',
besetzung: [],
folgenGesamt: 0,
}
const UNBEKANNT = (titel: string): Zuordnung => ({
titel,
jahr: null,
confidence: 0.3,
meta: {
typ: 'unknown',
id: null,
titel,
jahr: null,
beschreibung: '',
posterPfad: '',
backdropPfad: '',
laufzeitMinuten: 0,
genres: [],
quelle: '',
},
meta: { ...LEERER_TREFFER, titel },
})
/** Ein Eintrag ohne Inhalt ist kein Treffer (pur). Gemessen an den drei
* Spartacus: Gods of the Arena"-Stubs: kein Backdrop, keine Beschreibung,
* keine Bewertung TMDb kennt dort nur den Namen. */
export function istStub(details: FilmDetails): boolean {
const beschreibung = (details.overview ?? '').trim().length > 0
const bild = (details.backdrop_path ?? '') !== '' && details.backdrop_path !== null
const bewertet = (details.vote_count ?? 0) > 0
return !beschreibung && !bild && !bewertet
}
/** Titel Wortmenge, Satzzeichen fallen weg. Grundlage von Überlappung,
* Abdeckung und Titelvergleich alle drei sollen dasselbe Wort" meinen. */
function worte(t: string): Set<string> {
@@ -163,6 +222,35 @@ export interface DiscHinweis {
* trägt (discmerkmale.discTitel().istSerie). Dann sind die Film-Zweige
* aus: Was bewiesen ist, muss nicht mehr geraten werden. */
istSerie?: boolean
/** Weitere Titel derselben Disc (bdmt_deu neben bdmt_eng, 5.4.0). */
alternativen?: readonly string[]
}
interface Kandidat {
text: string
/** Die Vorlage, an der die Abdeckung gemessen wird (Titel ohne Zusatz). */
massstab: string
}
/** Suchvarianten aller Titel-Varianten: erst die vollen Titel, dann die
* gestutzten je Variante an ihrer eigenen Vorlage gemessen (pur). */
export function kandidatenAus(titel: string, alternativen: readonly string[] = []): Kandidat[] {
const quellen = [titel, ...alternativen].map((t) => t.trim()).filter((t) => t.length > 0)
const gesehen = new Set<string>()
const voll: Kandidat[] = []
const rest: Kandidat[] = []
for (const quelle of quellen) {
const massstab = discZusatzAbtrennen(quelle).titel || quelle
const varianten = titelKandidaten(quelle)
if (varianten.length === 0) varianten.push(quelle)
varianten.forEach((text, i) => {
const schluessel = text.toLowerCase()
if (gesehen.has(schluessel)) return
gesehen.add(schluessel)
;(i === 0 ? voll : rest).push({ text, massstab })
})
}
return [...voll, ...rest]
}
/** Die Kaskade Clients injiziert, damit sie ohne Netz prüfbar ist.
@@ -183,6 +271,8 @@ export interface DiscHinweis {
* '- Disc 2' zählen nicht mit). Vorher hing das an Kandidat Nummer
* 0"; sobald der Zusatz abgetrennt wurde, war der richtige Kandidat
* Nummer 1 und fiel durch dieses Raster.
* * (5.4.0) Ein Eintrag ohne Inhalt (istStub) ist kein Treffer der
* nächste wörtliche Treffer bekommt die Chance.
*/
export async function zuordnen(
discTitel: string,
@@ -194,43 +284,39 @@ export async function zuordnen(
if (titel.length === 0) return UNBEKANNT('')
const nurSerie = hinweis.istSerie === true
// Maßstab für die Abdeckung ist der Titel OHNE Verpackungs-Zusatz:
// '- Disc 2' ist Rauschen und darf einer guten Frage nicht angelastet werden.
const massstab = discZusatzAbtrennen(titel).titel || titel
const kandidaten = titelKandidaten(titel)
if (kandidaten.length === 0) kandidaten.push(titel)
const kandidaten = kandidatenAus(titel, hinweis.alternativen ?? [])
let ersteFilme: FilmTreffer[] = []
let ersteSerien: FilmTreffer[] = []
for (const kandidat of kandidaten) {
const deckung = abdeckung(massstab, kandidat)
const deckung = abdeckung(kandidat.massstab, kandidat.text)
const vollstaendig = deckung >= 1
/** Ein Treffer auf eine stark gestutzte Frage bleibt ein Vorschlag. */
const deckel = (basis: number): number => (deckung >= MINDEST_ABDECKUNG ? basis : Math.min(basis, VORSCHLAG))
const filme = nurSerie ? [] : ((await tmdb.searchMovie(kandidat)) ?? [])
const filme = nurSerie ? [] : ((await tmdb.searchMovie(kandidat.text)) ?? [])
if (filme.length > 0 && ersteFilme.length === 0) ersteFilme = filme
// Wörtlicher Filmtreffer (0,95)
for (const film of filme) {
if (gleichBedeutend(film.title ?? '', kandidat)) {
if (gleichBedeutend(film.title ?? '', kandidat.text)) {
const details = await tmdb.movieDetails(film.id)
if (details !== null) {
if (details !== null && !istStub(details)) {
const meta = ausDetails('movie', film.id, details)
return { titel: meta.titel, jahr: meta.jahr, confidence: deckel(0.95), meta }
}
}
}
const serien = (await tmdb.searchTv(kandidat)) ?? []
const serien = (await tmdb.searchTv(kandidat.text)) ?? []
if (serien.length > 0 && ersteSerien.length === 0) ersteSerien = serien
// Wörtlicher Serientreffer (0,90)
for (const serie of serien) {
if (gleichBedeutend(serie.name ?? '', kandidat)) {
if (gleichBedeutend(serie.name ?? '', kandidat.text)) {
const details = await tmdb.tvDetails(serie.id)
if (details !== null) {
if (details !== null && !istStub(details)) {
const meta = ausDetails('tv', serie.id, details)
return { titel: meta.titel, jahr: meta.jahr, confidence: deckel(0.9), meta }
}
@@ -239,20 +325,24 @@ export async function zuordnen(
// Spezifische Treffer (0,80): Wer auf eine VOLLSTÄNDIGE Anfrage eine
// Handvoll bekommt, hat gefragt wie jemand, der weiß, was er sucht
// (der Evangelion-Fund).
// (der Evangelion-Fund). Stubs zählen dabei nicht mit.
if (vollstaendig) {
if (filme.length > 0 && filme.length <= WENIGE_TREFFER) {
const details = await tmdb.movieDetails(filme[0].id)
if (details !== null) {
const meta = ausDetails('movie', filme[0].id, details)
return { titel: meta.titel, jahr: meta.jahr, confidence: 0.8, meta }
for (const film of filme) {
const details = await tmdb.movieDetails(film.id)
if (details !== null && !istStub(details)) {
const meta = ausDetails('movie', film.id, details)
return { titel: meta.titel, jahr: meta.jahr, confidence: 0.8, meta }
}
}
}
if (serien.length > 0 && serien.length <= WENIGE_TREFFER) {
const details = await tmdb.tvDetails(serien[0].id)
if (details !== null) {
const meta = ausDetails('tv', serien[0].id, details)
return { titel: meta.titel, jahr: meta.jahr, confidence: 0.8, meta }
for (const serie of serien) {
const details = await tmdb.tvDetails(serie.id)
if (details !== null && !istStub(details)) {
const meta = ausDetails('tv', serie.id, details)
return { titel: meta.titel, jahr: meta.jahr, confidence: 0.8, meta }
}
}
}
}
@@ -262,7 +352,7 @@ export async function zuordnen(
// Datensatz wird über TMDb /find (IMDb-ID) nachgeladen.
if (omdb.verfuegbar) {
for (const kandidat of kandidaten) {
const treffer = await omdb.lookup(kandidat)
const treffer = await omdb.lookup(kandidat.text)
if (treffer !== null) {
// Eine Serien-Disc nimmt keinen Film entgegen — sie hat bewiesen,
// was sie ist.
@@ -270,7 +360,7 @@ export async function zuordnen(
// Ähnlichkeits-Gate: OMDbs t=-Suche antwortet auch auf kryptische
// Labels mit irgendeinem Film — gemessen „Bd Evg D2" → „BD Scream
// 5". Ohne echte Wort-Überlappung ist das kein Treffer.
if (wortUeberlappung(kandidat, treffer.title) < 0.5) continue
if (wortUeberlappung(kandidat.text, treffer.title) < 0.5) continue
const meta = ausOmdb(treffer)
if (typeof meta.id === 'string' && meta.id.startsWith('tt') && tmdb.verfuegbar) {
const deutsch = await tmdb.findByImdb(meta.id)
@@ -288,7 +378,7 @@ export async function zuordnen(
}
// Auch OMDb wird gedeckelt: Ein Treffer auf eine ausgedünnte Frage
// ist ein Vorschlag, egal von welchem Dienst er kommt.
const sicher = abdeckung(massstab, kandidat) >= MINDEST_ABDECKUNG ? 0.8 : VORSCHLAG
const sicher = abdeckung(kandidat.massstab, kandidat.text) >= MINDEST_ABDECKUNG ? 0.8 : VORSCHLAG
return { titel: meta.titel, jahr: meta.jahr, confidence: sicher, meta }
}
}
@@ -296,20 +386,26 @@ export async function zuordnen(
// 5. Vorschlag: bester Rohtreffer (0,60) — das UI fragt nach. Bei einer
// Serien-Disc ist die Filmliste leer, dort zählen nur die Serien.
if (ersteFilme.length > 0) {
const details = await tmdb.movieDetails(ersteFilme[0].id)
if (details !== null) {
const meta = ausDetails('movie', ersteFilme[0].id, details)
return { titel: meta.titel, jahr: meta.jahr, confidence: VORSCHLAG, meta }
}
// Einträge MIT Inhalt zuerst; ist alles leer, bleibt der erste Treffer
// trotzdem ein Vorschlag — besser als gar keiner (es wird ja gefragt).
let notnagel: Zuordnung | null = null
for (const film of ersteFilme.slice(0, WENIGE_TREFFER)) {
const details = await tmdb.movieDetails(film.id)
if (details === null) continue
const meta = ausDetails('movie', film.id, details)
const vorschlag = { titel: meta.titel, jahr: meta.jahr, confidence: VORSCHLAG, meta }
if (!istStub(details)) return vorschlag
notnagel ??= vorschlag
}
if (ersteSerien.length > 0) {
const details = await tmdb.tvDetails(ersteSerien[0].id)
if (details !== null) {
const meta = ausDetails('tv', ersteSerien[0].id, details)
return { titel: meta.titel, jahr: meta.jahr, confidence: VORSCHLAG, meta }
}
for (const serie of ersteSerien.slice(0, WENIGE_TREFFER)) {
const details = await tmdb.tvDetails(serie.id)
if (details === null) continue
const meta = ausDetails('tv', serie.id, details)
const vorschlag = { titel: meta.titel, jahr: meta.jahr, confidence: VORSCHLAG, meta }
if (!istStub(details)) return vorschlag
notnagel ??= vorschlag
}
if (notnagel !== null) return notnagel
return UNBEKANNT(titel)
}
@@ -318,3 +414,71 @@ export async function zuordnen(
export function istSicherGenug(zuordnung: Zuordnung, schwelle = 0.9): boolean {
return zuordnung.confidence >= schwelle
}
/** Laufzeit passt, wenn die gemessene Länge des Hauptinhalts höchstens so
* weit von TMDb abweicht (Kino- gegen Heimvideo-Fassung, Abspann). */
export const LAUFZEIT_PASST = 0.06
/** Darüber passt sie NICHT — der Treffer wird zum Vorschlag (95 vs. 158 min). */
export const LAUFZEIT_FALSCH = 0.25
/** Was der Info-Lauf über die Disc gemessen hat (rip/titelwahl.ts). */
export interface Messung {
/** Länge des Hauptinhalts in Sekunden (Film) — 0 wenn keiner erkennbar. */
hauptS: number
/** Längen der Folgen-Kandidaten in Sekunden (Serie). */
folgenS: readonly number[]
/** Was die Titel-Struktur sagt: 'film' | 'serie' | 'unklar'. */
struktur: 'film' | 'serie' | 'unklar'
}
/**
* Nachprüfen NACH dem Info-Lauf (pur, 5.4.0): Erkannt wird beim EINLEGEN,
* also vor jeder Messung. Jetzt liegt die stärkste Evidenz vor, die zu
* haben ist die Laufzeit. Aus wahrscheinlich" wird „bestätigt", oder
* Rippy merkt selbst, dass es danebenlag. Eine Wahl des Nutzers
* (confidence 1) wird nie herabgestuft, nur kommentiert.
*/
export function nachpruefen(zuordnung: Zuordnung, messung: Messung): Zuordnung {
const hinweise: string[] = []
let confidence = zuordnung.confidence
const meta = zuordnung.meta
const vomNutzer = zuordnung.confidence >= 1
const soll = meta.laufzeitMinuten * 60
if (meta.typ === 'movie' && soll > 0 && messung.hauptS > 0) {
const abweichung = Math.abs(messung.hauptS - soll) / soll
const ist = Math.round(messung.hauptS / 60)
if (abweichung <= LAUFZEIT_PASST) {
hinweise.push(`Laufzeit bestätigt: ${ist} min auf der Disc, ${meta.laufzeitMinuten} min bei TMDb.`)
if (!vomNutzer) confidence = Math.max(confidence, 0.95)
} else if (abweichung >= LAUFZEIT_FALSCH) {
hinweise.push(
`Laufzeit passt NICHT: ${ist} min auf der Disc, ${meta.laufzeitMinuten} min bei TMDb — bitte über „Ändern …" nachsehen.`,
)
if (!vomNutzer) confidence = Math.min(confidence, VORSCHLAG)
} else {
hinweise.push(`Laufzeit ähnlich: ${ist} min auf der Disc, ${meta.laufzeitMinuten} min bei TMDb (andere Fassung?).`)
}
} else if (meta.typ === 'tv' && soll > 0 && messung.folgenS.length > 0) {
const mittel = messung.folgenS.reduce((s, f) => s + f, 0) / messung.folgenS.length
const abweichung = Math.abs(mittel - soll) / soll
const ist = Math.round(mittel / 60)
if (abweichung <= 0.2) {
hinweise.push(`Folgenlänge passt: ${ist} min auf der Disc, ${meta.laufzeitMinuten} min bei TMDb.`)
if (!vomNutzer) confidence = Math.max(confidence, 0.8)
} else if (abweichung >= 0.5) {
hinweise.push(`Folgenlänge passt NICHT: ${ist} min auf der Disc, ${meta.laufzeitMinuten} min bei TMDb.`)
if (!vomNutzer) confidence = Math.min(confidence, VORSCHLAG)
}
}
if (messung.struktur === 'serie' && meta.typ === 'movie') {
hinweise.push('Die Titel-Struktur sieht nach einer Serienstaffel aus (mehrere gleich lange Titel), TMDb-Treffer ist ein Film.')
if (!vomNutzer) confidence = Math.min(confidence, VORSCHLAG)
} else if (messung.struktur === 'film' && meta.typ === 'tv') {
hinweise.push('Die Titel-Struktur sieht nach einem Film aus (ein langer Titel), TMDb-Treffer ist eine Serie.')
if (!vomNutzer) confidence = Math.min(confidence, VORSCHLAG)
}
return { ...zuordnung, confidence: Math.round(confidence * 100) / 100, hinweise }
}
+3 -3
View File
@@ -11,7 +11,7 @@
// sich trotzdem mit 0 beenden (30.08.2026, Spartacus Disc 2).
// - Kritische MSG-NUMMERN einsammeln — die letzte Zeile ist fast immer
// nur „Failed to open disc", die Ursache steht Zeilen davor.
import { spawn } from 'node:child_process'
import { werkzeugSpawn } from '../werkzeuge/aufruf'
import { mkdirSync, readdirSync } from 'node:fs'
import { join } from 'node:path'
import {
@@ -160,7 +160,7 @@ export function titelInfoLesen(
abbruch?: AbortSignal,
): Promise<DiscInfo> {
return new Promise((fertig, scheitert) => {
const kind = spawn(programm, buildInfoArgs(geraetepfad), {
const kind = werkzeugSpawn(programm, buildInfoArgs(geraetepfad), {
windowsHide: true,
stdio: ['ignore', 'pipe', 'pipe'],
})
@@ -231,7 +231,7 @@ export function rippen(optionen: RipLaufOptionen): Promise<RipErgebnis> {
return new Promise((fertig) => {
mkdirSync(optionen.zielOrdner, { recursive: true })
const kind = spawn(
const kind = werkzeugSpawn(
optionen.programm,
buildRipArgs(optionen.geraetepfad, optionen.zielOrdner, optionen.titel),
{ windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] },
+15 -5
View File
@@ -170,11 +170,10 @@ export function parseStreamInfo(ausgabe: string): Map<number, TitelStreams> {
return ergebnis
}
export interface SprachEintrag {
lang: string
sprache: string
spuren: number
}
// EINE Definition für Kern und Fenster (5.4.0): Der Auswahl-Dialog zeigt
// die Sprachen je Titel — deshalb lebt der Typ in gemeinsam/.
import type { SprachEintrag } from '../../gemeinsam/nachrichten'
export type { SprachEintrag }
/** Welche Sprachen hat die DISC insgesamt? Über alle Titel vereinigt,
* Reihenfolge stabil (wie auf der Disc). */
@@ -200,6 +199,17 @@ export function sprachenZusammenfassen(streamsJeTitel: Map<number, TitelStreams>
return { audio: sammeln('audio'), untertitel: sammeln('untertitel') }
}
/** Die Sprachen EINES Titels (5.4.0, für den Auswahl-Dialog) dieselbe
* Zusammenfassung wie oben, nur auf einen Titel beschränkt. Ohne Streams
* (Titel unbekannt) leer, nicht erfunden. */
export function sprachenFuerTitel(streams: TitelStreams | undefined): {
audio: SprachEintrag[]
untertitel: SprachEintrag[]
} {
if (streams === undefined) return { audio: [], untertitel: [] }
return sprachenZusammenfassen(new Map([[0, streams]]))
}
// ── Titelwahl-Heuristiken ───────────────────────────────────────────────
/** Titel-Nummer für den Hauptfilm. Mit Soll-Laufzeit (Minuten, z. B. aus
* TMDb) gewinnt der Titel, der ihr am nächsten kommt (±20 min) das
+18
View File
@@ -62,6 +62,24 @@ export interface DiscHinweis {
folgen: number
}
/** Die Titel-Struktur als Fingerabdruck (5.4.0, KONZEPT § 5: 1 langer +
* viele kurze = Film; 613 gleich lange = Serienstaffel"). Bewusst
* ZURÜCKHALTEND 'unklar' ist die häufigste und ehrlichste Antwort:
* * 'serie': mindestens drei ähnlich lange Titel zwischen 15 und 75 min
* * 'film': ein Titel ab 60 min, der den zweitlängsten deutlich überragt
* Zwei gleich lange Fassungen (Seamless Branching) bleiben 'unklar', eine
* Zwei-Folgen-Disc auch (dafür ist das bdmt-Inhaltsverzeichnis da). */
export function strukturHinweis(alle: readonly TitelInfo[]): 'film' | 'serie' | 'unklar' {
const lang = alle.filter((t) => t.dauerS >= KURZ_S).sort((a, b) => b.dauerS - a.dauerS)
if (lang.length === 0) return 'unklar'
const laengster = lang[0].dauerS
const aehnlich = lang.filter((t) => Math.abs(t.dauerS - laengster) / laengster <= AEHNLICH)
if (aehnlich.length >= 3 && laengster >= 15 * 60 && laengster <= 75 * 60) return 'serie'
const zweiter = lang[1]?.dauerS ?? 0
if (laengster >= 60 * 60 && zweiter < laengster * 0.6) return 'film'
return 'unklar'
}
function dauerText(s: number): string {
const m = Math.round(s / 60)
return m >= 60 ? `${Math.floor(m / 60)} h ${String(m % 60).padStart(2, '0')} min` : `${m} min`
+144 -4
View File
@@ -1,18 +1,53 @@
// Der EINZIGE Ort, der die Datenbank anfasst — § 6.7: EINE Quelle für
// Einstellungen. Ein Wächter-Test (test/waechter.test.ts) prüft mechanisch,
// dass 'node:sqlite' nirgendwo sonst importiert wird. Der Fund hinter der
// Regel: In rc11 schrieb die Oberfläche in die Datenbank, der Betrieb las aus
// einer Konfigurationsdatei, die niemand schrieb.
// Regel: In rc11 schrieb die Oberfläche in die Datenbank, der Betrieb las
// aus einer Konfigurationsdatei, die niemand schrieb.
//
// node:sqlite statt better-sqlite3: am 30.08.2026 in Electron 44 im
// utilityProcess gemessen (KONZEPT-WINDOWS.md § 3.4, beweise/sqlite-*.js) —
// keine native Kompilierung, kein node-gyp in der Bau-Kette.
//
// Schema 4 (5.4.0): die Tabelle `vorgaenge`. Bis dahin wusste Rippy nach
// einem Rip NICHT mehr, welcher Roh-Ordner zu welchem Film gehört — und
// genau deshalb konnte niemand aufräumen (jede Blu-ray ließ 30+ GB liegen),
// niemand „ab Kompression wiederholen" und niemand nach einem Abbruch
// nachsehen, was halb fertig herumlag. Der Vorgang ist jetzt ein Datensatz.
import { DatabaseSync } from 'node:sqlite'
import { mkdirSync } from 'node:fs'
import { dirname } from 'node:path'
import type { BibliothekEintrag } from '../../gemeinsam/nachrichten'
import type { BibliothekEintrag, VorgangPhase } from '../../gemeinsam/nachrichten'
export const SCHEMA_VERSION = 3
export const SCHEMA_VERSION = 4
/** Ein Vorgang, wie die Datenbank ihn kennt (§ 6.7 / 5.4.0). */
export interface Vorgang {
/** Der Vorgangsname — zugleich der Name des Roh-Ordners. */
id: string
laufwerk: string
typ: string
titel: string
jahr: number | null
phase: VorgangPhase
begonnen: string
beendet: string | null
rohOrdner: string
rohBytes: number
rohGeloeschtAm: string | null
zielOrdner: string
fertigBytes: number
/** 0 = die Roh-Dateien SIND das Ergebnis (verschoben, nicht komprimiert). */
komprimiert: boolean
preset: string
fehler: string
/** Der Kompressions-Auftrag als JSON Grundlage für ab Kompression
* wiederholen" (kern/ablauf/auftrag.ts). */
auftrag: string
lesefehler: boolean
/** Die fertigen Dateien als JSON-Liste das Aufräumen prüft, dass sie
* wirklich da sind, BEVOR es den Roh-Ordner löscht. */
fertigeDateien: string
}
export class Datenbank {
readonly pfad: string
@@ -48,6 +83,27 @@ export class Datenbank {
datum TEXT NOT NULL,
dauer_s INTEGER NOT NULL,
poster_pfad TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS vorgaenge (
id TEXT PRIMARY KEY,
laufwerk TEXT NOT NULL,
typ TEXT NOT NULL,
titel TEXT NOT NULL,
jahr INTEGER,
phase TEXT NOT NULL,
begonnen TEXT NOT NULL,
beendet TEXT,
roh_ordner TEXT NOT NULL,
roh_bytes INTEGER NOT NULL DEFAULT 0,
roh_geloescht_am TEXT,
ziel_ordner TEXT NOT NULL DEFAULT '',
fertig_bytes INTEGER NOT NULL DEFAULT 0,
komprimiert INTEGER NOT NULL DEFAULT 0,
preset TEXT NOT NULL DEFAULT '',
fehler TEXT NOT NULL DEFAULT '',
auftrag TEXT NOT NULL DEFAULT '{}',
lesefehler INTEGER NOT NULL DEFAULT 0,
fertige_dateien TEXT NOT NULL DEFAULT '[]'
);`,
)
// Schema 2 → 3: Bestands-Datenbanken haben die Bibliothek noch ohne
@@ -129,7 +185,91 @@ export class Datenbank {
return zeile === undefined ? null : zeile.datum
}
// ── Vorgänge (5.4.0): welcher Roh-Ordner gehört zu welchem Rip ──────────
vorgangAnlegen(v: Vorgang): void {
this.db
.prepare(
`INSERT OR REPLACE INTO vorgaenge
(id, laufwerk, typ, titel, jahr, phase, begonnen, beendet, roh_ordner, roh_bytes,
roh_geloescht_am, ziel_ordner, fertig_bytes, komprimiert, preset, fehler, auftrag,
lesefehler, fertige_dateien)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(
v.id,
v.laufwerk,
v.typ,
v.titel,
v.jahr,
v.phase,
v.begonnen,
v.beendet,
v.rohOrdner,
v.rohBytes,
v.rohGeloeschtAm,
v.zielOrdner,
v.fertigBytes,
v.komprimiert ? 1 : 0,
v.preset,
v.fehler,
v.auftrag,
v.lesefehler ? 1 : 0,
v.fertigeDateien,
)
}
/** Ändert nur die genannten Felder — der Rest bleibt, wie er war. */
vorgangAktualisieren(id: string, teil: Partial<Omit<Vorgang, 'id'>>): void {
const alt = this.vorgangLesen(id)
if (alt === null) return
this.vorgangAnlegen({ ...alt, ...teil, id })
}
vorgangLesen(id: string): Vorgang | null {
const zeile = this.db.prepare(`SELECT * FROM vorgaenge WHERE id = ?`).get(id) as
| Record<string, unknown>
| undefined
return zeile === undefined ? null : vorgangAusZeile(zeile)
}
/** Alle Vorgänge, neueste zuerst. */
vorgangListe(): Vorgang[] {
const zeilen = this.db
.prepare(`SELECT * FROM vorgaenge ORDER BY begonnen DESC`)
.all() as Array<Record<string, unknown>>
return zeilen.map(vorgangAusZeile)
}
vorgangLoeschen(id: string): void {
this.db.prepare(`DELETE FROM vorgaenge WHERE id = ?`).run(id)
}
schliessen(): void {
this.db.close()
}
}
function vorgangAusZeile(z: Record<string, unknown>): Vorgang {
return {
id: String(z.id),
laufwerk: String(z.laufwerk),
typ: String(z.typ),
titel: String(z.titel),
jahr: z.jahr === null || z.jahr === undefined ? null : Number(z.jahr),
phase: String(z.phase) as VorgangPhase,
begonnen: String(z.begonnen),
beendet: z.beendet === null || z.beendet === undefined ? null : String(z.beendet),
rohOrdner: String(z.roh_ordner),
rohBytes: Number(z.roh_bytes ?? 0),
rohGeloeschtAm:
z.roh_geloescht_am === null || z.roh_geloescht_am === undefined ? null : String(z.roh_geloescht_am),
zielOrdner: String(z.ziel_ordner ?? ''),
fertigBytes: Number(z.fertig_bytes ?? 0),
komprimiert: Number(z.komprimiert ?? 0) === 1,
preset: String(z.preset ?? ''),
fehler: String(z.fehler ?? ''),
auftrag: String(z.auftrag ?? '{}'),
lesefehler: Number(z.lesefehler ?? 0) === 1,
fertigeDateien: String(z.fertige_dateien ?? '[]'),
}
}
@@ -0,0 +1,37 @@
// EINE Stelle, an der Werkzeuge gestartet werden (5.4.0) — mit einer Naht
// für den Klick-Beweis (bau/klick-smoke.mjs): Zeigt der eingestellte
// Werkzeug-Pfad auf ein Node-Skript (.cjs/.js), läuft es über die eigene
// Node-Laufzeit (ELECTRON_RUN_AS_NODE, Electron-Doku »Environment
// Variables«) statt als EXE. So klickt der Beweis durch das ECHTE Programm
// — Fenster, Kern, Datenbank, Warteschlange — nur makemkvcon und HandBrake
// sind nachgebaut. Für echte EXE-Pfade ändert sich nichts.
//
// Warum nicht einfach eine .cmd: Node weigert sich seit 18.20/20.12,
// .bat/.cmd ohne Shell zu starten (CVE-2024-27980) — und mit Shell wären
// Pfade mit Leerzeichen wieder eine Falle.
import { spawn } from 'node:child_process'
export function istSkript(programm: string): boolean {
const p = programm.toLowerCase()
return p.endsWith('.cjs') || p.endsWith('.mjs') || p.endsWith('.js')
}
/** Programm + Argumente, so wie spawn sie bekommen soll. */
export function werkzeugStart(
programm: string,
args: readonly string[],
umgebung: NodeJS.ProcessEnv = process.env,
): { programm: string; args: string[]; env: NodeJS.ProcessEnv } {
if (!istSkript(programm)) return { programm, args: [...args], env: umgebung }
return {
programm: process.execPath,
args: [programm, ...args],
env: { ...umgebung, ELECTRON_RUN_AS_NODE: '1' },
}
}
/** spawn mit der Naht — dieselbe Signatur, dieselben Typen. */
export const werkzeugSpawn: typeof spawn = ((programm: string, args: readonly string[], optionen: Record<string, unknown>) => {
const start = werkzeugStart(programm, args)
return spawn(start.programm, start.args, { ...optionen, env: start.env })
}) as typeof spawn
@@ -0,0 +1,78 @@
// MakeMKVs Spur-Auswahlregel (5.4.0) — damit ALLE Sprachen in der
// Roh-Datei landen, die der Auswahl-Dialog anbietet.
//
// Der Befund: Der Info-Lauf zeigt jede Tonspur der Disc — aber ob sie in
// die MKV kommt, entscheidet MakeMKVs „default selection rule". Die Doku
// (forum.makemkv.com, Thema 4386, am 01.09.2026 nachgelesen — Regel D)
// nennt als Regel des Standard-Profils
//
// -sel:all,+sel:(favlang|nolang),-sel:(havemulti|havecore),=100:all,-10:favlang
//
// mit: favlang = „matches favorite languages, always matches if no
// favorite language is set" · havemulti = „track is mono/stereo and there
// is a multi-channel track in same language" · havecore = „this track is
// hd track with core audio" · +sel/-sel = wählen/abwählen · =N = Gewicht.
//
// Solange KEINE Vorzugssprache gesetzt ist, nimmt MakeMKV also alles
// (bis auf die Stereo-Doppel und die DTS-Kerne). Sobald jemand in MakeMKVs
// Einstellungen „Deutsch" wählt, fielen japanischer und englischer Ton
// STILL weg — und der Dialog böte Sprachen an, die nicht in der Datei
// sind. Rippy setzt deshalb seine eigene Regel, die sprachunabhängig
// alles behält, und zwar NUR, wenn noch keine gesetzt ist (wer eine
// eigene hat, behält sie — wie bei app_UpdateEnable in schluessel.ts).
// Abgelegt wird wie der Schlüssel in HKCU\Software\MakeMKV (Windows liest
// dort, die settings.conf ist der Linux-Weg).
import { spawnSync } from 'node:child_process'
export const REGISTRY_PFAD = 'HKCU\\Software\\MakeMKV'
export const REGEL_NAME = 'app_DefaultSelectionString'
/** Alles behalten, Doppel und Kerne weg — sprachunabhängig. */
export const RIPPY_REGEL = '-sel:all,+sel:all,-sel:(havemulti|havecore),=100:all'
export interface SpurauswahlStand {
/** 'gesetzt' (von Rippy, jetzt) | 'eigen' (der Nutzer hat eine eigene) |
* 'rippy' (Rippys Regel steht schon) | 'fehler' */
stand: 'gesetzt' | 'eigen' | 'rippy' | 'fehler'
regel: string
text: string
}
function reg(args: string[]): { ok: boolean; ausgabe: string } {
const ergebnis = spawnSync('reg', args, { windowsHide: true, timeout: 15_000, encoding: 'utf8' })
return { ok: ergebnis.status === 0, ausgabe: (ergebnis.stdout ?? '') + (ergebnis.stderr ?? '') }
}
/** Den Wert aus `reg query`-Ausgabe lesen (pur, testbar):
* app_DefaultSelectionString REG_SZ -sel:all," */
export function regelAusQuery(ausgabe: string): string | null {
const treffer = new RegExp(`${REGEL_NAME}\\s+REG_SZ\\s+(.*)$`, 'm').exec(ausgabe)
return treffer === null ? null : treffer[1].trim()
}
/** Sicherstellen, dass eine Regel steht, die alle Sprachen behält. */
export function spurauswahlSicherstellen(): SpurauswahlStand {
if (process.platform !== 'win32') {
return { stand: 'fehler', regel: '', text: 'Nur unter Windows (Registry).' }
}
const vorhanden = reg(['query', REGISTRY_PFAD, '/v', REGEL_NAME])
const bisher = vorhanden.ok ? regelAusQuery(vorhanden.ausgabe) : null
if (bisher !== null && bisher.length > 0) {
if (bisher === RIPPY_REGEL) {
return { stand: 'rippy', regel: bisher, text: 'MakeMKV behält alle Sprachen (Rippys Auswahlregel steht).' }
}
return {
stand: 'eigen',
regel: bisher,
text: `MakeMKV hat eine eigene Auswahlregel („${bisher}") — Rippy lässt sie stehen. Fehlen Sprachen in der Roh-Datei, liegt es daran.`,
}
}
const gesetzt = reg(['add', REGISTRY_PFAD, '/v', REGEL_NAME, '/t', 'REG_SZ', '/d', RIPPY_REGEL, '/f'])
if (!gesetzt.ok) {
return { stand: 'fehler', regel: '', text: `Auswahlregel nicht setzbar: ${gesetzt.ausgabe.trim().slice(0, 160)}` }
}
return {
stand: 'gesetzt',
regel: RIPPY_REGEL,
text: 'MakeMKV-Auswahlregel gesetzt: alle Sprachen kommen in die Roh-Datei (Stereo-Doppel und DTS-Kerne nicht).',
}
}
+301
View File
@@ -0,0 +1,301 @@
// Tests von Kompression + Ablage (kern/ablauf/ablegen.ts, 5.4.0) — mit
// nachgebildetem HandBrake, das die Ausgabedatei wirklich SCHREIBT (die
// Serien-Umbenennung braucht sie). Die Regeln: Kompression nur wenn
// gewollt UND HandBrake da, sonst VERSCHIEBEN; ein Kompressions-Fehler
// lässt den verlustfreien Rip STEHEN und sagt wo er liegt; Sprachen je
// Datei erreichen HandBrake; Folgen von Hand schlagen die Laufzeit-Raterei.
import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { KompressionsAuftrag, RohDatei } from '../src/kern/ablauf/auftrag'
import type { Zuordnung } from '../src/kern/metadaten/zuordnung'
import { LEERER_TREFFER } from '../src/kern/metadaten/zuordnung'
const hb = vi.hoisted(() => ({
aufrufe: [] as Array<Record<string, unknown>>,
ergebnis: 'success' as 'success' | 'error' | 'cancelled',
}))
vi.mock('../src/kern/komprimieren/handbrake', async () => {
const fs = await import('node:fs')
return {
komprimieren: vi.fn(async (o: { ausgabe: string; aufFortschritt?: (p: number) => void }) => {
hb.aufrufe.push({ ...o })
o.aufFortschritt?.(50)
if (hb.ergebnis === 'cancelled') return { status: 'cancelled' }
if (hb.ergebnis === 'error') return { status: 'error', fehler: 'HandBrake endete mit Code 3' }
fs.writeFileSync(o.ausgabe, 'encoded')
return { status: 'success', ausgabe: o.ausgabe }
}),
}
})
import { komprimierenUndAblegen, type AblageUmgebung } from '../src/kern/ablauf/ablegen'
let wegwerf: string[] = []
afterEach(() => {
hb.aufrufe = []
hb.ergebnis = 'success'
for (const ordner of wegwerf.splice(0)) rmSync(ordner, { recursive: true, force: true })
})
function zuordnung(typ: 'movie' | 'tv', titel: string, jahr: number | null): Zuordnung {
return {
titel,
jahr,
confidence: 0.95,
meta: { ...LEERER_TREFFER, typ, id: 149, titel, jahr, quelle: 'tmdb', laufzeitMinuten: 124 },
}
}
function aufbau(dateinamen: string[], rollen: Array<RohDatei['rolle']> = []): {
ablage: string
roh: string
dateien: RohDatei[]
} {
const ablage = mkdtempSync(join(tmpdir(), 'rippy-ablegen-'))
wegwerf.push(ablage)
const roh = join(ablage, 'roh', 'bluray-2026-08-30T12-00-00')
mkdirSync(roh, { recursive: true })
const dateien = dateinamen.map((name, i) => {
const pfad = join(roh, name)
writeFileSync(pfad, 'roh'.repeat(10))
return {
pfad,
titelNr: i,
rolle: rollen[i] ?? 'hauptfilm',
dauerS: 3167,
groesseBytes: 30,
audioSprachen: [],
untertitelSprachen: [],
}
})
return { ablage, roh, dateien }
}
function auftrag(teil: Partial<KompressionsAuftrag> & { rohOrdner: string; dateien: RohDatei[] }): KompressionsAuftrag {
return {
vorgangId: 'bluray-2026-08-30T12-00-00',
laufwerk: 'G',
discTyp: 'bluray',
zuordnung: null,
staffel: null,
ersteFolge: null,
discNr: null,
fingerprint: 'LABEL|33',
lesefehler: false,
hinweise: [],
begonnen: '2026-08-30T12:00:00Z',
preset: '',
zielOrdner: '',
...teil,
}
}
function umgebung(ablage: string, überschreiben: Partial<AblageUmgebung> = {}): AblageUmgebung {
return {
handbrake: () => 'C:\\hb\\HandBrakeCLI.exe',
ablage: () => ablage,
einstellungen: () => ({ audioSprachen: 'deu,eng', untertitelSprachen: 'deu' }),
jetzt: () => new Date('2026-08-30T13:00:00Z'),
spurenScannen: async () => null,
...überschreiben,
}
}
const KEIN_ABBRUCH = new AbortController().signal
describe('komprimierenUndAblegen', () => {
it('komprimiert in Filme/<Titel (Jahr)> mit NFO und meldet die Phasen', async () => {
const { ablage, roh, dateien } = aufbau(['title_t00.mkv'])
const bibliothek: string[] = []
const phasen: string[] = []
const ergebnis = await komprimierenUndAblegen(
auftrag({ rohOrdner: roh, dateien, zuordnung: zuordnung('movie', 'Akira', 1988) }),
umgebung(ablage, { bibliothekEintragen: (e) => bibliothek.push(e.titel) }),
(s) => phasen.push(s.phase),
KEIN_ABBRUCH,
)
expect(ergebnis.status).toBe('fertig')
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(phasen).toContain('komprimiert')
expect(phasen.at(-1)).toBe('legt-ab')
expect(bibliothek).toEqual(['Akira'])
expect(ergebnis.text).toContain('komprimiert nach')
})
it('Kompression abgewählt: Roh-Dateien werden VERSCHOBEN, mit NFO — der Roh-Ordner bleibt leer zurück', async () => {
const { ablage, roh, dateien } = aufbau(['title_t00.mkv'])
const ergebnis = await komprimierenUndAblegen(
auftrag({ rohOrdner: roh, dateien, discTyp: 'uhd', zuordnung: zuordnung('movie', 'Akira', 1988) }),
umgebung(ablage, { einstellungen: () => ({ transcodePresetUhd: 'keine' }) }),
() => {},
KEIN_ABBRUCH,
)
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, 'movie.nfo'))).toBe(true)
expect(readdirSync(roh)).toEqual([])
expect(hb.aufrufe).toHaveLength(0)
})
it('ohne HandBrake: ebenfalls verschieben, nicht liegen lassen', async () => {
const { ablage, roh, dateien } = aufbau(['title_t00.mkv'])
const ergebnis = await komprimierenUndAblegen(
auftrag({ rohOrdner: roh, dateien }),
umgebung(ablage, { handbrake: () => '' }),
() => {},
KEIN_ABBRUCH,
)
expect(ergebnis.komprimiert).toBe(false)
expect(readdirSync(roh)).toEqual([])
expect(ergebnis.zielOrdner).toBe(join(ablage, 'Filme', 'bluray-2026-08-30T12-00-00'))
})
it('Kompressions-Fehler: Roh bleibt stehen und wird GENANNT', async () => {
hb.ergebnis = 'error'
const { ablage, roh, dateien } = aufbau(['title_t00.mkv'])
const ergebnis = await komprimierenUndAblegen(auftrag({ rohOrdner: roh, dateien }), umgebung(ablage), () => {}, KEIN_ABBRUCH)
expect(ergebnis.status).toBe('fehler')
expect(ergebnis.text).toContain('bleibt stehen')
expect(ergebnis.text).toContain(roh)
expect(existsSync(dateien[0].pfad)).toBe(true)
})
it('Abbruch mittendrin: abgebrochen, Roh bleibt', async () => {
hb.ergebnis = 'cancelled'
const { ablage, roh, dateien } = aufbau(['title_t00.mkv'])
const ergebnis = await komprimierenUndAblegen(auftrag({ rohOrdner: roh, dateien }), umgebung(ablage), () => {}, KEIN_ABBRUCH)
expect(ergebnis.status).toBe('abgebrochen')
expect(existsSync(dateien[0].pfad)).toBe(true)
})
it('Sprachen je Datei erreichen HandBrake — fremder Ton schaltet die deutschen Untertitel vor', async () => {
const { ablage, roh, dateien } = aufbau(['title_t00.mkv', 'title_t01.mkv'])
dateien[0].audioSprachen = ['jpn']
dateien[0].untertitelSprachen = ['eng', 'deu']
const ergebnis = await komprimierenUndAblegen(auftrag({ rohOrdner: roh, dateien }), umgebung(ablage), () => {}, KEIN_ABBRUCH)
expect(ergebnis.status).toBe('fertig')
expect(hb.aufrufe).toHaveLength(2)
// Datei 0: eigene Wahl — deu nach vorn, Vorwahl an.
expect(hb.aufrufe[0].audioSprachen).toEqual(['jpn'])
expect(hb.aufrufe[0].untertitelSprachen).toEqual(['deu', 'eng'])
expect(hb.aufrufe[0].untertitelStandard).toBe(true)
// Datei 1: nichts gewählt — die globale Einstellung, keine Vorwahl.
expect(hb.aufrufe[1].audioSprachen).toEqual(['deu', 'eng'])
expect(hb.aufrufe[1].untertitelSprachen).toEqual(['deu'])
expect(hb.aufrufe[1].untertitelStandard).toBe(false)
})
it('der Scan der Roh-Datei meldet fehlende Sprachen als Hinweis', async () => {
const { ablage, roh, dateien } = aufbau(['title_t00.mkv'])
dateien[0].audioSprachen = ['jpn']
const ergebnis = await komprimierenUndAblegen(
auftrag({ rohOrdner: roh, dateien }),
umgebung(ablage, {
spurenScannen: async () => ({ audio: [{ nr: 1, lang: 'deu', beschreibung: 'Deutsch' }], untertitel: [], dauerS: 3167 }),
}),
() => {},
KEIN_ABBRUCH,
)
expect(ergebnis.hinweise.join(' ')).toContain('Tonspur jpn ist NICHT in der Roh-Datei')
})
it('Serie von Hand: Staffel 1, ab Folge 3 → S01E03 und S01E04, Warnung bei TMDb-Abweichung', async () => {
const { ablage, roh, dateien } = aufbau(['title_t00.mkv', 'title_t01.mkv'], ['folge', 'folge'])
dateien[1].dauerS = 3249
const ergebnis = await komprimierenUndAblegen(
auftrag({
rohOrdner: roh,
dateien,
zuordnung: zuordnung('tv', 'Spartacus', 2010),
staffel: 1,
ersteFolge: 3,
}),
umgebung(ablage, {
// Folge 4 ist bei TMDb 20 min kürzer — das gibt eine Warnung, aber
// die Hand des Nutzers gilt.
episodenLaufzeiten: async () => [
[3, 3200],
[4, 2000],
],
}),
() => {},
KEIN_ABBRUCH,
)
expect(ergebnis.status).toBe('fertig')
expect(ergebnis.zielOrdner).toBe(join(ablage, 'Serien', 'Spartacus', 'Season 01'))
expect(readdirSync(ergebnis.zielOrdner).filter((n) => n.endsWith('.mkv')).sort()).toEqual([
'Spartacus S01E03.mkv',
'Spartacus S01E04.mkv',
])
expect(ergebnis.dateien.map((d) => d.split(/[\\/]/).pop())).toEqual(['Spartacus S01E03.mkv', 'Spartacus S01E04.mkv'])
expect(ergebnis.hinweise.join(' ')).toContain('Folge 4: Datei 54 min, TMDb 33 min')
expect(existsSync(join(ergebnis.zielOrdner, 'tvshow.nfo'))).toBe(true)
})
it('Serie ohne Vorgabe: Laufzeit-Zuordnung wie bisher — nur bei eindeutigem Treffer', async () => {
const { ablage, roh, dateien } = aufbau(['title_t00.mkv', 'title_t01.mkv'], ['folge', 'folge'])
dateien[0].dauerS = 3167
dateien[1].dauerS = 3249
const ergebnis = await komprimierenUndAblegen(
auftrag({ rohOrdner: roh, dateien, zuordnung: zuordnung('tv', 'Spartacus', 2010), staffel: 2 }),
umgebung(ablage, {
episodenLaufzeiten: async () => [
[1, 3180],
[2, 3240],
],
}),
() => {},
KEIN_ABBRUCH,
)
expect(readdirSync(ergebnis.zielOrdner).filter((n) => n.endsWith('.mkv')).sort()).toEqual([
'Spartacus S02E01.mkv',
'Spartacus S02E02.mkv',
])
})
it('Extras liegen im Unterordner extras\\ neben dem Hauptfilm', async () => {
const { ablage, roh, dateien } = aufbau(['title_t00.mkv', 'title_t05.mkv'], ['hauptfilm', 'extra'])
const ergebnis = await komprimierenUndAblegen(
auftrag({ rohOrdner: roh, dateien, zuordnung: zuordnung('movie', 'Akira', 1988) }),
umgebung(ablage),
() => {},
KEIN_ABBRUCH,
)
expect(existsSync(join(ergebnis.zielOrdner, 'title_t00.mkv'))).toBe(true)
expect(existsSync(join(ergebnis.zielOrdner, 'extras', 'title_t05.mkv'))).toBe(true)
})
it('Wiederholen: der vorgegebene Zielordner wird benutzt, kein zweiter angelegt', async () => {
const { ablage, roh, dateien } = aufbau(['title_t00.mkv'])
const ziel = join(ablage, 'Filme', 'Akira (1988)')
mkdirSync(ziel, { recursive: true })
const ergebnis = await komprimierenUndAblegen(
auftrag({ rohOrdner: roh, dateien, zuordnung: zuordnung('movie', 'Akira', 1988), zielOrdner: ziel, preset: 'Fast 1080p30' }),
umgebung(ablage),
() => {},
KEIN_ABBRUCH,
)
expect(ergebnis.zielOrdner).toBe(ziel)
expect(ergebnis.preset).toBe('Fast 1080p30')
expect(readdirSync(join(ablage, 'Filme'))).toEqual(['Akira (1988)'])
})
it('der Fortschritt ist über beide Dateien gewichtet', async () => {
const { ablage, roh, dateien } = aufbau(['title_t00.mkv', 'title_t01.mkv'])
dateien[0].groesseBytes = 3
dateien[1].groesseBytes = 1
const prozente: number[] = []
await komprimierenUndAblegen(auftrag({ rohOrdner: roh, dateien }), umgebung(ablage), (s) => prozente.push(s.prozent), KEIN_ABBRUCH)
// 50 % der ersten (3/4) = 37, 50 % der zweiten = 87.
expect(prozente).toContain(37)
expect(prozente).toContain(87)
})
})
+31
View File
@@ -0,0 +1,31 @@
// Die Werkzeug-Naht (kern/werkzeuge/aufruf.ts, 5.4.0): echte EXE-Pfade
// gehen unverändert durch; Node-Skripte (die Nachbauten des Klick-Beweises)
// laufen über die eigene Laufzeit mit ELECTRON_RUN_AS_NODE.
import { describe, expect, it } from 'vitest'
import { istSkript, werkzeugStart } from '../src/kern/werkzeuge/aufruf'
describe('werkzeugStart', () => {
it('eine EXE bleibt eine EXE — Argumente und Umgebung unverändert', () => {
const start = werkzeugStart('C:\\Programme\\MakeMKV\\makemkvcon.exe', ['-r', 'info', 'dev:G:'], { PATH: 'x' })
expect(start.programm).toBe('C:\\Programme\\MakeMKV\\makemkvcon.exe')
expect(start.args).toEqual(['-r', 'info', 'dev:G:'])
expect(start.env).toEqual({ PATH: 'x' })
})
it('ein Skript läuft über die eigene Laufzeit, das Skript wird erstes Argument', () => {
const start = werkzeugStart('F:\\rippy\\bau\\klick\\fake-makemkvcon.cjs', ['-r', 'info'], { PATH: 'x' })
expect(start.programm).toBe(process.execPath)
expect(start.args).toEqual(['F:\\rippy\\bau\\klick\\fake-makemkvcon.cjs', '-r', 'info'])
expect(start.env['ELECTRON_RUN_AS_NODE']).toBe('1')
expect(start.env['PATH']).toBe('x')
})
it('istSkript kennt .cjs/.mjs/.js — und sonst nichts', () => {
expect(istSkript('a.cjs')).toBe(true)
expect(istSkript('A.MJS')).toBe(true)
expect(istSkript('a.js')).toBe(true)
expect(istSkript('HandBrakeCLI.exe')).toBe(false)
expect(istSkript('a.json')).toBe(false)
expect(istSkript('')).toBe(false)
})
})
+118
View File
@@ -0,0 +1,118 @@
// Tests des Kompressions-Auftrags (kern/ablauf/auftrag.ts, 5.4.0) — pur:
// Sprachwahl je Datei, Folgen von Hand, gewichteter Fortschritt, JSON.
import { describe, expect, it } from 'vitest'
import {
auftragAusJson,
auftragZuJson,
folgenNummern,
folgenPlausibel,
gewichteterFortschritt,
sprachwahl,
type KompressionsAuftrag,
} from '../src/kern/ablauf/auftrag'
describe('sprachwahl — die Regel des Commanders', () => {
const global = { audio: ['deu', 'eng'], untertitel: ['deu'] }
it('nichts gewählt: die globale Einstellung, keine Vorwahl', () => {
expect(sprachwahl({ audioSprachen: [], untertitelSprachen: [] }, global)).toEqual({
audio: ['deu', 'eng'],
untertitel: ['deu'],
untertitelStandard: false,
})
})
it('japanischer Ton + deutsche Untertitel: Untertitel nach vorn und vorgewählt', () => {
expect(sprachwahl({ audioSprachen: ['jpn'], untertitelSprachen: ['eng', 'deu'] }, global)).toEqual({
audio: ['jpn'],
untertitel: ['deu', 'eng'],
untertitelStandard: true,
})
})
it('deutscher Ton zuerst: keine Vorwahl, auch wenn Untertitel gewählt sind', () => {
expect(sprachwahl({ audioSprachen: ['deu', 'jpn'], untertitelSprachen: ['deu'] }, global).untertitelStandard).toBe(false)
})
it('fremder Ton, aber kein deutscher Untertitel gewählt: nichts vorzuwählen', () => {
expect(sprachwahl({ audioSprachen: ['jpn'], untertitelSprachen: ['eng'] }, global)).toEqual({
audio: ['jpn'],
untertitel: ['eng'],
untertitelStandard: false,
})
})
it('ohne globale Wunschsprache gibt es keine Vorwahl', () => {
expect(sprachwahl({ audioSprachen: ['jpn'], untertitelSprachen: ['deu'] }, { audio: [], untertitel: [] }).untertitelStandard).toBe(false)
})
})
describe('folgenNummern und folgenPlausibel', () => {
it('fortlaufend ab der ersten Folge', () => {
expect(folgenNummern(2, 3)).toEqual([3, 4])
expect(folgenNummern(0, 3)).toEqual([])
expect(folgenNummern(2, 0)).toEqual([1, 2])
})
it('warnt nur bei deutlicher Abweichung von TMDb', () => {
const laufzeiten: Array<[number, number]> = [
[3, 3200],
[4, 2000],
]
const w = folgenPlausibel([3167, 3249], [3, 4], laufzeiten)
expect(w).toHaveLength(1)
expect(w[0]).toContain('Folge 4')
expect(folgenPlausibel([3167], [9], laufzeiten)).toEqual([])
})
})
describe('gewichteterFortschritt — nie zurück auf null', () => {
it('gewichtet nach Größe', () => {
expect(gewichteterFortschritt([3, 1], 0, 50)).toBe(37)
expect(gewichteterFortschritt([3, 1], 0, 100)).toBe(75)
expect(gewichteterFortschritt([3, 1], 1, 50)).toBe(87)
expect(gewichteterFortschritt([3, 1], 1, 100)).toBe(100)
})
it('ohne Gewichte zählen alle gleich; -1 heißt „noch keine Angabe" vom laufenden Teil', () => {
expect(gewichteterFortschritt([0, 0], 1, 50)).toBe(75)
expect(gewichteterFortschritt([1, 1], 1, -1)).toBe(50)
expect(gewichteterFortschritt([], 0, 50)).toBe(-1)
})
})
describe('Auftrag als JSON — und wieder zurück', () => {
const a: KompressionsAuftrag = {
vorgangId: 'bluray-2026-08-30T12-00-00',
laufwerk: 'G',
discTyp: 'bluray',
rohOrdner: 'E:\\Rippy\\roh\\bluray-2026-08-30T12-00-00',
dateien: [
{ pfad: 'E:\\Rippy\\roh\\x\\title_t00.mkv', titelNr: 0, rolle: 'folge', dauerS: 3167, groesseBytes: 16e9, audioSprachen: ['jpn'], untertitelSprachen: ['deu'] },
],
zuordnung: null,
staffel: 1,
ersteFolge: 3,
discNr: 2,
fingerprint: 'SPARTACUS_GOTA_D2|33759100928',
lesefehler: false,
hinweise: ['Übersprungen: Titel 5.'],
begonnen: '2026-08-30T12:00:00.000Z',
preset: '',
zielOrdner: '',
}
it('kommt unverändert zurück', () => {
expect(auftragAusJson(auftragZuJson(a))).toEqual(a)
})
it('Unsinn ist null, kein Absturz', () => {
expect(auftragAusJson('{')).toBeNull()
expect(auftragAusJson('{}')).toBeNull()
expect(auftragAusJson('{"vorgangId":"x","rohOrdner":"y","dateien":[{}]}')).toBeNull()
})
it('fehlende Felder bekommen Vorgaben', () => {
const b = auftragAusJson('{"vorgangId":"x","rohOrdner":"y","dateien":[{"pfad":"p"}]}')!
expect(b.dateien[0].rolle).toBe('hauptfilm')
expect(b.dateien[0].audioSprachen).toEqual([])
expect(b.staffel).toBeNull()
expect(b.preset).toBe('')
})
})
+56
View File
@@ -71,3 +71,59 @@ describe('Datenbank', () => {
expect(() => new Datenbank(kaputt)).toThrow()
})
})
// ── Vorgänge (5.4.0): welcher Roh-Ordner gehört zu welchem Rip ──────────
describe('Datenbank — Vorgänge', () => {
const vorgang = {
id: 'bluray-2026-09-01T20-00-00',
laufwerk: 'G',
typ: 'bluray',
titel: 'Spartacus',
jahr: 2010,
phase: 'rippt' as const,
begonnen: '2026-09-01T20:00:00.000Z',
beendet: null,
rohOrdner: 'E:\Rippy\roh\bluray-2026-09-01T20-00-00',
rohBytes: 0,
rohGeloeschtAm: null,
zielOrdner: '',
fertigBytes: 0,
komprimiert: false,
preset: '',
fehler: '',
auftrag: '{}',
lesefehler: false,
fertigeDateien: '[]',
}
it('anlegen, lesen, aktualisieren (nur die genannten Felder), listen, löschen', () => {
const db = new Datenbank(':memory:')
aufraeumen.push(() => db.schliessen())
db.vorgangAnlegen(vorgang)
expect(db.vorgangLesen(vorgang.id)).toEqual(vorgang)
db.vorgangAktualisieren(vorgang.id, { phase: 'fertig', komprimiert: true, fertigBytes: 4800, beendet: '2026-09-01T21:00:00.000Z' })
const neu = db.vorgangLesen(vorgang.id)!
expect(neu.phase).toBe('fertig')
expect(neu.komprimiert).toBe(true)
expect(neu.fertigBytes).toBe(4800)
expect(neu.titel).toBe('Spartacus')
expect(neu.rohGeloeschtAm).toBeNull()
db.vorgangAnlegen({ ...vorgang, id: 'dvd-2026-09-02T10-00-00', begonnen: '2026-09-02T10:00:00.000Z' })
expect(db.vorgangListe().map((v) => v.id)).toEqual(['dvd-2026-09-02T10-00-00', vorgang.id])
db.vorgangLoeschen(vorgang.id)
expect(db.vorgangLesen(vorgang.id)).toBeNull()
expect(db.vorgangAktualisieren('gibt-es-nicht', { phase: 'fertig' })).toBeUndefined()
})
it('Schema 3 → 4: eine Bestands-Datenbank bekommt die Tabelle ohne Datenverlust', () => {
const pfad = frischerPfad()
const alt = new Datenbank(pfad)
alt.einstellungSchreiben('ablage', 'E:\Rippy')
alt.schliessen()
const neu = new Datenbank(pfad)
aufraeumen.push(() => neu.schliessen())
expect(neu.einstellungLesen('ablage')).toBe('E:\Rippy')
expect(neu.vorgangListe()).toEqual([])
expect(neu.schemaVersion()).toBe(SCHEMA_VERSION)
})
})
+70
View File
@@ -0,0 +1,70 @@
// Tests der Laufwerks-Gesundheit (kern/laufwerk/ereignisse.ts, 5.4.0) —
// an der ECHTEN wevtutil-Ausgabe dieses PCs (01.09.2026): cdrom 7
// (fehlerhafter Block, der Spartacus-Defekt), cdrom 11 (Controllerfehler,
// der USB-Aussetzer).
import { describe, expect, it } from 'vitest'
import { beurteilen, buildWevtutilArgs, ereignisseAusXml, xpathFuer } from '../src/kern/laufwerk/ereignisse'
/** Drei Ereignisse, so wie wevtutil /f:xml sie schreibt — ohne Zeilenumbrüche. */
const XML =
"<Event xmlns='http://schemas.microsoft.com/win/2004/08/events/event'><System><Provider Name='cdrom'/><EventID Qualifiers='49156'>7</EventID><Version>0</Version><Level>2</Level><TimeCreated SystemTime='2026-09-01T18:32:42.3868580Z'/><EventRecordID>135751</EventRecordID><Channel>System</Channel><Computer>TobisNicerPC</Computer></System><EventData><Data>\\Device\\CdRom0</Data><Binary>0300</Binary></EventData></Event>" +
"<Event xmlns='http://schemas.microsoft.com/win/2004/08/events/event'><System><Provider Name='cdrom'/><EventID Qualifiers='49156'>7</EventID><TimeCreated SystemTime='2026-09-01T18:32:39.6629681Z'/><Channel>System</Channel></System><EventData><Data>\\Device\\CdRom0</Data></EventData></Event>" +
"<Event xmlns='http://schemas.microsoft.com/win/2004/08/events/event'><System><Provider Name='cdrom'/><EventID Qualifiers='49156'>11</EventID><TimeCreated SystemTime='2026-09-01T18:17:39.6632548Z'/><Channel>System</Channel></System><EventData><Data>\\Device\\CdRom0</Data></EventData></Event>"
describe('ereignisseAusXml', () => {
it('liest Provider, ID, Zeit und Gerät je Ereignis', () => {
const e = ereignisseAusXml(XML)
expect(e).toHaveLength(3)
expect(e[0]).toEqual({ zeit: '2026-09-01T18:32:42.3868580Z', provider: 'cdrom', id: 7, geraet: '\\Device\\CdRom0' })
expect(e[2].id).toBe(11)
})
it('leere Ausgabe = keine Ereignisse', () => {
expect(ereignisseAusXml('')).toEqual([])
expect(ereignisseAusXml('\r\n')).toEqual([])
})
})
describe('beurteilen — Disc oder Hardware?', () => {
const alle = ereignisseAusXml(XML)
it('ein Controllerfehler dabei: Hardware — Port am Mainboard probieren', () => {
const b = beurteilen(alle, 24)
expect(b.urteil).toBe('hardware')
expect(b.controllerfehler).toBe(1)
expect(b.fehlerhafteBloecke).toBe(2)
expect(b.text).toContain('Mainboard')
expect(b.letzte).toHaveLength(3)
expect(b.letzte[0]).toContain('fehlerhafter Block')
})
it('nur fehlerhafte Blöcke: die Disc', () => {
const b = beurteilen(alle.filter((e) => e.id === 7), 24)
expect(b.urteil).toBe('disc')
expect(b.text).toContain('DISC')
})
it('nichts: ruhig', () => {
const b = beurteilen([], 24)
expect(b.urteil).toBe('ruhig')
expect(b.text).toContain('nichts')
})
it('seit einem Zeitpunkt: nur die jüngeren zählen', () => {
const b = beurteilen(alle, 24, new Date('2026-09-01T18:30:00Z'))
expect(b.controllerfehler).toBe(0)
expect(b.urteil).toBe('disc')
})
})
describe('die wevtutil-Argumente (Regel D: so gemessen)', () => {
it('XPath auf cdrom/disk mit Zeitfenster, XML, neueste zuerst', () => {
expect(xpathFuer(24)).toBe("*[System[Provider[@Name='cdrom' or @Name='disk'] and TimeCreated[timediff(@SystemTime) <= 86400000]]]")
const args = buildWevtutilArgs(24, 50)
expect(args[0]).toBe('qe')
expect(args[1]).toBe('System')
expect(args[2]).toBe(`/q:${xpathFuer(24)}`)
expect(args).toContain('/f:xml')
expect(args).toContain('/c:50')
expect(args).toContain('/rd:true')
})
})
+19
View File
@@ -182,3 +182,22 @@ describe('presets', () => {
expect(sprachliste(null)).toEqual([])
})
})
describe('buildHandbrakeArgs — Untertitel vorwählen (5.4.0, am HandBrake 1.11 gemessen)', () => {
it('mit Sprachliste und untertitelStandard kommt --subtitle-default=1 dazu', () => {
const args = buildHandbrakeArgs({
eingabe: 'a.mkv',
ausgabe: 'b.mkv',
preset: 'X',
audioSprachen: ['jpn'],
untertitelSprachen: ['deu', 'eng'],
untertitelStandard: true,
})
expect(args).toContain('--subtitle-default=1')
expect(args[args.indexOf('--subtitle-lang-list') + 1]).toBe('deu,eng')
})
it('ohne Sprachliste KEINE Vorwahl — die Reihenfolge wäre nicht bestimmt', () => {
const args = buildHandbrakeArgs({ eingabe: 'a.mkv', ausgabe: 'b.mkv', preset: 'X', untertitelStandard: true })
expect(args).not.toContain('--subtitle-default=1')
})
})
+31
View File
@@ -0,0 +1,31 @@
// MESSUNG an einer ECHTEN Roh-Datei: Welche Ton- und Untertitelspuren hat
// MakeMKV wirklich in die MKV geschrieben? (5.4.0, Regel D)
//
// RIPPY_MESSUNG_MKV="E:\Rippy\roh\bluray-…\title_t00.mkv" npx vitest run test/messung.spuren.test.ts --reporter=verbose
//
// Der Anlass: Der Auswahl-Dialog zeigt die Sprachen, die der Info-Lauf an
// der DISC gemessen hat. Ob sie alle in der Datei landen, entscheidet
// MakeMKVs Auswahlregel (werkzeuge/spurauswahl.ts). Diese Messung fragt die
// Datei selbst — über denselben HandBrake-Scan, den die Kompression benutzt
// (komprimieren/scan.ts). Läuft nur auf Verlangen und nur unter Windows.
import { describe, expect, it } from 'vitest'
const datei = process.env.RIPPY_MESSUNG_MKV ?? ''
const messen = datei.length > 0 && process.platform === 'win32'
describe.skipIf(!messen)('Spuren einer echten Roh-Datei', () => {
it('HandBrake-Scan liefert Ton- und Untertitelspuren mit Sprachcode', { timeout: 180_000 }, async () => {
const { finden } = await import('../src/kern/werkzeuge/katalog')
const { spurenScannen } = await import('../src/kern/komprimieren/scan')
const handbrake = finden('handbrake', '')
expect(handbrake.length, 'HandBrakeCLI nicht gefunden').toBeGreaterThan(0)
const start = Date.now()
const spuren = await spurenScannen(handbrake, datei)
console.log(`MESSUNG Scan: ${((Date.now() - start) / 1000).toFixed(1)} s — ${datei}`)
expect(spuren, 'Scan lieferte nichts').not.toBeNull()
console.log(`MESSUNG Dauer: ${Math.round(spuren!.dauerS / 60)} min`)
for (const a of spuren!.audio) console.log(`MESSUNG Ton ${a.nr}: ${a.lang}${a.beschreibung}`)
for (const u of spuren!.untertitel) console.log(`MESSUNG Untertitel ${u.nr}: ${u.lang}${u.beschreibung}`)
expect(spuren!.audio.length).toBeGreaterThan(0)
})
})
+289
View File
@@ -0,0 +1,289 @@
// Erkennung v2 + Kino-Banner v2 (5.4.0) — alles an GEMESSENEN Antworten
// (01.09.2026, TMDb mit dem hinterlegten v4-Token):
// movie/149 (Akira): tagline, runtime 124, vote_average 7.94,
// images.logos mit iso_639_1 'en'/'de', credits.cast, release_dates DE
// mit certification '16' (type 3 Kino, 5 Heimvideo, 6 TV).
// tv/46296 (Spartacus): episode_run_time [53], content_ratings DE '18'.
// Die drei „Spartacus: Gods of the Arena"-Stubs: overview '', backdrop
// null, 1 Folge, Popularität 1.
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { bdmtZerlegen, inhaltAusBdmt } from '../src/kern/metadaten/discmerkmale'
import { besetzungAus, fskAus, logoAus, type FilmDetails } from '../src/kern/metadaten/tmdb'
import {
LEERER_TREFFER,
VORSCHLAG,
ausDetails,
istStub,
kandidatenAus,
nachpruefen,
zuordnen,
type Zuordnung,
} from '../src/kern/metadaten/zuordnung'
import { strukturHinweis } from '../src/kern/rip/titelwahl'
let wegwerf: string[] = []
afterEach(() => {
for (const o of wegwerf.splice(0)) rmSync(o, { recursive: true, force: true })
})
/** Die gemessene Akira-Antwort, auf die genutzten Felder gekürzt. */
const AKIRA: FilmDetails = {
id: 149,
title: 'Akira',
release_date: '1988-06-10',
overview: 'Tokio 1988. In einem Geheimlabor der Armee …',
poster_path: '/fDRZmPUPUHTxULsuSjPIqPHKjuE.jpg',
backdrop_path: '/fK40VGYIm7hmKrLJ26fgPQU0qRG.jpg',
runtime: 124,
genres: [{ name: 'Animation' }, { name: 'Science Fiction' }, { name: 'Action' }],
tagline: 'Neo-Tokyo ist am EXPLODIEREN',
vote_average: 7.94,
vote_count: 4944,
images: {
logos: [
{ iso_639_1: 'en', file_path: '/AbcUfIbGRwrlPqdmTSi2WzArjQh.png', width: 664, height: 387, vote_average: 1.75 },
{ iso_639_1: 'en', file_path: '/cHIsyx3xAW314SijTvOyPozWwDV.png', width: 472, height: 290, vote_average: 1.75 },
{ iso_639_1: 'de', file_path: '/lHBQKVTxGUUqCMVo7rzTkYICHjm.png', width: 867, height: 404, vote_average: 0 },
{ iso_639_1: null, file_path: '/sprachlos.png', width: 100, height: 50, vote_average: 5 },
],
},
credits: {
cast: [
{ name: 'Nozomu Sasaki', character: 'Tetsuo Shima (voice)', profile_path: '/7usoPlvc9Xa1rDA2vxmVi0OvLDb.jpg', order: 1 },
{ name: 'Mitsuo Iwata', character: 'Shotaro Kaneda (voice)', profile_path: '/xCAsuzc5OBQZWhG5WSZIyhBZ7xj.jpg', order: 0 },
{ name: 'Mami Koyama', character: 'Kei (voice)', profile_path: null, order: 2 },
],
},
release_dates: {
results: [
{ iso_3166_1: 'US', release_dates: [{ certification: 'R', type: 3 }] },
{
iso_3166_1: 'DE',
release_dates: [
{ certification: '16', type: 5 },
{ certification: '16', type: 3 },
{ certification: '', type: 6 },
],
},
],
},
}
const SPARTACUS_TV: FilmDetails = {
id: 46296,
name: 'Spartacus',
first_air_date: '2010-01-22',
overview: 'Der Thraker Spartacus …',
backdrop_path: '/x.jpg',
vote_average: 8,
vote_count: 1000,
episode_run_time: [53],
number_of_episodes: 39,
content_ratings: { results: [{ iso_3166_1: 'US', rating: 'TV-MA' }, { iso_3166_1: 'DE', rating: '18' }] },
}
/** Einer der drei gemessenen Stubs. */
const STUB: FilmDetails = {
id: 331850,
name: 'Spartacus: Gods of the Arena',
first_air_date: '',
overview: '',
backdrop_path: null,
vote_average: 8,
vote_count: 0,
number_of_episodes: 1,
episode_run_time: [],
}
describe('Kino-Banner v2 — die Felder aus der gemessenen Antwort', () => {
it('logoAus: deutsch vor englisch, darin das bestbewertete', () => {
expect(logoAus(AKIRA.images?.logos)).toBe('/lHBQKVTxGUUqCMVo7rzTkYICHjm.png')
expect(logoAus(AKIRA.images?.logos?.filter((l) => l.iso_639_1 !== 'de'))).toBe('/AbcUfIbGRwrlPqdmTSi2WzArjQh.png')
expect(logoAus([])).toBe('')
expect(logoAus(undefined)).toBe('')
})
it('fskAus: Film aus den deutschen Freigaben (Kino vor Heimvideo), Serie aus content_ratings', () => {
expect(fskAus(AKIRA, 'movie')).toBe('16')
expect(fskAus(SPARTACUS_TV, 'tv')).toBe('18')
expect(fskAus({ id: 1 }, 'movie')).toBe('')
expect(fskAus(STUB, 'tv')).toBe('')
})
it('besetzungAus: nach order sortiert, höchstens sechs, ohne Bild bleibt der Name', () => {
const b = besetzungAus(AKIRA.credits?.cast)
expect(b.map((d) => d.name)).toEqual(['Mitsuo Iwata', 'Nozomu Sasaki', 'Mami Koyama'])
expect(b[0].rolle).toBe('Shotaro Kaneda (voice)')
expect(b[2].bildPfad).toBe('')
expect(besetzungAus(undefined)).toEqual([])
})
it('ausDetails trägt alles in den Treffer — Film und Serie', () => {
const film = ausDetails('movie', 149, AKIRA)
expect(film.tagline).toBe('Neo-Tokyo ist am EXPLODIEREN')
expect(film.bewertung).toBe(7.9)
expect(film.fsk).toBe('16')
expect(film.logoPfad).toContain('.png')
expect(film.besetzung).toHaveLength(3)
expect(film.laufzeitMinuten).toBe(124)
expect(film.genres).toEqual(['Animation', 'Science Fiction', 'Action'])
const serie = ausDetails('tv', 46296, SPARTACUS_TV)
expect(serie.laufzeitMinuten).toBe(53)
expect(serie.folgenGesamt).toBe(39)
expect(serie.fsk).toBe('18')
})
it('istStub erkennt die leeren Einträge — und nichts anderes', () => {
expect(istStub(STUB)).toBe(true)
expect(istStub(AKIRA)).toBe(false)
expect(istStub(SPARTACUS_TV)).toBe(false)
// Nur die Beschreibung fehlt (deutsche Übersetzung nicht da) — kein Stub.
expect(istStub({ ...SPARTACUS_TV, overview: '' })).toBe(false)
})
})
describe('zuordnen — leere Einträge zählen nicht (der Stub-Fund)', () => {
const tmdb = {
verfuegbar: true,
searchMovie: async () => [],
searchTv: async (t: string) =>
t === 'Spartacus: Gods of the Arena'
? [
{ id: 331850, name: 'Spartacus: Gods of the Arena' },
{ id: 331915, name: 'Spartacus Gods of the Arena' },
]
: t === 'Spartacus'
? [{ id: 46296, name: 'Spartacus' }]
: [],
movieDetails: async () => null,
tvDetails: async (id: number) => (id === 46296 ? SPARTACUS_TV : { ...STUB, id }),
findByImdb: async () => null,
}
const keinOmdb = { verfuegbar: false, lookup: async () => null }
it('der wörtliche Stub-Treffer gewinnt nicht mehr — die echte Serie wird ein Vorschlag', async () => {
const z = await zuordnen('Spartacus: Gods of the Arena', tmdb, keinOmdb, { istSerie: true })
// Über den Doppelpunkt-Vorspann 'Spartacus' (Abdeckung 1/5 → gedeckelt).
expect(z.titel).toBe('Spartacus')
expect(z.confidence).toBe(VORSCHLAG)
expect(z.meta.laufzeitMinuten).toBe(53)
})
it('gibt es NUR Stubs, bleibt der erste ein Vorschlag — es wird ja gefragt', async () => {
const nurStubs = { ...tmdb, searchTv: async (t: string) => (t.startsWith('Spartacus: Gods') ? [{ id: 331850, name: 'Spartacus: Gods of the Arena' }] : []) }
const z = await zuordnen('Spartacus: Gods of the Arena', nurStubs, keinOmdb, { istSerie: true })
expect(z.confidence).toBe(VORSCHLAG)
expect(z.titel).toBe('Spartacus: Gods of the Arena')
})
})
describe('kandidatenAus — jede Titel-Variante an ihrer eigenen Vorlage gemessen', () => {
it('volle Titel zuerst, dann die gestutzten, ohne Doppel', () => {
const k = kandidatenAus('Spartacus: Gods Of The Arena - Disc 2', ['Spartacus: Götter der Arena - Disc 2'])
expect(k[0].text).toBe('Spartacus: Gods Of The Arena - Disc 2')
expect(k[1].text).toBe('Spartacus: Götter der Arena - Disc 2')
expect(k[0].massstab).toBe('Spartacus: Gods Of The Arena')
expect(k[1].massstab).toBe('Spartacus: Götter der Arena')
expect(new Set(k.map((x) => x.text.toLowerCase())).size).toBe(k.length)
})
it('ohne Alternativen wie bisher', () => {
expect(kandidatenAus('Akira').map((x) => x.text)).toEqual(['Akira'])
})
})
describe('nachpruefen — die Laufzeit nach dem Info-Lauf', () => {
const akira: Zuordnung = { titel: 'Akira', jahr: 1988, confidence: 0.8, meta: ausDetails('movie', 149, AKIRA) }
it('passt die Laufzeit, wird aus 0,80 ein bestätigtes 0,95', () => {
const z = nachpruefen(akira, { hauptS: 124 * 60 + 30, folgenS: [], struktur: 'film' })
expect(z.confidence).toBe(0.95)
expect(z.hinweise?.[0]).toContain('Laufzeit bestätigt')
})
it('passt sie gar nicht (95 vs 158 min), fällt der Treffer auf den Vorschlag zurück', () => {
const falsch = { ...akira, meta: { ...akira.meta, laufzeitMinuten: 158 } }
const z = nachpruefen(falsch, { hauptS: 95 * 60, folgenS: [], struktur: 'film' })
expect(z.confidence).toBe(VORSCHLAG)
expect(z.hinweise?.[0]).toContain('passt NICHT')
})
it('die Wahl des Nutzers (1,0) wird nie herabgestuft, nur kommentiert', () => {
const eigene = { ...akira, confidence: 1 }
const z = nachpruefen(eigene, { hauptS: 95 * 60, folgenS: [], struktur: 'film' })
expect(z.confidence).toBe(1)
expect(z.hinweise).toHaveLength(1)
})
it('Serie: Folgenlänge gegen episode_run_time', () => {
const serie: Zuordnung = { titel: 'Spartacus', jahr: 2010, confidence: 0.6, meta: ausDetails('tv', 46296, SPARTACUS_TV) }
const z = nachpruefen(serie, { hauptS: 0, folgenS: [3167, 3249], struktur: 'unklar' })
expect(z.confidence).toBe(0.8)
expect(z.hinweise?.[0]).toContain('Folgenlänge passt')
})
it('Struktur sagt Serie, Treffer sagt Film: Vorschlag mit Hinweis', () => {
const z = nachpruefen({ ...akira, meta: { ...akira.meta, laufzeitMinuten: 0 } }, { hauptS: 0, folgenS: [], struktur: 'serie' })
expect(z.confidence).toBe(VORSCHLAG)
expect(z.hinweise?.[0]).toContain('Serienstaffel')
})
it('ohne Laufzeit bei TMDb bleibt alles, wie es war', () => {
const ohne = { ...akira, meta: { ...LEERER_TREFFER, typ: 'movie' as const } }
const z = nachpruefen(ohne, { hauptS: 7000, folgenS: [], struktur: 'unklar' })
expect(z.confidence).toBe(0.8)
expect(z.hinweise).toEqual([])
})
})
describe('strukturHinweis — der Fingerabdruck der Titel-Struktur', () => {
const t = (nr: number, min: number) => ({ nr, dauerS: Math.round(min * 60), groesseBytes: 1e9, kapitel: 8 })
it('ein langer + viele kurze = Film', () => {
expect(strukturHinweis([t(0, 124), t(1, 3), t(2, 1.5), t(3, 20)])).toBe('film')
})
it('sechs gleich lange = Serie', () => {
expect(strukturHinweis([t(0, 44), t(1, 43), t(2, 45), t(3, 44), t(4, 42), t(5, 44), t(6, 2)])).toBe('serie')
})
it('zwei gleich lange Fassungen bleiben unklar (Seamless Branching)', () => {
expect(strukturHinweis([t(0, 120), t(1, 118)])).toBe('unklar')
})
it('zwei Folgen bleiben unklar — dafür ist das bdmt-Inhaltsverzeichnis da', () => {
expect(strukturHinweis([t(0, 52), t(1, 54), t(2, 1)])).toBe('unklar')
})
it('nichts Langes: unklar', () => {
expect(strukturHinweis([t(0, 1), t(1, 0.5)])).toBe('unklar')
expect(strukturHinweis([])).toBe('unklar')
})
})
describe('bdmt_deu als zweite Titel-Variante', () => {
it('bdmtZerlegen liest Titel und Inhaltsverzeichnis (pur)', () => {
const z = bdmtZerlegen('<x><di:name>Titel</di:name><di:titleName titleNumber="1">EP 1</di:titleName></x>')
expect(z).toEqual({ titel: 'Titel', eintraege: ['EP 1'] })
})
it('englisch bleibt der Haupttitel, deutsch wird Alternative — Doppel fallen weg', () => {
const wurzel = mkdtempSync(join(tmpdir(), 'rippy-bdmt2-'))
wegwerf.push(wurzel)
const meta = join(wurzel, 'BDMV', 'META', 'DL')
mkdirSync(meta, { recursive: true })
writeFileSync(join(meta, 'bdmt_deu.xml'), '<x><di:name>Spartacus: Götter der Arena</di:name></x>')
writeFileSync(join(meta, 'bdmt_eng.xml'), '<x><di:name>Spartacus: Gods of the Arena</di:name><di:titleName titleNumber="1">EP 1</di:titleName><di:titleName titleNumber="2">EP 2</di:titleName></x>')
writeFileSync(join(meta, 'bdmt_fra.xml'), '<x><di:name>Spartacus: Gods of the Arena</di:name></x>')
const inhalt = inhaltAusBdmt(wurzel)
expect(inhalt.titel).toBe('Spartacus: Gods of the Arena')
expect(inhalt.eintraege).toEqual(['EP 1', 'EP 2'])
expect(inhalt.alternativen).toEqual(['Spartacus: Götter der Arena'])
})
it('nur bdmt_deu: dann ist der deutsche Titel der Haupttitel', () => {
const wurzel = mkdtempSync(join(tmpdir(), 'rippy-bdmt3-'))
wegwerf.push(wurzel)
const meta = join(wurzel, 'BDMV', 'META', 'DL')
mkdirSync(meta, { recursive: true })
writeFileSync(join(meta, 'bdmt_deu.xml'), '<x><di:name>Nur Deutsch</di:name></x>')
expect(inhaltAusBdmt(wurzel)).toEqual({ titel: 'Nur Deutsch', eintraege: [], alternativen: [] })
})
})
+4 -2
View File
@@ -109,7 +109,9 @@ function fakeTmdb(filme: Record<string, FilmEintrag[]>, serien: Record<string, F
tvDetails: async (id: number) => {
for (const liste of Object.values(serien)) {
const treffer = liste.find((f) => f.id === id)
if (treffer !== undefined) return { id, name: treffer.title, first_air_date: '1993-09-10' }
// Eine ECHTE Serie hat eine Beschreibung — ohne sie wäre es ein
// leerer Eintrag (istStub, 5.4.0), und der zählt nicht als Treffer.
if (treffer !== undefined) return { id, name: treffer.title, first_air_date: '1993-09-10', overview: 'Ü', ...treffer.details }
}
return null
},
@@ -292,7 +294,7 @@ describe('inhaltAusBdmt — die Disc sagt selbst, was sie ist', () => {
it('ohne bdmt-Datei bleibt alles leer, ohne Absturz', () => {
const wurzel = mkdtempSync(join(tmpdir(), 'rippy-toc-'))
wegwerf.push(wurzel)
expect(inhaltAusBdmt(wurzel)).toEqual({ titel: '', eintraege: [] })
expect(inhaltAusBdmt(wurzel)).toEqual({ titel: '', eintraege: [], alternativen: [] })
})
})
+44
View File
@@ -71,3 +71,47 @@ describe('istHauptNachricht', () => {
expect(istHauptNachricht(undefined)).toBe(false)
})
})
// ── 5.4.0: Auftrag mit Sprachen und Optionen, neue Nachrichten ──────────
describe('istFensterNachricht — rip-start mit Sprachen und Optionen (5.4.0)', () => {
it('nimmt Sprachlisten und Staffel/Folge an', () => {
expect(
istFensterNachricht({
art: 'rip-start',
id: 'G',
auftrag: [{ nr: 0, rolle: 'folge', dauerS: 3167, groesseBytes: 16e9, audioSprachen: ['jpn'], untertitelSprachen: ['deu'] }],
optionen: { staffel: 1, ersteFolge: 3 },
}),
).toBe(true)
expect(istFensterNachricht({ art: 'rip-start', id: 'G', optionen: { staffel: null, ersteFolge: null } })).toBe(true)
})
it('lehnt kaputte Sprachlisten, Rollen und Optionen ab', () => {
expect(istFensterNachricht({ art: 'rip-start', id: 'G', auftrag: [{ nr: 0, rolle: 'folge', dauerS: 1, audioSprachen: 'jpn' }] })).toBe(false)
expect(istFensterNachricht({ art: 'rip-start', id: 'G', auftrag: [{ nr: 0, rolle: 'egal', dauerS: 1 }] })).toBe(false)
expect(istFensterNachricht({ art: 'rip-start', id: 'G', optionen: { staffel: 'eins' } })).toBe(false)
})
it('kennt die neuen Nachrichten', () => {
expect(istFensterNachricht({ art: 'vorgaenge-laden' })).toBe(true)
expect(istFensterNachricht({ art: 'vorgang-roh-loeschen', vorgangId: 'x' })).toBe(true)
expect(istFensterNachricht({ art: 'vorgang-roh-loeschen', vorgangId: '' })).toBe(false)
expect(istFensterNachricht({ art: 'vorgang-neu-komprimieren', vorgangId: 'x', preset: 'Fast' })).toBe(true)
expect(istFensterNachricht({ art: 'vorgang-neu-komprimieren', vorgangId: 'x', preset: 5 })).toBe(false)
expect(istFensterNachricht({ art: 'kompression-abbrechen', vorgangId: 'x' })).toBe(true)
expect(istFensterNachricht({ art: 'roh-aufraeumen' })).toBe(true)
expect(istFensterNachricht({ art: 'fremden-ordner-loeschen', ordner: 'E:\Rippy\roh\alt' })).toBe(true)
expect(istFensterNachricht({ art: 'laufwerk-pruefen', id: 'G' })).toBe(true)
})
})
describe('istKernNachricht — Vorgänge, Kompression, Gesundheit (5.4.0)', () => {
it('vorgaenge braucht Liste, Belegung und Warteschlange', () => {
expect(istKernNachricht({ art: 'vorgaenge', stand: { vorgaenge: [], rohGesamtBytes: 0, fremde: [], regel: '', kompression: { laeuft: null, wartend: [] } } })).toBe(true)
expect(istKernNachricht({ art: 'vorgaenge', stand: { vorgaenge: [] } })).toBe(false)
})
it('kompression-ergebnis und laufwerk-gesundheit', () => {
expect(istKernNachricht({ art: 'kompression-ergebnis', vorgangId: 'x', titel: 'T', ok: true, text: 'fertig' })).toBe(true)
expect(istKernNachricht({ art: 'kompression-ergebnis', vorgangId: 'x', ok: true })).toBe(false)
expect(istKernNachricht({ art: 'laufwerk-gesundheit', id: 'G', bericht: { urteil: 'disc', text: 't', fehlerhafteBloecke: 1, controllerfehler: 0, sonstige: 0, letzte: [], stunden: 24 } })).toBe(true)
expect(istKernNachricht({ art: 'kern-hinweis', text: 'Roh-Ordner gelöscht' })).toBe(true)
})
})
+208 -117
View File
@@ -1,170 +1,261 @@
// Tests der Verkettung Rip → Kompression → Ablage — mit nachgebildeten
// Werkzeug-Läufen. Die Regeln: Kompression nur wenn gewollt UND HandBrake
// da; ein Kompressions-Fehler lässt den verlustfreien Rip STEHEN und sagt
// wo er liegt; Statusmeldungen tragen die Phase.
import { mkdtempSync, rmSync } from 'node:fs'
// Tests der Rip-Phase (5.4.0): Die Pipeline führt den RIP und übergibt
// danach einen Kompressions-Auftrag an die Warteschlange — das Laufwerk
// ist frei, sobald die Roh-Dateien liegen. Die Kompression selbst prüft
// test/ablegen.test.ts.
//
// makemkvcon ist nachgebildet und SCHREIBT Dateien in den Roh-Ordner —
// nur so lässt sich prüfen, dass halbe Dateien nach Abbruch und Fehler
// wirklich verschwinden.
import { existsSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { RipStatus } from '../src/gemeinsam/nachrichten'
import type { RipAuftragTitel, RipStatus } from '../src/gemeinsam/nachrichten'
import type { KompressionsAuftrag } from '../src/kern/ablauf/auftrag'
const ripErgebnis = vi.hoisted(() => ({
wert: { status: 'success', dateien: ['R:\\roh\\a.mkv'], lesefehler: false } as unknown,
}))
const encodeErgebnis = vi.hoisted(() => ({
wert: { status: 'success', ausgabe: 'R:\\fertig\\a.mkv' } as unknown,
}))
interface Schritt {
status: 'success' | 'error' | 'cancelled'
/** Diese Datei entsteht im Roh-Ordner (auch bei Abbruch/Fehler: die halbe). */
datei?: string
bytes?: number
lesefehler?: boolean
fehler?: string
/** Fortschritts-Ticks, die der Lauf meldet. */
ticks?: number[]
}
vi.mock('../src/kern/rip/makemkv', () => ({
rippen: vi.fn(async () => ripErgebnis.wert),
}))
vi.mock('../src/kern/komprimieren/handbrake', () => ({
komprimieren: vi.fn(async () => encodeErgebnis.wert),
}))
const plan = vi.hoisted(() => ({ schritte: [] as Schritt[] }))
import { Pipeline, type PipelineUmgebung } from '../src/kern/ablauf/pipeline'
vi.mock('../src/kern/rip/makemkv', async () => {
const fs = await import('node:fs')
const pfad = await import('node:path')
return {
rippen: vi.fn(
async (o: {
zielOrdner: string
aufFortschritt?: (p: number) => void
}) => {
const schritt = plan.schritte.shift() ?? { status: 'success' as const, datei: 'title_t00.mkv' }
for (const p of schritt.ticks ?? []) o.aufFortschritt?.(p)
if (schritt.datei !== undefined) {
fs.writeFileSync(pfad.join(o.zielOrdner, schritt.datei), 'x'.repeat(schritt.bytes ?? 10))
}
if (schritt.status === 'cancelled') return { status: 'cancelled' }
if (schritt.status === 'error') {
return { status: 'error', fehler: schritt.fehler ?? 'MakeMKV endete mit Code 1', lesefehler: schritt.lesefehler ?? false }
}
const dateien = fs
.readdirSync(o.zielOrdner)
.filter((n: string) => n.endsWith('.mkv'))
.sort()
.map((n: string) => pfad.join(o.zielOrdner, n))
return { status: 'success', dateien, lesefehler: schritt.lesefehler ?? false }
},
),
}
})
import { Pipeline, halbeDateienEntfernen, type PipelineUmgebung } from '../src/kern/ablauf/pipeline'
let wegwerf: string[] = []
afterEach(() => {
plan.schritte = []
for (const ordner of wegwerf.splice(0)) rmSync(ordner, { recursive: true, force: true })
})
const GERAET = {
id: 'G',
name: 'Laufwerk G:',
typ: 'bluray' as const,
pfad: '\\\\.\\G:',
status: 'ready' as const,
modell: '',
seriennummer: '',
groesseBytes: 33e9,
grund: '',
}
function umgebung(überschreiben: Partial<PipelineUmgebung> = {}): {
pipeline: Pipeline
meldungen: RipStatus[]
ablage: string
eingereiht: KompressionsAuftrag[]
endet: Array<{ id: string; phase: string; text: string }>
ausgeworfen: string[]
} {
const ablage = mkdtempSync(join(tmpdir(), 'rippy-pipe-'))
wegwerf.push(ablage)
const meldungen: RipStatus[] = []
const eingereiht: KompressionsAuftrag[] = []
const endet: Array<{ id: string; phase: string; text: string }> = []
const ausgeworfen: string[] = []
const pipeline = new Pipeline({
makemkvcon: () => 'C:\\mk\\makemkvcon64.exe',
handbrake: () => 'C:\\hb\\HandBrakeCLI.exe',
flac: () => '',
geraet: () => ({
id: 'G',
name: 'Laufwerk G:',
typ: 'bluray',
pfad: '\\\\.\\G:',
status: 'ready',
modell: '',
seriennummer: '',
groesseBytes: 33e9,
grund: '',
}),
geraet: () => GERAET,
ablage: () => ablage,
einstellungen: () => ({}),
melden: (s) => meldungen.push(s),
jetzt: () => new Date('2026-08-30T12:00:00Z'),
vorgangEinreihen: (auftrag) => {
eingereiht.push(auftrag)
return eingereiht.length
},
vorgangEndet: (id, phase, text) => endet.push({ id, phase, text }),
nachRipAuswerfen: (id) => ausgeworfen.push(id),
...überschreiben,
})
return { pipeline, meldungen }
return { pipeline, meldungen, ablage, eingereiht, endet, ausgeworfen }
}
describe('Pipeline', () => {
it('kettet Rip → Kompression → Ablage und meldet die Phasen', async () => {
ripErgebnis.wert = { status: 'success', dateien: ['R:\\roh\\a.mkv'], lesefehler: false }
encodeErgebnis.wert = { status: 'success', ausgabe: 'R:\\fertig\\a.mkv' }
const AUFTRAG: RipAuftragTitel[] = [
{ nr: 0, rolle: 'folge', dauerS: 3167, groesseBytes: 3e9, audioSprachen: ['jpn'], untertitelSprachen: ['deu'] },
{ nr: 1, rolle: 'folge', dauerS: 3249, groesseBytes: 1e9 },
]
describe('Pipeline — die Rip-Phase (5.4.0)', () => {
it('rippt Titel für Titel, übergibt den Auftrag und gibt das Laufwerk frei', async () => {
plan.schritte = [
{ status: 'success', datei: 'title_t00.mkv', bytes: 300 },
{ status: 'success', datei: 'title_t01.mkv', bytes: 100 },
]
const { pipeline, meldungen, ablage, eingereiht, ausgeworfen } = umgebung()
const beginn: string[] = []
;(pipeline as unknown as { umgebung: PipelineUmgebung }).umgebung.vorgangBeginnt = (v) => beginn.push(v.rohOrdner)
await pipeline.starten('G', AUFTRAG, { staffel: 2, ersteFolge: 3 })
expect(meldungen[0].phase).toBe('rippt')
const letzte = meldungen.at(-1)!
expect(letzte.phase).toBe('fertig')
expect(letzte.text).toContain('eingereiht')
expect(letzte.text).toContain('Laufwerk ist frei')
expect(letzte.vorgangId).toBe('bluray-2026-08-30T12-00-00')
expect(pipeline.laeuft('G')).toBe(false)
expect(ausgeworfen).toEqual(['G'])
expect(beginn).toEqual([join(ablage, 'roh', 'bluray-2026-08-30T12-00-00')])
expect(eingereiht).toHaveLength(1)
const auftrag = eingereiht[0]
expect(auftrag.vorgangId).toBe('bluray-2026-08-30T12-00-00')
expect(auftrag.dateien.map((d) => d.rolle)).toEqual(['folge', 'folge'])
expect(auftrag.dateien[0].audioSprachen).toEqual(['jpn'])
expect(auftrag.dateien[0].untertitelSprachen).toEqual(['deu'])
expect(auftrag.dateien[1].audioSprachen).toEqual([])
expect(auftrag.dateien.map((d) => d.groesseBytes)).toEqual([300, 100])
expect(auftrag.staffel).toBe(2)
expect(auftrag.ersteFolge).toBe(3)
expect(auftrag.preset).toBe('')
})
it('der Fortschritt läuft durch — nach Dateigröße gewichtet, ohne Rücksprung', async () => {
plan.schritte = [
{ status: 'success', datei: 'title_t00.mkv', ticks: [50, 100] },
{ status: 'success', datei: 'title_t01.mkv', ticks: [50] },
]
const { pipeline, meldungen } = umgebung()
await pipeline.starten('G')
const phasen = meldungen.map((m) => m.phase)
expect(phasen[0]).toBe('rippt')
expect(phasen).toContain('komprimiert')
expect(phasen).toContain('legt-ab')
expect(phasen.at(-1)).toBe('fertig')
expect(meldungen.at(-1)?.dateien).toEqual(['R:\\fertig\\a.mkv'])
await pipeline.starten('G', AUFTRAG)
const prozente = meldungen.filter((m) => m.phase === 'rippt').map((m) => m.prozent)
// Titel 0 wiegt 3 GB, Titel 1 wiegt 1 GB: 50 % vom ersten = 37 %,
// erster fertig = 75 %, 50 % vom zweiten = 87 %. Nie zurück auf 0.
expect(prozente).toContain(37)
expect(prozente).toContain(75)
expect(prozente).toContain(87)
for (let i = 1; i < prozente.length; i++) expect(prozente[i]).toBeGreaterThanOrEqual(prozente[i - 1])
})
it('Kompression abgewählt: Roh-Rip ist das Ergebnis, mit Begründung', async () => {
ripErgebnis.wert = { status: 'success', dateien: ['R:\\roh\\a.mkv'], lesefehler: false }
const { pipeline, meldungen } = umgebung({
einstellungen: () => ({ transcodePresetBluray: 'keine' }),
})
await pipeline.starten('G')
const letzte = meldungen.at(-1)
expect(letzte?.phase).toBe('fertig')
expect(letzte?.text).toContain('abgewählt')
expect(letzte?.dateien).toEqual(['R:\\roh\\a.mkv'])
it('Abbruch: die halbe Datei verschwindet, der leere Roh-Ordner auch', async () => {
plan.schritte = [{ status: 'cancelled', datei: 'title_t00.mkv' }]
const { pipeline, meldungen, ablage, eingereiht, endet } = umgebung()
await pipeline.starten('G', AUFTRAG)
const letzte = meldungen.at(-1)!
expect(letzte.phase).toBe('abgebrochen')
expect(letzte.text).toContain('Halbe Datei entfernt: title_t00.mkv')
expect(existsSync(join(ablage, 'roh', 'bluray-2026-08-30T12-00-00'))).toBe(false)
expect(eingereiht).toHaveLength(0)
expect(endet).toEqual([{ id: 'bluray-2026-08-30T12-00-00', phase: 'abgebrochen', text: letzte.text }])
})
it('ohne HandBrake bleibt es beim Roh-Rip — mit klarem Satz', async () => {
ripErgebnis.wert = { status: 'success', dateien: ['R:\\roh\\a.mkv'], lesefehler: false }
const { pipeline, meldungen } = umgebung({ handbrake: () => '' })
await pipeline.starten('G')
expect(meldungen.at(-1)?.text).toContain('HandBrakeCLI nicht gefunden')
it('Abbruch nach einem fertigen Titel: der fertige bleibt, die halbe geht', async () => {
plan.schritte = [
{ status: 'success', datei: 'title_t00.mkv' },
{ status: 'cancelled', datei: 'title_t01.mkv' },
]
const { pipeline, meldungen, ablage } = umgebung()
await pipeline.starten('G', AUFTRAG)
expect(meldungen.at(-1)!.phase).toBe('abgebrochen')
const roh = join(ablage, 'roh', 'bluray-2026-08-30T12-00-00')
expect(readdirSync(roh)).toEqual(['title_t00.mkv'])
expect(meldungen.at(-1)!.text).toContain('1 fertige Datei(en) bleiben')
})
it('Kompressions-Fehler: Roh bleibt stehen und wird GENANNT', async () => {
ripErgebnis.wert = { status: 'success', dateien: ['R:\\roh\\a.mkv'], lesefehler: false }
encodeErgebnis.wert = { status: 'error', fehler: 'HandBrake endete mit Code 3' }
const { pipeline, meldungen } = umgebung()
await pipeline.starten('G')
const letzte = meldungen.at(-1)
expect(letzte?.phase).toBe('fehler')
expect(letzte?.text).toContain('bleibt stehen')
expect(letzte?.dateien).toEqual(['R:\\roh\\a.mkv'])
it('EIN kaputter Titel beendet den Auftrag nicht — seine halbe Datei wird entfernt', async () => {
plan.schritte = [
{ status: 'error', datei: 'title_t00.mkv', fehler: 'Lesefehler an Byte 56881152' },
{ status: 'success', datei: 'title_t01.mkv' },
]
const { pipeline, meldungen, eingereiht } = umgebung()
await pipeline.starten('G', AUFTRAG)
const letzte = meldungen.at(-1)!
expect(letzte.phase).toBe('fertig')
expect(eingereiht[0].dateien.map((d) => d.pfad.split(/[\\/]/).pop())).toEqual(['title_t01.mkv'])
expect(eingereiht[0].hinweise.join(' ')).toContain('Nicht gesichert')
expect(eingereiht[0].hinweise.join(' ')).toContain('Halbe Dateien entfernt: title_t00.mkv')
})
it('Lesefehler überleben bis in die Fertig-Meldung (Rückgabewert-0-Falle)', async () => {
ripErgebnis.wert = { status: 'success', dateien: ['R:\\roh\\a.mkv'], lesefehler: true }
encodeErgebnis.wert = { status: 'success', ausgabe: 'R:\\fertig\\a.mkv' }
const { pipeline, meldungen } = umgebung()
it('kein Titel gesichert: Fehler, kein Auftrag, Roh-Ordner weg', async () => {
plan.schritte = [{ status: 'error', fehler: 'kaputt' }, { status: 'error', fehler: 'auch kaputt' }]
const { pipeline, meldungen, ablage, eingereiht, endet } = umgebung()
await pipeline.starten('G', AUFTRAG)
expect(meldungen.at(-1)!.phase).toBe('fehler')
expect(meldungen.at(-1)!.text).toContain('kaputt')
expect(eingereiht).toHaveLength(0)
expect(endet[0].phase).toBe('fehler')
expect(existsSync(join(ablage, 'roh', 'bluray-2026-08-30T12-00-00'))).toBe(false)
})
it('Lesefehler reisen bis in den Auftrag und die Fertig-Meldung', async () => {
plan.schritte = [{ status: 'success', datei: 'title_t00.mkv', lesefehler: true }]
const { pipeline, meldungen, eingereiht } = umgebung()
await pipeline.starten('G', [AUFTRAG[0]])
expect(meldungen.at(-1)!.lesefehler).toBe(true)
expect(meldungen.at(-1)!.text).toContain('nicht lesen')
expect(eingereiht[0].lesefehler).toBe(true)
})
it('ohne Auftrag (alles rippen): eine Datei, Rolle Hauptfilm, Staffel von der Disc', async () => {
plan.schritte = [{ status: 'success', datei: 'title_t00.mkv' }]
const { pipeline, eingereiht } = umgebung({ discAngaben: () => ({ staffel: 3, discNr: 2 }) })
await pipeline.starten('G')
const letzte = meldungen.at(-1)
expect(letzte?.lesefehler).toBe(true)
expect(letzte?.text).toContain('nicht lesen')
expect(eingereiht[0].dateien).toHaveLength(1)
expect(eingereiht[0].dateien[0].rolle).toBe('hauptfilm')
expect(eingereiht[0].staffel).toBe(3)
expect(eingereiht[0].discNr).toBe(2)
})
it('ohne Empfänger für den Auftrag sagt die Meldung, wo die Roh-Dateien liegen', async () => {
plan.schritte = [{ status: 'success', datei: 'title_t00.mkv' }]
const { pipeline, meldungen } = umgebung({ vorgangEinreihen: undefined })
await pipeline.starten('G')
expect(meldungen.at(-1)!.text).toContain('nicht eingereiht')
})
it('leeres Laufwerk: ein Satz statt makemkvcon (aber unknown rippt!)', async () => {
const { pipeline, meldungen } = umgebung({
geraet: () => ({
id: 'G',
name: 'Laufwerk G:',
typ: 'unknown',
pfad: '\\\\.\\G:',
status: 'empty',
modell: '',
seriennummer: '',
groesseBytes: 0,
grund: '',
}),
geraet: () => ({ ...GERAET, typ: 'unknown', status: 'empty', groesseBytes: 0 }),
})
await pipeline.starten('G')
expect(meldungen[0].phase).toBe('fehler')
expect(meldungen[0].text).toContain('Kein Datenträger')
})
it('NFO landet im fertigen Ordner', async () => {
ripErgebnis.wert = { status: 'success', dateien: ['R:\\roh\\a.mkv'], lesefehler: false }
encodeErgebnis.wert = { status: 'success', ausgabe: 'R:\\fertig\\a.mkv' }
const ablage = mkdtempSync(join(tmpdir(), 'rippy-pipe-'))
wegwerf.push(ablage)
const meldungen: RipStatus[] = []
const pipeline = new Pipeline({
makemkvcon: () => 'x',
handbrake: () => 'y',
flac: () => '',
geraet: () => ({
id: 'G',
name: 'Laufwerk G:',
typ: 'dvd',
pfad: '\\\\.\\G:',
status: 'ready',
modell: '',
seriennummer: '',
groesseBytes: 4e9,
grund: '',
}),
ablage: () => ablage,
einstellungen: () => ({}),
melden: (s) => meldungen.push(s),
jetzt: () => new Date('2026-08-30T12:00:00Z'),
})
await pipeline.starten('G')
expect(meldungen.at(-1)?.phase).toBe('fertig')
const { existsSync, readdirSync } = await import('node:fs')
const filme = join(ablage, 'Filme')
const ordner = readdirSync(filme)
expect(ordner).toHaveLength(1)
expect(existsSync(join(filme, ordner[0], 'movie.nfo'))).toBe(true)
it('halbeDateienEntfernen: nur, was NEU dazukam', () => {
const ordner = mkdtempSync(join(tmpdir(), 'rippy-halb-'))
wegwerf.push(ordner)
writeFileSync(join(ordner, 'alt.mkv'), 'a')
const vorher = new Set(readdirSync(ordner))
writeFileSync(join(ordner, 'neu.mkv'), 'b')
expect(halbeDateienEntfernen(ordner, vorher)).toEqual(['neu.mkv'])
expect(readdirSync(ordner)).toEqual(['alt.mkv'])
})
})
+148
View File
@@ -0,0 +1,148 @@
// Tests des Roh-Aufräumens (kern/ablage/rohaufraeumen.ts, 5.4.0): die
// Regel, die Frist, die drei Sicherungen — und dass fremde Ordner nur
// gezeigt, nie von selbst gelöscht werden.
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
aufraeumen,
faelligAm,
fremdeOrdner,
istUnterRohWurzel,
loeschHindernis,
ordnerGroesse,
regelLesen,
regelText,
type AufraeumKandidat,
type AufraeumZugriff,
} from '../src/kern/ablage/rohaufraeumen'
let wegwerf: string[] = []
afterEach(() => {
for (const o of wegwerf.splice(0)) rmSync(o, { recursive: true, force: true })
})
describe('regelLesen', () => {
it('kennt behalten, sofort und Tage — und die Vorgabe 7', () => {
expect(regelLesen('behalten')).toEqual({ art: 'behalten' })
expect(regelLesen('sofort')).toEqual({ art: 'sofort' })
expect(regelLesen('30')).toEqual({ art: 'tage', tage: 30 })
expect(regelLesen(null)).toEqual({ art: 'tage', tage: 7 })
expect(regelLesen('')).toEqual({ art: 'tage', tage: 7 })
expect(regelLesen('quatsch')).toEqual({ art: 'tage', tage: 7 })
expect(regelLesen('-3')).toEqual({ art: 'tage', tage: 7 })
expect(regelText(regelLesen('7'))).toContain('7 Tage')
})
})
describe('faelligAm', () => {
it('Tage nach dem Ende; sofort = am Ende; behalten = nie', () => {
expect(faelligAm('2026-09-01T20:00:00.000Z', { art: 'tage', tage: 7 })).toBe('2026-09-08T20:00:00.000Z')
expect(faelligAm('2026-09-01T20:00:00.000Z', { art: 'sofort' })).toBe('2026-09-01T20:00:00.000Z')
expect(faelligAm('2026-09-01T20:00:00.000Z', { art: 'behalten' })).toBe('')
expect(faelligAm(null, { art: 'sofort' })).toBe('')
expect(faelligAm('kaputt', { art: 'sofort' })).toBe('')
})
})
const fertigeDateien = ['E:\\Rippy\\Filme\\Akira (1988)\\title_t00.mkv']
const kandidat = (teil: Partial<AufraeumKandidat> = {}): AufraeumKandidat => ({
id: 'bluray-1',
phase: 'fertig',
komprimiert: true,
rohOrdner: 'E:\\Rippy\\roh\\bluray-1',
rohGeloeschtAm: null,
beendet: '2026-09-01T20:00:00.000Z',
fertigeDateien,
...teil,
})
const zugriff = (überschreiben: Partial<AufraeumZugriff> = {}): AufraeumZugriff & { geloescht: string[] } => {
const geloescht: string[] = []
return {
existiert: () => true,
groesse: () => 1000,
ordnerLoeschen: (p) => {
geloescht.push(p)
},
geloescht,
...überschreiben,
}
}
const regel7 = { art: 'tage' as const, tage: 7 }
const spaeter = new Date('2026-09-10T00:00:00Z')
const frueher = new Date('2026-09-03T00:00:00Z')
describe('loeschHindernis — die drei Sicherungen', () => {
it('fertig, komprimiert, Ergebnis da, Frist um: löschbar', () => {
expect(loeschHindernis(kandidat(), regel7, spaeter, zugriff())).toBe('')
})
it('vor der Frist: nicht löschbar, mit Datum', () => {
expect(loeschHindernis(kandidat(), regel7, frueher, zugriff())).toContain('Frist läuft bis 2026-09-08')
})
it('nicht fertig / nicht komprimiert / schon gelöscht / Regel behalten', () => {
expect(loeschHindernis(kandidat({ phase: 'fehler' }), regel7, spaeter, zugriff())).toContain('nicht fertig')
expect(loeschHindernis(kandidat({ komprimiert: false }), regel7, spaeter, zugriff())).toContain('nicht komprimiert')
expect(loeschHindernis(kandidat({ rohGeloeschtAm: 'x' }), regel7, spaeter, zugriff())).toBe('schon gelöscht')
expect(loeschHindernis(kandidat(), { art: 'behalten' }, spaeter, zugriff())).toBe('Regel: behalten')
})
it('die fertige Datei fehlt oder ist leer: NIE löschen', () => {
expect(loeschHindernis(kandidat(), regel7, spaeter, zugriff({ existiert: (p) => p.endsWith('bluray-1') }))).toContain('fertige Datei fehlt')
expect(loeschHindernis(kandidat(), regel7, spaeter, zugriff({ groesse: () => 0 }))).toContain('ist leer')
expect(loeschHindernis(kandidat({ fertigeDateien: [] }), regel7, spaeter, zugriff())).toContain('keine fertige Datei')
})
it('sofort: gleich nach dem Ende löschbar', () => {
expect(loeschHindernis(kandidat(), { art: 'sofort' }, new Date('2026-09-01T20:00:01Z'), zugriff())).toBe('')
})
})
describe('aufraeumen', () => {
it('löscht nur die Fälligen und sagt, warum der Rest blieb', () => {
const z = zugriff()
const ergebnis = aufraeumen(
[kandidat(), kandidat({ id: 'jung', beendet: '2026-09-09T00:00:00.000Z' }), kandidat({ id: 'roh', komprimiert: false })],
regel7,
spaeter,
z,
)
expect(ergebnis.geloescht).toEqual(['bluray-1'])
expect(z.geloescht).toEqual(['E:\\Rippy\\roh\\bluray-1'])
expect(ergebnis.uebersprungen.map((u) => u.id)).toEqual(['jung', 'roh'])
expect(ergebnis.fehler).toEqual([])
})
it('ein Ordner, der sich nicht löschen lässt, ist ein lauter Fehler, kein stilles Nichts', () => {
const ergebnis = aufraeumen([kandidat()], regel7, spaeter, zugriff({ ordnerLoeschen: () => { throw new Error('EBUSY') } }))
expect(ergebnis.geloescht).toEqual([])
expect(ergebnis.fehler[0].grund).toContain('EBUSY')
})
})
describe('Belegung und fremde Ordner — am echten Dateisystem', () => {
it('ordnerGroesse zählt rekursiv; fremdeOrdner kennt die Vorgänge und lässt sie aus', () => {
const wurzel = mkdtempSync(join(tmpdir(), 'rippy-roh-'))
wegwerf.push(wurzel)
const roh = join(wurzel, 'roh')
mkdirSync(join(roh, 'bluray-1', 'tief'), { recursive: true })
mkdirSync(join(roh, 'alt-vor-540'), { recursive: true })
writeFileSync(join(roh, 'bluray-1', 'a.mkv'), 'x'.repeat(100))
writeFileSync(join(roh, 'bluray-1', 'tief', 'b.mkv'), 'x'.repeat(50))
writeFileSync(join(roh, 'alt-vor-540', 'c.mkv'), 'x'.repeat(7))
writeFileSync(join(roh, 'lose.txt'), 'x')
expect(ordnerGroesse(join(roh, 'bluray-1'))).toBe(150)
expect(ordnerGroesse(join(roh, 'gibt-es-nicht'))).toBe(0)
const fremde = fremdeOrdner(roh, new Set([join(roh, 'bluray-1')]))
expect(fremde).toEqual([{ ordner: join(roh, 'alt-vor-540'), bytes: 7 }])
expect(fremdeOrdner(join(wurzel, 'nix'), new Set())).toEqual([])
})
})
describe('istUnterRohWurzel — nur Ordner DIREKT unter roh\\', () => {
it('erlaubt genau eine Ebene', () => {
expect(istUnterRohWurzel('E:\\Rippy\\roh', 'E:\\Rippy\\roh\\bluray-1')).toBe(true)
expect(istUnterRohWurzel('E:\\Rippy\\roh\\', 'e:\\rippy\\roh\\bluray-1\\')).toBe(true)
expect(istUnterRohWurzel('E:\\Rippy\\roh', 'E:\\Rippy\\roh')).toBe(false)
expect(istUnterRohWurzel('E:\\Rippy\\roh', 'E:\\Rippy\\roh\\a\\b')).toBe(false)
expect(istUnterRohWurzel('E:\\Rippy\\roh', 'E:\\Rippy\\Filme\\Akira')).toBe(false)
expect(istUnterRohWurzel('E:\\Rippy\\roh', 'E:\\Rippy\\rohling\\x')).toBe(false)
})
})
+62
View File
@@ -0,0 +1,62 @@
// Tests des HandBrake-Scans (kern/komprimieren/scan.ts, 5.4.0) — am
// gemessenen Format: „JSON Title Set:" + Objekt + weiterer Text.
import { describe, expect, it } from 'vitest'
import { buildScanArgs, fehlendeSprachen, spurenAusScan } from '../src/kern/komprimieren/scan'
import { regelAusQuery, RIPPY_REGEL } from '../src/kern/werkzeuge/spurauswahl'
const AUSGABE = `[21:27:54] hb_init: starting libhb thread
Scanning title 1 of 1, preview 1, 25.00 %
JSON Title Set: {
"MainFeature": 0,
"TitleList": [
{
"AudioList": [
{ "Description": "\u65e5\u672c\u8a9e (AAC LC, 2.0 ch)", "Language": "\u65e5\u672c\u8a9e", "LanguageCode": "jpn", "TrackNumber": 1 },
{ "Description": "Deutsch (AAC LC, 2.0 ch)", "Language": "Deutsch", "LanguageCode": "deu", "TrackNumber": 2 },
{ "Description": "English (AAC LC, 2.0 ch)", "Language": "English", "LanguageCode": "eng", "TrackNumber": 3 }
],
"Duration": { "Hours": 0, "Minutes": 52, "Seconds": 47, "Ticks": 542070 },
"Index": 1,
"Name": "quelle",
"SubtitleList": [
{ "Format": "text", "Language": "Deutsch (UTF-8)", "LanguageCode": "deu", "SourceName": "UTF-8", "TrackNumber": 1 },
{ "Format": "text", "Language": "English (UTF-8)", "LanguageCode": "eng", "SourceName": "UTF-8", "TrackNumber": 2 }
]
}
]
}
HandBrake has exited.
`
describe('spurenAusScan', () => {
it('schneidet das Objekt aus dem Textprotokoll und liest Spuren + Dauer', () => {
const s = spurenAusScan(AUSGABE)!
expect(s.audio.map((a) => `${a.nr}:${a.lang}`)).toEqual(['1:jpn', '2:deu', '3:eng'])
expect(s.untertitel.map((u) => u.lang)).toEqual(['deu', 'eng'])
expect(s.dauerS).toBe(52 * 60 + 47)
})
it('ohne Marke oder mit kaputtem JSON: null, nie ein leerer Befund', () => {
expect(spurenAusScan('nichts')).toBeNull()
expect(spurenAusScan('JSON Title Set: { "TitleList": [ ')).toBeNull()
expect(spurenAusScan('JSON Title Set: { "TitleList": [] }')).toBeNull()
})
it('buildScanArgs: --scan --json, Titel 1', () => {
expect(buildScanArgs('R:\\roh\\a.mkv')).toEqual(['--scan', '--json', '--input', 'R:\\roh\\a.mkv', '--title', '1'])
})
})
describe('fehlendeSprachen', () => {
it('nennt, was gewünscht ist und nicht drin', () => {
const s = spurenAusScan(AUSGABE)!
expect(fehlendeSprachen(s, { audio: ['jpn', 'fra'], untertitel: ['deu', 'jpn'] })).toEqual({ audio: ['fra'], untertitel: ['jpn'] })
expect(fehlendeSprachen(s, { audio: [], untertitel: [] })).toEqual({ audio: [], untertitel: [] })
})
})
describe('MakeMKV-Auswahlregel — reg query lesen', () => {
it('findet den Wert in der Ausgabe', () => {
const ausgabe = `\r\nHKEY_CURRENT_USER\\Software\\MakeMKV\r\n app_DefaultSelectionString REG_SZ ${RIPPY_REGEL}\r\n\r\n`
expect(regelAusQuery(ausgabe)).toBe(RIPPY_REGEL)
expect(regelAusQuery('FEHLER: Der angegebene Registrierungsschlüssel oder Wert wurde nicht gefunden.')).toBeNull()
})
})
+132
View File
@@ -0,0 +1,132 @@
// Tests der Kompressions-Warteschlange (kern/ablauf/warteschlange.ts,
// 5.4.0): eine zur Zeit, der Reihe nach; abbrechen wartend und laufend;
// eine Ausnahme im Encode beendet nicht die Schlange; kein Doppel.
import { describe, expect, it } from 'vitest'
import type { KompressionsAuftrag } from '../src/kern/ablauf/auftrag'
import type { AblageErgebnis } from '../src/kern/ablauf/ablegen'
import { KompressionsWarteschlange, type WarteschlangenStand } from '../src/kern/ablauf/warteschlange'
function auftrag(id: string): KompressionsAuftrag {
return {
vorgangId: id,
laufwerk: 'G',
discTyp: 'bluray',
rohOrdner: `R:\\roh\\${id}`,
dateien: [],
zuordnung: null,
staffel: null,
ersteFolge: null,
discNr: null,
fingerprint: null,
lesefehler: false,
hinweise: [],
begonnen: '2026-08-30T12:00:00Z',
preset: '',
zielOrdner: '',
}
}
function fertig(text = 'ok'): AblageErgebnis {
return { status: 'fertig', zielOrdner: 'Z', dateien: [], text, komprimiert: true, preset: 'p', fertigBytes: 1, hinweise: [] }
}
/** Ein Encode, den der Test von außen beendet. */
function steuerbar() {
const freigaben: Array<() => void> = []
const gestartet: string[] = []
const ausfuehren = (a: KompressionsAuftrag, melden: (s: { phase: 'komprimiert'; prozent: number; text: string }) => void, abbruch: AbortSignal) =>
new Promise<AblageErgebnis>((loesen) => {
gestartet.push(a.vorgangId)
melden({ phase: 'komprimiert', prozent: 10, text: 'läuft' })
abbruch.addEventListener('abort', () => loesen({ ...fertig('abgebrochen'), status: 'abgebrochen' }), { once: true })
freigaben.push(() => loesen(fertig()))
})
return { ausfuehren, freigaben, gestartet }
}
const tick = (): Promise<void> => new Promise((r) => setTimeout(r, 0))
describe('KompressionsWarteschlange', () => {
it('arbeitet der Reihe nach, meldet Stand und Ergebnis, vergibt Plätze', async () => {
const { ausfuehren, freigaben, gestartet } = steuerbar()
const staende: WarteschlangenStand[] = []
const ergebnisse: string[] = []
const q = new KompressionsWarteschlange(ausfuehren, (s) => staende.push(s), (a, e) => {
ergebnisse.push(`${a.vorgangId}:${e.status}`)
})
expect(q.einreihen(auftrag('a'), 'A')).toBe(1)
expect(q.einreihen(auftrag('b'), 'B')).toBe(2)
expect(q.einreihen(auftrag('c'), 'C')).toBe(3)
await tick()
expect(gestartet).toEqual(['a'])
expect(q.stand().laeuft?.vorgangId).toBe('a')
expect(q.stand().laeuft?.prozent).toBe(10)
expect(q.stand().wartend.map((w) => w.vorgangId)).toEqual(['b', 'c'])
expect(q.laeuftEtwas()).toBe(true)
expect(q.kennt('b')).toBe(true)
freigaben[0]()
await tick()
expect(gestartet).toEqual(['a', 'b'])
expect(ergebnisse).toEqual(['a:fertig'])
freigaben[1]()
await tick()
freigaben[2]()
await tick()
expect(ergebnisse).toEqual(['a:fertig', 'b:fertig', 'c:fertig'])
expect(q.laeuftEtwas()).toBe(false)
expect(q.stand().laeuft).toBeNull()
expect(staende.length).toBeGreaterThan(3)
})
it('kein Doppel: derselbe Vorgang bekommt seinen alten Platz zurück', async () => {
const { ausfuehren } = steuerbar()
const q = new KompressionsWarteschlange(ausfuehren, () => {}, () => {})
q.einreihen(auftrag('a'), 'A')
q.einreihen(auftrag('b'), 'B')
await tick()
expect(q.einreihen(auftrag('a'), 'A')).toBe(1)
expect(q.einreihen(auftrag('b'), 'B')).toBe(2)
expect(q.stand().wartend).toHaveLength(1)
})
it('abbrechen: wartend wird entfernt (mit Ergebnis), laufend bekommt das Signal', async () => {
const { ausfuehren, freigaben } = steuerbar()
const ergebnisse: string[] = []
const q = new KompressionsWarteschlange(ausfuehren, () => {}, (a, e) => {
ergebnisse.push(`${a.vorgangId}:${e.status}`)
})
q.einreihen(auftrag('a'), 'A')
q.einreihen(auftrag('b'), 'B')
await tick()
expect(q.abbrechen('b')).toBe(true)
expect(ergebnisse).toEqual(['b:abgebrochen'])
expect(q.stand().wartend).toHaveLength(0)
expect(q.abbrechen('a')).toBe(true)
await tick()
expect(ergebnisse).toEqual(['b:abgebrochen', 'a:abgebrochen'])
expect(q.abbrechen('gibt-es-nicht')).toBe(false)
expect(freigaben).toHaveLength(1)
})
it('eine Ausnahme im Encode ist ein lautes Fehler-Ergebnis — die Schlange läuft weiter', async () => {
let n = 0
const ergebnisse: string[] = []
const q = new KompressionsWarteschlange(
async () => {
n += 1
if (n === 1) throw new Error('HandBrake explodiert')
return fertig()
},
() => {},
(a, e) => {
ergebnisse.push(`${a.vorgangId}:${e.status}:${e.text.includes('explodiert')}`)
},
)
q.einreihen(auftrag('a'), 'A')
q.einreihen(auftrag('b'), 'B')
await tick()
await tick()
expect(ergebnisse).toEqual(['a:fehler:true', 'b:fertig:false'])
})
})