Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d45760018e | ||
|
|
e7e33e9021 | ||
|
|
fc2d16ca38 | ||
|
|
0fc87aa022 | ||
|
|
6b6e5c3436 | ||
|
|
ae82d8c0fd | ||
|
|
3dacd7cb4f | ||
|
|
c1ee339446 | ||
|
|
783b8e4897 | ||
|
|
551f1d7b2c | ||
|
|
904e4b2ef6 | ||
|
|
8c4456d005 | ||
|
|
12b9720386 | ||
|
|
6f1add480e |
@@ -0,0 +1,402 @@
|
|||||||
|
# Durchsicht Rippy v5 (5.6.1) — Bericht vom 12.09.2026
|
||||||
|
|
||||||
|
> Anlass: Drei Filme (Der Patriot, Déjà Vu, Training Day) lassen sich auf
|
||||||
|
> einem ANDEREN PC mit Rippy nicht bearbeiten, während MakeMKV allein sie
|
||||||
|
> lesen und öffnen kann. Dazu die Bitte um einen kompletten Check von
|
||||||
|
> Kern, Haupt und Fenster.
|
||||||
|
>
|
||||||
|
> Was hier steht, ist GEMESSEN oder im Quelltext mit Zeile belegt — nichts
|
||||||
|
> ist geraten. Wo ich etwas nicht messen konnte, steht das dabei.
|
||||||
|
|
||||||
|
Stand des Repos: Branch `worktree-windows-electron`, Spitze `473eea7`,
|
||||||
|
Arbeitsbaum sauber. `npm run typecheck` grün, `npm test` grün
|
||||||
|
(450 Tests bestanden, 12 übersprungen — die Messungen, die ein Laufwerk
|
||||||
|
brauchen). Gelesen wurden ALLE 92 Quelldateien unter `rippy-windows/src`
|
||||||
|
(16 081 Zeilen), die Tests, die Bau-Konfiguration und die Protokolle des
|
||||||
|
echten Rippy auf diesem PC.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Die drei Filme — was am anderen PC dahintersteckt
|
||||||
|
|
||||||
|
Ich habe die Disc-Ausgaben des anderen PCs nicht; ich habe aber ALLE Wege
|
||||||
|
gefunden, auf denen Rippy scheitert, während MakeMKV allein weiterkommt.
|
||||||
|
Sie stehen in der Reihenfolge, in der ich sie für wahrscheinlich halte —
|
||||||
|
mit der Zeile, an der man sie am anderen PC in einer Minute erkennt.
|
||||||
|
|
||||||
|
**Die Protokoll-Datei liegt dort unter `%APPDATA%\Rippy\logs\rippy-JJJJ-MM-TT.log`**
|
||||||
|
(im Programm: Einstellungen → Allgemein → „Protokoll öffnen"). Die Zeilen
|
||||||
|
des Tages, an dem einer der drei Filme versucht wurde, entscheiden.
|
||||||
|
|
||||||
|
### Kandidat 1 — MakeMKV läuft dort ohne gültigen Schlüssel (Beta-Key-Fehler in Rippy)
|
||||||
|
|
||||||
|
Der frei veröffentlichte Beta-Key vom September 2026 enthält ein `@`:
|
||||||
|
`T-vwvn…6Ox4BI@N5rPp` (68 Zeichen, heute von der Herstellerseite geladen,
|
||||||
|
„valid until end of September 2026"). Rippys Muster
|
||||||
|
`KEY_MUSTER = /T-[A-Za-z0-9_]{50,}/` (`kern/werkzeuge/schluessel.ts:19`)
|
||||||
|
kennt kein `@` und schneidet den Schlüssel auf 62 Zeichen ab (Sonde 9,
|
||||||
|
gemessen an der echten Seite). Den verstümmelten Schlüssel lehnt MakeMKV
|
||||||
|
ab (5020), Rippy nimmt ihn „damit MakeMKV im Beta-Modus weiterläuft" wieder
|
||||||
|
zurück (`schluessel.ts:214-219`) — und MakeMKV läuft ohne Schlüssel.
|
||||||
|
Für DVDs ist das egal (laut Hersteller dauerhaft frei), **für Blu-rays
|
||||||
|
schaltet MakeMKV nach Ablauf des Testzeitraums ab** — nach der
|
||||||
|
Gnadenfrist („noch einige Tage", MSG 5051) beim Speichern, je nach
|
||||||
|
Fassung auch beim Öffnen. Ob das GUI am anderen PC noch in der
|
||||||
|
Gnadenfrist ist, sagt dessen Startmeldung; Rippy dagegen meldet den
|
||||||
|
Zustand nicht als Ausfall (siehe F1/F5).
|
||||||
|
|
||||||
|
Warum es bei DIR läuft: In der echten Datenbank dieses PCs steht unter
|
||||||
|
`makemkvAppKey` ein 68-Zeichen-Schlüssel MIT `@` — du hast den Key von
|
||||||
|
Hand als „Eigener Schlüssel" eingetragen. Der andere PC verlässt sich
|
||||||
|
vermutlich auf die Automatik, und die liefert den kaputten Key.
|
||||||
|
|
||||||
|
*Erkennen:* Einstellungen → MakeMKV zeigt dort gelb „BETA-MODUS — RIPS
|
||||||
|
LAUFEN" oder rot; im Protokoll steht `Schlüssel: Beta-Key abgelehnt … —
|
||||||
|
wieder entfernt`; ein gescheiterter Rip trägt `Ursache: MakeMKV ist nicht
|
||||||
|
freigeschaltet (Testzeitraum abgelaufen)`; MakeMKV-GUI zeigt beim Start
|
||||||
|
„Der Testzeitraum ist abgelaufen".
|
||||||
|
*Sofort-Abhilfe ohne neue Version:* den vollständigen Key (mit `@`) von
|
||||||
|
Hand unter Einstellungen → MakeMKV → „Eigener Schlüssel" eintragen,
|
||||||
|
„Schlüssel neu prüfen". Gilt bis 30.09.2026 — danach braucht es den
|
||||||
|
Rippy-Fix (Fund 1 unten) oder wieder Handarbeit.
|
||||||
|
|
||||||
|
### Kandidat 2 — Titel lesen bricht nach fünf Minuten ab (der Timeout, nicht die Disc)
|
||||||
|
|
||||||
|
`erkennung.titelLesen` tötet `makemkvcon info` nach 300 s
|
||||||
|
(`kern/dienste/erkennung.ts:315`, `kern/rip/makemkv.ts:170-173`) und
|
||||||
|
meldet „MakeMKV hat die Titel nach fünf Minuten nicht gelesen. Bei einer
|
||||||
|
beschädigten Disc kann das passieren". Ältere DVDs mit Kopierschutz
|
||||||
|
(Sony-ARccOS, Disney-99-Titel-Struktur) und Discs mit vielen Playlisten
|
||||||
|
brauchen im MakeMKV-GUI regelmäßig länger als fünf Minuten zum Öffnen —
|
||||||
|
das GUI wartet, Rippy nicht. Ein hart beendetes makemkvcon hinterlässt
|
||||||
|
außerdem gern den 5042-Zustand („findet kein Laufwerk"), sodass der
|
||||||
|
NÄCHSTE Versuch sofort scheitert.
|
||||||
|
*Erkennen:* `Titel-Lauf X gescheitert: MakeMKV hat die Titel nach fünf
|
||||||
|
Minuten nicht gelesen`; im GUI dauert „Disc wird geöffnet" länger als 5 min.
|
||||||
|
|
||||||
|
### Kandidat 3 — der Rip startet still nicht (Ablage oder Arbeitsordner nicht erreichbar)
|
||||||
|
|
||||||
|
`Pipeline.starten` legt den Roh-Ordner mit `mkdirSync` an
|
||||||
|
(`kern/ablauf/pipeline.ts:258`) — ohne Fangnetz. Ist die Ablage oder der
|
||||||
|
Arbeitsordner gerade nicht da (NAS aus, Netzlaufwerk noch nicht
|
||||||
|
verbunden, Buchstabe weg), fliegt eine Ausnahme aus `starten()`, der
|
||||||
|
Aufruf `void laufwerke.bereit.then(() => ripVerwaltung.starten(…))`
|
||||||
|
(`kern/index.ts:198`) verschluckt sie: **keine Meldung im Fenster, keine
|
||||||
|
Zeile im Protokoll, der Dialog schließt sich, nichts passiert** (Sonde 10:
|
||||||
|
`ENOENT … mkdir 'Q:\rippy-nicht-da\roh\…'`, gemeldet: nichts). Der Kern
|
||||||
|
stirbt dabei nicht (in Electron 44 gemessen: ein utilityProcess überlebt
|
||||||
|
eine unbehandelte Promise-Ablehnung) — er schweigt nur.
|
||||||
|
*Erkennen:* Nach `Titel-Lauf X: … s, N Titel` folgt KEINE Zeile
|
||||||
|
`Vorgang … beginnt`; die Kachel bleibt ohne Balken.
|
||||||
|
|
||||||
|
### Kandidat 4 — Disc-Typ „unbekannt" → Rippy sichert ein ISO statt zu rippen
|
||||||
|
|
||||||
|
Der Typ kommt aus `IOCTL_CDROM_DISK_TYPE` + Größe (`kern/laufwerk/disc.ts:52-59`).
|
||||||
|
Antwortet das Laufwerk darauf nicht (bei manchen USB-Brücken
|
||||||
|
`ERROR_INVALID_FUNCTION`) oder ohne Bits, steht `unknown` — die Kachel
|
||||||
|
zeigt „unbekannt" und PRESET „ISO-Abbild", der Knopf „Rippen" öffnet
|
||||||
|
trotzdem den Titel-Dialog (`LaufwerkKachel.tsx:157-166`), und nach der
|
||||||
|
Auswahl legt die Pipeline ein 1:1-Abbild an statt der gewählten Titel
|
||||||
|
(`pipeline.ts:236-241`).
|
||||||
|
*Erkennen:* `Disc eingelegt in X: unknown, … Bytes`, Kachel MEDIUM
|
||||||
|
„unbekannt", Statuszeile „Sichere 1:1-Abbild …".
|
||||||
|
|
||||||
|
### Kandidat 5 — Erkennung bleibt bei 60 %, die Automatik wartet (kein Fehler, aber „tut nichts")
|
||||||
|
|
||||||
|
Alle drei Discs tragen vermutlich englische Labels (`THE_PATRIOT`,
|
||||||
|
`DEJA_VU`, `TRAINING_DAY`). TMDb antwortet auf Deutsch („Der Patriot",
|
||||||
|
„Déjà Vu – Wettlauf gegen die Zeit"); der wörtliche Vergleich
|
||||||
|
`gleichBedeutend` (`kern/metadaten/zuordnung.ts:184-195`) vergleicht nur
|
||||||
|
den deutschen `title`, nie `original_title`, und kennt keine Akzente
|
||||||
|
(Sonde 1: „Déjà Vu" ≠ „Deja Vu", „Der Patriot" ≠ „The Patriot"). Ergebnis:
|
||||||
|
Vorschlag mit 60 % statt 95 % — die Automatik startet nicht
|
||||||
|
(`automatik.ts:79-82`), im Kasten steht „Automatik wartet: Die Erkennung
|
||||||
|
ist nicht sicher genug". Wer auf Zero-Click wartet, sieht: nichts.
|
||||||
|
*Erkennen:* Kachel „Vorschlag" (gelb) statt „sehr wahrscheinlich"; die
|
||||||
|
Automatik-Zeile.
|
||||||
|
|
||||||
|
### Was ich vom anderen PC brauche, um es festzunageln
|
||||||
|
|
||||||
|
1. Die Protokoll-Zeilen des Tages (siehe oben) — am besten die ganze Datei.
|
||||||
|
2. Einstellungen → MakeMKV: der Text der Schlüssel-Karte, und MakeMKV-Version.
|
||||||
|
3. Ob die drei Discs DVD, Blu-ray oder 4K sind.
|
||||||
|
4. Was genau „nicht bearbeiten" war: kein Start, roter Text, ISO, oder ewig „liest die Titel".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Fehler — nach Schwere, mit Beleg
|
||||||
|
|
||||||
|
Schwere: **HOCH** = Datenverlust, Ausfall oder stilles Scheitern ·
|
||||||
|
**MITTEL** = falsches Verhalten, das der Nutzer bemerkt · **NIEDRIG** =
|
||||||
|
Schönheit, Bedienung, Nebensache. Jede Nummer verweist auf Datei:Zeile im
|
||||||
|
Stand `473eea7`.
|
||||||
|
|
||||||
|
### HOCH
|
||||||
|
|
||||||
|
**F1 · Beta-Key mit `@` wird abgeschnitten → MakeMKV unlizenziert**
|
||||||
|
`kern/werkzeuge/schluessel.ts:19` (`KEY_MUSTER`), `:24-27`, `:212-219`.
|
||||||
|
Beleg: Sonde 9 an der heute geladenen Forum-Seite: Seite 68 Zeichen mit
|
||||||
|
`@`, Rippy zieht 62. Wirkung: siehe Kandidat 1 — auf jedem PC ohne
|
||||||
|
Hand-Key laufen Blu-ray-Rips nach dem Testzeitraum nicht mehr, und Rippy
|
||||||
|
zeigt dazu GELB „BETA-MODUS — RIPS LAUFEN" (`Karten.tsx:409-413`,
|
||||||
|
`App.tsx:115`). Dazu: `extractGueltigBis` (`:32-37`) erkennt „end of
|
||||||
|
September 2026" nicht → kein Ablaufdatum im Fenster (gemessen: leer).
|
||||||
|
Fix: Muster auf alles bis Leerzeichen/`<` (z. B. `/T-[^\s<"']{50,}/`),
|
||||||
|
Ablehnung ROT mit Klartext „Blu-ray-Rips laufen nicht", und ein Test mit
|
||||||
|
dem echten September-Schlüssel.
|
||||||
|
|
||||||
|
**F2 · Rip-Start ohne erreichbare Ablage/Arbeitsordner scheitert STILL**
|
||||||
|
`kern/ablauf/pipeline.ts:258` (`mkdirSync` ohne try), ebenso `:646`
|
||||||
|
(Audio-CD) und `:721` (ISO); Aufrufer `kern/index.ts:198` und `:236`.
|
||||||
|
Beleg: Sonde 10 + Electron-Messung (utilityProcess lebt weiter, Fehler
|
||||||
|
unsichtbar). Verstößt gegen R4. Fix: `starten()` in try/catch, Fehler als
|
||||||
|
`rip-status fehler` melden UND ins Protokoll; zusätzlich vor dem Start
|
||||||
|
eine Schreibprobe am Roh-Ordner, damit die Kachel sagt „Ablage nicht
|
||||||
|
erreichbar" statt zu schweigen; im Kern ein `process.on('unhandledRejection')`,
|
||||||
|
das ins Protokoll schreibt.
|
||||||
|
|
||||||
|
**F3 · Serien: zweite Disc derselben Staffel — Folgen werden nicht benannt, Überschreiben möglich**
|
||||||
|
`kern/ablage/struktur.ts:224-246`: `episodenUmbenennen` zählt ALLE `.mkv`
|
||||||
|
im Season-Ordner; liegen dort schon `S01E01`/`S01E02` der ersten Disc,
|
||||||
|
heißt es „Umbenennen übersprungen: 4 Dateien, 2 Zuordnungen" (Sonde 2,
|
||||||
|
real ausgeführt). Der Serien-Merker (5.5.0) und „ab Folge" laufen damit ab
|
||||||
|
Disc 2 ins Leere. Schlimmer: Folgen behalten bis dahin MakeMKVs Namen
|
||||||
|
(`ablegen.ts:198-209`, `dateinamen.ts:121`), und `title_t00.mkv` der
|
||||||
|
zweiten Disc landet im SELBEN Ordner (`ablegen.ts:152-156`) — HandBrake
|
||||||
|
bzw. `renameSync` (`ablegen.ts:95-104`) überschreiben eine gleichnamige
|
||||||
|
Datei der ersten Disc ohne Warnung, wenn deren Umbenennung ausgefallen
|
||||||
|
war (kein TMDb). Der bestehende Test `test/ablage.test.ts:106-116`
|
||||||
|
schreibt das Verhalten sogar fest. Fix: nur die NEUEN Dateien dieser
|
||||||
|
Disc umbenennen (Liste übergeben statt Ordner lesen); Zielnamen vor dem
|
||||||
|
Encode auf Kollision prüfen und nie überschreiben.
|
||||||
|
|
||||||
|
**F4 · Kern-Neustart: das Fenster bleibt taub**
|
||||||
|
`haupt/kernstart.ts:59-74` startet den Kern bis zu 3× je Minute neu, aber
|
||||||
|
`fensterVerbinden` läuft nur bei `did-finish-load` (`haupt/index.ts:245-253`);
|
||||||
|
das Fenster hält den toten Port (`kernverbindung.ts:268-282`), `anKern`
|
||||||
|
schickt ins Leere, und `App.tsx:92` zeigt keinen Fehler, weil
|
||||||
|
`verbindung.kern` noch die alte Bereit-Meldung ist. Wirkung: Laufwerke
|
||||||
|
frieren ein, Knöpfe tun nichts — bis Rippy komplett neu gestartet wird.
|
||||||
|
Das KONZEPT verspricht „Der Kern ist abgestürzt, ich starte ihn neu".
|
||||||
|
Fix: im `spawn`-Ereignis des neuen Kerns `fensterVerbinden` erneut
|
||||||
|
aufrufen und eine Zeile im Fenster zeigen.
|
||||||
|
|
||||||
|
**F31 · Nachtrag (12.09. abends, beim Bau von 5.7.0 gemessen): jede
|
||||||
|
Tonspur wurde seit 5.0 in AAC umgerechnet — nicht verlustfrei durchgereicht**
|
||||||
|
`kern/komprimieren/handbrake.ts` gibt `--aencoder copy --audio-fallback
|
||||||
|
av_aac` mit, aber KEIN `--audio-copy-mask`. HandBrakeCLI 1.11.2 verengt
|
||||||
|
die Kopiermaske dann auf „copy:aac" — obwohl die Presets („H.265 MKV
|
||||||
|
1080p30", „H.265 VCN 2160p 4K" …) alle Codecs erlauben. Beleg: Encode-
|
||||||
|
Protokoll `Auto Passthru: allowed codecs are AAC … passthru not possible
|
||||||
|
for track 1, using fallback`; Ausgabe per ffprobe `aac`. Mit
|
||||||
|
ausdrücklicher Maske (`aac,ac3,eac3,dtshd,dts,mp3,truehd,flac`) bleibt
|
||||||
|
AC3 AC3 und die Passthru-Zeile nennt alle acht Codecs (Messung in
|
||||||
|
`test/messung.spuren.test.ts`, `RIPPY_MESSUNG=1`). Wirkung: Jede seit
|
||||||
|
5.0 komprimierte Datei trägt AAC statt DTS-HD MA/TrueHD/AC3 — wo die
|
||||||
|
Roh-Dateien nach der Kompression gelöscht wurden, ist der Original-Ton
|
||||||
|
weg. Fix in 5.7.0: Maske auf beiden Wegen (Sprachlisten und Spuren
|
||||||
|
einzeln). Filme, bei denen der HD-Ton wichtig ist, noch einmal rippen.
|
||||||
|
|
||||||
|
### MITTEL
|
||||||
|
|
||||||
|
**F5 · Schlüssel-Prüfung urteilt ohne Grundlage**
|
||||||
|
`schluessel.ts:108-131`: `info disc:9999` liefert ohne angeschlossenes
|
||||||
|
Laufwerk NUR 5042/5010 (heute gemessen) — keine Lizenzzeile, also
|
||||||
|
`ok: true` und „angenommen", egal was im Register steht. `:179`:
|
||||||
|
das Ergebnis von `keyAblegen` (reg add) wird ignoriert — „hinterlegt"
|
||||||
|
steht auch dann im Protokoll, wenn nichts geschrieben wurde. `:194-237`:
|
||||||
|
Ablehnung liefert `weiterBetrieb: true` (gelb) statt rot.
|
||||||
|
|
||||||
|
**F6 · Jeder Auswurf legt eine weitere Takt-Kette der Disc-Wache an**
|
||||||
|
`kern/laufwerk/wache.ts:285-303`: `runde()` plant IMMER einen neuen
|
||||||
|
Timer, ohne den laufenden zu löschen; `laufwerke.ts:175` ruft `runde()`
|
||||||
|
nach jedem Auswurf. Beleg: Sonde 3 — drei Ketten, neunmal statt fünfmal
|
||||||
|
gelesen. Wirkung: nach 20 Discs fragt Rippy die Laufwerke alle 150 ms
|
||||||
|
statt alle 3 s (CreateFileW + IOCTL je Runde). Fix: vorhandenen Timer
|
||||||
|
in `runde()` erst löschen.
|
||||||
|
|
||||||
|
**F7 · Während der Kompression wird jede Sekunde die ganze Welt neu vermessen**
|
||||||
|
Kette: HandBrake-Fortschritt (mit ETA ≈ 1×/s, `handbrake.ts:152-158`) →
|
||||||
|
`warteschlange.ts:141-146` → `vorgaenge.ts:118-124` `senden()` →
|
||||||
|
`stand()` (`:217-260`) läuft mit `ordnerGroesse` rekursiv durch JEDEN
|
||||||
|
Roh-Ordner plus `fremdeOrdner`, danach `speicher.senden()` mit zwei
|
||||||
|
`statfsSync` (`speicher.ts:237-247`) — alles synchron im Kern; dann geht
|
||||||
|
die volle Vorgangsliste an Haupt und Fenster, das komplett neu zeichnet
|
||||||
|
(`kernverbindung.ts:123-125`). Auf einer NAS als Arbeitsordner ist das
|
||||||
|
ein Netz-Ordnerlauf pro Sekunde. Fix: Fortschritt als kleine Nachricht
|
||||||
|
getrennt vom Vorgangs-Stand; Roh-Größen nur bei Phasenwechsel oder
|
||||||
|
gedrosselt (z. B. alle 30 s) messen.
|
||||||
|
|
||||||
|
**F8 · Das Rettungs-Abbild wird gelöscht, wenn der Rip daraus leer ausgeht**
|
||||||
|
`pipeline.ts:279-300`: `beenden()` entfernt den ganzen Roh-Ordner, wenn
|
||||||
|
keine MKV entstand — inklusive `rettung.iso`, für das die Disc stundenlang
|
||||||
|
Sektor für Sektor gelesen wurde (`halbeDateienEntfernen` schont es,
|
||||||
|
`beenden` nicht). Fix: `rettung.iso` + Bericht vor dem `rmSync` verschonen.
|
||||||
|
|
||||||
|
**F9 · Halbe HandBrake-Ausgabe bleibt nach Abbruch/Fehler liegen**
|
||||||
|
`handbrake.ts:282-285,300-308`, `ablegen.ts:279-286`: nach `kill()` oder
|
||||||
|
Fehler wird die angefangene Datei nicht entfernt. Ohne Arbeitsordner
|
||||||
|
liegt sie direkt in `Filme\<Titel>\` — Jellyfin zeigt einen kaputten
|
||||||
|
Film. Fix: nach `.part`/Zwischenname encodieren, erst am Ende umbenennen;
|
||||||
|
bei Abbruch löschen.
|
||||||
|
|
||||||
|
**F10 · Typ „unbekannt" heißt stumm ISO, obwohl der Nutzer Titel gewählt hat**
|
||||||
|
`pipeline.ts:236-241` gegen `LaufwerkKachel.tsx:157-166`/`Uebersicht.tsx:114-120`
|
||||||
|
(siehe Kandidat 4). Dazu `disc.ts:49`: UHD wird allein an ≥ 55 GiB
|
||||||
|
erkannt — eine 4K-Disc mit 48 GB Daten wird „bluray" (Sonde 4) und
|
||||||
|
bekommt das Blu-ray-Preset. Fix: liegt ein Titel-Auftrag vor, immer
|
||||||
|
MakeMKV; UHD-Merkmal am Gerät nachmessen (Regel D) statt Größe.
|
||||||
|
|
||||||
|
**F11 · Laufwerks-Gesundheit beschuldigt das falsche Gerät**
|
||||||
|
`kern/laufwerk/ereignisse.ts:41-44`: der XPath nimmt Provider `cdrom` UND
|
||||||
|
`disk` für ALLE Geräte; `:67-76` filtert nicht nach `\Device\CdRomN`.
|
||||||
|
Eine hustende Festplatte oder ein zweites Laufwerk erzeugt „VERDACHT:
|
||||||
|
DIE HARDWARE" für die Kachel des Blu-ray-Laufwerks. Fix: Gerät des
|
||||||
|
Laufwerksbuchstabens ermitteln und nur dessen Ereignisse zählen.
|
||||||
|
|
||||||
|
**F12 · Zeiten stehen in UTC, nicht in deiner Uhrzeit**
|
||||||
|
`bausteine.tsx:35-38` (`datumKurz`), `Karten.tsx:269,276,418,574`,
|
||||||
|
`VerlaufKarte.tsx:295`: ISO-Strings werden abgeschnitten — 21:30 Uhr
|
||||||
|
erscheint als 19:30 (Sonde 5). Fix: lokal formatieren.
|
||||||
|
|
||||||
|
**F13 · Eigener Werkzeug-Pfad eingetragen → Anzeige bleibt „NICHT GEFUNDEN"**
|
||||||
|
`kern/index.ts:245-254`: nach `werkzeug.*` wird `werkzeuge.senden()` nicht
|
||||||
|
angestoßen (auch `makemkvAppKey` löst keinen Schlüssel-Lauf aus). In der
|
||||||
|
Erst-Einrichtung (`ErstEinrichtung.tsx:112-121`) bleibt die rote Zeile
|
||||||
|
stehen, obwohl der Pfad stimmt. Fix: nach diesen Schlüsseln messen bzw.
|
||||||
|
prüfen.
|
||||||
|
|
||||||
|
**F14 · Erkennung kennt weder Originaltitel noch Akzente**
|
||||||
|
`zuordnung.ts:184-195,302-309`, `tmdb.ts:24-34` (kein `original_title`).
|
||||||
|
Siehe Kandidat 5. Fix: `original_title`/`original_name` mitvergleichen,
|
||||||
|
Akzente falten (NFD + Kombinationszeichen entfernen).
|
||||||
|
|
||||||
|
**F15 · Tray „Beenden" tötet einen laufenden Rip ohne Rückfrage**
|
||||||
|
`haupt/index.ts:278-281,383-387`, `tray.ts:291-297`: `app.quit()` → Kern
|
||||||
|
`kill()` → die Leine reißt makemkvcon/HandBrake mit — 40 Minuten Rip weg.
|
||||||
|
Fix: bei aktiven Vorgängen nachfragen.
|
||||||
|
|
||||||
|
**F16 · MakeMKV-Installer aus dem Internet Archive mit Adminrechten**
|
||||||
|
`beschaffen.ts:143-155,193-232`, `haupt/index.ts:206-216`: fällt der
|
||||||
|
Hersteller aus, wird eine EXE vom Archiv geladen und per UAC gestartet;
|
||||||
|
geprüft wird nur die Versions-Ressource (fälschbar). Für ein Programm im
|
||||||
|
Bekanntenkreis ein Risiko. Fix: SHA-256 einer bekannten Fassung
|
||||||
|
mitführen oder Archiv-Quelle nur nach Bestätigung.
|
||||||
|
|
||||||
|
### NIEDRIG
|
||||||
|
|
||||||
|
**F17** „Roh-Dateien löschen" und „Ordner löschen" ohne Rückfrage — ein Klick, 30 GB weg (`Karten.tsx:315-319,351-353`; `vorgaenge.ts:305-349`).
|
||||||
|
**F18** Eingabefelder speichern nur beim Verlassen; Enter und Bereichswechsel verlieren Tipparbeit (`bausteine.tsx:356-389`).
|
||||||
|
**F19** Dialoge ohne Escape, Fokus-Fang, `role="dialog"` (`bausteine.tsx:453-473`).
|
||||||
|
**F20** Toast „Disc erkannt" bis zu dreimal je Disc — Erkennung, Nachprüfung, „Ändern" (`haupt/index.ts:126-134`).
|
||||||
|
**F21** Poster-Fehler wird verschluckt: Rückgabe von `posterSpeichern` ignoriert (`ablegen.ts:385-387`, R4).
|
||||||
|
**F22** `finden()` ruft bei fehlendem MakeMKV dreimal `reg query … /s` synchron (bis 45 s) — bei jedem Aufruf, auch alle 15 s im Update-Lauf (`katalog.ts:168-203`).
|
||||||
|
**F23** NFO ohne `<uniqueid type="tmdb">` — Jellyfin muss über Titel/Jahr raten (`nfo.ts:187-209`).
|
||||||
|
**F24** Doppelter Zielordner bekommt den Zusatz „[bluray-2]" (`struktur.ts:158-170`, `vorgangsName.slice(0,8)`).
|
||||||
|
**F25** ASCII-Umlaute in Knopftexten: „auswaehlen", „Titel ueberspringen", „naechsten" (`LaufwerkKachel.tsx:161,413,415`).
|
||||||
|
**F26** Phase `rettet` fehlt in der Haupt-Liste aktiver Vorgänge (`haupt/index.ts:184`) — Update-Sperre und Taskleiste greifen im Rettungs-Abbild nicht.
|
||||||
|
**F27** „Neu komprimieren" wird auch für verschobene (unkomprimierte) Vorgänge angeboten (`vorgaenge.ts:245`) — Klick endet in „Roh-Dateien sind nicht mehr da".
|
||||||
|
**F28** Automatik schweigt bei Typ `unknown`/`cd` (`automatik.ts:77`) — kein Grund im Kasten.
|
||||||
|
**F29** Ton: `--first-audio` nimmt je Sprache genau EINE Spur (`handbrake.ts:56`) — Kommentar- oder Stereo-Spuren derselben Sprache fallen weg. Bewusst? Dann ins Fenster schreiben.
|
||||||
|
**F30** Audio-CD: `audioSpurLesen` blockiert den Kern synchron je Spur (`disc.ts:200-220`) — Abbrechen wirkt erst zwischen zwei Spuren.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Auffälligkeiten (keine Fehler, aber wissenswert)
|
||||||
|
|
||||||
|
**A1 · Der „Datenbank-Befund vom 01.09." ist gelöst — ein Messartefakt.**
|
||||||
|
Das Terminal der Claude-Desktop-App läuft in einem MSIX-Paket; alles,
|
||||||
|
was daraus startet, sieht `%APPDATA%`, `%LOCALAPPDATA%` und `HKCU`
|
||||||
|
virtualisiert (Schattenkopie unter
|
||||||
|
`%LOCALAPPDATA%\Packages\Claude_pzs8sxrjxfjjc\LocalCache\…`). Deshalb
|
||||||
|
zeigte `%APPDATA%\Rippy\rippy.db` hier Schema 3 vom 01.09. — die ECHTE
|
||||||
|
Datei (über `\\localhost\C$\…` gelesen) ist Schema 4, 36 864 Bytes, Stand
|
||||||
|
04.09. 22:02, mit allen Einstellungen. Rippy ist unschuldig. Folge: die
|
||||||
|
Lizenzzeilen 5054/5051 in `beweise/messungen/2026-09-03-makemkvcon-info-kizuna.txt`
|
||||||
|
kamen aus dem Paket-Register ohne Schlüssel — kein Beleg für deinen
|
||||||
|
echten Zustand. (Als Gedächtnis-Notiz für künftige Sitzungen hinterlegt.)
|
||||||
|
|
||||||
|
**A2 · Rippy schreibt in MakeMKVs eigene Einstellungen** (`HKCU\Software\MakeMKV`:
|
||||||
|
`app_Key`, `app_DefaultSelectionString`, `app_UpdateEnable`). Damit
|
||||||
|
nimmt auch das MakeMKV-GUI seitdem ALLE Spuren mit (größere Dateien beim
|
||||||
|
Handbetrieb) — beabsichtigt, aber nirgends im Fenster gesagt.
|
||||||
|
|
||||||
|
**A3 · Das Rettungs-Abbild ist bei AACS-Discs (Blu-ray/4K) ungeprüft.**
|
||||||
|
Ein Sektor-Abbild bleibt verschlüsselt; ob makemkvcon es als `iso:` ohne
|
||||||
|
Laufwerk öffnet, hängt an MakeMKVs Schlüsseldaten. Bei DVDs (CSS) sollte
|
||||||
|
es gehen. Am Gerät messen, bevor es jemand im Ernstfall braucht.
|
||||||
|
|
||||||
|
**A4 · 4K wird standardmäßig komprimiert** — mit dem 8-Bit-Preset
|
||||||
|
„H.265 … 2160p 4K" bzw. auf CPU mit „H.265 MKV 1080p30" (`presets.ts:23-49`,
|
||||||
|
`preset-empfehlung.ts:321-335`). HDR-Metadaten und 10 Bit gehen dabei
|
||||||
|
verloren, der Encode dauert Stunden. Das Docker-Rippy hatte „4K
|
||||||
|
verlustfrei" als Regel. Entscheid nötig: Standard „keine" für UHD oder
|
||||||
|
ein 10-Bit-Preset.
|
||||||
|
|
||||||
|
**A5 · Toter Schlüssel `transcodePreset`** in `presets.ts:47-48` — seit
|
||||||
|
5.5.0 nicht mehr in `PIPELINE_EINSTELLUNGEN`, kann nie greifen.
|
||||||
|
|
||||||
|
**A6 · Titel-Lesen: 300 s fest verdrahtet**, kein Regler, Meldung nennt
|
||||||
|
die Disc „beschädigt" (`erkennung.ts:354-356`) — siehe Kandidat 2.
|
||||||
|
|
||||||
|
**A7 · Der Klick-Beweis und die 450 Tests sind gut, aber blind für genau
|
||||||
|
die Funde oben:** kein Test mit Bestandsdateien im Season-Ordner, keiner
|
||||||
|
für mehrfaches `runde()`, keiner mit einem Key mit `@`, keiner für den
|
||||||
|
Rip-Start ohne Ablage, keiner für den Kern-Neustart.
|
||||||
|
|
||||||
|
**A8 · Sicherheit/Härtung ist sonst gut:** contextIsolation + sandbox im
|
||||||
|
Fenster, CSP im Bau, keine Shell-Aufrufe mit Nutzerdaten, PowerShell-
|
||||||
|
Pfad wird korrekt gequotet, Update nur über HTTPS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Vorschläge zur Verbesserung — in dieser Reihenfolge
|
||||||
|
|
||||||
|
1. **F1 sofort** (eine Zeile plus Test, plus rote Ampel) — vor dem 30.09.
|
||||||
|
ist das der einzige Fund, der beide PCs betrifft.
|
||||||
|
2. **F2 + F4 + F6** — die drei „stillen" Fehler; jeder ist klein, alle
|
||||||
|
drei kosten Vertrauen („tut nichts, sagt nichts").
|
||||||
|
3. **F3** — Serien mit mehr als einer Disc gehen sonst kaputt; Test mit
|
||||||
|
vorhandenen Dateien im Ordner dazu.
|
||||||
|
4. **F8 + F9** — Aufräumen, das nicht zu viel und nicht zu wenig löscht.
|
||||||
|
5. **Kandidat 2** — Titel-Lesen-Grenze auf 10–15 min, Meldung ehrlich
|
||||||
|
(„die Disc braucht länger — läuft weiter / abbrechen"), Grenze in den
|
||||||
|
Einstellungen.
|
||||||
|
6. **F7** — Fortschritt von Vorgangs-Stand trennen; spürbar auf NAS.
|
||||||
|
7. **F14** — Originaltitel + Akzente: schlägt sich direkt in der
|
||||||
|
Trefferquote der Automatik nieder.
|
||||||
|
8. **F10/F11/F12/F13** — sichtbare Kleinigkeiten, je ein halber Nachmittag.
|
||||||
|
9. **F15/F17** — zwei Rückfragen, die Daten retten.
|
||||||
|
10. **A4** — dein Entscheid zu 4K.
|
||||||
|
11. **F31** (Nachtrag) — ist in 5.7.0 behoben; Filme mit wichtigem
|
||||||
|
HD-Ton, deren Roh-Dateien schon weg sind, noch einmal rippen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Was gemessen wurde (nachvollziehbar)
|
||||||
|
|
||||||
|
* Typprüfung und Tests: `npm run typecheck`, `npx vitest run` — grün
|
||||||
|
(450 bestanden, 12 übersprungen).
|
||||||
|
* Sonden gegen den echten Code (Vitest, außerhalb des Repos):
|
||||||
|
S1 Akzente/Originaltitel · S2 Episoden-Umbenennung mit Bestand ·
|
||||||
|
S3 Wache-Takt-Ketten (9 statt ~5 Runden) · S4 Typ-Zuordnung ·
|
||||||
|
S5 UTC-Anzeige · S6 Labels der drei Filme · S7 99-Titel-Muster ·
|
||||||
|
S8 `halbeDateienEntfernen` · S9 Beta-Key von der echten Forum-Seite ·
|
||||||
|
S10 Rip-Start mit unerreichbarer Ablage.
|
||||||
|
* Electron 44: utilityProcess überlebt unbehandelte Promise-Ablehnung
|
||||||
|
(zwei Läufe, mit und ohne Horcher).
|
||||||
|
* Forum-Seite t=1053 heute geladen: Key mit `@`, „valid until end of
|
||||||
|
September 2026".
|
||||||
|
* Echte `rippy.db` dieses PCs über `\\localhost\C$`: Schema 4,
|
||||||
|
`makemkvAppKey` 68 Zeichen mit `@`, `ablage E:\Rippy`, `automatik aus`,
|
||||||
|
Jellyfin, TMDb- und OMDb-Schlüssel gesetzt, keine Vorgänge (Verlauf
|
||||||
|
leer), Bibliothek leer.
|
||||||
|
* Protokolle dieses PCs (02.–05.09.): Rippy 5.5.0 → 5.6.0 → 5.6.1,
|
||||||
|
Kizuna erkannt (95 %), Titel-Lauf 22–92 s, Laufwerk G am 02.09. mit
|
||||||
|
„2 Controllerfehler, 112 fehlerhafte Blöcke" (siehe F11 — ob das dein
|
||||||
|
BU40N war oder eine Platte, weiß der Bericht nicht).
|
||||||
|
* `makemkvcon64 -r --noscan info disc:9999` heute: nur 5042/5010 — ohne
|
||||||
|
Laufwerk keine Lizenzauskunft (F5).
|
||||||
|
|
||||||
|
Nichts davon hat etwas am Repo, an deinen Einstellungen oder am Register
|
||||||
|
verändert; die Sonden liegen im Sitzungs-Scratchpad.
|
||||||
|
Before Width: | Height: | Size: 290 KiB After Width: | Height: | Size: 288 KiB |
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 158 KiB |
|
Before Width: | Height: | Size: 163 KiB After Width: | Height: | Size: 178 KiB |
|
Before Width: | Height: | Size: 341 KiB After Width: | Height: | Size: 335 KiB |
|
Before Width: | Height: | Size: 711 KiB After Width: | Height: | Size: 752 KiB |
|
Before Width: | Height: | Size: 712 KiB After Width: | Height: | Size: 753 KiB |
|
Before Width: | Height: | Size: 750 KiB After Width: | Height: | Size: 784 KiB |
|
Before Width: | Height: | Size: 342 KiB After Width: | Height: | Size: 337 KiB |
|
Before Width: | Height: | Size: 465 KiB After Width: | Height: | Size: 468 KiB |
|
Before Width: | Height: | Size: 456 KiB After Width: | Height: | Size: 457 KiB |
|
Before Width: | Height: | Size: 438 KiB After Width: | Height: | Size: 445 KiB |
@@ -124,16 +124,26 @@ await schritt('Titel-Lauf liefert drei Titel', async () => {
|
|||||||
await dialog().getByText('Titel 2', { 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 schritt('5.7.0: Spuren je Titel einzeln — Codec und Kanäle, der DTS-Kern bleibt unsichtbar', async () => {
|
||||||
await dialog().getByRole('button', { name: /jpn/ }).first().waitFor()
|
await dialog().getByLabel('Japanese · DTS-HD MA · Surround 5.1').first().waitFor()
|
||||||
await dialog().getByRole('button', { name: /✓ deu/ }).first().waitFor()
|
await dialog().getByLabel('German · DD · Surround 5.1').first().waitFor()
|
||||||
|
await dialog().getByLabel('German · DD · Stereo (Kommentar)').first().waitFor()
|
||||||
|
await dialog().getByLabel('German · PGS (nur erzwungene)').first().waitFor()
|
||||||
|
if ((await dialog().getByLabel('Japanese · DTS · Surround 5.1').count()) !== 0) throw new Error('der abgeleitete DTS-Kern wird angeboten')
|
||||||
|
// Vorbelegt: je Wunschsprache (deu,eng) die erste Tonspur → nur German DD 5.1;
|
||||||
|
// Untertitel: alle deutschen (auch „nur erzwungene"), englisch nicht.
|
||||||
|
if (!(await dialog().getByLabel('German · DD · Surround 5.1').first().isChecked())) throw new Error('German 5.1 nicht vorbelegt')
|
||||||
|
if (await dialog().getByLabel('German · DD · Stereo (Kommentar)').first().isChecked()) throw new Error('Kommentar-Spur vorbelegt')
|
||||||
|
if (await dialog().getByLabel('Japanese · DTS-HD MA · Surround 5.1').first().isChecked()) throw new Error('Japanisch vorbelegt, obwohl Deutsch da ist')
|
||||||
|
if (!(await dialog().getByLabel('German · PGS (nur erzwungene)').first().isChecked())) throw new Error('erzwungene Untertitel nicht vorbelegt')
|
||||||
|
if (await dialog().getByLabel('English · PGS').first().isChecked()) throw new Error('englische Untertitel vorbelegt')
|
||||||
})
|
})
|
||||||
|
|
||||||
await schritt('Titel 0 bekommt japanischen Ton (deu abwählen, jpn anwählen)', async () => {
|
await schritt('Titel 0 bekommt japanischen Ton (German 5.1 abhaken, Japanese anhaken)', async () => {
|
||||||
const chips = dialog().locator('button', { hasText: /^✓ deu$|^deu$/ })
|
await dialog().getByLabel('German · DD · Surround 5.1').first().uncheck()
|
||||||
await chips.first().click()
|
await dialog().getByText('keine Tonspur gewählt — HandBrake nimmt die erste Spur').waitFor()
|
||||||
await dialog().getByRole('button', { name: /^jpn$/ }).first().click()
|
await dialog().getByLabel('Japanese · DTS-HD MA · Surround 5.1').first().check()
|
||||||
await dialog().getByRole('button', { name: /^✓ jpn$/ }).first().waitFor()
|
if ((await dialog().getByText('keine Tonspur gewählt').count()) !== 0) throw new Error('Warnung bleibt trotz japanischer Spur')
|
||||||
})
|
})
|
||||||
|
|
||||||
await schritt('„Hauptinhalt + Extras" wählt beide langen Titel, der Schnipsel bleibt draußen', async () => {
|
await schritt('„Hauptinhalt + Extras" wählt beide langen Titel, der Schnipsel bleibt draußen', async () => {
|
||||||
@@ -153,7 +163,9 @@ await schritt('5.6.0: Untertitel-Vorwahl erscheint NUR bei Extras, „anderer Fi
|
|||||||
await rollen.nth(1).selectOption('extra')
|
await rollen.nth(1).selectOption('extra')
|
||||||
await dialog().getByText('BEIM ABSPIELEN AN').waitFor()
|
await dialog().getByText('BEIM ABSPIELEN AN').waitFor()
|
||||||
const vorwahl = dialog().locator('select').nth(2)
|
const vorwahl = dialog().locator('select').nth(2)
|
||||||
await vorwahl.selectOption('deu')
|
// 5.7.0: Rippys Regel nennt die Spur (japanischer Ton → deutsche
|
||||||
|
// Untertitel), die Optionen sind Spuren — gewählt wird per Nummer (5).
|
||||||
|
await vorwahl.selectOption('5')
|
||||||
const knoepfe = await dialog().getByRole('button', { name: 'anderer Film …' }).count()
|
const knoepfe = await dialog().getByRole('button', { name: 'anderer Film …' }).count()
|
||||||
if (knoepfe < 2) throw new Error(`nur ${knoepfe} „anderer Film"-Knöpfe`)
|
if (knoepfe < 2) throw new Error(`nur ${knoepfe} „anderer Film"-Knöpfe`)
|
||||||
await dialog().getByRole('button', { name: 'anderer Film …' }).first().click()
|
await dialog().getByRole('button', { name: 'anderer Film …' }).first().click()
|
||||||
@@ -279,22 +291,32 @@ await schritt('Werkzeuge haben Durchsuchen, Allgemein und Update sind eigene Kar
|
|||||||
})
|
})
|
||||||
await bild('einstellungen')
|
await bild('einstellungen')
|
||||||
|
|
||||||
await schritt('Roh-Dateien löschen → „gelöscht"', async () => {
|
await schritt('Roh-Dateien löschen → Rückfrage → „Ja, löschen" → „gelöscht"', async () => {
|
||||||
await seite.getByRole('button', { name: 'Roh-Dateien löschen' }).first().click()
|
await seite.getByRole('button', { name: 'Roh-Dateien löschen …' }).first().click()
|
||||||
|
await seite.getByRole('button', { name: 'Ja, löschen' }).first().click()
|
||||||
await seite.getByText(/Roh: gelöscht/).first().waitFor()
|
await seite.getByText(/Roh: gelöscht/).first().waitFor()
|
||||||
})
|
})
|
||||||
|
|
||||||
await schritt('5.6.0: Hell-Modus über Einstellungen → Allgemein, data-design wechselt', async () => {
|
await schritt('5.6.0: Hell-Modus über Einstellungen → Allgemein, data-design wechselt', async () => {
|
||||||
await seite.locator('#allgemein').locator('select').first().selectOption('hell')
|
await seite.locator('#allgemein').getByLabel('Erscheinungsbild').selectOption('hell')
|
||||||
await seite.locator('html[data-design="hell"]').waitFor()
|
await seite.locator('html[data-design="hell"]').waitFor()
|
||||||
})
|
})
|
||||||
await bild('hell-modus')
|
await bild('hell-modus')
|
||||||
|
|
||||||
await schritt('zurück in den Kinosaal (dunkel)', async () => {
|
await schritt('zurück in den Kinosaal (dunkel)', async () => {
|
||||||
await seite.locator('#allgemein').locator('select').first().selectOption('dunkel')
|
await seite.locator('#allgemein').getByLabel('Erscheinungsbild').selectOption('dunkel')
|
||||||
await seite.locator('html[data-design="dunkel"]').waitFor()
|
await seite.locator('html[data-design="dunkel"]').waitFor()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
await schritt('5.7.0: „Titel lesen — höchstens" steht auf 15 Minuten, 30 lässt sich wählen', async () => {
|
||||||
|
const regler = seite.locator('#allgemein').getByLabel('Titel lesen — höchstens')
|
||||||
|
if ((await regler.inputValue()) !== '15') throw new Error(`Vorgabe ist ${await regler.inputValue()}, nicht 15`)
|
||||||
|
await regler.selectOption('30')
|
||||||
|
// Der Wert geht zum Kern und kommt als Einstellung zurück — erst dann
|
||||||
|
// zeigt das (kontrollierte) Feld 30.
|
||||||
|
await regler.locator('option[value="30"]:checked').waitFor({ state: 'attached' })
|
||||||
|
})
|
||||||
|
|
||||||
await schritt('Ereignisse nennen die Kompression', async () => {
|
await schritt('Ereignisse nennen die Kompression', async () => {
|
||||||
await seite.getByRole('button', { name: 'Übersicht' }).click()
|
await seite.getByRole('button', { name: 'Übersicht' }).click()
|
||||||
await seite.getByText(/Kompression „/).first().waitFor()
|
await seite.getByText(/Kompression „/).first().waitFor()
|
||||||
@@ -357,6 +379,6 @@ await schritt('Fertig: der Knopf Los geht’s', async () => {
|
|||||||
})
|
})
|
||||||
await bild('einrichtung-fertig')
|
await bild('einrichtung-fertig')
|
||||||
|
|
||||||
console.log('KLICK: OK — Speicher, Rippen, Dialog, Sprachen, Folgen, Platz, Rip, Warteschlange, Kompression, Arbeitsordner, Ablage, Verlauf, Bilanz, Roh-Dateien, Einstellungen, Vorwahl, anderer Film, Vorgangs-Leiste, Restzeit, Hell-Modus, Erst-Einrichtung: alles geklickt.')
|
console.log('KLICK: OK — Speicher, Rippen, Dialog, Spuren einzeln, Folgen, Platz, Rip, Warteschlange, Kompression, Arbeitsordner, Ablage, Verlauf, Bilanz, Roh-Dateien, Einstellungen, Vorwahl, anderer Film, Vorgangs-Leiste, Restzeit, Hell-Modus, Erst-Einrichtung: alles geklickt.')
|
||||||
await app.close().catch(() => {})
|
await app.close().catch(() => {})
|
||||||
process.exit(0)
|
process.exit(0)
|
||||||
|
|||||||
@@ -47,16 +47,21 @@ if (args.includes('--scan')) {
|
|||||||
out('"MainFeature": 0,')
|
out('"MainFeature": 0,')
|
||||||
out('"TitleList": [')
|
out('"TitleList": [')
|
||||||
out('{')
|
out('{')
|
||||||
|
// 5.7.0: CodecName und ChannelCount wie das echte 1.11.2 (gemessen
|
||||||
|
// 12.09.2026) — dieselben Spuren, die fake-makemkvcon meldet, ohne den
|
||||||
|
// DTS-Kern (den nimmt MakeMKVs Regel nicht mit).
|
||||||
out('"AudioList": [')
|
out('"AudioList": [')
|
||||||
out('{ "Description": "Japanese (AAC LC, 2.0 ch)", "Language": "Japanese", "LanguageCode": "jpn", "TrackNumber": 1 },')
|
out('{ "ChannelCount": 6, "ChannelLayout": 1551, "CodecName": "dtshd", "Description": "Japanese (DTS-HD MA, 5.1 ch)", "Language": "Japanese", "LanguageCode": "jpn", "TrackNumber": 1 },')
|
||||||
out('{ "Description": "Deutsch (AAC LC, 2.0 ch)", "Language": "Deutsch", "LanguageCode": "deu", "TrackNumber": 2 }')
|
out('{ "ChannelCount": 6, "ChannelLayout": 1551, "CodecName": "ac3", "Description": "Deutsch (AC3, 5.1 ch)", "Language": "Deutsch", "LanguageCode": "deu", "TrackNumber": 2 },')
|
||||||
|
out('{ "ChannelCount": 2, "ChannelLayout": 3, "CodecName": "ac3", "Description": "Deutsch (AC3, 2.0 ch)", "Language": "Deutsch", "LanguageCode": "deu", "TrackNumber": 3 }')
|
||||||
out('],')
|
out('],')
|
||||||
out('"Duration": { "Hours": 0, "Minutes": 52, "Seconds": 47, "Ticks": 542070 },')
|
out('"Duration": { "Hours": 0, "Minutes": 52, "Seconds": 47, "Ticks": 542070 },')
|
||||||
out('"Index": 1,')
|
out('"Index": 1,')
|
||||||
out(`"Name": ${JSON.stringify(eingabe)},`)
|
out(`"Name": ${JSON.stringify(eingabe)},`)
|
||||||
out('"SubtitleList": [')
|
out('"SubtitleList": [')
|
||||||
out('{ "Format": "bitmap", "Language": "Deutsch", "LanguageCode": "deu", "SourceName": "PGS", "TrackNumber": 1 },')
|
out('{ "Format": "bitmap", "Language": "Deutsch", "LanguageCode": "deu", "Source": 5, "SourceName": "PGS", "TrackNumber": 1 },')
|
||||||
out('{ "Format": "bitmap", "Language": "English", "LanguageCode": "eng", "SourceName": "PGS", "TrackNumber": 2 }')
|
out('{ "Format": "bitmap", "Language": "Deutsch", "LanguageCode": "deu", "Source": 5, "SourceName": "PGS", "TrackNumber": 2 },')
|
||||||
|
out('{ "Format": "bitmap", "Language": "English", "LanguageCode": "eng", "Source": 5, "SourceName": "PGS", "TrackNumber": 3 }')
|
||||||
out(']')
|
out(']')
|
||||||
out('}')
|
out('}')
|
||||||
out(']')
|
out(']')
|
||||||
|
|||||||
@@ -31,18 +31,27 @@ if (befehl === 'info') {
|
|||||||
out(`TINFO:${t.nr},11,0,"${t.bytes}"`)
|
out(`TINFO:${t.nr},11,0,"${t.bytes}"`)
|
||||||
out(`SINFO:${t.nr},0,1,6201,"Video"`)
|
out(`SINFO:${t.nr},0,1,6201,"Video"`)
|
||||||
if (t.nr < 2) {
|
if (t.nr < 2) {
|
||||||
out(`SINFO:${t.nr},1,1,6202,"Audio"`)
|
// 5.7.0: jede Spur mit Codec (6), Kanälen (14), Anordnung (2), Flags
|
||||||
out(`SINFO:${t.nr},1,3,0,"jpn"`)
|
// (22) und Beschreibung (30), wie an der Kizuna-Disc gemessen — samt
|
||||||
out(`SINFO:${t.nr},1,4,0,"Japanese"`)
|
// dem abgeleiteten DTS-Kern (Flag 0x800), den der Dialog NICHT zeigt,
|
||||||
out(`SINFO:${t.nr},2,1,6202,"Audio"`)
|
// und der „nur erzwungene"-Fassung eines Untertitels, die er zeigt.
|
||||||
out(`SINFO:${t.nr},2,3,0,"deu"`)
|
const spur = (nr, code, typ, lang, name, codec, kanaele, layout, flags, text) => {
|
||||||
out(`SINFO:${t.nr},2,4,0,"German"`)
|
out(`SINFO:${t.nr},${nr},1,${code},"${typ}"`)
|
||||||
out(`SINFO:${t.nr},3,1,6203,"Subtitles"`)
|
if (layout) out(`SINFO:${t.nr},${nr},2,0,"${layout}"`)
|
||||||
out(`SINFO:${t.nr},3,3,0,"deu"`)
|
out(`SINFO:${t.nr},${nr},3,0,"${lang}"`)
|
||||||
out(`SINFO:${t.nr},3,4,0,"German"`)
|
out(`SINFO:${t.nr},${nr},4,0,"${name}"`)
|
||||||
out(`SINFO:${t.nr},4,1,6203,"Subtitles"`)
|
out(`SINFO:${t.nr},${nr},6,0,"${codec}"`)
|
||||||
out(`SINFO:${t.nr},4,3,0,"eng"`)
|
if (kanaele) out(`SINFO:${t.nr},${nr},14,0,"${kanaele}"`)
|
||||||
out(`SINFO:${t.nr},4,4,0,"English"`)
|
out(`SINFO:${t.nr},${nr},22,0,"${flags}"`)
|
||||||
|
out(`SINFO:${t.nr},${nr},30,0,"${text}"`)
|
||||||
|
}
|
||||||
|
spur(1, 6202, 'Audio', 'jpn', 'Japanese', 'DTS-HD MA', '6', 'Surround 5.1', 1024, 'DTS-HD MA Surround 5.1 Japanese')
|
||||||
|
spur(2, 6202, 'Audio', 'jpn', 'Japanese', 'DTS', '6', 'Surround 5.1', 2304, 'DTS Surround 5.1 Japanese')
|
||||||
|
spur(3, 6202, 'Audio', 'deu', 'German', 'DD', '6', 'Surround 5.1', 0, 'DD Surround 5.1 German')
|
||||||
|
spur(4, 6202, 'Audio', 'deu', 'German', 'DD', '2', 'Stereo', 0, 'DD Stereo German (Kommentar)')
|
||||||
|
spur(5, 6203, 'Untertitel', 'deu', 'German', 'PGS', '', '', 0, 'PGS German')
|
||||||
|
spur(6, 6203, 'Untertitel', 'deu', 'German', 'PGS', '', '', 6144, 'PGS German (nur erzwungene)')
|
||||||
|
spur(7, 6203, 'Untertitel', 'eng', 'English', 'PGS', '', '', 0, 'PGS English')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
process.exit(0)
|
process.exit(0)
|
||||||
|
|||||||
@@ -1,3 +1,88 @@
|
|||||||
|
Version 5.7.0
|
||||||
|
|
||||||
|
Der Beta-Schluessel kommt ganz an.
|
||||||
|
Der aktuelle MakeMKV-Beta-Schluessel enthaelt ein "@" - Rippy nahm nur
|
||||||
|
62 der 68 Zeichen, MakeMKV lief danach ohne Schluessel, und Blu-ray-Rips
|
||||||
|
scheiterten nach der Gnadenfrist. Das ist die wahrscheinlichste Ursache
|
||||||
|
fuer "Der Patriot", "Deja Vu" und "Training Day" auf dem anderen PC. Die
|
||||||
|
Schluessel-Karte sagt jetzt ehrlich, was Sache ist: gruen (angenommen),
|
||||||
|
grau (nicht pruefbar - ohne eingelegte Disc gibt MakeMKV keine Auskunft),
|
||||||
|
rot (abgelehnt - Blu-rays laufen dann nur noch in der Gnadenfrist). Ein
|
||||||
|
vorhandener, angenommener Schluessel wird nie ueberschrieben.
|
||||||
|
|
||||||
|
Jede Spur einzeln anhaken.
|
||||||
|
Der Auswahl-Dialog zeigt je Titel jede Tonspur mit Codec und Kanaelen
|
||||||
|
("German - DTS-HD MA - Surround 5.1", "German - DD - Stereo (Kommentar)")
|
||||||
|
und jede Untertitel-Spur ("German - PGS", "German - PGS (nur
|
||||||
|
erzwungene)"). Vorbelegt wie bisher - je Wunschsprache die erste Tonspur,
|
||||||
|
alle Untertitel der Wunschsprachen - aber angehakt wird einzeln. Die
|
||||||
|
Untertitel-Vorwahl bei Extras ist jetzt eine Spur. MakeMKV nimmt dafuer
|
||||||
|
auch Stereo-Fassungen und Kommentare in die Roh-Datei (nur die
|
||||||
|
abgeleiteten DTS-Kerne nicht). Was in der Roh-Datei fehlt, steht im
|
||||||
|
Hinweis.
|
||||||
|
|
||||||
|
Ton bleibt verlustfrei.
|
||||||
|
HandBrake hat seit 5.0 jede Tonspur in AAC umgerechnet - die Kopiermaske
|
||||||
|
war nicht ausdruecklich gesetzt, und die Kommandozeile verengt sie dann
|
||||||
|
auf AAC. Jetzt werden AC3, E-AC3, DTS, DTS-HD, TrueHD, FLAC, MP3 und AAC
|
||||||
|
durchgereicht. Filme, bei denen der HD-Ton wichtig ist und deren
|
||||||
|
Roh-Dateien schon weg sind, noch einmal rippen.
|
||||||
|
|
||||||
|
Titel lesen bis 15 Minuten - einstellbar.
|
||||||
|
Einstellungen -> Allgemein -> "Titel lesen - hoechstens": 5 bis 60
|
||||||
|
Minuten, Vorgabe 15 (vorher fest 5). Ab der zweiten Minute steht im
|
||||||
|
Dialog, wie lange MakeMKV schon liest; "Abbrechen" beendet den Lauf auch
|
||||||
|
im Kern, das Laufwerk wird frei. Laeuft die Zeit ab, nennt die Meldung
|
||||||
|
Kopierschutz und viele Playlisten als moegliche Gruende.
|
||||||
|
|
||||||
|
4K-UHD nach der Leistung des PCs.
|
||||||
|
Ist eine Grafikkarte mit 4K-Preset da (VCN, NVENC, QSV), wird 4K damit
|
||||||
|
komprimiert (8 Bit); nur mit CPU bleibt der Rip verlustfrei, statt
|
||||||
|
tagelang auf 1080p zu rechnen. Eigene Wahl schlaegt beides. AV1 10 Bit
|
||||||
|
steht als eigene Wahl in der Liste (nur fuer AV1-faehige Abspielgeraete).
|
||||||
|
|
||||||
|
Disc-Typ aus dem Dateisystem.
|
||||||
|
BDMV/index.bdmv mit "INDX0300" ist eine 4K-UHD, "0200"/"0100" eine
|
||||||
|
Blu-ray, VIDEO_TS eine DVD - auch wenn der Windows-Steuercode "unbekannt"
|
||||||
|
sagt. Vorher wurde eine 4K-Disc mit 48 GB als Blu-ray behandelt
|
||||||
|
(falsches Preset) und "unbekannt" direkt zum Rettungs-Abbild.
|
||||||
|
|
||||||
|
Kein stilles Scheitern mehr.
|
||||||
|
Der Rip-Start prueft, ob die Ablage schreibbar ist, und sagt es im
|
||||||
|
Fenster, wenn nicht ("NAS verbunden?"); Fehler im Kern landen im
|
||||||
|
Protokoll und im Fenster; nach einem Kern-Neustart bleibt das Fenster
|
||||||
|
nicht mehr taub; jedes Laufwerk hat genau eine Wache; das Rettungs-Abbild
|
||||||
|
ueberlebt das Aufraeumen; ein Titel-Auftrag bei Typ "unbekannt" rippt
|
||||||
|
statt ein ISO zu sichern.
|
||||||
|
|
||||||
|
Serien ueber mehrere Discs.
|
||||||
|
Disc 2 benennt nur ihre eigenen Folgen (S01E03, S01E04) und ueberschreibt
|
||||||
|
nie, was Disc 1 abgelegt hat; eine vorhandene Datei gleichen Namens
|
||||||
|
bekommt "(2)". HandBrake schreibt erst auf einen Zwischennamen - eine
|
||||||
|
halbe Datei liegt nie unter dem endgueltigen Namen in der Ablage.
|
||||||
|
|
||||||
|
Erkennung: Originaltitel und Akzente.
|
||||||
|
"THE_PATRIOT" trifft "Der Patriot", "DEJA_VU" trifft "Deja Vu" - TMDb
|
||||||
|
antwortet auf Deutsch, die Disc traegt den Originaltitel; verglichen
|
||||||
|
werden jetzt beide, Akzente zaehlen nicht.
|
||||||
|
|
||||||
|
MakeMKV-Installer nur nach Rueckfrage.
|
||||||
|
Ist MakeMKV nicht da und die Herstellerseite nicht erreichbar, fragt
|
||||||
|
Rippy, bevor eine Kopie aus dem Internet Archive geladen wird - mit
|
||||||
|
Groesse und SHA-256 im Bericht.
|
||||||
|
|
||||||
|
Dazu: Tray "Beenden" fragt nach, wenn ein Rip oder eine Kompression
|
||||||
|
laeuft; "Roh-Dateien loeschen" und "Ordner loeschen" fragen im Fenster
|
||||||
|
nach; Eingabefelder speichern auch bei Enter und beim Bereichswechsel;
|
||||||
|
Escape schliesst Dialoge; "Disc erkannt" einmal je Disc statt dreimal;
|
||||||
|
Laufwerks-Gesundheit zaehlt nur das eigene Laufwerk, nie eine Festplatte;
|
||||||
|
der Kompressions-Fortschritt belastet NAS und Fenster nicht mehr jede
|
||||||
|
Sekunde; Zeiten in Ortszeit; NFO mit TMDb/IMDb-Kennung; doppelter
|
||||||
|
Zielordner heisst "(2)"; Umlaute in drei Knopftexten; "Neu komprimieren"
|
||||||
|
nur, wo es Roh-Dateien gibt; die Automatik sagt bei Audio-CD und
|
||||||
|
unbekannter Disc, warum sie nichts tut; Audio-CDs lassen sich mitten in
|
||||||
|
der Spur abbrechen.
|
||||||
|
|
||||||
Version 5.6.1
|
Version 5.6.1
|
||||||
|
|
||||||
Untertitel-Spuren werden wieder erkannt.
|
Untertitel-Spuren werden wieder erkannt.
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "rippy",
|
"name": "rippy",
|
||||||
"version": "5.6.1",
|
"version": "5.7.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "rippy",
|
"name": "rippy",
|
||||||
"version": "5.6.1",
|
"version": "5.7.0",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"electron-updater": "^6.8.9",
|
"electron-updater": "^6.8.9",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "rippy",
|
"name": "rippy",
|
||||||
"productName": "Rippy",
|
"productName": "Rippy",
|
||||||
"version": "5.6.1",
|
"version": "5.7.0",
|
||||||
"description": "Rippy v5 — Disc-Ripping als eigenständiges Windows-Programm (KONZEPT-WINDOWS.md)",
|
"description": "Rippy v5 — Disc-Ripping als eigenständiges Windows-Programm (KONZEPT-WINDOWS.md)",
|
||||||
"main": "out/haupt/index.js",
|
"main": "out/haupt/index.js",
|
||||||
"author": "KrBrZ",
|
"author": "KrBrZ",
|
||||||
|
|||||||
@@ -112,7 +112,9 @@ export default function App() {
|
|||||||
const w = verbindung.werkzeuge
|
const w = verbindung.werkzeuge
|
||||||
const werkzeugTon = w === null ? 'grau' : w.makemkvcon.length > 0 && w.handbrake.length > 0 ? 'gruen' : w.makemkvcon.length > 0 ? 'warn' : 'rot'
|
const werkzeugTon = w === null ? 'grau' : w.makemkvcon.length > 0 && w.handbrake.length > 0 ? 'gruen' : w.makemkvcon.length > 0 ? 'warn' : 'rot'
|
||||||
const s = verbindung.schluessel
|
const s = verbindung.schluessel
|
||||||
const schluesselTon = s === null ? 'grau' : s.angenommen === true ? 'gruen' : s.weiterBetrieb ? 'warn' : 'rot'
|
// 5.7.0 (F1/F5): abgelehnt oder fehlend = ROT (Blu-rays laufen nicht),
|
||||||
|
// nicht prüfbar (kein Laufwerk) = grau — nie mehr ein gelbes „läuft".
|
||||||
|
const schluesselTon = s === null ? 'grau' : s.angenommen === true ? 'gruen' : s.angenommen === null ? 'grau' : 'rot'
|
||||||
const arbeit = verbindung.speicher?.arbeit ?? null
|
const arbeit = verbindung.speicher?.arbeit ?? null
|
||||||
const speicherTon = arbeit === null ? 'grau' : !arbeit.ok ? 'rot' : arbeit.freiBytes < SEHR_WENIG_FREI_BYTES ? 'rot' : arbeit.freiBytes < WENIG_FREI_BYTES ? 'warn' : 'gruen'
|
const speicherTon = arbeit === null ? 'grau' : !arbeit.ok ? 'rot' : arbeit.freiBytes < SEHR_WENIG_FREI_BYTES ? 'rot' : arbeit.freiBytes < WENIG_FREI_BYTES ? 'warn' : 'gruen'
|
||||||
const updateTon = haupt === null ? 'grau' : haupt.update.neueVersion.length > 0 ? 'warn' : haupt.update.letzteErfolgreichePruefung.length > 0 ? 'gruen' : 'grau'
|
const updateTon = haupt === null ? 'grau' : haupt.update.neueVersion.length > 0 ? 'warn' : haupt.update.letzteErfolgreichePruefung.length > 0 ? 'gruen' : 'grau'
|
||||||
|
|||||||
@@ -32,9 +32,14 @@ export function dauerGrob(s: number): string {
|
|||||||
return `${Math.floor(m / 60)} h ${String(m % 60).padStart(2, '0')} min`
|
return `${Math.floor(m / 60)} h ${String(m % 60).padStart(2, '0')} min`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** ISO-Zeit → „2026-09-12 21:30" in DEINER Uhrzeit (5.7.0, Durchsicht F12):
|
||||||
|
* vorher wurde der UTC-Text abgeschnitten — 21:30 Uhr stand als 19:30 da. */
|
||||||
export function datumKurz(iso: string | null): string {
|
export function datumKurz(iso: string | null): string {
|
||||||
if (iso === null || iso.length < 16) return '—'
|
if (iso === null || iso.length < 16) return '—'
|
||||||
return iso.slice(0, 16).replace('T', ' ')
|
const d = new Date(iso)
|
||||||
|
if (Number.isNaN(d.getTime())) return iso.slice(0, 16).replace('T', ' ')
|
||||||
|
const z = (n: number): string => String(n).padStart(2, '0')
|
||||||
|
return `${d.getFullYear()}-${z(d.getMonth() + 1)}-${z(d.getDate())} ${z(d.getHours())}:${z(d.getMinutes())}`
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Restzeit in Sekunden als Klartext (5.6.0, HandBrakes ETA): -1/fehlend =
|
/** Restzeit in Sekunden als Klartext (5.6.0, HandBrakes ETA): -1/fehlend =
|
||||||
@@ -370,6 +375,23 @@ export function Feld({
|
|||||||
}) {
|
}) {
|
||||||
const [entwurf, setEntwurf] = useState(wert)
|
const [entwurf, setEntwurf] = useState(wert)
|
||||||
useEffect(() => setEntwurf(wert), [wert])
|
useEffect(() => setEntwurf(wert), [wert])
|
||||||
|
// 5.7.0 (Durchsicht F18): Gespeichert wird beim Verlassen, bei Enter UND
|
||||||
|
// wenn das Feld verschwindet (Bereichswechsel, Dialog zu) — vorher ging
|
||||||
|
// Tipparbeit verloren, wenn man statt zu klicken Enter drückte oder in
|
||||||
|
// einen anderen Bereich wechselte. Die Refs tragen den jüngsten Stand in
|
||||||
|
// die Aufräum-Funktion des Effekts.
|
||||||
|
const juengster = useRef({ entwurf, wert, aufAendern })
|
||||||
|
juengster.current = { entwurf, wert, aufAendern }
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
const j = juengster.current
|
||||||
|
if (j.entwurf !== j.wert) j.aufAendern(j.entwurf)
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
const speichern = (): void => {
|
||||||
|
if (entwurf !== wert) aufAendern(entwurf)
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-xs font-semibold text-dunst">{name}</span>
|
<span className="text-xs font-semibold text-dunst">{name}</span>
|
||||||
@@ -379,8 +401,9 @@ export function Feld({
|
|||||||
value={entwurf}
|
value={entwurf}
|
||||||
placeholder={platzhalter}
|
placeholder={platzhalter}
|
||||||
onChange={(e) => setEntwurf(e.target.value)}
|
onChange={(e) => setEntwurf(e.target.value)}
|
||||||
onBlur={() => {
|
onBlur={speichern}
|
||||||
if (entwurf !== wert) aufAendern(entwurf)
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') speichern()
|
||||||
}}
|
}}
|
||||||
className={FELD_KLASSE}
|
className={FELD_KLASSE}
|
||||||
/>
|
/>
|
||||||
@@ -460,10 +483,28 @@ export function DialogRahmen({
|
|||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
const hintergrund = useHintergrundKlick(schliessen)
|
const hintergrund = useHintergrundKlick(schliessen)
|
||||||
|
// 5.7.0 (Durchsicht F19): Escape schließt, der Dialog ist als solcher
|
||||||
|
// ausgezeichnet (role/aria-modal) und bekommt den Fokus — ohne das blieb
|
||||||
|
// die Tastatur auf der Seite dahinter, und Escape tat nichts.
|
||||||
|
const karte = useRef<HTMLDivElement>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
const taste = (e: KeyboardEvent): void => {
|
||||||
|
if (e.key === 'Escape') schliessen()
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', taste)
|
||||||
|
// Fokus in den Dialog — aber nicht aus einem Eingabefeld heraus, das
|
||||||
|
// ein Kind schon per autoFocus genommen hat.
|
||||||
|
if (document.activeElement === null || document.activeElement === document.body) karte.current?.focus()
|
||||||
|
return () => window.removeEventListener('keydown', taste)
|
||||||
|
}, [schliessen])
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-6 backdrop-blur-sm" {...hintergrund}>
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-6 backdrop-blur-sm" {...hintergrund}>
|
||||||
<div
|
<div
|
||||||
className={`max-h-[85vh] w-full ${breite} overflow-y-auto rounded-2xl border border-tinte/12 bg-buehne p-5 shadow-2xl shadow-black/60`}
|
ref={karte}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
tabIndex={-1}
|
||||||
|
className={`max-h-[85vh] w-full ${breite} overflow-y-auto rounded-2xl border border-tinte/12 bg-buehne p-5 shadow-2xl shadow-black/60 focus:outline-none`}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -9,6 +9,10 @@
|
|||||||
// vorgewählt ist (weiche Spur, nie eingebrannt, immer optional) — und je
|
// vorgewählt ist (weiche Spur, nie eingebrannt, immer optional) — und je
|
||||||
// Titel „anderer Film …": Double Feature, Trilogie-Disc, Bonus-Film bekommen
|
// Titel „anderer Film …": Double Feature, Trilogie-Disc, Bonus-Film bekommen
|
||||||
// eigenen Roh-Ordner, Vorgang und Zielordner.
|
// eigenen Roh-Ordner, Vorgang und Zielordner.
|
||||||
|
// 5.7.0 (Commander, 12.09.2026): JEDE Spur einzeln anhaken — „Deutsch ·
|
||||||
|
// DTS-HD MA · 5.1" und „Deutsch · DD · 2.0 (Kommentar)" getrennt, Untertitel
|
||||||
|
// genauso. Die Sprach-Chips bleiben nur als Rückfall, wenn ein Titel keine
|
||||||
|
// einzelnen Spuren meldet.
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { sichererName, zielVorschau } from '../../gemeinsam/ablagepfad'
|
import { sichererName, zielVorschau } from '../../gemeinsam/ablagepfad'
|
||||||
import { platzUrteil } from '../../gemeinsam/platz'
|
import { platzUrteil } from '../../gemeinsam/platz'
|
||||||
@@ -19,11 +23,13 @@ import type {
|
|||||||
MetaVorschlag,
|
MetaVorschlag,
|
||||||
SpeicherStand,
|
SpeicherStand,
|
||||||
SprachEintrag,
|
SprachEintrag,
|
||||||
|
SpurAngabe,
|
||||||
TitelRolle,
|
TitelRolle,
|
||||||
TitelStand,
|
TitelStand,
|
||||||
} from '../../gemeinsam/nachrichten'
|
} from '../../gemeinsam/nachrichten'
|
||||||
import { DialogRahmen, IconDisc, KNOPF_GOLD, dauerText, gb, gbKurz, posterUrlKlein, sprachenVorbelegt } from '../bausteine'
|
import { angeboten, sprachenDerSpuren, spurenVorbelegt, spurLabel } from '../../gemeinsam/spuren'
|
||||||
import { metadatenSuchen, ripStarten } from '../kernverbindung'
|
import { DialogRahmen, IconDisc, KNOPF_GOLD, datumKurz, dauerText, gb, gbKurz, posterUrlKlein, sprachenVorbelegt } from '../bausteine'
|
||||||
|
import { metadatenSuchen, ripStarten, titelAbbrechen } from '../kernverbindung'
|
||||||
|
|
||||||
/** Sprach-Chips eines Titels — anklickbar, außerhalb des Auswahl-Labels
|
/** Sprach-Chips eines Titels — anklickbar, außerhalb des Auswahl-Labels
|
||||||
* (ein Knopf IM Label würde die Checkbox mit umschalten). */
|
* (ein Knopf IM Label würde die Checkbox mit umschalten). */
|
||||||
@@ -64,6 +70,53 @@ function SprachChips({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Spur-Zeilen eines Titels (5.7.0): jede Ton- bzw. Untertitelspur einzeln
|
||||||
|
* anhaken, mit Codec und Kanälen, wie MakeMKV sie gemessen hat. Außerhalb
|
||||||
|
* des Auswahl-Labels (ein Haken IM Label würde den Titel mit umschalten). */
|
||||||
|
function SpurZeilen({
|
||||||
|
name,
|
||||||
|
art,
|
||||||
|
vorhanden,
|
||||||
|
gewaehlt,
|
||||||
|
aufWechsel,
|
||||||
|
}: {
|
||||||
|
name: string
|
||||||
|
art: 'audio' | 'untertitel'
|
||||||
|
vorhanden: readonly SpurAngabe[]
|
||||||
|
gewaehlt: readonly number[]
|
||||||
|
aufWechsel: (nr: number) => void
|
||||||
|
}) {
|
||||||
|
const zeilen = vorhanden.filter((s) => angeboten(s, art))
|
||||||
|
if (zeilen.length === 0) return null
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 flex-col gap-0.5" data-spuren={art}>
|
||||||
|
<span className="font-mono text-[10px] tracking-wider text-nebel">{name}</span>
|
||||||
|
{zeilen.map((s) => {
|
||||||
|
const an = gewaehlt.includes(s.nr)
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={s.nr}
|
||||||
|
className={`inline-flex cursor-pointer items-center gap-1.5 font-mono text-[10.5px] ${an ? 'text-gold-hell' : 'text-nebel hover:text-dunst'}`}
|
||||||
|
title={s.beschreibung.length > 0 ? s.beschreibung : spurLabel(s)}
|
||||||
|
>
|
||||||
|
<input type="checkbox" checked={an} onChange={() => aufWechsel(s.nr)} className="accent-[oklch(0.83_0.11_75)]" />
|
||||||
|
{spurLabel(s)}
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Was Rippys Regel für diesen Titel vorwählen würde (dieselbe Regel wie
|
||||||
|
* kern/ablauf/auftrag.ts spurwahl, 5.7.0): Spielt die erste gewählte
|
||||||
|
* Tonspur nicht in der Wunschsprache, die erste gewählte Untertitelspur
|
||||||
|
* der Wunschsprache — sonst nichts. */
|
||||||
|
function regelVorwahlSpur(audio: readonly SpurAngabe[], untertitel: readonly SpurAngabe[], wunsch: string): SpurAngabe | undefined {
|
||||||
|
if (wunsch.length === 0 || audio.length === 0 || audio[0].lang.toLowerCase() === wunsch) return undefined
|
||||||
|
return untertitel.find((s) => s.lang.toLowerCase() === wunsch)
|
||||||
|
}
|
||||||
|
|
||||||
/** Was Rippys Regel für diesen Titel vorwählen würde (dieselbe Regel wie
|
/** Was Rippys Regel für diesen Titel vorwählen würde (dieselbe Regel wie
|
||||||
* kern/ablauf/auftrag.ts sprachwahl) — '' wenn nichts. */
|
* kern/ablauf/auftrag.ts sprachwahl) — '' wenn nichts. */
|
||||||
function regelVorwahl(audio: readonly string[], untertitel: readonly string[], wunsch: string): string {
|
function regelVorwahl(audio: readonly string[], untertitel: readonly string[], wunsch: string): string {
|
||||||
@@ -105,6 +158,12 @@ export function TitelWahlDialog({
|
|||||||
// 5.6.0: Untertitel-Vorwahl je Extra — fehlt: Rippys Regel; null: keine;
|
// 5.6.0: Untertitel-Vorwahl je Extra — fehlt: Rippys Regel; null: keine;
|
||||||
// sonst die Sprache. Immer optional, nie eingebrannt.
|
// sonst die Sprache. Immer optional, nie eingebrannt.
|
||||||
const [vorwahl, setVorwahl] = useState<Record<number, string | null>>({})
|
const [vorwahl, setVorwahl] = useState<Record<number, string | null>>({})
|
||||||
|
// 5.7.0: Spuren je Titel EINZELN (MakeMKV-Stream-Nummern). Was hier nicht
|
||||||
|
// drinsteht, ist vorbelegt: je Wunschsprache die erste Tonspur, alle
|
||||||
|
// Untertitel der Wunschsprachen (gemeinsam/spuren.ts). Dazu die
|
||||||
|
// Untertitel-Vorwahl je Extra als SPUR — fehlt: Regel; null: keine.
|
||||||
|
const [spuren, setSpuren] = useState<Record<number, { audio: number[]; untertitel: number[] }>>({})
|
||||||
|
const [vorwahlSpur, setVorwahlSpur] = useState<Record<number, number | null>>({})
|
||||||
// 5.6.0: Titel, die zu einem anderen Film gehören.
|
// 5.6.0: Titel, die zu einem anderen Film gehören.
|
||||||
const [filme, setFilme] = useState<Record<number, MetaVorschlag>>({})
|
const [filme, setFilme] = useState<Record<number, MetaVorschlag>>({})
|
||||||
const [filmSuche, setFilmSuche] = useState<{ nr: number; text: string } | null>(null)
|
const [filmSuche, setFilmSuche] = useState<{ nr: number; text: string } | null>(null)
|
||||||
@@ -130,6 +189,31 @@ export function TitelWahlDialog({
|
|||||||
const liste = jetzt[art].includes(lang) ? jetzt[art].filter((l) => l !== lang) : [...jetzt[art], lang]
|
const liste = jetzt[art].includes(lang) ? jetzt[art].filter((l) => l !== lang) : [...jetzt[art], lang]
|
||||||
setSprachen({ ...sprachen, [t.nr]: { ...jetzt, [art]: liste } })
|
setSprachen({ ...sprachen, [t.nr]: { ...jetzt, [art]: liste } })
|
||||||
}
|
}
|
||||||
|
const wunschListe = (schluessel: string): string[] =>
|
||||||
|
(einstellungen[schluessel] ?? '')
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim().toLowerCase())
|
||||||
|
.filter((s) => s.length > 0)
|
||||||
|
/** Meldet dieser Titel einzelne Spuren? Sonst gelten die Sprach-Chips. */
|
||||||
|
const spurenDa = (t: DiscTitelVorschlag): boolean => t.spuren !== undefined && (t.spuren.audio.length > 0 || t.spuren.untertitel.length > 0)
|
||||||
|
const spurenVon = (t: DiscTitelVorschlag): { audio: number[]; untertitel: number[] } =>
|
||||||
|
spuren[t.nr] ?? {
|
||||||
|
audio: spurenVorbelegt(t.spuren?.audio ?? [], wunschListe('audioSprachen'), 'audio'),
|
||||||
|
untertitel: spurenVorbelegt(t.spuren?.untertitel ?? [], wunschListe('untertitelSprachen'), 'untertitel'),
|
||||||
|
}
|
||||||
|
const spurWechsel = (t: DiscTitelVorschlag, art: 'audio' | 'untertitel', nr: number): void => {
|
||||||
|
const jetzt = spurenVon(t)
|
||||||
|
const liste = jetzt[art].includes(nr) ? jetzt[art].filter((n) => n !== nr) : [...jetzt[art], nr].sort((a, b) => a - b)
|
||||||
|
setSpuren({ ...spuren, [t.nr]: { ...jetzt, [art]: liste } })
|
||||||
|
}
|
||||||
|
/** Die gewählten Spuren als Angaben, in Disc-Reihenfolge. */
|
||||||
|
const gewaehlteSpuren = (t: DiscTitelVorschlag): { audio: SpurAngabe[]; untertitel: SpurAngabe[] } => {
|
||||||
|
const nrn = spurenVon(t)
|
||||||
|
return {
|
||||||
|
audio: (t.spuren?.audio ?? []).filter((s) => nrn.audio.includes(s.nr)),
|
||||||
|
untertitel: (t.spuren?.untertitel ?? []).filter((s) => nrn.untertitel.includes(s.nr)),
|
||||||
|
}
|
||||||
|
}
|
||||||
const umschalten = (nr: number): void => {
|
const umschalten = (nr: number): void => {
|
||||||
const neu = new Set(aktiv)
|
const neu = new Set(aktiv)
|
||||||
if (neu.has(nr)) neu.delete(nr)
|
if (neu.has(nr)) neu.delete(nr)
|
||||||
@@ -181,9 +265,12 @@ export function TitelWahlDialog({
|
|||||||
<h3 className="font-display text-xl font-bold text-schnee">Was soll gerippt werden?</h3>
|
<h3 className="font-display text-xl font-bold text-schnee">Was soll gerippt werden?</h3>
|
||||||
|
|
||||||
{stand === undefined || stand.laeuft ? (
|
{stand === undefined || stand.laeuft ? (
|
||||||
<p className="mt-4 font-mono text-xs text-nebel">
|
<div className="mt-4">
|
||||||
Rippy liest die Titel der Disc … das dauert bis zu zwei Minuten. Solange dreht das Laufwerk.
|
<p className="font-mono text-xs text-nebel">Rippy liest die Titel der Disc … meist dauert das ein bis zwei Minuten. Solange dreht das Laufwerk.</p>
|
||||||
</p>
|
{/* 5.7.0 (Kandidat 2): Ab der zweiten Minute sagt der Kern, wie lange
|
||||||
|
es schon läuft und wo die Grenze liegt. */}
|
||||||
|
{stand !== undefined && stand.einordnung.length > 0 && <p className="mt-2 font-mono text-xs text-warn">{stand.einordnung}</p>}
|
||||||
|
</div>
|
||||||
) : stand.fehler.length > 0 ? (
|
) : stand.fehler.length > 0 ? (
|
||||||
<p className="mt-4 font-mono text-xs text-rot">✗ {stand.fehler}</p>
|
<p className="mt-4 font-mono text-xs text-rot">✗ {stand.fehler}</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -228,6 +315,10 @@ export function TitelWahlDialog({
|
|||||||
const film = filme[t.nr]
|
const film = filme[t.nr]
|
||||||
const regelWahl = regelVorwahl(wahl.audio.length > 0 ? wahl.audio : t.sprachen.audio.map((s) => s.lang), wahl.untertitel, wunschSprache)
|
const regelWahl = regelVorwahl(wahl.audio.length > 0 ? wahl.audio : t.sprachen.audio.map((s) => s.lang), wahl.untertitel, wunschSprache)
|
||||||
const vorwahlWert = t.nr in vorwahl ? (vorwahl[t.nr] === null ? 'keine' : vorwahl[t.nr]!) : 'regel'
|
const vorwahlWert = t.nr in vorwahl ? (vorwahl[t.nr] === null ? 'keine' : vorwahl[t.nr]!) : 'regel'
|
||||||
|
const spurNrn = spurenVon(t)
|
||||||
|
const spurWahl = gewaehlteSpuren(t)
|
||||||
|
const regelSpur = regelVorwahlSpur(spurWahl.audio, spurWahl.untertitel, wunschSprache)
|
||||||
|
const vorwahlSpurWert = t.nr in vorwahlSpur ? (vorwahlSpur[t.nr] === null ? 'keine' : String(vorwahlSpur[t.nr])) : 'regel'
|
||||||
return (
|
return (
|
||||||
<div key={t.nr} className={`rounded-lg border ${an ? 'border-gold/40 bg-gold/8' : 'border-tinte/8 bg-samt/40'}`}>
|
<div key={t.nr} className={`rounded-lg border ${an ? 'border-gold/40 bg-gold/8' : 'border-tinte/8 bg-samt/40'}`}>
|
||||||
<label className="flex cursor-pointer items-center gap-3 p-2">
|
<label className="flex cursor-pointer items-center gap-3 p-2">
|
||||||
@@ -252,10 +343,56 @@ export function TitelWahlDialog({
|
|||||||
<span className="min-w-0 flex-1 truncate text-[11px] text-nebel">{t.grund}</span>
|
<span className="min-w-0 flex-1 truncate text-[11px] text-nebel">{t.grund}</span>
|
||||||
)}
|
)}
|
||||||
</label>
|
</label>
|
||||||
|
{/* Spuren je Titel (5.7.0): jede Ton- und Untertitelspur, die
|
||||||
|
der Info-Lauf an DIESEM Titel gemessen hat — einzeln
|
||||||
|
anhaken, was in die Datei soll. */}
|
||||||
|
{an && spurenDa(t) && (
|
||||||
|
<div className="flex flex-wrap items-start gap-x-6 gap-y-1.5 border-t border-tinte/8 px-2 py-1.5 pl-9">
|
||||||
|
<SpurZeilen name="TON" art="audio" vorhanden={t.spuren.audio} gewaehlt={spurNrn.audio} aufWechsel={(nr) => spurWechsel(t, 'audio', nr)} />
|
||||||
|
<SpurZeilen
|
||||||
|
name="UNTERTITEL"
|
||||||
|
art="untertitel"
|
||||||
|
vorhanden={t.spuren.untertitel}
|
||||||
|
gewaehlt={spurNrn.untertitel}
|
||||||
|
aufWechsel={(nr) => spurWechsel(t, 'untertitel', nr)}
|
||||||
|
/>
|
||||||
|
{spurNrn.audio.length === 0 && t.spuren.audio.some((s) => angeboten(s, 'audio')) && (
|
||||||
|
<span className="font-mono text-[10.5px] text-warn">keine Tonspur gewählt — HandBrake nimmt die erste Spur</span>
|
||||||
|
)}
|
||||||
|
{/* 5.6.0/5.7.0: Vorwahl NUR bei Extras, IMMER optional — eine
|
||||||
|
weiche Spur, die der Spieler von selbst einblendet;
|
||||||
|
eingebrannt wird nie. Hier als SPUR. */}
|
||||||
|
{rolle === 'extra' && spurWahl.untertitel.length > 0 && (
|
||||||
|
<label className="inline-flex items-center gap-1.5" title="Weiche Untertitel-Spur, die beim Abspielen schon an ist — nichts wird eingebrannt. Leer lassen ist immer erlaubt.">
|
||||||
|
<span className="font-mono text-[10px] tracking-wider text-nebel">BEIM ABSPIELEN AN</span>
|
||||||
|
<select
|
||||||
|
value={vorwahlSpurWert}
|
||||||
|
onChange={(e) => {
|
||||||
|
const w = e.target.value
|
||||||
|
const neu = { ...vorwahlSpur }
|
||||||
|
if (w === 'regel') delete neu[t.nr]
|
||||||
|
else neu[t.nr] = w === 'keine' ? null : Number(w)
|
||||||
|
setVorwahlSpur(neu)
|
||||||
|
}}
|
||||||
|
className="rounded-md border border-tinte/15 bg-samt/70 px-1.5 py-0.5 font-mono text-[10.5px] text-dunst"
|
||||||
|
>
|
||||||
|
<option value="regel">{regelSpur !== undefined ? `Rippys Regel (${spurLabel(regelSpur)})` : 'Rippys Regel (keine)'}</option>
|
||||||
|
<option value="keine">keine</option>
|
||||||
|
{spurWahl.untertitel.map((u) => (
|
||||||
|
<option key={u.nr} value={String(u.nr)}>
|
||||||
|
{spurLabel(u)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{/* Sprachen je Titel (5.4.0): Was der Info-Lauf an DIESEM
|
{/* Sprachen je Titel (5.4.0): Was der Info-Lauf an DIESEM
|
||||||
Titel gemessen hat — anhaken, was in die Datei soll.
|
Titel gemessen hat — anhaken, was in die Datei soll.
|
||||||
Der Commander-Fall: japanischer Ton, deutsche Untertitel. */}
|
Der Commander-Fall: japanischer Ton, deutsche Untertitel.
|
||||||
{an && (t.sprachen.audio.length > 0 || t.sprachen.untertitel.length > 0) && (
|
Seit 5.7.0 nur noch der Rückfall ohne einzelne Spuren. */}
|
||||||
|
{an && !spurenDa(t) && (t.sprachen.audio.length > 0 || t.sprachen.untertitel.length > 0) && (
|
||||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 border-t border-tinte/8 px-2 py-1.5 pl-9">
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 border-t border-tinte/8 px-2 py-1.5 pl-9">
|
||||||
<SprachChips name="TON" vorhanden={t.sprachen.audio} gewaehlt={wahl.audio} aufWechsel={(lang) => sprachWechsel(t, 'audio', lang)} />
|
<SprachChips name="TON" vorhanden={t.sprachen.audio} gewaehlt={wahl.audio} aufWechsel={(lang) => sprachWechsel(t, 'audio', lang)} />
|
||||||
<SprachChips name="UNTERTITEL" vorhanden={t.sprachen.untertitel} gewaehlt={wahl.untertitel} aufWechsel={(lang) => sprachWechsel(t, 'untertitel', lang)} />
|
<SprachChips name="UNTERTITEL" vorhanden={t.sprachen.untertitel} gewaehlt={wahl.untertitel} aufWechsel={(lang) => sprachWechsel(t, 'untertitel', lang)} />
|
||||||
@@ -410,7 +547,7 @@ export function TitelWahlDialog({
|
|||||||
</label>
|
</label>
|
||||||
<p className="min-w-[200px] flex-1 text-[11px] leading-4 text-nebel">
|
<p className="min-w-[200px] flex-1 text-[11px] leading-4 text-nebel">
|
||||||
{merkerGilt && merker !== null
|
{merkerGilt && merker !== null
|
||||||
? `Serien-Merker: Die vorige Disc dieser Serie endete mit Folge ${merker.naechsteFolge - 1} (${merker.wann.slice(0, 10)}) — Rippy zählt ab ${merker.naechsteFolge} weiter. Stimmt das nicht, ändere die Zahl.`
|
? `Serien-Merker: Die vorige Disc dieser Serie endete mit Folge ${merker.naechsteFolge - 1} (${datumKurz(merker.wann).slice(0, 10)}) — Rippy zählt ab ${merker.naechsteFolge} weiter. Stimmt das nicht, ändere die Zahl.`
|
||||||
: 'Leer heißt: Rippy ordnet die Folgen über die TMDb-Laufzeiten zu, und nur, wenn es eindeutig ist. Weißt du es besser („auf Disc 2 sind die Folgen 3 und 4"), trag es hier ein — dann zählt Rippy fortlaufend in Disc-Reihenfolge.'}
|
: 'Leer heißt: Rippy ordnet die Folgen über die TMDb-Laufzeiten zu, und nur, wenn es eindeutig ist. Weißt du es besser („auf Disc 2 sind die Folgen 3 und 4"), trag es hier ein — dann zählt Rippy fortlaufend in Disc-Reihenfolge.'}
|
||||||
{info !== undefined && info.discNr !== null && ` Die Disc nennt sich selbst Disc ${info.discNr}.`}
|
{info !== undefined && info.discNr !== null && ` Die Disc nennt sich selbst Disc ${info.discNr}.`}
|
||||||
</p>
|
</p>
|
||||||
@@ -463,7 +600,15 @@ export function TitelWahlDialog({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mt-5 flex justify-between gap-2">
|
<div className="mt-5 flex justify-between gap-2">
|
||||||
<button onClick={schliessen} className="rounded-lg border border-tinte/15 px-4 py-1.5 text-xs text-dunst hover:text-schnee">
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
// Läuft der Titel-Lauf noch, beendet „Abbrechen" ihn auch im Kern —
|
||||||
|
// sonst dreht das Laufwerk weiter, obwohl der Dialog zu ist.
|
||||||
|
if (stand !== undefined && stand.laeuft) titelAbbrechen(id)
|
||||||
|
schliessen()
|
||||||
|
}}
|
||||||
|
className="rounded-lg border border-tinte/15 px-4 py-1.5 text-xs text-dunst hover:text-schnee"
|
||||||
|
>
|
||||||
Abbrechen
|
Abbrechen
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@@ -473,6 +618,24 @@ export function TitelWahlDialog({
|
|||||||
gewaehlteTitel.map((t) => {
|
gewaehlteTitel.map((t) => {
|
||||||
const wahl = sprachenVon(t)
|
const wahl = sprachenVon(t)
|
||||||
const rolle = rolleVon(t)
|
const rolle = rolleVon(t)
|
||||||
|
// 5.7.0: Spuren einzeln — die Sprachlisten reisen abgeleitet
|
||||||
|
// mit (Rückfall des Kerns, wenn der Scan der Roh-Datei
|
||||||
|
// scheitert; und die alten Hinweise bleiben stimmig).
|
||||||
|
if (spurenDa(t)) {
|
||||||
|
const spurWahl = gewaehlteSpuren(t)
|
||||||
|
return {
|
||||||
|
nr: t.nr,
|
||||||
|
rolle,
|
||||||
|
dauerS: t.dauerS,
|
||||||
|
groesseBytes: t.groesseBytes,
|
||||||
|
audioSprachen: sprachenDerSpuren(spurWahl.audio),
|
||||||
|
untertitelSprachen: sprachenDerSpuren(spurWahl.untertitel),
|
||||||
|
audioSpuren: spurWahl.audio,
|
||||||
|
untertitelSpuren: spurWahl.untertitel,
|
||||||
|
...(rolle === 'extra' && t.nr in vorwahlSpur ? { untertitelVorwahlSpur: vorwahlSpur[t.nr] } : {}),
|
||||||
|
...(filme[t.nr] !== undefined ? { film: filme[t.nr] } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
nr: t.nr,
|
nr: t.nr,
|
||||||
rolle,
|
rolle,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
// Werkzeuge mit Durchsuchen, Kompression mit Sprach-Auswahl, TMDb,
|
// Werkzeuge mit Durchsuchen, Kompression mit Sprach-Auswahl, TMDb,
|
||||||
// Benachrichtigung, Automatik und Roh-Regel. Dazu der Poster-Hintergrund.
|
// Benachrichtigung, Automatik und Roh-Regel. Dazu der Poster-Hintergrund.
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { DEFAULT_HB_PRESET, presetEmpfehlung } from '../../gemeinsam/preset-empfehlung'
|
import { DEFAULT_HB_PRESET, empfehlungText, presetEmpfehlung } from '../../gemeinsam/preset-empfehlung'
|
||||||
import { Badge, FELD_KLASSE, Feld, KNOPF_GOLD, KNOPF_LEISE, PfadFeld, Schalter, gbKurz } from '../bausteine'
|
import { Badge, FELD_KLASSE, Feld, KNOPF_GOLD, KNOPF_LEISE, PfadFeld, Schalter, gbKurz } from '../bausteine'
|
||||||
import { SprachAuswahl } from '../einstellungen/SprachAuswahl'
|
import { SprachAuswahl } from '../einstellungen/SprachAuswahl'
|
||||||
import { benachrichtigungTesten, einstellungSetzen, posterLaden, speicherLaden, type Verbindung } from '../kernverbindung'
|
import { benachrichtigungTesten, einstellungSetzen, posterLaden, speicherLaden, type Verbindung } from '../kernverbindung'
|
||||||
@@ -167,7 +167,7 @@ export function ErstEinrichtung({ verbindung }: { verbindung: Verbindung }) {
|
|||||||
{EMPFEHLUNGEN.map(([name, typ]) => (
|
{EMPFEHLUNGEN.map(([name, typ]) => (
|
||||||
<div key={typ} className="rounded-lg border border-tinte/8 bg-buehne/60 p-2">
|
<div key={typ} className="rounded-lg border border-tinte/8 bg-buehne/60 p-2">
|
||||||
<p className="font-mono text-[10px] tracking-wider text-nebel">{name}</p>
|
<p className="font-mono text-[10px] tracking-wider text-nebel">{name}</p>
|
||||||
<p className="mt-0.5 font-mono text-xs text-gold-hell">{presetEmpfehlung(typ, backends, presets) || DEFAULT_HB_PRESET}</p>
|
<p className="mt-0.5 font-mono text-xs text-gold-hell">{empfehlungText(typ, presetEmpfehlung(typ, backends, presets) || DEFAULT_HB_PRESET)}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
// einstellungSetzen in die EINE Quelle (§ 6.7).
|
// einstellungSetzen in die EINE Quelle (§ 6.7).
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import type { GeraeteInfo, HauptStatus, VorgangEintrag } from '../../gemeinsam/nachrichten'
|
import type { GeraeteInfo, HauptStatus, VorgangEintrag } from '../../gemeinsam/nachrichten'
|
||||||
import { presetEmpfehlung } from '../../gemeinsam/preset-empfehlung'
|
import { empfehlungText, presetEmpfehlung } from '../../gemeinsam/preset-empfehlung'
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
FELD_KLASSE,
|
FELD_KLASSE,
|
||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
} from '../bausteine'
|
} from '../bausteine'
|
||||||
import {
|
import {
|
||||||
benachrichtigungTesten,
|
benachrichtigungTesten,
|
||||||
|
beschaffungAntworten,
|
||||||
einstellungSetzen,
|
einstellungSetzen,
|
||||||
fremdenOrdnerLoeschen,
|
fremdenOrdnerLoeschen,
|
||||||
kompressionAbbrechen,
|
kompressionAbbrechen,
|
||||||
@@ -126,7 +127,7 @@ export function KompressionKarte({ verbindung }: { verbindung: Verbindung }) {
|
|||||||
const werte = verbindung.einstellungen
|
const werte = verbindung.einstellungen
|
||||||
const presets = verbindung.werkzeuge?.presets ?? []
|
const presets = verbindung.werkzeuge?.presets ?? []
|
||||||
const backends = verbindung.werkzeuge?.backends ?? []
|
const backends = verbindung.werkzeuge?.backends ?? []
|
||||||
const empfehlungFuer = (typ: string): string => presetEmpfehlung(typ, backends, presets)
|
const empfehlungFuer = (typ: string): string => empfehlungText(typ, presetEmpfehlung(typ, backends, presets))
|
||||||
return (
|
return (
|
||||||
<SektionsKarte
|
<SektionsKarte
|
||||||
id="kompression"
|
id="kompression"
|
||||||
@@ -152,7 +153,8 @@ export function KompressionKarte({ verbindung }: { verbindung: Verbindung }) {
|
|||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] leading-4 text-nebel">
|
<p className="text-[11px] leading-4 text-nebel">
|
||||||
„automatisch" nimmt die Empfehlung aus der gemessenen Liste deines HandBrake — auf dieser Maschine die Grafikkarte, wo es sie gibt. Eine eigene Wahl
|
„automatisch" nimmt die Empfehlung aus der gemessenen Liste deines HandBrake — auf dieser Maschine die Grafikkarte, wo es sie gibt. Eine eigene Wahl
|
||||||
schlägt die Empfehlung immer.
|
schlägt die Empfehlung immer. 4K-UHD wird nur mit Grafikkarten-Encoder komprimiert (8 Bit); ohne bleibt es verlustfrei. Wer HDR in 10 Bit will, wählt
|
||||||
|
„AV1 VCN 2160p 4K" — die Abspielgeräte müssen dann AV1 können.
|
||||||
</p>
|
</p>
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
<SprachAuswahl
|
<SprachAuswahl
|
||||||
@@ -198,6 +200,9 @@ export function RohDateienKarte({ verbindung, aktivFuer }: { verbindung: Verbind
|
|||||||
const v = verbindung.vorgaenge
|
const v = verbindung.vorgaenge
|
||||||
const [presetWahl, setPresetWahl] = useState<Record<string, string>>({})
|
const [presetWahl, setPresetWahl] = useState<Record<string, string>>({})
|
||||||
const [alleZeigen, setAlleZeigen] = useState(aktivFuer === true)
|
const [alleZeigen, setAlleZeigen] = useState(aktivFuer === true)
|
||||||
|
// 5.7.0 (Durchsicht F17): Löschen fragt nach — ein Klick war vorher 30 GB.
|
||||||
|
// Der erste Klick macht aus dem Knopf die Rückfrage, der zweite löscht.
|
||||||
|
const [loeschfrage, setLoeschfrage] = useState<string | null>(null)
|
||||||
const presets = verbindung.werkzeuge?.presets ?? []
|
const presets = verbindung.werkzeuge?.presets ?? []
|
||||||
const regelWert = werte['rohAufbewahrung'] ?? '7'
|
const regelWert = werte['rohAufbewahrung'] ?? '7'
|
||||||
const phasenText: Record<VorgangEintrag['phase'], string> = {
|
const phasenText: Record<VorgangEintrag['phase'], string> = {
|
||||||
@@ -312,11 +317,28 @@ export function RohDateienKarte({ verbindung, aktivFuer }: { verbindung: Verbind
|
|||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{!e.rohGeloescht && ruhig && (
|
{!e.rohGeloescht && ruhig && loeschfrage !== e.id && (
|
||||||
<button onClick={() => vorgangRohLoeschen(e.id)} className="rounded-lg border border-rot/40 bg-rot/10 px-3 py-1 text-[11px] text-rot hover:bg-rot/20" title="Den Roh-Ordner dieses Vorgangs jetzt löschen">
|
<button onClick={() => setLoeschfrage(e.id)} className="rounded-lg border border-rot/40 bg-rot/10 px-3 py-1 text-[11px] text-rot hover:bg-rot/20" title="Den Roh-Ordner dieses Vorgangs löschen — fragt erst nach">
|
||||||
Roh-Dateien löschen
|
Roh-Dateien löschen …
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{!e.rohGeloescht && ruhig && loeschfrage === e.id && (
|
||||||
|
<span className="inline-flex flex-wrap items-center gap-2 rounded-lg border border-rot/50 bg-rot/10 px-2 py-1 text-[11px] text-rot">
|
||||||
|
{gb(e.rohBytes)} unter {e.rohOrdner} unwiderruflich löschen?
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setLoeschfrage(null)
|
||||||
|
vorgangRohLoeschen(e.id)
|
||||||
|
}}
|
||||||
|
className="rounded-md bg-rot px-2 py-0.5 font-semibold text-buehne hover:brightness-110"
|
||||||
|
>
|
||||||
|
Ja, löschen
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setLoeschfrage(null)} className="rounded-md border border-tinte/20 px-2 py-0.5 text-dunst hover:text-schnee">
|
||||||
|
Nein
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{e.phase === 'fertig' && e.zielOrdner.length > 0 && (
|
{e.phase === 'fertig' && e.zielOrdner.length > 0 && (
|
||||||
<button onClick={() => void window.rippy.ordnerOeffnen(e.zielOrdner)} className="rounded-lg border border-tinte/15 px-3 py-1 text-[11px] text-dunst hover:text-schnee">
|
<button onClick={() => void window.rippy.ordnerOeffnen(e.zielOrdner)} className="rounded-lg border border-tinte/15 px-3 py-1 text-[11px] text-dunst hover:text-schnee">
|
||||||
Ordner öffnen
|
Ordner öffnen
|
||||||
@@ -348,9 +370,27 @@ export function RohDateienKarte({ verbindung, aktivFuer }: { verbindung: Verbind
|
|||||||
<span className="min-w-0 break-all font-mono text-[11px] text-dunst">
|
<span className="min-w-0 break-all font-mono text-[11px] text-dunst">
|
||||||
{f.ordner} · {gb(f.bytes)}
|
{f.ordner} · {gb(f.bytes)}
|
||||||
</span>
|
</span>
|
||||||
<button onClick={() => fremdenOrdnerLoeschen(f.ordner)} className="flex-none rounded-lg border border-rot/40 bg-rot/10 px-2.5 py-0.5 text-[10.5px] text-rot hover:bg-rot/20">
|
{loeschfrage !== f.ordner ? (
|
||||||
Löschen
|
<button onClick={() => setLoeschfrage(f.ordner)} className="flex-none rounded-lg border border-rot/40 bg-rot/10 px-2.5 py-0.5 text-[10.5px] text-rot hover:bg-rot/20">
|
||||||
</button>
|
Löschen …
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="flex flex-none items-center gap-1.5 text-[10.5px] text-rot">
|
||||||
|
wirklich?
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setLoeschfrage(null)
|
||||||
|
fremdenOrdnerLoeschen(f.ordner)
|
||||||
|
}}
|
||||||
|
className="rounded-md bg-rot px-2 py-0.5 font-semibold text-buehne hover:brightness-110"
|
||||||
|
>
|
||||||
|
Ja
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setLoeschfrage(null)} className="rounded-md border border-tinte/20 px-2 py-0.5 text-dunst hover:text-schnee">
|
||||||
|
Nein
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -406,16 +446,19 @@ export function MakemkvKarte({ verbindung }: { verbindung: Verbindung }) {
|
|||||||
<p className="font-mono text-xs text-nebel">Schlüssel wird geprüft …</p>
|
<p className="font-mono text-xs text-nebel">Schlüssel wird geprüft …</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
{/* 5.7.0 (F1/F5): Ein abgelehnter oder fehlender Schlüssel ist
|
||||||
|
ROT — Blu-rays laufen dann nur noch in MakeMKVs Gnadenfrist.
|
||||||
|
„Nicht prüfbar" (kein Laufwerk) ist grau, kein Erfolg. */}
|
||||||
<p className="flex items-center gap-2 font-mono text-[10.5px] tracking-wider">
|
<p className="flex items-center gap-2 font-mono text-[10.5px] tracking-wider">
|
||||||
<span className={`inline-block h-2 w-2 rounded-full ${s.angenommen === true ? 'bg-gruen' : s.weiterBetrieb ? 'bg-warn' : 'bg-rot'}`} />
|
<span className={`inline-block h-2 w-2 rounded-full ${s.angenommen === true ? 'bg-gruen' : s.angenommen === null ? 'bg-tinte/25' : 'bg-rot'}`} />
|
||||||
<span className={s.angenommen === true ? 'text-gruen' : s.weiterBetrieb ? 'text-warn' : 'text-rot'}>
|
<span className={s.angenommen === true ? 'text-gruen' : s.angenommen === null ? 'text-nebel' : 'text-rot'}>
|
||||||
{s.angenommen === true ? 'SCHLÜSSEL ANGENOMMEN' : s.weiterBetrieb ? 'BETA-MODUS — RIPS LAUFEN' : 'AUSFALL'}
|
{s.angenommen === true ? 'SCHLÜSSEL ANGENOMMEN' : s.angenommen === null ? 'NICHT PRÜFBAR — KEIN LAUFWERK' : 'KEIN GÜLTIGER SCHLÜSSEL — BLU-RAY-RIPS LAUFEN NICHT'}
|
||||||
</span>
|
</span>
|
||||||
{s.version.length > 0 && <span className="ml-auto text-nebel">MakeMKV {s.version}</span>}
|
{s.version.length > 0 && <span className="ml-auto text-nebel">MakeMKV {s.version}</span>}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1.5 text-xs leading-5 text-dunst">{s.text}</p>
|
<p className="mt-1.5 text-xs leading-5 text-dunst">{s.text}</p>
|
||||||
<p className="mt-1 font-mono text-[10px] text-nebel">
|
<p className="mt-1 font-mono text-[10px] text-nebel">
|
||||||
zuletzt geprüft: {s.geholtAm.slice(0, 16).replace('T', ' ')}
|
zuletzt geprüft: {datumKurz(s.geholtAm)}
|
||||||
{s.gueltigBis.length > 0 && ` · Key gültig bis ${s.gueltigBis}`}
|
{s.gueltigBis.length > 0 && ` · Key gültig bis ${s.gueltigBis}`}
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
@@ -439,6 +482,19 @@ export function MakemkvKarte({ verbindung }: { verbindung: Verbindung }) {
|
|||||||
{b !== null && (
|
{b !== null && (
|
||||||
<div className="rounded-lg border border-tinte/10 bg-buehne/70 p-2.5">
|
<div className="rounded-lg border border-tinte/10 bg-buehne/70 p-2.5">
|
||||||
<p className={`font-mono text-[11px] leading-5 ${b.fehler ? 'text-rot' : b.laeuft ? 'text-gold-hell' : 'text-gruen'}`}>{b.text}</p>
|
<p className={`font-mono text-[11px] leading-5 ${b.fehler ? 'text-rot' : b.laeuft ? 'text-gold-hell' : 'text-gruen'}`}>{b.text}</p>
|
||||||
|
{b.frage !== undefined && b.frage.length > 0 && (
|
||||||
|
<div className="mt-2 rounded-lg border border-warn/45 bg-warn/10 p-2.5">
|
||||||
|
<p className="text-xs leading-5 text-dunst">{b.frage}</p>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
|
<button onClick={() => beschaffungAntworten(true)} className="rounded-lg border border-warn/50 bg-warn/15 px-3 py-1 text-[11px] font-semibold text-warn hover:bg-warn/25">
|
||||||
|
Archiv-Kopie laden
|
||||||
|
</button>
|
||||||
|
<button onClick={() => beschaffungAntworten(false)} className={KNOPF_LEISE}>
|
||||||
|
Lieber nicht
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{b.laeuft && b.anteil !== null && (
|
{b.laeuft && b.anteil !== null && (
|
||||||
<div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-tinte/8">
|
<div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-tinte/8">
|
||||||
<div className="h-full rounded-full bg-gradient-to-r from-gold-tief to-gold-hell transition-all" style={{ width: `${Math.round(b.anteil * 100)}%` }} />
|
<div className="h-full rounded-full bg-gradient-to-r from-gold-tief to-gold-hell transition-all" style={{ width: `${Math.round(b.anteil * 100)}%` }} />
|
||||||
@@ -448,11 +504,14 @@ export function MakemkvKarte({ verbindung }: { verbindung: Verbindung }) {
|
|||||||
)}
|
)}
|
||||||
<Feld
|
<Feld
|
||||||
name="Eigener Schlüssel (Dauerlizenz)"
|
name="Eigener Schlüssel (Dauerlizenz)"
|
||||||
hinweis="schlägt den automatischen Beta-Key — leer lassen, wenn keiner gekauft ist"
|
hinweis="schlägt den automatischen Beta-Key — leer lassen, wenn keiner gekauft ist; nach dem Eintragen prüft Rippy sofort"
|
||||||
geheim
|
geheim
|
||||||
wert={werte['makemkvAppKey'] ?? ''}
|
wert={werte['makemkvAppKey'] ?? ''}
|
||||||
aufAendern={(w) => setzen('makemkvAppKey', w)}
|
aufAendern={(w) => setzen('makemkvAppKey', w)}
|
||||||
/>
|
/>
|
||||||
|
<p className="text-[11px] leading-4 text-nebel">
|
||||||
|
Rippy schreibt in MakeMKVs eigene Einstellungen: den Schlüssel, die Auswahlregel „alle Spuren behalten" und den Kanal für 4K-Disc-Schlüssel. Deshalb nimmt auch das MakeMKV-Programm seitdem alle Tonspuren und Untertitel mit — die Dateien dort werden größer, verloren geht nichts.
|
||||||
|
</p>
|
||||||
</SektionsKarte>
|
</SektionsKarte>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -519,6 +578,17 @@ export function AllgemeinKarte({ verbindung, haupt }: { verbindung: Verbindung;
|
|||||||
return (
|
return (
|
||||||
<SektionsKarte id="allgemein" titel="ALLGEMEIN" intro="Rippy lebt im Infobereich neben der Uhr: Fenster zu heißt weiterlaufen, beendet wird über das Tray-Menü.">
|
<SektionsKarte id="allgemein" titel="ALLGEMEIN" intro="Rippy lebt im Infobereich neben der Uhr: Fenster zu heißt weiterlaufen, beendet wird über das Tray-Menü.">
|
||||||
<Schalter an={haupt?.autostart === true} aufWechsel={(an) => void window.rippy.autostartSetzen(an)} label="Rippy mit Windows starten (Autostart im Benutzerkonto — kein Dienst, keine Adminrechte)" />
|
<Schalter an={haupt?.autostart === true} aufWechsel={(an) => void window.rippy.autostartSetzen(an)} label="Rippy mit Windows starten (Autostart im Benutzerkonto — kein Dienst, keine Adminrechte)" />
|
||||||
|
<label className="block max-w-sm">
|
||||||
|
<span className="text-xs font-semibold text-dunst">Titel lesen — höchstens</span>
|
||||||
|
<select value={werte['titelLesenMinuten'] ?? '15'} onChange={(e) => setzen('titelLesenMinuten', e.target.value)} className={FELD_KLASSE}>
|
||||||
|
<option value="5">5 Minuten</option>
|
||||||
|
<option value="10">10 Minuten</option>
|
||||||
|
<option value="15">15 Minuten (Vorgabe)</option>
|
||||||
|
<option value="30">30 Minuten</option>
|
||||||
|
<option value="60">60 Minuten</option>
|
||||||
|
</select>
|
||||||
|
<span className="mt-1 block text-[11px] leading-4 text-nebel">So lange darf MakeMKV die Titel einer Disc lesen. Kopiergeschützte DVDs und Discs mit vielen Playlisten brauchen im MakeMKV-Programm oft länger als fünf Minuten — braucht eine Disc dort länger als hier eingestellt, erhöhe den Wert.</span>
|
||||||
|
</label>
|
||||||
<label className="block max-w-sm">
|
<label className="block max-w-sm">
|
||||||
<span className="text-xs font-semibold text-dunst">Erscheinungsbild</span>
|
<span className="text-xs font-semibold text-dunst">Erscheinungsbild</span>
|
||||||
<select value={werte['design'] ?? 'dunkel'} onChange={(e) => setzen('design', e.target.value)} className={FELD_KLASSE}>
|
<select value={werte['design'] ?? 'dunkel'} onChange={(e) => setzen('design', e.target.value)} className={FELD_KLASSE}>
|
||||||
@@ -571,7 +641,7 @@ export function UpdateKarte({ verbindung, haupt }: { verbindung: Verbindung; hau
|
|||||||
<p>
|
<p>
|
||||||
<span className="text-dunst">Stand:</span> {haupt.update.text}
|
<span className="text-dunst">Stand:</span> {haupt.update.text}
|
||||||
</p>
|
</p>
|
||||||
{haupt.update.letzteErfolgreichePruefung.length > 0 && <p>letzte erfolgreiche Prüfung: {haupt.update.letzteErfolgreichePruefung.slice(0, 16).replace('T', ' ')}</p>}
|
{haupt.update.letzteErfolgreichePruefung.length > 0 && <p>letzte erfolgreiche Prüfung: {datumKurz(haupt.update.letzteErfolgreichePruefung)}</p>}
|
||||||
{haupt.update.neueVersion.length > 0 && <p className="text-gold-hell">Version {haupt.update.neueVersion} gefunden — die Karte in der Übersicht zeigt, was neu ist.</p>}
|
{haupt.update.neueVersion.length > 0 && <p className="text-gold-hell">Version {haupt.update.neueVersion} gefunden — die Karte in der Übersicht zeigt, was neu ist.</p>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -131,6 +131,11 @@ function meldung(text: string): void {
|
|||||||
function kernNachricht(nachricht: KernNachricht): void {
|
function kernNachricht(nachricht: KernNachricht): void {
|
||||||
switch (nachricht.art) {
|
switch (nachricht.art) {
|
||||||
case 'kern-bereit':
|
case 'kern-bereit':
|
||||||
|
// 5.7.0 (F4): Eine neue PID heißt, der Kern wurde neu gestartet — das
|
||||||
|
// steht jetzt im Fenster, statt dass die Laufwerke stumm einfrieren.
|
||||||
|
if (stand.kern !== null && stand.kern.pid !== nachricht.pid) {
|
||||||
|
meldung(`⚠ Der Kern wurde neu gestartet (PID ${nachricht.pid}) — Rippy hat das Fenster wieder verbunden. Ein laufender Rip ist damit abgebrochen; der Grund steht im Protokoll (Einstellungen → Allgemein).`)
|
||||||
|
}
|
||||||
stand.kern = nachricht
|
stand.kern = nachricht
|
||||||
break
|
break
|
||||||
case 'pong':
|
case 'pong':
|
||||||
@@ -208,6 +213,10 @@ function kernNachricht(nachricht: KernNachricht): void {
|
|||||||
case 'vorgaenge':
|
case 'vorgaenge':
|
||||||
stand.vorgaenge = nachricht.stand
|
stand.vorgaenge = nachricht.stand
|
||||||
break
|
break
|
||||||
|
case 'kompression-stand':
|
||||||
|
// 5.7.0 (F7): nur die Warteschlange frisch — der Rest bleibt stehen.
|
||||||
|
if (stand.vorgaenge !== null) stand.vorgaenge = { ...stand.vorgaenge, kompression: nachricht.kompression }
|
||||||
|
break
|
||||||
case 'kompression-ergebnis':
|
case 'kompression-ergebnis':
|
||||||
meldung(`${nachricht.ok ? '' : '⚠ '}Kompression „${nachricht.titel}": ${nachricht.text}`)
|
meldung(`${nachricht.ok ? '' : '⚠ '}Kompression „${nachricht.titel}": ${nachricht.text}`)
|
||||||
break
|
break
|
||||||
@@ -266,6 +275,15 @@ function kernNachricht(nachricht: KernNachricht): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function portAnnehmen(neuerPort: MessagePort): void {
|
function portAnnehmen(neuerPort: MessagePort): void {
|
||||||
|
// Ein zweiter Port kommt nach einem Kern-Neustart (5.7.0, F4) — der alte
|
||||||
|
// ist tot und wird geschlossen, damit nichts mehr ins Leere geht.
|
||||||
|
if (port !== null && port !== neuerPort) {
|
||||||
|
try {
|
||||||
|
port.close()
|
||||||
|
} catch {
|
||||||
|
// Ein toter Port lässt sich nicht immer schließen — er ist ohnehin weg.
|
||||||
|
}
|
||||||
|
}
|
||||||
port = neuerPort
|
port = neuerPort
|
||||||
stand.verbunden = true
|
stand.verbunden = true
|
||||||
neuerPort.onmessage = (ereignis: MessageEvent) => {
|
neuerPort.onmessage = (ereignis: MessageEvent) => {
|
||||||
@@ -329,6 +347,11 @@ export function titelLesen(id: string): void {
|
|||||||
anKern({ art: 'titel-lesen', id }, `Titel lesen ${id}`)
|
anKern({ art: 'titel-lesen', id }, `Titel lesen ${id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 5.7.0: den laufenden Titel-Lauf beenden — makemkvcon gibt das Laufwerk frei. */
|
||||||
|
export function titelAbbrechen(id: string): void {
|
||||||
|
anKern({ art: 'titel-abbruch', id }, `Titel-Lauf abbrechen ${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
/** `auftrag` leer heißt: alles rippen (wie vor 5.2). */
|
/** `auftrag` leer heißt: alles rippen (wie vor 5.2). */
|
||||||
export function ripStarten(id: string, auftrag: RipAuftragTitel[] = [], optionen: RipAuftragOptionen = {}): void {
|
export function ripStarten(id: string, auftrag: RipAuftragTitel[] = [], optionen: RipAuftragOptionen = {}): void {
|
||||||
anKern({ art: 'rip-start', id, auftrag, optionen }, `Rip ${id}`)
|
anKern({ art: 'rip-start', id, auftrag, optionen }, `Rip ${id}`)
|
||||||
@@ -363,6 +386,11 @@ export function schluesselPruefen(): void {
|
|||||||
senden({ art: 'schluessel-pruefen' })
|
senden({ art: 'schluessel-pruefen' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 5.7.0 (F16): Ja/Nein auf eine Frage der MakeMKV-Beschaffung. */
|
||||||
|
export function beschaffungAntworten(ja: boolean): void {
|
||||||
|
anKern({ art: 'beschaffung-antwort', ja }, 'Antwort an die Beschaffung')
|
||||||
|
}
|
||||||
|
|
||||||
export function einstellungSetzen(schluessel: string, wert: string): void {
|
export function einstellungSetzen(schluessel: string, wert: string): void {
|
||||||
anKern({ art: 'einstellung-setzen', schluessel, wert }, `Einstellung ${schluessel}`)
|
anKern({ art: 'einstellung-setzen', schluessel, wert }, `Einstellung ${schluessel}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import type {
|
|||||||
RipStatus,
|
RipStatus,
|
||||||
VorgangEintrag,
|
VorgangEintrag,
|
||||||
} from '../../gemeinsam/nachrichten'
|
} from '../../gemeinsam/nachrichten'
|
||||||
import { DEFAULT_HB_PRESET, presetEmpfehlung } from '../../gemeinsam/preset-empfehlung'
|
import { DEFAULT_HB_PRESET, PRESET_KEINE, empfehlungText, presetEmpfehlung } from '../../gemeinsam/preset-empfehlung'
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
IconDisc,
|
IconDisc,
|
||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
PHASEN_MONO,
|
PHASEN_MONO,
|
||||||
TYP_NAMEN,
|
TYP_NAMEN,
|
||||||
backdropUrl,
|
backdropUrl,
|
||||||
|
datumKurz,
|
||||||
discHerkunft,
|
discHerkunft,
|
||||||
gb,
|
gb,
|
||||||
logoUrl,
|
logoUrl,
|
||||||
@@ -57,9 +58,11 @@ export function presetAnzeige(typ: GeraeteInfo['typ'], verbindung: Verbindung):
|
|||||||
if (werte['transcodeEnabled'] === 'false') return 'ohne Kompression'
|
if (werte['transcodeEnabled'] === 'false') return 'ohne Kompression'
|
||||||
const schluessel = typ === 'dvd' ? 'transcodePresetDvd' : typ === 'uhd' ? 'transcodePresetUhd' : 'transcodePresetBluray'
|
const schluessel = typ === 'dvd' ? 'transcodePresetDvd' : typ === 'uhd' ? 'transcodePresetUhd' : 'transcodePresetBluray'
|
||||||
const eigene = werte[schluessel] ?? ''
|
const eigene = werte[schluessel] ?? ''
|
||||||
|
if (eigene === PRESET_KEINE) return 'ohne Kompression'
|
||||||
|
if (eigene.length > 0) return eigene
|
||||||
const empfehlung = presetEmpfehlung(typ, verbindung.werkzeuge?.backends ?? [], verbindung.werkzeuge?.presets ?? [])
|
const empfehlung = presetEmpfehlung(typ, verbindung.werkzeuge?.backends ?? [], verbindung.werkzeuge?.presets ?? [])
|
||||||
const gewaehlt = eigene.length > 0 ? eigene : empfehlung.length > 0 ? empfehlung : DEFAULT_HB_PRESET
|
if (empfehlung === PRESET_KEINE) return typ === 'uhd' ? 'verlustfrei (nur CPU-Encoder)' : 'ohne Kompression'
|
||||||
return gewaehlt === 'keine' ? 'ohne Kompression' : gewaehlt
|
return empfehlungText(typ, empfehlung.length > 0 ? empfehlung : DEFAULT_HB_PRESET)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LaufwerkKachel({
|
export function LaufwerkKachel({
|
||||||
@@ -158,7 +161,7 @@ export function LaufwerkKachel({
|
|||||||
onClick={aufRippen}
|
onClick={aufRippen}
|
||||||
disabled={!discDa}
|
disabled={!discDa}
|
||||||
className="inline-flex items-center gap-2 rounded-xl bg-gradient-to-b from-gold-hell to-gold-tief px-5 py-1.5 text-xs font-bold text-buehne shadow-[inset_0_1px_0_oklch(1_0_0/0.4),0_8px_24px_oklch(0.75_0.13_60/0.3)] hover:from-gold-hell hover:to-gold disabled:cursor-not-allowed disabled:opacity-35 disabled:shadow-none"
|
className="inline-flex items-center gap-2 rounded-xl bg-gradient-to-b from-gold-hell to-gold-tief px-5 py-1.5 text-xs font-bold text-buehne shadow-[inset_0_1px_0_oklch(1_0_0/0.4),0_8px_24px_oklch(0.75_0.13_60/0.3)] hover:from-gold-hell hover:to-gold disabled:cursor-not-allowed disabled:opacity-35 disabled:shadow-none"
|
||||||
title="Titel der Disc lesen und auswaehlen — danach verlustfrei rippen"
|
title="Titel der Disc lesen und auswählen — danach verlustfrei rippen"
|
||||||
>
|
>
|
||||||
<IconPlay />
|
<IconPlay />
|
||||||
Rippen
|
Rippen
|
||||||
@@ -294,7 +297,7 @@ export function LaufwerkKachel({
|
|||||||
)}
|
)}
|
||||||
{erkannt && <Badge text={`${sicherheitsWort(info.confidence)}`} ton={info.confidence >= 0.8 ? 'gruen' : 'gelb'} />}
|
{erkannt && <Badge text={`${sicherheitsWort(info.confidence)}`} ton={info.confidence >= 0.8 ? 'gruen' : 'gelb'} />}
|
||||||
{info !== undefined && discHerkunft(info).length > 0 && <Badge text={discHerkunft(info)} ton="blau" />}
|
{info !== undefined && discHerkunft(info).length > 0 && <Badge text={discHerkunft(info)} ton="blau" />}
|
||||||
{info !== undefined && info.schonGerippt !== null && <Badge text={`schon gerippt am ${info.schonGerippt.slice(0, 10)}`} ton="gelb" />}
|
{info !== undefined && info.schonGerippt !== null && <Badge text={`schon gerippt am ${datumKurz(info.schonGerippt).slice(0, 10)}`} ton="gelb" />}
|
||||||
</div>
|
</div>
|
||||||
{/* Was Rippy NACH dem Info-Lauf dazugelernt hat (Erkennung v2). */}
|
{/* Was Rippy NACH dem Info-Lauf dazugelernt hat (Erkennung v2). */}
|
||||||
{erkannt && info.hinweise.length > 0 && (
|
{erkannt && info.hinweise.length > 0 && (
|
||||||
@@ -410,9 +413,9 @@ export function LaufwerkKachel({
|
|||||||
<button
|
<button
|
||||||
onClick={() => ripUeberspringen(geraet.id)}
|
onClick={() => ripUeberspringen(geraet.id)}
|
||||||
className="rounded-lg border border-warn/50 bg-warn/15 px-3 py-1 text-[11px] font-semibold text-warn hover:bg-warn/25"
|
className="rounded-lg border border-warn/50 bg-warn/15 px-3 py-1 text-[11px] font-semibold text-warn hover:bg-warn/25"
|
||||||
title="Diesen Titel aufgeben und mit dem naechsten weitermachen"
|
title="Diesen Titel aufgeben und mit dem nächsten weitermachen"
|
||||||
>
|
>
|
||||||
Titel ueberspringen
|
Titel überspringen
|
||||||
</button>
|
</button>
|
||||||
<span className="font-mono text-[10.5px] text-nebel">oder weiter versuchen — abbrechen geht oben.</span>
|
<span className="font-mono text-[10.5px] text-nebel">oder weiter versuchen — abbrechen geht oben.</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -98,6 +98,30 @@ export interface SprachEintrag {
|
|||||||
spuren: number
|
spuren: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** EINE Spur eines Titels, wie der Info-Lauf sie gemessen hat (5.7.0,
|
||||||
|
* Commander-Entscheid 12.09.2026: „jede Spur einzeln anhaken"). Die
|
||||||
|
* Attribute stammen aus SINFO (parser.ts): 3 Sprachcode, 4 Sprachname,
|
||||||
|
* 6 Codec, 14 Kanäle, 2 Anordnung, 30 Beschreibung, 22 Flags. */
|
||||||
|
export interface SpurAngabe {
|
||||||
|
/** MakeMKVs Stream-Nummer innerhalb des Titels. */
|
||||||
|
nr: number
|
||||||
|
lang: string
|
||||||
|
sprache: string
|
||||||
|
/** „DTS-HD MA", „TrueHD", „DD", „PGS", „VobSub" … */
|
||||||
|
codec: string
|
||||||
|
/** Kanalzahl als Text („6", „2"); leer bei Untertiteln. */
|
||||||
|
kanaele: string
|
||||||
|
/** „Surround 5.1", „Stereo" — Attribut 2. */
|
||||||
|
layout: string
|
||||||
|
/** Attribut 30, z. B. „DTS-HD MA Surround 5.1 German" oder „PGS German (nur erzwungene)". */
|
||||||
|
beschreibung: string
|
||||||
|
/** Bit 0x800 in Attribut 22 (an der Kizuna-Disc gemessen): eine von
|
||||||
|
* MakeMKV ABGELEITETE Spur — der DTS-Kern einer HD-Spur (nicht in der
|
||||||
|
* Roh-Datei) oder die „nur erzwungene"-Fassung eines Untertitels (in der
|
||||||
|
* Roh-Datei, wählbar). */
|
||||||
|
abgeleitet: boolean
|
||||||
|
}
|
||||||
|
|
||||||
/** Ein Titel im Rip-Auftrag: welcher, als was — und wie lang.
|
/** Ein Titel im Rip-Auftrag: welcher, als was — und wie lang.
|
||||||
* Die Dauer reist mit, weil die Episoden-Zuordnung sie braucht: Rippy
|
* Die Dauer reist mit, weil die Episoden-Zuordnung sie braucht: Rippy
|
||||||
* gleicht die Laufzeiten gegen die Staffel bei TMDb ab und benennt NUR
|
* gleicht die Laufzeiten gegen die Staffel bei TMDb ab und benennt NUR
|
||||||
@@ -117,6 +141,12 @@ export interface RipAuftragTitel {
|
|||||||
* vorgewählt ist (weiche Spur, nie eingebrannt). null = keine Vorwahl;
|
* vorgewählt ist (weiche Spur, nie eingebrannt). null = keine Vorwahl;
|
||||||
* fehlend = Rippys Regel (fremder Ton → Wunschsprache). */
|
* fehlend = Rippys Regel (fremder Ton → Wunschsprache). */
|
||||||
untertitelVorwahl?: string | null
|
untertitelVorwahl?: string | null
|
||||||
|
/** 5.7.0: die GEWÄHLTEN Spuren mit allem, was die Ablage braucht, um sie
|
||||||
|
* in der Roh-Datei wiederzufinden. Fehlend: die Sprachlisten gelten. */
|
||||||
|
audioSpuren?: SpurAngabe[]
|
||||||
|
untertitelSpuren?: SpurAngabe[]
|
||||||
|
/** 5.7.0: die vorgewählte Untertitel-SPUR (MakeMKV-Nummer); null = keine. */
|
||||||
|
untertitelVorwahlSpur?: number | null
|
||||||
/** 5.6.0: Dieser Titel gehört zu einem ANDEREN Film als die Disc-Zuordnung
|
/** 5.6.0: Dieser Titel gehört zu einem ANDEREN Film als die Disc-Zuordnung
|
||||||
* (Double Feature) — er bekommt eigenen Roh-Ordner, Vorgang und Zielordner. */
|
* (Double Feature) — er bekommt eigenen Roh-Ordner, Vorgang und Zielordner. */
|
||||||
film?: MetaVorschlag
|
film?: MetaVorschlag
|
||||||
@@ -147,6 +177,8 @@ export interface DiscTitelVorschlag {
|
|||||||
grund: string
|
grund: string
|
||||||
/** Die gemessenen Tonspuren und Untertitel DIESES Titels (5.4.0). */
|
/** Die gemessenen Tonspuren und Untertitel DIESES Titels (5.4.0). */
|
||||||
sprachen: { audio: SprachEintrag[]; untertitel: SprachEintrag[] }
|
sprachen: { audio: SprachEintrag[]; untertitel: SprachEintrag[] }
|
||||||
|
/** 5.7.0: jede Spur einzeln — der Dialog zeigt sie zum Anhaken. */
|
||||||
|
spuren: { audio: SpurAngabe[]; untertitel: SpurAngabe[] }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Was der Info-Lauf über die Titel der Disc ergab. Er dauert 20–120 s,
|
/** Was der Info-Lauf über die Titel der Disc ergab. Er dauert 20–120 s,
|
||||||
@@ -257,6 +289,9 @@ export interface BeschaffungStand {
|
|||||||
/** 0–1 während des Downloads; null wenn gerade nicht bezifferbar. */
|
/** 0–1 während des Downloads; null wenn gerade nicht bezifferbar. */
|
||||||
anteil: number | null
|
anteil: number | null
|
||||||
fehler: boolean
|
fehler: boolean
|
||||||
|
/** 5.7.0 (F16): Rippy wartet auf ein Ja/Nein — z. B. „Archiv-Kopie laden?".
|
||||||
|
* Fehlt: keine Frage offen. */
|
||||||
|
frage?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Vorgänge und Kompressions-Warteschlange (5.4.0) ─────────────────────
|
// ── Vorgänge und Kompressions-Warteschlange (5.4.0) ─────────────────────
|
||||||
@@ -414,6 +449,9 @@ export type KernNachricht =
|
|||||||
/** Alle Vorgänge, die Roh-Belegung und die Kompressions-Warteschlange
|
/** Alle Vorgänge, die Roh-Belegung und die Kompressions-Warteschlange
|
||||||
* (5.4.0) — geht auch ans Haupt (Taskleiste, Updates warten). */
|
* (5.4.0) — geht auch ans Haupt (Taskleiste, Updates warten). */
|
||||||
| { art: 'vorgaenge'; stand: VorgaengeStand }
|
| { art: 'vorgaenge'; stand: VorgaengeStand }
|
||||||
|
/** 5.7.0 (F7): NUR die Warteschlange — jeder Prozent-Tick der Kompression
|
||||||
|
* kommt so, ohne den teuren vollen Vorgangs-Stand (Ordnerläufe, statfs). */
|
||||||
|
| { art: 'kompression-stand'; kompression: VorgaengeStand['kompression'] }
|
||||||
/** Eine Kompression ist zu Ende — fürs Toast und die Ereignis-Zeile. */
|
/** Eine Kompression ist zu Ende — fürs Toast und die Ereignis-Zeile. */
|
||||||
| { art: 'kompression-ergebnis'; vorgangId: string; titel: string; ok: boolean; text: string }
|
| { art: 'kompression-ergebnis'; vorgangId: string; titel: string; ok: boolean; text: string }
|
||||||
| { art: 'laufwerk-gesundheit'; id: string; bericht: LaufwerkBericht }
|
| { art: 'laufwerk-gesundheit'; id: string; bericht: LaufwerkBericht }
|
||||||
@@ -432,6 +470,8 @@ export type FensterNachricht =
|
|||||||
* und nicht schon beim Einlegen (der 30.08.-Fund „das Erkennen dauert
|
* und nicht schon beim Einlegen (der 30.08.-Fund „das Erkennen dauert
|
||||||
* sehr sehr lange"). */
|
* sehr sehr lange"). */
|
||||||
| { art: 'titel-lesen'; id: string }
|
| { art: 'titel-lesen'; id: string }
|
||||||
|
/** 5.7.0: den laufenden Titel-Lauf abbrechen (Kandidat 2). */
|
||||||
|
| { art: 'titel-abbruch'; id: string }
|
||||||
/** `auftrag` leer heißt: alles rippen — wie vor 5.2. */
|
/** `auftrag` leer heißt: alles rippen — wie vor 5.2. */
|
||||||
| { art: 'rip-start'; id: string; auftrag?: RipAuftragTitel[]; optionen?: RipAuftragOptionen }
|
| { art: 'rip-start'; id: string; auftrag?: RipAuftragTitel[]; optionen?: RipAuftragOptionen }
|
||||||
| { art: 'rip-abbruch'; id: string }
|
| { art: 'rip-abbruch'; id: string }
|
||||||
@@ -447,6 +487,8 @@ export type FensterNachricht =
|
|||||||
| { art: 'makemkv-update' }
|
| { art: 'makemkv-update' }
|
||||||
/** Schlüsselkette sofort neu laufen lassen (z. B. nach einer Installation). */
|
/** Schlüsselkette sofort neu laufen lassen (z. B. nach einer Installation). */
|
||||||
| { art: 'schluessel-pruefen' }
|
| { art: 'schluessel-pruefen' }
|
||||||
|
/** 5.7.0 (F16): Antwort auf eine Frage der MakeMKV-Beschaffung. */
|
||||||
|
| { art: 'beschaffung-antwort'; ja: boolean }
|
||||||
// ── 5.4.0: Vorgänge, Roh-Dateien, Warteschlange, Laufwerks-Gesundheit ──
|
// ── 5.4.0: Vorgänge, Roh-Dateien, Warteschlange, Laufwerks-Gesundheit ──
|
||||||
| { art: 'vorgaenge-laden' }
|
| { art: 'vorgaenge-laden' }
|
||||||
/** Den Roh-Ordner EINES Vorgangs jetzt löschen (von Hand). */
|
/** Den Roh-Ordner EINES Vorgangs jetzt löschen (von Hand). */
|
||||||
@@ -594,6 +636,8 @@ export function istKernNachricht(wert: unknown): wert is KernNachricht {
|
|||||||
istObjekt((wert.stand as Record<string, unknown>).kompression) &&
|
istObjekt((wert.stand as Record<string, unknown>).kompression) &&
|
||||||
istObjekt((wert.stand as Record<string, unknown>).statistik)
|
istObjekt((wert.stand as Record<string, unknown>).statistik)
|
||||||
)
|
)
|
||||||
|
case 'kompression-stand':
|
||||||
|
return istObjekt(wert.kompression) && Array.isArray((wert.kompression as Record<string, unknown>).wartend)
|
||||||
case 'kompression-ergebnis':
|
case 'kompression-ergebnis':
|
||||||
return (
|
return (
|
||||||
istKennung(wert.vorgangId) &&
|
istKennung(wert.vorgangId) &&
|
||||||
@@ -673,9 +717,20 @@ export function istRipAuftragTitel(wert: unknown): wert is RipAuftragTitel {
|
|||||||
if (liste === undefined) continue
|
if (liste === undefined) continue
|
||||||
if (!Array.isArray(liste) || !liste.every(istText)) return false
|
if (!Array.isArray(liste) || !liste.every(istText)) return false
|
||||||
}
|
}
|
||||||
|
for (const feld of ['audioSpuren', 'untertitelSpuren']) {
|
||||||
|
const liste = wert[feld]
|
||||||
|
if (liste === undefined) continue
|
||||||
|
if (!Array.isArray(liste) || !liste.every(istSpurAngabe)) return false
|
||||||
|
}
|
||||||
|
if (wert.untertitelVorwahlSpur !== undefined && wert.untertitelVorwahlSpur !== null && typeof wert.untertitelVorwahlSpur !== 'number') return false
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function istSpurAngabe(wert: unknown): wert is SpurAngabe {
|
||||||
|
if (!istObjekt(wert)) return false
|
||||||
|
return typeof wert.nr === 'number' && typeof wert.lang === 'string' && typeof wert.codec === 'string' && typeof wert.abgeleitet === 'boolean'
|
||||||
|
}
|
||||||
|
|
||||||
export function istFensterNachricht(wert: unknown): wert is FensterNachricht {
|
export function istFensterNachricht(wert: unknown): wert is FensterNachricht {
|
||||||
if (!istObjekt(wert)) return false
|
if (!istObjekt(wert)) return false
|
||||||
if (wert.art === 'ping') return typeof wert.zeit === 'number'
|
if (wert.art === 'ping') return typeof wert.zeit === 'number'
|
||||||
@@ -695,6 +750,7 @@ export function istFensterNachricht(wert: unknown): wert is FensterNachricht {
|
|||||||
if (
|
if (
|
||||||
wert.art === 'auswerfen' ||
|
wert.art === 'auswerfen' ||
|
||||||
wert.art === 'titel-lesen' ||
|
wert.art === 'titel-lesen' ||
|
||||||
|
wert.art === 'titel-abbruch' ||
|
||||||
wert.art === 'rip-abbruch' ||
|
wert.art === 'rip-abbruch' ||
|
||||||
wert.art === 'rip-ueberspringen' ||
|
wert.art === 'rip-ueberspringen' ||
|
||||||
wert.art === 'laufwerk-pruefen' ||
|
wert.art === 'laufwerk-pruefen' ||
|
||||||
@@ -707,6 +763,9 @@ export function istFensterNachricht(wert: unknown): wert is FensterNachricht {
|
|||||||
if (wert.art === 'kompression-pause') {
|
if (wert.art === 'kompression-pause') {
|
||||||
return typeof wert.an === 'boolean'
|
return typeof wert.an === 'boolean'
|
||||||
}
|
}
|
||||||
|
if (wert.art === 'beschaffung-antwort') {
|
||||||
|
return typeof wert.ja === 'boolean'
|
||||||
|
}
|
||||||
if (wert.art === 'einstellung-setzen') {
|
if (wert.art === 'einstellung-setzen') {
|
||||||
return istKennung(wert.schluessel) && typeof wert.wert === 'string'
|
return istKennung(wert.schluessel) && typeof wert.wert === 'string'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,11 @@
|
|||||||
|
|
||||||
export const DEFAULT_HB_PRESET = 'H.265 MKV 1080p30'
|
export const DEFAULT_HB_PRESET = 'H.265 MKV 1080p30'
|
||||||
|
|
||||||
|
// Reservierter Wert: „diesen Typ NICHT komprimieren". Kein echtes
|
||||||
|
// HandBrake-Preset heißt so (gegen alle 107 aus --preset-list geprüft).
|
||||||
|
// Lebt seit 5.7.0 hier, weil die Empfehlung ihn liefern kann.
|
||||||
|
export const PRESET_KEINE = 'keine'
|
||||||
|
|
||||||
const FAMILIEN: ReadonlyArray<readonly [backend: string, presetKennung: string]> = [
|
const FAMILIEN: ReadonlyArray<readonly [backend: string, presetKennung: string]> = [
|
||||||
['vce', 'VCN'],
|
['vce', 'VCN'],
|
||||||
['nvenc', 'NVENC'],
|
['nvenc', 'NVENC'],
|
||||||
@@ -25,7 +30,19 @@ const FAMILIEN: ReadonlyArray<readonly [backend: string, presetKennung: string]>
|
|||||||
]
|
]
|
||||||
|
|
||||||
/** Das empfohlene Preset für diesen Disc-Typ — aus den GEMESSENEN
|
/** Das empfohlene Preset für diesen Disc-Typ — aus den GEMESSENEN
|
||||||
* Backends und Preset-Namen. Fällt auf den CPU-Default zurück. */
|
* Backends und Preset-Namen. DVD und Blu-ray fallen auf den CPU-Default
|
||||||
|
* zurück.
|
||||||
|
*
|
||||||
|
* 4K-UHD seit 5.7.0 nach der Leistung (Commander-Entscheid 12.09.2026):
|
||||||
|
* Gibt es einen Grafikkarten-Encoder UND dessen 4K-Preset, wird damit
|
||||||
|
* komprimiert; gibt es nur CPU-Encoder, bleibt UHD verlustfrei
|
||||||
|
* (PRESET_KEINE) — ein 4K-Encode auf der CPU dauert Tage, und der alte
|
||||||
|
* CPU-Default „1080p30" rechnete 4K klein. Gemessen am 12.09.2026: Die
|
||||||
|
* 4K-Hardware-Presets („H.265 VCN 2160p 4K" usw.) sind 8-Bit; der
|
||||||
|
* 10-Bit-HEVC-Encoder der AMD-Karte (vce_h265_10bit) stürzt in HandBrake
|
||||||
|
* 1.11.2 beim Start ab, 10-Bit-AV1 („AV1 VCN 2160p 4K") läuft — wer HDR
|
||||||
|
* in 10 Bit will, wählt das AV1-Preset selbst (Abspielgeräte müssen AV1
|
||||||
|
* können). */
|
||||||
export function presetEmpfehlung(
|
export function presetEmpfehlung(
|
||||||
discTyp: string,
|
discTyp: string,
|
||||||
backends: readonly string[],
|
backends: readonly string[],
|
||||||
@@ -36,9 +53,19 @@ export function presetEmpfehlung(
|
|||||||
if (!backends.includes(backend)) continue
|
if (!backends.includes(backend)) continue
|
||||||
const treffer = presets.find((p) => p.startsWith('H.265 ') && p.includes(kennung) && p.includes(gewuenscht))
|
const treffer = presets.find((p) => p.startsWith('H.265 ') && p.includes(kennung) && p.includes(gewuenscht))
|
||||||
if (treffer !== undefined) return treffer
|
if (treffer !== undefined) return treffer
|
||||||
// 4K-Familie ohne 4K-Preset: das 1080p-Pendant ist besser als CPU.
|
// Eine 4K-Disc ohne 4K-Preset der Familie: nicht klein rechnen —
|
||||||
|
// verlustfrei lassen (unten). Bei DVD/Blu-ray ist das 1080p-Pendant
|
||||||
|
// besser als CPU.
|
||||||
|
if (discTyp === 'uhd') continue
|
||||||
const kleiner = presets.find((p) => p.startsWith('H.265 ') && p.includes(kennung))
|
const kleiner = presets.find((p) => p.startsWith('H.265 ') && p.includes(kennung))
|
||||||
if (kleiner !== undefined) return kleiner
|
if (kleiner !== undefined) return kleiner
|
||||||
}
|
}
|
||||||
return DEFAULT_HB_PRESET
|
return discTyp === 'uhd' ? PRESET_KEINE : DEFAULT_HB_PRESET
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Klartext zur Empfehlung fürs Fenster (5.7.0). */
|
||||||
|
export function empfehlungText(discTyp: string, empfehlung: string): string {
|
||||||
|
if (empfehlung === PRESET_KEINE) return discTyp === 'uhd' ? 'verlustfrei lassen — nur CPU-Encoder gemessen' : 'verlustfrei lassen'
|
||||||
|
if (discTyp === 'uhd' && /2160p/.test(empfehlung)) return `${empfehlung} (8 Bit)`
|
||||||
|
return empfehlung
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
// Spuren einzeln (5.7.0, Commander-Entscheid 12.09.2026: „Jede Spur einzeln
|
||||||
|
// anhaken — Deutsch · DTS-HD MA 5.1, Deutsch · Kommentar 2.0 getrennt").
|
||||||
|
// PUR und gemeinsam: Der Dialog zeigt die Zeilen und belegt sie vor, der
|
||||||
|
// Kern findet die gewählten Spuren nach dem Rip in der Roh-Datei wieder.
|
||||||
|
//
|
||||||
|
// Zwei Welten, ein Vergleich:
|
||||||
|
// * MakeMKV (SINFO, kern/rip/parser.ts): Sprachcode, Codec-Wort („DTS-HD
|
||||||
|
// MA"), Kanalzahl („6"), Disc-Reihenfolge.
|
||||||
|
// * HandBrake (--scan --json, kern/komprimieren/scan.ts; am 12.09.2026 am
|
||||||
|
// mitgelieferten HandBrake 1.11.2 gemessen): LanguageCode, CodecName
|
||||||
|
// („dtshd", „dca", „truehd", „ac3", „eac3", „aac", „flac", „pcm_…"),
|
||||||
|
// ChannelCount, TrackNumber — in Datei-Reihenfolge.
|
||||||
|
// MakeMKV schreibt die Spuren in Disc-Reihenfolge; Rippys Auswahlregel
|
||||||
|
// (werkzeuge/spurauswahl.ts) lässt nur die abgeleiteten DTS-Kerne weg.
|
||||||
|
// Deshalb reicht: je gewählter Spur die erste noch freie HandBrake-Spur
|
||||||
|
// gleicher Sprache, gleicher Codec-Familie und gleicher Kanalzahl — sonst
|
||||||
|
// gestuft weniger streng; findet sich nichts, sagt die Ablage es.
|
||||||
|
import type { SpurAngabe } from './nachrichten'
|
||||||
|
|
||||||
|
/** Codec-Familie aus MakeMKVs Codec-Wort (Attribut 6) oder HandBrakes
|
||||||
|
* CodecName — beide auf denselben Schlüssel. '' wenn unbekannt. */
|
||||||
|
export function codecFamilie(text: string): string {
|
||||||
|
const t = (text ?? '').toLowerCase()
|
||||||
|
if (t.length === 0) return ''
|
||||||
|
if (t.includes('dts-hd') || t.includes('dtshd') || t.includes('dts:x') || t.includes('dts hd')) return 'dtshd'
|
||||||
|
if (t === 'dca' || t.startsWith('dts')) return 'dts'
|
||||||
|
if (t.includes('truehd') || t.includes('atmos') || t.includes('mlp')) return 'truehd'
|
||||||
|
if (t.includes('eac3') || t.includes('e-ac3') || t.includes('dd+') || t.includes('digital plus')) return 'eac3'
|
||||||
|
if (t === 'ac3' || t.startsWith('dd') || t.includes('dolby digital') || t === 'ac-3') return 'ac3'
|
||||||
|
if (t.includes('aac')) return 'aac'
|
||||||
|
if (t.includes('flac')) return 'flac'
|
||||||
|
if (t.includes('pcm') || t.includes('lpcm')) return 'pcm'
|
||||||
|
if (t.includes('mp3') || t.includes('mp2') || t.includes('mpeg')) return 'mp3'
|
||||||
|
if (t.includes('opus')) return 'opus'
|
||||||
|
if (t.includes('vorbis')) return 'vorbis'
|
||||||
|
if (t.includes('pgs') || t.includes('hdmv')) return 'pgs'
|
||||||
|
if (t.includes('vobsub') || t.startsWith('dvd')) return 'vobsub'
|
||||||
|
if (t.includes('srt') || t.includes('text') || t.includes('utf-8') || t.includes('ssa') || t.includes('ass')) return 'text'
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die Zeile im Dialog: „German · DTS-HD MA · Surround 5.1", „German · DD ·
|
||||||
|
* Stereo (Kommentar)". MakeMKV nennt die Sprache englisch (Attribut 4,
|
||||||
|
* „German"), auch bei deutscher Oberfläche — gemessen an der Kizuna-Disc. */
|
||||||
|
export function spurLabel(s: SpurAngabe): string {
|
||||||
|
const teile: string[] = [s.sprache.length > 0 ? s.sprache : s.lang.length > 0 ? s.lang : 'ohne Sprache']
|
||||||
|
if (s.codec.length > 0) teile.push(s.codec)
|
||||||
|
if (s.kanaele.length > 0) {
|
||||||
|
const n = Number(s.kanaele)
|
||||||
|
const kurz = n === 6 ? '5.1' : n === 8 ? '7.1' : n === 2 ? '2.0' : n === 1 ? '1.0' : s.kanaele
|
||||||
|
// MakeMKVs Anordnung („Surround 5.1", „Stereo") sagt genug — die Zahl
|
||||||
|
// nur, wenn sie fehlt.
|
||||||
|
teile.push(s.layout.length > 0 ? s.layout : kurz)
|
||||||
|
}
|
||||||
|
let label = teile.join(' · ')
|
||||||
|
// Was MakeMKV in der Beschreibung dazusagt — „(nur erzwungene)",
|
||||||
|
// Kommentar-Hinweise — hängt in Klammern an.
|
||||||
|
const zusatz = /\(([^()]*)\)\s*$/.exec(s.beschreibung)
|
||||||
|
if (zusatz !== null && !label.toLowerCase().includes(zusatz[1].toLowerCase())) label += ` (${zusatz[1]})`
|
||||||
|
return label
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eine Zeile, die der Dialog ANBIETET: Abgeleitete TON-Spuren (DTS-Kerne)
|
||||||
|
* sind nicht in der Roh-Datei — sie werden nicht gezeigt. Abgeleitete
|
||||||
|
* UNTERTITEL („nur erzwungene") sind drin und wählbar. */
|
||||||
|
export function angeboten(s: SpurAngabe, art: 'audio' | 'untertitel'): boolean {
|
||||||
|
return art === 'untertitel' || !s.abgeleitet
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rippys Vorbelegung (pur):
|
||||||
|
* * Ton: je Wunschsprache die ERSTE angebotene Spur — das ist die Regel
|
||||||
|
* von 5.4.0 („eine Spur je Sprache", meist die beste); trifft keine
|
||||||
|
* Wunschsprache, die erste angebotene Spur überhaupt (japanischer Ton
|
||||||
|
* statt gar keiner).
|
||||||
|
* * Untertitel: ALLE Spuren der Wunschsprachen (auch „nur erzwungene") —
|
||||||
|
* wie bisher mit --all-subtitles; ohne Treffer nichts.
|
||||||
|
* Liefert MakeMKV-Stream-Nummern. */
|
||||||
|
export function spurenVorbelegt(spuren: readonly SpurAngabe[], wunsch: readonly string[], art: 'audio' | 'untertitel'): number[] {
|
||||||
|
const kandidaten = spuren.filter((s) => angeboten(s, art))
|
||||||
|
const wunschKlein = wunsch.map((w) => w.trim().toLowerCase()).filter((w) => w.length > 0)
|
||||||
|
if (art === 'audio') {
|
||||||
|
const gewaehlt: number[] = []
|
||||||
|
for (const w of wunschKlein) {
|
||||||
|
const erste = kandidaten.find((s) => s.lang.toLowerCase() === w)
|
||||||
|
if (erste !== undefined && !gewaehlt.includes(erste.nr)) gewaehlt.push(erste.nr)
|
||||||
|
}
|
||||||
|
if (gewaehlt.length === 0 && kandidaten.length > 0) gewaehlt.push(kandidaten[0].nr)
|
||||||
|
return gewaehlt
|
||||||
|
}
|
||||||
|
return kandidaten.filter((s) => wunschKlein.includes(s.lang.toLowerCase())).map((s) => s.nr)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eine Spur, wie HandBrakes Scan sie in der Roh-Datei sieht (scan.ts). */
|
||||||
|
export interface HbSpurAngabe {
|
||||||
|
nr: number
|
||||||
|
lang: string
|
||||||
|
/** CodecName („dtshd") bzw. bei Untertiteln Source („PGS"). */
|
||||||
|
codec: string
|
||||||
|
kanaele: number
|
||||||
|
beschreibung: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpurZuordnung {
|
||||||
|
/** HandBrake-Spurnummern in der Reihenfolge der gewählten Spuren. */
|
||||||
|
nummern: number[]
|
||||||
|
/** Gewählte Spuren, die in der Roh-Datei nicht zu finden waren. */
|
||||||
|
fehlend: SpurAngabe[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gewählte MakeMKV-Spuren → HandBrake-Spurnummern (pur). Gestuft: Sprache
|
||||||
|
* + Familie + Kanäle → Sprache + Familie → Sprache + Kanäle → Sprache →
|
||||||
|
* (nur wenn die Datei GAR keine Sprachen kennt) Reihenfolge. Jede
|
||||||
|
* HandBrake-Spur nur einmal. */
|
||||||
|
export function spurenZuordnen(gewaehlt: readonly SpurAngabe[], vorhanden: readonly HbSpurAngabe[]): SpurZuordnung {
|
||||||
|
const frei = new Set(vorhanden.map((v) => v.nr))
|
||||||
|
const nummern: number[] = []
|
||||||
|
const fehlend: SpurAngabe[] = []
|
||||||
|
const ohneSprachen = vorhanden.every((v) => v.lang.length === 0 || v.lang === 'und')
|
||||||
|
for (const s of gewaehlt) {
|
||||||
|
const lang = s.lang.toLowerCase()
|
||||||
|
const familie = codecFamilie(s.codec)
|
||||||
|
const kanaele = Number(s.kanaele)
|
||||||
|
const stufen: Array<(v: HbSpurAngabe) => boolean> = [
|
||||||
|
(v) => v.lang.toLowerCase() === lang && codecFamilie(v.codec) === familie && v.kanaele === kanaele,
|
||||||
|
(v) => v.lang.toLowerCase() === lang && codecFamilie(v.codec) === familie,
|
||||||
|
(v) => v.lang.toLowerCase() === lang && v.kanaele === kanaele,
|
||||||
|
(v) => v.lang.toLowerCase() === lang,
|
||||||
|
]
|
||||||
|
if (ohneSprachen) stufen.push((v) => codecFamilie(v.codec) === familie && v.kanaele === kanaele, () => true)
|
||||||
|
let treffer: HbSpurAngabe | undefined
|
||||||
|
for (const passt of stufen) {
|
||||||
|
treffer = vorhanden.find((v) => frei.has(v.nr) && passt(v))
|
||||||
|
if (treffer !== undefined) break
|
||||||
|
}
|
||||||
|
if (treffer === undefined) {
|
||||||
|
fehlend.push(s)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
frei.delete(treffer.nr)
|
||||||
|
nummern.push(treffer.nr)
|
||||||
|
}
|
||||||
|
return { nummern, fehlend }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sprachen der gewählten Spuren, ohne Doppel, in Wahl-Reihenfolge —
|
||||||
|
* der Rückfall, wenn die Spuren in der Roh-Datei nicht zu finden sind. */
|
||||||
|
export function sprachenDerSpuren(spuren: readonly SpurAngabe[]): string[] {
|
||||||
|
const liste: string[] = []
|
||||||
|
for (const s of spuren) {
|
||||||
|
const l = s.lang.toLowerCase()
|
||||||
|
if (l.length > 0 && !liste.includes(l)) liste.push(l)
|
||||||
|
}
|
||||||
|
return liste
|
||||||
|
}
|
||||||
@@ -121,10 +121,18 @@ function start(): void {
|
|||||||
// 5.4.0: Die Kompression läuft OHNE Laufwerk weiter — Updates
|
// 5.4.0: Die Kompression läuft OHNE Laufwerk weiter — Updates
|
||||||
// warten auch auf sie, und die Taskleiste zeigt ihren Fortschritt.
|
// warten auch auf sie, und die Taskleiste zeigt ihren Fortschritt.
|
||||||
kompressionVerarbeiten(nachricht.stand.kompression.laeuft)
|
kompressionVerarbeiten(nachricht.stand.kompression.laeuft)
|
||||||
|
} else if (nachricht.art === 'kompression-stand') {
|
||||||
|
// 5.7.0 (F7): der leichte Fortschritts-Tick — dieselbe Wirkung.
|
||||||
|
kompressionVerarbeiten(nachricht.kompression.laeuft)
|
||||||
} else if (nachricht.art === 'kompression-ergebnis' && !ohneNebenwirkungen) {
|
} else if (nachricht.art === 'kompression-ergebnis' && !ohneNebenwirkungen) {
|
||||||
toast(nachricht.ok ? 'Rippy ist fertig' : 'Rippy: Kompression fehlgeschlagen', `${nachricht.titel}: ${nachricht.text}`)
|
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) {
|
} else if (nachricht.art === 'disc-info' && nachricht.info.titel.length > 0 && !ohneNebenwirkungen) {
|
||||||
// Windows-Toast beim Erkennen (§ 7) — Klick holt das Fenster hoch.
|
// Windows-Toast beim Erkennen (§ 7) — Klick holt das Fenster hoch.
|
||||||
|
// 5.7.0 (F20): einmal je Disc und Titel — die Nachprüfung nach dem
|
||||||
|
// Info-Lauf und „Ändern …" schicken denselben Stand erneut.
|
||||||
|
const schluessel = `${nachricht.info.id}:${nachricht.info.titel}`
|
||||||
|
if (letzterErkennungsToast.get(nachricht.info.id) === schluessel) return
|
||||||
|
letzterErkennungsToast.set(nachricht.info.id, schluessel)
|
||||||
toast(
|
toast(
|
||||||
'Disc erkannt',
|
'Disc erkannt',
|
||||||
`${nachricht.info.titel}${nachricht.info.jahr !== null ? ` (${nachricht.info.jahr})` : ''} · ${Math.round(nachricht.info.confidence * 100)} %` +
|
`${nachricht.info.titel}${nachricht.info.jahr !== null ? ` (${nachricht.info.jahr})` : ''} · ${Math.round(nachricht.info.confidence * 100)} %` +
|
||||||
@@ -145,6 +153,8 @@ function start(): void {
|
|||||||
// aktiven Vorgängen, weil sie an keinem Laufwerk mehr hängt.
|
// aktiven Vorgängen, weil sie an keinem Laufwerk mehr hängt.
|
||||||
const KOMPRESSION = 'kompression'
|
const KOMPRESSION = 'kompression'
|
||||||
let kompressionStand: { titel: string; prozent: number } | null = null
|
let kompressionStand: { titel: string; prozent: number } | null = null
|
||||||
|
/** Je Laufwerk der zuletzt gemeldete Titel — gegen doppelte Toasts (F20). */
|
||||||
|
const letzterErkennungsToast = new Map<string, string>()
|
||||||
|
|
||||||
function kompressionVerarbeiten(laeuft: { titel: string; prozent: number } | null): void {
|
function kompressionVerarbeiten(laeuft: { titel: string; prozent: number } | null): void {
|
||||||
kompressionStand = laeuft === null ? null : { titel: laeuft.titel, prozent: laeuft.prozent }
|
kompressionStand = laeuft === null ? null : { titel: laeuft.titel, prozent: laeuft.prozent }
|
||||||
@@ -157,7 +167,8 @@ function start(): void {
|
|||||||
* die Kompression — sonst nichts. */
|
* die Kompression — sonst nichts. */
|
||||||
let letzterRip: Extract<KernNachricht, { art: 'rip-status' }>['status'] | null = null
|
let letzterRip: Extract<KernNachricht, { art: 'rip-status' }>['status'] | null = null
|
||||||
function anzeigeAktualisieren(): void {
|
function anzeigeAktualisieren(): void {
|
||||||
const rip = letzterRip !== null && letzterRip.phase === 'rippt' ? letzterRip : null
|
// 5.7.0 (F26): Das Rettungs-Abbild ist ein laufender Vorgang wie der Rip.
|
||||||
|
const rip = letzterRip !== null && (letzterRip.phase === 'rippt' || letzterRip.phase === 'rettet') ? letzterRip : null
|
||||||
if (fenster !== null && !fenster.isDestroyed()) {
|
if (fenster !== null && !fenster.isDestroyed()) {
|
||||||
if (rip !== null && rip.prozent >= 0) {
|
if (rip !== null && rip.prozent >= 0) {
|
||||||
fenster.setProgressBar(rip.prozent / 100)
|
fenster.setProgressBar(rip.prozent / 100)
|
||||||
@@ -181,7 +192,7 @@ function start(): void {
|
|||||||
// § 7: Toasts, Taskleisten-Fortschritt und Tray-Zustand aus dem
|
// § 7: Toasts, Taskleisten-Fortschritt und Tray-Zustand aus dem
|
||||||
// Rip-Status — die Information, die man will, ohne das Fenster zu öffnen.
|
// Rip-Status — die Information, die man will, ohne das Fenster zu öffnen.
|
||||||
function ripStatusVerarbeiten(status: Extract<KernNachricht, { art: 'rip-status' }>['status']): void {
|
function ripStatusVerarbeiten(status: Extract<KernNachricht, { art: 'rip-status' }>['status']): void {
|
||||||
if (status.phase === 'rippt' || status.phase === 'komprimiert' || status.phase === 'legt-ab' || status.phase === 'liest-info') {
|
if (status.phase === 'rippt' || status.phase === 'rettet' || status.phase === 'komprimiert' || status.phase === 'legt-ab' || status.phase === 'liest-info') {
|
||||||
aktiveVorgaenge.add(status.id)
|
aktiveVorgaenge.add(status.id)
|
||||||
} else {
|
} else {
|
||||||
aktiveVorgaenge.delete(status.id)
|
aktiveVorgaenge.delete(status.id)
|
||||||
@@ -280,6 +291,28 @@ function start(): void {
|
|||||||
app.quit()
|
app.quit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** „Beenden" im Tray (5.7.0, Durchsicht F15): Läuft ein Rip oder eine
|
||||||
|
* Kompression, fragt Rippy nach — app.quit() reißt über die Leine
|
||||||
|
* makemkvcon und HandBrake mit, und vierzig Minuten Rip wären weg. */
|
||||||
|
function beendenMitRueckfrage(): void {
|
||||||
|
if (aktiveVorgaenge.size === 0) {
|
||||||
|
beenden()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const laufend = kompressionStand !== null ? `die Kompression von „${kompressionStand.titel}"` : 'ein Rip'
|
||||||
|
const wahl = dialog.showMessageBoxSync({
|
||||||
|
type: 'warning',
|
||||||
|
title: 'Rippy beenden?',
|
||||||
|
message: `Gerade läuft ${laufend}.`,
|
||||||
|
detail: 'Beim Beenden bricht Rippy alles Laufende ab — der verlustfreie Rip und die halbe Kompression gehen verloren. Fenster schließen lässt Rippy im Infobereich weiterarbeiten.',
|
||||||
|
buttons: ['Weiterlaufen lassen', 'Trotzdem beenden'],
|
||||||
|
defaultId: 0,
|
||||||
|
cancelId: 0,
|
||||||
|
noLink: true,
|
||||||
|
})
|
||||||
|
if (wahl === 1) beenden()
|
||||||
|
}
|
||||||
|
|
||||||
// Kurze Rückmeldungen aus dem Fenster (Brücke: rippy.melden). Im
|
// Kurze Rückmeldungen aus dem Fenster (Brücke: rippy.melden). Im
|
||||||
// Smoke-Lauf ist 'kern-pong' der Beweis, dass der MessagePort
|
// Smoke-Lauf ist 'kern-pong' der Beweis, dass der MessagePort
|
||||||
// Fenster ↔ Kern in BEIDE Richtungen trägt.
|
// Fenster ↔ Kern in BEIDE Richtungen trägt.
|
||||||
@@ -389,7 +422,7 @@ function start(): void {
|
|||||||
void app.whenReady().then(() => {
|
void app.whenReady().then(() => {
|
||||||
kernVerwaltung.starten()
|
kernVerwaltung.starten()
|
||||||
if (!ohneNebenwirkungen) {
|
if (!ohneNebenwirkungen) {
|
||||||
trayErstellen(fensterZeigen, beenden)
|
trayErstellen(fensterZeigen, beendenMitRueckfrage)
|
||||||
}
|
}
|
||||||
fenster = fensterErstellen(app.getPath('userData'))
|
fenster = fensterErstellen(app.getPath('userData'))
|
||||||
fensterVerdrahten()
|
fensterVerdrahten()
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ export class KernVerwaltung {
|
|||||||
private beendenGewollt = false
|
private beendenGewollt = false
|
||||||
private neustarts = 0
|
private neustarts = 0
|
||||||
private neustartZeiten: number[] = []
|
private neustartZeiten: number[] = []
|
||||||
|
/** Das Fenster, das zuletzt einen Port bekam — ein NEU gestarteter Kern
|
||||||
|
* bekommt es wieder (5.7.0, Durchsicht F4: vorher hielt das Fenster nach
|
||||||
|
* einem Kern-Neustart den toten Port und war taub, bis Rippy komplett neu
|
||||||
|
* gestartet wurde). */
|
||||||
|
private letztesFenster: WebContents | null = null
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly datenbankPfad: string,
|
private readonly datenbankPfad: string,
|
||||||
@@ -54,6 +59,11 @@ export class KernVerwaltung {
|
|||||||
|
|
||||||
kern.on('spawn', () => {
|
kern.on('spawn', () => {
|
||||||
this.beiZustand(this.zustand())
|
this.beiZustand(this.zustand())
|
||||||
|
// Ein Neustart: Das Fenster hängt noch am toten Port des alten Kerns —
|
||||||
|
// frischen Port aufspannen, sonst tut kein Knopf mehr etwas.
|
||||||
|
if (this.neustarts > 0 && this.letztesFenster !== null && !this.letztesFenster.isDestroyed()) {
|
||||||
|
this.fensterVerbinden(this.letztesFenster)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
kern.on('exit', (code) => {
|
kern.on('exit', (code) => {
|
||||||
@@ -80,6 +90,7 @@ export class KernVerwaltung {
|
|||||||
* ins Fenster (§ 4.1).
|
* ins Fenster (§ 4.1).
|
||||||
*/
|
*/
|
||||||
fensterVerbinden(webContents: WebContents): void {
|
fensterVerbinden(webContents: WebContents): void {
|
||||||
|
this.letztesFenster = webContents
|
||||||
if (this.kern === null) return
|
if (this.kern === null) return
|
||||||
const { port1, port2 } = new MessageChannelMain()
|
const { port1, port2 } = new MessageChannelMain()
|
||||||
const nachricht: HauptNachricht = { art: 'fenster-port' }
|
const nachricht: HauptNachricht = { art: 'fenster-port' }
|
||||||
|
|||||||
@@ -45,3 +45,23 @@ export function rollenNummern(rollen: readonly TitelRolle[]): Array<[number, num
|
|||||||
return [n, gesamt.get(r) ?? 1]
|
return [n, gesamt.get(r) ?? 1]
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ein Name, der im Ordner noch frei ist (5.7.0, Durchsicht F3): `x.mkv`,
|
||||||
|
* sonst `x (2).mkv`, `x (3).mkv` … — nie eine vorhandene Datei
|
||||||
|
* überschreiben. Der Fall: Disc 2 einer Staffel legt `title_t00.mkv` in
|
||||||
|
* denselben Season-Ordner, in dem Disc 1 ihr `title_t00.mkv` noch trägt
|
||||||
|
* (Umbenennung ausgefallen) — HandBrake hätte sie still ersetzt. Pur:
|
||||||
|
* `existiert` kommt von außen; getrennt wird am letzten Punkt des Namens,
|
||||||
|
* Pfadtrenner beider Welten bleiben unangetastet. */
|
||||||
|
export function freierName(pfad: string, existiert: (p: string) => boolean): string {
|
||||||
|
if (!existiert(pfad)) return pfad
|
||||||
|
const trenner = Math.max(pfad.lastIndexOf('/'), pfad.lastIndexOf('\\'))
|
||||||
|
const punkt = pfad.lastIndexOf('.')
|
||||||
|
const stamm = punkt > trenner + 1 ? pfad.slice(0, punkt) : pfad
|
||||||
|
const endung = punkt > trenner + 1 ? pfad.slice(punkt) : ''
|
||||||
|
for (let n = 2; n < 1000; n++) {
|
||||||
|
const kandidat = `${stamm} (${n})${endung}`
|
||||||
|
if (!existiert(kandidat)) return kandidat
|
||||||
|
}
|
||||||
|
return `${stamm} (${Date.now()})${endung}`
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ export interface NfoMeta {
|
|||||||
genres?: readonly string[]
|
genres?: readonly string[]
|
||||||
laufzeitMinuten?: number
|
laufzeitMinuten?: number
|
||||||
quelle?: string
|
quelle?: string
|
||||||
|
/** 5.7.0 (F23): die Kennung bei TMDb (Zahl) oder IMDb (tt…) — Jellyfin,
|
||||||
|
* Emby und Kodi lesen sie als <uniqueid> und müssen nicht mehr über
|
||||||
|
* Titel und Jahr raten (kodi.wiki NFO files: uniqueid type=tmdb|imdb). */
|
||||||
|
kennung?: { quelle: string; id: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
function xml(wert: string): string {
|
function xml(wert: string): string {
|
||||||
@@ -40,6 +44,10 @@ export function baueNfo(meta: NfoMeta, titel: string, jahr?: number | string | n
|
|||||||
if (meta.laufzeitMinuten !== undefined && meta.laufzeitMinuten > 0) {
|
if (meta.laufzeitMinuten !== undefined && meta.laufzeitMinuten > 0) {
|
||||||
zeilen.push(` <runtime>${xml(String(meta.laufzeitMinuten))}</runtime>`)
|
zeilen.push(` <runtime>${xml(String(meta.laufzeitMinuten))}</runtime>`)
|
||||||
}
|
}
|
||||||
|
if (meta.kennung !== undefined && meta.kennung.id.length > 0) {
|
||||||
|
const typ = meta.kennung.id.startsWith('tt') ? 'imdb' : meta.kennung.quelle === 'omdb' ? 'imdb' : 'tmdb'
|
||||||
|
zeilen.push(` <uniqueid type="${typ}" default="true">${xml(meta.kennung.id)}</uniqueid>`)
|
||||||
|
}
|
||||||
zeilen.push(` <details>Source: ${xml(meta.quelle ?? 'Rippy')}</details>`)
|
zeilen.push(` <details>Source: ${xml(meta.quelle ?? 'Rippy')}</details>`)
|
||||||
zeilen.push(`</${wurzel}>`)
|
zeilen.push(`</${wurzel}>`)
|
||||||
return zeilen.join('\n') + '\n'
|
return zeilen.join('\n') + '\n'
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ export function ordnerGroesse(pfad: string): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Ordner unter roh\, die kein Vorgang kennt — nur zeigen, nie von selbst löschen. */
|
/** Ordner unter roh\, die kein Vorgang kennt — nur zeigen, nie von selbst löschen. */
|
||||||
export function fremdeOrdner(rohWurzel: string, bekannte: ReadonlySet<string>): FremderOrdner[] {
|
export function fremdeOrdner(rohWurzel: string, bekannte: ReadonlySet<string>, groesse: (pfad: string) => number = ordnerGroesse): FremderOrdner[] {
|
||||||
if (!existsSync(rohWurzel)) return []
|
if (!existsSync(rohWurzel)) return []
|
||||||
const liste: FremderOrdner[] = []
|
const liste: FremderOrdner[] = []
|
||||||
let namen: string[]
|
let namen: string[]
|
||||||
@@ -182,7 +182,7 @@ export function fremdeOrdner(rohWurzel: string, bekannte: ReadonlySet<string>):
|
|||||||
for (const name of namen) {
|
for (const name of namen) {
|
||||||
const voll = join(rohWurzel, name)
|
const voll = join(rohWurzel, name)
|
||||||
if (bekannte.has(voll) || bekannte.has(name)) continue
|
if (bekannte.has(voll) || bekannte.has(name)) continue
|
||||||
liste.push({ ordner: voll, bytes: ordnerGroesse(voll) })
|
liste.push({ ordner: voll, bytes: groesse(voll) })
|
||||||
}
|
}
|
||||||
return liste.sort((a, b) => b.bytes - a.bytes)
|
return liste.sort((a, b) => b.bytes - a.bytes)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ import { sichererName, staffelOrdnerName } from '../../gemeinsam/ablagepfad'
|
|||||||
export { sichererName }
|
export { sichererName }
|
||||||
|
|
||||||
/** Zielordner: sprechender Name; existiert er schon (Disc doppelt
|
/** Zielordner: sprechender Name; existiert er schon (Disc doppelt
|
||||||
* gerippt), wird die Job-Kennung angehängt statt zu überschreiben. */
|
* gerippt), wird „(2)", „(3)" … angehängt statt zu überschreiben — 5.7.0
|
||||||
|
* (F24): vorher die ersten acht Zeichen der Vorgangskennung, also
|
||||||
|
* „Akira (1988) [bluray-2]", was niemandem etwas sagte. */
|
||||||
export function zielordner(
|
export function zielordner(
|
||||||
basis: string,
|
basis: string,
|
||||||
titel: string,
|
titel: string,
|
||||||
@@ -29,8 +31,12 @@ export function zielordner(
|
|||||||
const name = sichererName(titel, jahr)
|
const name = sichererName(titel, jahr)
|
||||||
if (name.length === 0) return join(basis, jobId)
|
if (name.length === 0) return join(basis, jobId)
|
||||||
const ziel = join(basis, name)
|
const ziel = join(basis, name)
|
||||||
if (existiert(ziel)) return join(basis, `${name} [${jobId.slice(0, 8)}]`)
|
if (!existiert(ziel)) return ziel
|
||||||
return ziel
|
for (let n = 2; n < 100; n++) {
|
||||||
|
const kandidat = join(basis, `${name} (${n})`)
|
||||||
|
if (!existiert(kandidat)) return kandidat
|
||||||
|
}
|
||||||
|
return join(basis, `${name} [${jobId.slice(0, 8)}]`)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Jellyfin-Konvention: <basis>/<Serie>/Season NN (pur). */
|
/** Jellyfin-Konvention: <basis>/<Serie>/Season NN (pur). */
|
||||||
@@ -84,7 +90,14 @@ export function matcheEpisoden(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Benennt MKVs nach Jellyfin-Schema um: „Serie S01E02.mkv". Reihenfolge =
|
/** Benennt MKVs nach Jellyfin-Schema um: „Serie S01E02.mkv". Reihenfolge =
|
||||||
* sortierte Dateinamen (MakeMKV nummeriert in Disc-Reihenfolge). */
|
* sortierte Dateinamen (MakeMKV nummeriert in Disc-Reihenfolge).
|
||||||
|
*
|
||||||
|
* 5.7.0 (Durchsicht F3): `nurDiese` nennt die Dateien DIESER Disc (nur
|
||||||
|
* Namen, in Ablage-Reihenfolge). Vorher zählte die Funktion ALLE MKVs im
|
||||||
|
* Season-Ordner — und sobald Disc 1 dort schon S01E01/S01E02 abgelegt
|
||||||
|
* hatte, hieß es bei Disc 2 „übersprungen: 4 Dateien, 2 Zuordnungen".
|
||||||
|
* Der Serien-Merker lief damit ab der zweiten Disc ins Leere. Ohne
|
||||||
|
* `nurDiese` gilt das alte Verhalten (alle MKVs des Ordners). */
|
||||||
export function episodenUmbenennen(
|
export function episodenUmbenennen(
|
||||||
ordner: string,
|
ordner: string,
|
||||||
serie: string,
|
serie: string,
|
||||||
@@ -99,12 +112,16 @@ export function episodenUmbenennen(
|
|||||||
existiert: existsSync,
|
existiert: existsSync,
|
||||||
umbenennen: renameSync,
|
umbenennen: renameSync,
|
||||||
},
|
},
|
||||||
|
nurDiese?: readonly string[],
|
||||||
): string[] {
|
): string[] {
|
||||||
const meldungen: string[] = []
|
const meldungen: string[] = []
|
||||||
const dateien = fs
|
const dateien =
|
||||||
.listen(ordner)
|
nurDiese !== undefined
|
||||||
.filter((f) => f.endsWith('.mkv'))
|
? [...nurDiese]
|
||||||
.sort()
|
: fs
|
||||||
|
.listen(ordner)
|
||||||
|
.filter((f) => f.endsWith('.mkv'))
|
||||||
|
.sort()
|
||||||
if (dateien.length !== zuordnung.length) {
|
if (dateien.length !== zuordnung.length) {
|
||||||
return [`Umbenennen übersprungen: ${dateien.length} Dateien, ${zuordnung.length} Zuordnungen`]
|
return [`Umbenennen übersprungen: ${dateien.length} Dateien, ${zuordnung.length} Zuordnungen`]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ import {
|
|||||||
// Plattform-Pfade wie in struktur.ts: Laufzeit-Wurzeln, CI testet auf Linux.
|
// Plattform-Pfade wie in struktur.ts: Laufzeit-Wurzeln, CI testet auf Linux.
|
||||||
import { basename, join } from 'node:path'
|
import { basename, join } from 'node:path'
|
||||||
import type { BibliothekEintrag, KompressionStand } from '../../gemeinsam/nachrichten'
|
import type { BibliothekEintrag, KompressionStand } from '../../gemeinsam/nachrichten'
|
||||||
import { rollenNummern, zielDateiname } from '../ablage/dateinamen'
|
import { freierName, rollenNummern, zielDateiname } from '../ablage/dateinamen'
|
||||||
import { baueNfo } from '../ablage/nfo'
|
import { baueNfo } from '../ablage/nfo'
|
||||||
import { bibliothekRefresh } from '../ablage/medienserver'
|
import { bibliothekRefresh } from '../ablage/medienserver'
|
||||||
import { posterSpeichern } from '../ablage/poster'
|
import { posterSpeichern } from '../ablage/poster'
|
||||||
@@ -46,8 +46,12 @@ import {
|
|||||||
folgenNummern,
|
folgenNummern,
|
||||||
folgenPlausibel,
|
folgenPlausibel,
|
||||||
gewichteterFortschritt,
|
gewichteterFortschritt,
|
||||||
|
sprachlistenAusSpuren,
|
||||||
sprachwahl,
|
sprachwahl,
|
||||||
|
spurwahl,
|
||||||
type KompressionsAuftrag,
|
type KompressionsAuftrag,
|
||||||
|
type RohDatei,
|
||||||
|
type SpurWahl,
|
||||||
} from './auftrag'
|
} from './auftrag'
|
||||||
|
|
||||||
export interface AblageUmgebung {
|
export interface AblageUmgebung {
|
||||||
@@ -103,6 +107,24 @@ export function dateiVerschieben(von: string, nach: string): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Die Sprachlisten aus den gewählten Spuren, als Felder für sprachwahl. */
|
||||||
|
function sprachlistenAusSpurenFuerWahl(datei: RohDatei): { audioSprachen: string[]; untertitelSprachen: string[] } {
|
||||||
|
const listen = sprachlistenAusSpuren(datei)
|
||||||
|
return { audioSprachen: listen.audio, untertitelSprachen: listen.untertitel }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Der Zusatz in der Fortschrittszeile: „ · Ton 1,3 · Untertitel 2 (vorgewählt)". */
|
||||||
|
function spurText(spurNr: SpurWahl | null, wahl: { audio: string[]; untertitel: string[]; untertitelStandard: boolean }): string {
|
||||||
|
if (spurNr !== null) {
|
||||||
|
return `${spurNr.audioNr.length > 0 ? ` · Ton Spur ${spurNr.audioNr.join(',')}` : ' · Ton: erste Spur'}${
|
||||||
|
spurNr.untertitelNr.length > 0 ? ` · Untertitel Spur ${spurNr.untertitelNr.join(',')}${spurNr.untertitelStandard > 0 ? ' (vorgewählt)' : ''}` : ''
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
return `${wahl.audio.length > 0 ? ` · Ton ${wahl.audio.join(',')}` : ''}${
|
||||||
|
wahl.untertitel.length > 0 ? ` · Untertitel ${wahl.untertitel.join(',')}${wahl.untertitelStandard ? ' (vorgewählt)' : ''}` : ''
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
|
||||||
function groesseVon(pfad: string): number {
|
function groesseVon(pfad: string): number {
|
||||||
try {
|
try {
|
||||||
return statSync(pfad).size
|
return statSync(pfad).size
|
||||||
@@ -121,7 +143,7 @@ export async function komprimierenUndAblegen(
|
|||||||
): Promise<AblageErgebnis> {
|
): Promise<AblageErgebnis> {
|
||||||
const einstellungen = umgebung.einstellungen()
|
const einstellungen = umgebung.einstellungen()
|
||||||
const handbrake = umgebung.handbrake()
|
const handbrake = umgebung.handbrake()
|
||||||
const soll = komprimierenFuer(auftrag.discTyp, einstellungen)
|
const soll = komprimierenFuer(auftrag.discTyp, einstellungen, umgebung.presetEmpfehlung?.(auftrag.discTyp) ?? '')
|
||||||
const komprimiert = soll && handbrake.length > 0
|
const komprimiert = soll && handbrake.length > 0
|
||||||
const preset =
|
const preset =
|
||||||
auftrag.preset.length > 0
|
auftrag.preset.length > 0
|
||||||
@@ -189,24 +211,35 @@ export async function komprimierenUndAblegen(
|
|||||||
// Laufzeiten der FOLGEN in Ablage-Reihenfolge — Grundlage der
|
// Laufzeiten der FOLGEN in Ablage-Reihenfolge — Grundlage der
|
||||||
// Episoden-Zuordnung weiter unten.
|
// Episoden-Zuordnung weiter unten.
|
||||||
const folgenSekunden: number[] = []
|
const folgenSekunden: number[] = []
|
||||||
|
/** Die Folgen DIESER Disc (Zielnamen, Ablage-Reihenfolge) — nur die werden
|
||||||
|
* umbenannt, nicht der ganze Season-Ordner (5.7.0, F3). */
|
||||||
|
const folgenDateien: string[] = []
|
||||||
// 5.6.0: Dateien heißen wie der Film (dateinamen.ts) — Folgen behalten
|
// 5.6.0: Dateien heißen wie der Film (dateinamen.ts) — Folgen behalten
|
||||||
// ihren Namen bis zur Episoden-Zuordnung.
|
// ihren Namen bis zur Episoden-Zuordnung.
|
||||||
const nummern = rollenNummern(dateien.map((d) => d.rolle))
|
const nummern = rollenNummern(dateien.map((d) => d.rolle))
|
||||||
|
// Beim Wiederholen darf die neue Fassung den alten Namen tragen — die alte
|
||||||
|
// Datei fällt erst, wenn die neue fertig ist (unten). Alles andere, was
|
||||||
|
// schon dasteht, wird NIE überschrieben (F3): dann „x (2).mkv".
|
||||||
|
const ersetzt = new Set(auftrag.ersetzt ?? [])
|
||||||
|
const freiOderErsetzt = (pfad: string): string => (ersetzt.has(pfad) ? pfad : freierName(pfad, existsSync))
|
||||||
for (let i = 0; i < dateien.length; i++) {
|
for (let i = 0; i < dateien.length; i++) {
|
||||||
const datei = dateien[i]
|
const datei = dateien[i]
|
||||||
const [nummer, anzahl] = nummern[i]
|
const [nummer, anzahl] = nummern[i]
|
||||||
const zielDatei = join(
|
const zielDatei = freiOderErsetzt(
|
||||||
zielFuer(datei.rolle),
|
join(
|
||||||
zielDateiname({
|
zielFuer(datei.rolle),
|
||||||
titel: zuordnung?.titel ?? '',
|
zielDateiname({
|
||||||
jahr: zuordnung?.jahr ?? null,
|
titel: zuordnung?.titel ?? '',
|
||||||
alsSerie,
|
jahr: zuordnung?.jahr ?? null,
|
||||||
rolle: datei.rolle,
|
alsSerie,
|
||||||
nummer,
|
rolle: datei.rolle,
|
||||||
anzahl,
|
nummer,
|
||||||
original: basename(datei.pfad),
|
anzahl,
|
||||||
}),
|
original: basename(datei.pfad),
|
||||||
|
}),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
if (datei.rolle === 'folge') folgenDateien.push(basename(zielDatei))
|
||||||
if (abbruch.aborted) return abgebrochen(fertigOrdner, fertigeDateien, preset, komprimiert, hinweise)
|
if (abbruch.aborted) return abgebrochen(fertigOrdner, fertigeDateien, preset, komprimiert, hinweise)
|
||||||
if (!komprimiert) {
|
if (!komprimiert) {
|
||||||
melden({
|
melden({
|
||||||
@@ -228,13 +261,34 @@ export async function komprimierenUndAblegen(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const wahl = sprachwahl(datei, global)
|
let wahl = sprachwahl(datei, global)
|
||||||
|
// 5.7.0: Spuren EINZELN gewählt — die Roh-Datei wird gescannt und jede
|
||||||
|
// gewählte MakeMKV-Spur als HandBrake-Spurnummer wiedergefunden
|
||||||
|
// (auftrag.ts spurwahl). Scheitert der Scan, gelten die Sprachen der
|
||||||
|
// gewählten Spuren als Liste (wie 5.4.0–5.6.x).
|
||||||
|
const einzeln = datei.audioSpuren !== undefined || datei.untertitelSpuren !== undefined
|
||||||
|
let spurNr: SpurWahl | null = null
|
||||||
// Die Roh-Datei fragen, ob die gewünschten Sprachen wirklich drin sind
|
// 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
|
// (scan.ts). Nur wenn der Nutzer etwas JE TITEL gewählt hat — die
|
||||||
// globale Vorgabe war schon immer „was da ist".
|
// globale Vorgabe war schon immer „was da ist".
|
||||||
if (datei.audioSprachen.length > 0 || datei.untertitelSprachen.length > 0) {
|
if (einzeln || datei.audioSprachen.length > 0 || datei.untertitelSprachen.length > 0) {
|
||||||
const spuren = await scannen(handbrake, datei.pfad)
|
const spuren = await scannen(handbrake, datei.pfad)
|
||||||
if (spuren !== null) {
|
if (spuren !== null && einzeln) {
|
||||||
|
spurNr = spurwahl(datei, spuren, global.audio[0] ?? '')
|
||||||
|
for (const f of spurNr?.fehlend ?? []) {
|
||||||
|
hinweise.push(`${basename(datei.pfad)}: ${f} ist NICHT in der Roh-Datei — MakeMKV hat sie nicht mitgenommen (Auswahlregel prüfen).`)
|
||||||
|
}
|
||||||
|
if (spurNr !== null && spurNr.audioNr.length === 0 && (datei.audioSpuren?.length ?? 0) > 0) {
|
||||||
|
// Keine gewählte Tonspur gefunden → lieber Sprachlisten als eine
|
||||||
|
// stumme Datei.
|
||||||
|
spurNr = null
|
||||||
|
wahl = sprachwahl({ ...datei, ...sprachlistenAusSpurenFuerWahl(datei) }, global)
|
||||||
|
hinweise.push(`${basename(datei.pfad)}: keine der gewählten Tonspuren gefunden — Sprachen ${wahl.audio.join(',') || 'nach Einstellung'} statt Spuren.`)
|
||||||
|
}
|
||||||
|
} else if (spuren === null && einzeln) {
|
||||||
|
wahl = sprachwahl({ ...datei, ...sprachlistenAusSpurenFuerWahl(datei) }, global)
|
||||||
|
hinweise.push(`${basename(datei.pfad)}: Spuren der Roh-Datei ließen sich nicht messen — Sprachen ${[...wahl.audio, ...wahl.untertitel].join(',') || 'nach Einstellung'} statt einzelner Spuren.`)
|
||||||
|
} else if (spuren !== null) {
|
||||||
const fehlt = fehlendeSprachen(spuren, wahl)
|
const fehlt = fehlendeSprachen(spuren, wahl)
|
||||||
if (fehlt.audio.length > 0) {
|
if (fehlt.audio.length > 0) {
|
||||||
hinweise.push(
|
hinweise.push(
|
||||||
@@ -254,9 +308,7 @@ export async function komprimierenUndAblegen(
|
|||||||
phase: 'komprimiert',
|
phase: 'komprimiert',
|
||||||
prozent: gewichteterFortschritt(gewichte, i, 0),
|
prozent: gewichteterFortschritt(gewichte, i, 0),
|
||||||
restS: -1,
|
restS: -1,
|
||||||
text: `Komprimiere ${i + 1}/${dateien.length}: ${basename(datei.pfad)} („${preset}")${
|
text: `Komprimiere ${i + 1}/${dateien.length}: ${basename(datei.pfad)} („${preset}")${spurText(spurNr, wahl)} …`,
|
||||||
wahl.audio.length > 0 ? ` · Ton ${wahl.audio.join(',')}` : ''
|
|
||||||
}${wahl.untertitel.length > 0 ? ` · Untertitel ${wahl.untertitel.join(',')}${wahl.untertitelStandard ? ' (vorgewählt)' : ''}` : ''} …`,
|
|
||||||
})
|
})
|
||||||
const encode = await komprimieren({
|
const encode = await komprimieren({
|
||||||
programm: handbrake,
|
programm: handbrake,
|
||||||
@@ -266,6 +318,12 @@ export async function komprimierenUndAblegen(
|
|||||||
audioSprachen: wahl.audio,
|
audioSprachen: wahl.audio,
|
||||||
untertitelSprachen: wahl.untertitel,
|
untertitelSprachen: wahl.untertitel,
|
||||||
untertitelStandard: wahl.untertitelStandard,
|
untertitelStandard: wahl.untertitelStandard,
|
||||||
|
...(spurNr !== null
|
||||||
|
? { audioNr: spurNr.audioNr, untertitelNr: spurNr.untertitelNr, untertitelStandardNr: spurNr.untertitelStandard }
|
||||||
|
: {}),
|
||||||
|
// Beim Wiederholen darf die neue Fassung die alte gleichen Namens
|
||||||
|
// ersetzen (ohne Arbeitsordner schreibt HandBrake direkt ins Ziel).
|
||||||
|
ueberschreiben: zwischen.length === 0 && ersetzt.has(zielDatei),
|
||||||
abbruch,
|
abbruch,
|
||||||
aufFortschritt: (prozent, restS) => {
|
aufFortschritt: (prozent, restS) => {
|
||||||
melden({
|
melden({
|
||||||
@@ -346,7 +404,7 @@ export async function komprimierenUndAblegen(
|
|||||||
episoden = laufzeiten.length > 0 ? matcheEpisoden(folgenSekunden, laufzeiten) : null
|
episoden = laufzeiten.length > 0 ? matcheEpisoden(folgenSekunden, laufzeiten) : null
|
||||||
}
|
}
|
||||||
if (episoden !== null) {
|
if (episoden !== null) {
|
||||||
hinweise.push(...episodenUmbenennen(fertigOrdner, serienTitel, staffel, episoden))
|
hinweise.push(...episodenUmbenennen(fertigOrdner, serienTitel, staffel, episoden, undefined, folgenDateien))
|
||||||
folgenVergeben = episoden
|
folgenVergeben = episoden
|
||||||
} else {
|
} else {
|
||||||
hinweise.push(
|
hinweise.push(
|
||||||
@@ -372,6 +430,7 @@ export async function komprimierenUndAblegen(
|
|||||||
laufzeitMinuten: zuordnung?.meta.laufzeitMinuten,
|
laufzeitMinuten: zuordnung?.meta.laufzeitMinuten,
|
||||||
quelle:
|
quelle:
|
||||||
zuordnung !== null && zuordnung.meta.quelle.length > 0 ? `Rippy v5 (${zuordnung.meta.quelle})` : 'Rippy v5',
|
zuordnung !== null && zuordnung.meta.quelle.length > 0 ? `Rippy v5 (${zuordnung.meta.quelle})` : 'Rippy v5',
|
||||||
|
kennung: zuordnung === null || zuordnung.meta.id === null ? undefined : { quelle: zuordnung.meta.quelle, id: String(zuordnung.meta.id) },
|
||||||
},
|
},
|
||||||
anzeigeTitel,
|
anzeigeTitel,
|
||||||
zuordnung?.jahr ?? null,
|
zuordnung?.jahr ?? null,
|
||||||
@@ -383,7 +442,10 @@ export async function komprimierenUndAblegen(
|
|||||||
hinweise.push(`NFO fehlgeschlagen: ${String(fehler)}`)
|
hinweise.push(`NFO fehlgeschlagen: ${String(fehler)}`)
|
||||||
}
|
}
|
||||||
if (zuordnung !== null && zuordnung.meta.posterPfad.length > 0) {
|
if (zuordnung !== null && zuordnung.meta.posterPfad.length > 0) {
|
||||||
await posterSpeichern(zuordnung.meta.posterPfad, join(fertigOrdner, 'poster.jpg'))
|
// 5.7.0 (F21): Was mit dem Poster passiert ist, steht in den Hinweisen
|
||||||
|
// — vorher wurde die Antwort verschluckt (R4).
|
||||||
|
const posterErgebnis = await posterSpeichern(zuordnung.meta.posterPfad, join(fertigOrdner, 'poster.jpg'))
|
||||||
|
if (posterErgebnis.length > 0 && !posterErgebnis.startsWith('poster.jpg')) hinweise.push(posterErgebnis)
|
||||||
}
|
}
|
||||||
|
|
||||||
const server = String(einstellungen['mediaServer'] ?? '')
|
const server = String(einstellungen['mediaServer'] ?? '')
|
||||||
|
|||||||
@@ -6,7 +6,9 @@
|
|||||||
// nur noch diese Beschreibung. Deshalb wird sie als JSON in der Datenbank
|
// nur noch diese Beschreibung. Deshalb wird sie als JSON in der Datenbank
|
||||||
// gemerkt — so kann „Wiederholen ab Kompression" sie Tage später wieder
|
// gemerkt — so kann „Wiederholen ab Kompression" sie Tage später wieder
|
||||||
// aufnehmen, ohne die Disc noch einmal zu lesen (40 Minuten je Blu-ray).
|
// aufnehmen, ohne die Disc noch einmal zu lesen (40 Minuten je Blu-ray).
|
||||||
import type { MetaVorschlag, TitelRolle } from '../../gemeinsam/nachrichten'
|
import type { MetaVorschlag, SpurAngabe, TitelRolle } from '../../gemeinsam/nachrichten'
|
||||||
|
import { istSpurAngabe } from '../../gemeinsam/nachrichten'
|
||||||
|
import { spurenZuordnen, sprachenDerSpuren, type HbSpurAngabe } from '../../gemeinsam/spuren'
|
||||||
import type { Zuordnung } from '../metadaten/zuordnung'
|
import type { Zuordnung } from '../metadaten/zuordnung'
|
||||||
|
|
||||||
export interface RohDatei {
|
export interface RohDatei {
|
||||||
@@ -21,6 +23,11 @@ export interface RohDatei {
|
|||||||
untertitelSprachen: string[]
|
untertitelSprachen: string[]
|
||||||
/** 5.6.0 (Extras): vorgewählte Untertitel-Sprache; null = keine; fehlend = Regel. */
|
/** 5.6.0 (Extras): vorgewählte Untertitel-Sprache; null = keine; fehlend = Regel. */
|
||||||
untertitelVorwahl?: string | null
|
untertitelVorwahl?: string | null
|
||||||
|
/** 5.7.0: die GEWÄHLTEN Spuren (einzeln); fehlend = Sprachlisten gelten. */
|
||||||
|
audioSpuren?: SpurAngabe[]
|
||||||
|
untertitelSpuren?: SpurAngabe[]
|
||||||
|
/** 5.7.0: vorgewählte Untertitel-SPUR (MakeMKV-Nummer); null = keine. */
|
||||||
|
untertitelVorwahlSpur?: number | null
|
||||||
/** 5.6.0: der Film, zu dem diese Datei gehört, wenn nicht die Disc-Zuordnung. */
|
/** 5.6.0: der Film, zu dem diese Datei gehört, wenn nicht die Disc-Zuordnung. */
|
||||||
film?: MetaVorschlag
|
film?: MetaVorschlag
|
||||||
}
|
}
|
||||||
@@ -86,6 +93,9 @@ export function auftragAusJson(text: string): KompressionsAuftrag | null {
|
|||||||
audioSprachen: Array.isArray(e.audioSprachen) ? e.audioSprachen.map(String) : [],
|
audioSprachen: Array.isArray(e.audioSprachen) ? e.audioSprachen.map(String) : [],
|
||||||
untertitelSprachen: Array.isArray(e.untertitelSprachen) ? e.untertitelSprachen.map(String) : [],
|
untertitelSprachen: Array.isArray(e.untertitelSprachen) ? e.untertitelSprachen.map(String) : [],
|
||||||
...(e.untertitelVorwahl === null || typeof e.untertitelVorwahl === 'string' ? { untertitelVorwahl: e.untertitelVorwahl } : {}),
|
...(e.untertitelVorwahl === null || typeof e.untertitelVorwahl === 'string' ? { untertitelVorwahl: e.untertitelVorwahl } : {}),
|
||||||
|
...(Array.isArray(e.audioSpuren) && e.audioSpuren.every(istSpurAngabe) ? { audioSpuren: e.audioSpuren } : {}),
|
||||||
|
...(Array.isArray(e.untertitelSpuren) && e.untertitelSpuren.every(istSpurAngabe) ? { untertitelSpuren: e.untertitelSpuren } : {}),
|
||||||
|
...(e.untertitelVorwahlSpur === null || typeof e.untertitelVorwahlSpur === 'number' ? { untertitelVorwahlSpur: e.untertitelVorwahlSpur } : {}),
|
||||||
...(filmAus(e.film) !== null ? { film: filmAus(e.film)! } : {}),
|
...(filmAus(e.film) !== null ? { film: filmAus(e.film)! } : {}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -162,6 +172,69 @@ export function sprachwahl(
|
|||||||
return { audio, untertitel, untertitelStandard }
|
return { audio, untertitel, untertitelStandard }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Was HandBrake bekommt, wenn Spuren EINZELN gewählt wurden (5.7.0). */
|
||||||
|
export interface SpurWahl {
|
||||||
|
/** HandBrake-Spurnummern (1-basiert, Scan-Reihenfolge). */
|
||||||
|
audioNr: number[]
|
||||||
|
untertitelNr: number[]
|
||||||
|
/** Index (1-basiert) der vorgewählten Untertitelspur in `untertitelNr`, 0 = keine. */
|
||||||
|
untertitelStandard: number
|
||||||
|
/** Klartext für die Hinweise: gewählte Spuren, die nicht in der Datei sind. */
|
||||||
|
fehlend: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spurwahl je Datei (pur, 5.7.0): Die gewählten MakeMKV-Spuren werden in
|
||||||
|
* HandBrakes Scan der Roh-Datei wiedergefunden (spurenZuordnen). Die Regel
|
||||||
|
* von 5.4.0 gilt weiter: Spielt die erste gewählte Tonspur nicht in der
|
||||||
|
* Wunschsprache, wird eine gewählte Untertitelspur der Wunschsprache
|
||||||
|
* vorgewählt — außer der Nutzer hat eine Vorwahl gesetzt (Spur) oder
|
||||||
|
* ausdrücklich keine (null). Liefert null, wenn keine Spuren gewählt sind
|
||||||
|
* (dann gilt die Sprachwahl). */
|
||||||
|
export function spurwahl(
|
||||||
|
datei: Pick<RohDatei, 'audioSpuren' | 'untertitelSpuren' | 'untertitelVorwahlSpur'>,
|
||||||
|
scan: { audio: readonly HbSpurAngabe[]; untertitel: readonly HbSpurAngabe[] },
|
||||||
|
wunschSprache: string,
|
||||||
|
): SpurWahl | null {
|
||||||
|
if (datei.audioSpuren === undefined && datei.untertitelSpuren === undefined) return null
|
||||||
|
const audio = spurenZuordnen(datei.audioSpuren ?? [], scan.audio)
|
||||||
|
const untertitel = spurenZuordnen(datei.untertitelSpuren ?? [], scan.untertitel)
|
||||||
|
const gefundeneUntertitel = (datei.untertitelSpuren ?? []).filter((s) => !untertitel.fehlend.includes(s))
|
||||||
|
// Wer wird vorgewählt?
|
||||||
|
let standardSpur: SpurAngabe | undefined
|
||||||
|
if (datei.untertitelVorwahlSpur === null) {
|
||||||
|
standardSpur = undefined
|
||||||
|
} else if (typeof datei.untertitelVorwahlSpur === 'number') {
|
||||||
|
standardSpur = gefundeneUntertitel.find((s) => s.nr === datei.untertitelVorwahlSpur)
|
||||||
|
} else {
|
||||||
|
const ersteTon = (datei.audioSpuren ?? []).find((s) => !audio.fehlend.includes(s))
|
||||||
|
const tonFremd = wunschSprache.length > 0 && ersteTon !== undefined && ersteTon.lang.toLowerCase() !== wunschSprache
|
||||||
|
if (tonFremd) standardSpur = gefundeneUntertitel.find((s) => s.lang.toLowerCase() === wunschSprache)
|
||||||
|
}
|
||||||
|
// Die vorgewählte Spur nach vorn — HandBrakes --subtitle-default zählt
|
||||||
|
// die AUSGABE-Reihenfolge, und die folgt der Liste.
|
||||||
|
let untertitelNr = untertitel.nummern
|
||||||
|
let untertitelStandard = 0
|
||||||
|
if (standardSpur !== undefined) {
|
||||||
|
const i = gefundeneUntertitel.indexOf(standardSpur)
|
||||||
|
if (i >= 0) {
|
||||||
|
untertitelNr = [untertitel.nummern[i], ...untertitel.nummern.filter((_, k) => k !== i)]
|
||||||
|
untertitelStandard = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const fehlend = [
|
||||||
|
...audio.fehlend.map((s) => `Tonspur ${s.sprache || s.lang}${s.codec.length > 0 ? ` ${s.codec}` : ''}`),
|
||||||
|
...untertitel.fehlend.map((s) => `Untertitel ${s.sprache || s.lang}${s.beschreibung.includes('erzwungen') ? ' (nur erzwungene)' : ''}`),
|
||||||
|
]
|
||||||
|
return { audioNr: audio.nummern, untertitelNr, untertitelStandard, fehlend }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die Sprachlisten aus gewählten Spuren — der Rückfall, wenn der Scan der
|
||||||
|
* Roh-Datei scheitert (pur). */
|
||||||
|
export function sprachlistenAusSpuren(datei: Pick<RohDatei, 'audioSpuren' | 'untertitelSpuren'>): { audio: string[]; untertitel: string[] } {
|
||||||
|
return { audio: sprachenDerSpuren(datei.audioSpuren ?? []), untertitel: sprachenDerSpuren(datei.untertitelSpuren ?? []) }
|
||||||
|
}
|
||||||
|
|
||||||
/** Folgen-Nummern von Hand: fortlaufend ab der ersten, in Disc-Reihenfolge. */
|
/** Folgen-Nummern von Hand: fortlaufend ab der ersten, in Disc-Reihenfolge. */
|
||||||
export function folgenNummern(anzahl: number, ersteFolge: number): number[] {
|
export function folgenNummern(anzahl: number, ersteFolge: number): number[] {
|
||||||
const start = Math.max(1, Math.trunc(ersteFolge))
|
const start = Math.max(1, Math.trunc(ersteFolge))
|
||||||
|
|||||||
@@ -16,11 +16,12 @@
|
|||||||
// 5.5.0: das Rettungs-Abbild (rip/rettung.ts) für Discs, an denen Rippy
|
// 5.5.0: das Rettungs-Abbild (rip/rettung.ts) für Discs, an denen Rippy
|
||||||
// Lesefehler GESEHEN hat — erst das Abbild mit Nullen, dann derselbe Rip
|
// Lesefehler GESEHEN hat — erst das Abbild mit Nullen, dann derselbe Rip
|
||||||
// aus dem Abbild (iso:), das nicht mehr hängen kann.
|
// aus dem Abbild (iso:), das nicht mehr hängen kann.
|
||||||
import { existsSync, mkdirSync, readdirSync, renameSync, rmSync, statSync, unlinkSync } from 'node:fs'
|
import { existsSync, mkdirSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||||
// Plattform-Pfade wie in struktur.ts: Laufzeit-Wurzeln, CI testet auf Linux.
|
// Plattform-Pfade wie in struktur.ts: Laufzeit-Wurzeln, CI testet auf Linux.
|
||||||
import { basename, join } from 'node:path'
|
import { basename, join } from 'node:path'
|
||||||
import type {
|
import type {
|
||||||
BibliothekEintrag,
|
BibliothekEintrag,
|
||||||
|
DiscTyp,
|
||||||
GeraeteInfo,
|
GeraeteInfo,
|
||||||
RipAuftragOptionen,
|
RipAuftragOptionen,
|
||||||
RipAuftragTitel,
|
RipAuftragTitel,
|
||||||
@@ -39,6 +40,7 @@ import { mkdtempSync, createWriteStream } from 'node:fs'
|
|||||||
import { tmpdir } from 'node:os'
|
import { tmpdir } from 'node:os'
|
||||||
import { gewichteterFortschritt, type KompressionsAuftrag, type RohDatei } from './auftrag'
|
import { gewichteterFortschritt, type KompressionsAuftrag, type RohDatei } from './auftrag'
|
||||||
import { filmGruppen, rohOrdnerName, zuordnungAusVorschlag } from './filme'
|
import { filmGruppen, rohOrdnerName, zuordnungAusVorschlag } from './filme'
|
||||||
|
import { BLURAY_MIN_BYTES } from '../laufwerk/disc'
|
||||||
|
|
||||||
interface LaufenderVorgang {
|
interface LaufenderVorgang {
|
||||||
/** Beendet den GANZEN Auftrag. */
|
/** Beendet den GANZEN Auftrag. */
|
||||||
@@ -70,14 +72,15 @@ export interface PipelineUmgebung {
|
|||||||
flac: () => string
|
flac: () => string
|
||||||
/** Audio-TOC des Laufwerks (W-7); wirft bei Gerätefehlern. */
|
/** Audio-TOC des Laufwerks (W-7); wirft bei Gerätefehlern. */
|
||||||
audioToc?: (id: string) => Spur[]
|
audioToc?: (id: string) => Spur[]
|
||||||
/** Rohe Audio-Sektoren lesen (W-7). */
|
/** Rohe Audio-Sektoren lesen (W-7) — seit 5.7.0 asynchron mit Abbruch (F30). */
|
||||||
audioSpurLesen?: (
|
audioSpurLesen?: (
|
||||||
id: string,
|
id: string,
|
||||||
start: number,
|
start: number,
|
||||||
sektoren: number,
|
sektoren: number,
|
||||||
schreiben: (daten: Buffer) => void,
|
schreiben: (daten: Buffer) => void,
|
||||||
fortschritt?: (anteil: number) => void,
|
fortschritt?: (anteil: number) => void,
|
||||||
) => number
|
abbruch?: AbortSignal,
|
||||||
|
) => Promise<number>
|
||||||
/** Aktueller Stand des Laufwerks (für Typ und Vorab-Blick). */
|
/** Aktueller Stand des Laufwerks (für Typ und Vorab-Blick). */
|
||||||
geraet: (id: string) => GeraeteInfo | null
|
geraet: (id: string) => GeraeteInfo | null
|
||||||
/** Wurzel der Ablage (fertige Filme, Musik, ISO liegen darunter). */
|
/** Wurzel der Ablage (fertige Filme, Musik, ISO liegen darunter). */
|
||||||
@@ -114,6 +117,11 @@ export interface PipelineUmgebung {
|
|||||||
rettungsAbbild?: typeof rettungsAbbildDatei
|
rettungsAbbild?: typeof rettungsAbbildDatei
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Das Rettungs-Abbild und sein Bericht — die zwei Dateien im Roh-Ordner,
|
||||||
|
* die KEIN Aufräumen anfasst (5.7.0, Durchsicht F8). */
|
||||||
|
export const RETTUNG_ISO = 'rettung.iso'
|
||||||
|
export const RETTUNG_DATEIEN: readonly string[] = [RETTUNG_ISO, `${RETTUNG_ISO}.rettung.txt`]
|
||||||
|
|
||||||
/** Der letzte Auftrag je Laufwerk — die Rettung wiederholt genau ihn. */
|
/** Der letzte Auftrag je Laufwerk — die Rettung wiederholt genau ihn. */
|
||||||
interface LetzterAuftrag {
|
interface LetzterAuftrag {
|
||||||
auftrag: RipAuftragTitel[]
|
auftrag: RipAuftragTitel[]
|
||||||
@@ -180,12 +188,52 @@ export class Pipeline {
|
|||||||
await this.starten(id, letzter?.auftrag ?? [], letzter?.optionen ?? {}, true)
|
await this.starten(id, letzter?.auftrag ?? [], letzter?.optionen ?? {}, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Startet die Kette. `auftrag` leer heißt: alles rippen (wie früher). */
|
/** Startet die Kette. `auftrag` leer heißt: alles rippen (wie früher).
|
||||||
|
*
|
||||||
|
* 5.7.0 (Durchsicht 12.09.2026, F2): Löst NIE mit einer Ausnahme auf.
|
||||||
|
* Vorher warf `mkdirSync` bei nicht erreichbarer Ablage aus dieser
|
||||||
|
* Funktion heraus, der Aufrufer (`void … .then(() => starten())`)
|
||||||
|
* verschluckte das — kein Fehler im Fenster, keine Zeile im Protokoll,
|
||||||
|
* der Dialog ging zu, nichts passierte (Sonde 10). Jetzt wird jede
|
||||||
|
* Ausnahme zur Fehler-Meldung des Laufwerks (R4). */
|
||||||
async starten(
|
async starten(
|
||||||
id: string,
|
id: string,
|
||||||
auftrag: readonly RipAuftragTitel[] = [],
|
auftrag: readonly RipAuftragTitel[] = [],
|
||||||
optionen: RipAuftragOptionen = {},
|
optionen: RipAuftragOptionen = {},
|
||||||
rettung = false,
|
rettung = false,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.lauf(id, auftrag, optionen, rettung)
|
||||||
|
} catch (fehler) {
|
||||||
|
const vorgangId = this.laufend.get(id)?.status.vorgangId
|
||||||
|
this.laufend.delete(id)
|
||||||
|
const text = `Rip auf Laufwerk ${id} abgebrochen — unerwarteter Fehler: ${String(fehler).slice(0, 300)}`
|
||||||
|
if (vorgangId !== undefined) this.umgebung.vorgangEndet?.(vorgangId, 'fehler', text)
|
||||||
|
this.umgebung.melden({ ...this.fehler(id, text), ...(vorgangId !== undefined ? { vorgangId } : {}) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ist dieser Ordner da und beschreibbar? Eine Ablage auf einer NAS oder
|
||||||
|
* einem Netzlaufwerk ist beim Start nicht selbstverständlich erreichbar —
|
||||||
|
* ausprobieren statt annehmen (Regel aus AGENTS: Wo eine Eigenschaft
|
||||||
|
* ausprobierbar ist, probiere sie aus). '' wenn alles gut, sonst der Grund. */
|
||||||
|
static schreibprobe(ordner: string): string {
|
||||||
|
try {
|
||||||
|
mkdirSync(ordner, { recursive: true })
|
||||||
|
const probe = join(ordner, `.rippy-schreibprobe-${process.pid}`)
|
||||||
|
writeFileSync(probe, 'rippy')
|
||||||
|
unlinkSync(probe)
|
||||||
|
return ''
|
||||||
|
} catch (fehler) {
|
||||||
|
return String(fehler instanceof Error ? fehler.message : fehler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async lauf(
|
||||||
|
id: string,
|
||||||
|
auftrag: readonly RipAuftragTitel[],
|
||||||
|
optionen: RipAuftragOptionen,
|
||||||
|
rettung: boolean,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (this.laufend.has(id)) {
|
if (this.laufend.has(id)) {
|
||||||
this.umgebung.melden(this.fehler(id, `Auf Laufwerk ${id}: läuft schon ein Vorgang.`))
|
this.umgebung.melden(this.fehler(id, `Auf Laufwerk ${id}: läuft schon ein Vorgang.`))
|
||||||
@@ -226,6 +274,21 @@ export class Pipeline {
|
|||||||
const startZeit = this.umgebung.jetzt?.() ?? new Date()
|
const startZeit = this.umgebung.jetzt?.() ?? new Date()
|
||||||
const vorgangsName = vorgangsNameFuer(geraet, startZeit)
|
const vorgangsName = vorgangsNameFuer(geraet, startZeit)
|
||||||
|
|
||||||
|
// 5.7.0 (F2): Erst nachsehen, ob das Ziel überhaupt beschreibbar ist —
|
||||||
|
// eine NAS, die gerade weg ist, soll einen Satz in der Kachel
|
||||||
|
// hinterlassen, nicht ein stilles Nichts.
|
||||||
|
const zielWurzel = geraet.typ === 'cd' || geraet.typ === 'unknown' ? this.umgebung.ablage() : (this.umgebung.rohWurzel?.() ?? join(this.umgebung.ablage(), 'roh'))
|
||||||
|
const probe = Pipeline.schreibprobe(zielWurzel)
|
||||||
|
if (probe.length > 0) {
|
||||||
|
this.umgebung.melden(
|
||||||
|
this.fehler(
|
||||||
|
id,
|
||||||
|
`Rip nicht gestartet: Der Ordner ${zielWurzel} ist nicht erreichbar oder nicht beschreibbar (${probe}). Ist das Netzlaufwerk bzw. die NAS verbunden? Einstellungen → Ablage zeigt die Pfade.`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// § 5 Schritt 2: Musik und Daten gehen eigene Wege (W-7).
|
// § 5 Schritt 2: Musik und Daten gehen eigene Wege (W-7).
|
||||||
if (geraet.typ === 'cd') {
|
if (geraet.typ === 'cd') {
|
||||||
this.laufend.set(id, { abbruch, status: { id, phase: 'rippt', prozent: -1, text: 'Lese Audio-CD …', dateien: [], lesefehler: false } })
|
this.laufend.set(id, { abbruch, status: { id, phase: 'rippt', prozent: -1, text: 'Lese Audio-CD …', dateien: [], lesefehler: false } })
|
||||||
@@ -233,13 +296,22 @@ export class Pipeline {
|
|||||||
await this.audioCdRippen(id, geraet, startZeit, abbruch)
|
await this.audioCdRippen(id, geraet, startZeit, abbruch)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (geraet.typ === 'unknown') {
|
// 5.7.0 (F10): „unbekannt" heißt nur dann ISO, wenn NIEMAND Titel gewählt
|
||||||
|
// hat. Liegt ein Titel-Auftrag vor, hat makemkvcon die Disc gerade
|
||||||
|
// gelesen — dann ist es eine Video-Disc, egal was der Windows-Steuercode
|
||||||
|
// zum Typ sagte (bei manchen USB-Brücken antwortet er gar nicht).
|
||||||
|
if (geraet.typ === 'unknown' && auftrag.length === 0) {
|
||||||
this.laufend.set(id, { abbruch, status: { id, phase: 'rippt', prozent: -1, text: 'Sichere 1:1-Abbild …', dateien: [], lesefehler: false } })
|
this.laufend.set(id, { abbruch, status: { id, phase: 'rippt', prozent: -1, text: 'Sichere 1:1-Abbild …', dateien: [], lesefehler: false } })
|
||||||
this.melden(this.laufend.get(id)!.status)
|
this.melden(this.laufend.get(id)!.status)
|
||||||
await this.isoSichern(id, geraet, vorgangsName, startZeit, abbruch)
|
await this.isoSichern(id, geraet, vorgangsName, startZeit, abbruch)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ein Titel-Auftrag auf einer unbekannt gemeldeten Disc: nach der Größe
|
||||||
|
// ist es eine Blu-ray oder DVD — der Typ steuert nur noch Preset und
|
||||||
|
// Ordnername.
|
||||||
|
const discTyp: DiscTyp = geraet.typ === 'unknown' ? (geraet.groesseBytes >= BLURAY_MIN_BYTES ? 'bluray' : 'dvd') : geraet.typ
|
||||||
|
|
||||||
this.letzterAuftrag.set(id, { auftrag: [...auftrag], optionen: { ...optionen } })
|
this.letzterAuftrag.set(id, { auftrag: [...auftrag], optionen: { ...optionen } })
|
||||||
// 5.6.0: Der Roh-Ordner heißt wie der Film (filme.ts) — der Vorgang
|
// 5.6.0: Der Roh-Ordner heißt wie der Film (filme.ts) — der Vorgang
|
||||||
// behält seinen Zeitstempel-Namen als Kennung.
|
// behält seinen Zeitstempel-Namen als Kennung.
|
||||||
@@ -260,7 +332,7 @@ export class Pipeline {
|
|||||||
this.umgebung.vorgangBeginnt?.({
|
this.umgebung.vorgangBeginnt?.({
|
||||||
id: vorgangsName,
|
id: vorgangsName,
|
||||||
laufwerk: id,
|
laufwerk: id,
|
||||||
typ: geraet.typ,
|
typ: discTyp,
|
||||||
titel: anzeigeTitel,
|
titel: anzeigeTitel,
|
||||||
jahr: zuordnung?.jahr ?? null,
|
jahr: zuordnung?.jahr ?? null,
|
||||||
rohOrdner,
|
rohOrdner,
|
||||||
@@ -279,10 +351,20 @@ export class Pipeline {
|
|||||||
const beenden = (phase: 'fehler' | 'abgebrochen', text: string): void => {
|
const beenden = (phase: 'fehler' | 'abgebrochen', text: string): void => {
|
||||||
this.laufend.delete(id)
|
this.laufend.delete(id)
|
||||||
// Ein Roh-Ordner ohne eine einzige brauchbare Datei ist Müll (5.4.0,
|
// Ein Roh-Ordner ohne eine einzige brauchbare Datei ist Müll (5.4.0,
|
||||||
// Punkt „Aufräumen nach Abbruch").
|
// Punkt „Aufräumen nach Abbruch"). 5.7.0 (Durchsicht F8): AUSSER dem
|
||||||
|
// Rettungs-Abbild — dafür wurde die Disc stundenlang Sektor für Sektor
|
||||||
|
// gelesen; es bleibt liegen, auch wenn der Rip daraus leer ausging.
|
||||||
if (alleDateien.size === 0) {
|
if (alleDateien.size === 0) {
|
||||||
try {
|
try {
|
||||||
rmSync(rohOrdner, { recursive: true, force: true })
|
const rettungDa = existsSync(join(rohOrdner, RETTUNG_ISO))
|
||||||
|
if (rettungDa) {
|
||||||
|
for (const name of dateienIn(rohOrdner)) {
|
||||||
|
if (!RETTUNG_DATEIEN.includes(name)) rmSync(join(rohOrdner, name), { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
text += ` Das Rettungs-Abbild bleibt unter ${rohOrdner}.`
|
||||||
|
} else {
|
||||||
|
rmSync(rohOrdner, { recursive: true, force: true })
|
||||||
|
}
|
||||||
} catch (fehler) {
|
} catch (fehler) {
|
||||||
text += ` (Roh-Ordner ließ sich nicht entfernen: ${String(fehler)})`
|
text += ` (Roh-Ordner ließ sich nicht entfernen: ${String(fehler)})`
|
||||||
}
|
}
|
||||||
@@ -302,7 +384,7 @@ export class Pipeline {
|
|||||||
// ── 5.5.0: Rettungs-Abbild — die Quelle des Rips wird die ISO ────────
|
// ── 5.5.0: Rettungs-Abbild — die Quelle des Rips wird die ISO ────────
|
||||||
let quellePfad = geraet.pfad
|
let quellePfad = geraet.pfad
|
||||||
if (rettung) {
|
if (rettung) {
|
||||||
const isoPfad = join(rohOrdner, 'rettung.iso')
|
const isoPfad = join(rohOrdner, RETTUNG_ISO)
|
||||||
this.melden({
|
this.melden({
|
||||||
id,
|
id,
|
||||||
phase: 'rettet',
|
phase: 'rettet',
|
||||||
@@ -430,7 +512,7 @@ export class Pipeline {
|
|||||||
abbruch.signal.removeEventListener('abort', weiterreichen)
|
abbruch.signal.removeEventListener('abort', weiterreichen)
|
||||||
|
|
||||||
if (rip.status === 'cancelled') {
|
if (rip.status === 'cancelled') {
|
||||||
aufgeraeumt.push(...halbeDateienEntfernen(rohOrdner, vorher, rettung ? ['rettung.iso', 'rettung.iso.rettung.txt'] : []))
|
aufgeraeumt.push(...halbeDateienEntfernen(rohOrdner, vorher, rettung ? RETTUNG_DATEIEN : []))
|
||||||
// Nur dieser Titel — oder der ganze Auftrag?
|
// Nur dieser Titel — oder der ganze Auftrag?
|
||||||
if (this.laufend.get(id)?.uebersprungen !== true) {
|
if (this.laufend.get(id)?.uebersprungen !== true) {
|
||||||
beenden(
|
beenden(
|
||||||
@@ -452,7 +534,7 @@ export class Pipeline {
|
|||||||
// vielleicht heil. Der Grund wird gesammelt und am Ende genannt.
|
// vielleicht heil. Der Grund wird gesammelt und am Ende genannt.
|
||||||
// Was dieser Lauf hinterlassen hat, ist nicht vertrauenswürdig.
|
// Was dieser Lauf hinterlassen hat, ist nicht vertrauenswürdig.
|
||||||
if (!rettung) this.fehlerGesehen.set(id, `Titel gescheitert: ${rip.fehler.slice(0, 80)}`)
|
if (!rettung) this.fehlerGesehen.set(id, `Titel gescheitert: ${rip.fehler.slice(0, 80)}`)
|
||||||
aufgeraeumt.push(...halbeDateienEntfernen(rohOrdner, vorher, rettung ? ['rettung.iso', 'rettung.iso.rettung.txt'] : []))
|
aufgeraeumt.push(...halbeDateienEntfernen(rohOrdner, vorher, rettung ? RETTUNG_DATEIEN : []))
|
||||||
gescheitert.push(`${beschriftung}: ${rip.fehler}`)
|
gescheitert.push(`${beschriftung}: ${rip.fehler}`)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -493,6 +575,10 @@ export class Pipeline {
|
|||||||
audioSprachen: a?.audioSprachen ?? [],
|
audioSprachen: a?.audioSprachen ?? [],
|
||||||
untertitelSprachen: a?.untertitelSprachen ?? [],
|
untertitelSprachen: a?.untertitelSprachen ?? [],
|
||||||
...(a?.untertitelVorwahl === null || typeof a?.untertitelVorwahl === 'string' ? { untertitelVorwahl: a.untertitelVorwahl } : {}),
|
...(a?.untertitelVorwahl === null || typeof a?.untertitelVorwahl === 'string' ? { untertitelVorwahl: a.untertitelVorwahl } : {}),
|
||||||
|
// 5.7.0: die einzeln gewählten Spuren reisen mit in die Ablage.
|
||||||
|
...(a?.audioSpuren !== undefined ? { audioSpuren: a.audioSpuren } : {}),
|
||||||
|
...(a?.untertitelSpuren !== undefined ? { untertitelSpuren: a.untertitelSpuren } : {}),
|
||||||
|
...(a?.untertitelVorwahlSpur === null || typeof a?.untertitelVorwahlSpur === 'number' ? { untertitelVorwahlSpur: a.untertitelVorwahlSpur } : {}),
|
||||||
...(a?.film !== undefined ? { film: a.film } : {}),
|
...(a?.film !== undefined ? { film: a.film } : {}),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -527,7 +613,7 @@ export class Pipeline {
|
|||||||
this.umgebung.vorgangBeginnt?.({
|
this.umgebung.vorgangBeginnt?.({
|
||||||
id: gruppenId,
|
id: gruppenId,
|
||||||
laufwerk: id,
|
laufwerk: id,
|
||||||
typ: geraet.typ,
|
typ: discTyp,
|
||||||
titel: gruppenZuordnung.titel,
|
titel: gruppenZuordnung.titel,
|
||||||
jahr: gruppenZuordnung.jahr,
|
jahr: gruppenZuordnung.jahr,
|
||||||
rohOrdner: gruppenOrdner,
|
rohOrdner: gruppenOrdner,
|
||||||
@@ -538,7 +624,7 @@ export class Pipeline {
|
|||||||
auftrag: {
|
auftrag: {
|
||||||
vorgangId: gruppenId,
|
vorgangId: gruppenId,
|
||||||
laufwerk: id,
|
laufwerk: id,
|
||||||
discTyp: geraet.typ,
|
discTyp,
|
||||||
rohOrdner: gruppenOrdner,
|
rohOrdner: gruppenOrdner,
|
||||||
dateien: gruppe.dateien,
|
dateien: gruppe.dateien,
|
||||||
zuordnung: gruppenZuordnung,
|
zuordnung: gruppenZuordnung,
|
||||||
@@ -559,7 +645,7 @@ export class Pipeline {
|
|||||||
const kompression: KompressionsAuftrag = {
|
const kompression: KompressionsAuftrag = {
|
||||||
vorgangId: vorgangsName,
|
vorgangId: vorgangsName,
|
||||||
laufwerk: id,
|
laufwerk: id,
|
||||||
discTyp: geraet.typ,
|
discTyp,
|
||||||
rohOrdner,
|
rohOrdner,
|
||||||
dateien: hauptDateien,
|
dateien: hauptDateien,
|
||||||
zuordnung: hauptZuordnung,
|
zuordnung: hauptZuordnung,
|
||||||
@@ -667,10 +753,17 @@ export class Pipeline {
|
|||||||
const wav = join(arbeit, 'spur.wav')
|
const wav = join(arbeit, 'spur.wav')
|
||||||
const strom = createWriteStream(wav)
|
const strom = createWriteStream(wav)
|
||||||
strom.write(wavKopf(spur.sektoren * SEKTOR_BYTES))
|
strom.write(wavKopf(spur.sektoren * SEKTOR_BYTES))
|
||||||
this.umgebung.audioSpurLesen(id, spur.start, spur.sektoren, (daten) => strom.write(daten), (anteil) => {
|
await this.umgebung.audioSpurLesen(
|
||||||
const s = this.laufend.get(id)?.status
|
id,
|
||||||
if (s !== undefined) this.melden({ ...s, prozent: Math.floor(((i + anteil) * 100) / spuren.length) })
|
spur.start,
|
||||||
})
|
spur.sektoren,
|
||||||
|
(daten) => strom.write(daten),
|
||||||
|
(anteil) => {
|
||||||
|
const s = this.laufend.get(id)?.status
|
||||||
|
if (s !== undefined) this.melden({ ...s, prozent: Math.floor(((i + anteil) * 100) / spuren.length) })
|
||||||
|
},
|
||||||
|
abbruch.signal,
|
||||||
|
)
|
||||||
await new Promise<void>((fertig, kaputt) => {
|
await new Promise<void>((fertig, kaputt) => {
|
||||||
strom.end(() => fertig())
|
strom.end(() => fertig())
|
||||||
strom.on('error', kaputt)
|
strom.on('error', kaputt)
|
||||||
|
|||||||
@@ -74,7 +74,15 @@ export class AutomatikDienst {
|
|||||||
async pruefen(geraet: GeraeteInfo, zuordnung: Zuordnung | null, schonGerippt: string | null): Promise<void> {
|
async pruefen(geraet: GeraeteInfo, zuordnung: Zuordnung | null, schonGerippt: string | null): Promise<void> {
|
||||||
const id = geraet.id
|
const id = geraet.id
|
||||||
if (!this.abh.an()) return
|
if (!this.abh.an()) return
|
||||||
if (geraet.typ === 'cd' || geraet.typ === 'unknown') return
|
// 5.7.0 (F28): nicht stumm — der Kasten sagt, warum nichts passiert.
|
||||||
|
if (geraet.typ === 'cd') {
|
||||||
|
this.melden(id, false, 0, 'Automatik: Audio-CDs startet Rippy nicht von selbst — „Rippen" liest sie als FLAC.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (geraet.typ === 'unknown') {
|
||||||
|
this.melden(id, false, 0, 'Automatik wartet: Disc-Typ unbekannt — „Rippen" liest die Titel, dann entscheidest du.')
|
||||||
|
return
|
||||||
|
}
|
||||||
if (this.abh.ripLaeuft(id)) return
|
if (this.abh.ripLaeuft(id)) return
|
||||||
if (zuordnung === null || zuordnung.confidence < AUTOMATIK_SICHER) {
|
if (zuordnung === null || zuordnung.confidence < AUTOMATIK_SICHER) {
|
||||||
this.melden(id, false, 0, 'Automatik wartet: Die Erkennung ist nicht sicher genug — bitte selbst starten oder „Ändern …".')
|
this.melden(id, false, 0, 'Automatik wartet: Die Erkennung ist nicht sicher genug — bitte selbst starten oder „Ändern …".')
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ export const ERLAUBTE_EINSTELLUNGEN: ReadonlySet<string> = new Set([
|
|||||||
'telegramToken',
|
'telegramToken',
|
||||||
'telegramChatId',
|
'telegramChatId',
|
||||||
'design',
|
'design',
|
||||||
|
// 5.7.0: wie lange makemkvcon die Titel lesen darf (Kandidat 2 der Durchsicht)
|
||||||
|
'titelLesenMinuten',
|
||||||
])
|
])
|
||||||
|
|
||||||
/** Die Schlüssel, die Rip und Kompression lesen (Pipeline/Ablage). Das
|
/** Die Schlüssel, die Rip und Kompression lesen (Pipeline/Ablage). Das
|
||||||
@@ -56,6 +58,10 @@ const PIPELINE_EINSTELLUNGEN = [
|
|||||||
] as const
|
] as const
|
||||||
|
|
||||||
export const AUTOMATIK_SEKUNDEN_VORGABE = 30
|
export const AUTOMATIK_SEKUNDEN_VORGABE = 30
|
||||||
|
/** 5.7.0: Vorgabe 15 min (Commander-Entscheid 12.09.2026) — kopiergeschützte
|
||||||
|
* DVDs und Discs mit vielen Playlisten brauchen im MakeMKV-GUI regelmäßig
|
||||||
|
* länger als die alten fünf Minuten. */
|
||||||
|
export const TITEL_LESEN_MINUTEN_VORGABE = 15
|
||||||
|
|
||||||
export class EinstellungsDienst {
|
export class EinstellungsDienst {
|
||||||
constructor(private readonly kontext: KernKontext) {}
|
constructor(private readonly kontext: KernKontext) {}
|
||||||
@@ -101,6 +107,12 @@ export class EinstellungsDienst {
|
|||||||
return this.lesen('automatik', 'an') !== 'aus'
|
return this.lesen('automatik', 'an') !== 'aus'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Grenze fürs Titel-Lesen in Minuten (5–60, Vorgabe 15). */
|
||||||
|
titelLesenMinuten(): number {
|
||||||
|
const n = Number(this.lesen('titelLesenMinuten', String(TITEL_LESEN_MINUTEN_VORGABE)))
|
||||||
|
return Number.isInteger(n) && n >= 5 && n <= 60 ? n : TITEL_LESEN_MINUTEN_VORGABE
|
||||||
|
}
|
||||||
|
|
||||||
automatikSekunden(): number {
|
automatikSekunden(): number {
|
||||||
const n = Number(this.lesen('automatikSekunden', String(AUTOMATIK_SEKUNDEN_VORGABE)))
|
const n = Number(this.lesen('automatikSekunden', String(AUTOMATIK_SEKUNDEN_VORGABE)))
|
||||||
return Number.isInteger(n) && n >= 5 && n <= 600 ? n : AUTOMATIK_SEKUNDEN_VORGABE
|
return Number.isInteger(n) && n >= 5 && n <= 600 ? n : AUTOMATIK_SEKUNDEN_VORGABE
|
||||||
|
|||||||
@@ -17,13 +17,15 @@ import { OmdbClient } from '../metadaten/omdb'
|
|||||||
import { TmdbClient } from '../metadaten/tmdb'
|
import { TmdbClient } from '../metadaten/tmdb'
|
||||||
import { LEERER_TREFFER, ausDetails, nachpruefen, zuordnen, type Zuordnung } from '../metadaten/zuordnung'
|
import { LEERER_TREFFER, ausDetails, nachpruefen, zuordnen, type Zuordnung } from '../metadaten/zuordnung'
|
||||||
import { ABGELOEST, titelInfoLesen } from '../rip/makemkv'
|
import { ABGELOEST, titelInfoLesen } from '../rip/makemkv'
|
||||||
import { sprachenFuerTitel, type TitelInfo } from '../rip/parser'
|
import { spurenFuerTitel, sprachenFuerTitel, type TitelInfo } from '../rip/parser'
|
||||||
import { strukturHinweis, titelVorauswahl } from '../rip/titelwahl'
|
import { strukturHinweis, titelVorauswahl } from '../rip/titelwahl'
|
||||||
|
|
||||||
export interface ErkennungAbhaengigkeiten {
|
export interface ErkennungAbhaengigkeiten {
|
||||||
ripLaeuft(id: string): boolean
|
ripLaeuft(id: string): boolean
|
||||||
geraetInfoVon(id: string): GeraeteInfo | null
|
geraetInfoVon(id: string): GeraeteInfo | null
|
||||||
makemkvcon(): string
|
makemkvcon(): string
|
||||||
|
/** 5.7.0: wie lange makemkvcon die Titel lesen darf (Minuten). Fehlt: 15. */
|
||||||
|
titelLesenMinuten?(): number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Der Serien-Merker (5.5.0): je TMDb-Serie, was die vorige Disc hinterließ. */
|
/** Der Serien-Merker (5.5.0): je TMDb-Serie, was die vorige Disc hinterließ. */
|
||||||
@@ -311,8 +313,22 @@ export class ErkennungDienst {
|
|||||||
|
|
||||||
senden({ ...leer, laeuft: true })
|
senden({ ...leer, laeuft: true })
|
||||||
const start = Date.now()
|
const start = Date.now()
|
||||||
|
// 5.7.0 (Kandidat 2): Die Grenze kommt aus den Einstellungen (Vorgabe
|
||||||
|
// 15 min statt fest fünf), und ab der zweiten Minute sagt der Dialog,
|
||||||
|
// dass es länger dauert — mit Abbrechen-Knopf, statt stumm zu drehen.
|
||||||
|
const minuten = this.abh.titelLesenMinuten?.() ?? 15
|
||||||
|
const zwischenstand = setInterval(() => {
|
||||||
|
if (!nochAktuell()) return
|
||||||
|
const vergangen = Math.round((Date.now() - start) / 60_000)
|
||||||
|
senden({
|
||||||
|
...leer,
|
||||||
|
laeuft: true,
|
||||||
|
einordnung: `MakeMKV liest seit ${vergangen} min. Kopiergeschützte DVDs und Discs mit vielen Playlisten brauchen länger — Rippy wartet bis ${minuten} min (Einstellungen → Allgemein), „Abbrechen" beendet den Lauf.`,
|
||||||
|
})
|
||||||
|
}, 60_000)
|
||||||
|
zwischenstand.unref()
|
||||||
try {
|
try {
|
||||||
const info = await titelInfoLesen(programm, geraet.pfad, 300_000, abbruch.signal)
|
const info = await titelInfoLesen(programm, geraet.pfad, minuten * 60_000, abbruch.signal)
|
||||||
if (!nochAktuell()) return { ...leer, fehler: ABGELOEST }
|
if (!nochAktuell()) return { ...leer, fehler: ABGELOEST }
|
||||||
const merkmale = this.merkmale.get(id)
|
const merkmale = this.merkmale.get(id)
|
||||||
const wahl = titelVorauswahl(info.titel, {
|
const wahl = titelVorauswahl(info.titel, {
|
||||||
@@ -332,6 +348,8 @@ export class ErkennungDienst {
|
|||||||
...t,
|
...t,
|
||||||
// Die Sprachen JE TITEL reisen mit (5.4.0) — der Dialog bietet sie zum Anhaken an.
|
// Die Sprachen JE TITEL reisen mit (5.4.0) — der Dialog bietet sie zum Anhaken an.
|
||||||
sprachen: sprachenFuerTitel(info.streams.get(t.nr)),
|
sprachen: sprachenFuerTitel(info.streams.get(t.nr)),
|
||||||
|
// 5.7.0: und jede Spur einzeln.
|
||||||
|
spuren: spurenFuerTitel(info.streams.get(t.nr)),
|
||||||
}))
|
}))
|
||||||
const stand = senden({
|
const stand = senden({
|
||||||
id,
|
id,
|
||||||
@@ -352,11 +370,12 @@ export class ErkennungDienst {
|
|||||||
// R4: sonst laut scheitern. „Konnte nicht nachsehen" ist etwas anderes
|
// R4: sonst laut scheitern. „Konnte nicht nachsehen" ist etwas anderes
|
||||||
// als „die Disc hat keine Titel" — beides zu vermischen erzeugt Unsinn.
|
// als „die Disc hat keine Titel" — beides zu vermischen erzeugt Unsinn.
|
||||||
const grund = String(fehler).includes('keine Antwort nach')
|
const grund = String(fehler).includes('keine Antwort nach')
|
||||||
? 'MakeMKV hat die Titel nach fünf Minuten nicht gelesen. Bei einer beschädigten Disc kann das passieren — Rippy hat den Versuch beendet und das Laufwerk freigegeben.'
|
? `MakeMKV hat die Titel nach ${minuten} Minuten nicht gelesen — Rippy hat den Versuch beendet und das Laufwerk freigegeben. Das kann an einer beschädigten Disc liegen, aber auch an Kopierschutz oder vielen Playlisten, die einfach länger brauchen: Die Grenze steht unter Einstellungen → Allgemein. Zur Probe die Disc im MakeMKV-Programm öffnen und die Zeit stoppen.`
|
||||||
: `Titel lesen: ${String(fehler).slice(0, 200)}`
|
: `Titel lesen: ${String(fehler).slice(0, 200)}`
|
||||||
this.kontext.protokoll.schreiben(`Titel-Lauf ${id} gescheitert: ${grund}`)
|
this.kontext.protokoll.schreiben(`Titel-Lauf ${id} gescheitert: ${grund}`)
|
||||||
return senden({ ...leer, fehler: grund })
|
return senden({ ...leer, fehler: grund })
|
||||||
} finally {
|
} finally {
|
||||||
|
clearInterval(zwischenstand)
|
||||||
// Nur aufräumen, wenn ICH noch der aktuelle Lauf bin.
|
// Nur aufräumen, wenn ICH noch der aktuelle Lauf bin.
|
||||||
if (nochAktuell()) {
|
if (nochAktuell()) {
|
||||||
this.titelAbbrueche.delete(id)
|
this.titelAbbrueche.delete(id)
|
||||||
@@ -365,6 +384,18 @@ export class ErkennungDienst {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Der Nutzer bricht das Titel-Lesen ab (5.7.0): Der Lauf endet sofort,
|
||||||
|
* makemkvcon gibt das Laufwerk frei, der Dialog zeigt „abgebrochen". */
|
||||||
|
titelAbbrechen(id: string): void {
|
||||||
|
const abbruch = this.titelAbbrueche.get(id)
|
||||||
|
if (abbruch === undefined) return
|
||||||
|
abbruch.abort()
|
||||||
|
this.titelAbbrueche.delete(id)
|
||||||
|
this.titelLaufNummern.delete(id)
|
||||||
|
this.kontext.protokoll.schreiben(`Titel-Lauf ${id} vom Nutzer abgebrochen`)
|
||||||
|
this.kontext.anFenster({ art: 'disc-titel', stand: { id, laeuft: false, titel: [], einordnung: '', unsicher: false, fehler: 'Abgebrochen — „Rippen" liest die Titel neu.' } })
|
||||||
|
}
|
||||||
|
|
||||||
/** Erkennung v2 (5.4.0): Erkannt wird beim EINLEGEN, vor jeder Messung.
|
/** Erkennung v2 (5.4.0): Erkannt wird beim EINLEGEN, vor jeder Messung.
|
||||||
* Nach dem Info-Lauf wird noch einmal gerechnet — Laufzeit gegen TMDb,
|
* Nach dem Info-Lauf wird noch einmal gerechnet — Laufzeit gegen TMDb,
|
||||||
* Titel-Struktur gegen den Treffer-Typ. Aus „wahrscheinlich" wird
|
* Titel-Struktur gegen den Treffer-Typ. Aus „wahrscheinlich" wird
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
listOptischeLaufwerke,
|
listOptischeLaufwerke,
|
||||||
} from '../laufwerk/disc'
|
} from '../laufwerk/disc'
|
||||||
import { beurteilen, ereignisseLesen } from '../laufwerk/ereignisse'
|
import { beurteilen, ereignisseLesen } from '../laufwerk/ereignisse'
|
||||||
|
import { typAusDateisystem } from '../metadaten/discmerkmale'
|
||||||
import { DiscWache } from '../laufwerk/wache'
|
import { DiscWache } from '../laufwerk/wache'
|
||||||
import type { Spur } from '../rip/audiocd'
|
import type { Spur } from '../rip/audiocd'
|
||||||
|
|
||||||
@@ -40,6 +41,9 @@ export class LaufwerkDienst {
|
|||||||
* Leer im normalen Betrieb; die Win32-Schicht bleibt dann der einzige Weg. */
|
* Leer im normalen Betrieb; die Win32-Schicht bleibt dann der einzige Weg. */
|
||||||
readonly fake: string
|
readonly fake: string
|
||||||
readonly bereit: Promise<void>
|
readonly bereit: Promise<void>
|
||||||
|
/** 5.7.0 (F10): Der Typ aus dem Dateisystem, je Laufwerk und Disc-Größe
|
||||||
|
* gemerkt — die Wache fragt alle 3 s, die Disc ändert sich nicht. */
|
||||||
|
private typGemerkt = new Map<string, { groesse: number; typ: GeraeteInfo['typ'] }>()
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly kontext: KernKontext,
|
private readonly kontext: KernKontext,
|
||||||
@@ -99,7 +103,7 @@ export class LaufwerkDienst {
|
|||||||
const { win32Api } = await import('../laufwerk/win32')
|
const { win32Api } = await import('../laufwerk/win32')
|
||||||
this.api = win32Api()
|
this.api = win32Api()
|
||||||
this.wache = new DiscWache(
|
this.wache = new DiscWache(
|
||||||
() => listOptischeLaufwerke(this.api!).map((g) => geraeteInfo(this.api!, g)),
|
() => listOptischeLaufwerke(this.api!).map((g) => this.typVerfeinern(geraeteInfo(this.api!, g))),
|
||||||
(m) => this.wacheMeldung(m),
|
(m) => this.wacheMeldung(m),
|
||||||
wacheFehler,
|
wacheFehler,
|
||||||
)
|
)
|
||||||
@@ -115,6 +119,30 @@ export class LaufwerkDienst {
|
|||||||
return this.wache?.aktuellerStand() ?? []
|
return this.wache?.aktuellerStand() ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Was auf der Disc liegt, schlägt Steuercode und Größe (F10): BDMV mit
|
||||||
|
* INDX0300 ist UHD, sonst Blu-ray; VIDEO_TS ist DVD. Nur bei „ready",
|
||||||
|
* und nur einmal je Disc (gemerkt an der Größe). */
|
||||||
|
private typVerfeinern(geraet: GeraeteInfo): GeraeteInfo {
|
||||||
|
if (geraet.status !== 'ready' || geraet.typ === 'cd') {
|
||||||
|
this.typGemerkt.delete(geraet.id)
|
||||||
|
return geraet
|
||||||
|
}
|
||||||
|
const alt = this.typGemerkt.get(geraet.id)
|
||||||
|
if (alt !== undefined && alt.groesse === geraet.groesseBytes) return alt.typ === geraet.typ ? geraet : { ...geraet, typ: alt.typ }
|
||||||
|
let typ = geraet.typ
|
||||||
|
try {
|
||||||
|
const gesehen = typAusDateisystem(geraet.pfad)
|
||||||
|
if (gesehen !== null && gesehen !== geraet.typ) {
|
||||||
|
this.kontext.protokoll.schreiben(`Laufwerk ${geraet.id}: Typ laut Dateisystem ${gesehen} (Steuercode/Größe sagten ${geraet.typ})`)
|
||||||
|
typ = gesehen
|
||||||
|
}
|
||||||
|
} catch (fehler) {
|
||||||
|
this.kontext.protokoll.schreiben(`Laufwerk ${geraet.id}: Dateisystem nicht lesbar (${String(fehler)})`)
|
||||||
|
}
|
||||||
|
this.typGemerkt.set(geraet.id, { groesse: geraet.groesseBytes, typ })
|
||||||
|
return typ === geraet.typ ? geraet : { ...geraet, typ }
|
||||||
|
}
|
||||||
|
|
||||||
/** Der Geräte-Name zu einer Kennung — oder null. */
|
/** Der Geräte-Name zu einer Kennung — oder null. */
|
||||||
geraetVon(id: string): string | null {
|
geraetVon(id: string): string | null {
|
||||||
if (this.api === null) return null
|
if (this.api === null) return null
|
||||||
@@ -126,7 +154,7 @@ export class LaufwerkDienst {
|
|||||||
if (this.fake.length > 0) return id === this.fake ? this.fakeGeraet() : null
|
if (this.fake.length > 0) return id === this.fake ? this.fakeGeraet() : null
|
||||||
const geraet = this.geraetVon(id)
|
const geraet = this.geraetVon(id)
|
||||||
if (geraet === null || this.api === null) return null
|
if (geraet === null || this.api === null) return null
|
||||||
return geraeteInfo(this.api, geraet)
|
return this.typVerfeinern(geraeteInfo(this.api, geraet))
|
||||||
}
|
}
|
||||||
|
|
||||||
audioToc(id: string): Spur[] {
|
audioToc(id: string): Spur[] {
|
||||||
@@ -141,10 +169,11 @@ export class LaufwerkDienst {
|
|||||||
sektoren: number,
|
sektoren: number,
|
||||||
schreiben: (daten: Buffer) => void,
|
schreiben: (daten: Buffer) => void,
|
||||||
fortschritt?: (anteil: number) => void,
|
fortschritt?: (anteil: number) => void,
|
||||||
): number {
|
abbruch?: AbortSignal,
|
||||||
|
): Promise<number> {
|
||||||
const geraet = this.geraetVon(id)
|
const geraet = this.geraetVon(id)
|
||||||
if (this.api === null || geraet === null) throw new Error(`Laufwerk ${id}: gibt es nicht`)
|
if (this.api === null || geraet === null) return Promise.reject(new Error(`Laufwerk ${id}: gibt es nicht`))
|
||||||
return audioSpurLesen(this.api, geraet, start, sektoren, schreiben, fortschritt)
|
return audioSpurLesen(this.api, geraet, start, sektoren, schreiben, fortschritt, abbruch)
|
||||||
}
|
}
|
||||||
|
|
||||||
async auswerfen(id: string): Promise<void> {
|
async auswerfen(id: string): Promise<void> {
|
||||||
@@ -195,7 +224,16 @@ export class LaufwerkDienst {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const bericht = beurteilen(ergebnis.ereignisse, GESUNDHEIT_STUNDEN)
|
// 5.7.0 (F11): nur die Ereignisse DIESES Geräts — \Device\CdRomN
|
||||||
|
// hinter dem Buchstaben, nicht die einer Festplatte oder eines zweiten
|
||||||
|
// Laufwerks. Ohne Gerätename (Nachbau, Fehlschlag) zählt nur cdrom.
|
||||||
|
let geraet = ''
|
||||||
|
try {
|
||||||
|
geraet = this.api?.geraeteName(id) ?? ''
|
||||||
|
} catch (fehler) {
|
||||||
|
this.kontext.protokoll.schreiben(`Laufwerk ${id}: Gerätename nicht ermittelbar (${String(fehler)})`)
|
||||||
|
}
|
||||||
|
const bericht = beurteilen(ergebnis.ereignisse, GESUNDHEIT_STUNDEN, undefined, geraet)
|
||||||
this.kontext.protokoll.schreiben(`Laufwerk ${id} geprüft: ${bericht.urteil} — ${bericht.text}`)
|
this.kontext.protokoll.schreiben(`Laufwerk ${id} geprüft: ${bericht.urteil} — ${bericht.text}`)
|
||||||
this.kontext.anFenster({ art: 'laufwerk-gesundheit', id, bericht })
|
this.kontext.anFenster({ art: 'laufwerk-gesundheit', id, bericht })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,12 +115,46 @@ export class VorgangDienst {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Jede Änderung der Warteschlange — Fortschritt eingeschlossen. 5.7.0
|
||||||
|
* (Durchsicht F7): Ein Prozent-Tick ist eine KLEINE Nachricht
|
||||||
|
* (kompression-stand); vorher löste jeder Tick (HandBrake: etwa einmal je
|
||||||
|
* Sekunde) den vollen Vorgangs-Stand aus — mit einem Ordnerlauf durch
|
||||||
|
* jeden Roh-Ordner, zwei statfs und einem kompletten Neuzeichnen des
|
||||||
|
* Fensters. Der volle Stand geht nur noch bei einem Phasenwechsel. */
|
||||||
private standGeaendert(laeuft: KompressionStand | null): void {
|
private standGeaendert(laeuft: KompressionStand | null): void {
|
||||||
if (laeuft !== null && this.letztePhase.get(laeuft.vorgangId) !== laeuft.phase) {
|
const phasenwechsel = laeuft !== null && this.letztePhase.get(laeuft.vorgangId) !== laeuft.phase
|
||||||
|
if (laeuft !== null && phasenwechsel) {
|
||||||
this.letztePhase.set(laeuft.vorgangId, laeuft.phase)
|
this.letztePhase.set(laeuft.vorgangId, laeuft.phase)
|
||||||
this.db?.vorgangAktualisieren(laeuft.vorgangId, { phase: laeuft.phase === 'legt-ab' ? 'legt-ab' : 'komprimiert' })
|
this.db?.vorgangAktualisieren(laeuft.vorgangId, { phase: laeuft.phase === 'legt-ab' ? 'legt-ab' : 'komprimiert' })
|
||||||
}
|
}
|
||||||
this.senden()
|
if (phasenwechsel || laeuft === null) {
|
||||||
|
this.senden()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.kontext.anHauptUndFenster({
|
||||||
|
art: 'kompression-stand',
|
||||||
|
kompression: { ...this.warteschlange.stand(), pausiert: this.warteschlange.istPausiert() },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Roh-Größen sind teuer (rekursiver Ordnerlauf, bei einer NAS über das
|
||||||
|
// Netz) und ändern sich nur, wenn Rippy selbst etwas anlegt oder löscht —
|
||||||
|
// deshalb 30 s lang gemerkt (F7). Wer löscht, leert das Gedächtnis.
|
||||||
|
private groessen = new Map<string, { bytes: number; wann: number }>()
|
||||||
|
private static readonly GROESSEN_FRISCH_MS = 30_000
|
||||||
|
|
||||||
|
private groesseGemerkt(pfad: string): number {
|
||||||
|
const jetzt = Date.now()
|
||||||
|
const alt = this.groessen.get(pfad)
|
||||||
|
if (alt !== undefined && jetzt - alt.wann < VorgangDienst.GROESSEN_FRISCH_MS) return alt.bytes
|
||||||
|
const bytes = ordnerGroesse(pfad)
|
||||||
|
this.groessen.set(pfad, { bytes, wann: jetzt })
|
||||||
|
return bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nach Löschen oder Aufräumen: frisch messen. */
|
||||||
|
private groessenVergessen(): void {
|
||||||
|
this.groessen.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ergebnis(auftrag: KompressionsAuftrag, ergebnis: AblageErgebnis): Promise<void> {
|
private async ergebnis(auftrag: KompressionsAuftrag, ergebnis: AblageErgebnis): Promise<void> {
|
||||||
@@ -220,7 +254,7 @@ export class VorgangDienst {
|
|||||||
let rohGesamtBytes = 0
|
let rohGesamtBytes = 0
|
||||||
const vorgaenge: VorgangEintrag[] = liste.map((v) => {
|
const vorgaenge: VorgangEintrag[] = liste.map((v) => {
|
||||||
const rohDa = v.rohGeloeschtAm === null && existsSync(v.rohOrdner)
|
const rohDa = v.rohGeloeschtAm === null && existsSync(v.rohOrdner)
|
||||||
const rohBytes = rohDa ? ordnerGroesse(v.rohOrdner) : 0
|
const rohBytes = rohDa ? this.groesseGemerkt(v.rohOrdner) : 0
|
||||||
rohGesamtBytes += rohBytes
|
rohGesamtBytes += rohBytes
|
||||||
const auftrag = auftragAusJson(v.auftrag)
|
const auftrag = auftragAusJson(v.auftrag)
|
||||||
const ruhig = v.phase === 'fertig' || v.phase === 'fehler' || v.phase === 'abgebrochen'
|
const ruhig = v.phase === 'fertig' || v.phase === 'fehler' || v.phase === 'abgebrochen'
|
||||||
@@ -242,12 +276,15 @@ export class VorgangDienst {
|
|||||||
preset: v.preset,
|
preset: v.preset,
|
||||||
fehler: v.fehler,
|
fehler: v.fehler,
|
||||||
titelZahl: auftrag?.dateien.length ?? 0,
|
titelZahl: auftrag?.dateien.length ?? 0,
|
||||||
neuKomprimierbar: rohDa && ruhig && auftrag !== null && auftrag.dateien.length > 0 && !this.warteschlange.kennt(v.id),
|
// 5.7.0 (F27): Ein fertiger, UNkomprimierter Vorgang hat seine Roh-
|
||||||
|
// Dateien verschoben — da ist nichts mehr zu wiederholen.
|
||||||
|
neuKomprimierbar:
|
||||||
|
rohDa && ruhig && !(v.phase === 'fertig' && !v.komprimiert) && auftrag !== null && auftrag.dateien.length > 0 && !this.warteschlange.kennt(v.id),
|
||||||
faelligAm: v.phase === 'fertig' && v.komprimiert && rohDa ? faelligAm(v.beendet, regel) : '',
|
faelligAm: v.phase === 'fertig' && v.komprimiert && rohDa ? faelligAm(v.beendet, regel) : '',
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const bekannte = new Set(liste.map((v) => v.rohOrdner))
|
const bekannte = new Set(liste.map((v) => v.rohOrdner))
|
||||||
const fremde = fremdeOrdner(this.einstellungen.rohWurzel(), bekannte)
|
const fremde = fremdeOrdner(this.einstellungen.rohWurzel(), bekannte, (pfad) => this.groesseGemerkt(pfad))
|
||||||
for (const f of fremde) rohGesamtBytes += f.bytes
|
for (const f of fremde) rohGesamtBytes += f.bytes
|
||||||
return {
|
return {
|
||||||
vorgaenge,
|
vorgaenge,
|
||||||
@@ -259,6 +296,12 @@ export class VorgangDienst {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Der Knopf „Aktualisieren": frisch messen, nicht aus dem Gedächtnis. */
|
||||||
|
neuMessen(): void {
|
||||||
|
this.groessenVergessen()
|
||||||
|
this.senden()
|
||||||
|
}
|
||||||
|
|
||||||
senden(): void {
|
senden(): void {
|
||||||
try {
|
try {
|
||||||
this.kontext.anHauptUndFenster({ art: 'vorgaenge', stand: this.stand() })
|
this.kontext.anHauptUndFenster({ art: 'vorgaenge', stand: this.stand() })
|
||||||
@@ -290,6 +333,7 @@ export class VorgangDienst {
|
|||||||
fertigeDateien: dateienAusJson(v.fertigeDateien, (g) => this.kontext.protokoll.schreiben(`${v.id}: ${g}`)),
|
fertigeDateien: dateienAusJson(v.fertigeDateien, (g) => this.kontext.protokoll.schreiben(`${v.id}: ${g}`)),
|
||||||
}))
|
}))
|
||||||
const ergebnis = aufraeumen(kandidaten, regel, new Date())
|
const ergebnis = aufraeumen(kandidaten, regel, new Date())
|
||||||
|
if (ergebnis.geloescht.length > 0) this.groessenVergessen()
|
||||||
const jetzt = new Date().toISOString()
|
const jetzt = new Date().toISOString()
|
||||||
for (const id of ergebnis.geloescht) {
|
for (const id of ergebnis.geloescht) {
|
||||||
this.db.vorgangAktualisieren(id, { rohGeloeschtAm: jetzt, rohBytes: this.db.vorgangLesen(id)?.rohBytes ?? 0 })
|
this.db.vorgangAktualisieren(id, { rohGeloeschtAm: jetzt, rohBytes: this.db.vorgangLesen(id)?.rohBytes ?? 0 })
|
||||||
@@ -321,6 +365,7 @@ export class VorgangDienst {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
rmSync(v.rohOrdner, { recursive: true, force: true })
|
rmSync(v.rohOrdner, { recursive: true, force: true })
|
||||||
|
this.groessenVergessen()
|
||||||
this.db?.vorgangAktualisieren(vorgangId, { rohGeloeschtAm: new Date().toISOString() })
|
this.db?.vorgangAktualisieren(vorgangId, { rohGeloeschtAm: new Date().toISOString() })
|
||||||
this.kontext.hinweis(`Roh-Dateien von „${v.titel}" gelöscht (von Hand).`)
|
this.kontext.hinweis(`Roh-Dateien von „${v.titel}" gelöscht (von Hand).`)
|
||||||
} catch (fehler) {
|
} catch (fehler) {
|
||||||
@@ -341,6 +386,7 @@ export class VorgangDienst {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
rmSync(ordner, { recursive: true, force: true })
|
rmSync(ordner, { recursive: true, force: true })
|
||||||
|
this.groessenVergessen()
|
||||||
this.kontext.hinweis(`Ordner „${ordner}" gelöscht (von Hand).`)
|
this.kontext.hinweis(`Ordner „${ordner}" gelöscht (von Hand).`)
|
||||||
} catch (fehler) {
|
} catch (fehler) {
|
||||||
this.kontext.fehler(`„${ordner}" nicht gelöscht: ${String(fehler)}`)
|
this.kontext.fehler(`„${ordner}" nicht gelöscht: ${String(fehler)}`)
|
||||||
|
|||||||
@@ -15,26 +15,51 @@ export class WerkzeugDienst {
|
|||||||
schluesselStand: SchluesselStand | null = null
|
schluesselStand: SchluesselStand | null = null
|
||||||
private beschaffungLaeuft = false
|
private beschaffungLaeuft = false
|
||||||
private installerAntwort: ((fehler: string | null) => void) | null = null
|
private installerAntwort: ((fehler: string | null) => void) | null = null
|
||||||
|
/** 5.7.0 (F16): Die offene Ja/Nein-Frage der Beschaffung — null, wenn keine. */
|
||||||
|
private frageAntwort: ((ja: boolean) => void) | null = null
|
||||||
|
private letzterBeschaffungsText = ''
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly kontext: KernKontext,
|
private readonly kontext: KernKontext,
|
||||||
private readonly einstellungen: EinstellungsDienst,
|
private readonly einstellungen: EinstellungsDienst,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
// 5.7.0 (Durchsicht F22): Ein Fund gilt 60 s. Vorher lief bei fehlendem
|
||||||
|
// MakeMKV bei JEDEM Aufruf die Suchkette bis in die Registry (dreimal
|
||||||
|
// `reg query … /s`, synchron, bis 45 s) — und makemkvcon() wird oft
|
||||||
|
// gerufen: je Pipeline-Start, je Titel-Lauf, im Update-Lauf alle 15 s.
|
||||||
|
private gefunden = new Map<string, { pfad: string; eingestellt: string; wann: number }>()
|
||||||
|
private static readonly FUND_FRISCH_MS = 60_000
|
||||||
|
|
||||||
|
private suchen(name: string, schluessel: string): string {
|
||||||
|
const eingestellt = this.einstellungen.lesen(schluessel)
|
||||||
|
const alt = this.gefunden.get(name)
|
||||||
|
if (alt !== undefined && alt.eingestellt === eingestellt && Date.now() - alt.wann < WerkzeugDienst.FUND_FRISCH_MS) return alt.pfad
|
||||||
|
const pfad = finden(name, eingestellt)
|
||||||
|
this.gefunden.set(name, { pfad, eingestellt, wann: Date.now() })
|
||||||
|
return pfad
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nach einer Installation oder einem neuen Pfad: frisch suchen. */
|
||||||
|
neuSuchen(): void {
|
||||||
|
this.gefunden.clear()
|
||||||
|
}
|
||||||
|
|
||||||
makemkvcon(): string {
|
makemkvcon(): string {
|
||||||
return finden('makemkv', this.einstellungen.lesen('werkzeug.makemkv'))
|
return this.suchen('makemkv', 'werkzeug.makemkv')
|
||||||
}
|
}
|
||||||
|
|
||||||
handbrake(): string {
|
handbrake(): string {
|
||||||
return finden('handbrake', this.einstellungen.lesen('werkzeug.handbrake'))
|
return this.suchen('handbrake', 'werkzeug.handbrake')
|
||||||
}
|
}
|
||||||
|
|
||||||
flac(): string {
|
flac(): string {
|
||||||
return finden('flac', this.einstellungen.lesen('werkzeug.flac'))
|
return this.suchen('flac', 'werkzeug.flac')
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Werkzeuge suchen, HandBrake messen, ans Fenster. */
|
/** Werkzeuge suchen, HandBrake messen, ans Fenster. */
|
||||||
async senden(): Promise<void> {
|
async senden(): Promise<void> {
|
||||||
|
this.neuSuchen()
|
||||||
const makemkvcon = this.makemkvcon()
|
const makemkvcon = this.makemkvcon()
|
||||||
const handbrake = this.handbrake()
|
const handbrake = this.handbrake()
|
||||||
const flac = this.flac()
|
const flac = this.flac()
|
||||||
@@ -93,9 +118,40 @@ export class WerkzeugDienst {
|
|||||||
this.installerAntwort = null
|
this.installerAntwort = null
|
||||||
}
|
}
|
||||||
|
|
||||||
private beschaffungMelden(text: string, anteil: number | null, laeuft: boolean, fehler = false): void {
|
private beschaffungMelden(text: string, anteil: number | null, laeuft: boolean, fehler = false, frage?: string): void {
|
||||||
this.kontext.anFenster({ art: 'beschaffung-status', stand: { werkzeug: 'makemkv', laeuft, text, anteil, fehler } })
|
this.letzterBeschaffungsText = text
|
||||||
this.kontext.protokoll.schreiben(`MakeMKV-Update: ${text}`)
|
this.kontext.anFenster({ art: 'beschaffung-status', stand: { werkzeug: 'makemkv', laeuft, text, anteil, fehler, ...(frage !== undefined ? { frage } : {}) } })
|
||||||
|
this.kontext.protokoll.schreiben(`MakeMKV-Update: ${text}${frage !== undefined ? ` — FRAGE: ${frage}` : ''}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Antwort des Fensters auf eine offene Frage (beschaffung-antwort). */
|
||||||
|
beschaffungAntwort(ja: boolean): void {
|
||||||
|
const antwort = this.frageAntwort
|
||||||
|
this.frageAntwort = null
|
||||||
|
if (antwort === null) {
|
||||||
|
this.kontext.hinweis('Es ist keine Frage der MakeMKV-Beschaffung offen.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
antwort(ja)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eine Ja/Nein-Frage ans Fenster; ohne Antwort in zehn Minuten gilt Nein. */
|
||||||
|
private frage(text: string): Promise<boolean> {
|
||||||
|
return new Promise((fertig) => {
|
||||||
|
this.frageAntwort = (ja) => {
|
||||||
|
clearTimeout(zeiger)
|
||||||
|
this.beschaffungMelden(ja ? 'Freigegeben — die Archiv-Kopie wird geladen.' : 'Nicht freigegeben.', null, true)
|
||||||
|
fertig(ja)
|
||||||
|
}
|
||||||
|
const zeiger = setTimeout(() => {
|
||||||
|
if (this.frageAntwort === null) return
|
||||||
|
this.frageAntwort = null
|
||||||
|
this.beschaffungMelden('Keine Antwort — die Archiv-Kopie wird nicht geladen.', null, true)
|
||||||
|
fertig(false)
|
||||||
|
}, 10 * 60_000)
|
||||||
|
zeiger.unref()
|
||||||
|
this.beschaffungMelden(this.letzterBeschaffungsText, null, true, false, text)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── MakeMKV-Update (§ 9): holen, prüfen, installieren lassen ────────────
|
// ── MakeMKV-Update (§ 9): holen, prüfen, installieren lassen ────────────
|
||||||
@@ -146,8 +202,25 @@ export class WerkzeugDienst {
|
|||||||
const alteExe = this.makemkvcon()
|
const alteExe = this.makemkvcon()
|
||||||
const alteVersion = alteExe.length > 0 ? versionsangaben(alteExe).version : ''
|
const alteVersion = alteExe.length > 0 ? versionsangaben(alteExe).version : ''
|
||||||
|
|
||||||
const ergebnis = await makemkvInstallerHolen(neueste, (text, anteil) => this.beschaffungMelden(text, anteil, true))
|
const ergebnis = await makemkvInstallerHolen(
|
||||||
this.beschaffungMelden(`Geprüft (Quelle: ${ergebnis.quelle}) — der Installer wird gestartet …`, null, true)
|
neueste,
|
||||||
|
(text, anteil) => this.beschaffungMelden(text, anteil, true),
|
||||||
|
'',
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
// F16: Das Archiv liefert eine unsignierte EXE, die gleich mit
|
||||||
|
// Adminrechten läuft — das entscheidet der Nutzer, nicht Rippy.
|
||||||
|
(quelle) =>
|
||||||
|
this.frage(
|
||||||
|
`makemkv.com antwortet nicht. Soll Rippy den MakeMKV-Installer ${neueste} aus dem ${quelle} laden? Das ist eine Kopie von fremder Hand, geprüft wird nur, dass sie ein MakeMKV-Programm ist — er läuft danach mit Adminrechten. Wer sicher gehen will, lädt später selbst von makemkv.com.`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
this.beschaffungMelden(
|
||||||
|
`Geprüft (Quelle: ${ergebnis.quelle}, ${(ergebnis.bytes / 1024 ** 2).toFixed(1)} MB, SHA-256 ${ergebnis.sha256}) — der Installer wird gestartet …`,
|
||||||
|
null,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
|
||||||
const startFehler = await new Promise<string | null>((fertig) => {
|
const startFehler = await new Promise<string | null>((fertig) => {
|
||||||
this.installerAntwort = fertig
|
this.installerAntwort = fertig
|
||||||
|
|||||||
@@ -87,6 +87,21 @@ const kontext: KernKontext = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 5.7.0 (Durchsicht 12.09.2026, F2): Ein Fehler, den niemand fängt, darf
|
||||||
|
// nicht still verschwinden. Gemessen in Electron 44: Ein utilityProcess
|
||||||
|
// STIRBT NICHT an einer unbehandelten Promise-Ablehnung — er schweigt nur,
|
||||||
|
// und die Konsole eines Tray-Programms sieht niemand. Deshalb landet jede
|
||||||
|
// solche Ablehnung im Protokoll und im Fenster (R4). Eine echte Ausnahme
|
||||||
|
// wird protokolliert und beendet den Kern kontrolliert — der Haupt startet
|
||||||
|
// ihn neu und verdrahtet das Fenster wieder (kernstart.ts).
|
||||||
|
process.on('unhandledRejection', (grund: unknown) => {
|
||||||
|
kontext.fehler(`Unbehandelte Ablehnung im Kern: ${String(grund instanceof Error ? (grund.stack ?? grund.message) : grund).slice(0, 600)}`)
|
||||||
|
})
|
||||||
|
process.on('uncaughtException', (fehler: Error) => {
|
||||||
|
kontext.fehler(`Absturz im Kern: ${String(fehler.stack ?? fehler.message).slice(0, 600)} — der Kern beendet sich, der Haupt startet ihn neu.`)
|
||||||
|
setTimeout(() => process.exit(1), 200)
|
||||||
|
})
|
||||||
|
|
||||||
// ── Dienste verdrahten ──────────────────────────────────────────────────
|
// ── Dienste verdrahten ──────────────────────────────────────────────────
|
||||||
const einstellungen = new EinstellungsDienst(kontext)
|
const einstellungen = new EinstellungsDienst(kontext)
|
||||||
const werkzeuge = new WerkzeugDienst(kontext, einstellungen)
|
const werkzeuge = new WerkzeugDienst(kontext, einstellungen)
|
||||||
@@ -101,10 +116,13 @@ const laufwerke = new LaufwerkDienst(
|
|||||||
kontext,
|
kontext,
|
||||||
{
|
{
|
||||||
discEingelegt: (geraet) => {
|
discEingelegt: (geraet) => {
|
||||||
void erkennung.discErmitteln(geraet).then((zuordnung) => {
|
void erkennung
|
||||||
automatik.zuordnungMerken(geraet.id, zuordnung)
|
.discErmitteln(geraet)
|
||||||
return automatik.pruefen(geraet, zuordnung, erkennung.schonGerippt(geraet.id))
|
.then((zuordnung) => {
|
||||||
})
|
automatik.zuordnungMerken(geraet.id, zuordnung)
|
||||||
|
return automatik.pruefen(geraet, zuordnung, erkennung.schonGerippt(geraet.id))
|
||||||
|
})
|
||||||
|
.catch((fehler: unknown) => kontext.fehler(`Nach dem Einlegen in ${geraet.id}: ${String(fehler)}`))
|
||||||
},
|
},
|
||||||
discEntnommen: (geraet) => {
|
discEntnommen: (geraet) => {
|
||||||
erkennung.discVergessen(geraet.id)
|
erkennung.discVergessen(geraet.id)
|
||||||
@@ -119,6 +137,7 @@ const erkennung = new ErkennungDienst(kontext, {
|
|||||||
ripLaeuft: (id) => ripVerwaltung.laeuft(id),
|
ripLaeuft: (id) => ripVerwaltung.laeuft(id),
|
||||||
geraetInfoVon: (id) => laufwerke.geraetInfoVon(id),
|
geraetInfoVon: (id) => laufwerke.geraetInfoVon(id),
|
||||||
makemkvcon: () => werkzeuge.makemkvcon(),
|
makemkvcon: () => werkzeuge.makemkvcon(),
|
||||||
|
titelLesenMinuten: () => einstellungen.titelLesenMinuten(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const vorgaenge = new VorgangDienst(kontext, einstellungen, werkzeuge, erkennung, benachrichtigung, () => speicher.senden())
|
const vorgaenge = new VorgangDienst(kontext, einstellungen, werkzeuge, erkennung, benachrichtigung, () => speicher.senden())
|
||||||
@@ -128,7 +147,7 @@ ripVerwaltung = new Pipeline({
|
|||||||
handbrake: () => werkzeuge.handbrake(),
|
handbrake: () => werkzeuge.handbrake(),
|
||||||
flac: () => werkzeuge.flac(),
|
flac: () => werkzeuge.flac(),
|
||||||
audioToc: (id) => laufwerke.audioToc(id),
|
audioToc: (id) => laufwerke.audioToc(id),
|
||||||
audioSpurLesen: (id, start, sektoren, schreiben, fortschritt) => laufwerke.audioSpurLesen(id, start, sektoren, schreiben, fortschritt),
|
audioSpurLesen: (id, start, sektoren, schreiben, fortschritt, abbruch) => laufwerke.audioSpurLesen(id, start, sektoren, schreiben, fortschritt, abbruch),
|
||||||
geraet: (id) => laufwerke.geraetInfoVon(id),
|
geraet: (id) => laufwerke.geraetInfoVon(id),
|
||||||
ablage: () => einstellungen.ablageOrdner(),
|
ablage: () => einstellungen.ablageOrdner(),
|
||||||
rohWurzel: () => einstellungen.rohWurzel(),
|
rohWurzel: () => einstellungen.rohWurzel(),
|
||||||
@@ -221,6 +240,9 @@ function fensterNachricht(frage: unknown): void {
|
|||||||
automatik.stopp(frage.id, '')
|
automatik.stopp(frage.id, '')
|
||||||
void erkennung.titelLesen(frage.id)
|
void erkennung.titelLesen(frage.id)
|
||||||
return
|
return
|
||||||
|
case 'titel-abbruch':
|
||||||
|
erkennung.titelAbbrechen(frage.id)
|
||||||
|
return
|
||||||
case 'rip-start':
|
case 'rip-start':
|
||||||
automatik.stopp(frage.id, '')
|
automatik.stopp(frage.id, '')
|
||||||
ripMitPlatz(frage.id, frage.auftrag ?? [], frage.optionen ?? {})
|
ripMitPlatz(frage.id, frage.auftrag ?? [], frage.optionen ?? {})
|
||||||
@@ -250,6 +272,12 @@ function fensterNachricht(frage: unknown): void {
|
|||||||
vorgaenge.senden()
|
vorgaenge.senden()
|
||||||
speicher.senden()
|
speicher.senden()
|
||||||
}
|
}
|
||||||
|
// 5.7.0 (Durchsicht F13): Ein neuer Werkzeug-Pfad wird sofort neu
|
||||||
|
// gesucht und gemessen — vorher stand „NICHT GEFUNDEN" so lange, bis
|
||||||
|
// das Fenster neu geladen wurde (in der Erst-Einrichtung besonders
|
||||||
|
// irreführend). Ein neuer eigener Schlüssel wird sofort geprüft.
|
||||||
|
if (frage.schluessel.startsWith('werkzeug.')) void werkzeuge.senden()
|
||||||
|
if (frage.schluessel === 'makemkvAppKey') void werkzeuge.schluesselLauf().then(() => werkzeuge.senden())
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
case 'einstellungen-laden':
|
case 'einstellungen-laden':
|
||||||
@@ -264,8 +292,11 @@ function fensterNachricht(frage: unknown): void {
|
|||||||
case 'schluessel-pruefen':
|
case 'schluessel-pruefen':
|
||||||
void werkzeuge.schluesselLauf().then(() => werkzeuge.senden())
|
void werkzeuge.schluesselLauf().then(() => werkzeuge.senden())
|
||||||
return
|
return
|
||||||
|
case 'beschaffung-antwort':
|
||||||
|
werkzeuge.beschaffungAntwort(frage.ja)
|
||||||
|
return
|
||||||
case 'vorgaenge-laden':
|
case 'vorgaenge-laden':
|
||||||
vorgaenge.senden()
|
vorgaenge.neuMessen()
|
||||||
return
|
return
|
||||||
case 'vorgang-roh-loeschen':
|
case 'vorgang-roh-loeschen':
|
||||||
vorgaenge.rohLoeschen(frage.vorgangId)
|
vorgaenge.rohLoeschen(frage.vorgangId)
|
||||||
|
|||||||
@@ -15,8 +15,15 @@
|
|||||||
// im Fehlerfall die einzige Auskunft.
|
// im Fehlerfall die einzige Auskunft.
|
||||||
// - Abbruch bei JEDER Zeile prüfen, nicht nur bei Prozent-Änderung —
|
// - Abbruch bei JEDER Zeile prüfen, nicht nur bei Prozent-Änderung —
|
||||||
// sonst sieht „Abbrechen" bei einem 4K-Encode minutenlang tot aus.
|
// sonst sieht „Abbrechen" bei einem 4K-Encode minutenlang tot aus.
|
||||||
|
// - `--aencoder copy` OHNE `--audio-copy-mask` kopiert NUR AAC: HandBrake-
|
||||||
|
// CLI 1.11.2 setzt die Kopiermaske dann auf „copy:aac" — obwohl das
|
||||||
|
// Preset alle Codecs erlaubt — und rechnet AC3/DTS/TrueHD in AAC um
|
||||||
|
// („Auto Passthru: allowed codecs are AAC … passthru not possible for
|
||||||
|
// track 1, using fallback"). Gemessen 12.09.2026 (Durchsicht, F31):
|
||||||
|
// mit ausdrücklicher Maske bleibt AC3 AC3. Seit 5.0 bis 5.6.1 wurde
|
||||||
|
// also jede Tonspur verlustbehaftet umgerechnet.
|
||||||
import { werkzeugSpawn } from '../werkzeuge/aufruf'
|
import { werkzeugSpawn } from '../werkzeuge/aufruf'
|
||||||
import { existsSync } from 'node:fs'
|
import { existsSync, renameSync, unlinkSync } from 'node:fs'
|
||||||
// win32-Pfadregeln ausdrücklich — die CI prüft diese Logik auf Linux.
|
// win32-Pfadregeln ausdrücklich — die CI prüft diese Logik auf Linux.
|
||||||
import { win32 as pfad } from 'node:path'
|
import { win32 as pfad } from 'node:path'
|
||||||
import { textVon } from '../rip/parser'
|
import { textVon } from '../rip/parser'
|
||||||
@@ -33,6 +40,13 @@ export interface HandbrakeArgsOptionen {
|
|||||||
/** Die ERSTE gewählte Untertitelspur beim Abspielen vorwählen (5.4.0):
|
/** Die ERSTE gewählte Untertitelspur beim Abspielen vorwählen (5.4.0):
|
||||||
* japanischer Ton, deutsche Untertitel sollen von selbst an sein. */
|
* japanischer Ton, deutsche Untertitel sollen von selbst an sein. */
|
||||||
untertitelStandard?: boolean
|
untertitelStandard?: boolean
|
||||||
|
/** 5.7.0: Spuren EINZELN — HandBrake-Spurnummern (`-a`/`-s`, 1-basiert,
|
||||||
|
* Scan-Reihenfolge). Gesetzt, gelten die Sprachlisten nicht. Eine leere
|
||||||
|
* Liste heißt: keine Spur dieser Art. `untertitelStandardNr` ist der
|
||||||
|
* 1-basierte Index in `untertitelNr` (0 = keine Vorwahl). */
|
||||||
|
audioNr?: readonly number[]
|
||||||
|
untertitelNr?: readonly number[]
|
||||||
|
untertitelStandardNr?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sprach-Schalter am mitgelieferten HandBrake 1.11 GEMESSEN (01.09.2026,
|
// Sprach-Schalter am mitgelieferten HandBrake 1.11 GEMESSEN (01.09.2026,
|
||||||
@@ -47,16 +61,47 @@ export interface HandbrakeArgsOptionen {
|
|||||||
// zusätzlich forced=1 (so schreibt es HandBrakes Muxer; Jellyfin zeigt
|
// zusätzlich forced=1 (so schreibt es HandBrakes Muxer; Jellyfin zeigt
|
||||||
// sie damit von selbst)
|
// sie damit von selbst)
|
||||||
// -N deu allein fügt KEINE Untertitelspur hinzu (nur die Vorwahl-Logik)
|
// -N deu allein fügt KEINE Untertitelspur hinzu (nur die Vorwahl-Logik)
|
||||||
|
// Spur-Schalter GEMESSEN (12.09.2026, Testdatei jpn 5.1/deu 2.0/deu 2.0
|
||||||
|
// „Kommentar" AC3 + deu/eng SRT, Preset „H.265 MKV 1080p30"):
|
||||||
|
// --audio 1,3 → jpn und deu-Kommentar, in dieser Reihenfolge
|
||||||
|
// --subtitle 2,1 --subtitle-default=1 → eng (default=1) vor deu
|
||||||
|
// --subtitle none → keine Untertitelspur (ohne --audio: die erste Tonspur)
|
||||||
|
// --audio-copy-mask aac,ac3,eac3,dtshd,dts,mp3,truehd,flac → AC3 bleibt AC3
|
||||||
|
|
||||||
|
/** Was HandBrake verlustfrei durchreichen darf — genau die Maske der
|
||||||
|
* mitgelieferten Presets („copy:aac" … „copy:flac"), ausdrücklich, weil
|
||||||
|
* die CLI sie sonst auf AAC verengt (siehe Kopf). */
|
||||||
|
export const KOPIERMASKE = 'aac,ac3,eac3,dtshd,dts,mp3,truehd,flac'
|
||||||
export function buildHandbrakeArgs(optionen: HandbrakeArgsOptionen): string[] {
|
export function buildHandbrakeArgs(optionen: HandbrakeArgsOptionen): string[] {
|
||||||
const args = ['--input', optionen.eingabe, '--output', optionen.ausgabe, '--preset', optionen.preset]
|
const args = ['--input', optionen.eingabe, '--output', optionen.ausgabe, '--preset', optionen.preset]
|
||||||
const format = FORMATE[extname(optionen.ausgabe).toLowerCase()]
|
const format = FORMATE[extname(optionen.ausgabe).toLowerCase()]
|
||||||
if (format !== undefined) args.push('--format', format)
|
if (format !== undefined) args.push('--format', format)
|
||||||
|
// 5.7.0: Spuren einzeln — `--audio 1,3` und `--subtitle 2` (HandBrakeCLI
|
||||||
|
// --help: „-a, --audio <string> Select audio track(s), separated by
|
||||||
|
// commas", „-s, --subtitle <string> Select subtitle track(s)"; am
|
||||||
|
// mitgelieferten 1.11.2 gemessen, siehe test/messung.spuren.test.ts).
|
||||||
|
if (optionen.audioNr !== undefined || optionen.untertitelNr !== undefined) {
|
||||||
|
const audioNr = optionen.audioNr ?? []
|
||||||
|
// Ohne gewählte Tonspur bleibt HandBrakes Vorgabe: die erste Spur —
|
||||||
|
// ein stummer Film wäre nie gewollt (der Dialog sagt das dazu).
|
||||||
|
if (audioNr.length > 0) args.push('--audio', audioNr.join(','))
|
||||||
|
args.push('--aencoder', 'copy', '--audio-copy-mask', KOPIERMASKE, '--audio-fallback', 'av_aac')
|
||||||
|
const untertitelNr = optionen.untertitelNr ?? []
|
||||||
|
if (untertitelNr.length > 0) {
|
||||||
|
args.push('--subtitle', untertitelNr.join(','))
|
||||||
|
if ((optionen.untertitelStandardNr ?? 0) > 0) args.push(`--subtitle-default=${optionen.untertitelStandardNr}`)
|
||||||
|
} else {
|
||||||
|
args.push('--subtitle', 'none')
|
||||||
|
}
|
||||||
|
return args
|
||||||
|
}
|
||||||
const audio = (optionen.audioSprachen ?? []).filter((s) => s.length > 0)
|
const audio = (optionen.audioSprachen ?? []).filter((s) => s.length > 0)
|
||||||
if (audio.length > 0) args.push('--audio-lang-list', audio.join(','))
|
if (audio.length > 0) args.push('--audio-lang-list', audio.join(','))
|
||||||
args.push('--first-audio')
|
args.push('--first-audio')
|
||||||
// `copy` reicht die gewählten Tonspuren verlustfrei durch; `av_aac` ist
|
// `copy` reicht die gewählten Tonspuren verlustfrei durch — aber nur mit
|
||||||
// der dokumentierte Rückfall, wenn ein Codec nicht kopierbar ist.
|
// ausdrücklicher Kopiermaske (F31, siehe Kopf); `av_aac` ist der
|
||||||
args.push('--aencoder', 'copy', '--audio-fallback', 'av_aac')
|
// dokumentierte Rückfall, wenn ein Codec nicht kopierbar ist.
|
||||||
|
args.push('--aencoder', 'copy', '--audio-copy-mask', KOPIERMASKE, '--audio-fallback', 'av_aac')
|
||||||
const untertitel = (optionen.untertitelSprachen ?? []).filter((s) => s.length > 0)
|
const untertitel = (optionen.untertitelSprachen ?? []).filter((s) => s.length > 0)
|
||||||
if (untertitel.length > 0) args.push('--subtitle-lang-list', untertitel.join(','))
|
if (untertitel.length > 0) args.push('--subtitle-lang-list', untertitel.join(','))
|
||||||
args.push('--all-subtitles')
|
args.push('--all-subtitles')
|
||||||
@@ -264,14 +309,55 @@ export interface HbLaufOptionen {
|
|||||||
untertitelStandard?: boolean
|
untertitelStandard?: boolean
|
||||||
/** Zusätzliche Schalter ANS ENDE (z. B. --stop-at duration:5 im Messlauf). */
|
/** Zusätzliche Schalter ANS ENDE (z. B. --stop-at duration:5 im Messlauf). */
|
||||||
extraArgs?: readonly string[]
|
extraArgs?: readonly string[]
|
||||||
|
/** Die fertige Datei darf eine vorhandene gleichen Namens ersetzen —
|
||||||
|
* nur beim Wiederholen (auftrag.ersetzt); sonst ist das ein Fehler. */
|
||||||
|
ueberschreiben?: boolean
|
||||||
/** Fortschritt 0–100 und Restzeit in Sekunden (-1 = noch keine Angabe). */
|
/** Fortschritt 0–100 und Restzeit in Sekunden (-1 = noch keine Angabe). */
|
||||||
aufFortschritt?: (prozent: number, restS: number) => void
|
aufFortschritt?: (prozent: number, restS: number) => void
|
||||||
abbruch?: AbortSignal
|
abbruch?: AbortSignal
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Der Zwischenname, unter dem HandBrake schreibt (5.7.0, Durchsicht F9):
|
||||||
|
* `Akira (1988).mkv` → `Akira (1988).teil.mkv`. Die Endung bleibt, damit
|
||||||
|
* --format und die Datei-daneben-Prüfung weiter an der Endung hängen;
|
||||||
|
* erst eine FERTIGE Datei bekommt den echten Namen. Vorher blieb nach
|
||||||
|
* Abbruch oder Fehler eine halbe Datei unter dem endgültigen Namen liegen
|
||||||
|
* — ohne Arbeitsordner direkt in der Ablage, wo Jellyfin sie als kaputten
|
||||||
|
* Film zeigte. */
|
||||||
|
export function zwischenname(ausgabe: string): string {
|
||||||
|
const endung = extname(ausgabe)
|
||||||
|
return endung.length > 0 ? ausgabe.slice(0, -endung.length) + '.teil' + endung : ausgabe + '.teil'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `x.teil.mkv` → `x.mkv` (auch für die Datei daneben: `x.teil.mp4` → `x.mp4`). */
|
||||||
|
export function endname(zwischen: string): string {
|
||||||
|
return zwischen.replace(/\.teil(\.[^.\\/]+)$/, '$1')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reste eines abgebrochenen oder gescheiterten Laufs entfernen — die
|
||||||
|
* Zwischen-Datei und, falls HandBrake einen anderen Container wählte,
|
||||||
|
* ihre Datei daneben. Liefert, was weg ist. */
|
||||||
|
export function zwischenReste(zwischen: string, entfernen: (p: string) => void = unlinkSync, existiert: (p: string) => boolean = existsSync): string[] {
|
||||||
|
const weg: string[] = []
|
||||||
|
for (const kandidat of [zwischen, dateiDaneben(zwischen, existiert)]) {
|
||||||
|
if (kandidat.length === 0 || !existiert(kandidat)) continue
|
||||||
|
try {
|
||||||
|
entfernen(kandidat)
|
||||||
|
weg.push(kandidat)
|
||||||
|
} catch {
|
||||||
|
// Eine Datei, die sich nicht löschen lässt (noch offen?), bleibt —
|
||||||
|
// sie heißt „.teil" und ist damit als halb erkennbar.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return weg
|
||||||
|
}
|
||||||
|
|
||||||
export function komprimieren(optionen: HbLaufOptionen): Promise<HbErgebnis> {
|
export function komprimieren(optionen: HbLaufOptionen): Promise<HbErgebnis> {
|
||||||
return new Promise((fertig) => {
|
return new Promise((fertig) => {
|
||||||
const args = buildHandbrakeArgs(optionen)
|
// HandBrake schreibt auf den Zwischennamen; der echte Name kommt erst
|
||||||
|
// mit dem Erfolg (F9).
|
||||||
|
const zwischen = zwischenname(optionen.ausgabe)
|
||||||
|
const args = buildHandbrakeArgs({ ...optionen, ausgabe: zwischen })
|
||||||
args.push(...(optionen.extraArgs ?? []))
|
args.push(...(optionen.extraArgs ?? []))
|
||||||
const kind = werkzeugSpawn(optionen.programm, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
const kind = werkzeugSpawn(optionen.programm, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||||
|
|
||||||
@@ -294,6 +380,7 @@ export function komprimieren(optionen: HbLaufOptionen): Promise<HbErgebnis> {
|
|||||||
|
|
||||||
kind.on('error', (fehler) => {
|
kind.on('error', (fehler) => {
|
||||||
optionen.abbruch?.removeEventListener('abort', abbrechen)
|
optionen.abbruch?.removeEventListener('abort', abbrechen)
|
||||||
|
zwischenReste(zwischen)
|
||||||
fertig({ status: 'error', fehler: String(fehler) })
|
fertig({ status: 'error', fehler: String(fehler) })
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -301,10 +388,27 @@ export function komprimieren(optionen: HbLaufOptionen): Promise<HbErgebnis> {
|
|||||||
optionen.abbruch?.removeEventListener('abort', abbrechen)
|
optionen.abbruch?.removeEventListener('abort', abbrechen)
|
||||||
leser.schluss().forEach(zeile)
|
leser.schluss().forEach(zeile)
|
||||||
if (abgebrochen) {
|
if (abgebrochen) {
|
||||||
|
zwischenReste(zwischen)
|
||||||
fertig({ status: 'cancelled' })
|
fertig({ status: 'cancelled' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
fertig(auswertung.urteil(code, optionen.ausgabe))
|
const urteil = auswertung.urteil(code, zwischen)
|
||||||
|
if (urteil.status !== 'success') {
|
||||||
|
zwischenReste(zwischen)
|
||||||
|
fertig(urteil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Fertig: Zwischenname → echter Name (eine vorhandene Datei gleichen
|
||||||
|
// Namens wäre ein Fehler in der Ablage-Logik — nie still überschreiben).
|
||||||
|
const ziel = endname(urteil.ausgabe)
|
||||||
|
try {
|
||||||
|
if (optionen.ueberschreiben !== true && existsSync(ziel)) throw new Error(`${basename(ziel)} existiert schon`)
|
||||||
|
renameSync(urteil.ausgabe, ziel)
|
||||||
|
} catch (fehler) {
|
||||||
|
fertig({ status: 'error', fehler: `Die fertige Datei ließ sich nicht umbenennen (${String(fehler)}) — sie liegt unter ${urteil.ausgabe}` })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fertig({ ...urteil, ausgabe: ziel })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
// mit den Befunden vom 25.07.2026 begründet: EIN Preset für alles rechnete
|
// mit den Befunden vom 25.07.2026 begründet: EIN Preset für alles rechnete
|
||||||
// 4K auf 1080p herunter und DVDs hinauf.
|
// 4K auf 1080p herunter und DVDs hinauf.
|
||||||
|
|
||||||
import { DEFAULT_HB_PRESET, presetEmpfehlung } from '../../gemeinsam/preset-empfehlung'
|
import { DEFAULT_HB_PRESET, PRESET_KEINE, presetEmpfehlung } from '../../gemeinsam/preset-empfehlung'
|
||||||
|
|
||||||
export { DEFAULT_HB_PRESET, presetEmpfehlung }
|
export { DEFAULT_HB_PRESET, PRESET_KEINE, presetEmpfehlung }
|
||||||
|
|
||||||
/** Einstellungs-Schlüssel je Disc-Typ. */
|
/** Einstellungs-Schlüssel je Disc-Typ. */
|
||||||
export const PRESET_SCHLUESSEL: Record<string, string> = {
|
export const PRESET_SCHLUESSEL: Record<string, string> = {
|
||||||
@@ -13,15 +13,12 @@ export const PRESET_SCHLUESSEL: Record<string, string> = {
|
|||||||
uhd: 'transcodePresetUhd',
|
uhd: 'transcodePresetUhd',
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reservierter Wert: „diesen Typ NICHT komprimieren". Kein echtes
|
|
||||||
// HandBrake-Preset heißt so (gegen alle 90 aus --preset-list geprüft).
|
|
||||||
// Grund: 4K verlustfrei durchreichen, DVDs trotzdem schrumpfen.
|
|
||||||
export const PRESET_KEINE = 'keine'
|
|
||||||
|
|
||||||
export type Einstellungen = Record<string, string | boolean | undefined>
|
export type Einstellungen = Record<string, string | boolean | undefined>
|
||||||
|
|
||||||
/** Soll dieser Disc-Typ nach dem Rip komprimiert werden? (pur) */
|
/** Soll dieser Disc-Typ nach dem Rip komprimiert werden? (pur)
|
||||||
export function komprimierenFuer(discTyp: string, einstellungen: Einstellungen): boolean {
|
* 5.7.0: Steht der Typ auf „automatisch" und die gemessene Empfehlung
|
||||||
|
* sagt „keine" (4K ohne Grafikkarten-Encoder), bleibt es verlustfrei. */
|
||||||
|
export function komprimierenFuer(discTyp: string, einstellungen: Einstellungen, empfehlung = ''): boolean {
|
||||||
if (einstellungen['transcodeEnabled'] === false || einstellungen['transcodeEnabled'] === 'false') {
|
if (einstellungen['transcodeEnabled'] === false || einstellungen['transcodeEnabled'] === 'false') {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -29,24 +26,25 @@ export function komprimierenFuer(discTyp: string, einstellungen: Einstellungen):
|
|||||||
if (schluessel.length > 0) {
|
if (schluessel.length > 0) {
|
||||||
const wert = String(einstellungen[schluessel] ?? '').trim()
|
const wert = String(einstellungen[schluessel] ?? '').trim()
|
||||||
if (wert === PRESET_KEINE) return false
|
if (wert === PRESET_KEINE) return false
|
||||||
|
if (wert.length === 0 && empfehlung === PRESET_KEINE) return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Welches HandBrake-Preset gilt für diesen Disc-Typ? (pur)
|
/** Welches HandBrake-Preset gilt für diesen Disc-Typ? (pur)
|
||||||
* Kette: Typ-Preset → allgemeines transcodePreset → EMPFEHLUNG (gemessen,
|
* Kette: Typ-Preset → EMPFEHLUNG (gemessen, Hardware zuerst) → CPU-DEFAULT.
|
||||||
* Hardware zuerst) → CPU-DEFAULT. PRESET_KEINE wird ÜBERSPRUNGEN — diese
|
* PRESET_KEINE wird ÜBERSPRUNGEN — diese Funktion darf nie „keine" als
|
||||||
* Funktion darf nie „keine" als Preset-Namen liefern, sonst bekäme
|
* Preset-Namen liefern, sonst bekäme HandBrake `--preset keine` und
|
||||||
* HandBrake `--preset keine` und scheiterte. */
|
* scheiterte. (5.7.0, A5: das „allgemeine Preset" transcodePreset ist seit
|
||||||
|
* 5.5.0 aus den Pipeline-Einstellungen raus — hier stand es noch als
|
||||||
|
* toter Zweig.) */
|
||||||
export function presetFuer(discTyp: string, einstellungen: Einstellungen, empfehlung = ''): string {
|
export function presetFuer(discTyp: string, einstellungen: Einstellungen, empfehlung = ''): string {
|
||||||
const schluessel = PRESET_SCHLUESSEL[discTyp] ?? ''
|
const schluessel = PRESET_SCHLUESSEL[discTyp] ?? ''
|
||||||
if (schluessel.length > 0) {
|
if (schluessel.length > 0) {
|
||||||
const eigen = String(einstellungen[schluessel] ?? '').trim()
|
const eigen = String(einstellungen[schluessel] ?? '').trim()
|
||||||
if (eigen.length > 0 && eigen !== PRESET_KEINE) return eigen
|
if (eigen.length > 0 && eigen !== PRESET_KEINE) return eigen
|
||||||
}
|
}
|
||||||
const allgemein = String(einstellungen['transcodePreset'] ?? '').trim()
|
return empfehlung.length > 0 && empfehlung !== PRESET_KEINE ? empfehlung : DEFAULT_HB_PRESET
|
||||||
if (allgemein.length > 0) return allgemein
|
|
||||||
return empfehlung.length > 0 ? empfehlung : DEFAULT_HB_PRESET
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** „deu, eng , ,DEU" → ["deu","eng"] — HandBrake will ISO-639-2 klein;
|
/** „deu, eng , ,DEU" → ["deu","eng"] — HandBrake will ISO-639-2 klein;
|
||||||
|
|||||||
@@ -18,9 +18,21 @@
|
|||||||
import { werkzeugSpawn } from '../werkzeuge/aufruf'
|
import { werkzeugSpawn } from '../werkzeuge/aufruf'
|
||||||
import { textVon } from '../rip/parser'
|
import { textVon } from '../rip/parser'
|
||||||
|
|
||||||
|
/** Eine Spur laut HandBrake-Scan. 5.7.0: dazu CodecName („dtshd", „dca",
|
||||||
|
* „truehd", „ac3" …), ChannelCount und bei Untertiteln Source/SourceName
|
||||||
|
* („PGS") — am 12.09.2026 am mitgelieferten HandBrake 1.11.2 gemessen
|
||||||
|
* (AudioList[].CodecName/ChannelCount/ChannelLayout/TrackNumber). */
|
||||||
|
export interface HbSpur {
|
||||||
|
nr: number
|
||||||
|
lang: string
|
||||||
|
beschreibung: string
|
||||||
|
codec: string
|
||||||
|
kanaele: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface HbSpuren {
|
export interface HbSpuren {
|
||||||
audio: Array<{ nr: number; lang: string; beschreibung: string }>
|
audio: HbSpur[]
|
||||||
untertitel: Array<{ nr: number; lang: string; beschreibung: string }>
|
untertitel: HbSpur[]
|
||||||
dauerS: number
|
dauerS: number
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,14 +72,17 @@ export function spurenAusScan(ausgabe: string): HbSpuren | null {
|
|||||||
const liste = (daten as { TitleList?: unknown }).TitleList
|
const liste = (daten as { TitleList?: unknown }).TitleList
|
||||||
if (!Array.isArray(liste) || liste.length === 0) return null
|
if (!Array.isArray(liste) || liste.length === 0) return null
|
||||||
const titel = liste[0] as Record<string, unknown>
|
const titel = liste[0] as Record<string, unknown>
|
||||||
const spuren = (feld: unknown): Array<{ nr: number; lang: string; beschreibung: string }> => {
|
const spuren = (feld: unknown): HbSpur[] => {
|
||||||
if (!Array.isArray(feld)) return []
|
if (!Array.isArray(feld)) return []
|
||||||
return feld.map((s, i) => {
|
return feld.map((s, i) => {
|
||||||
const eintrag = (typeof s === 'object' && s !== null ? s : {}) as Record<string, unknown>
|
const eintrag = (typeof s === 'object' && s !== null ? s : {}) as Record<string, unknown>
|
||||||
|
const kanaele = eintrag.ChannelCount
|
||||||
return {
|
return {
|
||||||
nr: i + 1,
|
nr: i + 1,
|
||||||
lang: String(eintrag.LanguageCode ?? '').toLowerCase(),
|
lang: String(eintrag.LanguageCode ?? '').toLowerCase(),
|
||||||
beschreibung: String(eintrag.Description ?? eintrag.Language ?? ''),
|
beschreibung: String(eintrag.Description ?? eintrag.Language ?? ''),
|
||||||
|
codec: String(eintrag.CodecName ?? eintrag.SourceName ?? eintrag.Source ?? eintrag.Format ?? ''),
|
||||||
|
kanaele: typeof kanaele === 'number' && Number.isFinite(kanaele) ? kanaele : 0,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,6 +84,9 @@ export class Win32Fehler extends Error {
|
|||||||
export interface LaufwerkApi {
|
export interface LaufwerkApi {
|
||||||
laufwerksbuchstaben(): string[]
|
laufwerksbuchstaben(): string[]
|
||||||
laufwerkstyp(buchstabe: string): number
|
laufwerkstyp(buchstabe: string): number
|
||||||
|
/** Kernel-Gerätename hinter dem Buchstaben (\Device\CdRom0) — '' wenn
|
||||||
|
* nicht zu ermitteln (5.7.0, F11). */
|
||||||
|
geraeteName(buchstabe: string): string
|
||||||
/** Öffnet das Gerät. `zugriff` 0 fragt das GERÄT, GENERIC_READ das MEDIUM —
|
/** Öffnet das Gerät. `zugriff` 0 fragt das GERÄT, GENERIC_READ das MEDIUM —
|
||||||
* bei gestörter Disc scheitert das zweite und das erste geht weiter
|
* bei gestörter Disc scheitert das zweite und das erste geht weiter
|
||||||
* (rc11-Befund, in beweise/laufwerk.js nachgemessen). Wirft Win32Fehler. */
|
* (rc11-Befund, in beweise/laufwerk.js nachgemessen). Wirft Win32Fehler. */
|
||||||
|
|||||||
@@ -196,27 +196,39 @@ export function audioToc(api: LaufwerkApi, geraet: string): Spur[] {
|
|||||||
/** Eine Audio-Spur sektorweise lesen. `schreiben` bekommt jeden Block —
|
/** Eine Audio-Spur sektorweise lesen. `schreiben` bekommt jeden Block —
|
||||||
* eine 47-MB-Spur wandert so nie komplett in den Speicher.
|
* eine 47-MB-Spur wandert so nie komplett in den Speicher.
|
||||||
* ⚠ DiskOffset zählt in 2048ern (rawReadInfo), obwohl ein Audio-Sektor
|
* ⚠ DiskOffset zählt in 2048ern (rawReadInfo), obwohl ein Audio-Sektor
|
||||||
* 2352 Bytes hat — dokumentiert, sieht falsch aus, ist richtig. */
|
* 2352 Bytes hat — dokumentiert, sieht falsch aus, ist richtig.
|
||||||
export function audioSpurLesen(
|
* 5.7.0 (Durchsicht F30): Zwischen den Blöcken bekommt der Kern die
|
||||||
|
* Schleife zurück (setImmediate) — vorher blockierte eine ganze Spur den
|
||||||
|
* Kern, und „Abbrechen" wirkte erst zwischen zwei Spuren. `abbruch` endet
|
||||||
|
* den Lauf am nächsten Block. */
|
||||||
|
export async function audioSpurLesen(
|
||||||
api: LaufwerkApi,
|
api: LaufwerkApi,
|
||||||
geraet: string,
|
geraet: string,
|
||||||
start: number,
|
start: number,
|
||||||
sektoren: number,
|
sektoren: number,
|
||||||
schreiben: (daten: Buffer) => void,
|
schreiben: (daten: Buffer) => void,
|
||||||
fortschritt?: (anteil: number) => void,
|
fortschritt?: (anteil: number) => void,
|
||||||
): number {
|
abbruch?: AbortSignal,
|
||||||
return mitHandle(api, geraet, GENERIC_READ, (h) => {
|
): Promise<number> {
|
||||||
|
const handle = api.oeffnen(geraet, GENERIC_READ)
|
||||||
|
try {
|
||||||
const teile = bloecke(start, sektoren)
|
const teile = bloecke(start, sektoren)
|
||||||
let gelesen = 0
|
let gelesen = 0
|
||||||
for (let i = 0; i < teile.length; i++) {
|
for (let i = 0; i < teile.length; i++) {
|
||||||
|
if (abbruch?.aborted === true) throw new Error('abgebrochen')
|
||||||
const [von, anzahl] = teile[i]
|
const [von, anzahl] = teile[i]
|
||||||
const daten = api.steuern(h, IOCTL_CDROM_RAW_READ, rawReadInfo(von, anzahl), anzahl * SEKTOR_BYTES)
|
const daten = api.steuern(handle, IOCTL_CDROM_RAW_READ, rawReadInfo(von, anzahl), anzahl * SEKTOR_BYTES)
|
||||||
schreiben(daten)
|
schreiben(daten)
|
||||||
gelesen += daten.length
|
gelesen += daten.length
|
||||||
fortschritt?.((i + 1) / teile.length)
|
fortschritt?.((i + 1) / teile.length)
|
||||||
|
// Alle acht Blöcke (~500 KB) einmal Luft holen — Nachrichten, Wache,
|
||||||
|
// Abbruch kommen dann durch.
|
||||||
|
if (i % 8 === 7) await new Promise<void>((weiter) => setImmediate(weiter))
|
||||||
}
|
}
|
||||||
return gelesen
|
return gelesen
|
||||||
})
|
} finally {
|
||||||
|
api.schliessen(handle)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Klartext zu Win32-Fehlern (der 30.08.-Befund: Rippy KANNTE die
|
// ── Klartext zu Win32-Fehlern (der 30.08.-Befund: Rippy KANNTE die
|
||||||
|
|||||||
@@ -62,10 +62,24 @@ export function ereignisseAusXml(xml: string): LaufwerkEreignis[] {
|
|||||||
return liste
|
return liste
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Gehört dieses Ereignis zu DIESEM Laufwerk? (pur, 5.7.0 — Durchsicht F11)
|
||||||
|
* Mit bekanntem Kernel-Gerät (\Device\CdRom0) zählt nur, was dieses
|
||||||
|
* Gerät nennt; ohne Gerätename zählt nur der cdrom-Treiber — nie mehr die
|
||||||
|
* Festplatten (Provider disk), die vorher ein „VERDACHT: DIE HARDWARE"
|
||||||
|
* für das Blu-ray-Laufwerk auslösten. */
|
||||||
|
export function gehoertZuLaufwerk(e: LaufwerkEreignis, geraet: string): boolean {
|
||||||
|
if (geraet.length > 0) return e.geraet.toLowerCase().includes(geraet.toLowerCase())
|
||||||
|
return e.provider === 'cdrom'
|
||||||
|
}
|
||||||
|
|
||||||
/** Urteil aus den Ereignissen (pur). `seit` grenzt auf einen Zeitraum ein
|
/** Urteil aus den Ereignissen (pur). `seit` grenzt auf einen Zeitraum ein
|
||||||
* (z. B. den laufenden Rip); ohne `seit` zählt alles, was da ist. */
|
* (z. B. den laufenden Rip); ohne `seit` zählt alles, was da ist.
|
||||||
export function beurteilen(ereignisse: readonly LaufwerkEreignis[], stunden: number, seit?: Date): LaufwerkBericht {
|
* `geraet` ist der Kernel-Gerätename des Laufwerks (\Device\CdRom0),
|
||||||
const relevant = seit === undefined ? [...ereignisse] : ereignisse.filter((e) => new Date(e.zeit).getTime() >= seit.getTime())
|
* '' wenn unbekannt — dann zählen alle cdrom-Ereignisse. */
|
||||||
|
export function beurteilen(ereignisse: readonly LaufwerkEreignis[], stunden: number, seit?: Date, geraet = ''): LaufwerkBericht {
|
||||||
|
const relevant = ereignisse.filter(
|
||||||
|
(e) => gehoertZuLaufwerk(e, geraet) && (seit === undefined || new Date(e.zeit).getTime() >= seit.getTime()),
|
||||||
|
)
|
||||||
let fehlerhafteBloecke = 0
|
let fehlerhafteBloecke = 0
|
||||||
let controllerfehler = 0
|
let controllerfehler = 0
|
||||||
let sonstige = 0
|
let sonstige = 0
|
||||||
@@ -87,7 +101,7 @@ export function beurteilen(ereignisse: readonly LaufwerkEreignis[], stunden: num
|
|||||||
if (relevant.length === 0) {
|
if (relevant.length === 0) {
|
||||||
return {
|
return {
|
||||||
urteil: 'ruhig',
|
urteil: 'ruhig',
|
||||||
text: `Das Windows-Protokoll meldet ${zeitraum} nichts zum Laufwerk — weder Lesefehler noch Hardware-Fehler.`,
|
text: `Das Windows-Protokoll meldet ${zeitraum} nichts zu${geraet.length > 0 ? ` ${geraet}` : 'm Laufwerk'} — weder Lesefehler noch Hardware-Fehler.`,
|
||||||
fehlerhafteBloecke,
|
fehlerhafteBloecke,
|
||||||
controllerfehler,
|
controllerfehler,
|
||||||
sonstige,
|
sonstige,
|
||||||
|
|||||||
@@ -82,6 +82,14 @@ export class DiscWache {
|
|||||||
* Öffentlich, damit Tests sie ohne Uhr treiben können. */
|
* Öffentlich, damit Tests sie ohne Uhr treiben können. */
|
||||||
runde(): void {
|
runde(): void {
|
||||||
if (!this.laeuft) return
|
if (!this.laeuft) return
|
||||||
|
// 5.7.0 (Durchsicht F6): Eine von außen angestoßene Runde (nach dem
|
||||||
|
// Auswurf) ersetzt die geplante — vorher legte JEDER Aufruf eine zweite
|
||||||
|
// Takt-Kette an, und nach zwanzig Discs fragte Rippy die Laufwerke alle
|
||||||
|
// 150 ms statt alle 3 s (Sonde 3: neun statt fünf Runden).
|
||||||
|
if (this.zeiger !== null) {
|
||||||
|
clearTimeout(this.zeiger)
|
||||||
|
this.zeiger = null
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const liste = this.lesen()
|
const liste = this.lesen()
|
||||||
const jetzt = new Map(liste.map((g) => [g.id, g]))
|
const jetzt = new Map(liste.map((g) => [g.id, g]))
|
||||||
|
|||||||
@@ -24,6 +24,15 @@ export function win32Api(): LaufwerkApi {
|
|||||||
)
|
)
|
||||||
const CloseHandle = kernel32.func('bool __stdcall CloseHandle(void *hObject)')
|
const CloseHandle = kernel32.func('bool __stdcall CloseHandle(void *hObject)')
|
||||||
const GetLastError = kernel32.func('uint32_t __stdcall GetLastError()')
|
const GetLastError = kernel32.func('uint32_t __stdcall GetLastError()')
|
||||||
|
// 5.7.0 (Durchsicht F11): Welches Kernel-Gerät steckt hinter G:? —
|
||||||
|
// QueryDosDeviceW liefert \Device\CdRom0 (learn.microsoft.com,
|
||||||
|
// fileapi/nf-fileapi-querydosdevicew: Ziel ist eine Mehrfach-Zeichenkette,
|
||||||
|
// der erste Eintrag zählt; am 12.09.2026 an C: dieses PCs gemessen).
|
||||||
|
// Damit zählt die Laufwerks-Gesundheit nur noch die Ereignisse DIESES
|
||||||
|
// Laufwerks, nicht die einer hustenden Festplatte.
|
||||||
|
const QueryDosDeviceW = kernel32.func(
|
||||||
|
'uint32_t __stdcall QueryDosDeviceW(str16 lpDeviceName, _Out_ char16_t *lpTargetPath, uint32_t ucchMax)',
|
||||||
|
)
|
||||||
const DeviceIoControl = kernel32.func(
|
const DeviceIoControl = kernel32.func(
|
||||||
'bool __stdcall DeviceIoControl(void *hDevice, uint32_t dwIoControlCode, void *lpInBuffer, uint32_t nInBufferSize, _Out_ void *lpOutBuffer, uint32_t nOutBufferSize, _Out_ uint32_t *lpBytesReturned, void *lpOverlapped)',
|
'bool __stdcall DeviceIoControl(void *hDevice, uint32_t dwIoControlCode, void *lpInBuffer, uint32_t nInBufferSize, _Out_ void *lpOutBuffer, uint32_t nOutBufferSize, _Out_ uint32_t *lpBytesReturned, void *lpOverlapped)',
|
||||||
)
|
)
|
||||||
@@ -65,6 +74,16 @@ export function win32Api(): LaufwerkApi {
|
|||||||
CloseHandle(handle)
|
CloseHandle(handle)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
geraeteName(buchstabe: string): string {
|
||||||
|
const ziel = Buffer.alloc(2 * 1024)
|
||||||
|
const laenge = QueryDosDeviceW(`${buchstabe.replace(/:$/, '')}:`, ziel, 1024) as number
|
||||||
|
if (laenge === 0) return ''
|
||||||
|
// UTF-16LE, die erste Zeichenkette bis zum ersten NUL.
|
||||||
|
const text = ziel.toString('utf16le', 0, laenge * 2)
|
||||||
|
const ende = text.indexOf('\0')
|
||||||
|
return (ende >= 0 ? text.slice(0, ende) : text).trim()
|
||||||
|
},
|
||||||
|
|
||||||
steuern(handle: unknown, code: number, eingabe?: Buffer | null, ausgabeBytes = 0): Buffer {
|
steuern(handle: unknown, code: number, eingabe?: Buffer | null, ausgabeBytes = 0): Buffer {
|
||||||
const aus = ausgabeBytes > 0 ? Buffer.alloc(ausgabeBytes) : null
|
const aus = ausgabeBytes > 0 ? Buffer.alloc(ausgabeBytes) : null
|
||||||
const zurueck = [0]
|
const zurueck = [0]
|
||||||
|
|||||||
@@ -250,6 +250,46 @@ export function discZusatzAbtrennen(titel: string): DiscZusatz {
|
|||||||
return { titel: rest.replace(new RegExp(`${TRENNER}+$`), '').trim(), discNr, staffel }
|
return { titel: rest.replace(new RegExp(`${TRENNER}+$`), '').trim(), discNr, staffel }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Der Disc-Typ aus dem, was auf der Disc liegt (5.7.0, Durchsicht F10) —
|
||||||
|
* unabhängig vom Windows-Steuercode, der bei manchen USB-Brücken nicht
|
||||||
|
* antwortet, und genauer als die Größe (eine 4K-Disc mit 48 GB Daten
|
||||||
|
* wurde vorher „bluray"):
|
||||||
|
* * BDMV/index.bdmv beginnt mit „INDX" + Fassung: „0300" ist das
|
||||||
|
* UHD-Format (BDMV 3.0), „0100"/„0200" eine gewöhnliche Blu-ray —
|
||||||
|
* am Akira-UHD-Abbild (F:\Video\backup\UHD_AKIRA) am 12.09.2026 gemessen:
|
||||||
|
* INDX0300.
|
||||||
|
* * VIDEO_TS → DVD.
|
||||||
|
* null, wenn nichts davon zu sehen ist (Audio-CD, Datendisc, Lesefehler). */
|
||||||
|
export function typAusIndexKopf(kopf: Buffer): 'uhd' | 'bluray' | null {
|
||||||
|
if (kopf.length < 8 || kopf.subarray(0, 4).toString('latin1') !== 'INDX') return null
|
||||||
|
const fassung = kopf.subarray(4, 8).toString('latin1')
|
||||||
|
if (fassung === '0300') return 'uhd'
|
||||||
|
if (fassung === '0100' || fassung === '0200') return 'bluray'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function typAusDateisystem(geraetepfad: string): 'uhd' | 'bluray' | 'dvd' | null {
|
||||||
|
const wurzel = discWurzel(geraetepfad)
|
||||||
|
if (wurzel.length === 0) return null
|
||||||
|
const index = join(wurzel, 'BDMV', 'index.bdmv')
|
||||||
|
if (existsSync(index)) {
|
||||||
|
let fd: number | null = null
|
||||||
|
try {
|
||||||
|
fd = openSync(index, 'r')
|
||||||
|
const kopf = lesenBei(fd, 0, 8)
|
||||||
|
const typ = typAusIndexKopf(kopf)
|
||||||
|
if (typ !== null) return typ
|
||||||
|
} catch {
|
||||||
|
// nicht lesbar — dann entscheidet die Größe (unten)
|
||||||
|
} finally {
|
||||||
|
if (fd !== null) closeSync(fd)
|
||||||
|
}
|
||||||
|
return 'bluray'
|
||||||
|
}
|
||||||
|
if (existsSync(join(wurzel, 'VIDEO_TS'))) return 'dvd'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
/** \\.\G: → G:\ — unter Windows hängt Windows die Disc selbst ein. */
|
/** \\.\G: → G:\ — unter Windows hängt Windows die Disc selbst ein. */
|
||||||
export function discWurzel(geraetepfad: string): string {
|
export function discWurzel(geraetepfad: string): string {
|
||||||
const buchstabe = geraetepfad.replace(/:$/, '').split('\\').pop()!.replace(/:$/, '')
|
const buchstabe = geraetepfad.replace(/:$/, '').split('\\').pop()!.replace(/:$/, '')
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ export interface FilmTreffer {
|
|||||||
id: number
|
id: number
|
||||||
title?: string
|
title?: string
|
||||||
name?: string
|
name?: string
|
||||||
|
/** 5.7.0 (Durchsicht F14): TMDb antwortet mit language=de-DE auf Deutsch
|
||||||
|
* („Der Patriot"), die Disc trägt meist den Originaltitel („The
|
||||||
|
* Patriot") — der wörtliche Vergleich braucht beide. */
|
||||||
|
original_title?: string
|
||||||
|
original_name?: string
|
||||||
poster_path?: string | null
|
poster_path?: string | null
|
||||||
backdrop_path?: string | null
|
backdrop_path?: string | null
|
||||||
release_date?: string
|
release_date?: string
|
||||||
|
|||||||
@@ -153,11 +153,19 @@ export function istStub(details: FilmDetails): boolean {
|
|||||||
return !beschreibung && !bild && !bewertet
|
return !beschreibung && !bild && !bewertet
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Akzente und Umlaut-Striche fallen (5.7.0, Durchsicht F14): „Déjà Vu"
|
||||||
|
* auf der Disc heißt „DEJA_VU", bei TMDb „Déjà Vu" — dasselbe Wort. NFD
|
||||||
|
* zerlegt „é" in „e" + Akzent, \p{M} entfernt den Akzent. Gemessen an
|
||||||
|
* Sonde 1 der Durchsicht: vorher kein wörtlicher Treffer. */
|
||||||
|
export function ohneAkzente(t: string): string {
|
||||||
|
return t.normalize('NFD').replace(/\p{M}+/gu, '').replace(/ß/g, 'ss')
|
||||||
|
}
|
||||||
|
|
||||||
/** Titel → Wortmenge, Satzzeichen fallen weg. Grundlage von Überlappung,
|
/** Titel → Wortmenge, Satzzeichen fallen weg. Grundlage von Überlappung,
|
||||||
* Abdeckung und Titelvergleich — alle drei sollen dasselbe „Wort" meinen. */
|
* Abdeckung und Titelvergleich — alle drei sollen dasselbe „Wort" meinen. */
|
||||||
function worte(t: string): Set<string> {
|
function worte(t: string): Set<string> {
|
||||||
return new Set(
|
return new Set(
|
||||||
t
|
ohneAkzente(t)
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.split(/[^\p{L}\p{N}]+/u)
|
.split(/[^\p{L}\p{N}]+/u)
|
||||||
.filter((w) => w.length > 0),
|
.filter((w) => w.length > 0),
|
||||||
@@ -185,7 +193,7 @@ export function gleichBedeutend(a: string, b: string): boolean {
|
|||||||
// Wortmenge wäre hier falsch: Sie machte 'New York, New York' gleich
|
// Wortmenge wäre hier falsch: Sie machte 'New York, New York' gleich
|
||||||
// 'New York'. Verglichen wird die Wort-FOLGE, nur ohne Satzzeichen.
|
// 'New York'. Verglichen wird die Wort-FOLGE, nur ohne Satzzeichen.
|
||||||
const nackt = (t: string): string =>
|
const nackt = (t: string): string =>
|
||||||
t
|
ohneAkzente(t)
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.split(/[^\p{L}\p{N}]+/u)
|
.split(/[^\p{L}\p{N}]+/u)
|
||||||
.filter((w) => w.length > 0)
|
.filter((w) => w.length > 0)
|
||||||
@@ -298,9 +306,10 @@ export async function zuordnen(
|
|||||||
const filme = nurSerie ? [] : ((await tmdb.searchMovie(kandidat.text)) ?? [])
|
const filme = nurSerie ? [] : ((await tmdb.searchMovie(kandidat.text)) ?? [])
|
||||||
if (filme.length > 0 && ersteFilme.length === 0) ersteFilme = filme
|
if (filme.length > 0 && ersteFilme.length === 0) ersteFilme = filme
|
||||||
|
|
||||||
// Wörtlicher Filmtreffer (0,95)
|
// Wörtlicher Filmtreffer (0,95) — auf den deutschen ODER den Originaltitel
|
||||||
|
// (F14: „The Patriot" auf der Disc, „Der Patriot" bei TMDb).
|
||||||
for (const film of filme) {
|
for (const film of filme) {
|
||||||
if (gleichBedeutend(film.title ?? '', kandidat.text)) {
|
if (gleichBedeutend(film.title ?? '', kandidat.text) || gleichBedeutend(film.original_title ?? '', kandidat.text)) {
|
||||||
const details = await tmdb.movieDetails(film.id)
|
const details = await tmdb.movieDetails(film.id)
|
||||||
if (details !== null && !istStub(details)) {
|
if (details !== null && !istStub(details)) {
|
||||||
const meta = ausDetails('movie', film.id, details)
|
const meta = ausDetails('movie', film.id, details)
|
||||||
@@ -312,9 +321,9 @@ export async function zuordnen(
|
|||||||
const serien = (await tmdb.searchTv(kandidat.text)) ?? []
|
const serien = (await tmdb.searchTv(kandidat.text)) ?? []
|
||||||
if (serien.length > 0 && ersteSerien.length === 0) ersteSerien = serien
|
if (serien.length > 0 && ersteSerien.length === 0) ersteSerien = serien
|
||||||
|
|
||||||
// Wörtlicher Serientreffer (0,90)
|
// Wörtlicher Serientreffer (0,90) — deutscher oder Originalname.
|
||||||
for (const serie of serien) {
|
for (const serie of serien) {
|
||||||
if (gleichBedeutend(serie.name ?? '', kandidat.text)) {
|
if (gleichBedeutend(serie.name ?? '', kandidat.text) || gleichBedeutend(serie.original_name ?? '', kandidat.text)) {
|
||||||
const details = await tmdb.tvDetails(serie.id)
|
const details = await tmdb.tvDetails(serie.id)
|
||||||
if (details !== null && !istStub(details)) {
|
if (details !== null && !istStub(details)) {
|
||||||
const meta = ausDetails('tv', serie.id, details)
|
const meta = ausDetails('tv', serie.id, details)
|
||||||
|
|||||||
@@ -134,8 +134,18 @@ export interface StreamInfo {
|
|||||||
codec: string
|
codec: string
|
||||||
kanaele: string
|
kanaele: string
|
||||||
beschreibung: string
|
beschreibung: string
|
||||||
|
/** Attribut 2: „Surround 5.1", „Stereo" (5.7.0). */
|
||||||
|
layout: string
|
||||||
|
/** Attribut 22: Flags. Bit 0x800 heißt „abgeleitet" — an der Kizuna-Disc
|
||||||
|
* gemessen (03.09.2026): 2304 (0x900) am DTS-Kern der DTS-HD-Spur, 6144
|
||||||
|
* (0x1800) an der „nur erzwungene"-Fassung eines Untertitels, 1024 und 0
|
||||||
|
* an den vollen Spuren (5.7.0). */
|
||||||
|
flags: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Bit 0x800 in Attribut 22: eine von MakeMKV abgeleitete Spur. */
|
||||||
|
export const STREAM_FLAG_ABGELEITET = 0x800
|
||||||
|
|
||||||
export interface TitelStreams {
|
export interface TitelStreams {
|
||||||
audio: StreamInfo[]
|
audio: StreamInfo[]
|
||||||
untertitel: StreamInfo[]
|
untertitel: StreamInfo[]
|
||||||
@@ -160,17 +170,19 @@ export function parseStreamInfo(ausgabe: string): Map<number, TitelStreams> {
|
|||||||
}
|
}
|
||||||
let eintrag = streams.get(sNr)
|
let eintrag = streams.get(sNr)
|
||||||
if (eintrag === undefined) {
|
if (eintrag === undefined) {
|
||||||
eintrag = { nr: sNr, typ: '', typCode: 0, lang: '', sprache: '', codec: '', kanaele: '', beschreibung: '' }
|
eintrag = { nr: sNr, typ: '', typCode: 0, lang: '', sprache: '', codec: '', kanaele: '', beschreibung: '', layout: '', flags: 0 }
|
||||||
streams.set(sNr, eintrag)
|
streams.set(sNr, eintrag)
|
||||||
}
|
}
|
||||||
if (attr === 1) {
|
if (attr === 1) {
|
||||||
eintrag.typ = wert
|
eintrag.typ = wert
|
||||||
eintrag.typCode = code
|
eintrag.typCode = code
|
||||||
}
|
}
|
||||||
|
else if (attr === 2) eintrag.layout = wert
|
||||||
else if (attr === 3) eintrag.lang = wert
|
else if (attr === 3) eintrag.lang = wert
|
||||||
else if (attr === 4) eintrag.sprache = wert
|
else if (attr === 4) eintrag.sprache = wert
|
||||||
else if (attr === 6) eintrag.codec = wert
|
else if (attr === 6) eintrag.codec = wert
|
||||||
else if (attr === 14) eintrag.kanaele = wert
|
else if (attr === 14) eintrag.kanaele = wert
|
||||||
|
else if (attr === 22 && /^\d+$/.test(wert)) eintrag.flags = Number(wert)
|
||||||
else if (attr === 30) eintrag.beschreibung = wert
|
else if (attr === 30) eintrag.beschreibung = wert
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,8 +204,8 @@ export function parseStreamInfo(ausgabe: string): Map<number, TitelStreams> {
|
|||||||
|
|
||||||
// EINE Definition für Kern und Fenster (5.4.0): Der Auswahl-Dialog zeigt
|
// EINE Definition für Kern und Fenster (5.4.0): Der Auswahl-Dialog zeigt
|
||||||
// die Sprachen je Titel — deshalb lebt der Typ in gemeinsam/.
|
// die Sprachen je Titel — deshalb lebt der Typ in gemeinsam/.
|
||||||
import type { SprachEintrag } from '../../gemeinsam/nachrichten'
|
import type { SprachEintrag, SpurAngabe } from '../../gemeinsam/nachrichten'
|
||||||
export type { SprachEintrag }
|
export type { SprachEintrag, SpurAngabe }
|
||||||
|
|
||||||
/** Welche Sprachen hat die DISC insgesamt? Über alle Titel vereinigt,
|
/** Welche Sprachen hat die DISC insgesamt? Über alle Titel vereinigt,
|
||||||
* Reihenfolge stabil (wie auf der Disc). */
|
* Reihenfolge stabil (wie auf der Disc). */
|
||||||
@@ -219,6 +231,24 @@ export function sprachenZusammenfassen(streamsJeTitel: Map<number, TitelStreams>
|
|||||||
return { audio: sammeln('audio'), untertitel: sammeln('untertitel') }
|
return { audio: sammeln('audio'), untertitel: sammeln('untertitel') }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Jede Spur EINES Titels als SpurAngabe (5.7.0, für den Auswahl-Dialog):
|
||||||
|
* Ton und Untertitel getrennt, in Disc-Reihenfolge, abgeleitete Spuren
|
||||||
|
* markiert. Ohne Streams leer. */
|
||||||
|
export function spurenFuerTitel(streams: TitelStreams | undefined): { audio: SpurAngabe[]; untertitel: SpurAngabe[] } {
|
||||||
|
const angabe = (s: StreamInfo): SpurAngabe => ({
|
||||||
|
nr: s.nr,
|
||||||
|
lang: s.lang,
|
||||||
|
sprache: s.sprache.length > 0 ? s.sprache : s.lang,
|
||||||
|
codec: s.codec,
|
||||||
|
kanaele: s.kanaele,
|
||||||
|
layout: s.layout,
|
||||||
|
beschreibung: s.beschreibung,
|
||||||
|
abgeleitet: (s.flags & STREAM_FLAG_ABGELEITET) !== 0,
|
||||||
|
})
|
||||||
|
if (streams === undefined) return { audio: [], untertitel: [] }
|
||||||
|
return { audio: streams.audio.map(angabe), untertitel: streams.untertitel.map(angabe) }
|
||||||
|
}
|
||||||
|
|
||||||
/** Die Sprachen EINES Titels (5.4.0, für den Auswahl-Dialog) — dieselbe
|
/** Die Sprachen EINES Titels (5.4.0, für den Auswahl-Dialog) — dieselbe
|
||||||
* Zusammenfassung wie oben, nur auf einen Titel beschränkt. Ohne Streams
|
* Zusammenfassung wie oben, nur auf einen Titel beschränkt. Ohne Streams
|
||||||
* (Titel unbekannt) leer, nicht erfunden. */
|
* (Titel unbekannt) leer, nicht erfunden. */
|
||||||
|
|||||||
@@ -22,7 +22,8 @@
|
|||||||
// 3. NIE eine gute Datei durch eine halbe ersetzen: Geladen wird in
|
// 3. NIE eine gute Datei durch eine halbe ersetzen: Geladen wird in
|
||||||
// `.neu`, geprüft, erst dann getauscht.
|
// `.neu`, geprüft, erst dann getauscht.
|
||||||
import { spawnSync } from 'node:child_process'
|
import { spawnSync } from 'node:child_process'
|
||||||
import { createWriteStream, openSync, readSync, closeSync, mkdirSync, renameSync, rmSync, statSync } from 'node:fs'
|
import { createHash } from 'node:crypto'
|
||||||
|
import { createWriteStream, openSync, readFileSync, readSync, closeSync, mkdirSync, renameSync, rmSync, statSync } from 'node:fs'
|
||||||
import { win32 as pfad } from 'node:path'
|
import { win32 as pfad } from 'node:path'
|
||||||
import { werkzeugOrdner } from './katalog'
|
import { werkzeugOrdner } from './katalog'
|
||||||
|
|
||||||
@@ -285,6 +286,22 @@ export interface BeschaffungErgebnis {
|
|||||||
pfad: string
|
pfad: string
|
||||||
quelle: string
|
quelle: string
|
||||||
version: string
|
version: string
|
||||||
|
/** 5.7.0 (F16): Größe und SHA-256 der geladenen Datei — damit man sie
|
||||||
|
* gegen die Herstellerangabe halten kann, sobald die Seite wieder da ist. */
|
||||||
|
bytes: number
|
||||||
|
sha256: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SHA-256 einer Datei als Hex — für die Anzeige neben dem Installer. */
|
||||||
|
export function pruefsumme(dateiPfad: string): string {
|
||||||
|
return createHash('sha256').update(readFileSync(dateiPfad)).digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Welche Quelle ein Rückfrage braucht: alles außer Hersteller und eigener
|
||||||
|
* Basis. Das Internet Archive liefert eine unsignierte EXE, die mit
|
||||||
|
* Adminrechten läuft — das entscheidet der Nutzer, nicht Rippy (F16). */
|
||||||
|
export function brauchtFreigabe(quellenName: string): boolean {
|
||||||
|
return quellenName === 'Internet Archive'
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Lädt den MakeMKV-Installer über die Quellenkette in Rippys
|
/** Lädt den MakeMKV-Installer über die Quellenkette in Rippys
|
||||||
@@ -297,6 +314,9 @@ export async function makemkvInstallerHolen(
|
|||||||
holen: typeof fetch = fetch,
|
holen: typeof fetch = fetch,
|
||||||
ordner: string = pfad.join(werkzeugOrdner(), 'downloads'),
|
ordner: string = pfad.join(werkzeugOrdner(), 'downloads'),
|
||||||
pruefen: typeof dateiPruefen = dateiPruefen,
|
pruefen: typeof dateiPruefen = dateiPruefen,
|
||||||
|
/** 5.7.0 (F16): Darf eine Quelle mit Rückfrage benutzt werden? Ohne
|
||||||
|
* diese Funktion wird sie übersprungen — nie still geladen. */
|
||||||
|
freigabe?: (quellenName: string, url: string) => Promise<boolean>,
|
||||||
): Promise<BeschaffungErgebnis> {
|
): Promise<BeschaffungErgebnis> {
|
||||||
mkdirSync(ordner, { recursive: true })
|
mkdirSync(ordner, { recursive: true })
|
||||||
const ziel = pfad.join(ordner, `Setup_MakeMKV_v${version}.exe`)
|
const ziel = pfad.join(ordner, `Setup_MakeMKV_v${version}.exe`)
|
||||||
@@ -304,6 +324,14 @@ export async function makemkvInstallerHolen(
|
|||||||
|
|
||||||
const fehlschlaege: string[] = []
|
const fehlschlaege: string[] = []
|
||||||
for (const [name, url] of makemkvQuellen(version, basis)) {
|
for (const [name, url] of makemkvQuellen(version, basis)) {
|
||||||
|
if (brauchtFreigabe(name)) {
|
||||||
|
const erlaubt = freigabe !== undefined && (await freigabe(name, url))
|
||||||
|
if (!erlaubt) {
|
||||||
|
fehlschlaege.push(`${name}: nicht freigegeben`)
|
||||||
|
melden(`${name} wurde nicht freigegeben.`, null)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
melden(`MakeMKV ${version} wird geladen (${name}) …`, 0)
|
melden(`MakeMKV ${version} wird geladen (${name}) …`, 0)
|
||||||
try {
|
try {
|
||||||
await dateiLaden(url, vorlaeufig, melden, holen)
|
await dateiLaden(url, vorlaeufig, melden, holen)
|
||||||
@@ -312,7 +340,7 @@ export async function makemkvInstallerHolen(
|
|||||||
// Erst tauschen, wenn geprüft — ein halber Download darf eine gute
|
// Erst tauschen, wenn geprüft — ein halber Download darf eine gute
|
||||||
// Datei nicht ersetzen.
|
// Datei nicht ersetzen.
|
||||||
renameSync(vorlaeufig, ziel)
|
renameSync(vorlaeufig, ziel)
|
||||||
return { pfad: ziel, quelle: name, version }
|
return { pfad: ziel, quelle: name, version, bytes: statSync(ziel).size, sha256: pruefsumme(ziel) }
|
||||||
} catch (fehler) {
|
} catch (fehler) {
|
||||||
fehlschlaege.push(`${name}: ${fehler instanceof Error ? fehler.message : String(fehler)}`)
|
fehlschlaege.push(`${name}: ${fehler instanceof Error ? fehler.message : String(fehler)}`)
|
||||||
melden(`${name} hat nicht geliefert.`, null)
|
melden(`${name} hat nicht geliefert.`, null)
|
||||||
|
|||||||
@@ -3,20 +3,32 @@
|
|||||||
// am Commander-PC gemessen):
|
// am Commander-PC gemessen):
|
||||||
//
|
//
|
||||||
// 1. Ein eingetragener Dauerlizenz-Schlüssel schlägt alles.
|
// 1. Ein eingetragener Dauerlizenz-Schlüssel schlägt alles.
|
||||||
// 2. Sonst der Beta-Key aus dem Forum-Thread des Herstellers (t=1053).
|
// 2. Sonst: Ein Schlüssel, der schon im Register steht und angenommen
|
||||||
// 3. Abgelegt wird in HKCU\Software\MakeMKV\app_Key — die settings.conf
|
// wird, bleibt (5.7.0 — vorher überschrieb Rippy jeden vorhandenen
|
||||||
|
// Schlüssel mit dem Forum-Key, auch einen gekauften aus dem GUI).
|
||||||
|
// 3. Sonst der Beta-Key aus dem Forum-Thread des Herstellers (t=1053).
|
||||||
|
// 4. Abgelegt wird in HKCU\Software\MakeMKV\app_Key — die settings.conf
|
||||||
// ist der Linux-Weg; unter Windows liest MakeMKV die Registry.
|
// ist der Linux-Weg; unter Windows liest MakeMKV die Registry.
|
||||||
// 4. GEFRAGT statt gerechnet: makemkvcon selbst sagt per MSG 5020/5021/
|
// 5. GEFRAGT statt gerechnet: makemkvcon selbst sagt per MSG 5020/5021/
|
||||||
// 5051, ob der Key taugt. Ein ABGELEHNTER Key ist schlimmer als
|
// 5051, ob der Key taugt. Ein ABGELEHNTER Key ist schlimmer als
|
||||||
// keiner (0 Titel, kein Laufwerk) — er wird dann ZURÜCKGENOMMEN.
|
// keiner (0 Titel, kein Laufwerk) — er wird dann ZURÜCKGENOMMEN.
|
||||||
// 5. app_UpdateEnable=1 nur-wenn-fehlt: Über diesen Kanal holt MakeMKV
|
// Ohne angeschlossenes Laufwerk sagt makemkvcon NICHTS zur Lizenz
|
||||||
|
// (12.09.2026 gemessen: nur 5042/5010) — dann heißt das Urteil
|
||||||
|
// „nicht prüfbar", nicht „angenommen".
|
||||||
|
// 6. app_UpdateEnable=1 nur-wenn-fehlt: Über diesen Kanal holt MakeMKV
|
||||||
// die 4K-UHD-Disc-Schlüssel nach (MSG 3338; unter Windows gemessen).
|
// die 4K-UHD-Disc-Schlüssel nach (MSG 3338; unter Windows gemessen).
|
||||||
import { spawn, spawnSync } from 'node:child_process'
|
import { spawn, spawnSync } from 'node:child_process'
|
||||||
import { textVon } from '../rip/parser'
|
import { textVon } from '../rip/parser'
|
||||||
import { KEY_ABGELEHNT, KRITISCHE_CODES } from '../rip/parser'
|
import { KEY_ABGELEHNT, KRITISCHE_CODES } from '../rip/parser'
|
||||||
|
|
||||||
export const FORUM_URL = 'https://forum.makemkv.com/forum/viewtopic.php?t=1053'
|
export const FORUM_URL = 'https://forum.makemkv.com/forum/viewtopic.php?t=1053'
|
||||||
export const KEY_MUSTER = /T-[A-Za-z0-9_]{50,}/
|
// 5.7.0 (Durchsicht 12.09.2026, F1): Der September-Key lautet
|
||||||
|
// „T-vwvn…6Ox4BI@N5rPp" — 68 Zeichen MIT einem @. Das alte Muster
|
||||||
|
// /T-[A-Za-z0-9_]{50,}/ endete am @ und lieferte 62 Zeichen; MakeMKV lehnte
|
||||||
|
// den Stumpf ab (5020), Rippy nahm ihn zurück, und MakeMKV lief ohne
|
||||||
|
// Schlüssel. Jetzt zählt alles bis zum nächsten Leerraum oder HTML-Zeichen —
|
||||||
|
// Rippy rät nicht, welche Zeichen der Hersteller in seine Schlüssel setzt.
|
||||||
|
export const KEY_MUSTER = /T-[^\s<>"'&;]{50,}/
|
||||||
const REGISTRY_PFAD = 'HKCU\\Software\\MakeMKV'
|
const REGISTRY_PFAD = 'HKCU\\Software\\MakeMKV'
|
||||||
const KEY_NAME = 'app_Key'
|
const KEY_NAME = 'app_Key'
|
||||||
|
|
||||||
@@ -28,12 +40,15 @@ export function extractKey(html: string): string {
|
|||||||
|
|
||||||
/** „valid until/through <Datum>" aus dem Thread — best effort, Rohtext.
|
/** „valid until/through <Datum>" aus dem Thread — best effort, Rohtext.
|
||||||
* § 9.1 will das Ablaufdatum sichtbar; die verlässliche Instanz bleibt
|
* § 9.1 will das Ablaufdatum sichtbar; die verlässliche Instanz bleibt
|
||||||
* aber makemkvcon selbst (MSG 5051 = abgelaufen). */
|
* aber makemkvcon selbst (MSG 5051 = abgelaufen). Der Thread schreibt
|
||||||
|
* mal „September 30th, 2026", mal „end of September 2026" (so am
|
||||||
|
* 12.09.2026 gemessen) — beide Formen kommen an. */
|
||||||
export function extractGueltigBis(html: string): string {
|
export function extractGueltigBis(html: string): string {
|
||||||
const treffer = /valid\s+(?:until|through)\s+([A-Za-zäöüÄÖÜ]+\s+\d{1,2}(?:st|nd|rd|th)?,?\s+\d{4})/i.exec(
|
const treffer =
|
||||||
html ?? '',
|
/valid\s+(?:until|through)\s+((?:(?:the\s+)?end\s+of\s+|(?:the\s+)?beginning\s+of\s+|mid-?\s*)?[A-Za-zäöüÄÖÜ]+(?:\s+\d{1,2}(?:st|nd|rd|th)?,?)?\s+\d{4})/i.exec(
|
||||||
)
|
html ?? '',
|
||||||
return treffer === null ? '' : treffer[1].replace(/(\d)(st|nd|rd|th)/, '$1')
|
)
|
||||||
|
return treffer === null ? '' : treffer[1].replace(/(\d)(st|nd|rd|th)/, '$1').replace(/\s+/g, ' ')
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ForumKey {
|
export interface ForumKey {
|
||||||
@@ -79,6 +94,19 @@ export function keyZuruecknehmen(): boolean {
|
|||||||
return reg(['delete', REGISTRY_PFAD, '/v', KEY_NAME, '/f']).ok
|
return reg(['delete', REGISTRY_PFAD, '/v', KEY_NAME, '/f']).ok
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Den Wert aus einer `reg query`-Ausgabe lesen (pur, testbar):
|
||||||
|
* „ app_Key REG_SZ T-…" — '' wenn keiner dasteht. */
|
||||||
|
export function keyAusQuery(ausgabe: string): string {
|
||||||
|
const treffer = new RegExp(`${KEY_NAME}\\s+REG_SZ\\s+(\\S+)`).exec(ausgabe ?? '')
|
||||||
|
return treffer === null ? '' : treffer[1].trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Der Schlüssel, der gerade im Register steht — '' wenn keiner. */
|
||||||
|
export function keyLesen(): string {
|
||||||
|
const vorhanden = reg(['query', REGISTRY_PFAD, '/v', KEY_NAME])
|
||||||
|
return vorhanden.ok ? keyAusQuery(vorhanden.ausgabe) : ''
|
||||||
|
}
|
||||||
|
|
||||||
/** app_UpdateEnable=1 setzen, NUR wenn es fehlt — wer den Web-Kontakt
|
/** app_UpdateEnable=1 setzen, NUR wenn es fehlt — wer den Web-Kontakt
|
||||||
* bewusst abgeschaltet hat, behält seine Entscheidung. */
|
* bewusst abgeschaltet hat, behält seine Entscheidung. */
|
||||||
export function updateKanalSicherstellen(): boolean {
|
export function updateKanalSicherstellen(): boolean {
|
||||||
@@ -89,6 +117,9 @@ export function updateKanalSicherstellen(): boolean {
|
|||||||
|
|
||||||
export interface KeyUrteil {
|
export interface KeyUrteil {
|
||||||
ok: boolean
|
ok: boolean
|
||||||
|
/** false: makemkvcon hat die Lizenz gar nicht angesehen — es sah kein
|
||||||
|
* Laufwerk (5042). Dann ist `ok` keine Aussage über den Schlüssel. */
|
||||||
|
pruefbar: boolean
|
||||||
code: number | null
|
code: number | null
|
||||||
grund: string
|
grund: string
|
||||||
/** Die MakeMKV-Version aus MSG 1005 („MakeMKV v1.18.4 …") — '' wenn
|
/** Die MakeMKV-Version aus MSG 1005 („MakeMKV v1.18.4 …") — '' wenn
|
||||||
@@ -98,7 +129,9 @@ export interface KeyUrteil {
|
|||||||
|
|
||||||
/** Nimmt MakeMKV den hinterlegten Schlüssel an? — es selbst gefragt:
|
/** Nimmt MakeMKV den hinterlegten Schlüssel an? — es selbst gefragt:
|
||||||
* `info disc:9999` braucht keinen Datenträger, die Freischaltungs-Meldung
|
* `info disc:9999` braucht keinen Datenträger, die Freischaltungs-Meldung
|
||||||
* kommt trotzdem (makemkv_aufruf.py, 29.08.2026).
|
* kommt trotzdem (makemkv_aufruf.py, 29.08.2026) — ABER nur mit einem
|
||||||
|
* angeschlossenen Laufwerk (12.09.2026 ohne Laufwerk gemessen: 5042 und
|
||||||
|
* 5010, keine Lizenzzeile).
|
||||||
*
|
*
|
||||||
* ⚠ ALLE Codes einsammeln, dann urteilen: Beim Commander kamen 5020 UND
|
* ⚠ ALLE Codes einsammeln, dann urteilen: Beim Commander kamen 5020 UND
|
||||||
* 5021 zusammen (30.08.2026 live gemessen) — 5020 allein klingt nach
|
* 5021 zusammen (30.08.2026 live gemessen) — 5020 allein klingt nach
|
||||||
@@ -108,7 +141,7 @@ export interface KeyUrteil {
|
|||||||
export function keyPruefen(programm: string, timeoutMs = 120_000): Promise<KeyUrteil> {
|
export function keyPruefen(programm: string, timeoutMs = 120_000): Promise<KeyUrteil> {
|
||||||
return new Promise((fertig) => {
|
return new Promise((fertig) => {
|
||||||
if (programm.length === 0) {
|
if (programm.length === 0) {
|
||||||
fertig({ ok: false, code: null, grund: 'MakeMKV nicht gefunden', version: '' })
|
fertig({ ok: false, pruefbar: false, code: null, grund: 'MakeMKV nicht gefunden', version: '' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const kind = spawn(programm, ['-r', '--noscan', 'info', 'disc:9999'], {
|
const kind = spawn(programm, ['-r', '--noscan', 'info', 'disc:9999'], {
|
||||||
@@ -121,7 +154,7 @@ export function keyPruefen(programm: string, timeoutMs = 120_000): Promise<KeyUr
|
|||||||
const zeiger = setTimeout(() => kind.kill(), timeoutMs)
|
const zeiger = setTimeout(() => kind.kill(), timeoutMs)
|
||||||
kind.on('error', (fehler) => {
|
kind.on('error', (fehler) => {
|
||||||
clearTimeout(zeiger)
|
clearTimeout(zeiger)
|
||||||
fertig({ ok: false, code: null, grund: `MakeMKV nicht befragbar (${String(fehler)})`, version: '' })
|
fertig({ ok: false, pruefbar: false, code: null, grund: `MakeMKV nicht befragbar (${String(fehler)})`, version: '' })
|
||||||
})
|
})
|
||||||
kind.on('close', () => {
|
kind.on('close', () => {
|
||||||
clearTimeout(zeiger)
|
clearTimeout(zeiger)
|
||||||
@@ -130,10 +163,14 @@ export function keyPruefen(programm: string, timeoutMs = 120_000): Promise<KeyUr
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** MakeMKV sah kein Laufwerk — ohne eines prüft es die Lizenz nicht. */
|
||||||
|
export const KEIN_LAUFWERK = 5042
|
||||||
|
|
||||||
/** Die Ausgabe eines info-Laufs → Urteil (pur, testbar). */
|
/** Die Ausgabe eines info-Laufs → Urteil (pur, testbar). */
|
||||||
export function urteilAusAusgabe(ausgabe: string): KeyUrteil {
|
export function urteilAusAusgabe(ausgabe: string): KeyUrteil {
|
||||||
const codes: number[] = []
|
const codes: number[] = []
|
||||||
let version = ''
|
let version = ''
|
||||||
|
let keinLaufwerk = false
|
||||||
for (const zeile of ausgabe.split(/\r?\n/)) {
|
for (const zeile of ausgabe.split(/\r?\n/)) {
|
||||||
if (!zeile.startsWith('MSG:')) continue
|
if (!zeile.startsWith('MSG:')) continue
|
||||||
const code = Number(zeile.slice(4).split(',', 1)[0])
|
const code = Number(zeile.slice(4).split(',', 1)[0])
|
||||||
@@ -142,21 +179,29 @@ export function urteilAusAusgabe(ausgabe: string): KeyUrteil {
|
|||||||
const treffer = /MakeMKV v([0-9][0-9.]*)/.exec(zeile)
|
const treffer = /MakeMKV v([0-9][0-9.]*)/.exec(zeile)
|
||||||
if (treffer !== null) version = treffer[1]
|
if (treffer !== null) version = treffer[1]
|
||||||
}
|
}
|
||||||
|
if (code === KEIN_LAUFWERK) keinLaufwerk = true
|
||||||
if (KEY_ABGELEHNT.includes(code) || code === 5051) codes.push(code)
|
if (KEY_ABGELEHNT.includes(code) || code === 5051) codes.push(code)
|
||||||
}
|
}
|
||||||
if (codes.length === 0) return { ok: true, code: null, grund: '', version }
|
if (codes.length === 0) {
|
||||||
|
// Keine Lizenzzeile: entweder angenommen — oder gar nicht angesehen.
|
||||||
|
return keinLaufwerk
|
||||||
|
? { ok: true, pruefbar: false, code: KEIN_LAUFWERK, grund: 'MakeMKV sah kein Laufwerk und hat die Lizenz nicht geprüft', version }
|
||||||
|
: { ok: true, pruefbar: true, code: null, grund: '', version }
|
||||||
|
}
|
||||||
// 5021 schlägt alles: Es benennt die URSACHE (Version zu alt) — 5020
|
// 5021 schlägt alles: Es benennt die URSACHE (Version zu alt) — 5020
|
||||||
// ist dann nur die Folge und würde den Schlüssel zu Unrecht beschuldigen.
|
// ist dann nur die Folge und würde den Schlüssel zu Unrecht beschuldigen.
|
||||||
const code = codes.includes(5021) ? 5021 : codes[0]
|
const code = codes.includes(5021) ? 5021 : codes[0]
|
||||||
return { ok: false, code, grund: KRITISCHE_CODES[code] ?? `MSG ${code}`, version }
|
return { ok: false, pruefbar: true, code, grund: KRITISCHE_CODES[code] ?? `MSG ${code}`, version }
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SchluesselStand {
|
export interface SchluesselStand {
|
||||||
/** 'dauerlizenz' | 'forum' | 'keiner' */
|
/** 'dauerlizenz' | 'vorhanden' | 'forum' | 'keiner' */
|
||||||
quelle: string
|
quelle: string
|
||||||
|
/** true angenommen · false abgelehnt/fehlt · null nicht prüfbar (kein Laufwerk). */
|
||||||
angenommen: boolean | null
|
angenommen: boolean | null
|
||||||
/** Laufen Rips trotzdem? (Beta-Modus ohne Key = ja — das ist eine
|
/** Laufen Rips trotzdem? Ohne angenommenen Schlüssel: DVDs ja (laut
|
||||||
* WARNUNG mit Handlungsempfehlung, kein Ausfall.) */
|
* Hersteller dauerhaft frei), Blu-rays nur in MakeMKVs Gnadenfrist —
|
||||||
|
* das ist ein AUSFALL für Blu-ray, keine Warnung (5.7.0). */
|
||||||
weiterBetrieb: boolean
|
weiterBetrieb: boolean
|
||||||
gueltigBis: string
|
gueltigBis: string
|
||||||
geholtAm: string
|
geholtAm: string
|
||||||
@@ -165,19 +210,51 @@ export interface SchluesselStand {
|
|||||||
version: string
|
version: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Die ganze Kette: Dauerlizenz → Forum-Key → ablegen → PRÜFEN →
|
/** Was ein abgelehnter oder fehlender Schlüssel bedeutet — EIN Satz für
|
||||||
* bei Ablehnung zurücknehmen. Gibt den Stand fürs Dashboard zurück. */
|
* alle Wege, damit das Fenster nie „Rips laufen" sagt, wo Blu-rays nicht
|
||||||
|
* laufen. */
|
||||||
|
export const OHNE_SCHLUESSEL_FOLGE =
|
||||||
|
'Ohne angenommenen Schlüssel liest MakeMKV DVDs weiter (dauerhaft frei), Blu-rays und 4K aber nur in seiner Gnadenfrist nach dem Testzeitraum — danach verweigert es sie.'
|
||||||
|
|
||||||
|
/** Die ganze Kette: Dauerlizenz → vorhandener Schlüssel → Forum-Key →
|
||||||
|
* ablegen → PRÜFEN → bei Ablehnung zurücknehmen. Gibt den Stand fürs
|
||||||
|
* Dashboard zurück. */
|
||||||
export async function schluesselKette(
|
export async function schluesselKette(
|
||||||
makemkvcon: string,
|
makemkvcon: string,
|
||||||
dauerlizenz: string,
|
dauerlizenz: string,
|
||||||
jetztIso: string,
|
jetztIso: string,
|
||||||
holen: typeof fetch = fetch,
|
holen: typeof fetch = fetch,
|
||||||
|
registerLesen: () => string = keyLesen,
|
||||||
): Promise<SchluesselStand> {
|
): Promise<SchluesselStand> {
|
||||||
updateKanalSicherstellen()
|
updateKanalSicherstellen()
|
||||||
|
|
||||||
|
const versionsText = (urteil: KeyUrteil): string => (urteil.version.length > 0 ? ` ${urteil.version}` : '')
|
||||||
|
const nichtPruefbar = (quelle: string, gueltigBis: string, urteil: KeyUrteil, wer: string): SchluesselStand => ({
|
||||||
|
quelle,
|
||||||
|
angenommen: null,
|
||||||
|
weiterBetrieb: true,
|
||||||
|
gueltigBis,
|
||||||
|
geholtAm: jetztIso,
|
||||||
|
text: `${wer} hinterlegt — ob MakeMKV${versionsText(urteil)} ihn annimmt, lässt sich erst mit angeschlossenem Laufwerk prüfen (${urteil.grund}).`,
|
||||||
|
version: urteil.version,
|
||||||
|
})
|
||||||
|
|
||||||
if (dauerlizenz.trim().length > 0) {
|
if (dauerlizenz.trim().length > 0) {
|
||||||
keyAblegen(dauerlizenz)
|
// 5.7.0: Das Ergebnis von reg add zählt — „hinterlegt" stand vorher
|
||||||
|
// auch da, wenn nichts geschrieben wurde.
|
||||||
|
if (!keyAblegen(dauerlizenz)) {
|
||||||
|
return {
|
||||||
|
quelle: 'dauerlizenz',
|
||||||
|
angenommen: false,
|
||||||
|
weiterBetrieb: false,
|
||||||
|
gueltigBis: '',
|
||||||
|
geholtAm: jetztIso,
|
||||||
|
text: 'Eigener Schlüssel konnte nicht ins Register geschrieben werden (reg add scheiterte). ' + OHNE_SCHLUESSEL_FOLGE,
|
||||||
|
version: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
const urteil = await keyPruefen(makemkvcon)
|
const urteil = await keyPruefen(makemkvcon)
|
||||||
|
if (!urteil.pruefbar) return nichtPruefbar('dauerlizenz', '', urteil, 'Eigener Schlüssel')
|
||||||
return {
|
return {
|
||||||
quelle: 'dauerlizenz',
|
quelle: 'dauerlizenz',
|
||||||
angenommen: urteil.ok,
|
angenommen: urteil.ok,
|
||||||
@@ -185,54 +262,85 @@ export async function schluesselKette(
|
|||||||
gueltigBis: '',
|
gueltigBis: '',
|
||||||
geholtAm: jetztIso,
|
geholtAm: jetztIso,
|
||||||
text: urteil.ok
|
text: urteil.ok
|
||||||
? `Eigener Schlüssel hinterlegt und von MakeMKV${urteil.version.length > 0 ? ` ${urteil.version}` : ''} angenommen.`
|
? `Eigener Schlüssel hinterlegt und von MakeMKV${versionsText(urteil)} angenommen.`
|
||||||
: `Eigener Schlüssel abgelehnt: ${urteil.grund}`,
|
: `Eigener Schlüssel abgelehnt: ${urteil.grund}. ${OHNE_SCHLUESSEL_FOLGE}`,
|
||||||
version: urteil.version,
|
version: urteil.version,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 5.7.0: Ein Schlüssel, der schon steht und angenommen wird, bleibt —
|
||||||
|
// egal ob gekauft (im GUI eingetragen) oder ein älterer Forum-Key.
|
||||||
|
const vorhanden = registerLesen()
|
||||||
|
if (vorhanden.length > 0) {
|
||||||
|
const urteil = await keyPruefen(makemkvcon)
|
||||||
|
if (!urteil.pruefbar) return nichtPruefbar('vorhanden', '', urteil, 'Ein Schlüssel ist')
|
||||||
|
if (urteil.ok) {
|
||||||
|
return {
|
||||||
|
quelle: 'vorhanden',
|
||||||
|
angenommen: true,
|
||||||
|
weiterBetrieb: true,
|
||||||
|
gueltigBis: '',
|
||||||
|
geholtAm: jetztIso,
|
||||||
|
text: `Der hinterlegte Schlüssel wird von MakeMKV${versionsText(urteil)} angenommen — Rippy lässt ihn stehen.`,
|
||||||
|
version: urteil.version,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Abgelehnt: dann darf der Forum-Key ihn ersetzen (unten).
|
||||||
|
}
|
||||||
|
|
||||||
const forum = await betaKeyHolen(holen)
|
const forum = await betaKeyHolen(holen)
|
||||||
if (forum === null) {
|
if (forum === null) {
|
||||||
// Kein neuer Key erreichbar — der VORHANDENE bleibt stehen (R2) und
|
// Kein neuer Key erreichbar — der VORHANDENE bleibt stehen (R2) und
|
||||||
// wird geprüft, damit der Stand ehrlich ist.
|
// wird geprüft, damit der Stand ehrlich ist.
|
||||||
const urteil = await keyPruefen(makemkvcon)
|
const urteil = await keyPruefen(makemkvcon)
|
||||||
|
if (!urteil.pruefbar) return nichtPruefbar('keiner', '', urteil, 'Forum nicht erreichbar; der vorhandene Stand ist')
|
||||||
return {
|
return {
|
||||||
quelle: 'keiner',
|
quelle: 'keiner',
|
||||||
angenommen: urteil.ok,
|
angenommen: urteil.ok,
|
||||||
weiterBetrieb: true,
|
weiterBetrieb: urteil.ok,
|
||||||
gueltigBis: '',
|
gueltigBis: '',
|
||||||
geholtAm: jetztIso,
|
geholtAm: jetztIso,
|
||||||
text: urteil.ok
|
text: urteil.ok
|
||||||
? 'Forum nicht erreichbar — der hinterlegte Schlüssel funktioniert weiter.'
|
? 'Forum nicht erreichbar — der hinterlegte Schlüssel funktioniert weiter.'
|
||||||
: `Forum nicht erreichbar und der hinterlegte Schlüssel taugt nicht: ${urteil.grund}`,
|
: `Forum nicht erreichbar und der hinterlegte Schlüssel taugt nicht: ${urteil.grund}. ${OHNE_SCHLUESSEL_FOLGE}`,
|
||||||
version: urteil.version,
|
version: urteil.version,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
keyAblegen(forum.key)
|
if (!keyAblegen(forum.key)) {
|
||||||
|
return {
|
||||||
|
quelle: 'forum',
|
||||||
|
angenommen: false,
|
||||||
|
weiterBetrieb: false,
|
||||||
|
gueltigBis: forum.gueltigBis,
|
||||||
|
geholtAm: jetztIso,
|
||||||
|
text: 'Beta-Key geholt, aber nicht ins Register schreibbar (reg add scheiterte). ' + OHNE_SCHLUESSEL_FOLGE,
|
||||||
|
version: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
const urteil = await keyPruefen(makemkvcon)
|
const urteil = await keyPruefen(makemkvcon)
|
||||||
|
if (!urteil.pruefbar) return nichtPruefbar('forum', forum.gueltigBis, urteil, 'Beta-Key aus dem Forum')
|
||||||
if (!urteil.ok) {
|
if (!urteil.ok) {
|
||||||
// Ein abgelehnter Schlüssel ist schlimmer als keiner — zurücknehmen.
|
// Ein abgelehnter Schlüssel ist schlimmer als keiner — zurücknehmen.
|
||||||
// (Live gemessen 30.08.2026: MIT abgelehntem Key verweigert MakeMKV
|
// (Live gemessen 30.08.2026: MIT abgelehntem Key verweigert MakeMKV
|
||||||
// alles; OHNE läuft es im Beta-Modus weiter und rippt.)
|
// alles; OHNE läuft es im Beta-Modus weiter und rippt — DVDs immer,
|
||||||
|
// Blu-rays nur bis zum Ende des Testzeitraums.)
|
||||||
keyZuruecknehmen()
|
keyZuruecknehmen()
|
||||||
}
|
}
|
||||||
const versionsText = urteil.version.length > 0 ? ` ${urteil.version}` : ''
|
|
||||||
return {
|
return {
|
||||||
quelle: 'forum',
|
quelle: 'forum',
|
||||||
angenommen: urteil.ok,
|
angenommen: urteil.ok,
|
||||||
// Der Zurücknahme-Weg lässt MakeMKV im Beta-Modus weiterlaufen —
|
weiterBetrieb: urteil.ok,
|
||||||
// Rips gehen, nur eben ohne Freischaltungs-Komfort.
|
|
||||||
weiterBetrieb: true,
|
|
||||||
gueltigBis: forum.gueltigBis,
|
gueltigBis: forum.gueltigBis,
|
||||||
geholtAm: jetztIso,
|
geholtAm: jetztIso,
|
||||||
text: urteil.ok
|
text: urteil.ok
|
||||||
? `Beta-Key aus dem Forum hinterlegt und von MakeMKV${versionsText} angenommen${forum.gueltigBis.length > 0 ? ` — gültig bis ${forum.gueltigBis}` : ''}.`
|
? `Beta-Key aus dem Forum hinterlegt und von MakeMKV${versionsText(urteil)} angenommen${forum.gueltigBis.length > 0 ? ` — gültig bis ${forum.gueltigBis}` : ''}.`
|
||||||
: urteil.code === 5021
|
: urteil.code === 5021
|
||||||
? `Der aktuelle Beta-Key ist in Ordnung — aber MakeMKV${versionsText} ist ZU ALT für ihn. ` +
|
? `Der aktuelle Beta-Key ist in Ordnung — aber MakeMKV${versionsText(urteil)} ist ZU ALT für ihn. ` +
|
||||||
'Rippy hat ihn wieder entfernt, damit MakeMKV im Beta-Modus weiterläuft (so klappen Rips trotzdem). ' +
|
'Rippy hat ihn wieder entfernt. ' +
|
||||||
'Abhilfe: MakeMKV von makemkv.com aktualisieren — danach hinterlegt Rippy den Key automatisch neu.'
|
OHNE_SCHLUESSEL_FOLGE +
|
||||||
: `Beta-Key abgelehnt (${urteil.grund}) — wieder entfernt, damit MakeMKV im Beta-Modus weiterläuft.`,
|
' Abhilfe: MakeMKV von makemkv.com aktualisieren — danach hinterlegt Rippy den Key automatisch neu.'
|
||||||
|
: `Beta-Key abgelehnt (${urteil.grund}) — wieder entfernt. ${OHNE_SCHLUESSEL_FOLGE}`,
|
||||||
version: urteil.version,
|
version: urteil.version,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,12 +22,20 @@
|
|||||||
// eigene hat, behält sie — wie bei app_UpdateEnable in schluessel.ts).
|
// eigene hat, behält sie — wie bei app_UpdateEnable in schluessel.ts).
|
||||||
// Abgelegt wird wie der Schlüssel in HKCU\Software\MakeMKV (Windows liest
|
// Abgelegt wird wie der Schlüssel in HKCU\Software\MakeMKV (Windows liest
|
||||||
// dort, die settings.conf ist der Linux-Weg).
|
// dort, die settings.conf ist der Linux-Weg).
|
||||||
|
//
|
||||||
|
// 5.7.0: Seit der Dialog jede Spur einzeln anbietet, dürfen auch die
|
||||||
|
// Stereo-Doppel bleiben (havemulti — „Deutsch · DD · 2.0 (Kommentar)"
|
||||||
|
// neben „Deutsch · DTS-HD MA · 5.1"): Nur die abgeleiteten DTS-Kerne
|
||||||
|
// (havecore) fallen weg, die zeigt der Dialog gar nicht erst. Stand
|
||||||
|
// Rippys ALTE Regel in der Registry, wird sie ersetzt; eine fremde bleibt.
|
||||||
import { spawnSync } from 'node:child_process'
|
import { spawnSync } from 'node:child_process'
|
||||||
|
|
||||||
export const REGISTRY_PFAD = 'HKCU\\Software\\MakeMKV'
|
export const REGISTRY_PFAD = 'HKCU\\Software\\MakeMKV'
|
||||||
export const REGEL_NAME = 'app_DefaultSelectionString'
|
export const REGEL_NAME = 'app_DefaultSelectionString'
|
||||||
/** Alles behalten, Doppel und Kerne weg — sprachunabhängig. */
|
/** Alles behalten, nur die Kerne weg — sprachunabhängig (5.7.0). */
|
||||||
export const RIPPY_REGEL = '-sel:all,+sel:all,-sel:(havemulti|havecore),=100:all'
|
export const RIPPY_REGEL = '-sel:all,+sel:all,-sel:havecore,=100:all'
|
||||||
|
/** Rippys frühere Regeln (5.4.0–5.6.1) — werden beim Start ersetzt. */
|
||||||
|
export const RIPPY_REGELN_ALT: readonly string[] = ['-sel:all,+sel:all,-sel:(havemulti|havecore),=100:all']
|
||||||
|
|
||||||
export interface SpurauswahlStand {
|
export interface SpurauswahlStand {
|
||||||
/** 'gesetzt' (von Rippy, jetzt) | 'eigen' (der Nutzer hat eine eigene) |
|
/** 'gesetzt' (von Rippy, jetzt) | 'eigen' (der Nutzer hat eine eigene) |
|
||||||
@@ -37,6 +45,16 @@ export interface SpurauswahlStand {
|
|||||||
text: string
|
text: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Was mit einer vorgefundenen Regel geschieht (pur, testbar):
|
||||||
|
* 'rippy' = steht schon · 'ersetzen' = Rippys alte Regel · 'eigen' =
|
||||||
|
* fremd, bleibt · 'setzen' = keine da. */
|
||||||
|
export function regelUrteil(bisher: string | null): 'rippy' | 'ersetzen' | 'eigen' | 'setzen' {
|
||||||
|
if (bisher === null || bisher.length === 0) return 'setzen'
|
||||||
|
if (bisher === RIPPY_REGEL) return 'rippy'
|
||||||
|
if (RIPPY_REGELN_ALT.includes(bisher)) return 'ersetzen'
|
||||||
|
return 'eigen'
|
||||||
|
}
|
||||||
|
|
||||||
function reg(args: string[]): { ok: boolean; ausgabe: string } {
|
function reg(args: string[]): { ok: boolean; ausgabe: string } {
|
||||||
const ergebnis = spawnSync('reg', args, { windowsHide: true, timeout: 15_000, encoding: 'utf8' })
|
const ergebnis = spawnSync('reg', args, { windowsHide: true, timeout: 15_000, encoding: 'utf8' })
|
||||||
return { ok: ergebnis.status === 0, ausgabe: (ergebnis.stdout ?? '') + (ergebnis.stderr ?? '') }
|
return { ok: ergebnis.status === 0, ausgabe: (ergebnis.stdout ?? '') + (ergebnis.stderr ?? '') }
|
||||||
@@ -56,14 +74,15 @@ export function spurauswahlSicherstellen(): SpurauswahlStand {
|
|||||||
}
|
}
|
||||||
const vorhanden = reg(['query', REGISTRY_PFAD, '/v', REGEL_NAME])
|
const vorhanden = reg(['query', REGISTRY_PFAD, '/v', REGEL_NAME])
|
||||||
const bisher = vorhanden.ok ? regelAusQuery(vorhanden.ausgabe) : null
|
const bisher = vorhanden.ok ? regelAusQuery(vorhanden.ausgabe) : null
|
||||||
if (bisher !== null && bisher.length > 0) {
|
const urteil = regelUrteil(bisher)
|
||||||
if (bisher === RIPPY_REGEL) {
|
if (urteil === 'rippy') {
|
||||||
return { stand: 'rippy', regel: bisher, text: 'MakeMKV behält alle Sprachen (Rippys Auswahlregel steht).' }
|
return { stand: 'rippy', regel: bisher ?? '', text: 'MakeMKV behält alle Spuren (Rippys Auswahlregel steht).' }
|
||||||
}
|
}
|
||||||
|
if (urteil === 'eigen') {
|
||||||
return {
|
return {
|
||||||
stand: 'eigen',
|
stand: 'eigen',
|
||||||
regel: bisher,
|
regel: bisher ?? '',
|
||||||
text: `MakeMKV hat eine eigene Auswahlregel („${bisher}") — Rippy lässt sie stehen. Fehlen Sprachen in der Roh-Datei, liegt es daran.`,
|
text: `MakeMKV hat eine eigene Auswahlregel („${bisher}") — Rippy lässt sie stehen. Fehlen Spuren in der Roh-Datei, liegt es daran.`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const gesetzt = reg(['add', REGISTRY_PFAD, '/v', REGEL_NAME, '/t', 'REG_SZ', '/d', RIPPY_REGEL, '/f'])
|
const gesetzt = reg(['add', REGISTRY_PFAD, '/v', REGEL_NAME, '/t', 'REG_SZ', '/d', RIPPY_REGEL, '/f'])
|
||||||
@@ -73,6 +92,9 @@ export function spurauswahlSicherstellen(): SpurauswahlStand {
|
|||||||
return {
|
return {
|
||||||
stand: 'gesetzt',
|
stand: 'gesetzt',
|
||||||
regel: RIPPY_REGEL,
|
regel: RIPPY_REGEL,
|
||||||
text: 'MakeMKV-Auswahlregel gesetzt: alle Sprachen kommen in die Roh-Datei (Stereo-Doppel und DTS-Kerne nicht).',
|
text:
|
||||||
|
urteil === 'ersetzen'
|
||||||
|
? 'MakeMKV-Auswahlregel erneuert: jede Spur kommt in die Roh-Datei, auch Stereo-Fassungen und Kommentare (nur DTS-Kerne nicht).'
|
||||||
|
: 'MakeMKV-Auswahlregel gesetzt: jede Spur kommt in die Roh-Datei (nur DTS-Kerne nicht).',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -172,3 +172,28 @@ describe('Encoder- und Preset-Listen', () => {
|
|||||||
expect(parsePresetListe(liste)).toEqual(['Very Fast 2160p60 4K AV1', 'H.265 VCN 2160p 4K'])
|
expect(parsePresetListe(liste)).toEqual(['Very Fast 2160p60 4K AV1', 'H.265 VCN 2160p 4K'])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 5.7.0 (Durchsicht F3): Nur die Dateien DIESER Disc werden benannt — der
|
||||||
|
// Season-Ordner darf voll sein.
|
||||||
|
describe('episodenUmbenennen mit nurDiese (5.7.0)', () => {
|
||||||
|
it('Bestand im Ordner stört nicht', () => {
|
||||||
|
const umbenannt: Array<[string, string]> = []
|
||||||
|
const meldungen = episodenUmbenennen(
|
||||||
|
'S',
|
||||||
|
'Akte X',
|
||||||
|
1,
|
||||||
|
[3, 4],
|
||||||
|
{
|
||||||
|
listen: () => ['Akte X S01E01.mkv', 'Akte X S01E02.mkv', 'title_t00.mkv', 'title_t01.mkv'],
|
||||||
|
existiert: () => false,
|
||||||
|
umbenennen: (von, nach) => umbenannt.push([von, nach]),
|
||||||
|
},
|
||||||
|
['title_t00.mkv', 'title_t01.mkv'],
|
||||||
|
)
|
||||||
|
expect(umbenannt).toEqual([
|
||||||
|
[join('S', 'title_t00.mkv'), join('S', 'Akte X S01E03.mkv')],
|
||||||
|
[join('S', 'title_t01.mkv'), join('S', 'Akte X S01E04.mkv')],
|
||||||
|
])
|
||||||
|
expect(meldungen).toHaveLength(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ describe('komprimierenUndAblegen', () => {
|
|||||||
const ergebnis = await komprimierenUndAblegen(
|
const ergebnis = await komprimierenUndAblegen(
|
||||||
auftrag({ rohOrdner: roh, dateien }),
|
auftrag({ rohOrdner: roh, dateien }),
|
||||||
umgebung(ablage, {
|
umgebung(ablage, {
|
||||||
spurenScannen: async () => ({ audio: [{ nr: 1, lang: 'deu', beschreibung: 'Deutsch' }], untertitel: [], dauerS: 3167 }),
|
spurenScannen: async () => ({ audio: [{ nr: 1, lang: 'deu', beschreibung: 'Deutsch', codec: 'dtshd', kanaele: 6 }], untertitel: [], dauerS: 3167 }),
|
||||||
}),
|
}),
|
||||||
() => {},
|
() => {},
|
||||||
KEIN_ABBRUCH,
|
KEIN_ABBRUCH,
|
||||||
@@ -347,3 +347,139 @@ describe('Wiederholen ersetzt die alte Fassung', () => {
|
|||||||
expect(readFileSync(alt, 'utf8')).toBe('alt')
|
expect(readFileSync(alt, 'utf8')).toBe('alt')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── 5.7.0: Funde der Durchsicht vom 12.09.2026 ─────────────────────────
|
||||||
|
describe('komprimierenUndAblegen — Durchsicht 5.7.0', () => {
|
||||||
|
// F3: Disc 2 derselben Staffel landet im selben Season-Ordner wie Disc 1.
|
||||||
|
// Vorher zählte die Umbenennung ALLE MKVs des Ordners („4 Dateien, 2
|
||||||
|
// Zuordnungen") und tat nichts — der Serien-Merker lief ins Leere.
|
||||||
|
it('F3: zweite Disc in denselben Season-Ordner — nur ihre Folgen werden benannt', async () => {
|
||||||
|
const { ablage, roh, dateien } = aufbau(['title_t00.mkv', 'title_t01.mkv'], ['folge', 'folge'])
|
||||||
|
const season = join(ablage, 'Serien', 'Spartacus', 'Season 01')
|
||||||
|
mkdirSync(season, { recursive: true })
|
||||||
|
writeFileSync(join(season, 'Spartacus S01E01.mkv'), 'disc1')
|
||||||
|
writeFileSync(join(season, 'Spartacus S01E02.mkv'), 'disc1')
|
||||||
|
const ergebnis = await komprimierenUndAblegen(
|
||||||
|
auftrag({ rohOrdner: roh, dateien, zuordnung: zuordnung('tv', 'Spartacus', 2010), staffel: 1, ersteFolge: 3 }),
|
||||||
|
umgebung(ablage),
|
||||||
|
() => {},
|
||||||
|
KEIN_ABBRUCH,
|
||||||
|
)
|
||||||
|
expect(ergebnis.status).toBe('fertig')
|
||||||
|
expect(readdirSync(season).filter((n) => n.endsWith('.mkv')).sort()).toEqual([
|
||||||
|
'Spartacus S01E01.mkv',
|
||||||
|
'Spartacus S01E02.mkv',
|
||||||
|
'Spartacus S01E03.mkv',
|
||||||
|
'Spartacus S01E04.mkv',
|
||||||
|
])
|
||||||
|
expect(readFileSync(join(season, 'Spartacus S01E01.mkv'), 'utf8')).toBe('disc1')
|
||||||
|
expect(ergebnis.folgenVergeben).toEqual([3, 4])
|
||||||
|
})
|
||||||
|
|
||||||
|
// F3: Liegt im Season-Ordner noch ein title_t00.mkv der ersten Disc
|
||||||
|
// (deren Umbenennung ausfiel), darf die zweite es NICHT überschreiben.
|
||||||
|
it('F3: gleichnamige Datei im Ziel wird nie überschrieben — „(2)" statt Verlust', async () => {
|
||||||
|
const { ablage, roh, dateien } = aufbau(['title_t00.mkv'], ['folge'])
|
||||||
|
const season = join(ablage, 'Serien', 'Spartacus', 'Season 01')
|
||||||
|
mkdirSync(season, { recursive: true })
|
||||||
|
writeFileSync(join(season, 'title_t00.mkv'), 'disc1-unbenannt')
|
||||||
|
const ergebnis = await komprimierenUndAblegen(
|
||||||
|
auftrag({ rohOrdner: roh, dateien, zuordnung: zuordnung('tv', 'Spartacus', 2010), staffel: 1 }),
|
||||||
|
umgebung(ablage),
|
||||||
|
() => {},
|
||||||
|
KEIN_ABBRUCH,
|
||||||
|
)
|
||||||
|
expect(ergebnis.status).toBe('fertig')
|
||||||
|
expect(readFileSync(join(season, 'title_t00.mkv'), 'utf8')).toBe('disc1-unbenannt')
|
||||||
|
expect(existsSync(join(season, 'title_t00 (2).mkv'))).toBe(true)
|
||||||
|
expect(ergebnis.dateien.map((d) => d.split(/[\\/]/).pop())).toEqual(['title_t00 (2).mkv'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 5.7.0: Spuren einzeln ──────────────────────────────────────────────────
|
||||||
|
describe('Spuren einzeln (5.7.0)', () => {
|
||||||
|
const jpnHd = { nr: 1, lang: 'jpn', sprache: 'Japanese', codec: 'DTS-HD MA', kanaele: '6', layout: 'Surround 5.1', beschreibung: '', abgeleitet: false }
|
||||||
|
const deuKommentar = { nr: 4, lang: 'deu', sprache: 'German', codec: 'DD', kanaele: '2', layout: 'Stereo', beschreibung: 'DD Stereo German (Kommentar)', abgeleitet: false }
|
||||||
|
const deuPgs = { nr: 5, lang: 'deu', sprache: 'German', codec: 'PGS', kanaele: '', layout: '', beschreibung: 'PGS German', abgeleitet: false }
|
||||||
|
const engPgs = { nr: 7, lang: 'eng', sprache: 'English', codec: 'PGS', kanaele: '', layout: '', beschreibung: 'PGS English', abgeleitet: false }
|
||||||
|
const scan = {
|
||||||
|
audio: [
|
||||||
|
{ nr: 1, lang: 'jpn', beschreibung: '', codec: 'dtshd', kanaele: 6 },
|
||||||
|
{ nr: 2, lang: 'deu', beschreibung: '', codec: 'ac3', kanaele: 6 },
|
||||||
|
{ nr: 3, lang: 'deu', beschreibung: '', codec: 'ac3', kanaele: 2 },
|
||||||
|
],
|
||||||
|
untertitel: [
|
||||||
|
{ nr: 1, lang: 'deu', beschreibung: '', codec: 'PGS', kanaele: 0 },
|
||||||
|
{ nr: 2, lang: 'eng', beschreibung: '', codec: 'PGS', kanaele: 0 },
|
||||||
|
],
|
||||||
|
dauerS: 3167,
|
||||||
|
}
|
||||||
|
|
||||||
|
it('gewählte Spuren erreichen HandBrake als Nummern — japanischer Ton stellt die deutschen Untertitel vor und wählt sie vor', async () => {
|
||||||
|
const { ablage, roh, dateien } = aufbau(['title_t00.mkv'])
|
||||||
|
dateien[0].audioSprachen = ['jpn', 'deu']
|
||||||
|
dateien[0].untertitelSprachen = ['eng', 'deu']
|
||||||
|
dateien[0].audioSpuren = [jpnHd, deuKommentar]
|
||||||
|
dateien[0].untertitelSpuren = [engPgs, deuPgs]
|
||||||
|
const ergebnis = await komprimierenUndAblegen(
|
||||||
|
auftrag({ rohOrdner: roh, dateien }),
|
||||||
|
umgebung(ablage, { spurenScannen: async () => scan }),
|
||||||
|
() => {},
|
||||||
|
KEIN_ABBRUCH,
|
||||||
|
)
|
||||||
|
expect(ergebnis.status).toBe('fertig')
|
||||||
|
expect(hb.aufrufe[0].audioNr).toEqual([1, 3])
|
||||||
|
expect(hb.aufrufe[0].untertitelNr).toEqual([1, 2])
|
||||||
|
expect(hb.aufrufe[0].untertitelStandardNr).toBe(1)
|
||||||
|
expect(ergebnis.hinweise.join(' ')).not.toContain('NICHT in der Roh-Datei')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('eine gewählte Spur fehlt in der Roh-Datei → Hinweis, der Rest läuft', async () => {
|
||||||
|
const { ablage, roh, dateien } = aufbau(['title_t00.mkv'])
|
||||||
|
const fra = { ...jpnHd, nr: 9, lang: 'fra', sprache: 'French' }
|
||||||
|
dateien[0].audioSpuren = [fra, deuKommentar]
|
||||||
|
dateien[0].untertitelSpuren = []
|
||||||
|
const ergebnis = await komprimierenUndAblegen(
|
||||||
|
auftrag({ rohOrdner: roh, dateien }),
|
||||||
|
umgebung(ablage, { spurenScannen: async () => scan }),
|
||||||
|
() => {},
|
||||||
|
KEIN_ABBRUCH,
|
||||||
|
)
|
||||||
|
expect(hb.aufrufe[0].audioNr).toEqual([3])
|
||||||
|
expect(hb.aufrufe[0].untertitelNr).toEqual([])
|
||||||
|
expect(ergebnis.hinweise.join(' ')).toContain('Tonspur French DTS-HD MA ist NICHT in der Roh-Datei')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Scan der Roh-Datei scheitert → Sprachen der gewählten Spuren als Liste (wie bisher)', async () => {
|
||||||
|
const { ablage, roh, dateien } = aufbau(['title_t00.mkv'])
|
||||||
|
dateien[0].audioSpuren = [jpnHd]
|
||||||
|
dateien[0].untertitelSpuren = [deuPgs]
|
||||||
|
const ergebnis = await komprimierenUndAblegen(
|
||||||
|
auftrag({ rohOrdner: roh, dateien }),
|
||||||
|
umgebung(ablage, { spurenScannen: async () => null }),
|
||||||
|
() => {},
|
||||||
|
KEIN_ABBRUCH,
|
||||||
|
)
|
||||||
|
expect(hb.aufrufe[0].audioNr).toBeUndefined()
|
||||||
|
expect(hb.aufrufe[0].audioSprachen).toEqual(['jpn'])
|
||||||
|
expect(hb.aufrufe[0].untertitelSprachen).toEqual(['deu'])
|
||||||
|
expect(hb.aufrufe[0].untertitelStandard).toBe(true)
|
||||||
|
expect(ergebnis.hinweise.join(' ')).toContain('ließen sich nicht messen')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keine gewählte Tonspur findbar → Sprachlisten statt stummer Datei', async () => {
|
||||||
|
const { ablage, roh, dateien } = aufbau(['title_t00.mkv'])
|
||||||
|
const fra = { ...jpnHd, nr: 9, lang: 'fra', sprache: 'French' }
|
||||||
|
dateien[0].audioSpuren = [fra]
|
||||||
|
dateien[0].untertitelSpuren = [deuPgs]
|
||||||
|
const ergebnis = await komprimierenUndAblegen(
|
||||||
|
auftrag({ rohOrdner: roh, dateien }),
|
||||||
|
umgebung(ablage, { spurenScannen: async () => scan }),
|
||||||
|
() => {},
|
||||||
|
KEIN_ABBRUCH,
|
||||||
|
)
|
||||||
|
expect(hb.aufrufe[0].audioNr).toBeUndefined()
|
||||||
|
expect(hb.aufrufe[0].audioSprachen).toEqual(['fra'])
|
||||||
|
expect(ergebnis.hinweise.join(' ')).toContain('keine der gewählten Tonspuren gefunden')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -160,4 +160,19 @@ describe('Schlüsselkette (§ 9.1)', () => {
|
|||||||
expect(extractGueltigBis('The key is valid until September 30th, 2026 or so')).toBe('September 30, 2026')
|
expect(extractGueltigBis('The key is valid until September 30th, 2026 or so')).toBe('September 30, 2026')
|
||||||
expect(extractGueltigBis('nichts')).toBe('')
|
expect(extractGueltigBis('nichts')).toBe('')
|
||||||
})
|
})
|
||||||
|
// 5.7.0 (Durchsicht 12.09.2026, F1): So steht es WIRKLICH auf der Forum-
|
||||||
|
// Seite — der September-Key trägt ein @, und das Datum heißt „end of".
|
||||||
|
// Das alte Muster endete am @ und lieferte 62 von 68 Zeichen.
|
||||||
|
it('der echte September-2026-Key mit @ kommt ganz an', () => {
|
||||||
|
const key = 'T-vwvnrmF87JuRPLYYO8HnIrZR_LCAF7JXs9iJEm10moGpjEgkvmPTsf6Ox4BI@N5rPp'
|
||||||
|
const html = `<div class="content">The current beta key is:<br><code>${key}</code><br>The key is valid until end of September 2026. Please check back for updated key on this page.</div>`
|
||||||
|
expect(extractKey(html)).toBe(key)
|
||||||
|
expect(extractKey(html)).toHaveLength(68)
|
||||||
|
expect(extractGueltigBis(html)).toBe('end of September 2026')
|
||||||
|
})
|
||||||
|
it('der Key endet am HTML-Zeichen, nicht mitten im Wort', () => {
|
||||||
|
const key = 'T-' + 'a'.repeat(66)
|
||||||
|
expect(extractKey(`<code>${key}</code><p>valid</p>`)).toBe(key)
|
||||||
|
expect(extractKey(`key: ${key} (valid until end of October 2026)`)).toBe(key)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import type { KernKontext } from '../src/kern/kontext'
|
|||||||
import type { Zuordnung } from '../src/kern/metadaten/zuordnung'
|
import type { Zuordnung } from '../src/kern/metadaten/zuordnung'
|
||||||
|
|
||||||
function titel(nr: number, gewaehlt: boolean, rolle: 'hauptfilm' | 'extra' | 'folge' = 'hauptfilm'): TitelStand['titel'][number] {
|
function titel(nr: number, gewaehlt: boolean, rolle: 'hauptfilm' | 'extra' | 'folge' = 'hauptfilm'): TitelStand['titel'][number] {
|
||||||
return { nr, dauerS: 3000 + nr, groesseBytes: 1e9 * (nr + 1), kapitel: 12, gewaehlt, rolle, grund: gewaehlt ? 'Hauptfilm' : 'zu kurz', sprachen: { audio: [], untertitel: [] } }
|
return { nr, dauerS: 3000 + nr, groesseBytes: 1e9 * (nr + 1), kapitel: 12, gewaehlt, rolle, grund: gewaehlt ? 'Hauptfilm' : 'zu kurz', sprachen: { audio: [], untertitel: [] }, spuren: { audio: [], untertitel: [] } }
|
||||||
}
|
}
|
||||||
|
|
||||||
function stand(teile: TitelStand['titel'], unsicher = false, fehler = ''): TitelStand {
|
function stand(teile: TitelStand['titel'], unsicher = false, fehler = ''): TitelStand {
|
||||||
@@ -144,16 +144,21 @@ describe('AutomatikDienst', () => {
|
|||||||
expect(a.nachrichten[0].text).toContain('schon gerippt (2026-08-30)')
|
expect(a.nachrichten[0].text).toContain('schon gerippt (2026-08-30)')
|
||||||
expect(a.gestartet).toHaveLength(0)
|
expect(a.gestartet).toHaveLength(0)
|
||||||
})
|
})
|
||||||
it('aus, CD, unbekannte Disc oder laufender Rip: gar nichts', async () => {
|
it('aus oder laufender Rip: gar nichts — CD und unbekannte Disc sagen, warum (5.7.0, F28)', async () => {
|
||||||
const a = aufbau()
|
const a = aufbau()
|
||||||
a.setAn(false)
|
a.setAn(false)
|
||||||
await a.dienst.pruefen(geraet(), zuordnung(0.99), null)
|
await a.dienst.pruefen(geraet(), zuordnung(0.99), null)
|
||||||
a.setAn(true)
|
|
||||||
await a.dienst.pruefen(geraet('cd'), zuordnung(0.99), null)
|
|
||||||
await a.dienst.pruefen(geraet('unknown'), zuordnung(0.99), null)
|
|
||||||
a.setRipLaeuft(true)
|
a.setRipLaeuft(true)
|
||||||
await a.dienst.pruefen(geraet(), zuordnung(0.99), null)
|
await a.dienst.pruefen(geraet(), zuordnung(0.99), null)
|
||||||
expect(a.nachrichten).toHaveLength(0)
|
expect(a.nachrichten).toHaveLength(0)
|
||||||
|
a.setRipLaeuft(false)
|
||||||
|
a.setAn(true)
|
||||||
|
await a.dienst.pruefen(geraet('cd'), zuordnung(0.99), null)
|
||||||
|
await a.dienst.pruefen(geraet('unknown'), zuordnung(0.99), null)
|
||||||
|
expect(a.nachrichten).toHaveLength(2)
|
||||||
|
expect(a.nachrichten[0].text).toContain('Audio-CD')
|
||||||
|
expect(a.nachrichten[1].text).toContain('unbekannt')
|
||||||
|
expect(a.gestartet).toHaveLength(0)
|
||||||
})
|
})
|
||||||
it('unsicherer Titel-Lauf (zwei fast gleich lange Titel): kein Countdown, die Einordnung wird gesagt', async () => {
|
it('unsicherer Titel-Lauf (zwei fast gleich lange Titel): kein Countdown, die Einordnung wird gesagt', async () => {
|
||||||
const a = aufbau()
|
const a = aufbau()
|
||||||
|
|||||||
@@ -136,10 +136,11 @@ describe('dateiPruefen', () => {
|
|||||||
describe('makemkvInstallerHolen', () => {
|
describe('makemkvInstallerHolen', () => {
|
||||||
const GROSS = Buffer.concat([Buffer.from('MZ'), Buffer.alloc(1024 * 1024 + 100, 7)])
|
const GROSS = Buffer.concat([Buffer.from('MZ'), Buffer.alloc(1024 * 1024 + 100, 7)])
|
||||||
|
|
||||||
it('Fehlerseite mit HTTP 200 fällt durch die Mindestgröße — die nächste Quelle gewinnt', async () => {
|
it('Fehlerseite mit HTTP 200 fällt durch die Mindestgröße — die nächste Quelle gewinnt (mit Freigabe)', async () => {
|
||||||
const ordner = tempOrdner()
|
const ordner = tempOrdner()
|
||||||
const holen = (async (url: string | URL | Request) =>
|
const holen = (async (url: string | URL | Request) =>
|
||||||
String(url).includes('web.archive.org') ? antwort(GROSS) : antwort('<html>kaputt</html>')) as typeof fetch
|
String(url).includes('web.archive.org') ? antwort(GROSS) : antwort('<html>kaputt</html>')) as typeof fetch
|
||||||
|
const gefragt: string[] = []
|
||||||
const ergebnis = await makemkvInstallerHolen(
|
const ergebnis = await makemkvInstallerHolen(
|
||||||
'1.18.4',
|
'1.18.4',
|
||||||
() => {},
|
() => {},
|
||||||
@@ -147,16 +148,35 @@ describe('makemkvInstallerHolen', () => {
|
|||||||
holen,
|
holen,
|
||||||
ordner,
|
ordner,
|
||||||
(p, v) => dateiPruefen(p, v, () => ({ firma: 'GuinpinSoft inc', produkt: 'MakeMKV', version: 'v1.18.4' }), 'win32'),
|
(p, v) => dateiPruefen(p, v, () => ({ firma: 'GuinpinSoft inc', produkt: 'MakeMKV', version: 'v1.18.4' }), 'win32'),
|
||||||
|
async (quelle) => {
|
||||||
|
gefragt.push(quelle)
|
||||||
|
return true
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
expect(gefragt).toEqual(['Internet Archive'])
|
||||||
expect(ergebnis.quelle).toBe('Internet Archive')
|
expect(ergebnis.quelle).toBe('Internet Archive')
|
||||||
expect(readFileSync(ergebnis.pfad).length).toBe(GROSS.length)
|
expect(readFileSync(ergebnis.pfad).length).toBe(GROSS.length)
|
||||||
|
expect(ergebnis.bytes).toBe(GROSS.length)
|
||||||
|
expect(ergebnis.sha256).toMatch(/^[0-9a-f]{64}$/)
|
||||||
expect(existsSync(`${ergebnis.pfad}.neu`)).toBe(false)
|
expect(existsSync(`${ergebnis.pfad}.neu`)).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 5.7.0 (Durchsicht F16): Ohne Freigabe wird das Archiv NIE benutzt — es
|
||||||
|
// liefert eine unsignierte EXE, die mit Adminrechten läuft.
|
||||||
|
it('ohne Freigabe bleibt das Archiv aus — und der Bericht sagt es', async () => {
|
||||||
|
const ordner = tempOrdner()
|
||||||
|
const holen = (async (url: string | URL | Request) =>
|
||||||
|
String(url).includes('web.archive.org') ? antwort(GROSS) : antwort('<html>kaputt</html>')) as typeof fetch
|
||||||
|
await expect(makemkvInstallerHolen('1.18.4', () => {}, '', holen, ordner)).rejects.toThrow(/Internet Archive: nicht freigegeben/)
|
||||||
|
await expect(makemkvInstallerHolen('1.18.4', () => {}, '', holen, ordner, undefined, async () => false)).rejects.toThrow(
|
||||||
|
/Internet Archive: nicht freigegeben/,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it('liefert keine Quelle, steht im Wurf der Bericht ALLER Versuche', async () => {
|
it('liefert keine Quelle, steht im Wurf der Bericht ALLER Versuche', async () => {
|
||||||
const ordner = tempOrdner()
|
const ordner = tempOrdner()
|
||||||
const holen = (async () => antwort('weg', 404)) as typeof fetch
|
const holen = (async () => antwort('weg', 404)) as typeof fetch
|
||||||
await expect(makemkvInstallerHolen('1.18.4', () => {}, '', holen, ordner)).rejects.toThrow(
|
await expect(makemkvInstallerHolen('1.18.4', () => {}, '', holen, ordner, undefined, async () => true)).rejects.toThrow(
|
||||||
/Hersteller: HTTP 404[\s\S]*Internet Archive: HTTP 404[\s\S]*makemkv\.com/,
|
/Hersteller: HTTP 404[\s\S]*Internet Archive: HTTP 404[\s\S]*makemkv\.com/,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -166,6 +186,8 @@ describe('Nachrichten-Schema der Update-Strecke', () => {
|
|||||||
it('Fenster → Kern: makemkv-update und schluessel-pruefen', () => {
|
it('Fenster → Kern: makemkv-update und schluessel-pruefen', () => {
|
||||||
expect(istFensterNachricht({ art: 'makemkv-update' })).toBe(true)
|
expect(istFensterNachricht({ art: 'makemkv-update' })).toBe(true)
|
||||||
expect(istFensterNachricht({ art: 'schluessel-pruefen' })).toBe(true)
|
expect(istFensterNachricht({ art: 'schluessel-pruefen' })).toBe(true)
|
||||||
|
expect(istFensterNachricht({ art: 'beschaffung-antwort', ja: true })).toBe(true)
|
||||||
|
expect(istFensterNachricht({ art: 'beschaffung-antwort' })).toBe(false)
|
||||||
})
|
})
|
||||||
it('Haupt → Kern: installer-ergebnis', () => {
|
it('Haupt → Kern: installer-ergebnis', () => {
|
||||||
expect(istHauptNachricht({ art: 'installer-ergebnis', fehler: null })).toBe(true)
|
expect(istHauptNachricht({ art: 'installer-ergebnis', fehler: null })).toBe(true)
|
||||||
|
|||||||
@@ -28,12 +28,46 @@ describe('Fund 1 — „Key korrekt, aber als ungültig angezeigt"', () => {
|
|||||||
'MSG:1005,0,1,"MakeMKV v1.18.4 win(x64-release) gestartet","x"\nMSG:5010,0,0,"Das Öffnen der Disk schlug fehl","x"',
|
'MSG:1005,0,1,"MakeMKV v1.18.4 win(x64-release) gestartet","x"\nMSG:5010,0,0,"Das Öffnen der Disk schlug fehl","x"',
|
||||||
)
|
)
|
||||||
expect(urteil.ok).toBe(true)
|
expect(urteil.ok).toBe(true)
|
||||||
|
expect(urteil.pruefbar).toBe(true)
|
||||||
expect(urteil.version).toBe('1.18.4')
|
expect(urteil.version).toBe('1.18.4')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('5020 allein bleibt „Schlüssel wird nicht angenommen"', () => {
|
it('5020 allein bleibt „Schlüssel wird nicht angenommen"', () => {
|
||||||
const urteil = urteilAusAusgabe('MSG:5020,516,0,"…","…"')
|
const urteil = urteilAusAusgabe('MSG:5020,516,0,"…","…"')
|
||||||
expect(urteil.code).toBe(5020)
|
expect(urteil.code).toBe(5020)
|
||||||
|
expect(urteil.pruefbar).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 5.7.0 (Durchsicht 12.09.2026, F5): OHNE angeschlossenes Laufwerk sagt
|
||||||
|
// makemkvcon zur Lizenz gar nichts — gemessen an diesem PC: nur 5042 und
|
||||||
|
// 5010. Das ist kein „angenommen", sondern „nicht angesehen".
|
||||||
|
it('5042 (kein Laufwerk) ohne Lizenzzeile heißt: nicht prüfbar', () => {
|
||||||
|
const urteil = urteilAusAusgabe(
|
||||||
|
[
|
||||||
|
'MSG:1005,0,1,"MakeMKV v1.18.4 win(x64-release) gestartet","%1 gestartet","MakeMKV v1.18.4 win(x64-release)"',
|
||||||
|
'MSG:5042,0,0,"Das Programm konnte keine verwendbaren optischen Laufwerke finden.","…"',
|
||||||
|
'MSG:5010,0,0,"Das Öffnen der Disk schlug fehl ","…"',
|
||||||
|
].join('\n'),
|
||||||
|
)
|
||||||
|
expect(urteil.pruefbar).toBe(false)
|
||||||
|
expect(urteil.code).toBe(5042)
|
||||||
|
expect(urteil.version).toBe('1.18.4')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('5051 (Testzeitraum abgelaufen) ist prüfbar und abgelehnt — auch neben 5042', () => {
|
||||||
|
const urteil = urteilAusAusgabe('MSG:5042,0,0,"x","x"\nMSG:5054,0,1,"Evaluierungsversion. Der Testzeitraum lief vor 10 Tagen ab.","x","10"\nMSG:5051,1028,0,"Der Testzeitraum ist abgelaufen.","x"')
|
||||||
|
expect(urteil.ok).toBe(false)
|
||||||
|
expect(urteil.pruefbar).toBe(true)
|
||||||
|
expect(urteil.code).toBe(5051)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Schlüssel aus dem Register lesen (5.7.0)', () => {
|
||||||
|
it('keyAusQuery liest den Wert aus reg query', async () => {
|
||||||
|
const { keyAusQuery } = await import('../src/kern/werkzeuge/schluessel')
|
||||||
|
const ausgabe = '\r\nHKEY_CURRENT_USER\\Software\\MakeMKV\r\n app_Key REG_SZ T-vwvnrmF87JuRPLYYO8HnIrZR_LCAF7JXs9iJEm10moGpjEgkvmPTsf6Ox4BI@N5rPp\r\n\r\n'
|
||||||
|
expect(keyAusQuery(ausgabe)).toBe('T-vwvnrmF87JuRPLYYO8HnIrZR_LCAF7JXs9iJEm10moGpjEgkvmPTsf6Ox4BI@N5rPp')
|
||||||
|
expect(keyAusQuery('FEHLER: Der Registrierungsschlüssel oder -wert wurde nicht gefunden.')).toBe('')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -40,3 +40,17 @@ describe('rollenNummern', () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 5.7.0 (Durchsicht F3): nie überschreiben — ein freier Name, wo einer
|
||||||
|
// nötig ist.
|
||||||
|
describe('freierName', () => {
|
||||||
|
it('frei bleibt frei, belegt bekommt (2), (3) …', async () => {
|
||||||
|
const { freierName } = await import('../src/kern/ablage/dateinamen')
|
||||||
|
const belegt = new Set(['E:\\S\\x.mkv', 'E:\\S\\x (2).mkv'])
|
||||||
|
expect(freierName('E:\\S\\y.mkv', (p) => belegt.has(p))).toBe('E:\\S\\y.mkv')
|
||||||
|
expect(freierName('E:\\S\\x.mkv', (p) => belegt.has(p))).toBe('E:\\S\\x (3).mkv')
|
||||||
|
expect(freierName('/a/b/x.mkv', () => true).startsWith('/a/b/x (')).toBe(true)
|
||||||
|
// Ein Punkt im Ordnernamen ist keine Endung.
|
||||||
|
expect(freierName('E:\\Akira (1988)\\ohne-endung', (p) => p === 'E:\\Akira (1988)\\ohne-endung')).toBe('E:\\Akira (1988)\\ohne-endung (2)')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// 5.7.0 (Durchsicht F10): Der Disc-Typ aus dem Dateisystem — BDMV/index.bdmv
|
||||||
|
// mit „INDX0300" ist UHD (am Akira-UHD-Abbild gemessen), „0200"/„0100" eine
|
||||||
|
// Blu-ray, VIDEO_TS eine DVD. Der Windows-Steuercode antwortet bei manchen
|
||||||
|
// USB-Brücken nicht, und die Größe allein machte aus einer 4K-Disc mit
|
||||||
|
// 48 GB Daten „bluray".
|
||||||
|
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 { typAusIndexKopf } from '../src/kern/metadaten/discmerkmale'
|
||||||
|
|
||||||
|
let wegwerf: string[] = []
|
||||||
|
afterEach(() => {
|
||||||
|
for (const o of wegwerf.splice(0)) rmSync(o, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('typAusIndexKopf', () => {
|
||||||
|
it('INDX0300 ist UHD, INDX0200/0100 Blu-ray, alles andere nichts', () => {
|
||||||
|
expect(typAusIndexKopf(Buffer.from('INDX0300\0\0\0N', 'latin1'))).toBe('uhd')
|
||||||
|
expect(typAusIndexKopf(Buffer.from('INDX0200xxxx', 'latin1'))).toBe('bluray')
|
||||||
|
expect(typAusIndexKopf(Buffer.from('INDX0100xxxx', 'latin1'))).toBe('bluray')
|
||||||
|
expect(typAusIndexKopf(Buffer.from('MOBJ0200', 'latin1'))).toBe(null)
|
||||||
|
expect(typAusIndexKopf(Buffer.from('IND', 'latin1'))).toBe(null)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('typAusDateisystem an einem nachgebauten Laufzeit-Ordner', () => {
|
||||||
|
// discWurzel() rechnet aus \\.\G: den Buchstaben — das geht nur unter
|
||||||
|
// Windows an einem echten Laufwerk. Hier wird deshalb die Kopf-Funktion
|
||||||
|
// über eine echte Datei geprüft, so wie typAusDateisystem sie liest.
|
||||||
|
it('liest die ersten acht Bytes einer echten index.bdmv', () => {
|
||||||
|
const wurzel = mkdtempSync(join(tmpdir(), 'rippy-bdmv-'))
|
||||||
|
wegwerf.push(wurzel)
|
||||||
|
mkdirSync(join(wurzel, 'BDMV'), { recursive: true })
|
||||||
|
const index = join(wurzel, 'BDMV', 'index.bdmv')
|
||||||
|
writeFileSync(index, Buffer.concat([Buffer.from('INDX0300', 'latin1'), Buffer.alloc(64)]))
|
||||||
|
const { openSync, readSync, closeSync } = require('node:fs') as typeof import('node:fs')
|
||||||
|
const fd = openSync(index, 'r')
|
||||||
|
const kopf = Buffer.alloc(8)
|
||||||
|
readSync(fd, kopf, 0, 8, 0)
|
||||||
|
closeSync(fd)
|
||||||
|
expect(typAusIndexKopf(kopf)).toBe('uhd')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// 5.7.0 (Durchsicht 12.09.2026, F11): Die Laufwerks-Gesundheit zählt nur die
|
||||||
|
// Ereignisse DIESES Laufwerks. Vorher nahm der XPath Provider cdrom UND
|
||||||
|
// disk für ALLE Geräte — eine hustende Festplatte (\Device\Harddisk1)
|
||||||
|
// erzeugte „VERDACHT: DIE HARDWARE" in der Kachel des Blu-ray-Laufwerks.
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { beurteilen, gehoertZuLaufwerk, type LaufwerkEreignis } from '../src/kern/laufwerk/ereignisse'
|
||||||
|
|
||||||
|
const e = (provider: string, id: number, geraet: string): LaufwerkEreignis => ({
|
||||||
|
zeit: '2026-09-02T18:32:00.000Z',
|
||||||
|
provider,
|
||||||
|
id,
|
||||||
|
geraet,
|
||||||
|
})
|
||||||
|
|
||||||
|
const PLATTE = e('disk', 11, '\\Device\\Harddisk1\\DR1')
|
||||||
|
const PLATTE_BLOCK = e('disk', 7, '\\Device\\Harddisk1\\DR1')
|
||||||
|
const CDROM0 = e('cdrom', 7, '\\Device\\CdRom0')
|
||||||
|
const CDROM1 = e('cdrom', 11, '\\Device\\CdRom1')
|
||||||
|
|
||||||
|
describe('gehoertZuLaufwerk', () => {
|
||||||
|
it('mit Gerätename zählt nur dieses Gerät', () => {
|
||||||
|
expect(gehoertZuLaufwerk(CDROM0, '\\Device\\CdRom0')).toBe(true)
|
||||||
|
expect(gehoertZuLaufwerk(CDROM1, '\\Device\\CdRom0')).toBe(false)
|
||||||
|
expect(gehoertZuLaufwerk(PLATTE, '\\Device\\CdRom0')).toBe(false)
|
||||||
|
// Groß/Klein ist Windows egal.
|
||||||
|
expect(gehoertZuLaufwerk(CDROM0, '\\device\\cdrom0')).toBe(true)
|
||||||
|
})
|
||||||
|
it('ohne Gerätename zählt nur der cdrom-Treiber — nie eine Festplatte', () => {
|
||||||
|
expect(gehoertZuLaufwerk(CDROM0, '')).toBe(true)
|
||||||
|
expect(gehoertZuLaufwerk(CDROM1, '')).toBe(true)
|
||||||
|
expect(gehoertZuLaufwerk(PLATTE, '')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('beurteilen je Gerät', () => {
|
||||||
|
it('Controllerfehler der Festplatte beschuldigen nicht das Laufwerk', () => {
|
||||||
|
const bericht = beurteilen([PLATTE, PLATTE_BLOCK, CDROM0], 24, undefined, '\\Device\\CdRom0')
|
||||||
|
expect(bericht.urteil).toBe('disc')
|
||||||
|
expect(bericht.controllerfehler).toBe(0)
|
||||||
|
expect(bericht.fehlerhafteBloecke).toBe(1)
|
||||||
|
expect(bericht.letzte).toHaveLength(1)
|
||||||
|
})
|
||||||
|
it('ein zweites optisches Laufwerk zählt nicht mit', () => {
|
||||||
|
const bericht = beurteilen([CDROM1, CDROM0], 24, undefined, '\\Device\\CdRom0')
|
||||||
|
expect(bericht.urteil).toBe('disc')
|
||||||
|
expect(bericht.controllerfehler).toBe(0)
|
||||||
|
})
|
||||||
|
it('nur Festplatten-Ereignisse → für das Laufwerk ist es ruhig, mit Gerätename im Text', () => {
|
||||||
|
const bericht = beurteilen([PLATTE, PLATTE_BLOCK], 24, undefined, '\\Device\\CdRom0')
|
||||||
|
expect(bericht.urteil).toBe('ruhig')
|
||||||
|
expect(bericht.text).toContain('\\Device\\CdRom0')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -171,11 +171,14 @@ describe('presets', () => {
|
|||||||
expect(komprimierenFuer('dvd', { transcodePresetUhd: PRESET_KEINE })).toBe(true)
|
expect(komprimierenFuer('dvd', { transcodePresetUhd: PRESET_KEINE })).toBe(true)
|
||||||
expect(komprimierenFuer('bluray', {})).toBe(true)
|
expect(komprimierenFuer('bluray', {})).toBe(true)
|
||||||
})
|
})
|
||||||
it('presetFuer: Typ-Preset → allgemein → Default, nie „keine"', () => {
|
it('presetFuer: Typ-Preset → Empfehlung → Default, nie „keine" (5.7.0: kein allgemeines Preset mehr, A5)', () => {
|
||||||
expect(presetFuer('dvd', { transcodePresetDvd: 'Fast 480p' })).toBe('Fast 480p')
|
expect(presetFuer('dvd', { transcodePresetDvd: 'Fast 480p' })).toBe('Fast 480p')
|
||||||
expect(presetFuer('dvd', { transcodePreset: 'Allgemein' })).toBe('Allgemein')
|
// Das alte „allgemeine Preset" ist seit 5.5.0 nicht mehr Teil der
|
||||||
|
// Pipeline-Einstellungen — es darf nichts mehr übersteuern.
|
||||||
|
expect(presetFuer('dvd', { transcodePreset: 'Allgemein' })).toBe(DEFAULT_HB_PRESET)
|
||||||
expect(presetFuer('dvd', {})).toBe(DEFAULT_HB_PRESET)
|
expect(presetFuer('dvd', {})).toBe(DEFAULT_HB_PRESET)
|
||||||
expect(presetFuer('uhd', { transcodePresetUhd: PRESET_KEINE, transcodePreset: 'A' })).toBe('A')
|
expect(presetFuer('uhd', { transcodePresetUhd: PRESET_KEINE }, 'H.265 VCN 2160p 4K')).toBe('H.265 VCN 2160p 4K')
|
||||||
|
expect(presetFuer('uhd', {}, PRESET_KEINE)).toBe(DEFAULT_HB_PRESET)
|
||||||
})
|
})
|
||||||
it('sprachliste säubert, dedupliziert, erzwingt Kleinschreibung', () => {
|
it('sprachliste säubert, dedupliziert, erzwingt Kleinschreibung', () => {
|
||||||
expect(sprachliste('deu, eng , ,DEU')).toEqual(['deu', 'eng'])
|
expect(sprachliste('deu, eng , ,DEU')).toEqual(['deu', 'eng'])
|
||||||
@@ -220,3 +223,22 @@ describe('etaAusZeile — Restzeit aus der Encoding-Zeile (5.6.0, Format aus dem
|
|||||||
expect(a.verarbeite('Encoding: task 1 of 1, 11.00 %')).toEqual({ prozent: 11 })
|
expect(a.verarbeite('Encoding: task 1 of 1, 11.00 %')).toEqual({ prozent: 11 })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 5.7.0 (Durchsicht F9): HandBrake schreibt auf einen Zwischennamen; eine
|
||||||
|
// halbe Datei trägt nie den echten Namen.
|
||||||
|
describe('Zwischenname (5.7.0)', () => {
|
||||||
|
it('zwischenname und endname gehören zusammen', async () => {
|
||||||
|
const { zwischenname, endname } = await import('../src/kern/komprimieren/handbrake')
|
||||||
|
expect(zwischenname('E:\\Filme\\Akira (1988)\\Akira (1988).mkv')).toBe('E:\\Filme\\Akira (1988)\\Akira (1988).teil.mkv')
|
||||||
|
expect(endname('E:\\Filme\\Akira (1988)\\Akira (1988).teil.mkv')).toBe('E:\\Filme\\Akira (1988)\\Akira (1988).mkv')
|
||||||
|
expect(endname('E:\\x\\title_t00.teil.mp4')).toBe('E:\\x\\title_t00.mp4')
|
||||||
|
expect(zwischenname('ohne-endung')).toBe('ohne-endung.teil')
|
||||||
|
})
|
||||||
|
it('zwischenReste räumt die Zwischen-Datei und ihre Datei daneben', async () => {
|
||||||
|
const { zwischenReste } = await import('../src/kern/komprimieren/handbrake')
|
||||||
|
const da = new Set(['E:\\x\\a.teil.mkv', 'E:\\x\\a.teil.mp4'])
|
||||||
|
const weg: string[] = []
|
||||||
|
expect(zwischenReste('E:\\x\\a.teil.mkv', (p) => weg.push(p), (p) => da.has(p))).toEqual(['E:\\x\\a.teil.mkv', 'E:\\x\\a.teil.mp4'])
|
||||||
|
expect(zwischenReste('E:\\x\\b.teil.mkv', (p) => weg.push(p), (p) => da.has(p))).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ function fakeApi(verhalten: FakeVerhalten): LaufwerkApi & { protokoll: string[]
|
|||||||
protokoll,
|
protokoll,
|
||||||
laufwerksbuchstaben: () => verhalten.buchstaben ?? ['C', 'G'],
|
laufwerksbuchstaben: () => verhalten.buchstaben ?? ['C', 'G'],
|
||||||
laufwerkstyp: (b) => verhalten.typen?.[b] ?? (b === 'G' ? DRIVE_CDROM : 3),
|
laufwerkstyp: (b) => verhalten.typen?.[b] ?? (b === 'G' ? DRIVE_CDROM : 3),
|
||||||
|
geraeteName: (b) => (b === 'G' ? 'GERAET-CDROM0' : ''),
|
||||||
oeffnen(pfad, zugriff) {
|
oeffnen(pfad, zugriff) {
|
||||||
protokoll.push(`oeffnen ${pfad} zugriff=${zugriff}`)
|
protokoll.push(`oeffnen ${pfad} zugriff=${zugriff}`)
|
||||||
if (verhalten.oeffnenFehler !== undefined) throw new Win32Fehler('CreateFileW', verhalten.oeffnenFehler)
|
if (verhalten.oeffnenFehler !== undefined) throw new Win32Fehler('CreateFileW', verhalten.oeffnenFehler)
|
||||||
|
|||||||
@@ -29,3 +29,72 @@ describe.skipIf(!messen)('Spuren einer echten Roh-Datei', () => {
|
|||||||
expect(spuren!.audio.length).toBeGreaterThan(0)
|
expect(spuren!.audio.length).toBeGreaterThan(0)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── 5.7.0: Spur-Schalter am mitgelieferten HandBrakeCLI ────────────────────
|
||||||
|
// Baut sich die Quelle selbst (ffmpeg muss im PATH sein): jpn 5.1 / deu 2.0 /
|
||||||
|
// deu 2.0 „Kommentar" als AC3, deu/eng SRT — und prüft die Ausgabe wieder
|
||||||
|
// über HandBrakes eigenen Scan. Anlass: `--audio 1,3`, `--subtitle 2,1`,
|
||||||
|
// `--subtitle-default=1` und die Kopiermaske (F31: ohne `--audio-copy-mask`
|
||||||
|
// wurde AC3 zu AAC). Läuft nur mit RIPPY_MESSUNG=1 unter Windows.
|
||||||
|
//
|
||||||
|
// RIPPY_MESSUNG=1 npx vitest run test/messung.spuren.test.ts --reporter=verbose
|
||||||
|
import { spawnSync } from 'node:child_process'
|
||||||
|
import { mkdtempSync, writeFileSync } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
|
||||||
|
const spurMessung = process.env.RIPPY_MESSUNG === '1' && process.platform === 'win32'
|
||||||
|
|
||||||
|
describe.skipIf(!spurMessung)('Spur-Schalter am echten HandBrakeCLI (5.7.0)', () => {
|
||||||
|
it('--audio 1,3 --subtitle 2,1 --subtitle-default=1 mit Kopiermaske: AC3 bleibt AC3, eng vor deu', { timeout: 10 * 60_000 }, async () => {
|
||||||
|
const ffmpeg = spawnSync('ffmpeg', ['-version'], { encoding: 'utf8', windowsHide: true })
|
||||||
|
if (ffmpeg.status !== 0) {
|
||||||
|
console.log('MESSUNG übersprungen: kein ffmpeg im PATH')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const { finden } = await import('../src/kern/werkzeuge/katalog')
|
||||||
|
const { buildHandbrakeArgs } = await import('../src/kern/komprimieren/handbrake')
|
||||||
|
const { spurenScannen } = await import('../src/kern/komprimieren/scan')
|
||||||
|
const handbrake = finden('handbrake', '')
|
||||||
|
expect(handbrake.length, 'HandBrakeCLI nicht gefunden').toBeGreaterThan(0)
|
||||||
|
const ordner = mkdtempSync(join(tmpdir(), 'rippy-spuren-'))
|
||||||
|
const deu = join(ordner, 'deu.srt')
|
||||||
|
const eng = join(ordner, 'eng.srt')
|
||||||
|
writeFileSync(deu, '1\n00:00:00,500 --> 00:00:02,000\nHallo\n\n')
|
||||||
|
writeFileSync(eng, '1\n00:00:00,500 --> 00:00:02,000\nHello\n\n')
|
||||||
|
const quelle = join(ordner, 'quelle.mkv')
|
||||||
|
const bau = spawnSync(
|
||||||
|
'ffmpeg',
|
||||||
|
[
|
||||||
|
'-y', '-loglevel', 'error',
|
||||||
|
'-f', 'lavfi', '-i', 'testsrc=size=320x240:rate=25',
|
||||||
|
'-f', 'lavfi', '-i', 'sine=frequency=440:sample_rate=48000',
|
||||||
|
'-f', 'lavfi', '-i', 'sine=frequency=660:sample_rate=48000',
|
||||||
|
'-f', 'lavfi', '-i', 'sine=frequency=880:sample_rate=48000',
|
||||||
|
'-i', deu, '-i', eng, '-t', '3',
|
||||||
|
'-map', '0:v', '-map', '1:a', '-map', '2:a', '-map', '3:a', '-map', '4:s', '-map', '5:s',
|
||||||
|
'-c:v', 'libx264', '-preset', 'ultrafast', '-c:a', 'ac3', '-ac:a:0', '6', '-ac:a:1', '2', '-ac:a:2', '2', '-c:s', 'srt',
|
||||||
|
'-metadata:s:a:0', 'language=jpn', '-metadata:s:a:1', 'language=deu', '-metadata:s:a:2', 'language=deu', '-metadata:s:a:2', 'title=Kommentar',
|
||||||
|
'-metadata:s:s:0', 'language=deu', '-metadata:s:s:1', 'language=eng',
|
||||||
|
quelle,
|
||||||
|
],
|
||||||
|
{ encoding: 'utf8', windowsHide: true, timeout: 120_000 },
|
||||||
|
)
|
||||||
|
expect(bau.status, `ffmpeg: ${bau.stderr}`).toBe(0)
|
||||||
|
const ausgabe = join(ordner, 'ausgabe.mkv')
|
||||||
|
const args = buildHandbrakeArgs({ eingabe: quelle, ausgabe, preset: 'H.265 MKV 1080p30', audioNr: [1, 3], untertitelNr: [2, 1], untertitelStandardNr: 1 })
|
||||||
|
const lauf = spawnSync(handbrake, args, { encoding: 'utf8', windowsHide: true, timeout: 8 * 60_000 })
|
||||||
|
expect(lauf.status, `HandBrake: ${(lauf.stderr ?? '').slice(-600)}`).toBe(0)
|
||||||
|
const passthru = /Auto Passthru: allowed codecs are ([^\n]*)/.exec(lauf.stderr ?? '')
|
||||||
|
console.log(`MESSUNG Passthru: ${passthru?.[1] ?? '(Zeile fehlt)'}`)
|
||||||
|
const spuren = await spurenScannen(handbrake, ausgabe)
|
||||||
|
expect(spuren).not.toBeNull()
|
||||||
|
for (const a of spuren!.audio) console.log(`MESSUNG Ton ${a.nr}: ${a.lang} ${a.codec} ${a.kanaele}ch — ${a.beschreibung}`)
|
||||||
|
for (const u of spuren!.untertitel) console.log(`MESSUNG Untertitel ${u.nr}: ${u.lang} ${u.codec} — ${u.beschreibung}`)
|
||||||
|
expect(spuren!.audio.map((a) => [a.lang, a.codec, a.kanaele])).toEqual([
|
||||||
|
['jpn', 'ac3', 6],
|
||||||
|
['deu', 'ac3', 2],
|
||||||
|
])
|
||||||
|
expect(spuren!.untertitel.map((u) => u.lang)).toEqual(['eng', 'deu'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// 5.7.0 (Durchsicht 12.09.2026, F14): Der wörtliche Vergleich kennt jetzt
|
||||||
|
// Akzente und den Originaltitel. Die drei Filme des Commanders (Der
|
||||||
|
// Patriot, Déjà Vu, Training Day) tragen englische Labels, TMDb antwortet
|
||||||
|
// auf Deutsch — vorher ein Vorschlag mit 60 %, die Automatik wartete.
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { gleichBedeutend, ohneAkzente, wortUeberlappung, zuordnen } from '../src/kern/metadaten/zuordnung'
|
||||||
|
|
||||||
|
const keinOmdb = { verfuegbar: false, lookup: async () => null }
|
||||||
|
|
||||||
|
function tmdb(filme: Array<{ id: number; title: string; original_title?: string }>) {
|
||||||
|
return {
|
||||||
|
verfuegbar: true,
|
||||||
|
searchMovie: async () => filme,
|
||||||
|
searchTv: async () => [],
|
||||||
|
movieDetails: async (id: number) => {
|
||||||
|
const f = filme.find((x) => x.id === id)
|
||||||
|
return f === undefined ? null : { id, title: f.title, release_date: '2000-06-28', overview: 'Ü', vote_count: 100 }
|
||||||
|
},
|
||||||
|
tvDetails: async () => null,
|
||||||
|
findByImdb: async () => null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ohneAkzente und gleichBedeutend', () => {
|
||||||
|
it('Déjà Vu ist Deja Vu', () => {
|
||||||
|
expect(ohneAkzente('Déjà Vu')).toBe('Deja Vu')
|
||||||
|
expect(gleichBedeutend('Déjà Vu', 'Deja Vu')).toBe(true)
|
||||||
|
expect(gleichBedeutend('Amélie', 'AMELIE')).toBe(true)
|
||||||
|
expect(wortUeberlappung('Déjà Vu', 'Deja Vu')).toBe(1)
|
||||||
|
})
|
||||||
|
it('Umlaute bleiben unterscheidbar genug (ä → a, ß → ss)', () => {
|
||||||
|
expect(gleichBedeutend('Der Baader Meinhof Komplex', 'Der Baader Meinhof Komplex')).toBe(true)
|
||||||
|
expect(gleichBedeutend('Das Boot', 'Das Bot')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('zuordnen mit Originaltitel', () => {
|
||||||
|
it('Label „The Patriot" trifft „Der Patriot" (original_title) wörtlich — 0,95', async () => {
|
||||||
|
const z = await zuordnen('The Patriot', tmdb([{ id: 8489, title: 'Der Patriot', original_title: 'The Patriot' }]), keinOmdb)
|
||||||
|
expect(z.confidence).toBe(0.95)
|
||||||
|
expect(z.titel).toBe('Der Patriot')
|
||||||
|
})
|
||||||
|
it('Label „Deja Vu" trifft „Déjà Vu – Wettlauf gegen die Zeit" über den Originaltitel', async () => {
|
||||||
|
const z = await zuordnen(
|
||||||
|
'Deja Vu',
|
||||||
|
tmdb([
|
||||||
|
{ id: 7551, title: 'Déjà Vu – Wettlauf gegen die Zeit', original_title: 'Deja Vu' },
|
||||||
|
{ id: 1, title: 'Déjà Vu', original_title: 'Déjà Vu' },
|
||||||
|
]),
|
||||||
|
keinOmdb,
|
||||||
|
)
|
||||||
|
expect(z.confidence).toBe(0.95)
|
||||||
|
expect(z.titel).toBe('Déjà Vu – Wettlauf gegen die Zeit')
|
||||||
|
})
|
||||||
|
it('ohne Originaltitel und ohne wörtlichen Treffer bleibt es ein Vorschlag', async () => {
|
||||||
|
const z = await zuordnen('The Patriot', tmdb([{ id: 1, title: 'Der Patriot' }, { id: 2, title: 'Patriot Games' }, { id: 3, title: 'Der Patriot 2' }, { id: 4, title: 'Patrioten' }]), keinOmdb)
|
||||||
|
expect(z.confidence).toBe(0.6)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -259,3 +259,73 @@ describe('Pipeline — die Rip-Phase (5.4.0)', () => {
|
|||||||
expect(readdirSync(ordner)).toEqual(['alt.mkv'])
|
expect(readdirSync(ordner)).toEqual(['alt.mkv'])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── 5.7.0: Funde der Durchsicht vom 12.09.2026 ─────────────────────────
|
||||||
|
describe('Pipeline — Durchsicht 5.7.0', () => {
|
||||||
|
// F2: Vorher warf mkdirSync aus starten() heraus, der Aufrufer schluckte
|
||||||
|
// die Ablehnung — kein Fehler im Fenster, keine Zeile im Protokoll
|
||||||
|
// (Sonde 10). Jetzt: eine Fehler-Meldung, keine Ausnahme.
|
||||||
|
it('F2: Ablage nicht erreichbar → Fehler-Meldung statt stiller Ausnahme', async () => {
|
||||||
|
// Ein Laufwerksbuchstabe, den es nicht gibt — wie eine NAS, die gerade
|
||||||
|
// weg ist. (Unter Linux ein Ort, an dem niemand anlegen darf.)
|
||||||
|
const weg = process.platform === 'win32' ? 'Q:\\rippy-nicht-da' : '/proc/rippy-nicht-da'
|
||||||
|
const { pipeline, meldungen, endet } = umgebung({ ablage: () => weg })
|
||||||
|
await pipeline.starten('G', AUFTRAG)
|
||||||
|
expect(meldungen).toHaveLength(1)
|
||||||
|
expect(meldungen[0].phase).toBe('fehler')
|
||||||
|
expect(meldungen[0].text).toContain('nicht erreichbar')
|
||||||
|
expect(meldungen[0].text).toContain(weg)
|
||||||
|
expect(endet).toHaveLength(0) // kein Vorgang angelegt, also keiner beendet
|
||||||
|
expect(pipeline.laeuft('G')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F2: eine Ausnahme mitten im Lauf wird zur Fehler-Meldung des Laufwerks', async () => {
|
||||||
|
plan.schritte = [{ status: 'success', datei: 'title_t00.mkv' }]
|
||||||
|
const { pipeline, meldungen } = umgebung({
|
||||||
|
vorgangBeginnt: () => {
|
||||||
|
throw new Error('Datenbank gesperrt')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await expect(pipeline.starten('G', AUFTRAG)).resolves.toBeUndefined()
|
||||||
|
expect(meldungen.at(-1)!.phase).toBe('fehler')
|
||||||
|
expect(meldungen.at(-1)!.text).toContain('Datenbank gesperrt')
|
||||||
|
expect(pipeline.laeuft('G')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
// F10: Ein Titel-Auftrag auf einer unbekannt gemeldeten Disc ist ein
|
||||||
|
// Video-Rip, kein ISO — makemkvcon hat die Titel ja gerade geliefert.
|
||||||
|
it('F10: Typ unbekannt MIT Titel-Auftrag rippt mit MakeMKV (Typ nach Größe)', async () => {
|
||||||
|
plan.schritte = [{ status: 'success', datei: 'title_t00.mkv' }]
|
||||||
|
const { pipeline, meldungen, eingereiht } = umgebung({ geraet: () => ({ ...GERAET, typ: 'unknown' }) })
|
||||||
|
await pipeline.starten('G', [AUFTRAG[0]])
|
||||||
|
expect(meldungen.some((m) => m.text.includes('1:1-Abbild'))).toBe(false)
|
||||||
|
expect(eingereiht).toHaveLength(1)
|
||||||
|
expect(eingereiht[0].discTyp).toBe('bluray')
|
||||||
|
})
|
||||||
|
|
||||||
|
// F8: Der Roh-Ordner ohne brauchbare Datei wird geräumt — das Rettungs-
|
||||||
|
// Abbild aber nie: dafür wurde die Disc stundenlang gelesen.
|
||||||
|
it('F8: rettung.iso überlebt einen leer ausgegangenen Rip aus dem Abbild', async () => {
|
||||||
|
plan.schritte = [{ status: 'error', fehler: 'MakeMKV endete mit Code 1' }, { status: 'error', fehler: 'MakeMKV endete mit Code 1' }]
|
||||||
|
const { pipeline, ablage, meldungen } = umgebung({
|
||||||
|
rettungsAbbild: async (_geraet, ziel) => {
|
||||||
|
writeFileSync(ziel, 'ABBILD')
|
||||||
|
writeFileSync(`${ziel}.rettung.txt`, 'Bericht')
|
||||||
|
return { status: 'success', bytes: 6, unlesbareSektoren: 1, stellen: [{ offset: 0, bytes: 2048 }] }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
// Erst ein normaler Rip, der Lesefehler sieht — nur dann gibt es die Rettung.
|
||||||
|
plan.schritte = [{ status: 'error', fehler: 'Lesefehler', lesefehler: true }]
|
||||||
|
await pipeline.starten('G', [AUFTRAG[0]])
|
||||||
|
expect(pipeline.rettungGrund('G')).not.toBe('')
|
||||||
|
plan.schritte = [{ status: 'error', fehler: 'MakeMKV endete mit Code 1' }]
|
||||||
|
await pipeline.rettungStarten('G')
|
||||||
|
const roh = join(ablage, 'roh')
|
||||||
|
const ordner = readdirSync(roh)
|
||||||
|
expect(ordner.length).toBeGreaterThan(0)
|
||||||
|
const inhalt = ordner.flatMap((o) => readdirSync(join(roh, o)))
|
||||||
|
expect(inhalt).toContain('rettung.iso')
|
||||||
|
expect(inhalt).toContain('rettung.iso.rettung.txt')
|
||||||
|
expect(meldungen.at(-1)!.text).toContain('Rettungs-Abbild bleibt')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -0,0 +1,261 @@
|
|||||||
|
// 5.7.0 — Spuren einzeln (Commander-Entscheid 12.09.2026: „Jede Spur einzeln
|
||||||
|
// anhaken"). Pur und ohne Laufwerk: der Parser liefert je Titel jede Spur mit
|
||||||
|
// Codec, Kanälen und dem Abgeleitet-Flag; der Dialog belegt vor und
|
||||||
|
// beschriftet; die Ablage findet die gewählten MakeMKV-Spuren in HandBrakes
|
||||||
|
// Scan der Roh-Datei wieder und baut daraus `--audio 1,3 --subtitle 2`.
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { istRipAuftragTitel, istSpurAngabe, type SpurAngabe } from '../src/gemeinsam/nachrichten'
|
||||||
|
import { angeboten, codecFamilie, spurLabel, spurenVorbelegt, spurenZuordnen, sprachenDerSpuren } from '../src/gemeinsam/spuren'
|
||||||
|
import { auftragAusJson, spurwahl, sprachlistenAusSpuren } from '../src/kern/ablauf/auftrag'
|
||||||
|
import { buildHandbrakeArgs, KOPIERMASKE } from '../src/kern/komprimieren/handbrake'
|
||||||
|
import { spurenAusScan } from '../src/kern/komprimieren/scan'
|
||||||
|
import { parseStreamInfo, spurenFuerTitel, STREAM_FLAG_ABGELEITET } from '../src/kern/rip/parser'
|
||||||
|
import { regelUrteil, RIPPY_REGEL, RIPPY_REGELN_ALT } from '../src/kern/werkzeuge/spurauswahl'
|
||||||
|
|
||||||
|
// Wie fake-makemkvcon (bau/klick) und die Kizuna-Messung: DTS-HD MA mit
|
||||||
|
// abgeleitetem Kern (Flag 2304 = 0x900), DD 5.1, DD Stereo Kommentar,
|
||||||
|
// PGS deu, PGS deu „nur erzwungene" (6144 = 0x1800), PGS eng.
|
||||||
|
const SINFO = [
|
||||||
|
'SINFO:0,0,1,6201,"Video"',
|
||||||
|
'SINFO:0,1,1,6202,"Audio"',
|
||||||
|
'SINFO:0,1,2,0,"Surround 5.1"',
|
||||||
|
'SINFO:0,1,3,0,"jpn"',
|
||||||
|
'SINFO:0,1,4,0,"Japanese"',
|
||||||
|
'SINFO:0,1,6,0,"DTS-HD MA"',
|
||||||
|
'SINFO:0,1,14,0,"6"',
|
||||||
|
'SINFO:0,1,22,0,"1024"',
|
||||||
|
'SINFO:0,1,30,0,"DTS-HD MA Surround 5.1 Japanese"',
|
||||||
|
'SINFO:0,2,1,6202,"Audio"',
|
||||||
|
'SINFO:0,2,2,0,"Surround 5.1"',
|
||||||
|
'SINFO:0,2,3,0,"jpn"',
|
||||||
|
'SINFO:0,2,4,0,"Japanese"',
|
||||||
|
'SINFO:0,2,6,0,"DTS"',
|
||||||
|
'SINFO:0,2,14,0,"6"',
|
||||||
|
'SINFO:0,2,22,0,"2304"',
|
||||||
|
'SINFO:0,3,1,6202,"Audio"',
|
||||||
|
'SINFO:0,3,2,0,"Surround 5.1"',
|
||||||
|
'SINFO:0,3,3,0,"deu"',
|
||||||
|
'SINFO:0,3,4,0,"German"',
|
||||||
|
'SINFO:0,3,6,0,"DD"',
|
||||||
|
'SINFO:0,3,14,0,"6"',
|
||||||
|
'SINFO:0,3,22,0,"0"',
|
||||||
|
'SINFO:0,4,1,6202,"Audio"',
|
||||||
|
'SINFO:0,4,2,0,"Stereo"',
|
||||||
|
'SINFO:0,4,3,0,"deu"',
|
||||||
|
'SINFO:0,4,4,0,"German"',
|
||||||
|
'SINFO:0,4,6,0,"DD"',
|
||||||
|
'SINFO:0,4,14,0,"2"',
|
||||||
|
'SINFO:0,4,30,0,"DD Stereo German (Kommentar)"',
|
||||||
|
'SINFO:0,5,1,6203,"Untertitel"',
|
||||||
|
'SINFO:0,5,3,0,"deu"',
|
||||||
|
'SINFO:0,5,4,0,"German"',
|
||||||
|
'SINFO:0,5,6,0,"PGS"',
|
||||||
|
'SINFO:0,6,1,6203,"Untertitel"',
|
||||||
|
'SINFO:0,6,3,0,"deu"',
|
||||||
|
'SINFO:0,6,4,0,"German"',
|
||||||
|
'SINFO:0,6,6,0,"PGS"',
|
||||||
|
'SINFO:0,6,22,0,"6144"',
|
||||||
|
'SINFO:0,6,30,0,"PGS German (nur erzwungene)"',
|
||||||
|
'SINFO:0,7,1,6203,"Untertitel"',
|
||||||
|
'SINFO:0,7,3,0,"eng"',
|
||||||
|
'SINFO:0,7,4,0,"English"',
|
||||||
|
'SINFO:0,7,6,0,"PGS"',
|
||||||
|
].join('\n')
|
||||||
|
|
||||||
|
const spuren = spurenFuerTitel(parseStreamInfo(SINFO).get(0))
|
||||||
|
|
||||||
|
describe('Parser: jede Spur einzeln, abgeleitete markiert', () => {
|
||||||
|
it('liest Anordnung (2), Kanäle (14), Flags (22) und erkennt den DTS-Kern am Bit 0x800', () => {
|
||||||
|
expect(STREAM_FLAG_ABGELEITET).toBe(0x800)
|
||||||
|
expect(spuren.audio.map((s) => [s.nr, s.lang, s.codec, s.kanaele, s.layout, s.abgeleitet])).toEqual([
|
||||||
|
[1, 'jpn', 'DTS-HD MA', '6', 'Surround 5.1', false],
|
||||||
|
[2, 'jpn', 'DTS', '6', 'Surround 5.1', true],
|
||||||
|
[3, 'deu', 'DD', '6', 'Surround 5.1', false],
|
||||||
|
[4, 'deu', 'DD', '2', 'Stereo', false],
|
||||||
|
])
|
||||||
|
expect(spuren.untertitel.map((s) => [s.nr, s.lang, s.abgeleitet])).toEqual([
|
||||||
|
[5, 'deu', false],
|
||||||
|
[6, 'deu', true],
|
||||||
|
[7, 'eng', false],
|
||||||
|
])
|
||||||
|
expect(spuren.audio.every(istSpurAngabe)).toBe(true)
|
||||||
|
expect(spurenFuerTitel(undefined)).toEqual({ audio: [], untertitel: [] })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Dialog: Beschriftung, Angebot, Vorbelegung', () => {
|
||||||
|
it('beschriftet mit Sprache, Codec, Anordnung und dem Klammer-Zusatz der Beschreibung', () => {
|
||||||
|
expect(spurLabel(spuren.audio[0])).toBe('Japanese · DTS-HD MA · Surround 5.1')
|
||||||
|
expect(spurLabel(spuren.audio[3])).toBe('German · DD · Stereo (Kommentar)')
|
||||||
|
expect(spurLabel(spuren.untertitel[0])).toBe('German · PGS')
|
||||||
|
expect(spurLabel(spuren.untertitel[1])).toBe('German · PGS (nur erzwungene)')
|
||||||
|
expect(spurLabel({ ...spuren.audio[0], layout: '', sprache: '' })).toBe('jpn · DTS-HD MA · 5.1')
|
||||||
|
expect(spurLabel({ ...spuren.audio[0], lang: '', sprache: '', codec: '', kanaele: '', layout: '', beschreibung: '' })).toBe('ohne Sprache')
|
||||||
|
})
|
||||||
|
it('bietet abgeleitete TONspuren nicht an, abgeleitete Untertitel schon', () => {
|
||||||
|
expect(spuren.audio.filter((s) => angeboten(s, 'audio')).map((s) => s.nr)).toEqual([1, 3, 4])
|
||||||
|
expect(spuren.untertitel.filter((s) => angeboten(s, 'untertitel')).map((s) => s.nr)).toEqual([5, 6, 7])
|
||||||
|
})
|
||||||
|
it('belegt vor: je Wunschsprache die erste Tonspur, alle Untertitel der Wunschsprachen', () => {
|
||||||
|
expect(spurenVorbelegt(spuren.audio, ['deu', 'eng'], 'audio')).toEqual([3])
|
||||||
|
expect(spurenVorbelegt(spuren.audio, ['jpn', 'deu'], 'audio')).toEqual([1, 3])
|
||||||
|
// Keine Wunschsprache da → die erste angebotene Spur (nie der Kern).
|
||||||
|
expect(spurenVorbelegt(spuren.audio, ['fra'], 'audio')).toEqual([1])
|
||||||
|
expect(spurenVorbelegt(spuren.audio, [], 'audio')).toEqual([1])
|
||||||
|
expect(spurenVorbelegt(spuren.untertitel, ['deu'], 'untertitel')).toEqual([5, 6])
|
||||||
|
expect(spurenVorbelegt(spuren.untertitel, ['fra'], 'untertitel')).toEqual([])
|
||||||
|
})
|
||||||
|
it('Sprachen der gewählten Spuren ohne Doppel', () => {
|
||||||
|
expect(sprachenDerSpuren([spuren.audio[2], spuren.audio[3], spuren.audio[0]])).toEqual(['deu', 'jpn'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Codec-Familien: MakeMKV-Wort und HandBrake-CodecName auf einen Schlüssel', () => {
|
||||||
|
it('DTS-HD MA ↔ dtshd, DTS ↔ dca, DD ↔ ac3, TrueHD, DVD-Untertitel ↔ VOBSUB', () => {
|
||||||
|
expect(codecFamilie('DTS-HD MA')).toBe('dtshd')
|
||||||
|
expect(codecFamilie('dtshd')).toBe('dtshd')
|
||||||
|
expect(codecFamilie('DTS')).toBe('dts')
|
||||||
|
expect(codecFamilie('dca')).toBe('dts')
|
||||||
|
expect(codecFamilie('DD')).toBe('ac3')
|
||||||
|
expect(codecFamilie('ac3')).toBe('ac3')
|
||||||
|
expect(codecFamilie('DD+')).toBe('eac3')
|
||||||
|
expect(codecFamilie('eac3')).toBe('eac3')
|
||||||
|
expect(codecFamilie('TrueHD Atmos')).toBe('truehd')
|
||||||
|
expect(codecFamilie('PGS')).toBe('pgs')
|
||||||
|
expect(codecFamilie('DVD Subtitles')).toBe('vobsub')
|
||||||
|
expect(codecFamilie('VOBSUB')).toBe('vobsub')
|
||||||
|
expect(codecFamilie('UTF-8')).toBe('text')
|
||||||
|
expect(codecFamilie('')).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// HandBrakes Scan der Roh-Datei (Felder wie am 1.11.2 gemessen).
|
||||||
|
const SCAN = `JSON Title Set: {
|
||||||
|
"TitleList": [ {
|
||||||
|
"AudioList": [
|
||||||
|
{ "ChannelCount": 6, "CodecName": "dtshd", "Description": "Japanese (DTS-HD MA, 5.1 ch)", "LanguageCode": "jpn", "TrackNumber": 1 },
|
||||||
|
{ "ChannelCount": 6, "CodecName": "ac3", "Description": "Deutsch (AC3, 5.1 ch)", "LanguageCode": "deu", "TrackNumber": 2 },
|
||||||
|
{ "ChannelCount": 2, "CodecName": "ac3", "Description": "Deutsch (AC3, 2.0 ch)", "LanguageCode": "deu", "TrackNumber": 3 }
|
||||||
|
],
|
||||||
|
"Duration": { "Hours": 1, "Minutes": 30, "Seconds": 0 },
|
||||||
|
"SubtitleList": [
|
||||||
|
{ "Format": "bitmap", "LanguageCode": "deu", "Source": 5, "SourceName": "PGS", "TrackNumber": 1 },
|
||||||
|
{ "Format": "bitmap", "LanguageCode": "deu", "Source": 5, "SourceName": "PGS", "TrackNumber": 2 },
|
||||||
|
{ "Format": "bitmap", "LanguageCode": "eng", "Source": 5, "SourceName": "PGS", "TrackNumber": 3 }
|
||||||
|
]
|
||||||
|
} ] }
|
||||||
|
HandBrake has exited.`
|
||||||
|
|
||||||
|
describe('Ablage: gewählte MakeMKV-Spuren in HandBrakes Scan wiederfinden', () => {
|
||||||
|
const scan = spurenAusScan(SCAN)!
|
||||||
|
it('der Scan liefert Codec und Kanäle', () => {
|
||||||
|
expect(scan.audio.map((a) => [a.nr, a.lang, a.codec, a.kanaele])).toEqual([
|
||||||
|
[1, 'jpn', 'dtshd', 6],
|
||||||
|
[2, 'deu', 'ac3', 6],
|
||||||
|
[3, 'deu', 'ac3', 2],
|
||||||
|
])
|
||||||
|
expect(scan.untertitel[0].codec).toBe('PGS')
|
||||||
|
})
|
||||||
|
it('ordnet gestuft zu — Sprache + Familie + Kanäle zuerst, jede Spur nur einmal', () => {
|
||||||
|
// Kommentar (deu DD 2.0) vor dem 5.1 gewählt: trotzdem landet er auf Spur 3.
|
||||||
|
const z = spurenZuordnen([spuren.audio[3], spuren.audio[2], spuren.audio[0]], scan.audio)
|
||||||
|
expect(z.nummern).toEqual([3, 2, 1])
|
||||||
|
expect(z.fehlend).toEqual([])
|
||||||
|
})
|
||||||
|
it('meldet, was nicht in der Datei ist — und fällt nur bei sprachlosen Dateien auf die Reihenfolge zurück', () => {
|
||||||
|
const fra: SpurAngabe = { ...spuren.audio[0], nr: 9, lang: 'fra', sprache: 'French' }
|
||||||
|
expect(spurenZuordnen([fra], scan.audio).fehlend).toEqual([fra])
|
||||||
|
const stumm = scan.audio.map((a) => ({ ...a, lang: '' }))
|
||||||
|
expect(spurenZuordnen([spuren.audio[3]], stumm).nummern).toEqual([3])
|
||||||
|
expect(spurenZuordnen([fra], stumm).nummern).toEqual([1])
|
||||||
|
})
|
||||||
|
it('spurwahl: HandBrake-Nummern, Vorwahl per Regel (fremder Ton → Wunschsprache nach vorn)', () => {
|
||||||
|
const wahl = spurwahl({ audioSpuren: [spuren.audio[0]], untertitelSpuren: [spuren.untertitel[2], spuren.untertitel[0]] }, scan, 'deu')
|
||||||
|
expect(wahl).toEqual({ audioNr: [1], untertitelNr: [1, 3], untertitelStandard: 1, fehlend: [] })
|
||||||
|
})
|
||||||
|
it('spurwahl: Vorwahl von Hand (Spur) oder ausdrücklich keine; deutscher Ton → keine Regel-Vorwahl', () => {
|
||||||
|
const deutsch = spurwahl({ audioSpuren: [spuren.audio[2]], untertitelSpuren: [spuren.untertitel[0], spuren.untertitel[2]] }, scan, 'deu')
|
||||||
|
expect(deutsch).toEqual({ audioNr: [2], untertitelNr: [1, 3], untertitelStandard: 0, fehlend: [] })
|
||||||
|
const hand = spurwahl({ audioSpuren: [spuren.audio[2]], untertitelSpuren: [spuren.untertitel[0], spuren.untertitel[2]], untertitelVorwahlSpur: 7 }, scan, 'deu')
|
||||||
|
expect(hand).toEqual({ audioNr: [2], untertitelNr: [3, 1], untertitelStandard: 1, fehlend: [] })
|
||||||
|
const keine = spurwahl({ audioSpuren: [spuren.audio[0]], untertitelSpuren: [spuren.untertitel[0]], untertitelVorwahlSpur: null }, scan, 'deu')
|
||||||
|
expect(keine!.untertitelStandard).toBe(0)
|
||||||
|
})
|
||||||
|
it('spurwahl: ohne gewählte Spuren null (die Sprachwahl gilt); Fehlendes im Klartext', () => {
|
||||||
|
expect(spurwahl({}, scan, 'deu')).toBeNull()
|
||||||
|
const fra: SpurAngabe = { ...spuren.audio[0], nr: 9, lang: 'fra', sprache: 'French' }
|
||||||
|
const wahl = spurwahl({ audioSpuren: [fra, spuren.audio[2]], untertitelSpuren: [] }, scan, 'deu')
|
||||||
|
expect(wahl!.audioNr).toEqual([2])
|
||||||
|
expect(wahl!.fehlend).toEqual(['Tonspur French DTS-HD MA'])
|
||||||
|
expect(sprachlistenAusSpuren({ audioSpuren: [fra, spuren.audio[2]], untertitelSpuren: [spuren.untertitel[2]] })).toEqual({
|
||||||
|
audio: ['fra', 'deu'],
|
||||||
|
untertitel: ['eng'],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('HandBrake-Schalter für einzelne Spuren (am 1.11.2 gemessen, 12.09.2026)', () => {
|
||||||
|
it('--audio 1,3 --subtitle 2,1 --subtitle-default=1, mit Kopiermaske — keine Sprachlisten', () => {
|
||||||
|
const args = buildHandbrakeArgs({ eingabe: 'a.mkv', ausgabe: 'b.mkv', preset: 'P', audioNr: [1, 3], untertitelNr: [2, 1], untertitelStandardNr: 1 })
|
||||||
|
expect(args[args.indexOf('--audio') + 1]).toBe('1,3')
|
||||||
|
expect(args[args.indexOf('--subtitle') + 1]).toBe('2,1')
|
||||||
|
expect(args).toContain('--subtitle-default=1')
|
||||||
|
expect(args[args.indexOf('--audio-copy-mask') + 1]).toBe(KOPIERMASKE)
|
||||||
|
expect(args).not.toContain('--audio-lang-list')
|
||||||
|
expect(args).not.toContain('--all-subtitles')
|
||||||
|
expect(args).not.toContain('--first-audio')
|
||||||
|
})
|
||||||
|
it('keine Untertitel gewählt → --subtitle none; keine Tonspur → HandBrakes erste (kein --audio)', () => {
|
||||||
|
const args = buildHandbrakeArgs({ eingabe: 'a.mkv', ausgabe: 'b.mkv', preset: 'P', audioNr: [], untertitelNr: [] })
|
||||||
|
expect(args[args.indexOf('--subtitle') + 1]).toBe('none')
|
||||||
|
expect(args).not.toContain('--audio')
|
||||||
|
expect(args).not.toContain('--subtitle-default=1')
|
||||||
|
})
|
||||||
|
it('F31: auch die Sprachwelt bekommt die Kopiermaske — sonst rechnet HandBrake AC3/DTS zu AAC um', () => {
|
||||||
|
const args = buildHandbrakeArgs({ eingabe: 'a.mkv', ausgabe: 'b.mkv', preset: 'P', audioSprachen: ['deu'] })
|
||||||
|
expect(args[args.indexOf('--audio-copy-mask') + 1]).toBe('aac,ac3,eac3,dtshd,dts,mp3,truehd,flac')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Auftrag: Spuren reisen durch Schema und JSON', () => {
|
||||||
|
it('istRipAuftragTitel nimmt Spuren und Vorwahl-Spur an, weist Unsinn ab', () => {
|
||||||
|
const titel = { nr: 0, rolle: 'hauptfilm', dauerS: 1, groesseBytes: 1, audioSprachen: [], untertitelSprachen: [] }
|
||||||
|
expect(istRipAuftragTitel({ ...titel, audioSpuren: [spuren.audio[0]], untertitelVorwahlSpur: null })).toBe(true)
|
||||||
|
expect(istRipAuftragTitel({ ...titel, audioSpuren: [{ nr: 'x' }] })).toBe(false)
|
||||||
|
expect(istRipAuftragTitel({ ...titel, untertitelVorwahlSpur: 'deu' })).toBe(false)
|
||||||
|
})
|
||||||
|
it('auftragAusJson liest die Spuren zurück, lässt Kaputtes weg', () => {
|
||||||
|
const json = JSON.stringify({
|
||||||
|
vorgangId: 'v1',
|
||||||
|
discTyp: 'bluray',
|
||||||
|
rohOrdner: 'E:\\roh\\x',
|
||||||
|
dateien: [
|
||||||
|
{
|
||||||
|
pfad: 'E:\\roh\\x\\title_t00.mkv',
|
||||||
|
rolle: 'hauptfilm',
|
||||||
|
audioSpuren: [spuren.audio[0]],
|
||||||
|
untertitelSpuren: [spuren.untertitel[1]],
|
||||||
|
untertitelVorwahlSpur: 6,
|
||||||
|
},
|
||||||
|
{ pfad: 'E:\\roh\\x\\title_t01.mkv', rolle: 'extra', audioSpuren: ['kaputt'], untertitelVorwahlSpur: 'deu' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
const a = auftragAusJson(json)!
|
||||||
|
expect(a.dateien[0].audioSpuren).toEqual([spuren.audio[0]])
|
||||||
|
expect(a.dateien[0].untertitelSpuren).toEqual([spuren.untertitel[1]])
|
||||||
|
expect(a.dateien[0].untertitelVorwahlSpur).toBe(6)
|
||||||
|
expect(a.dateien[1].audioSpuren).toBeUndefined()
|
||||||
|
expect(a.dateien[1].untertitelVorwahlSpur).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('MakeMKV-Auswahlregel 5.7.0: Stereo-Doppel bleiben, nur Kerne weg', () => {
|
||||||
|
it('die neue Regel lässt havemulti stehen; die alte wird ersetzt, eine fremde bleibt', () => {
|
||||||
|
expect(RIPPY_REGEL).toBe('-sel:all,+sel:all,-sel:havecore,=100:all')
|
||||||
|
expect(regelUrteil(null)).toBe('setzen')
|
||||||
|
expect(regelUrteil('')).toBe('setzen')
|
||||||
|
expect(regelUrteil(RIPPY_REGEL)).toBe('rippy')
|
||||||
|
for (const alt of RIPPY_REGELN_ALT) expect(regelUrteil(alt)).toBe('ersetzen')
|
||||||
|
expect(regelUrteil('-sel:all,+sel:(favlang|nolang)')).toBe('eigen')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// 5.7.0 — 4K nach der Leistung (Commander-Entscheid 12.09.2026): Grafik-
|
||||||
|
// karten-Encoder plus 4K-Preset → komprimieren; nur CPU → verlustfrei.
|
||||||
|
// Preset-Namen und Encoder am mitgelieferten HandBrake 1.11.2 gemessen
|
||||||
|
// (--preset-list, --help; vce_h265_10bit stürzt ab, AV1 10-Bit läuft).
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { DEFAULT_HB_PRESET, PRESET_KEINE, empfehlungText, presetEmpfehlung } from '../src/gemeinsam/preset-empfehlung'
|
||||||
|
import { komprimierenFuer, presetFuer } from '../src/kern/komprimieren/presets'
|
||||||
|
|
||||||
|
const PRESETS = ['H.265 MKV 1080p30', 'H.265 MKV 2160p60 4K', 'AV1 VCN 2160p 4K', 'H.265 VCN 2160p 4K', 'H.265 VCN 1080p', 'H.265 NVENC 1080p']
|
||||||
|
const NUR_CPU = ['cpu-x264', 'cpu-x265', 'cpu-av1']
|
||||||
|
const AMD = [...NUR_CPU, 'vce', 'vce-av1']
|
||||||
|
const NVIDIA_OHNE_4K = [...NUR_CPU, 'nvenc']
|
||||||
|
|
||||||
|
describe('presetEmpfehlung für 4K-UHD', () => {
|
||||||
|
it('Grafikkarte mit 4K-Preset → das 4K-Preset (8 Bit im Klartext)', () => {
|
||||||
|
expect(presetEmpfehlung('uhd', AMD, PRESETS)).toBe('H.265 VCN 2160p 4K')
|
||||||
|
expect(empfehlungText('uhd', 'H.265 VCN 2160p 4K')).toBe('H.265 VCN 2160p 4K (8 Bit)')
|
||||||
|
})
|
||||||
|
it('nur CPU → verlustfrei lassen, nie das 1080p-CPU-Preset', () => {
|
||||||
|
expect(presetEmpfehlung('uhd', NUR_CPU, PRESETS)).toBe(PRESET_KEINE)
|
||||||
|
expect(empfehlungText('uhd', PRESET_KEINE)).toContain('verlustfrei')
|
||||||
|
})
|
||||||
|
it('Grafikkarte ohne 4K-Preset → verlustfrei, nicht klein rechnen', () => {
|
||||||
|
expect(presetEmpfehlung('uhd', NVIDIA_OHNE_4K, PRESETS)).toBe(PRESET_KEINE)
|
||||||
|
})
|
||||||
|
it('DVD und Blu-ray wie bisher: Grafikkarte, sonst CPU-Default', () => {
|
||||||
|
expect(presetEmpfehlung('bluray', AMD, PRESETS)).toBe('H.265 VCN 1080p')
|
||||||
|
expect(presetEmpfehlung('dvd', NUR_CPU, PRESETS)).toBe(DEFAULT_HB_PRESET)
|
||||||
|
expect(presetEmpfehlung('bluray', NVIDIA_OHNE_4K, PRESETS)).toBe('H.265 NVENC 1080p')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('komprimierenFuer mit Empfehlung', () => {
|
||||||
|
it('automatisch + Empfehlung „keine" → nicht komprimieren', () => {
|
||||||
|
expect(komprimierenFuer('uhd', {}, PRESET_KEINE)).toBe(false)
|
||||||
|
expect(komprimierenFuer('uhd', { transcodePresetUhd: '' }, PRESET_KEINE)).toBe(false)
|
||||||
|
})
|
||||||
|
it('eigene Wahl schlägt die Empfehlung — in beide Richtungen', () => {
|
||||||
|
expect(komprimierenFuer('uhd', { transcodePresetUhd: 'AV1 VCN 2160p 4K' }, PRESET_KEINE)).toBe(true)
|
||||||
|
expect(komprimierenFuer('uhd', { transcodePresetUhd: PRESET_KEINE }, 'H.265 VCN 2160p 4K')).toBe(false)
|
||||||
|
expect(presetFuer('uhd', { transcodePresetUhd: 'AV1 VCN 2160p 4K' }, PRESET_KEINE)).toBe('AV1 VCN 2160p 4K')
|
||||||
|
})
|
||||||
|
it('Blu-ray bleibt unberührt', () => {
|
||||||
|
expect(komprimierenFuer('bluray', {}, 'H.265 VCN 1080p')).toBe(true)
|
||||||
|
expect(komprimierenFuer('bluray', {}, '')).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -88,3 +88,32 @@ describe('DiscWache', () => {
|
|||||||
wache.stoppen()
|
wache.stoppen()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 5.7.0 (Durchsicht F6): runde() von außen — nach dem Auswurf — ersetzt die
|
||||||
|
// geplante Runde, statt eine zweite Takt-Kette anzulegen. Vorher: nach
|
||||||
|
// zwanzig Auswürfen zwanzig Ketten (Sonde 3, neun statt fünf Runden).
|
||||||
|
describe('DiscWache — eine Takt-Kette, egal wie oft runde() von außen kommt', () => {
|
||||||
|
it('drei Aufrufe von außen, danach nur EINE Kette', async () => {
|
||||||
|
let gelesen = 0
|
||||||
|
const wache = new DiscWache(
|
||||||
|
() => {
|
||||||
|
gelesen += 1
|
||||||
|
return []
|
||||||
|
},
|
||||||
|
() => {},
|
||||||
|
() => {},
|
||||||
|
20,
|
||||||
|
)
|
||||||
|
wache.starten()
|
||||||
|
wache.runde()
|
||||||
|
wache.runde()
|
||||||
|
const vorher = gelesen
|
||||||
|
await new Promise((f) => setTimeout(f, 130))
|
||||||
|
wache.stoppen()
|
||||||
|
const runden = gelesen - vorher
|
||||||
|
// Eine Kette im 20-ms-Takt schafft in 130 ms höchstens ~7 Runden; drei
|
||||||
|
// Ketten schafften vorher ~19.
|
||||||
|
expect(runden).toBeLessThanOrEqual(8)
|
||||||
|
expect(runden).toBeGreaterThanOrEqual(3)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||