Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b77ab0302 | ||
|
|
cedab61ddf | ||
|
|
6f230cbd7f |
@@ -52,17 +52,6 @@ jobs:
|
||||
|
||||
if [ -n "$pkg_dateien" ]; then
|
||||
echo "== Node/TypeScript erkannt =="
|
||||
# Rippy v5 (rippy-windows/) braucht Node >= 22 (node:sqlite, Vite 7).
|
||||
# Das Runner-Image (docker.gitea.com/runner-images:ubuntu-latest)
|
||||
# bringt Node 24 nativ mit — am 30.08.2026 nachgemessen. Ein
|
||||
# actions/setup-node-Schritt scheiterte hier dagegen (Lauf 221);
|
||||
# deshalb die Prüfung als Klartext statt Beschaffungs-Magie.
|
||||
node_major=$(node -p 'parseInt(process.versions.node)' 2>/dev/null || echo 0)
|
||||
echo "-- Node im Runner: $(node -v 2>/dev/null || echo 'FEHLT')"
|
||||
if [ "$node_major" -lt 22 ]; then
|
||||
echo "❌ Node zu alt für rippy-windows (braucht >= 22) — Runner-Image prüfen."
|
||||
rot=1
|
||||
fi
|
||||
while IFS= read -r pkg; do
|
||||
[ -n "$pkg" ] || continue
|
||||
d=$(dirname "$pkg")
|
||||
|
||||
@@ -55,6 +55,3 @@ logs/
|
||||
*.db
|
||||
*.sqlite
|
||||
dist/
|
||||
|
||||
# Design-Canvas-Arbeitsdateien (leben im Claude-Artifact, nicht im Git)
|
||||
design-entwuerfe/
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
# Beweise — die Messungen zu KONZEPT-WINDOWS.md § 3
|
||||
|
||||
Diese zwei Skripte sind die Grundlage für die Entscheidung, Rippy v5 in
|
||||
TypeScript/Node zu bauen. Sie beantworten die einzige Frage, an der das ganze
|
||||
Konzept hängt:
|
||||
|
||||
> **Kann Node überhaupt alles, was Rippy am Laufwerk braucht?**
|
||||
|
||||
Wäre die Antwort nein gewesen, wäre das Konzept wertlos gewesen. Deshalb sind
|
||||
sie zuerst gelaufen — vor der ersten Zeile Konzept.
|
||||
|
||||
`AGENTS.md` Regel D: *„Externe Schnittstellen NIE aus dem Kopf."*
|
||||
|
||||
---
|
||||
|
||||
## Nachstellen
|
||||
|
||||
```
|
||||
npm install koffi
|
||||
node laufwerk.js
|
||||
```
|
||||
|
||||
Gemessen am 30.08.2026 mit Node v24.19.0, koffi 3.1.6, an einem
|
||||
HL-DT-ST BD-RE BU40N (USB) mit eingelegter Blu-ray.
|
||||
|
||||
---
|
||||
|
||||
## `laufwerk.js` — Win32-Laufwerkszugriff
|
||||
|
||||
Ruft dieselben Steuercodes auf wie `src/rippy/drives/win_ioctl.py` im
|
||||
Docker-Zweig. **Nur lesend** — kein Auswerfen, kein Verriegeln.
|
||||
|
||||
```
|
||||
1. GetLogicalDrives + GetDriveTypeW -> optische Laufwerke: [ 'G' ]
|
||||
2. CreateFileW \\.\G: (Zugriff 0) -> offen
|
||||
CreateFileW \\.\G: (GENERIC_READ) -> offen
|
||||
3. IOCTL_STORAGE_QUERY_PROPERTY -> Hersteller: HL-DT-ST | Modell: BD-RE BU40N
|
||||
| Rev: 1.03 | Serial: 0025114C0149
|
||||
4. IOCTL_STORAGE_CHECK_VERIFY2 -> Medium eingelegt
|
||||
5. IOCTL_CDROM_DISK_TYPE -> Win32-Fehler 50
|
||||
6. IOCTL_DISK_GET_LENGTH_INFO -> 33.759.690.752 Bytes (33,76 GB) => Blu-ray
|
||||
```
|
||||
|
||||
**Punkt 5 ist kein Fehlschlag.** Fehler 50 ist `ERROR_NOT_SUPPORTED` — genau
|
||||
das, was der Python-Treiber an diesem Laufwerk auch sieht (`SAVEPOINT.md`,
|
||||
v4.0-rc11, Abschnitt „Das Laufwerk"). Node verhält sich identisch. Die Lehre
|
||||
steht im alten Code und gilt weiter: **Die Disc-Einordnung darf sich nicht auf
|
||||
`IOCTL_CDROM_DISK_TYPE` verlassen — die Größe ist der verlässliche Weg.**
|
||||
|
||||
Punkt 2 zeigt die Unterscheidung, die in rc11 teuer war: `Zugriff 0` fragt das
|
||||
**Gerät**, `GENERIC_READ` fragt das **Medium**. Bei einer gestörten Disc
|
||||
scheitert das zweite und das erste geht weiter — wer nur eins probiert, hält
|
||||
ein antwortendes Laufwerk für tot.
|
||||
|
||||
---
|
||||
|
||||
## `leine.js` — die Prozess-Leine (Job Object)
|
||||
|
||||
Stellt den Befund aus `SAVEPOINT.md` v4.0-rc10 nach: Windows räumt
|
||||
Kindprozesse **nicht** auf. Ein hart beendeter Rippy hinterlässt ein
|
||||
`makemkvcon`, das das Laufwerk festhält, und jeder spätere Rip scheitert.
|
||||
|
||||
Das Skript setzt eine Arbeitsgruppe mit `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`,
|
||||
startet ein langlebiges Kind und lässt sich dann hart abschießen —
|
||||
`taskkill /F` **ohne** `/T`, also genau das, was bei einem Absturz und beim
|
||||
Drüber-Installieren passiert.
|
||||
|
||||
```
|
||||
1. CreateJobObjectW -> Arbeitsgruppe angelegt
|
||||
2. SetInformationJobObject -> KILL_ON_JOB_CLOSE gesetzt
|
||||
3. AssignProcessToJobObject -> dieser Prozess hängt in der Gruppe
|
||||
4. Kindprozess gestartet, PID 16040
|
||||
|
||||
vor dem Abschuss: Eltern lebt: True Kind lebt: True
|
||||
taskkill /F /PID 29488 -> ERFOLGREICH
|
||||
danach: Eltern lebt: False Kind lebt: False
|
||||
```
|
||||
|
||||
Nachstellen (PowerShell):
|
||||
|
||||
```powershell
|
||||
$p = Start-Process node -ArgumentList "leine.js" -PassThru -RedirectStandardOutput out.txt
|
||||
Start-Sleep 3
|
||||
$kind = (Select-String -Path out.txt -Pattern 'KIND-PID=(\d+)').Matches[0].Groups[1].Value
|
||||
taskkill /F /PID $p.Id
|
||||
Start-Sleep 2
|
||||
Get-Process -Id $kind -ErrorAction SilentlyContinue # muss LEER sein
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `sqlite-main.js` + `sqlite-kern.js` — node:sqlite in Electron
|
||||
|
||||
Beantwortet die Frage, die in der ersten Fassung des Konzepts als ⚠️ offen
|
||||
stand: Electron baut sein Node mit eigenen Flags — ist `node:sqlite` dort
|
||||
überhaupt vorhanden?
|
||||
|
||||
Gemessen am 30.08.2026 mit **Electron 44.0.0**, und zwar im
|
||||
**utilityProcess** — dem Kontext, in dem Rippy v5 seine Datenbank betreiben
|
||||
wird (`kern/speicher/db.ts`), nicht bloß im Node-Modus der Binary:
|
||||
|
||||
```
|
||||
KERN-ANTWORT: {"ok":true,"wert":"geht","node":"24.18.1"}
|
||||
Kern beendet mit 0
|
||||
```
|
||||
|
||||
**Antwort: ja.** Die im Konzept vorgesehene Rückfallebene `better-sqlite3`
|
||||
wird nicht gebraucht. (Zur Einordnung: In Node 24.19.0 pur war dieselbe
|
||||
Messung zuvor ebenfalls grün.)
|
||||
|
||||
Nachstellen:
|
||||
|
||||
```
|
||||
npm install electron@44 --no-save
|
||||
node node_modules/electron/install.js
|
||||
./node_modules/electron/dist/electron.exe sqlite-main.js
|
||||
```
|
||||
|
||||
Die zweite Zeile ist kein Versehen — siehe nächster Abschnitt.
|
||||
|
||||
---
|
||||
|
||||
## npm 11 blockiert Install-Skripte — und das trifft Electron selbst
|
||||
|
||||
```
|
||||
npm warn allow-scripts koffi@3.1.6 (install: node ./cnoke.cjs -P . --prebuild --release)
|
||||
```
|
||||
|
||||
koffi lädt trotzdem — es bringt vorgebaute Binärdateien mit. **Electron
|
||||
trifft die Sperre härter:** Sein Install-Skript lädt die eigentliche
|
||||
Programmdatei herunter; wird es geblockt, liegt nach `npm install` ein
|
||||
Paket **ohne `dist/electron.exe`** da, und nichts startet. Am 30.08.2026
|
||||
beim sqlite-Beweis selbst hineingelaufen; `node node_modules/electron/install.js`
|
||||
holt die Binary nach. Beides gehört in die Bau-Anleitung, sonst sucht eines
|
||||
Tages jemand an der falschen Stelle.
|
||||
@@ -1,402 +0,0 @@
|
||||
# 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: 288 KiB |
|
Before Width: | Height: | Size: 158 KiB |
|
Before Width: | Height: | Size: 178 KiB |
|
Before Width: | Height: | Size: 337 KiB |
|
Before Width: | Height: | Size: 759 KiB |
|
Before Width: | Height: | Size: 760 KiB |
|
Before Width: | Height: | Size: 791 KiB |
|
Before Width: | Height: | Size: 339 KiB |
|
Before Width: | Height: | Size: 468 KiB |
|
Before Width: | Height: | Size: 457 KiB |
|
Before Width: | Height: | Size: 445 KiB |
@@ -1,109 +0,0 @@
|
||||
// Beweis-Test: Kann Node/koffi dieselben Win32-Aufrufe wie Rippys Python-Treiber?
|
||||
// NUR LESEND — kein Auswerfen, kein Verriegeln.
|
||||
const koffi = require('koffi');
|
||||
|
||||
// ── CTL_CODE aus winioctl.h, eins zu eins wie in src/rippy/drives/win_ioctl.py
|
||||
const ctl = (typ, fn, methode, zugriff) =>
|
||||
((typ << 16) | (zugriff << 14) | (fn << 2) | methode) >>> 0;
|
||||
|
||||
const FILE_DEVICE_CD_ROM = 0x02, FILE_DEVICE_DISK = 0x07, FILE_DEVICE_MASS_STORAGE = 0x2d;
|
||||
const IOCTL_STORAGE_CHECK_VERIFY2 = ctl(FILE_DEVICE_MASS_STORAGE, 0x0200, 0, 0);
|
||||
const IOCTL_STORAGE_QUERY_PROPERTY = ctl(FILE_DEVICE_MASS_STORAGE, 0x0500, 0, 0);
|
||||
const IOCTL_CDROM_DISK_TYPE = ctl(FILE_DEVICE_CD_ROM, 0x0010, 0, 0);
|
||||
const IOCTL_DISK_GET_LENGTH_INFO = ctl(FILE_DEVICE_DISK, 0x0017, 0, 1);
|
||||
|
||||
const kernel32 = koffi.load('kernel32.dll');
|
||||
const GetLogicalDrives = kernel32.func('uint32_t __stdcall GetLogicalDrives()');
|
||||
const GetDriveTypeW = kernel32.func('uint32_t __stdcall GetDriveTypeW(str16 lpRootPathName)');
|
||||
const CreateFileW = kernel32.func('void* __stdcall CreateFileW(str16 lpFileName, uint32_t dwDesiredAccess, uint32_t dwShareMode, void *lpSecurityAttributes, uint32_t dwCreationDisposition, uint32_t dwFlagsAndAttributes, void *hTemplateFile)');
|
||||
const CloseHandle = kernel32.func('bool __stdcall CloseHandle(void *hObject)');
|
||||
const GetLastError = kernel32.func('uint32_t __stdcall GetLastError()');
|
||||
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)');
|
||||
|
||||
const GENERIC_READ = 0x80000000, FILE_SHARE_READ = 1, FILE_SHARE_WRITE = 2, OPEN_EXISTING = 3;
|
||||
const DRIVE_CDROM = 5;
|
||||
|
||||
// ── 1. Laufwerke finden (die Windows-Entsprechung von glob /dev/sr*)
|
||||
const maske = GetLogicalDrives();
|
||||
const optische = [];
|
||||
for (let i = 0; i < 26; i++) {
|
||||
if (!(maske & (1 << i))) continue;
|
||||
const buchstabe = String.fromCharCode(65 + i);
|
||||
if (GetDriveTypeW(buchstabe + ':\\') === DRIVE_CDROM) optische.push(buchstabe);
|
||||
}
|
||||
console.log('1. GetLogicalDrives + GetDriveTypeW -> optische Laufwerke:', optische);
|
||||
if (!optische.length) { console.log(' Kein optisches Laufwerk — Test endet hier.'); process.exit(0); }
|
||||
|
||||
const lw = optische[0];
|
||||
|
||||
// ── 2. Gerät öffnen. Zugriff 0 = nur das GERÄT fragen, nicht das Medium.
|
||||
// Genau die Unterscheidung aus SAVEPOINT rc11: Bei gestörtem Medium
|
||||
// scheitert GENERIC_READ, Zugriff 0 geht weiter.
|
||||
function oeffnen(zugriff) {
|
||||
const h = CreateFileW('\\\\.\\' + lw + ':', zugriff,
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE, null, OPEN_EXISTING, 0, null);
|
||||
const adr = koffi.address(h);
|
||||
// INVALID_HANDLE_VALUE ist -1, als vorzeichenlose 64-Bit-Zahl 0xFFFF...
|
||||
if (adr === 0n || adr === 0xffffffffffffffffn) return { h: null, fehler: GetLastError() };
|
||||
return { h, fehler: 0 };
|
||||
}
|
||||
|
||||
const ohneMedium = oeffnen(0);
|
||||
console.log(`2. CreateFileW \\\\.\\${lw}: (Zugriff 0) -> ` +
|
||||
(ohneMedium.h ? 'offen' : 'Win32-Fehler ' + ohneMedium.fehler));
|
||||
const mitLesen = oeffnen(GENERIC_READ);
|
||||
console.log(` CreateFileW \\\\.\\${lw}: (GENERIC_READ) -> ` +
|
||||
(mitLesen.h ? 'offen' : 'Win32-Fehler ' + mitLesen.fehler));
|
||||
|
||||
const h = ohneMedium.h;
|
||||
if (!h) { console.log(' Gerät nicht ansprechbar — Test endet hier.'); process.exit(0); }
|
||||
|
||||
const rueck = Buffer.alloc(4);
|
||||
|
||||
// ── 3. Hersteller/Modell/Serial (STORAGE_DEVICE_DESCRIPTOR)
|
||||
const anfrage = Buffer.alloc(12);
|
||||
anfrage.writeUInt32LE(0, 0); // PropertyId = StorageDeviceProperty
|
||||
anfrage.writeUInt32LE(0, 4); // QueryType = PropertyStandardQuery
|
||||
const antwort = Buffer.alloc(1024);
|
||||
if (DeviceIoControl(h, IOCTL_STORAGE_QUERY_PROPERTY, anfrage, 12, antwort, 1024, rueck, null)) {
|
||||
const text = (off) => {
|
||||
const start = antwort.readUInt32LE(off);
|
||||
if (!start || start >= antwort.length) return '';
|
||||
let ende = start; while (ende < antwort.length && antwort[ende] !== 0) ende++;
|
||||
return antwort.toString('latin1', start, ende).trim();
|
||||
};
|
||||
console.log('3. IOCTL_STORAGE_QUERY_PROPERTY -> Hersteller:', text(12),
|
||||
'| Modell:', text(16), '| Rev:', text(20), '| Serial:', text(24));
|
||||
} else {
|
||||
console.log('3. IOCTL_STORAGE_QUERY_PROPERTY -> Win32-Fehler', GetLastError());
|
||||
}
|
||||
|
||||
// ── 4. Liegt ein Medium drin? (ERROR_NOT_READY = 21 heißt: leer)
|
||||
const okVerify = DeviceIoControl(h, IOCTL_STORAGE_CHECK_VERIFY2, null, 0, null, 0, rueck, null);
|
||||
console.log('4. IOCTL_STORAGE_CHECK_VERIFY2 -> ' +
|
||||
(okVerify ? 'Medium eingelegt' : 'kein Medium (Win32-Fehler ' + GetLastError() + ')'));
|
||||
|
||||
// ── 5. Audio- oder Datenspur?
|
||||
const typBuf = Buffer.alloc(1);
|
||||
if (DeviceIoControl(h, IOCTL_CDROM_DISK_TYPE, null, 0, typBuf, 1, rueck, null)) {
|
||||
const t = typBuf[0];
|
||||
console.log('5. IOCTL_CDROM_DISK_TYPE -> ' +
|
||||
(t === 1 ? 'Audio-CD' : t === 2 ? 'Daten-Disc' : 'Wert ' + t));
|
||||
} else {
|
||||
console.log('5. IOCTL_CDROM_DISK_TYPE -> Win32-Fehler', GetLastError());
|
||||
}
|
||||
|
||||
// ── 6. Größe des Mediums (für die Unterscheidung DVD / BD / UHD)
|
||||
const laenge = Buffer.alloc(8);
|
||||
if (mitLesen.h && DeviceIoControl(mitLesen.h, IOCTL_DISK_GET_LENGTH_INFO, null, 0, laenge, 8, rueck, null)) {
|
||||
const bytes = laenge.readBigUInt64LE(0);
|
||||
const gb = Number(bytes) / 1e9;
|
||||
console.log('6. IOCTL_DISK_GET_LENGTH_INFO -> ' + bytes + ' Bytes (' + gb.toFixed(2) + ' GB)' +
|
||||
' => Einordnung: ' + (gb > 60 ? 'UHD' : gb > 9.5 ? 'Blu-ray' : gb > 1 ? 'DVD' : 'CD'));
|
||||
} else {
|
||||
console.log('6. IOCTL_DISK_GET_LENGTH_INFO -> Win32-Fehler', GetLastError());
|
||||
}
|
||||
|
||||
CloseHandle(h);
|
||||
if (mitLesen.h) CloseHandle(mitLesen.h);
|
||||
console.log('\nAlle Handles geschlossen. Nichts ausgeworfen, nichts verriegelt.');
|
||||
@@ -1,114 +0,0 @@
|
||||
// MESSUNG: Überlebt ein ausdrücklich ausgeklinktes Kind den Tod seines
|
||||
// Elternprozesses — und stirbt ein normales Kind weiterhin mit?
|
||||
//
|
||||
// Anlass (01.09.2026): Das Update auf 5.1.3 lief nicht. Rippy beendete
|
||||
// sauber, aber der Installer startete nie. Verdacht: Die Prozess-Leine
|
||||
// (§ 3.3, KILL_ON_JOB_CLOSE) erschlägt den Installer, weil
|
||||
// electron-updater ihn per spawn(…, { detached: true }) startet und
|
||||
// `detached` unter Windows KEIN Breakaway setzt.
|
||||
//
|
||||
// Aufbau (drei Läufe, der äußere ruft die inneren):
|
||||
// node beweise/leine-breakaway.js
|
||||
// -> startet zweimal sich selbst als „innen":
|
||||
// innen-ohne : Arbeitsgruppe NUR mit KILL_ON_JOB_CLOSE, Enkel normal
|
||||
// innen-mit : Arbeitsgruppe zusätzlich mit BREAKAWAY_OK,
|
||||
// Enkel mit CREATE_BREAKAWAY_FROM_JOB
|
||||
// Beide beenden sich sofort. Danach schaut der äußere Lauf nach,
|
||||
// welcher Enkel noch lebt.
|
||||
//
|
||||
// Erwartung: ohne Ausklinken stirbt der Enkel, mit Ausklinken lebt er.
|
||||
//
|
||||
// Ausführen: node beweise/leine-breakaway.js
|
||||
const { execFileSync, spawnSync } = require('node:child_process')
|
||||
const path = require('node:path')
|
||||
|
||||
const koffi = require(path.join(__dirname, '..', 'rippy-windows', 'node_modules', 'koffi'))
|
||||
|
||||
const JOB_KLASSE = 9 // JobObjectExtendedLimitInformation
|
||||
const KILL_ON_JOB_CLOSE = 0x2000
|
||||
const BREAKAWAY_OK = 0x0800
|
||||
const CREATE_BREAKAWAY_FROM_JOB = 0x01000000
|
||||
const CREATE_NO_WINDOW = 0x08000000
|
||||
const STRUKTUR_GROESSE = 144
|
||||
const LIMIT_FLAGS_OFFSET = 16
|
||||
const STARTUPINFO_GROESSE = 104
|
||||
const PROCESS_INFORMATION_GROESSE = 24
|
||||
|
||||
const kernel32 = koffi.load('kernel32.dll')
|
||||
const CreateJobObjectW = kernel32.func('void* __stdcall CreateJobObjectW(void *a, str16 n)')
|
||||
const SetInformationJobObject = kernel32.func(
|
||||
'bool __stdcall SetInformationJobObject(void *j, int k, void *i, uint32_t l)',
|
||||
)
|
||||
const AssignProcessToJobObject = kernel32.func('bool __stdcall AssignProcessToJobObject(void *j, void *p)')
|
||||
const GetCurrentProcess = kernel32.func('void* __stdcall GetCurrentProcess()')
|
||||
const GetLastError = kernel32.func('uint32_t __stdcall GetLastError()')
|
||||
const CreateProcessW = kernel32.func(
|
||||
'bool __stdcall CreateProcessW(str16 an, void *cl, void *pa, void *ta, bool ih, uint32_t cf, void *env, str16 cd, void *si, void *pi)',
|
||||
)
|
||||
|
||||
/** Ein Enkel, der 40 Sekunden lebt — lange genug zum Nachsehen. */
|
||||
const ENKEL = 'C:\\Windows\\System32\\cmd.exe'
|
||||
const ENKEL_ZEILE = '"C:\\Windows\\System32\\cmd.exe" /c ping -n 40 127.0.0.1'
|
||||
|
||||
function innen(jobErlaubtAusklinken, kindKlinktSichAus) {
|
||||
const job = CreateJobObjectW(null, null)
|
||||
const info = Buffer.alloc(STRUKTUR_GROESSE)
|
||||
info.writeUInt32LE(KILL_ON_JOB_CLOSE | (jobErlaubtAusklinken ? BREAKAWAY_OK : 0), LIMIT_FLAGS_OFFSET)
|
||||
SetInformationJobObject(job, JOB_KLASSE, info, STRUKTUR_GROESSE)
|
||||
AssignProcessToJobObject(job, GetCurrentProcess())
|
||||
|
||||
const si = Buffer.alloc(STARTUPINFO_GROESSE)
|
||||
si.writeUInt32LE(STARTUPINFO_GROESSE, 0)
|
||||
const pi = Buffer.alloc(PROCESS_INFORMATION_GROESSE)
|
||||
const zeile = Buffer.from(ENKEL_ZEILE + '\0', 'utf16le')
|
||||
// CREATE_NO_WINDOW gibt dem Enkel eine EIGENE (unsichtbare) Konsole.
|
||||
// Ohne das teilt er sich die Konsole des Elternprozesses und stirbt mit
|
||||
// IHR, nicht mit der Arbeitsgruppe — der erste Messlauf am 01.09.2026
|
||||
// lief genau in diese Falle und zeigte fälschlich „Ausklinken wirkt
|
||||
// nicht". Beide Fälle bekommen es, damit der Vergleich fair bleibt.
|
||||
const flags = CREATE_NO_WINDOW | (kindKlinktSichAus ? CREATE_BREAKAWAY_FROM_JOB : 0)
|
||||
|
||||
const ok = CreateProcessW(ENKEL, zeile, null, null, false, flags, null, null, si, pi)
|
||||
if (!ok) {
|
||||
console.log(`PID=0 FEHLER=${GetLastError()}`)
|
||||
return
|
||||
}
|
||||
// dwProcessId steht in PROCESS_INFORMATION an Offset 16.
|
||||
console.log(`PID=${pi.readUInt32LE(16)} FEHLER=0`)
|
||||
}
|
||||
|
||||
function lebt(pid) {
|
||||
const ergebnis = spawnSync('tasklist', ['/FI', `PID eq ${pid}`, '/NH'], { encoding: 'utf8' })
|
||||
return (ergebnis.stdout || '').includes(String(pid))
|
||||
}
|
||||
|
||||
function aussen() {
|
||||
const faelle = [
|
||||
['1) Leine wie bisher, Kind normal — die Ausgangslage', 'alt', false],
|
||||
// DIE Regressionsfrage: Weicht das blosse ERLAUBEN die Leine auf?
|
||||
// Wenn hier JA stuende, waere Paragraph 3.3 kaputt und makemkvcon
|
||||
// koennte als Waise das Laufwerk festhalten (der rc10-Fund).
|
||||
['2) Leine erlaubt Ausklinken, Kind normal — MUSS weiter sterben', 'erlaubt-normal', false],
|
||||
['3) Leine erlaubt Ausklinken, Kind klinkt sich aus — der Installer', 'erlaubt-aus', true],
|
||||
]
|
||||
for (const [name, modus, erwartetLebend] of faelle) {
|
||||
const ausgabe = execFileSync(process.execPath, [__filename, modus], { encoding: 'utf8' }).trim()
|
||||
const pid = Number(/PID=(\d+)/.exec(ausgabe)?.[1] ?? 0)
|
||||
const fehler = Number(/FEHLER=(\d+)/.exec(ausgabe)?.[1] ?? 0)
|
||||
// Der innere Lauf ist hier schon beendet — die Arbeitsgruppe ist also
|
||||
// zugeschnappt, falls sie zuschnappt.
|
||||
const nochDa = pid > 0 ? lebt(pid) : false
|
||||
const urteil = nochDa === erwartetLebend ? 'WIE ERWARTET' : 'ABWEICHUNG'
|
||||
console.log(
|
||||
`${name}\n Enkel-PID ${pid || '—'}${fehler ? ` (Win32-Fehler ${fehler})` : ''}` +
|
||||
`\n lebt nach dem Tod des Elternprozesses: ${nochDa ? 'JA' : 'NEIN'} -> ${urteil}\n`,
|
||||
)
|
||||
if (nochDa) spawnSync('taskkill', ['/PID', String(pid), '/F', '/T'])
|
||||
}
|
||||
}
|
||||
|
||||
const modus = process.argv[2] ?? ''
|
||||
if (modus === 'alt') innen(false, false)
|
||||
else if (modus === 'erlaubt-normal') innen(true, false)
|
||||
else if (modus === 'erlaubt-aus') innen(true, true)
|
||||
else aussen()
|
||||
@@ -1,50 +0,0 @@
|
||||
// Beweis-Test 2: Kann Node eine Windows-Arbeitsgruppe (Job Object) setzen,
|
||||
// die Kindprozesse mitreisst, wenn der Elternprozess HART beendet wird?
|
||||
// Das ist der Mechanismus aus SAVEPOINT v4.0-rc10 — ohne ihn ueberlebt
|
||||
// makemkvcon Rippy und haelt das Laufwerk fest.
|
||||
const koffi = require('koffi');
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
const kernel32 = koffi.load('kernel32.dll');
|
||||
const CreateJobObjectW = kernel32.func('void* __stdcall CreateJobObjectW(void *lpJobAttributes, str16 lpName)');
|
||||
const SetInformationJobObject = kernel32.func('bool __stdcall SetInformationJobObject(void *hJob, int JobObjectInformationClass, void *lpJobObjectInformation, uint32_t cbJobObjectInformationLength)');
|
||||
const AssignProcessToJobObject = kernel32.func('bool __stdcall AssignProcessToJobObject(void *hJob, void *hProcess)');
|
||||
const GetCurrentProcess = kernel32.func('void* __stdcall GetCurrentProcess()');
|
||||
const GetLastError = kernel32.func('uint32_t __stdcall GetLastError()');
|
||||
|
||||
// JobObjectExtendedLimitInformation = 9 (winnt.h)
|
||||
const JobObjectExtendedLimitInformation = 9;
|
||||
// JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000 (winnt.h)
|
||||
const JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000;
|
||||
|
||||
const job = CreateJobObjectW(null, null);
|
||||
if (koffi.address(job) === 0n) {
|
||||
console.log('CreateJobObjectW fehlgeschlagen, Win32-Fehler', GetLastError());
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('1. CreateJobObjectW -> Arbeitsgruppe angelegt');
|
||||
|
||||
// JOBOBJECT_EXTENDED_LIMIT_INFORMATION ist 144 Byte auf x64.
|
||||
// LimitFlags liegt im eingebetteten BASIC_LIMIT_INFORMATION bei Offset 16.
|
||||
const info = Buffer.alloc(144);
|
||||
info.writeUInt32LE(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, 16);
|
||||
if (!SetInformationJobObject(job, JobObjectExtendedLimitInformation, info, 144)) {
|
||||
console.log('SetInformationJobObject fehlgeschlagen, Win32-Fehler', GetLastError());
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('2. SetInformationJobObject -> KILL_ON_JOB_CLOSE gesetzt');
|
||||
|
||||
if (!AssignProcessToJobObject(job, GetCurrentProcess())) {
|
||||
console.log('AssignProcessToJobObject fehlgeschlagen, Win32-Fehler', GetLastError());
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('3. AssignProcessToJobObject -> dieser Prozess haengt in der Gruppe');
|
||||
|
||||
// Ein langlebiges Kind starten — der Platzhalter fuer makemkvcon.
|
||||
const kind = spawn('ping', ['-n', '300', '127.0.0.1'], { stdio: 'ignore' });
|
||||
console.log('4. Kindprozess gestartet, PID ' + kind.pid);
|
||||
console.log('ELTERN-PID=' + process.pid);
|
||||
console.log('KIND-PID=' + kind.pid);
|
||||
|
||||
// Offen halten, bis uns jemand hart abschiesst.
|
||||
setTimeout(() => { console.log('Zeit abgelaufen'); process.exit(0); }, 60000);
|
||||
@@ -1,375 +0,0 @@
|
||||
MSG:1005,0,1,"MakeMKV v1.18.4 win(x64-release) gestartet","%1 gestartet","MakeMKV v1.18.4 win(x64-release)"
|
||||
DRV:0,2,999,12,"BD-RE HL-DT-ST BD-RE BU40N 1.03 MOBPBDF1840","DA_LAST EVOLUTION KIZUNA","G:"
|
||||
DRV:1,256,999,0,"","",""
|
||||
DRV:2,256,999,0,"","",""
|
||||
DRV:3,256,999,0,"","",""
|
||||
DRV:4,256,999,0,"","",""
|
||||
DRV:5,256,999,0,"","",""
|
||||
DRV:6,256,999,0,"","",""
|
||||
DRV:7,256,999,0,"","",""
|
||||
DRV:8,256,999,0,"","",""
|
||||
DRV:9,256,999,0,"","",""
|
||||
DRV:10,256,999,0,"","",""
|
||||
DRV:11,256,999,0,"","",""
|
||||
DRV:12,256,999,0,"","",""
|
||||
DRV:13,256,999,0,"","",""
|
||||
DRV:14,256,999,0,"","",""
|
||||
DRV:15,256,999,0,"","",""
|
||||
MSG:1011,0,1,"Using LibreDrive mode (v06.3 id=866A98CB9C4E)","%1","Using LibreDrive mode (v06.3 id=866A98CB9C4E)"
|
||||
MSG:3007,0,0,"Direkter Zugriffsmodus wird verwendet","Direkter Zugriffsmodus wird verwendet"
|
||||
MSG:5054,0,1,"Evaluierungsversion. Der Testzeitraum lief vor 10 Tagen ab.","Evaluierungsversion. Der Testzeitraum lief vor %1 Tagen ab.","10"
|
||||
MSG:5051,1028,0,"Der Testzeitraum ist abgelaufen. Erwerben Sie bitte einen Aktivierungsschlüssel, wenn Sie das Programm nützlich fanden. Sie können den vollen Funktionsumfang noch einige Tage nutzen.","Der Testzeitraum ist abgelaufen. Erwerben Sie bitte einen Aktivierungsschlüssel, wenn Sie das Programm nützlich fanden. Sie können den vollen Funktionsumfang noch einige Tage nutzen."
|
||||
MSG:5085,0,0,"Inhalte-Hashtabelle geladen, überprüfe Integrität der M2TS-Dateien.","Inhalte-Hashtabelle geladen, überprüfe Integrität der M2TS-Dateien."
|
||||
MSG:3307,16777216,2,"Datei 00011.mpls wurde als Titel 0 hinzugefügt ","Datei %1 wurde als Titel %2 hinzugefügt ","00011.mpls","0"
|
||||
MSG:3025,0,3,"Titel #00000.mpls hat eine Länge von 41 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00000.mpls","41","120"
|
||||
MSG:3307,16777216,2,"Datei 00003.mpls wurde als Titel 1 hinzugefügt ","Datei %1 wurde als Titel %2 hinzugefügt ","00003.mpls","1"
|
||||
MSG:3025,16777216,3,"Titel #00005.m2ts hat eine Länge von 93 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00005.m2ts","93","120"
|
||||
MSG:3025,16777216,3,"Titel #00004.m2ts hat eine Länge von 98 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00004.m2ts","98","120"
|
||||
MSG:3025,16777216,3,"Titel #00015.m2ts hat eine Länge von 10 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00015.m2ts","10","120"
|
||||
MSG:3025,16777216,3,"Titel #00003.m2ts hat eine Länge von 7 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00003.m2ts","7","120"
|
||||
MSG:3025,16777216,3,"Titel #00002.m2ts hat eine Länge von 18 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00002.m2ts","18","120"
|
||||
MSG:3025,16777216,3,"Titel #00001.m2ts hat eine Länge von 7 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00001.m2ts","7","120"
|
||||
MSG:3025,16777216,3,"Titel #00000.m2ts hat eine Länge von 8 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00000.m2ts","8","120"
|
||||
MSG:3025,16777216,3,"Titel #00014.m2ts hat eine Länge von 107 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00014.m2ts","107","120"
|
||||
MSG:3025,16777216,3,"Titel #00013.m2ts hat eine Länge von 63 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00013.m2ts","63","120"
|
||||
MSG:3025,16777216,3,"Titel #00012.m2ts hat eine Länge von 93 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00012.m2ts","93","120"
|
||||
MSG:3025,16777216,3,"Titel #00011.m2ts hat eine Länge von 78 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00011.m2ts","78","120"
|
||||
MSG:3025,16777216,3,"Titel #00010.m2ts hat eine Länge von 89 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00010.m2ts","89","120"
|
||||
MSG:3025,16777216,3,"Titel #00009.m2ts hat eine Länge von 98 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00009.m2ts","98","120"
|
||||
MSG:3025,0,3,"Titel #00008.m2ts hat eine Länge von 95 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00008.m2ts","95","120"
|
||||
MSG:5011,0,0,"Die Aufgabe wurde erfolgreich abgeschlossen","Die Aufgabe wurde erfolgreich abgeschlossen"
|
||||
TCOUNT:2
|
||||
CINFO:1,6209,"Blu-ray-Disk"
|
||||
CINFO:2,0,"Digimon Adventure: Last Evolution Kizuna"
|
||||
CINFO:28,0,"eng"
|
||||
CINFO:29,0,"English"
|
||||
CINFO:30,0,"Digimon Adventure: Last Evolution Kizuna"
|
||||
CINFO:31,6119,"<b>Quelleninformationen</b><br>"
|
||||
CINFO:32,0,"DA_LAST EVOLUTION KIZUNA"
|
||||
CINFO:33,0,"0"
|
||||
TINFO:0,2,0,"Digimon Adventure: Last Evolution Kizuna"
|
||||
TINFO:0,8,0,"6"
|
||||
TINFO:0,9,0,"0:08:51"
|
||||
TINFO:0,10,0,"1.1 GB"
|
||||
TINFO:0,11,0,"1219086336"
|
||||
TINFO:0,16,0,"00011.mpls"
|
||||
TINFO:0,25,0,"6"
|
||||
TINFO:0,26,0,"9,10,11,12,13,14"
|
||||
TINFO:0,27,0,"Digimon Adventure- Last Evolution Kizuna_t00.mkv"
|
||||
TINFO:0,28,0,"eng"
|
||||
TINFO:0,29,0,"English"
|
||||
TINFO:0,30,0,"Digimon Adventure: Last Evolution Kizuna - 6 Kapitel , 1.1 GB"
|
||||
TINFO:0,31,6120,"<b>Titelinformationen</b><br>"
|
||||
TINFO:0,33,0,"0"
|
||||
SINFO:0,0,1,6201,"Video"
|
||||
SINFO:0,0,5,0,"V_MPEG4/ISO/AVC"
|
||||
SINFO:0,0,6,0,"Mpeg4"
|
||||
SINFO:0,0,7,0,"Mpeg4 AVC High@L4.1"
|
||||
SINFO:0,0,19,0,"1920x1080"
|
||||
SINFO:0,0,20,0,"16:9"
|
||||
SINFO:0,0,21,0,"23.976 (24000/1001)"
|
||||
SINFO:0,0,22,0,"0"
|
||||
SINFO:0,0,28,0,"eng"
|
||||
SINFO:0,0,29,0,"English"
|
||||
SINFO:0,0,30,0,"Mpeg4 AVC High@L4.1"
|
||||
SINFO:0,0,31,6121,"<b>Spurinformationen</b><br>"
|
||||
SINFO:0,0,33,0,"0"
|
||||
SINFO:0,0,38,0,""
|
||||
SINFO:0,0,42,5088,"( Lossless conversion )"
|
||||
SINFO:0,1,1,6202,"Audio"
|
||||
SINFO:0,1,2,5091,"Stereo"
|
||||
SINFO:0,1,3,0,"deu"
|
||||
SINFO:0,1,4,0,"German"
|
||||
SINFO:0,1,5,0,"A_DTS"
|
||||
SINFO:0,1,6,0,"DTS-HD MA"
|
||||
SINFO:0,1,7,0,"DTS-HD Master Audio"
|
||||
SINFO:0,1,14,0,"2"
|
||||
SINFO:0,1,17,0,"48000"
|
||||
SINFO:0,1,18,0,"16"
|
||||
SINFO:0,1,22,0,"1024"
|
||||
SINFO:0,1,28,0,"eng"
|
||||
SINFO:0,1,29,0,"English"
|
||||
SINFO:0,1,30,0,"DTS-HD MA Stereo German"
|
||||
SINFO:0,1,31,6121,"<b>Spurinformationen</b><br>"
|
||||
SINFO:0,1,33,0,"100"
|
||||
SINFO:0,1,38,0,""
|
||||
SINFO:0,1,40,0,"stereo"
|
||||
SINFO:0,1,42,5088,"( Lossless conversion )"
|
||||
SINFO:0,2,1,6202,"Audio"
|
||||
SINFO:0,2,2,5091,"Stereo"
|
||||
SINFO:0,2,3,0,"deu"
|
||||
SINFO:0,2,4,0,"German"
|
||||
SINFO:0,2,5,0,"A_DTS"
|
||||
SINFO:0,2,6,0,"DTS"
|
||||
SINFO:0,2,7,0,"DTS"
|
||||
SINFO:0,2,13,0,"768 Kb/s"
|
||||
SINFO:0,2,14,0,"2"
|
||||
SINFO:0,2,17,0,"48000"
|
||||
SINFO:0,2,22,0,"2304"
|
||||
SINFO:0,2,28,0,"eng"
|
||||
SINFO:0,2,29,0,"English"
|
||||
SINFO:0,2,30,0,"DTS Stereo German"
|
||||
SINFO:0,2,31,6121,"<b>Spurinformationen</b><br>"
|
||||
SINFO:0,2,33,0,"100"
|
||||
SINFO:0,2,38,0,"d"
|
||||
SINFO:0,2,39,0,"Default"
|
||||
SINFO:0,2,40,0,"stereo"
|
||||
SINFO:0,2,42,5088,"( Lossless conversion )"
|
||||
SINFO:0,3,1,6202,"Audio"
|
||||
SINFO:0,3,2,5091,"Stereo"
|
||||
SINFO:0,3,3,0,"deu"
|
||||
SINFO:0,3,4,0,"German"
|
||||
SINFO:0,3,5,0,"A_DTS"
|
||||
SINFO:0,3,6,0,"DTS-HD MA"
|
||||
SINFO:0,3,7,0,"DTS-HD Master Audio"
|
||||
SINFO:0,3,14,0,"2"
|
||||
SINFO:0,3,17,0,"48000"
|
||||
SINFO:0,3,18,0,"16"
|
||||
SINFO:0,3,22,0,"1024"
|
||||
SINFO:0,3,28,0,"eng"
|
||||
SINFO:0,3,29,0,"English"
|
||||
SINFO:0,3,30,0,"DTS-HD MA Stereo German"
|
||||
SINFO:0,3,31,6121,"<b>Spurinformationen</b><br>"
|
||||
SINFO:0,3,33,0,"100"
|
||||
SINFO:0,3,38,0,""
|
||||
SINFO:0,3,40,0,"stereo"
|
||||
SINFO:0,3,42,5088,"( Lossless conversion )"
|
||||
SINFO:0,4,1,6202,"Audio"
|
||||
SINFO:0,4,2,5091,"Stereo"
|
||||
SINFO:0,4,3,0,"deu"
|
||||
SINFO:0,4,4,0,"German"
|
||||
SINFO:0,4,5,0,"A_DTS"
|
||||
SINFO:0,4,6,0,"DTS"
|
||||
SINFO:0,4,7,0,"DTS"
|
||||
SINFO:0,4,13,0,"768 Kb/s"
|
||||
SINFO:0,4,14,0,"2"
|
||||
SINFO:0,4,17,0,"48000"
|
||||
SINFO:0,4,22,0,"2304"
|
||||
SINFO:0,4,28,0,"eng"
|
||||
SINFO:0,4,29,0,"English"
|
||||
SINFO:0,4,30,0,"DTS Stereo German"
|
||||
SINFO:0,4,31,6121,"<b>Spurinformationen</b><br>"
|
||||
SINFO:0,4,33,0,"100"
|
||||
SINFO:0,4,38,0,""
|
||||
SINFO:0,4,40,0,"stereo"
|
||||
SINFO:0,4,42,5088,"( Lossless conversion )"
|
||||
SINFO:0,5,1,6203,"Untertitel"
|
||||
SINFO:0,5,3,0,"deu"
|
||||
SINFO:0,5,4,0,"German"
|
||||
SINFO:0,5,5,0,"S_HDMV/PGS"
|
||||
SINFO:0,5,6,0,"PGS"
|
||||
SINFO:0,5,7,0,"HDMV PGS Subtitles"
|
||||
SINFO:0,5,22,0,"0"
|
||||
SINFO:0,5,28,0,"eng"
|
||||
SINFO:0,5,29,0,"English"
|
||||
SINFO:0,5,30,0,"PGS German"
|
||||
SINFO:0,5,31,6121,"<b>Spurinformationen</b><br>"
|
||||
SINFO:0,5,33,0,"100"
|
||||
SINFO:0,5,38,0,""
|
||||
SINFO:0,5,42,5088,"( Lossless conversion )"
|
||||
SINFO:0,6,1,6203,"Untertitel"
|
||||
SINFO:0,6,3,0,"deu"
|
||||
SINFO:0,6,4,0,"German"
|
||||
SINFO:0,6,5,0,"S_HDMV/PGS"
|
||||
SINFO:0,6,6,0,"PGS"
|
||||
SINFO:0,6,7,0,"HDMV PGS Subtitles"
|
||||
SINFO:0,6,22,0,"6144"
|
||||
SINFO:0,6,28,0,"eng"
|
||||
SINFO:0,6,29,0,"English"
|
||||
SINFO:0,6,30,0,"PGS German (nur erzwungene)"
|
||||
SINFO:0,6,31,6121,"<b>Spurinformationen</b><br>"
|
||||
SINFO:0,6,33,0,"100"
|
||||
SINFO:0,6,38,0,"d"
|
||||
SINFO:0,6,39,0,"Default"
|
||||
SINFO:0,6,42,5088,"( Lossless conversion )"
|
||||
SINFO:0,7,1,6203,"Untertitel"
|
||||
SINFO:0,7,3,0,"deu"
|
||||
SINFO:0,7,4,0,"German"
|
||||
SINFO:0,7,5,0,"S_HDMV/PGS"
|
||||
SINFO:0,7,6,0,"PGS"
|
||||
SINFO:0,7,7,0,"HDMV PGS Subtitles"
|
||||
SINFO:0,7,22,0,"0"
|
||||
SINFO:0,7,28,0,"eng"
|
||||
SINFO:0,7,29,0,"English"
|
||||
SINFO:0,7,30,0,"PGS German"
|
||||
SINFO:0,7,31,6121,"<b>Spurinformationen</b><br>"
|
||||
SINFO:0,7,33,0,"100"
|
||||
SINFO:0,7,38,0,""
|
||||
SINFO:0,7,42,5088,"( Lossless conversion )"
|
||||
SINFO:0,8,1,6203,"Untertitel"
|
||||
SINFO:0,8,3,0,"deu"
|
||||
SINFO:0,8,4,0,"German"
|
||||
SINFO:0,8,5,0,"S_HDMV/PGS"
|
||||
SINFO:0,8,6,0,"PGS"
|
||||
SINFO:0,8,7,0,"HDMV PGS Subtitles"
|
||||
SINFO:0,8,22,0,"6144"
|
||||
SINFO:0,8,28,0,"eng"
|
||||
SINFO:0,8,29,0,"English"
|
||||
SINFO:0,8,30,0,"PGS German (nur erzwungene)"
|
||||
SINFO:0,8,31,6121,"<b>Spurinformationen</b><br>"
|
||||
SINFO:0,8,33,0,"100"
|
||||
SINFO:0,8,38,0,""
|
||||
SINFO:0,8,42,5088,"( Lossless conversion )"
|
||||
TINFO:1,2,0,"Digimon Adventure: Last Evolution Kizuna"
|
||||
TINFO:1,8,0,"13"
|
||||
TINFO:1,9,0,"1:34:45"
|
||||
TINFO:1,10,0,"16.4 GB"
|
||||
TINFO:1,11,0,"17668571136"
|
||||
TINFO:1,16,0,"00003.mpls"
|
||||
TINFO:1,25,0,"1"
|
||||
TINFO:1,26,0,"7"
|
||||
TINFO:1,27,0,"Digimon Adventure- Last Evolution Kizuna_t01.mkv"
|
||||
TINFO:1,28,0,"eng"
|
||||
TINFO:1,29,0,"English"
|
||||
TINFO:1,30,0,"Digimon Adventure: Last Evolution Kizuna - 13 Kapitel , 16.4 GB"
|
||||
TINFO:1,31,6120,"<b>Titelinformationen</b><br>"
|
||||
TINFO:1,33,0,"0"
|
||||
SINFO:1,0,1,6201,"Video"
|
||||
SINFO:1,0,5,0,"V_MPEG4/ISO/AVC"
|
||||
SINFO:1,0,6,0,"Mpeg4"
|
||||
SINFO:1,0,7,0,"Mpeg4 AVC High@L4.1"
|
||||
SINFO:1,0,19,0,"1920x1080"
|
||||
SINFO:1,0,20,0,"16:9"
|
||||
SINFO:1,0,21,0,"23.976 (24000/1001)"
|
||||
SINFO:1,0,22,0,"0"
|
||||
SINFO:1,0,28,0,"eng"
|
||||
SINFO:1,0,29,0,"English"
|
||||
SINFO:1,0,30,0,"Mpeg4 AVC High@L4.1"
|
||||
SINFO:1,0,31,6121,"<b>Spurinformationen</b><br>"
|
||||
SINFO:1,0,33,0,"0"
|
||||
SINFO:1,0,38,0,""
|
||||
SINFO:1,0,42,5088,"( Lossless conversion )"
|
||||
SINFO:1,1,1,6202,"Audio"
|
||||
SINFO:1,1,2,0,"Surround 5.1"
|
||||
SINFO:1,1,3,0,"deu"
|
||||
SINFO:1,1,4,0,"German"
|
||||
SINFO:1,1,5,0,"A_DTS"
|
||||
SINFO:1,1,6,0,"DTS-HD MA"
|
||||
SINFO:1,1,7,0,"DTS-HD Master Audio"
|
||||
SINFO:1,1,14,0,"6"
|
||||
SINFO:1,1,17,0,"48000"
|
||||
SINFO:1,1,18,0,"16"
|
||||
SINFO:1,1,22,0,"1024"
|
||||
SINFO:1,1,28,0,"eng"
|
||||
SINFO:1,1,29,0,"English"
|
||||
SINFO:1,1,30,0,"DTS-HD MA Surround 5.1 German"
|
||||
SINFO:1,1,31,6121,"<b>Spurinformationen</b><br>"
|
||||
SINFO:1,1,33,0,"100"
|
||||
SINFO:1,1,38,0,""
|
||||
SINFO:1,1,40,0,"5.1(side)"
|
||||
SINFO:1,1,42,5088,"( Lossless conversion )"
|
||||
SINFO:1,2,1,6202,"Audio"
|
||||
SINFO:1,2,2,0,"Surround 5.1"
|
||||
SINFO:1,2,3,0,"deu"
|
||||
SINFO:1,2,4,0,"German"
|
||||
SINFO:1,2,5,0,"A_DTS"
|
||||
SINFO:1,2,6,0,"DTS"
|
||||
SINFO:1,2,7,0,"DTS"
|
||||
SINFO:1,2,13,0,"1.5 Mb/s"
|
||||
SINFO:1,2,14,0,"6"
|
||||
SINFO:1,2,17,0,"48000"
|
||||
SINFO:1,2,22,0,"2304"
|
||||
SINFO:1,2,28,0,"eng"
|
||||
SINFO:1,2,29,0,"English"
|
||||
SINFO:1,2,30,0,"DTS Surround 5.1 German"
|
||||
SINFO:1,2,31,6121,"<b>Spurinformationen</b><br>"
|
||||
SINFO:1,2,33,0,"100"
|
||||
SINFO:1,2,38,0,"d"
|
||||
SINFO:1,2,39,0,"Default"
|
||||
SINFO:1,2,40,0,"5.1(side)"
|
||||
SINFO:1,2,42,5088,"( Lossless conversion )"
|
||||
SINFO:1,3,1,6202,"Audio"
|
||||
SINFO:1,3,2,5091,"Stereo"
|
||||
SINFO:1,3,3,0,"jpn"
|
||||
SINFO:1,3,4,0,"Japanese"
|
||||
SINFO:1,3,5,0,"A_DTS"
|
||||
SINFO:1,3,6,0,"DTS-HD MA"
|
||||
SINFO:1,3,7,0,"DTS-HD Master Audio"
|
||||
SINFO:1,3,14,0,"2"
|
||||
SINFO:1,3,17,0,"48000"
|
||||
SINFO:1,3,18,0,"16"
|
||||
SINFO:1,3,22,0,"1024"
|
||||
SINFO:1,3,28,0,"eng"
|
||||
SINFO:1,3,29,0,"English"
|
||||
SINFO:1,3,30,0,"DTS-HD MA Stereo Japanese"
|
||||
SINFO:1,3,31,6121,"<b>Spurinformationen</b><br>"
|
||||
SINFO:1,3,33,0,"100"
|
||||
SINFO:1,3,38,0,""
|
||||
SINFO:1,3,40,0,"stereo"
|
||||
SINFO:1,3,42,5088,"( Lossless conversion )"
|
||||
SINFO:1,4,1,6202,"Audio"
|
||||
SINFO:1,4,2,5091,"Stereo"
|
||||
SINFO:1,4,3,0,"jpn"
|
||||
SINFO:1,4,4,0,"Japanese"
|
||||
SINFO:1,4,5,0,"A_DTS"
|
||||
SINFO:1,4,6,0,"DTS"
|
||||
SINFO:1,4,7,0,"DTS"
|
||||
SINFO:1,4,13,0,"768 Kb/s"
|
||||
SINFO:1,4,14,0,"2"
|
||||
SINFO:1,4,17,0,"48000"
|
||||
SINFO:1,4,22,0,"2304"
|
||||
SINFO:1,4,28,0,"eng"
|
||||
SINFO:1,4,29,0,"English"
|
||||
SINFO:1,4,30,0,"DTS Stereo Japanese"
|
||||
SINFO:1,4,31,6121,"<b>Spurinformationen</b><br>"
|
||||
SINFO:1,4,33,0,"100"
|
||||
SINFO:1,4,38,0,""
|
||||
SINFO:1,4,40,0,"stereo"
|
||||
SINFO:1,4,42,5088,"( Lossless conversion )"
|
||||
SINFO:1,5,1,6203,"Untertitel"
|
||||
SINFO:1,5,3,0,"deu"
|
||||
SINFO:1,5,4,0,"German"
|
||||
SINFO:1,5,5,0,"S_HDMV/PGS"
|
||||
SINFO:1,5,6,0,"PGS"
|
||||
SINFO:1,5,7,0,"HDMV PGS Subtitles"
|
||||
SINFO:1,5,22,0,"0"
|
||||
SINFO:1,5,28,0,"eng"
|
||||
SINFO:1,5,29,0,"English"
|
||||
SINFO:1,5,30,0,"PGS German"
|
||||
SINFO:1,5,31,6121,"<b>Spurinformationen</b><br>"
|
||||
SINFO:1,5,33,0,"100"
|
||||
SINFO:1,5,38,0,""
|
||||
SINFO:1,5,42,5088,"( Lossless conversion )"
|
||||
SINFO:1,6,1,6203,"Untertitel"
|
||||
SINFO:1,6,3,0,"deu"
|
||||
SINFO:1,6,4,0,"German"
|
||||
SINFO:1,6,5,0,"S_HDMV/PGS"
|
||||
SINFO:1,6,6,0,"PGS"
|
||||
SINFO:1,6,7,0,"HDMV PGS Subtitles"
|
||||
SINFO:1,6,22,0,"6144"
|
||||
SINFO:1,6,28,0,"eng"
|
||||
SINFO:1,6,29,0,"English"
|
||||
SINFO:1,6,30,0,"PGS German (nur erzwungene)"
|
||||
SINFO:1,6,31,6121,"<b>Spurinformationen</b><br>"
|
||||
SINFO:1,6,33,0,"100"
|
||||
SINFO:1,6,38,0,"d"
|
||||
SINFO:1,6,39,0,"Default"
|
||||
SINFO:1,6,42,5088,"( Lossless conversion )"
|
||||
SINFO:1,7,1,6203,"Untertitel"
|
||||
SINFO:1,7,3,0,"deu"
|
||||
SINFO:1,7,4,0,"German"
|
||||
SINFO:1,7,5,0,"S_HDMV/PGS"
|
||||
SINFO:1,7,6,0,"PGS"
|
||||
SINFO:1,7,7,0,"HDMV PGS Subtitles"
|
||||
SINFO:1,7,22,0,"0"
|
||||
SINFO:1,7,28,0,"eng"
|
||||
SINFO:1,7,29,0,"English"
|
||||
SINFO:1,7,30,0,"PGS German"
|
||||
SINFO:1,7,31,6121,"<b>Spurinformationen</b><br>"
|
||||
SINFO:1,7,33,0,"100"
|
||||
SINFO:1,7,38,0,""
|
||||
SINFO:1,7,42,5088,"( Lossless conversion )"
|
||||
SINFO:1,8,1,6203,"Untertitel"
|
||||
SINFO:1,8,3,0,"deu"
|
||||
SINFO:1,8,4,0,"German"
|
||||
SINFO:1,8,5,0,"S_HDMV/PGS"
|
||||
SINFO:1,8,6,0,"PGS"
|
||||
SINFO:1,8,7,0,"HDMV PGS Subtitles"
|
||||
SINFO:1,8,22,0,"6144"
|
||||
SINFO:1,8,28,0,"eng"
|
||||
SINFO:1,8,29,0,"English"
|
||||
SINFO:1,8,30,0,"PGS German (nur erzwungene)"
|
||||
SINFO:1,8,31,6121,"<b>Spurinformationen</b><br>"
|
||||
SINFO:1,8,33,0,"100"
|
||||
SINFO:1,8,38,0,""
|
||||
SINFO:1,8,42,5088,"( Lossless conversion )"
|
||||
@@ -1,12 +0,0 @@
|
||||
// Läuft als utilityProcess — der Kontext, in dem Rippy v5 seine Datenbank
|
||||
// betreiben wird (kern/speicher/db.ts laut KONZEPT-WINDOWS.md § 4.2).
|
||||
try {
|
||||
const s = require('node:sqlite');
|
||||
const db = new s.DatabaseSync(':memory:');
|
||||
db.exec('CREATE TABLE t (a TEXT)');
|
||||
db.prepare('INSERT INTO t VALUES (?)').run('geht');
|
||||
const wert = db.prepare('SELECT a FROM t').get().a;
|
||||
process.parentPort.postMessage({ ok: true, wert: wert, node: process.versions.node });
|
||||
} catch (e) {
|
||||
process.parentPort.postMessage({ ok: false, fehler: e.message });
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
// Beweis: node:sqlite funktioniert im utilityProcess von Electron 44.
|
||||
// Gemessen 30.08.2026 — siehe README.md in diesem Ordner.
|
||||
//
|
||||
// Startet den Kern-Prozess (sqlite-kern.js) genau so, wie Rippy v5 seinen
|
||||
// Kern starten wird (utilityProcess.fork), und wartet auf dessen Antwort.
|
||||
const { app, utilityProcess } = require('electron');
|
||||
const path = require('path');
|
||||
|
||||
app.whenReady().then(() => {
|
||||
const kind = utilityProcess.fork(path.join(__dirname, 'sqlite-kern.js'));
|
||||
kind.on('message', (antwort) => {
|
||||
console.log('KERN-ANTWORT: ' + JSON.stringify(antwort));
|
||||
app.exit(antwort.ok ? 0 : 1);
|
||||
});
|
||||
kind.on('exit', (code) => {
|
||||
console.log('Kern beendet mit ' + code);
|
||||
if (code !== 0) app.exit(1);
|
||||
});
|
||||
setTimeout(() => { console.log('TIMEOUT'); app.exit(2); }, 15000);
|
||||
});
|
||||
|
Before Width: | Height: | Size: 295 KiB |
|
Before Width: | Height: | Size: 239 KiB |
|
Before Width: | Height: | Size: 319 KiB |
|
Before Width: | Height: | Size: 244 KiB |
|
Before Width: | Height: | Size: 282 KiB |
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 224 KiB |
@@ -1507,19 +1507,9 @@ async def storage_targets():
|
||||
"free_gb": frei_gb,
|
||||
})
|
||||
|
||||
# Die Laufwerke — nur nativ. Im Container gäbe es nichts zu holen.
|
||||
if frei_blaettern():
|
||||
from rippy import betrieb
|
||||
bekannt = {z["path"].rstrip("\\/").lower() for z in ziele}
|
||||
for laufwerk in betrieb.windows_laufwerke():
|
||||
if laufwerk.rstrip("\\/").lower() in bekannt:
|
||||
continue
|
||||
ziele.append({
|
||||
"name": "Laufwerk %s" % laufwerk.rstrip("\\/"),
|
||||
"path": laufwerk,
|
||||
"is_mount": False,
|
||||
"free_gb": platz(laufwerk),
|
||||
})
|
||||
# Der Laufwerksbuchstaben-Block stand hier für die Windows-
|
||||
# Standalone-App — sie ist mit v5 ein eigenes Produkt (KONZEPT-
|
||||
# WINDOWS.md Entscheid 7); im Container gab es hier nie etwas.
|
||||
return ziele
|
||||
|
||||
return await asyncio.to_thread(sammle)
|
||||
@@ -2145,7 +2135,7 @@ async def browse(path: str = ""):
|
||||
einem anderen Laufwerk — und genau dort ist Platz für 100 GB Rohdaten.
|
||||
"""
|
||||
if not path.strip():
|
||||
laufwerke = betrieb_laufwerke() if frei_blaettern() else []
|
||||
laufwerke = []
|
||||
if laufwerke:
|
||||
return {"path": "", "parent": None, "dirs": laufwerke, "files": []}
|
||||
path = medien_wurzel()
|
||||
@@ -2203,16 +2193,6 @@ def eltern_von(pfad: str, wurzel: str = None, frei: bool = None) -> Optional[str
|
||||
return "" if oben == pfad else oben
|
||||
|
||||
|
||||
def betrieb_laufwerke() -> list:
|
||||
"""Die Laufwerke als Ordner-Einträge für die oberste Ebene des Browsers."""
|
||||
from rippy import betrieb
|
||||
try:
|
||||
return [{"name": "Laufwerk " + laufwerk.rstrip("\\/"), "path": laufwerk}
|
||||
for laufwerk in betrieb.windows_laufwerke()]
|
||||
except Exception: # noqa: BLE001
|
||||
return []
|
||||
|
||||
|
||||
class MkdirRequest(BaseModel):
|
||||
path: str
|
||||
name: str
|
||||
|
||||
@@ -77,6 +77,3 @@ def test_die_wurzel_kommt_aus_dem_betrieb_und_ist_nie_leer():
|
||||
assert main.medien_wurzel()
|
||||
|
||||
|
||||
def test_laufwerke_ohne_windows_sind_eine_leere_liste():
|
||||
"""Auf dem Linux-Runner der Ampel darf das kein Fehler sein."""
|
||||
assert isinstance(main.betrieb_laufwerke(), list)
|
||||
|
||||
@@ -1114,66 +1114,14 @@ def rip_video(device_path: str, disc_id: str, disc_type: str = "dvd", progress_c
|
||||
log_cb=log_cb)
|
||||
|
||||
|
||||
def _rip_cd_windows(device_path: str, disc_id: str, progress_cb=None,
|
||||
output_dir: str = None, auswerfen: bool = True) -> dict:
|
||||
"""Audio-CD unter Windows: selbst lesen, mit FLAC kodieren.
|
||||
|
||||
Die Rechnung steht in `rippy.rip.audio_cd` (ohne Laufwerk pruefbar), das
|
||||
Lesen im Windows-Treiber (der einzige Ort mit Win32-Aufrufen).
|
||||
"""
|
||||
from rippy.drives import windows as win
|
||||
from rippy.rip import audio_cd
|
||||
|
||||
class Leser:
|
||||
toc = staticmethod(win.audio_toc)
|
||||
|
||||
@staticmethod
|
||||
def spur(geraet, start, sektoren, schreiben, fortschritt=None):
|
||||
return win.audio_spur_lesen(geraet, start, sektoren, schreiben,
|
||||
fortschritt)
|
||||
|
||||
# Erst die Disc bei MusicBrainz nachschlagen — daraus kommen sowohl der
|
||||
# Ordnername als auch die Titel. Ohne Treffer heissen die Dateien
|
||||
# `Track 01.flac`; das ist keine Ausrede, sondern die Wahrheit ueber eine
|
||||
# Disc, die niemand kennt.
|
||||
from rippy.rip import musicbrainz_cd
|
||||
|
||||
try:
|
||||
spuren = win.audio_toc(device_path)
|
||||
antwort = musicbrainz_cd.abfragen(
|
||||
musicbrainz_cd.kennung_aus_spuren(spuren))
|
||||
except Exception: # noqa: BLE001
|
||||
antwort = None
|
||||
tags = musicbrainz_cd.tags_je_spur(antwort)
|
||||
ordner = musicbrainz_cd.album_ordner(antwort)
|
||||
|
||||
ziel = output_dir or os.path.join(RIP_OUTPUT_DIR, "cd", ordner or disc_id)
|
||||
ergebnis = audio_cd.rippen(device_path, ziel, Leser(),
|
||||
werkzeuge.finden("flac"),
|
||||
fortschritt=progress_cb, tags_je_spur=tags)
|
||||
if auswerfen and ergebnis.get("status") == "success":
|
||||
wirf_disc_aus(device_path)
|
||||
return ergebnis
|
||||
|
||||
|
||||
def rip_cd(device_path: str, disc_id: str, progress_cb=None, output_dir: str = None,
|
||||
auswerfen: bool = True) -> dict:
|
||||
"""Rippt eine CD mit abcde (FLAC). `auswerfen` = Einstellung „Automatischer
|
||||
Auswurf" (abcde macht das selbst per -x)."""
|
||||
# ── Windows geht einen eigenen Weg (Befund 29.08.2026) ─────────────
|
||||
#
|
||||
# `abcde` und `cdparanoia` sind Linux-Werkzeuge; es gibt sie fuer Windows
|
||||
# nicht, und ein Nachbau ihrer Shell-Logik waere ein zweites Projekt.
|
||||
# Windows bietet stattdessen zwei Steuercodes an, mit denen sich eine
|
||||
# Audio-CD direkt lesen laesst — `rippy.rip.audio_cd`, kodiert wird mit
|
||||
# dem FLAC-Encoder aus dem Werkzeug-Katalog.
|
||||
#
|
||||
# Das war die letzte offene Luecke des Windows-Betriebs; sie stand seit
|
||||
# Wochen so im SAVEPOINT.
|
||||
if os.name == "nt":
|
||||
return _rip_cd_windows(device_path, disc_id, progress_cb, output_dir,
|
||||
auswerfen)
|
||||
Auswurf" (abcde macht das selbst per -x).
|
||||
|
||||
Der Windows-Weg (Roh-Sektoren + FLAC) ist mit der Standalone-App nach
|
||||
Rippy v5 gewandert (KONZEPT-WINDOWS.md Entscheid 7, dort § 6.3) —
|
||||
diese Fassung rippt CDs nur noch im Container, mit abcde."""
|
||||
if not check_abcde_installed():
|
||||
return {
|
||||
"status": "error",
|
||||
|
||||
@@ -1,254 +0,0 @@
|
||||
"""Baut RippySetup.exe — eine Datei, die installiert, laeuft und sich entfernt.
|
||||
|
||||
## Aufruf
|
||||
|
||||
python packaging/windows/build.py [--ausgabe ORDNER] [--version 2.0.0]
|
||||
|
||||
Voraussetzungen (in der Bau-Umgebung, nicht beim Nutzer):
|
||||
* Python 3.12 mit den API-Abhaengigkeiten (docker/api/requirements.txt)
|
||||
* pyinstaller, pystray, pillow
|
||||
* Das UI muss gebaut sein: cd docker/ui && npm run build
|
||||
|
||||
## Warum die Versions-Ressource nicht optional ist
|
||||
|
||||
Ohne sie steht im Taskmanager unter „Beschreibung" nichts, und in den
|
||||
Datei-Eigenschaften fehlt jede Angabe zu Herausgeber und Version. Eine EXE
|
||||
ohne diese Angaben sieht aus wie etwas, das man nicht ausfuehren sollte —
|
||||
und Windows SmartScreen sieht das genauso.
|
||||
|
||||
Der Commander hat ausdruecklich einen Eintrag im Taskmanager gewuenscht.
|
||||
Der Prozessname kommt vom Dateinamen (`Rippy.exe`), die BESCHREIBUNG
|
||||
daneben aus dieser Ressource.
|
||||
|
||||
## Was mit hinein muss
|
||||
|
||||
docker/api/ die API-Module (main.py und Nachbarn)
|
||||
docker/ui/dist/ die gebaute Weboberflaeche
|
||||
src/rippy/ der Kern (kommt ueber den Import automatisch mit)
|
||||
rippy.ico Symbol fuer Tray, Verknuepfung und Programmliste
|
||||
|
||||
`docker/api` liegt im Paket unter `api/`, `docker/ui/dist` unter `ui/` —
|
||||
genau dort sucht `rippy/daemon.py` sie, wenn `sys._MEIPASS` gesetzt ist.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
HIER = os.path.dirname(os.path.abspath(__file__))
|
||||
REPO = os.path.dirname(os.path.dirname(HIER))
|
||||
|
||||
VERSION_VORLAGE = """
|
||||
VSVersionInfo(
|
||||
ffi=FixedFileInfo(
|
||||
filevers=({v0}, {v1}, {v2}, 0),
|
||||
prodvers=({v0}, {v1}, {v2}, 0),
|
||||
mask=0x3f, flags=0x0, OS=0x40004, fileType=0x1, subtype=0x0,
|
||||
date=(0, 0)
|
||||
),
|
||||
kids=[
|
||||
StringFileInfo([
|
||||
StringTable(u'040704b0', [
|
||||
StringStruct(u'CompanyName', u'Rippy'),
|
||||
StringStruct(u'FileDescription', u'Rippy — automatisches Ripping'),
|
||||
StringStruct(u'FileVersion', u'{version}'),
|
||||
StringStruct(u'InternalName', u'Rippy'),
|
||||
StringStruct(u'OriginalFilename', u'Rippy.exe'),
|
||||
StringStruct(u'ProductName', u'Rippy'),
|
||||
StringStruct(u'ProductVersion', u'{version}')])
|
||||
]),
|
||||
# 0x0407 = Deutsch, 0x04b0 = Unicode. Ohne den passenden Eintrag zeigt
|
||||
# Windows die Angaben je nach Systemsprache gar nicht an.
|
||||
VarFileInfo([VarStruct(u'Translation', [1031, 1200])])
|
||||
]
|
||||
)
|
||||
"""
|
||||
|
||||
|
||||
def versionsdatei(version: str, ordner: str) -> str:
|
||||
teile = (version.split("-")[0].split(".") + ["0", "0", "0"])[:3]
|
||||
pfad = os.path.join(ordner, "version_info.txt")
|
||||
with open(pfad, "w", encoding="utf-8") as f:
|
||||
f.write(VERSION_VORLAGE.format(
|
||||
version=version, v0=int(teile[0]), v1=int(teile[1]), v2=int(teile[2])))
|
||||
return pfad
|
||||
|
||||
|
||||
def pruefen() -> list:
|
||||
"""Alles da? Erst pruefen, dann bauen — wie install.sh es macht.
|
||||
|
||||
Ein Bau, der auf halber Strecke abbricht, hinterlaesst eine EXE, die
|
||||
aussieht wie fertig und es nicht ist.
|
||||
"""
|
||||
maengel = []
|
||||
if sys.platform != "win32":
|
||||
maengel.append("Eine Windows-.exe laesst sich nur unter Windows bauen.")
|
||||
if not os.path.isfile(os.path.join(REPO, "docker", "api", "main.py")):
|
||||
maengel.append("docker/api/main.py fehlt.")
|
||||
if not os.path.isfile(os.path.join(REPO, "docker", "worker", "ablauf.py")):
|
||||
maengel.append("docker/worker/ablauf.py fehlt — ohne den Ablauf kann "
|
||||
"die App nicht rippen.")
|
||||
if not os.path.isdir(os.path.join(REPO, "docker", "ui", "dist")):
|
||||
maengel.append(
|
||||
"docker/ui/dist fehlt — die Oberflaeche ist nicht gebaut. "
|
||||
"Abhilfe: cd docker/ui && npm ci && npm run build")
|
||||
icon = os.path.join(REPO, "deploy", "worker-windows", "rippy.ico")
|
||||
if not os.path.isfile(icon):
|
||||
maengel.append("deploy/worker-windows/rippy.ico fehlt.")
|
||||
else:
|
||||
# Nicht nur DA, sondern VOLLSTAENDIG. Am 28.08.2026 steckte genau ein
|
||||
# Bild darin (256x256), und Windows zeigte auf Desktop, im Startmenue
|
||||
# und in der Taskleiste ein leeres Blatt. Eine Datei, die es gibt, ist
|
||||
# noch keine Datei, die funktioniert.
|
||||
import sys as _sys
|
||||
_sys.path.insert(0, HIER)
|
||||
from icon import fehlende_pflichtgroessen
|
||||
|
||||
fehlt = fehlende_pflichtgroessen(icon)
|
||||
if fehlt:
|
||||
maengel.append(
|
||||
"rippy.ico fehlen die Groessen %s — Windows zeigt dann ein "
|
||||
"leeres Blatt. Abhilfe: python packaging/windows/icon.py"
|
||||
% (fehlt,))
|
||||
try:
|
||||
import PyInstaller # noqa: F401
|
||||
except ImportError:
|
||||
maengel.append("pyinstaller fehlt (pip install pyinstaller).")
|
||||
for modul, paket, wofuer in (
|
||||
("pystray", "pystray", "kein Tray-Symbol"),
|
||||
("PIL", "pillow", "kein Tray-Symbol"),
|
||||
("webview", "pywebview", "kein eigenes Fenster, nur ein Browser-Tab"),
|
||||
("clr", "pythonnet", "kein eigenes Fenster (WebView2 laeuft ueber .NET)"),
|
||||
):
|
||||
try:
|
||||
__import__(modul)
|
||||
except ImportError:
|
||||
maengel.append(
|
||||
f"{modul} fehlt (pip install {paket}) — ohne das {wofuer}.")
|
||||
return maengel
|
||||
|
||||
|
||||
def bauen(ausgabe: str, version: str) -> str:
|
||||
arbeit = os.path.join(ausgabe, "_bau")
|
||||
os.makedirs(arbeit, exist_ok=True)
|
||||
|
||||
trenner = ";" # Windows-Trennzeichen fuer --add-data
|
||||
|
||||
# HandBrakeCLI ins Paket holen. Ohne das kann Rippy auf einem Rechner
|
||||
# ohne Internet nicht komprimieren -- und der Commander war da deutlich:
|
||||
# "Ohne das ist das tool NICHT einsatzfaehig." MakeMKV geht diesen Weg
|
||||
# NICHT mit (proprietaer, keine Weitergabe erlaubt); es wird beim
|
||||
# Einrichten vom Hersteller geladen. Siehe packaging/windows/werkzeuge.py.
|
||||
import sys as _sys
|
||||
_sys.path.insert(0, HIER)
|
||||
from werkzeuge import paket_ordner_bauen
|
||||
|
||||
werkzeug_ordner = paket_ordner_bauen(ausgabe)
|
||||
if not werkzeug_ordner:
|
||||
print(" ! HandBrakeCLI ist NICHT im Paket. Rippy holt es dann beim")
|
||||
print(" Einrichten selbst -- das braucht aber eine Verbindung.")
|
||||
|
||||
befehl = [
|
||||
sys.executable, "-m", "PyInstaller",
|
||||
"--noconfirm", "--clean",
|
||||
"--onefile",
|
||||
# KEINE Konsole. Wunsch des Commanders am 28.08.2026: "Unterbinde
|
||||
# das CMD Fenster was sich beim Oeffnen mitoeffnet." Ein Verstecken
|
||||
# zur Laufzeit reicht nicht -- eine Onefile-EXE laeuft als ZWEI
|
||||
# Prozesse (die Pruefung "haengt genau einer an der Konsole" ist damit
|
||||
# nie wahr), und selbst wenn, blitzte das Fenster ein bis zwei
|
||||
# Sekunden auf, bis Python so weit ist.
|
||||
#
|
||||
# Was dafuer noetig ist, steht in packaging/windows/einstieg.py:
|
||||
# ohne Konsole ist sys.stdout None, und uvicorn wuerde daran sterben.
|
||||
"--windowed",
|
||||
"--name", "RippySetup",
|
||||
"--icon", os.path.join(REPO, "deploy", "worker-windows", "rippy.ico"),
|
||||
"--version-file", versionsdatei(version, arbeit),
|
||||
"--distpath", ausgabe,
|
||||
"--workpath", os.path.join(arbeit, "work"),
|
||||
"--specpath", arbeit,
|
||||
"--paths", os.path.join(REPO, "src"),
|
||||
"--paths", os.path.join(REPO, "docker", "api"),
|
||||
"--paths", os.path.join(REPO, "docker", "worker"),
|
||||
"--add-data", os.path.join(REPO, "docker", "api") + trenner + "api",
|
||||
# Die Worker-Module — ohne sie kann Rippy anzeigen, aber nicht rippen.
|
||||
# ablauf.py, ripping.py, medien.py, caps.py und Nachbarn.
|
||||
"--add-data", os.path.join(REPO, "docker", "worker") + trenner + "worker",
|
||||
"--add-data", os.path.join(REPO, "docker", "ui", "dist") + trenner + "ui",
|
||||
"--add-data", os.path.join(REPO, "deploy", "worker-windows", "rippy.ico") + trenner + ".",
|
||||
# Was PyInstaller nicht von allein findet: dynamisch importierte Module.
|
||||
"--hidden-import", "uvicorn.logging",
|
||||
"--hidden-import", "uvicorn.loops.auto",
|
||||
"--hidden-import", "uvicorn.protocols.http.auto",
|
||||
"--hidden-import", "uvicorn.protocols.websockets.auto",
|
||||
"--hidden-import", "uvicorn.lifespan.on",
|
||||
"--hidden-import", "rippy.drives.windows",
|
||||
"--hidden-import", "pystray._win32",
|
||||
# pywebview waehlt seine Plattform zur LAUFZEIT aus (importlib), also
|
||||
# sieht PyInstaller den Import nicht. Ohne diese drei geht das Fenster
|
||||
# nicht auf und Rippy faellt still auf den Browser zurueck -- also
|
||||
# genau dorthin, wovon wir gerade weg wollten.
|
||||
"--hidden-import", "webview",
|
||||
"--hidden-import", "webview.platforms.winforms",
|
||||
"--hidden-import", "webview.platforms.edgechromium",
|
||||
# Nicht mitnehmen: Der Windows-Treiber braucht kein psycopg2/celery,
|
||||
# und beides zieht Ballast nach.
|
||||
"--exclude-module", "psycopg2",
|
||||
"--exclude-module", "tkinter",
|
||||
os.path.join(REPO, "packaging", "windows", "einstieg.py"),
|
||||
]
|
||||
if werkzeug_ordner:
|
||||
# Vor dem Einstiegspunkt einfuegen -- PyInstaller will das Skript zuletzt.
|
||||
befehl[-1:-1] = ["--add-data", werkzeug_ordner + trenner + "tools"]
|
||||
|
||||
print("PyInstaller wird aufgerufen …\n")
|
||||
ergebnis = subprocess.run(befehl, cwd=REPO)
|
||||
if ergebnis.returncode != 0:
|
||||
raise SystemExit("PyInstaller ist gescheitert (Code %d)." % ergebnis.returncode)
|
||||
|
||||
exe = os.path.join(ausgabe, "RippySetup.exe")
|
||||
if not os.path.isfile(exe):
|
||||
raise SystemExit("PyInstaller meldete Erfolg, aber %s gibt es nicht." % exe)
|
||||
return exe
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
p = argparse.ArgumentParser(description="Baut RippySetup.exe")
|
||||
p.add_argument("--ausgabe", default=os.path.join(REPO, "dist", "windows"))
|
||||
p.add_argument("--version", default=os.environ.get("RIPPY_VERSION", "2.0.0"))
|
||||
p.add_argument("--nur-pruefen", action="store_true")
|
||||
args = p.parse_args(argv)
|
||||
|
||||
print("Rippy — Windows-Bau")
|
||||
print(" Repo %s" % REPO)
|
||||
print(" Ausgabe %s" % args.ausgabe)
|
||||
print(" Version %s" % args.version)
|
||||
print()
|
||||
|
||||
maengel = pruefen()
|
||||
if maengel:
|
||||
print("Der Bau kann so nicht laufen:")
|
||||
for m in maengel:
|
||||
print(" X %s" % m)
|
||||
return 1
|
||||
print("Alle Voraussetzungen erfuellt.")
|
||||
if args.nur_pruefen:
|
||||
return 0
|
||||
|
||||
os.makedirs(args.ausgabe, exist_ok=True)
|
||||
exe = bauen(args.ausgabe, args.version)
|
||||
|
||||
groesse = os.path.getsize(exe) / 1024**2
|
||||
print()
|
||||
print("Fertig: %s (%.1f MB)" % (exe, groesse))
|
||||
|
||||
arbeit = os.path.join(args.ausgabe, "_bau")
|
||||
shutil.rmtree(arbeit, ignore_errors=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,61 +0,0 @@
|
||||
"""Einstiegspunkt der gebuendelten RippySetup.exe.
|
||||
|
||||
Bewusst winzig: PyInstaller braucht EINE Datei als Startpunkt, und alles
|
||||
Weitere gehoert ins Paket `rippy`, wo es getestet werden kann. Was hier
|
||||
steht, laesst sich nicht testen — also steht hier so wenig wie moeglich.
|
||||
|
||||
## Warum hier trotzdem etwas zur Ausgabe steht
|
||||
|
||||
Die EXE ist als Fenster-Programm gebaut (`--windowed`), damit beim Start
|
||||
KEINE Konsole aufgeht — ausdruecklicher Wunsch des Commanders am 28.08.2026:
|
||||
„Unterbinde das CMD Fenster was sich beim Oeffnen mitoeffnet."
|
||||
|
||||
Der Preis: `sys.stdout` ist dann `None`. Jede Bibliothek, die auf `stderr`
|
||||
schreibt — uvicorn tut das —, wuerde mit
|
||||
`AttributeError: 'NoneType' object has no attribute 'write'` sterben, und
|
||||
zwar mitten im Start, ohne dass irgendwo etwas stuende.
|
||||
|
||||
Deshalb ganz zu Anfang, VOR allen anderen Importen aus `rippy`:
|
||||
|
||||
1. In einem Terminal gestartet? -> dort hineinschreiben
|
||||
2. Sonst -> in %LOCALAPPDATA%\\Rippy\\rippy.log
|
||||
"""
|
||||
|
||||
import multiprocessing
|
||||
import sys
|
||||
|
||||
|
||||
def _ausgabe_richten() -> None:
|
||||
"""Dafuer sorgen, dass es eine Ausgabe GIBT — Konsole oder Datei."""
|
||||
from rippy.platform import winlauf
|
||||
|
||||
if not winlauf.ohne_konsole():
|
||||
return # aus dem Quellcode gestartet
|
||||
if winlauf.an_elternkonsole_haengen():
|
||||
# Ein Terminal ist da. Die Stroeme neu oeffnen, sonst bleibt
|
||||
# sys.stdout trotz Konsole None.
|
||||
try:
|
||||
sys.stdout = open("CONOUT$", "w", encoding="utf-8", errors="replace",
|
||||
buffering=1)
|
||||
sys.stderr = sys.stdout
|
||||
return
|
||||
except OSError:
|
||||
pass
|
||||
winlauf.ausgabe_umleiten()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
# Pflicht in gebuendelten Programmen: Ohne diesen Aufruf startet ein
|
||||
# Kindprozess unter Windows die ganze EXE erneut — eine Startschleife,
|
||||
# die sich als "das Programm oeffnet sich immer wieder" zeigt.
|
||||
multiprocessing.freeze_support()
|
||||
|
||||
_ausgabe_richten()
|
||||
|
||||
from rippy.windows_app import main as rippy_main
|
||||
|
||||
return rippy_main()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,142 +0,0 @@
|
||||
"""Ein Windows-Symbol, das Windows auch wirklich anzeigt.
|
||||
|
||||
## Der Befund (28.08.2026)
|
||||
|
||||
Commander: *„Startmenü, Desktop und Taskleisten-Icon ohne icon file (Weißes
|
||||
Leeres Blatt)."*
|
||||
|
||||
Nachgesehen, was in `deploy/worker-windows/rippy.ico` steckte:
|
||||
|
||||
Bilder = 1
|
||||
256x256 32 bit 5657 Bytes PNG
|
||||
|
||||
**Genau ein Bild, und zwar das größte.** Windows holt sich für jede Stelle
|
||||
die passende Größe aus der Datei:
|
||||
|
||||
Taskleiste, Verknüpfung klein 16x16
|
||||
Desktop, Startmenü 32x32 / 48x48
|
||||
Große Kacheln, Alt-Tab 256x256
|
||||
|
||||
Fehlt die passende Größe, skaliert die Shell nicht immer selbst — sie zeigt
|
||||
das leere Blatt. Bei einer Datei, die NUR ein PNG-komprimiertes 256er
|
||||
enthält, passiert das zuverlässig.
|
||||
|
||||
## Was diese Datei tut
|
||||
|
||||
Sie baut aus dem vorhandenen Symbol eine `.ico` mit allen gebrauchten Größen.
|
||||
Die kleinen Größen werden mit `LANCZOS` verkleinert — bei 16x16 ist das der
|
||||
Unterschied zwischen einem erkennbaren Zeichen und Pixelbrei.
|
||||
|
||||
Aufruf (macht der Bau selbst, siehe `build.py`):
|
||||
|
||||
python packaging/windows/icon.py [--quelle DATEI] [--ziel DATEI]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
|
||||
# Was Windows an den einzelnen Stellen holt. 24 und 64 sind fuer hohe
|
||||
# Bildschirmaufloesungen (150 %/200 % Skalierung) — ohne sie skaliert die
|
||||
# Shell aus 32 bzw. 48 hoch, und das sieht ausgefranst aus.
|
||||
GROESSEN = (16, 20, 24, 32, 40, 48, 64, 128, 256)
|
||||
|
||||
HIER = os.path.dirname(os.path.abspath(__file__))
|
||||
REPO = os.path.dirname(os.path.dirname(HIER))
|
||||
QUELLE = os.path.join(REPO, "deploy", "worker-windows", "rippy.ico")
|
||||
|
||||
|
||||
def groessen_in(pfad: str) -> list:
|
||||
"""Welche Bildgroessen stecken in einer .ico? (ohne Pillow, reines Lesen)
|
||||
|
||||
Fuer die Pruefung im Bau: Eine Datei mit nur EINER Groesse ist genau der
|
||||
Fehler, den es hier zu verhindern gilt.
|
||||
"""
|
||||
import struct
|
||||
|
||||
with open(pfad, "rb") as f:
|
||||
roh = f.read()
|
||||
if len(roh) < 6:
|
||||
return []
|
||||
reserviert, typ, anzahl = struct.unpack("<HHH", roh[:6])
|
||||
if reserviert != 0 or typ != 1:
|
||||
return []
|
||||
gefunden = []
|
||||
for i in range(anzahl):
|
||||
block = roh[6 + i * 16: 22 + i * 16]
|
||||
if len(block) < 16:
|
||||
break
|
||||
breite = block[0] or 256 # 0 heisst 256 (passt nicht in ein Byte)
|
||||
gefunden.append(breite)
|
||||
return sorted(gefunden)
|
||||
|
||||
|
||||
# Ohne diese Groessen sieht der Nutzer an der jeweiligen Stelle ein leeres
|
||||
# Blatt. Gehoert hierher und nicht in den Test, damit der BAU sie kennt.
|
||||
PFLICHTGROESSEN = (16, 32, 48, 256)
|
||||
|
||||
|
||||
def fehlende_pflichtgroessen(pfad: str) -> tuple:
|
||||
"""Welche Pflichtgroessen fehlen? Leer heisst: alles da."""
|
||||
vorhanden = set(groessen_in(pfad))
|
||||
return tuple(k for k in PFLICHTGROESSEN if k not in vorhanden)
|
||||
|
||||
|
||||
def bauen(quelle: str = None, ziel: str = None) -> str:
|
||||
"""Baut die vollstaendige .ico. Gibt den Zielpfad zurueck.
|
||||
|
||||
Wirft, wenn es nicht geht — ein Symbol, das still fehlschlaegt, fuehrt
|
||||
genau zu dem leeren Blatt, das hier abgestellt werden soll.
|
||||
"""
|
||||
from PIL import Image
|
||||
|
||||
quelle = quelle or QUELLE
|
||||
ziel = ziel or quelle
|
||||
|
||||
bild = Image.open(quelle)
|
||||
bild = bild.convert("RGBA")
|
||||
|
||||
# Aus dem groessten vorhandenen Bild verkleinern, nicht aus einem
|
||||
# zufaellig ausgewaehlten: Pillow oeffnet bei einer .ico sonst die erste
|
||||
# Groesse, und aus 16x16 laesst sich kein 256er machen.
|
||||
if hasattr(bild, "size") and bild.size[0] < 256:
|
||||
try:
|
||||
bild = Image.open(quelle)
|
||||
bild.size = (256, 256) # Pillow: gewuenschte Groesse waehlen
|
||||
bild = bild.convert("RGBA")
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
ebenen = []
|
||||
for kante in GROESSEN:
|
||||
if kante >= bild.size[0]:
|
||||
ebenen.append(bild.copy().resize((kante, kante), Image.LANCZOS))
|
||||
else:
|
||||
# LANCZOS auch beim Verkleinern: bei 16x16 ist das der Unterschied
|
||||
# zwischen einem erkennbaren Zeichen und Pixelbrei.
|
||||
ebenen.append(bild.resize((kante, kante), Image.LANCZOS))
|
||||
|
||||
ebenen[-1].save(ziel, format="ICO",
|
||||
sizes=[(k, k) for k in GROESSEN])
|
||||
return ziel
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
p = argparse.ArgumentParser(description="Baut eine vollstaendige rippy.ico")
|
||||
p.add_argument("--quelle", default=QUELLE)
|
||||
p.add_argument("--ziel", default=None)
|
||||
p.add_argument("--nur-pruefen", action="store_true")
|
||||
args = p.parse_args(argv)
|
||||
|
||||
vorher = groessen_in(args.quelle)
|
||||
print("Vorher: %s" % (vorher or "keine lesbare .ico"))
|
||||
if args.nur_pruefen:
|
||||
return 0 if len(vorher) > 1 else 1
|
||||
|
||||
ziel = bauen(args.quelle, args.ziel)
|
||||
print("Nachher: %s" % groessen_in(ziel))
|
||||
print("Geschrieben: %s (%d Bytes)" % (ziel, os.path.getsize(ziel)))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,25 +0,0 @@
|
||||
# Was die BAU-Umgebung braucht, zusaetzlich zu docker/api/requirements.txt.
|
||||
# Der Nutzer installiert davon nichts — alles landet in der RippySetup.exe.
|
||||
#
|
||||
# py -3.12 -m venv venv
|
||||
# venv\Scripts\pip install -r docker/api/requirements.txt
|
||||
# venv\Scripts\pip install -r packaging/windows/requirements-bau.txt
|
||||
# venv\Scripts\python packaging/windows/build.py
|
||||
#
|
||||
# Warum die Fassungen festgenagelt sind: Ein Bau, der gestern ging und heute
|
||||
# nicht mehr, kostet mehr Zeit als jede Aktualisierung einbringt. Ein Wechsel
|
||||
# ist eine Entscheidung, kein Zufall.
|
||||
|
||||
pyinstaller==6.22.2
|
||||
|
||||
# Tray-Symbol — ohne das laeuft Rippy zwar, ist aber unsichtbar und nicht
|
||||
# zu beenden, ausser ueber den Taskmanager.
|
||||
pystray==0.19.5
|
||||
pillow==12.3.0
|
||||
|
||||
# Das eigene Fenster (WebView2). Gemessen am 28.08.2026: 8,0 MB in der
|
||||
# Bau-Umgebung. Electron waere an derselben Stelle 150-210 MB gewesen und
|
||||
# haette eine zweite komplette Laufzeitumgebung neben Python mitgebracht.
|
||||
# Siehe src/rippy/fenster.py fuer die vollstaendige Begruendung.
|
||||
pywebview==6.2.1
|
||||
pythonnet==3.1.0
|
||||
@@ -1,76 +0,0 @@
|
||||
"""Das mitgelieferte Symbol muss alle Groessen haben, die Windows holt.
|
||||
|
||||
## Der Befund (28.08.2026)
|
||||
|
||||
Commander: *„Startmenue, Desktop und Taskleisten-Icon ohne icon file (Weisses
|
||||
Leeres Blatt)."*
|
||||
|
||||
In `deploy/worker-windows/rippy.ico` steckte GENAU EIN Bild: 256x256 als PNG.
|
||||
Windows holt sich fuer jede Stelle die passende Groesse:
|
||||
|
||||
Taskleiste, kleine Verknuepfung 16x16
|
||||
Desktop, Startmenue 32x32 / 48x48
|
||||
Alt-Tab, grosse Kacheln 256x256
|
||||
|
||||
Fehlt sie, skaliert die Shell nicht zuverlaessig selbst — sie zeigt das leere
|
||||
Blatt. Bei einer Datei mit nur einem PNG-komprimierten 256er passiert das
|
||||
regelmaessig.
|
||||
|
||||
Diese Pruefung braucht kein Pillow und kein Windows: Sie liest den
|
||||
ICO-Verzeichniskopf direkt. Damit laeuft sie auch auf der Ampel.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
HIER = os.path.dirname(os.path.abspath(__file__))
|
||||
REPO = os.path.dirname(os.path.dirname(HIER))
|
||||
ICON = os.path.join(REPO, "deploy", "worker-windows", "rippy.ico")
|
||||
|
||||
|
||||
def _icon_modul():
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, HIER)
|
||||
import icon
|
||||
|
||||
return icon
|
||||
|
||||
|
||||
def _groessen():
|
||||
return _icon_modul().groessen_in(ICON)
|
||||
|
||||
|
||||
# Aus icon.py geholt, nicht abgeschrieben: Sonst koennte der Bau eine Groesse
|
||||
# fordern, die dieser Test nicht prueft — oder umgekehrt.
|
||||
PFLICHTGROESSEN = _icon_modul().PFLICHTGROESSEN
|
||||
|
||||
|
||||
def test_icon_ist_da():
|
||||
assert os.path.isfile(ICON), ICON
|
||||
|
||||
|
||||
def test_icon_ist_eine_gueltige_ico():
|
||||
assert _groessen(), "Kein lesbarer ICO-Verzeichniskopf"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kante", PFLICHTGROESSEN)
|
||||
def test_pflichtgroesse_ist_enthalten(kante):
|
||||
"""DER Fehler vom 28.08.2026: nur 256x256 drin, alles andere leer."""
|
||||
vorhanden = _groessen()
|
||||
assert kante in vorhanden, (
|
||||
"%dx%d fehlt in rippy.ico (drin: %s). Windows zeigt an dieser Stelle "
|
||||
"ein leeres Blatt. Abhilfe: python packaging/windows/icon.py"
|
||||
% (kante, kante, vorhanden)
|
||||
)
|
||||
|
||||
|
||||
def test_eine_einzige_groesse_reicht_nie():
|
||||
"""Der Zustand, in dem die Datei war. Als eigener Test, damit die
|
||||
Fehlermeldung die Ursache nennt und nicht nur eine fehlende Zahl."""
|
||||
assert len(_groessen()) > 1, (
|
||||
"rippy.ico enthaelt nur EINE Groesse — genau der Zustand, der zu den "
|
||||
"leeren Symbolen auf Desktop, im Startmenue und in der Taskleiste "
|
||||
"gefuehrt hat."
|
||||
)
|
||||
@@ -1,135 +0,0 @@
|
||||
"""HandBrakeCLI für den Bau beschaffen — damit Rippy ohne Netz arbeiten kann.
|
||||
|
||||
## Warum HandBrake mitgeliefert wird und MakeMKV nicht
|
||||
|
||||
Commander am 28.08.2026: *„handbrake und makemkv MÜSSEN mitgeliefert werden
|
||||
oder während des Setups separat installiert werden! Ohne das ist das tool
|
||||
NICHT einsatzfähig."*
|
||||
|
||||
Er hat recht. Die beiden gehen aber unterschiedliche Wege, und der Grund ist
|
||||
die Lizenz, nicht die Bequemlichkeit:
|
||||
|
||||
* **HandBrake ist GPL-2.** Weitergabe ist ausdrücklich erlaubt, solange
|
||||
Lizenztext und Quellverweis dabei sind. Also kommt es mit ins Paket, und
|
||||
Rippy kann auch auf einem Rechner ohne Internet komprimieren.
|
||||
* **MakeMKV ist proprietär.** Die Lizenz erlaubt keine Weitergabe durch
|
||||
Dritte. Rippy lädt die offizielle Datei beim Einrichten vom Hersteller und
|
||||
startet dessen Installer — der Nutzer bezieht sie also weiterhin von
|
||||
MakeMKV, Rippy nimmt ihm nur die Handgriffe ab. Dieselbe Black-Box-Trennung
|
||||
wie in `KONZEPT.md` § 6 und `KONZEPT-V2.md` § 4.1.
|
||||
|
||||
## Warum die Datei nicht im Repo liegt
|
||||
|
||||
24,5 MB Binärdatei in git wären bei jedem Klon dabei und bei jedem
|
||||
HandBrake-Update ein zweites Mal. Der Bau holt sie stattdessen nach
|
||||
`dist/windows/vendor/` (nicht versioniert) und nimmt sie von dort ins Paket —
|
||||
dasselbe Muster wie der `vendor/`-Ordner für MakeMKV auf der VM.
|
||||
|
||||
Beim zweiten Bau liegt sie schon da und wird nicht erneut geladen.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
HIER = os.path.dirname(os.path.abspath(__file__))
|
||||
REPO = os.path.dirname(os.path.dirname(HIER))
|
||||
|
||||
# Der Lizenztext gehört ins Paket, nicht nur in einen Kommentar. GPL-2
|
||||
# verlangt ihn bei jeder Weitergabe.
|
||||
GPL_HINWEIS = """HandBrakeCLI — mitgeliefert unter der GNU General Public License v2
|
||||
|
||||
Rippy liefert HandBrakeCLI unveraendert mit. HandBrake ist freie Software und
|
||||
steht unter der GPL-2. Das bedeutet fuer dich:
|
||||
|
||||
* Du darfst es weitergeben und veraendern.
|
||||
* Der Quelltext ist oeffentlich verfuegbar unter:
|
||||
https://github.com/HandBrake/HandBrake
|
||||
* Der vollstaendige Lizenztext steht dort in COPYING, ausserdem unter:
|
||||
https://www.gnu.org/licenses/old-licenses/gpl-2.0.html
|
||||
|
||||
Mitgelieferte Fassung: {version}
|
||||
Bezogen von: {url}
|
||||
|
||||
MakeMKV ist NICHT Teil dieser Lieferung. Es ist proprietaer und wird beim
|
||||
Einrichten von makemkv.com geladen und mit dem Installer des Herstellers
|
||||
installiert.
|
||||
"""
|
||||
|
||||
|
||||
def vendor_ordner(basis: str = None) -> str:
|
||||
return os.path.join(basis or os.path.join(REPO, "dist", "windows"), "vendor")
|
||||
|
||||
|
||||
def handbrake_bereitstellen(ziel_basis: str = None, melden=print) -> dict:
|
||||
"""Sorgt dafür, dass HandBrakeCLI im vendor-Ordner liegt.
|
||||
|
||||
Gibt `{"pfad": …, "version": …, "geladen": bool}` zurück; `pfad` ist
|
||||
leer, wenn es nicht geklappt hat. Wirft NICHT — ein Bau ohne Netz soll
|
||||
möglich bleiben, muss dann aber laut sagen, was fehlt.
|
||||
"""
|
||||
sys.path.insert(0, os.path.join(REPO, "src"))
|
||||
from rippy.tools import beschaffen
|
||||
|
||||
ordner = os.path.join(vendor_ordner(ziel_basis), "handbrake")
|
||||
exe = os.path.join(ordner, "HandBrakeCLI.exe")
|
||||
|
||||
if os.path.isfile(exe) and os.path.getsize(exe) > 1024 * 1024:
|
||||
return {"pfad": exe, "version": _version_von(exe), "geladen": False}
|
||||
|
||||
melden(" HandBrakeCLI wird geholt …")
|
||||
try:
|
||||
pfad = beschaffen.handbrake_holen(
|
||||
ziel_ordner=ordner,
|
||||
fortschritt=lambda t, anteil=None: melden(" " + t))
|
||||
except Exception as e: # noqa: BLE001
|
||||
melden(" X HandBrakeCLI konnte nicht geholt werden: %s" % e)
|
||||
return {"pfad": "", "version": "", "geladen": False}
|
||||
return {"pfad": pfad, "version": _version_von(pfad), "geladen": True}
|
||||
|
||||
|
||||
def _version_von(exe: str) -> str:
|
||||
sys.path.insert(0, os.path.join(REPO, "src"))
|
||||
from rippy.tools import katalog
|
||||
|
||||
try:
|
||||
return katalog.version_von("handbrake", exe) or ""
|
||||
except Exception: # noqa: BLE001
|
||||
return ""
|
||||
|
||||
|
||||
def paket_ordner_bauen(ziel_basis: str = None, melden=print) -> str:
|
||||
"""Baut den `tools`-Ordner, der ins Paket wandert. "" wenn nichts drin ist.
|
||||
|
||||
Struktur — genau die, in der `katalog.kandidaten()` sucht:
|
||||
|
||||
tools/handbrake/HandBrakeCLI.exe
|
||||
tools/handbrake/LIZENZ-HandBrake.txt
|
||||
"""
|
||||
hb = handbrake_bereitstellen(ziel_basis, melden)
|
||||
if not hb["pfad"]:
|
||||
return ""
|
||||
|
||||
basis = os.path.join(vendor_ordner(ziel_basis), "paket")
|
||||
ordner = os.path.join(basis, "handbrake")
|
||||
os.makedirs(ordner, exist_ok=True)
|
||||
ziel_exe = os.path.join(ordner, "HandBrakeCLI.exe")
|
||||
if os.path.abspath(hb["pfad"]) != os.path.abspath(ziel_exe):
|
||||
shutil.copy2(hb["pfad"], ziel_exe)
|
||||
|
||||
from rippy.tools import beschaffen
|
||||
|
||||
with open(os.path.join(ordner, "LIZENZ-HandBrake.txt"), "w",
|
||||
encoding="utf-8") as f:
|
||||
f.write(GPL_HINWEIS.format(version=hb["version"] or "unbekannt",
|
||||
url=beschaffen.HANDBRAKE_API))
|
||||
|
||||
melden(" HandBrakeCLI %s liegt im Paket (%.1f MB)"
|
||||
% (hb["version"] or "?", os.path.getsize(ziel_exe) / 1024**2))
|
||||
return basis
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
ordner = paket_ordner_bauen()
|
||||
print("Paket-Ordner:", ordner or "(leer)")
|
||||
raise SystemExit(0 if ordner else 1)
|
||||
@@ -1,11 +0,0 @@
|
||||
# Bau-Ausgaben von electron-vite (out/) — dist/ ignoriert schon die Root-.gitignore
|
||||
node_modules/
|
||||
out/
|
||||
*.tsbuildinfo
|
||||
# Setup-Bau (electron-builder) und die MITGELIEFERTEN Binaerdateien —
|
||||
# vendor/ wird beim Paketieren aus LOCALAPPDATA\Rippy\tools befuellt
|
||||
# (BAUEN.md), 74 MB gehoeren nicht ins Git.
|
||||
# Auch die Ausweich-Ordner der win-unpacked-Sperre (BAUEN.md)
|
||||
dist-setup*/
|
||||
vendor/
|
||||
.electron-cache/
|
||||
@@ -1,114 +0,0 @@
|
||||
# Rippy v5 bauen — Anleitung
|
||||
|
||||
> Gilt für den Ordner `rippy-windows/`. Grundlage: `KONZEPT-WINDOWS.md`,
|
||||
> die Messungen dazu liegen in `beweise/`.
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
| Werkzeug | Version | Anmerkung |
|
||||
|----------|---------|-----------|
|
||||
| Node | 24 (LTS) | dieselbe Linie wie Electrons eingebautes Node (24.18) |
|
||||
| npm | 11 | kommt mit Node 24 |
|
||||
| Windows | 10 (ab 1809) / 11, x64 | zum Entwickeln und für den Smoke-Beweis; Bau/Tests laufen auch auf Linux (CI) |
|
||||
|
||||
## Einrichten — zwei Schritte, nicht einer
|
||||
|
||||
```
|
||||
npm install
|
||||
node node_modules/electron/install.js
|
||||
```
|
||||
|
||||
**Die zweite Zeile ist kein Versehen** (gemessen 30.08.2026, siehe
|
||||
`beweise/README.md` und KONZEPT § 3.5): npm 11 blockiert Install-Skripte
|
||||
fremder Pakete. `koffi` übersteht das, weil es vorgebaute Binärdateien
|
||||
mitbringt — **Electron nicht**: Sein Install-Skript lädt die eigentliche
|
||||
Programmdatei herunter. Wird es geblockt, liegt nach `npm install` ein Paket
|
||||
**ohne `dist/electron.exe`** da, und nichts startet. Der Nachholer ist auch
|
||||
als npm-Skript hinterlegt: `npm run werkzeug:electron`.
|
||||
|
||||
## Die Läufe
|
||||
|
||||
| Befehl | Was er tut |
|
||||
|--------|------------|
|
||||
| `npm run dev` | Entwicklung: baut, startet Electron, lädt das Fenster vom Vite-Server |
|
||||
| `npm run build` | Typprüfung (beide tsconfig) + Bau nach `out/` |
|
||||
| `npm test` | Vitest: Wächter-Tests (§ 4.3), Datenbank, Nachrichten-Schema |
|
||||
| `npm run smoke` | **Der W-0-Beweis:** baut, startet das echte Programm mit `--smoke`, prüft Fenster + Kern + Datenbank + Leine + Ping/Pong über den MessagePort und endet mit Code 0 (grün) oder 2. `RIPPY_SMOKE_BILD=<pfad.png>` legt zusätzlich einen Screenshot ab. Läuft nur unter Windows (Electron-Binary + koffi). |
|
||||
| `npm run klick` | **Der Klick-Beweis (5.4.0):** baut, startet das echte Programm mit eigenem Profil (`--profil=…`, `--klick-smoke`: ohne Tray, Updater, Toasts), einem nachgebauten Laufwerk (`RIPPY_FAKE_LAUFWERK=Q`) und nachgebauten Werkzeugen (`bau/klick/fake-*.cjs`, laufen über `kern/werkzeuge/aufruf.ts`) — und klickt per Playwright durch Speicher-Karte → Rippen → Dialog → Sprachen → Folgen → Platz-Prüfung → Rip → Warteschlange → Kompression → Arbeitsordner (Roh lokal, Zwischenordner geräumt) → Ablage → Verlauf → Bilanz → Roh-Dateien → Einstellungen (Sprach-Chips, Durchsuchen, Karten Automatik/Benachrichtigung/Allgemein/Update). Seit 5.5.0 mit getrenntem Arbeitsordner und Automatik aus (der Beweis klickt selbst). 5.6.0: dazu die Untertitel-Vorwahl (nur bei Extras), „anderer Film …" je Titel, die Restzeit in der Kompressions-Karte (das nachgebaute HandBrake schreibt die ETA-Klammer wie das Binary), die Kachel-Kurzzeile, die Vorgangs-Leiste (TITEL LESEN ✓ → RIPPEN aktiv → KOMPRIMIEREN → ABLEGEN, keine „PHASE x/4"-Texte mehr) und der Hell-Modus (Bild `07-hell-modus.png`). Bilder in `../beweise/klick/`, Code 0 oder 2. Grund: Beide Fehler vom 01.09.2026 waren Verkabelung, die kein Unit-Test sah. Playwright ist reines Bau-Werkzeug (MIT, `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1` beim Installieren — Electron braucht keinen Browser-Download). |
|
||||
|
||||
Der Smoke-Lauf benutzt ein Wegwerf-Profil unter `%TEMP%\rippy-smoke` — er
|
||||
fasst die echten Einstellungen nicht an.
|
||||
|
||||
## Was die CI-Ampel prüft (`.gitea/workflows/ci.yml`)
|
||||
|
||||
Die Ampel läuft auf Linux und führt hier `npm ci`, `npm run build` und
|
||||
`npm test` aus. Deshalb gilt für Tests: **nichts importieren, was koffi,
|
||||
Win32 oder das Electron-Binary braucht.** Die Wächter-Tests LESEN Quelltext
|
||||
nur; die Datenbank-Tests laufen, weil `node:sqlite` im System-Node genauso
|
||||
vorhanden ist wie in Electrons Node (§ 3.4). Was zwingend Windows braucht
|
||||
(Smoke, später die Laufwerks-Messungen), wird lokal ausgeführt und im
|
||||
`SAVEPOINT.md` mit Ausgabe belegt.
|
||||
|
||||
## Das Setup bauen (W-6)
|
||||
|
||||
```
|
||||
npm run paket
|
||||
```
|
||||
|
||||
erzeugt `dist-setup/RippySetup-<version>.exe` (NSIS, pro Benutzer, ohne
|
||||
Adminrechte) plus `latest.yml` und `.blockmap` — **alle drei Dateien
|
||||
gehören zum Release** (§ 6.8: `latest.yml` ist die Update-Auskunft, die
|
||||
`.blockmap` macht aus dem Voll-Download einen Differenz-Download).
|
||||
|
||||
⚠ **Die `win-unpacked`-Sperre (30./31.08.2026 gemessen):** Nachdem der
|
||||
Paket-Smoke (`win-unpacked\Rippy.exe --smoke`) gelaufen ist, hält ein
|
||||
Filter-Treiber (Virenscanner/Indexer — KEIN Prozess mit sichtbarem Pfad)
|
||||
`resources\app.asar` mitunter STUNDENLANG fest. Der nächste Bau in
|
||||
denselben Ordner scheitert dann mit `EBUSY … unlink app.asar`, und zwar
|
||||
mit **Exit-Code 0** (electron-builder!) — nicht vom Log-Ende täuschen
|
||||
lassen, die ⨯-Zeile zählt. Ausweg: in ein frisches Verzeichnis bauen
|
||||
(`npx electron-builder --win --config electron-builder.yml
|
||||
-c.directories.output=dist-setup-neu`), die fertige Setup-EXE nach
|
||||
`dist-setup/` KOPIEREN (eine Kopie ist eine neue Datei ohne Sperre) und
|
||||
den gesperrten Ordner löschen, sobald er wieder frei ist.
|
||||
|
||||
Vorher einmalig `vendor/` befüllen (die MITGELIEFERTEN Werkzeuge, § 9 —
|
||||
liegen nicht im Git, 74 MB):
|
||||
|
||||
```
|
||||
vendor/handbrake/HandBrakeCLI.exe (GPL-2 — Lizenztext liegt bei)
|
||||
vendor/flac/flac.exe + libFLAC.dll (Xiph — OHNE die DLL endet jeder
|
||||
Aufruf ohne eine Zeile Ausgabe)
|
||||
```
|
||||
|
||||
Quelle auf dieser Maschine: `%LOCALAPPDATA%\Rippy\tools\` (die rc11-
|
||||
Beschaffung hat sie dort abgelegt); Original-Quellen und Lizenzen stehen
|
||||
in `bau/lizenzen/QUELLEN.md`.
|
||||
|
||||
**Release veröffentlichen (Entscheid 6):** Die drei Dateien in ein
|
||||
Gitea-Release mit dem festen Tag `aktuell` hochladen (Dateien dort
|
||||
ERSETZEN) — `electron-updater` prüft
|
||||
`…/releases/download/aktuell/latest.yml`. Ob Gitea 1.27 zusätzlich das
|
||||
GitHub-Muster `releases/latest/download/…` bedient, ist am ersten echten
|
||||
Release zu messen (§ 3.6 ⚠); das feste Tag ist die Rückfallebene.
|
||||
|
||||
**SmartScreen (Entscheid 5):** Die EXE ist unsigniert. Beim ersten Start
|
||||
eines Downloads: „Weitere Informationen → Trotzdem ausführen". Beim
|
||||
Release Größe und SHA-256 mit angeben, damit Bekannte die echte Datei
|
||||
erkennen.
|
||||
|
||||
## Festgelegte Versionen und warum
|
||||
|
||||
- **Electron 44.0.0** — exakt die am 30.08.2026 gemessene Version
|
||||
(KONZEPT § 3.1/§ 3.4). Sprünge nach der Pflege-Regel § 6.8: bei jedem
|
||||
Release prüfen, ob eine neue Linie ansteht.
|
||||
- **TypeScript 5.9.3, fest gepinnt** — bewusst NICHT die neue
|
||||
Go-Implementierung (7.x, „tsgo", seit 2026 `latest` auf npm): Für die
|
||||
Ampel zählt ein Typprüfer, dessen Verhalten die restliche Werkzeugkette
|
||||
(Vitest 4, electron-vite 5) breit getestet hat. Der Umstieg auf 7.x ist
|
||||
eine bewusste Entscheidung für später, kein Nebeneffekt eines `npm update`.
|
||||
- **Vite 7** — der gemeinsame Nenner von electron-vite 5 (verträgt 5–7) und
|
||||
Vitest 4 (verträgt 6–8), am 30.08.2026 per `npm view` ermittelt.
|
||||
- **koffi** ist die einzige Laufzeit-Abhängigkeit (`dependencies`) — sie
|
||||
wird nicht gebündelt, sondern zur Laufzeit aus `node_modules` geladen
|
||||
(natives Modul). Alles andere ist Bauzeit (`devDependencies`).
|
||||
|
Before Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 7.2 KiB |
@@ -1,384 +0,0 @@
|
||||
// Der Klick-Beweis (5.4.0, Punkt 7 der Übergabe vom 01.09.2026): startet
|
||||
// das ECHTE Rippy (Electron, Haupt, Kern, Fenster, Datenbank) mit einem
|
||||
// frischen Profil, einem nachgebauten Laufwerk und nachgebauten Werkzeugen
|
||||
// — und KLICKT sich durch: Rippen → Titel-Dialog → Sprachen je Titel →
|
||||
// Rollen auf Folge → Staffel/Folge → Rip → Warteschlange → Kompression →
|
||||
// Ablage → Roh-Dateien → löschen.
|
||||
//
|
||||
// Warum: Beide Fehler vom 01.09.2026 waren Verkabelung (Nachrichtentyp
|
||||
// nicht im Prüfer, zwei gleichzeitige Titel-Läufe). 261 Unit-Tests sahen
|
||||
// keinen davon; ein Klick hätte beide gefunden. Playwright treibt Electron
|
||||
// (Doku »Electron« bei playwright.dev, _electron.launch).
|
||||
//
|
||||
// npm run klick (baut vorher)
|
||||
// Bilder: ../beweise/klick/*.png — Ausgang 0 (grün) oder 2.
|
||||
import { _electron as electron } from 'playwright'
|
||||
import { existsSync, mkdirSync, readdirSync, rmSync, statSync } from 'node:fs'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const wurzel = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const profil = join(tmpdir(), 'rippy-klick')
|
||||
const ablage = join(profil, 'ablage')
|
||||
// 5.5.0: Arbeitsordner getrennt von der Ablage — Roh und Encode liegen
|
||||
// lokal, nur das Fertige wandert (hier: von Ordner zu Ordner im Profil).
|
||||
const arbeit = join(profil, 'arbeit')
|
||||
const bilder = resolve(wurzel, '..', 'beweise', 'klick')
|
||||
|
||||
rmSync(profil, { recursive: true, force: true })
|
||||
mkdirSync(profil, { recursive: true })
|
||||
mkdirSync(bilder, { recursive: true })
|
||||
// Alte Bilder weg — sonst bleiben Nummern früherer Fassungen liegen.
|
||||
for (const name of readdirSync(bilder)) if (name.endsWith('.png')) rmSync(join(bilder, name))
|
||||
|
||||
// Das Profil vorbereiten: Einrichtung erledigt, Ablage im Profil, die
|
||||
// Werkzeuge sind die Nachbauten (kern/werkzeuge/aufruf.ts startet Skripte
|
||||
// über die eigene Node-Laufzeit). Die Datenbank legt der Kern selbst an —
|
||||
// hier nur die Einstellungen (Tabelle wie in kern/speicher/db.ts).
|
||||
{
|
||||
const db = new DatabaseSync(join(profil, 'rippy.db'))
|
||||
db.exec('CREATE TABLE IF NOT EXISTS einstellungen (schluessel TEXT PRIMARY KEY, wert TEXT NOT NULL)')
|
||||
const setzen = db.prepare('INSERT OR REPLACE INTO einstellungen (schluessel, wert) VALUES (?, ?)')
|
||||
setzen.run('setup.done', 'true')
|
||||
setzen.run('ablage', ablage)
|
||||
setzen.run('werkzeug.makemkv', join(wurzel, 'bau', 'klick', 'fake-makemkvcon.cjs'))
|
||||
setzen.run('werkzeug.handbrake', join(wurzel, 'bau', 'klick', 'fake-handbrake.cjs'))
|
||||
setzen.run('audioSprachen', 'deu,eng')
|
||||
setzen.run('untertitelSprachen', 'deu')
|
||||
setzen.run('rohAufbewahrung', 'behalten')
|
||||
setzen.run('autoAuswurf', 'false')
|
||||
setzen.run('arbeitsordner', arbeit)
|
||||
// Die Automatik bleibt aus: Der Beweis klickt selbst.
|
||||
setzen.run('automatik', 'aus')
|
||||
db.close()
|
||||
}
|
||||
|
||||
const start = (env) =>
|
||||
electron.launch({
|
||||
executablePath: join(wurzel, 'node_modules', 'electron', 'dist', 'electron.exe'),
|
||||
args: [wurzel, `--profil=${profil}`, '--klick-smoke'],
|
||||
env: { ...process.env, RIPPY_FAKE_LAUFWERK: 'Q', RIPPY_OHNE_SCHLUESSEL: '1', ...env },
|
||||
timeout: 60_000,
|
||||
})
|
||||
let app = await start({})
|
||||
async function fenster() {
|
||||
const s = await app.firstWindow()
|
||||
s.setDefaultTimeout(30_000)
|
||||
s.on('console', (m) => {
|
||||
if (m.type() === 'error') console.log(`KLICK fenster: ${m.text().slice(0, 200)}`)
|
||||
})
|
||||
return s
|
||||
}
|
||||
let seite = await fenster()
|
||||
|
||||
let nr = 0
|
||||
async function bild(name) {
|
||||
nr += 1
|
||||
const pfad = join(bilder, `${String(nr).padStart(2, '0')}-${name}.png`)
|
||||
await seite.screenshot({ path: pfad, fullPage: true })
|
||||
console.log(`KLICK bild: ${pfad}`)
|
||||
}
|
||||
|
||||
async function schritt(name, tun) {
|
||||
try {
|
||||
await tun()
|
||||
console.log(`KLICK ✓ ${name}`)
|
||||
} catch (fehler) {
|
||||
console.error(`KLICK ✗ ${name}: ${String(fehler.message ?? fehler).split('\n')[0]}`)
|
||||
await seite.screenshot({ path: join(bilder, `fehler-${name.replace(/[^a-z0-9]+/gi, '-').toLowerCase()}.png`), fullPage: true }).catch(() => {})
|
||||
await app.close().catch(() => {})
|
||||
process.exit(2)
|
||||
}
|
||||
}
|
||||
|
||||
const dialog = () => seite.locator('div.fixed.inset-0').last()
|
||||
|
||||
await schritt('Fenster offen, nachgebautes Laufwerk Q: sichtbar', async () => {
|
||||
await seite.getByText('LAUFWERK Q:').waitFor()
|
||||
await seite.getByText('NACHGEBAUT (KLICK-BEWEIS)').waitFor()
|
||||
})
|
||||
await bild('uebersicht')
|
||||
|
||||
await schritt('Speicher-Karte misst Arbeitsordner und Ablage getrennt, die Fußleiste zeigt FREI', async () => {
|
||||
await seite.getByText('SPEICHER UND BILANZ').waitFor()
|
||||
await seite.getByText('ARBEITSORDNER (LOKAL)').waitFor()
|
||||
await seite.getByText('ABLAGE (SERVER)').waitFor()
|
||||
await seite.getByText(/^FREI [0-9]/).waitFor()
|
||||
})
|
||||
|
||||
await schritt('Keine Bibliothek mehr — Übersicht und Einstellungen sind die Bereiche', async () => {
|
||||
const n = await seite.getByRole('button', { name: 'Bibliothek' }).count()
|
||||
if (n !== 0) throw new Error('Bibliothek-Knopf ist noch da')
|
||||
})
|
||||
|
||||
await schritt('Rippen → Dialog öffnet sich', async () => {
|
||||
await seite.getByRole('button', { name: 'Rippen', exact: true }).click()
|
||||
await seite.getByText('Was soll gerippt werden?').waitFor()
|
||||
})
|
||||
|
||||
await schritt('Titel-Lauf liefert drei Titel', async () => {
|
||||
await dialog().getByText('Titel 0', { exact: true }).waitFor()
|
||||
await dialog().getByText('Titel 1', { exact: true }).waitFor()
|
||||
await dialog().getByText('Titel 2', { exact: true }).waitFor()
|
||||
})
|
||||
|
||||
await schritt('5.7.0: Spuren je Titel einzeln — Codec und Kanäle, der DTS-Kern bleibt unsichtbar', async () => {
|
||||
await dialog().getByLabel('Japanese · DTS-HD MA · Surround 5.1').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 (German 5.1 abhaken, Japanese anhaken)', async () => {
|
||||
await dialog().getByLabel('German · DD · Surround 5.1').first().uncheck()
|
||||
await dialog().getByText('keine Tonspur gewählt — HandBrake nimmt die erste Spur').waitFor()
|
||||
await dialog().getByLabel('Japanese · DTS-HD MA · Surround 5.1').first().check()
|
||||
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 () => {
|
||||
// Rippy wählt bei zwei fast gleich langen Titeln nur EINEN vor („bitte
|
||||
// nachsehen") — der Knopf holt den zweiten dazu, Titel 2 (35 s) nicht.
|
||||
await dialog().getByRole('button', { name: 'Hauptinhalt + Extras' }).click()
|
||||
await dialog().getByText(/2 von 3 gewählt/).waitFor()
|
||||
})
|
||||
|
||||
await schritt('5.6.0: Untertitel-Vorwahl erscheint NUR bei Extras, „anderer Film …" je Titel', async () => {
|
||||
// Rippys Vorschlag macht bei zwei langen Titeln den zweiten zum Extra —
|
||||
// erst beide auf Hauptfilm, dann ist klar: keine Vorwahl bei Hauptfilmen.
|
||||
const rollen = dialog().locator('select')
|
||||
await rollen.nth(0).selectOption('hauptfilm')
|
||||
await rollen.nth(1).selectOption('hauptfilm')
|
||||
if ((await dialog().getByText('BEIM ABSPIELEN AN').count()) !== 0) throw new Error('Vorwahl schon bei Hauptfilmen sichtbar')
|
||||
await rollen.nth(1).selectOption('extra')
|
||||
await dialog().getByText('BEIM ABSPIELEN AN').waitFor()
|
||||
const vorwahl = dialog().locator('select').nth(2)
|
||||
// 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()
|
||||
if (knoepfe < 2) throw new Error(`nur ${knoepfe} „anderer Film"-Knöpfe`)
|
||||
await dialog().getByRole('button', { name: 'anderer Film …' }).first().click()
|
||||
await dialog().getByPlaceholder('Filmtitel suchen …').waitFor()
|
||||
await dialog().getByRole('button', { name: 'anderer Film …' }).first().click()
|
||||
await rollen.nth(1).selectOption('hauptfilm')
|
||||
if ((await dialog().getByText('BEIM ABSPIELEN AN').count()) !== 0) throw new Error('Vorwahl bleibt nach Rollenwechsel sichtbar')
|
||||
})
|
||||
await bild('dialog-vorwahl')
|
||||
|
||||
await schritt('Rollen auf Folge → Staffel/Folge-Felder erscheinen', async () => {
|
||||
const rollen = dialog().locator('select')
|
||||
await rollen.nth(0).selectOption('folge')
|
||||
await rollen.nth(1).selectOption('folge')
|
||||
await dialog().getByText('AB FOLGE').waitFor()
|
||||
await dialog().getByPlaceholder('automatisch').fill('3')
|
||||
})
|
||||
|
||||
await schritt('Vorschau nennt Serien\\…\\Season 01 und S01E03', async () => {
|
||||
await dialog().getByText(/Serien\\/).waitFor()
|
||||
await dialog().getByText(/Season 01/).waitFor()
|
||||
await dialog().getByText(/S01E03/).waitFor()
|
||||
})
|
||||
|
||||
await schritt('Platz-Prüfung im Dialog: der Arbeitsordner reicht', async () => {
|
||||
await dialog().getByText(/^Platz: .* frei im Arbeitsordner/).waitFor()
|
||||
})
|
||||
await bild('dialog')
|
||||
|
||||
await schritt('Rip starten → Phase RIPPT, der Automatik-Kasten ist weg', async () => {
|
||||
await dialog().getByRole('button', { name: /Titel rippen/ }).click()
|
||||
await seite.getByText(/RIPPT/).first().waitFor()
|
||||
if ((await seite.getByText('Automatik gestoppt').count()) !== 0) throw new Error('„Automatik gestoppt" steht noch unter dem laufenden Rip')
|
||||
})
|
||||
|
||||
await schritt('5.6.0: Vorgangs-Leiste in der Kachel — TITEL LESEN fertig, RIPPEN aktiv, KOMPRIMIEREN und ABLEGEN offen', async () => {
|
||||
await seite.getByText('TITEL LESEN', { exact: true }).first().waitFor()
|
||||
await seite.getByText('RIPPEN', { exact: true }).first().waitFor()
|
||||
await seite.getByText('KOMPRIMIEREN', { exact: true }).first().waitFor()
|
||||
await seite.getByText('ABLEGEN', { exact: true }).first().waitFor()
|
||||
if ((await seite.getByText(/PHASE [0-9]\/4/).count()) !== 0) throw new Error('alte PHASE-x/4-Texte sind noch da')
|
||||
})
|
||||
|
||||
await schritt('Rip fertig, Kompression eingereiht, Laufwerk frei', async () => {
|
||||
await seite.getByText(/Kompression eingereiht/).waitFor({ timeout: 60_000 })
|
||||
await seite.getByRole('button', { name: 'Rippen', exact: true }).waitFor()
|
||||
})
|
||||
|
||||
await schritt('Kompressions-Karte läuft mit Restzeit (ETA aus HandBrakes Zeile), die Kachel zeigt nur eine Kurzzeile', async () => {
|
||||
await seite.getByText('KOMPRESSION', { exact: true }).waitFor({ timeout: 30_000 })
|
||||
await seite.getByText(/^noch /).first().waitFor({ timeout: 20_000 })
|
||||
// Die Leiste in der Karte: zwei Stationen fertig (✓), KOMPRIMIEREN aktiv.
|
||||
if ((await seite.getByText('KOMPRIMIEREN', { exact: true }).count()) < 1) throw new Error('keine Leiste in der Kompressions-Karte')
|
||||
if ((await seite.getByText(/KOMPRESSION LÄUFT/).count()) !== 0) throw new Error('Kachel zeigt noch den vollen Kompressions-Block')
|
||||
await seite.getByText(/Kompression fertig — das Ergebnis steht im Verlauf/).waitFor({ timeout: 60_000 })
|
||||
})
|
||||
await bild('fertig')
|
||||
|
||||
await schritt('Ergebnis liegt als S01E03/S01E04 unter Serien\\…\\Season 01', async () => {
|
||||
const serien = join(ablage, 'Serien')
|
||||
if (!existsSync(serien)) throw new Error(`kein Ordner ${serien}`)
|
||||
const gefunden = []
|
||||
const gehen = (o) => {
|
||||
for (const e of readdirSync(o, { withFileTypes: true })) {
|
||||
const p = join(o, e.name)
|
||||
if (e.isDirectory()) gehen(p)
|
||||
else if (e.name.endsWith('.mkv') && statSync(p).size > 0) gefunden.push(p)
|
||||
}
|
||||
}
|
||||
gehen(serien)
|
||||
const namen = gefunden.map((p) => p.split(/[\\/]/).pop())
|
||||
if (!namen.some((n) => n.endsWith('S01E03.mkv')) || !namen.some((n) => n.endsWith('S01E04.mkv'))) {
|
||||
throw new Error(`gefunden: ${namen.join(', ')}`)
|
||||
}
|
||||
if (!gefunden[0].includes('Season 01')) throw new Error(`kein Season-01-Ordner: ${gefunden[0]}`)
|
||||
console.log(`KLICK dateien: ${gefunden.join(' | ')}`)
|
||||
})
|
||||
|
||||
await schritt('Arbeitsordner: Roh liegt lokal, der Encode-Zwischenordner ist aufgeräumt', async () => {
|
||||
const roh = join(arbeit, 'roh')
|
||||
if (!existsSync(roh) || readdirSync(roh).length === 0) throw new Error(`kein Roh-Ordner unter ${roh}`)
|
||||
const fertig = join(arbeit, 'fertig')
|
||||
if (existsSync(fertig) && readdirSync(fertig).length > 0) throw new Error(`Zwischenordner nicht geräumt: ${readdirSync(fertig).join(', ')}`)
|
||||
const rohInAblage = join(ablage, 'roh')
|
||||
if (existsSync(rohInAblage)) throw new Error('Roh-Ordner liegt in der Ablage statt im Arbeitsordner')
|
||||
})
|
||||
|
||||
await schritt('Verlauf in der Übersicht nennt den Vorgang als fertig', async () => {
|
||||
await seite.getByText('VERLAUF', { exact: true }).waitFor()
|
||||
await seite.getByText(/die letzten 1 Vorgänge/).waitFor()
|
||||
await seite.getByRole('button', { name: 'Ordner öffnen' }).first().waitFor()
|
||||
})
|
||||
|
||||
await schritt('Bilanz zählt eine Disc', async () => {
|
||||
await seite.getByText('DISCS', { exact: true }).waitFor()
|
||||
await seite.getByText('1 fertig', { exact: true }).waitFor()
|
||||
})
|
||||
|
||||
await schritt('Einstellungen → Roh-Dateien zeigt den Vorgang mit Belegung', async () => {
|
||||
await seite.getByRole('button', { name: 'Einstellungen' }).click()
|
||||
await seite.locator('#roh').waitFor()
|
||||
await seite.getByText(/Belegung jetzt/).waitFor()
|
||||
await seite.getByText(/Roh: \d/).first().waitFor()
|
||||
})
|
||||
await bild('roh-dateien')
|
||||
|
||||
await schritt('Kompression: kein Allgemeines Preset, Sprachen als Chips (Deutsch, Englisch)', async () => {
|
||||
if ((await seite.getByText(/Allgemeines Preset/).count()) !== 0) throw new Error('Allgemeines Preset ist noch da')
|
||||
await seite.locator('#kompression').getByText('Deutsch', { exact: true }).first().waitFor()
|
||||
await seite.locator('#kompression').getByText('Englisch', { exact: true }).first().waitFor()
|
||||
await seite.locator('#kompression').locator('select').first().waitFor()
|
||||
})
|
||||
|
||||
await schritt('Werkzeuge haben Durchsuchen, Allgemein und Update sind eigene Karten, MakeMKV-Karte trägt den Schlüssel', async () => {
|
||||
const n = await seite.locator('#werkzeuge').getByRole('button', { name: /Durchsuchen/ }).count()
|
||||
if (n < 3) throw new Error(`nur ${n} Durchsuchen-Knöpfe`)
|
||||
await seite.locator('#allgemein').waitFor()
|
||||
await seite.locator('#update').waitFor()
|
||||
await seite.locator('#automatik').waitFor()
|
||||
await seite.locator('#benachrichtigung').waitFor()
|
||||
await seite.locator('#makemkv').getByText(/Eigener Schlüssel/).waitFor()
|
||||
await seite.getByRole('button', { name: 'Protokoll öffnen' }).waitFor()
|
||||
})
|
||||
await bild('einstellungen')
|
||||
|
||||
await schritt('Roh-Dateien löschen → Rückfrage → „Ja, löschen" → „gelöscht"', async () => {
|
||||
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 schritt('5.6.0: Hell-Modus über Einstellungen → Allgemein, data-design wechselt', async () => {
|
||||
await seite.locator('#allgemein').getByLabel('Erscheinungsbild').selectOption('hell')
|
||||
await seite.locator('html[data-design="hell"]').waitFor()
|
||||
})
|
||||
await bild('hell-modus')
|
||||
|
||||
await schritt('zurück in den Kinosaal (dunkel)', async () => {
|
||||
await seite.locator('#allgemein').getByLabel('Erscheinungsbild').selectOption('dunkel')
|
||||
await seite.locator('html[data-design="dunkel"]').waitFor()
|
||||
})
|
||||
|
||||
await schritt('5.7.0: „Titel lesen — ohne Lebenszeichen höchstens" steht auf 15 Minuten, 30 lässt sich wählen', async () => {
|
||||
const regler = seite.locator('#allgemein').getByLabel('Titel lesen — ohne Lebenszeichen 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 seite.getByRole('button', { name: 'Übersicht' }).click()
|
||||
await seite.getByText(/Kompression „/).first().waitFor()
|
||||
})
|
||||
await bild('ereignisse')
|
||||
|
||||
// ── Zweiter Start: die Erst-Einrichtung (5.5.0) ─────────────────────────
|
||||
// RIPPY_START_BEREICH=einrichtung erzwingt den Assistenten (#einrichtung),
|
||||
// auch wenn setup.done schon gesetzt ist — derselbe Weg wie im Smoke.
|
||||
await app.close().catch(() => {})
|
||||
app = await start({ RIPPY_START_BEREICH: 'einrichtung' })
|
||||
seite = await fenster()
|
||||
const weiter = () => seite.getByRole('button', { name: 'Weiter', exact: true }).click()
|
||||
|
||||
await schritt('Erst-Einrichtung: Willkommen vor der Poster-Kulisse (7 Spalten Platzhalter)', async () => {
|
||||
await seite.getByText('Willkommen bei Rippy').waitFor()
|
||||
const spalten = await seite.locator('.poster-spalte').count()
|
||||
if (spalten !== 7) throw new Error(`${spalten} Poster-Spalten`)
|
||||
})
|
||||
await bild('einrichtung-willkommen')
|
||||
|
||||
await schritt('Prüfung: MakeMKV (Nachbau) gefunden, HandBrake dabei, Laufwerk Q: gesehen', async () => {
|
||||
await weiter()
|
||||
await seite.getByText(/MakeMKV gefunden/).waitFor()
|
||||
await seite.getByText(/HandBrakeCLI dabei/).waitFor()
|
||||
await seite.getByText(/1 optisches Laufwerk gefunden/).waitFor()
|
||||
})
|
||||
|
||||
await schritt('Ordner: Ablage und Arbeitsordner mit Durchsuchen und freiem Platz', async () => {
|
||||
await weiter()
|
||||
await seite.getByText('Ablage-Ordner', { exact: true }).waitFor()
|
||||
await seite.getByText('Arbeitsordner (lokal, optional)').waitFor()
|
||||
const n = await seite.getByRole('button', { name: /Durchsuchen/ }).count()
|
||||
if (n < 2) throw new Error(`nur ${n} Durchsuchen-Knöpfe`)
|
||||
await seite.getByText(/frei von/).first().waitFor()
|
||||
})
|
||||
|
||||
await schritt('Qualität: gemessene Encoder, Empfehlung je Disc-Typ, Sprach-Chips', async () => {
|
||||
await weiter()
|
||||
await seite.getByText('AUF DIESER MASCHINE GEMESSEN:').waitFor()
|
||||
await seite.getByText('4K-UHD', { exact: true }).waitFor()
|
||||
await seite.getByText('Deutsch', { exact: true }).first().waitFor()
|
||||
})
|
||||
await bild('einrichtung-qualitaet')
|
||||
|
||||
await schritt('Titel, Meldungen, Automatik: TMDb-Schlüssel, Discord/Telegram mit Testknopf, Countdown und Roh-Regel', async () => {
|
||||
await weiter()
|
||||
await seite.getByText('TMDb-API-Key', { exact: true }).waitFor()
|
||||
await weiter()
|
||||
await seite.getByText('Discord-Webhook', { exact: true }).waitFor()
|
||||
await seite.getByRole('button', { name: 'Testnachricht schicken' }).waitFor()
|
||||
await weiter()
|
||||
await seite.getByText('Countdown', { exact: true }).waitFor()
|
||||
await seite.getByText(/Roh-Dateien nach gelungener Kompression/).waitFor()
|
||||
})
|
||||
|
||||
await schritt('Fertig: der Knopf Los geht’s', async () => {
|
||||
await weiter()
|
||||
await seite.getByRole('button', { name: "Los geht's" }).waitFor()
|
||||
})
|
||||
await bild('einrichtung-fertig')
|
||||
|
||||
console.log('KLICK: OK — Speicher, Rippen, Dialog, 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(() => {})
|
||||
process.exit(0)
|
||||
@@ -1,103 +0,0 @@
|
||||
// Nachgebautes HandBrakeCLI für den Klick-Beweis (bau/klick-smoke.mjs).
|
||||
// Antwortet so, wie encoder.ts, scan.ts und handbrake.ts es am echten
|
||||
// HandBrake 1.11 gemessen haben:
|
||||
// --help → Encoder-Block „-e, --encoder" bis zur nächsten Option
|
||||
// --preset-list → Preset-Namen mit genau vier Leerzeichen Einrückung
|
||||
// --scan --json → „JSON Title Set:" + Objekt + „HandBrake has exited."
|
||||
// Encode → „Encoding: task 1 of 1, x %" in Stufen, Datei kopiert;
|
||||
// ab der zweiten Stufe mit der fps/ETA-Klammer, wie das
|
||||
// Binary sie formatiert (5.6.0, Restzeit).
|
||||
const fs = require('node:fs')
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const out = (zeile) => process.stdout.write(zeile + '\n')
|
||||
const wert = (schalter) => {
|
||||
const i = args.indexOf(schalter)
|
||||
return i >= 0 ? args[i + 1] : undefined
|
||||
}
|
||||
|
||||
if (args.includes('--help') || args.includes('-h')) {
|
||||
out('Usage: HandBrakeCLI [options] -i <source> -o <destination>')
|
||||
out('')
|
||||
out('Video Options -----------------------------------------------------------------')
|
||||
out('')
|
||||
out(' -e, --encoder <string> Select video encoder:')
|
||||
out(' x264')
|
||||
out(' x265')
|
||||
out(' nvenc_h264')
|
||||
out(' -q, --quality <float> Set video quality (e.g. 22.0)')
|
||||
out('')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
if (args.includes('--preset-list')) {
|
||||
out('General/')
|
||||
out(' Very Fast 1080p30')
|
||||
out(' Fast 1080p30')
|
||||
out(' HQ 1080p30 Surround')
|
||||
out('')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
if (args.includes('--scan')) {
|
||||
const eingabe = wert('--input') ?? wert('-i') ?? 'quelle'
|
||||
out('[12:00:00] hb_init: starting libhb thread')
|
||||
out('Scanning title 1 of 1, preview 1, 100.00 %')
|
||||
out('JSON Title Set: {')
|
||||
out('"MainFeature": 0,')
|
||||
out('"TitleList": [')
|
||||
out('{')
|
||||
// 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('{ "ChannelCount": 6, "ChannelLayout": 1551, "CodecName": "dtshd", "Description": "Japanese (DTS-HD MA, 5.1 ch)", "Language": "Japanese", "LanguageCode": "jpn", "TrackNumber": 1 },')
|
||||
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('"Duration": { "Hours": 0, "Minutes": 52, "Seconds": 47, "Ticks": 542070 },')
|
||||
out('"Index": 1,')
|
||||
out(`"Name": ${JSON.stringify(eingabe)},`)
|
||||
out('"SubtitleList": [')
|
||||
out('{ "Format": "bitmap", "Language": "Deutsch", "LanguageCode": "deu", "Source": 5, "SourceName": "PGS", "TrackNumber": 1 },')
|
||||
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('HandBrake has exited.')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const eingabe = wert('--input') ?? wert('-i')
|
||||
const ausgabe = wert('--output') ?? wert('-o')
|
||||
if (eingabe === undefined || ausgabe === undefined) {
|
||||
out('Missing input or output file name. Run HandBrakeCLI --help for syntax.')
|
||||
process.exit(1)
|
||||
}
|
||||
out('[12:00:00] hb_init: starting libhb thread')
|
||||
out('Scanning title 1 of 1, preview 10, 100.00 %')
|
||||
let schritt = 0
|
||||
const stufen = [5, 30, 55, 80, 100]
|
||||
const tick = () => {
|
||||
const prozent = stufen[schritt].toFixed(2)
|
||||
if (schritt === 0) out(`Encoding: task 1 of 1, ${prozent} %`)
|
||||
else {
|
||||
// Formatstring aus dem Binary (1.11.2): „%.2f %% (%.2f fps, avg %.2f fps, ETA %02dh%02dm%02ds)"
|
||||
const rest = (stufen.length - 1 - schritt) * 300
|
||||
out(`Encoding: task 1 of 1, ${prozent} % (48.00 fps, avg 47.50 fps, ETA 00h${String(Math.floor(rest / 60)).padStart(2, '0')}m${String(rest % 60).padStart(2, '0')}s)`)
|
||||
}
|
||||
schritt += 1
|
||||
if (schritt < stufen.length) {
|
||||
setTimeout(tick, 500)
|
||||
return
|
||||
}
|
||||
fs.copyFileSync(eingabe, ausgabe)
|
||||
out('[12:00:05] libhb: work result = 0')
|
||||
out('')
|
||||
out('Encode done!')
|
||||
out('HandBrake has exited.')
|
||||
process.exit(0)
|
||||
}
|
||||
setTimeout(tick, 200)
|
||||
@@ -1,88 +0,0 @@
|
||||
// Nachgebautes makemkvcon für den Klick-Beweis (bau/klick-smoke.mjs).
|
||||
// Spricht den Robot-Modus so, wie der Parser (kern/rip/parser.ts) ihn an
|
||||
// echten Discs gemessen hat: TINFO (8 Kapitel, 9 Dauer, 11 Bytes), SINFO
|
||||
// (1 Typ, 3 Sprachcode, 4 Sprachname), PRGV (aktuell,gesamt,max), MSG.
|
||||
//
|
||||
// info dev:X: → drei Titel: zwei Folgen (52:47 / 54:09, jpn+deu
|
||||
// Ton, deu+eng Untertitel) und ein Logo-Schnipsel
|
||||
// mkv dev:X: <nr> <ziel> → Fortschritt in Stufen, dann title_t0N.mkv
|
||||
//
|
||||
// Läuft über die eigene Node-Laufzeit (kern/werkzeuge/aufruf.ts) — kein EXE.
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const befehl = args.find((a) => a === 'info' || a === 'mkv')
|
||||
const out = (zeile) => process.stdout.write(zeile + '\n')
|
||||
|
||||
out('MSG:1005,0,1,"MakeMKV v1.18.4 win(x86-release) gestartet","%1 gestartet","MakeMKV v1.18.4 win(x86-release)"')
|
||||
|
||||
const TITEL = [
|
||||
{ nr: 0, dauer: '0:52:47', bytes: 16283000000, kapitel: 7 },
|
||||
{ nr: 1, dauer: '0:54:09', bytes: 16670000000, kapitel: 7 },
|
||||
{ nr: 2, dauer: '0:00:35', bytes: 20000000, kapitel: 1 },
|
||||
]
|
||||
|
||||
if (befehl === 'info') {
|
||||
out(`TCOUNT:${TITEL.length}`)
|
||||
for (const t of TITEL) {
|
||||
out(`TINFO:${t.nr},8,0,"${t.kapitel}"`)
|
||||
out(`TINFO:${t.nr},9,0,"${t.dauer}"`)
|
||||
out(`TINFO:${t.nr},11,0,"${t.bytes}"`)
|
||||
out(`SINFO:${t.nr},0,1,6201,"Video"`)
|
||||
if (t.nr < 2) {
|
||||
// 5.7.0: jede Spur mit Codec (6), Kanälen (14), Anordnung (2), Flags
|
||||
// (22) und Beschreibung (30), wie an der Kizuna-Disc gemessen — samt
|
||||
// dem abgeleiteten DTS-Kern (Flag 0x800), den der Dialog NICHT zeigt,
|
||||
// und der „nur erzwungene"-Fassung eines Untertitels, die er zeigt.
|
||||
const spur = (nr, code, typ, lang, name, codec, kanaele, layout, flags, text) => {
|
||||
out(`SINFO:${t.nr},${nr},1,${code},"${typ}"`)
|
||||
if (layout) out(`SINFO:${t.nr},${nr},2,0,"${layout}"`)
|
||||
out(`SINFO:${t.nr},${nr},3,0,"${lang}"`)
|
||||
out(`SINFO:${t.nr},${nr},4,0,"${name}"`)
|
||||
out(`SINFO:${t.nr},${nr},6,0,"${codec}"`)
|
||||
if (kanaele) out(`SINFO:${t.nr},${nr},14,0,"${kanaele}"`)
|
||||
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)
|
||||
}
|
||||
|
||||
if (befehl === 'mkv') {
|
||||
const i = args.indexOf('mkv')
|
||||
const nr = Number(args[i + 2])
|
||||
const ziel = args[i + 3]
|
||||
if (!Number.isInteger(nr) || typeof ziel !== 'string') {
|
||||
out('MSG:5010,0,0,"Falscher Aufruf"')
|
||||
process.exit(1)
|
||||
}
|
||||
let schritt = 0
|
||||
const stufen = [10, 35, 60, 85, 100]
|
||||
const tick = () => {
|
||||
out(`PRGV:${stufen[schritt]},${stufen[schritt]},100`)
|
||||
schritt += 1
|
||||
if (schritt < stufen.length) {
|
||||
setTimeout(tick, 250)
|
||||
return
|
||||
}
|
||||
fs.mkdirSync(ziel, { recursive: true })
|
||||
const datei = path.join(ziel, `title_t${String(nr).padStart(2, '0')}.mkv`)
|
||||
// 1 MB „Roh-Material" — groß genug, dass Größen sichtbar sind.
|
||||
fs.writeFileSync(datei, Buffer.alloc(1024 * 1024, nr + 1))
|
||||
out(`MSG:5003,0,1,"1 Titel gespeichert","%1 Titel gespeichert","1"`)
|
||||
process.exit(0)
|
||||
}
|
||||
setTimeout(tick, 200)
|
||||
} else {
|
||||
out('MSG:5010,0,0,"Unbekannter Befehl"')
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
Copyright (C) 2000-2009 Josh Coalson
|
||||
Copyright (C) 2011-2025 Xiph.Org Foundation
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions
|
||||
are met:
|
||||
|
||||
- Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
|
||||
- Redistributions in binary form must reproduce the above copyright
|
||||
notice, this list of conditions and the following disclaimer in the
|
||||
documentation and/or other materials provided with the distribution.
|
||||
|
||||
- Neither the name of the Xiph.Org Foundation nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||
``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE FOUNDATION OR
|
||||
CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
|
||||
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
|
||||
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
|
||||
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
|
||||
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
|
||||
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
@@ -1,338 +0,0 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 2, June 1991
|
||||
|
||||
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
|
||||
<https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The licenses for most software are designed to take away your
|
||||
freedom to share and change it. By contrast, the GNU General Public
|
||||
License is intended to guarantee your freedom to share and change free
|
||||
software--to make sure the software is free for all its users. This
|
||||
General Public License applies to most of the Free Software
|
||||
Foundation's software and to any other program whose authors commit to
|
||||
using it. (Some other Free Software Foundation software is covered by
|
||||
the GNU Lesser General Public License instead.) You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
this service if you wish), that you receive source code or can get it
|
||||
if you want it, that you can change the software or use pieces of it
|
||||
in new free programs; and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to make restrictions that forbid
|
||||
anyone to deny you these rights or to ask you to surrender the rights.
|
||||
These restrictions translate to certain responsibilities for you if you
|
||||
distribute copies of the software, or if you modify it.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must give the recipients all the rights that
|
||||
you have. You must make sure that they, too, receive or can get the
|
||||
source code. And you must show them these terms so they know their
|
||||
rights.
|
||||
|
||||
We protect your rights with two steps: (1) copyright the software, and
|
||||
(2) offer you this license which gives you legal permission to copy,
|
||||
distribute and/or modify the software.
|
||||
|
||||
Also, for each author's protection and ours, we want to make certain
|
||||
that everyone understands that there is no warranty for this free
|
||||
software. If the software is modified by someone else and passed on, we
|
||||
want its recipients to know that what they have is not the original, so
|
||||
that any problems introduced by others will not reflect on the original
|
||||
authors' reputations.
|
||||
|
||||
Finally, any free program is threatened constantly by software
|
||||
patents. We wish to avoid the danger that redistributors of a free
|
||||
program will individually obtain patent licenses, in effect making the
|
||||
program proprietary. To prevent this, we have made it clear that any
|
||||
patent must be licensed for everyone's free use or not licensed at all.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
||||
|
||||
0. This License applies to any program or other work which contains
|
||||
a notice placed by the copyright holder saying it may be distributed
|
||||
under the terms of this General Public License. The "Program", below,
|
||||
refers to any such program or work, and a "work based on the Program"
|
||||
means either the Program or any derivative work under copyright law:
|
||||
that is to say, a work containing the Program or a portion of it,
|
||||
either verbatim or with modifications and/or translated into another
|
||||
language. (Hereinafter, translation is included without limitation in
|
||||
the term "modification".) Each licensee is addressed as "you".
|
||||
|
||||
Activities other than copying, distribution and modification are not
|
||||
covered by this License; they are outside its scope. The act of
|
||||
running the Program is not restricted, and the output from the Program
|
||||
is covered only if its contents constitute a work based on the
|
||||
Program (independent of having been made by running the Program).
|
||||
Whether that is true depends on what the Program does.
|
||||
|
||||
1. You may copy and distribute verbatim copies of the Program's
|
||||
source code as you receive it, in any medium, provided that you
|
||||
conspicuously and appropriately publish on each copy an appropriate
|
||||
copyright notice and disclaimer of warranty; keep intact all the
|
||||
notices that refer to this License and to the absence of any warranty;
|
||||
and give any other recipients of the Program a copy of this License
|
||||
along with the Program.
|
||||
|
||||
You may charge a fee for the physical act of transferring a copy, and
|
||||
you may at your option offer warranty protection in exchange for a fee.
|
||||
|
||||
2. You may modify your copy or copies of the Program or any portion
|
||||
of it, thus forming a work based on the Program, and copy and
|
||||
distribute such modifications or work under the terms of Section 1
|
||||
above, provided that you also meet all of these conditions:
|
||||
|
||||
a) You must cause the modified files to carry prominent notices
|
||||
stating that you changed the files and the date of any change.
|
||||
|
||||
b) You must cause any work that you distribute or publish, that in
|
||||
whole or in part contains or is derived from the Program or any
|
||||
part thereof, to be licensed as a whole at no charge to all third
|
||||
parties under the terms of this License.
|
||||
|
||||
c) If the modified program normally reads commands interactively
|
||||
when run, you must cause it, when started running for such
|
||||
interactive use in the most ordinary way, to print or display an
|
||||
announcement including an appropriate copyright notice and a
|
||||
notice that there is no warranty (or else, saying that you provide
|
||||
a warranty) and that users may redistribute the program under
|
||||
these conditions, and telling the user how to view a copy of this
|
||||
License. (Exception: if the Program itself is interactive but
|
||||
does not normally print such an announcement, your work based on
|
||||
the Program is not required to print an announcement.)
|
||||
|
||||
These requirements apply to the modified work as a whole. If
|
||||
identifiable sections of that work are not derived from the Program,
|
||||
and can be reasonably considered independent and separate works in
|
||||
themselves, then this License, and its terms, do not apply to those
|
||||
sections when you distribute them as separate works. But when you
|
||||
distribute the same sections as part of a whole which is a work based
|
||||
on the Program, the distribution of the whole must be on the terms of
|
||||
this License, whose permissions for other licensees extend to the
|
||||
entire whole, and thus to each and every part regardless of who wrote it.
|
||||
|
||||
Thus, it is not the intent of this section to claim rights or contest
|
||||
your rights to work written entirely by you; rather, the intent is to
|
||||
exercise the right to control the distribution of derivative or
|
||||
collective works based on the Program.
|
||||
|
||||
In addition, mere aggregation of another work not based on the Program
|
||||
with the Program (or with a work based on the Program) on a volume of
|
||||
a storage or distribution medium does not bring the other work under
|
||||
the scope of this License.
|
||||
|
||||
3. You may copy and distribute the Program (or a work based on it,
|
||||
under Section 2) in object code or executable form under the terms of
|
||||
Sections 1 and 2 above provided that you also do one of the following:
|
||||
|
||||
a) Accompany it with the complete corresponding machine-readable
|
||||
source code, which must be distributed under the terms of Sections
|
||||
1 and 2 above on a medium customarily used for software interchange; or,
|
||||
|
||||
b) Accompany it with a written offer, valid for at least three
|
||||
years, to give any third party, for a charge no more than your
|
||||
cost of physically performing source distribution, a complete
|
||||
machine-readable copy of the corresponding source code, to be
|
||||
distributed under the terms of Sections 1 and 2 above on a medium
|
||||
customarily used for software interchange; or,
|
||||
|
||||
c) Accompany it with the information you received as to the offer
|
||||
to distribute corresponding source code. (This alternative is
|
||||
allowed only for noncommercial distribution and only if you
|
||||
received the program in object code or executable form with such
|
||||
an offer, in accord with Subsection b above.)
|
||||
|
||||
The source code for a work means the preferred form of the work for
|
||||
making modifications to it. For an executable work, complete source
|
||||
code means all the source code for all modules it contains, plus any
|
||||
associated interface definition files, plus the scripts used to
|
||||
control compilation and installation of the executable. However, as a
|
||||
special exception, the source code distributed need not include
|
||||
anything that is normally distributed (in either source or binary
|
||||
form) with the major components (compiler, kernel, and so on) of the
|
||||
operating system on which the executable runs, unless that component
|
||||
itself accompanies the executable.
|
||||
|
||||
If distribution of executable or object code is made by offering
|
||||
access to copy from a designated place, then offering equivalent
|
||||
access to copy the source code from the same place counts as
|
||||
distribution of the source code, even though third parties are not
|
||||
compelled to copy the source along with the object code.
|
||||
|
||||
4. You may not copy, modify, sublicense, or distribute the Program
|
||||
except as expressly provided under this License. Any attempt
|
||||
otherwise to copy, modify, sublicense or distribute the Program is
|
||||
void, and will automatically terminate your rights under this License.
|
||||
However, parties who have received copies, or rights, from you under
|
||||
this License will not have their licenses terminated so long as such
|
||||
parties remain in full compliance.
|
||||
|
||||
5. You are not required to accept this License, since you have not
|
||||
signed it. However, nothing else grants you permission to modify or
|
||||
distribute the Program or its derivative works. These actions are
|
||||
prohibited by law if you do not accept this License. Therefore, by
|
||||
modifying or distributing the Program (or any work based on the
|
||||
Program), you indicate your acceptance of this License to do so, and
|
||||
all its terms and conditions for copying, distributing or modifying
|
||||
the Program or works based on it.
|
||||
|
||||
6. Each time you redistribute the Program (or any work based on the
|
||||
Program), the recipient automatically receives a license from the
|
||||
original licensor to copy, distribute or modify the Program subject to
|
||||
these terms and conditions. You may not impose any further
|
||||
restrictions on the recipients' exercise of the rights granted herein.
|
||||
You are not responsible for enforcing compliance by third parties to
|
||||
this License.
|
||||
|
||||
7. If, as a consequence of a court judgment or allegation of patent
|
||||
infringement or for any other reason (not limited to patent issues),
|
||||
conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot
|
||||
distribute so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you
|
||||
may not distribute the Program at all. For example, if a patent
|
||||
license would not permit royalty-free redistribution of the Program by
|
||||
all those who receive copies directly or indirectly through you, then
|
||||
the only way you could satisfy both it and this License would be to
|
||||
refrain entirely from distribution of the Program.
|
||||
|
||||
If any portion of this section is held invalid or unenforceable under
|
||||
any particular circumstance, the balance of the section is intended to
|
||||
apply and the section as a whole is intended to apply in other
|
||||
circumstances.
|
||||
|
||||
It is not the purpose of this section to induce you to infringe any
|
||||
patents or other property right claims or to contest validity of any
|
||||
such claims; this section has the sole purpose of protecting the
|
||||
integrity of the free software distribution system, which is
|
||||
implemented by public license practices. Many people have made
|
||||
generous contributions to the wide range of software distributed
|
||||
through that system in reliance on consistent application of that
|
||||
system; it is up to the author/donor to decide if he or she is willing
|
||||
to distribute software through any other system and a licensee cannot
|
||||
impose that choice.
|
||||
|
||||
This section is intended to make thoroughly clear what is believed to
|
||||
be a consequence of the rest of this License.
|
||||
|
||||
8. If the distribution and/or use of the Program is restricted in
|
||||
certain countries either by patents or by copyrighted interfaces, the
|
||||
original copyright holder who places the Program under this License
|
||||
may add an explicit geographical distribution limitation excluding
|
||||
those countries, so that distribution is permitted only in or among
|
||||
countries not thus excluded. In such case, this License incorporates
|
||||
the limitation as if written in the body of this License.
|
||||
|
||||
9. The Free Software Foundation may publish revised and/or new versions
|
||||
of the General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the Program
|
||||
specifies a version number of this License which applies to it and "any
|
||||
later version", you have the option of following the terms and conditions
|
||||
either of that version or of any later version published by the Free
|
||||
Software Foundation. If the Program does not specify a version number of
|
||||
this License, you may choose any version ever published by the Free Software
|
||||
Foundation.
|
||||
|
||||
10. If you wish to incorporate parts of the Program into other free
|
||||
programs whose distribution conditions are different, write to the author
|
||||
to ask for permission. For software which is copyrighted by the Free
|
||||
Software Foundation, write to the Free Software Foundation; we sometimes
|
||||
make exceptions for this. Our decision will be guided by the two goals
|
||||
of preserving the free status of all derivatives of our free software and
|
||||
of promoting the sharing and reuse of software generally.
|
||||
|
||||
NO WARRANTY
|
||||
|
||||
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
|
||||
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
|
||||
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
|
||||
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
|
||||
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
|
||||
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
|
||||
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
|
||||
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
|
||||
REPAIR OR CORRECTION.
|
||||
|
||||
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
|
||||
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
|
||||
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
|
||||
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
|
||||
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
|
||||
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
|
||||
POSSIBILITY OF SUCH DAMAGES.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
convey the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software; you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation; either version 2 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along
|
||||
with this program; if not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program is interactive, make it output a short notice like this
|
||||
when it starts in an interactive mode:
|
||||
|
||||
Gnomovision version 69, Copyright (C) year name of author
|
||||
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, the commands you use may
|
||||
be called something other than `show w' and `show c'; they could even be
|
||||
mouse-clicks or menu items--whatever suits your program.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or your
|
||||
school, if any, to sign a "copyright disclaimer" for the program, if
|
||||
necessary. Here is a sample; alter the names:
|
||||
|
||||
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
|
||||
`Gnomovision' (which makes passes at compilers) written by James Hacker.
|
||||
|
||||
<signature of Moe Ghoul>, 1 April 1989
|
||||
Moe Ghoul, President of Vice
|
||||
|
||||
This General Public License does not permit incorporating your program into
|
||||
proprietary programs. If your program is a subroutine library, you may
|
||||
consider it more useful to permit linking proprietary applications with the
|
||||
library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License.
|
||||
@@ -1,35 +0,0 @@
|
||||
# Mitgelieferte Werkzeuge — Lizenzen und Quellen
|
||||
|
||||
Rippy v5 liefert zwei Werkzeuge mit (KONZEPT-WINDOWS.md § 9). Weitergabe an
|
||||
Bekannte ist Weitergabe (Entscheid 9) — deshalb liegen die Lizenztexte und
|
||||
Quellverweise hier bei. MakeMKV wird NICHT mitgeliefert (proprietär); Rippy
|
||||
holt es beim Einrichten vom Hersteller.
|
||||
|
||||
## HandBrakeCLI
|
||||
|
||||
- Lizenz: GNU General Public License v2 — Volltext in
|
||||
`HANDBRAKE-GPL-2.0.txt` (bezogen von
|
||||
https://www.gnu.org/licenses/old-licenses/gpl-2.0.txt am 30.08.2026).
|
||||
- Quelltext und Projekt: https://handbrake.fr bzw.
|
||||
https://github.com/HandBrake/HandBrake — der Quelltext der
|
||||
mitgelieferten Fassung ist dort erhältlich (GPL-2 § 3).
|
||||
|
||||
## FLAC (flac.exe + libFLAC.dll)
|
||||
|
||||
- Lizenz der Kommandozeilen-Werkzeuge/Bibliothek: BSD-artig — Volltext in
|
||||
`FLAC-COPYING.Xiph` (bezogen von
|
||||
https://raw.githubusercontent.com/xiph/flac/master/COPYING.Xiph am
|
||||
30.08.2026).
|
||||
- Projekt: https://xiph.org/flac/
|
||||
- ⚠ `flac.exe` braucht `libFLAC.dll` DANEBEN — mit nur der EXE endet
|
||||
jeder Aufruf ohne eine Zeile Ausgabe (§ 9, gemessen).
|
||||
|
||||
## Schriften (im Renderer-Bundle, seit dem Kino-Mix 31.08.2026)
|
||||
|
||||
- **Bricolage Grotesque** (Display), **Instrument Sans** (Fließtext),
|
||||
**JetBrains Mono** (Technik) — alle drei unter der SIL Open Font
|
||||
License 1.1; Volltexte in `SCHRIFT-*-OFL.txt`.
|
||||
- Bezogen als npm-Pakete `@fontsource-variable/bricolage-grotesque`,
|
||||
`@fontsource/instrument-sans`, `@fontsource/jetbrains-mono` (je 5.3.0);
|
||||
Vite bündelt die woff2-Dateien ins Fenster — Rippy lädt zur Laufzeit
|
||||
keine Schriften aus dem Netz.
|
||||
@@ -1,93 +0,0 @@
|
||||
Copyright 2022 The Bricolage Grotesque Project Authors (https://github.com/ateliertriay/bricolage)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -1,93 +0,0 @@
|
||||
Copyright 2022 The Instrument Sans Project Authors (https://github.com/Instrument/instrument-sans) InstrumentSans-Italic[wdth,wght].ttf: Copyright 2022 The Instrument Sans Project Authors (https://github.com/Instrument/instrument-sans)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -1,93 +0,0 @@
|
||||
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) JetBrainsMono-Italic[wght].ttf: Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -1,266 +0,0 @@
|
||||
Version 5.7.1
|
||||
|
||||
Titel lesen: Rippy wartet, solange MakeMKV arbeitet.
|
||||
"Der Patriot" (DVD mit beschaedigter IFO-Datei) scheiterte in 5.7.0 nach
|
||||
15 Minuten, waehrend das MakeMKV-Programm die Disc fertig las: MakeMKV
|
||||
liest bei so einer Disc die ganze VOB-Datei durch, und das dauert so lange
|
||||
wie das Lesen der Disc. Die Grenze aus den Einstellungen gilt jetzt fuer
|
||||
Stille - solange MakeMKV Fortschritt oder Meldungen schickt, wartet Rippy
|
||||
(kuendigt MakeMKV den VOB-Scan an, bis zu vier Stunden). Der Dialog zeigt
|
||||
dabei, was MakeMKV gerade tut: Schritt, Prozent und letzte Meldung.
|
||||
"Abbrechen" beendet den Lauf jederzeit. Bricht Rippy doch ab, stehen die
|
||||
letzten Zeilen von MakeMKV im Protokoll.
|
||||
|
||||
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
|
||||
|
||||
Untertitel-Spuren werden wieder erkannt.
|
||||
MakeMKV schreibt den Typ eines Streams in seiner Anzeigesprache - auf einem
|
||||
deutsch eingestellten MakeMKV "Untertitel" statt "Subtitles". Rippy prueft
|
||||
seit 5.4.0 auf das englische Wort und fand deshalb keine Untertitel mehr
|
||||
(Ton heisst in beiden Sprachen "Audio", darum fiel es dort nicht auf).
|
||||
Jetzt zaehlt der Typ-Code (6202 Ton, 6203 Untertitel), der ist
|
||||
sprachunabhaengig. Gemessen an der Blu-ray "Digimon Adventure: Last
|
||||
Evolution Kizuna": drei deutsche PGS-Spuren, darunter eine nur fuer
|
||||
erzwungene Untertitel.
|
||||
|
||||
Version 5.6.0
|
||||
|
||||
Mehrere Filme auf einer Disc.
|
||||
Im Auswahl-Dialog bekommt jeder Titel "anderer Film ..." mit TMDb-Suche
|
||||
(Double Feature, Trilogie-Disc, Bonus-Film). Jeder Film erhaelt seinen
|
||||
eigenen Roh-Ordner, Vorgang und Zielordner mit Poster und Infodatei; der
|
||||
Verlauf zeigt beide.
|
||||
|
||||
Ordner und Dateien tragen den Filmnamen.
|
||||
Der Roh-Ordner heisst wie der Film ("Akira (1988)", bei Wiederholung mit
|
||||
Zusatz), die Dateien auch: "Akira (1988).mkv", zwei Fassungen "Akira (1988)
|
||||
- Fassung 1.mkv", Zugaben "Akira (1988) - Extra 01.mkv" im Unterordner
|
||||
extras. Folgen behalten ihren Namen, bis die Episoden-Zuordnung sie
|
||||
benennt; unerkannte Discs behalten den Zeitstempel-Namen.
|
||||
|
||||
Untertitel-Vorwahl je Extra.
|
||||
Bei Zugaben (z. B. japanisches Bonus-Material) legst du im Dialog fest,
|
||||
welche Untertitel-Spur beim Abspielen schon an ist - als weiche Spur,
|
||||
nichts wird eingebrannt, immer optional. Rippys Regel (fremder Ton ->
|
||||
Wunschsprache) bleibt die Vorgabe.
|
||||
|
||||
Eine Vorgangs-Leiste statt "Phase 1-4 plus Komprimierung".
|
||||
Jede Disc hat EINEN Weg mit vier Stationen: Titel lesen, Rippen,
|
||||
Komprimieren, Ablegen (das Rettungs-Abbild schiebt sich bei Bedarf davor).
|
||||
Die Leiste zeigt immer den ganzen Weg, der Segment-Balken sitzt in der
|
||||
aktiven Station: in der Laufwerks-Kachel, solange das Laufwerk arbeitet,
|
||||
danach in der Kompressions-Karte mit HandBrakes eigener Restzeit ("noch
|
||||
~12 min"), im Verlauf als vier Punkte. Die Kachel zeigt nach dem Rip nur
|
||||
noch eine Zeile, wo es weitergeht.
|
||||
|
||||
Hell-Modus.
|
||||
Einstellungen -> Allgemein -> Erscheinungsbild: Dunkel (der Kinosaal),
|
||||
Hell (Papier und Gold) oder wie Windows.
|
||||
|
||||
Dazu: Die Kopfzeile mit Minimieren, Maximieren und Schliessen bleibt beim
|
||||
Scrollen stehen.
|
||||
|
||||
Version 5.5.0
|
||||
|
||||
Uebersicht und Einstellungen neu sortiert.
|
||||
Die Bibliothek ist weg (Jellyfin IST die Bibliothek). Dafuer zeigt die
|
||||
Uebersicht den Verlauf der letzten Vorgaenge, den freien Platz auf
|
||||
Arbeitsordner und Ablage samt Bilanz (Discs, gesicherte GB, Dauer je Disc)
|
||||
und eine Fussleiste mit Kern, Datenbank, Werkzeugen, Schluessel, freiem
|
||||
Platz und Update-Stand. Der MakeMKV-Kopf ist in die Einstellungen gezogen.
|
||||
Dort gibt es eigene Karten fuer Automatik, Benachrichtigung, Allgemein und
|
||||
Update; "Allgemeines Preset" ist weg, Ton- und Untertitelsprachen sind
|
||||
Auswahl-Chips mit Dropdown in Wunschreihenfolge; Werkzeuge haben
|
||||
"Durchsuchen ...".
|
||||
|
||||
Arbeitsordner lokal, Ablage auf dem Server.
|
||||
Roh-Dateien und Kompression liegen im Arbeitsordner (lokale Platte), nur
|
||||
das fertige Ergebnis wandert in die Ablage. Vor jedem Rip prueft Rippy den
|
||||
Platz: zu wenig fuer den verlustfreien Rip heisst kein Start, knapp heisst
|
||||
Hinweis.
|
||||
|
||||
Automatik mit Countdown.
|
||||
Ist Rippy sicher (ab 90 %) und die Disc noch nicht gerippt, liest es die
|
||||
Titel und startet nach 30 s (einstellbar) mit seinem Vorschlag - mit
|
||||
"Stopp" und "Jetzt" in der Kachel. Bei Unsicherheit wartet es und sagt
|
||||
warum.
|
||||
|
||||
Serien-Merker ueber Discs hinweg.
|
||||
Disc 1 endet mit Folge 4, Disc 2 zaehlt ab 5 weiter - der Dialog schlaegt
|
||||
es vor, aenderbar.
|
||||
|
||||
Rettungs-Abbild fuer defekte Discs.
|
||||
Hat Rippy Lesefehler gesehen (Defekt-Waechter, Lesefehler-Meldung), bietet
|
||||
die Kachel "Rettungs-Abbild" an: Rippys eigener Sektor-Leser liest die Disc
|
||||
Stueck fuer Stueck, wiederholt in immer kleineren Stuecken und fuellt nur
|
||||
das wirklich Unlesbare mit Nullen; danach laeuft derselbe Rip aus dem
|
||||
Abbild, an dem MakeMKV nicht mehr haengen bleibt. Ein Bericht daneben
|
||||
nennt jede genullte Stelle. Bei einer heilen Disc passiert das nie.
|
||||
|
||||
Warteschlange pausieren, Benachrichtigung, Protokoll.
|
||||
"Pausieren" laesst den laufenden Film fertig rechnen und haelt die Schlange
|
||||
an. Discord-Webhook oder Telegram-Bot melden fertige Rips, Kompressionen
|
||||
und Fehler aufs Handy (Testknopf). Haupt und Kern schreiben eine
|
||||
Protokoll-Datei je Tag (14 Tage), "Protokoll oeffnen" unter Allgemein;
|
||||
Ordner oeffnen sich aus Verlauf, Roh-Dateien und Speicher-Karte im Explorer.
|
||||
|
||||
Erst-Einrichtung mit Poster-Kulisse.
|
||||
Acht Schritte mit denselben Bausteinen wie die Einstellungen (Ordner,
|
||||
Qualitaet mit Sprachen, Titel, Meldungen, Automatik) vor einem langsam
|
||||
laufenden Poster-Raster - Platzhalter bis zum TMDb-Schluessel, danach
|
||||
echte Poster.
|
||||
|
||||
Dazu: Das Fenster oeffnet mit 80 % des Bildschirms, mittig, mindestens
|
||||
1100 x 720, und merkt sich Lage und Groesse (nur, wenn sie noch auf einem
|
||||
Bildschirm liegt). Oberflaeche und Kern sind in Bereiche aufgeteilt.
|
||||
|
||||
Version 5.4.0
|
||||
|
||||
Das Laufwerk ist frei, sobald die Roh-Dateien liegen.
|
||||
Rip und Kompression sind getrennt: Der Rip belegt das Laufwerk, danach
|
||||
laeuft die Kompression in einer Warteschlange im Hintergrund (Karte in der
|
||||
Uebersicht). Die naechste Disc kann rein, waehrend die vorige noch
|
||||
rechnet; nach dem Rip wirft Rippy aus (abschaltbar unter Ablage).
|
||||
|
||||
Roh-Dateien raeumen sich auf.
|
||||
Jeder Rip ist jetzt ein Vorgang mit Roh-Ordner und Zielordner. Unter
|
||||
Einstellungen -> Roh-Dateien: Belegung, die Regel (Vorgabe: 7 Tage nach
|
||||
gelungener Kompression, nur wenn jede fertige Datei nachweislich da ist),
|
||||
"Neu komprimieren" mit anderem Preset ohne Neulesen der Disc, "Roh-Dateien
|
||||
loeschen" von Hand. Ordner aus der Zeit vor 5.4.0 werden nur gezeigt, nie
|
||||
von selbst geloescht. Halbe Dateien nach Abbruch oder Fehler verschwinden.
|
||||
Unkomprimierte Rips (4K verlustfrei) landen im Zielordner statt unter roh.
|
||||
|
||||
Sprachen je Titel.
|
||||
Der Auswahl-Dialog zeigt je Titel die gemessenen Ton- und Untertitelspuren
|
||||
zum Anhaken. Japanischer Ton plus deutsche Untertitel: Die Untertitel sind
|
||||
beim Abspielen vorgewaehlt. Vor der Kompression prueft Rippy die Roh-Datei,
|
||||
ob die Sprachen wirklich drin sind, und setzt MakeMKVs Auswahlregel so,
|
||||
dass alle Sprachen mitkommen.
|
||||
|
||||
Staffel und Folge von Hand.
|
||||
Bei Folgen: "Staffel 1, ab Folge 3" im Dialog, dann zaehlt Rippy
|
||||
fortlaufend in Disc-Reihenfolge; TMDb gibt nur noch eine Warnung. Zwei
|
||||
Zeilen Vorschau zeigen, wohin was landet. Der Fortschrittsbalken laeuft
|
||||
durch, nach Groesse gewichtet, statt je Titel auf null zu springen.
|
||||
|
||||
Erkennung v2 und Kino-Banner.
|
||||
Nach dem Titel-Lauf haelt Rippy die gemessene Laufzeit gegen TMDb: aus
|
||||
"wahrscheinlich" wird "bestaetigt", oder der Treffer faellt auf einen
|
||||
Vorschlag zurueck. Leere TMDb-Eintraege zaehlen nicht mehr als Treffer.
|
||||
Die Kachel zeigt Logo, Tagline, Jahr, FSK, Laufzeit, Genre, Bewertung
|
||||
und Besetzung. "Laufwerk pruefen" liest das Windows-Protokoll: Disc oder
|
||||
Hardware?
|
||||
|
||||
Dazu: Dialoge schliessen nicht mehr beim Markieren von Text; nach einem
|
||||
Neustart nimmt Rippy wartende Kompressionen wieder auf; der Datenbank-
|
||||
Pfad steht unter Programm; ein Klick-Beweis (Playwright) prueft den
|
||||
ganzen Weg durch das echte Programm.
|
||||
|
||||
Unveraendert: Updates warten, bis kein Rip und keine Kompression mehr laeuft.
|
||||
|
||||
Version 5.3.2
|
||||
|
||||
Die Titelliste bleibt jetzt stehen.
|
||||
In 5.3.1 kam die Auswahl korrekt — und Minuten spaeter legte sich ein
|
||||
"keine Antwort nach 300 s" darueber. Der Grund: Ein aelterer, laengst
|
||||
ueberholter Lese-Vorgang meldete verspaetet seinen Abbruch. Jetzt gilt je
|
||||
Laufwerk nur der NEUESTE Lauf; ein abgeloester gibt das Laufwerk sofort
|
||||
frei und schweigt. Nebenbei bremsen sich damit nicht mehr zwei Lesevorgaenge
|
||||
auf derselben Disc gegenseitig aus.
|
||||
|
||||
Und wenn es wirklich zu lange dauert, steht da jetzt ein Satz statt einer
|
||||
Fehlermeldung: Bei einer beschaedigten Disc kann das Lesen ueber fuenf
|
||||
Minuten brauchen — Rippy beendet den Versuch dann und gibt das Laufwerk frei.
|
||||
|
||||
Aus 5.3.1: Die Titel-Auswahl funktioniert (der Kern verwarf die Anfrage
|
||||
vorher still), und Rippy nennt die Ursache, wenn MakeMKV kein Laufwerk findet.
|
||||
Aus 5.3.0: Serien unter Serien/<Titel>/Season NN, Film-Extras im Unterordner.
|
||||
Aus 5.2.0: Auswahl, was gerippt wird — plus der Defekt-Waechter.
|
||||
|
||||
Unveraendert: Updates warten immer, bis kein Rip mehr laeuft.
|
||||
|
Before Width: | Height: | Size: 486 B |
@@ -1,42 +0,0 @@
|
||||
# Der Bau zum Setup (KONZEPT § 8): electron-builder → NSIS, EIN
|
||||
# RippySetup.exe. Pro Benutzer, ohne Administratorrechte, Ziel
|
||||
# %LOCALAPPDATA%\Programs\Rippy. Unsigniert (Entscheid 5) — SmartScreen-Weg
|
||||
# und Prüfsumme stehen in der Anleitung; Updates NUR über HTTPS (§ 6.8).
|
||||
appId: de.krbrz.rippy
|
||||
productName: Rippy
|
||||
directories:
|
||||
output: dist-setup
|
||||
buildResources: bau
|
||||
files:
|
||||
- out/**
|
||||
- package.json
|
||||
extraResources:
|
||||
# Mitgelieferte Werkzeuge (§ 9, Lizenzgründe in bau/lizenzen/QUELLEN.md)
|
||||
- from: vendor
|
||||
to: vendor
|
||||
- from: bau/lizenzen
|
||||
to: lizenzen
|
||||
- from: bau/tray.png
|
||||
to: bau/tray.png
|
||||
win:
|
||||
icon: bau/icon.ico
|
||||
target:
|
||||
- nsis
|
||||
nsis:
|
||||
oneClick: true
|
||||
perMachine: false
|
||||
# Drüber-Installieren funktioniert: Der Installer beendet die laufende
|
||||
# Fassung, bevor er kopiert (§ 8, der Rippy.exe.neu-Fund).
|
||||
allowToChangeInstallationDirectory: false
|
||||
deleteAppDataOnUninstall: false
|
||||
artifactName: RippySetup-${version}.${ext}
|
||||
publish:
|
||||
# Entscheid 6: das öffentliche Gitea, festes Update-Tag „aktuell" als
|
||||
# Rückfallebene (ob Gitea 1.27 releases/latest/download bedient, wird am
|
||||
# ersten echten Release gemessen — § 3.6 ⚠).
|
||||
provider: generic
|
||||
url: https://git.tobisniceshomelab.ddnsfree.com/Hitonabi/rippy/releases/download/aktuell
|
||||
electronDownload:
|
||||
# npm 11 blockt Electrons Install-Skript (§ 3.5) — electron-builder
|
||||
# lädt die Binärdateien selbst über diesen Cache-Weg.
|
||||
cache: .electron-cache
|
||||
@@ -1,70 +0,0 @@
|
||||
// Bau-Konfiguration für die drei Prozesse (KONZEPT-WINDOWS.md § 4.1):
|
||||
// haupt → out/haupt/index.js (Electron Main)
|
||||
// kern → out/haupt/kern.js (utilityProcess — als zweiter Eintrag im
|
||||
// selben Node-Bau, weil er dieselbe Umgebung hat wie haupt)
|
||||
// bruecke → out/bruecke/index.js (Preload des Fensters)
|
||||
// fenster → out/fenster/ (React)
|
||||
// externalizeDepsPlugin hält "dependencies" (koffi — natives Modul) aus dem
|
||||
// Bündel heraus; sie werden zur Laufzeit aus node_modules geladen.
|
||||
import { resolve } from 'node:path'
|
||||
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import type { Plugin } from 'vite'
|
||||
|
||||
// Strikte CSP fürs GEBAUTE Fenster (lädt nur eigene Dateien). Nur im Bau:
|
||||
// Der Dev-Server braucht Inline-Skripte (react-refresh) und WebSocket (HMR),
|
||||
// eine Dev-CSP bräche also genau das Werkzeug, das sie schützen soll.
|
||||
// Ob die Seite unter der CSP vollständig läuft, misst der Smoke-Lauf —
|
||||
// blockte sie das Skript, käme kein Pong und der Lauf würde rot.
|
||||
function cspNurImBau(): Plugin {
|
||||
return {
|
||||
name: 'csp-nur-im-bau',
|
||||
apply: 'build',
|
||||
transformIndexHtml(html) {
|
||||
return html.replace(
|
||||
'<head>',
|
||||
// img-src erlaubt TMDb-Poster (Kachel + Ändern-Dialog) — sonst
|
||||
// bleibt alles bei 'self'. font-src data: (5.4.0, im Klick-Beweis
|
||||
// gesehen): Vite bettet kleine Schrift-Teile der fontsource-Pakete
|
||||
// als data:-URI ein, und die blockte die CSP — als Fehler in der
|
||||
// Konsole, unsichtbar im Fenster.
|
||||
`<head>\n <meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src 'self' data: https://image.tmdb.org; font-src 'self' data:" />`,
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
main: {
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
build: {
|
||||
outDir: 'out/haupt',
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: resolve(__dirname, 'src/haupt/index.ts'),
|
||||
kern: resolve(__dirname, 'src/kern/index.ts'),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
preload: {
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
build: {
|
||||
outDir: 'out/bruecke',
|
||||
rollupOptions: {
|
||||
input: { index: resolve(__dirname, 'src/bruecke/index.ts') },
|
||||
},
|
||||
},
|
||||
},
|
||||
renderer: {
|
||||
root: 'src/fenster',
|
||||
plugins: [react(), tailwindcss(), cspNurImBau()],
|
||||
build: {
|
||||
outDir: 'out/fenster',
|
||||
rollupOptions: {
|
||||
input: { index: resolve(__dirname, 'src/fenster/index.html') },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,44 +0,0 @@
|
||||
{
|
||||
"name": "rippy",
|
||||
"productName": "Rippy",
|
||||
"version": "5.7.1",
|
||||
"description": "Rippy v5 — Disc-Ripping als eigenständiges Windows-Programm (KONZEPT-WINDOWS.md)",
|
||||
"main": "out/haupt/index.js",
|
||||
"author": "KrBrZ",
|
||||
"license": "UNLICENSED",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "electron-vite dev",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.bruecke.json && tsc --noEmit -p tsconfig.web.json",
|
||||
"build": "npm run typecheck && electron-vite build",
|
||||
"test": "vitest run",
|
||||
"smoke": "electron-vite build && electron . --smoke",
|
||||
"klick": "electron-vite build && node bau/klick-smoke.mjs",
|
||||
"paket": "npm run build && electron-builder --win --config electron-builder.yml",
|
||||
"werkzeug:electron": "node node_modules/electron/install.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"electron-updater": "^6.8.9",
|
||||
"koffi": "^3.1.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@fontsource-variable/bricolage-grotesque": "^5.3.0",
|
||||
"@fontsource/instrument-sans": "^5.3.0",
|
||||
"@fontsource/jetbrains-mono": "^5.3.0",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.5",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"electron": "44.0.0",
|
||||
"electron-builder": "^26.15.3",
|
||||
"electron-vite": "^5.0.0",
|
||||
"playwright": "^1.62.1",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "5.9.3",
|
||||
"vite": "^7.3.6",
|
||||
"vitest": "^4.1.11"
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
// Die Brücke (Preload) — läuft im Fenster-Prozess mit contextIsolation.
|
||||
// Sie reicht GENAU DREI Dinge in die Seite, mehr nicht:
|
||||
// 1. den MessagePort zum Kern (Muster aus der Electron-Doku
|
||||
// »MessagePorts in Electron«: window.postMessage mit Transfer über die
|
||||
// Kontextgrenze — contextBridge kann keine Ports übertragen)
|
||||
// 2. den Haupt-Status (Kanal 'haupt-status')
|
||||
// 3. rippy.melden(art) für kurze Rückmeldungen ans Haupt
|
||||
// … plus die Windows-Dialoge und Shell-Wege, die nur das Haupt öffnen kann
|
||||
// (Ordner/Datei wählen, Ordner im Explorer zeigen, Protokoll öffnen).
|
||||
//
|
||||
// Beides wird GEPUFFERT: Das Haupt schickt Port und Status direkt nach
|
||||
// did-finish-load — ob die React-Seite ihre Horcher da schon registriert
|
||||
// hat, ist Timing. Ein Port, der vor dem Horcher eintrifft, wäre sonst
|
||||
// stumm verloren (R2: nichts verlieren, nur weil eine Nachricht früh kam).
|
||||
import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron'
|
||||
|
||||
let seiteBereit = false
|
||||
const wartendePorts: IpcRendererEvent[] = []
|
||||
|
||||
ipcRenderer.on('kern-port', (ereignis) => {
|
||||
if (seiteBereit) {
|
||||
window.postMessage({ art: 'kern-port' }, '*', ereignis.ports)
|
||||
} else {
|
||||
wartendePorts.push(ereignis)
|
||||
}
|
||||
})
|
||||
|
||||
let letzterStatus: unknown = null
|
||||
const statusHorcher = new Set<(status: unknown) => void>()
|
||||
|
||||
ipcRenderer.on('haupt-status', (_ereignis, status: unknown) => {
|
||||
letzterStatus = status
|
||||
for (const horcher of statusHorcher) horcher(status)
|
||||
})
|
||||
|
||||
contextBridge.exposeInMainWorld('rippy', {
|
||||
/** Die Seite meldet: Mein 'message'-Horcher steht — gepufferte Ports jetzt. */
|
||||
bereit(): void {
|
||||
seiteBereit = true
|
||||
for (const ereignis of wartendePorts.splice(0)) {
|
||||
window.postMessage({ art: 'kern-port' }, '*', ereignis.ports)
|
||||
}
|
||||
},
|
||||
/** Haupt-Status abonnieren; der letzte bekannte Stand kommt sofort. */
|
||||
aufStatus(horcher: (status: unknown) => void): void {
|
||||
statusHorcher.add(horcher)
|
||||
if (letzterStatus !== null) horcher(letzterStatus)
|
||||
},
|
||||
/** Kurze Rückmeldung ans Haupt (z. B. 'kern-pong' im Smoke-Beweis). */
|
||||
melden(art: string): void {
|
||||
ipcRenderer.send('fenster-meldung', String(art))
|
||||
},
|
||||
/** Echter Windows-Ordnerdialog (§ 7). null = abgebrochen. */
|
||||
ordnerWaehlen(titel: string): Promise<string | null> {
|
||||
return ipcRenderer.invoke('ordner-waehlen', String(titel)) as Promise<string | null>
|
||||
},
|
||||
/** Echter Windows-Dateidialog für Programme (5.5.0: Werkzeuge → Durchsuchen). */
|
||||
dateiWaehlen(titel: string): Promise<string | null> {
|
||||
return ipcRenderer.invoke('datei-waehlen', String(titel)) as Promise<string | null>
|
||||
},
|
||||
/** Einen Ordner im Explorer zeigen (5.5.0). Liefert '' oder den Fehler. */
|
||||
ordnerOeffnen(pfad: string): Promise<string> {
|
||||
return ipcRenderer.invoke('ordner-oeffnen', String(pfad)) as Promise<string>
|
||||
},
|
||||
/** Den Protokoll-Ordner im Explorer zeigen (5.5.0). */
|
||||
protokollOeffnen(): Promise<string> {
|
||||
return ipcRenderer.invoke('protokoll-oeffnen') as Promise<string>
|
||||
},
|
||||
/** Autostart an/aus (§ 8) — liefert den tatsächlichen Zustand zurück. */
|
||||
autostartSetzen(an: boolean): Promise<boolean> {
|
||||
return ipcRenderer.invoke('autostart-setzen', an === true) as Promise<boolean>
|
||||
},
|
||||
/** Eigene Titelleiste (§ 4.2): minimieren | maximieren | schliessen.
|
||||
* „schliessen" versteckt nur — Rippy lebt im Tray weiter. */
|
||||
fensterSteuern(was: 'minimieren' | 'maximieren' | 'schliessen'): Promise<boolean> {
|
||||
return ipcRenderer.invoke('fenster-steuern', String(was)) as Promise<boolean>
|
||||
},
|
||||
/** „Jetzt prüfen" (§ 6.8) — das Ergebnis kommt über den Haupt-Status. */
|
||||
updatePruefen(): Promise<unknown> {
|
||||
return ipcRenderer.invoke('update-pruefen') as Promise<unknown>
|
||||
},
|
||||
/** „Jetzt neu starten und installieren" — antwortet mit Grund, wenn nicht. */
|
||||
updateInstallieren(): Promise<{ ok: boolean; grund: string }> {
|
||||
return ipcRenderer.invoke('update-installieren') as Promise<{ ok: boolean; grund: string }>
|
||||
},
|
||||
})
|
||||
@@ -1,196 +0,0 @@
|
||||
// Die Oberfläche im Kino-Mix (Design-Entscheid des Commanders, 31.08.2026,
|
||||
// beweise/…-Canvas „Rippy Redesign"): die Kinosaal-Bühne — warmes Schwarz,
|
||||
// Saallicht, Filmkorn, Gold, Poster mit Goldkante, Plakat-Titel — plus die
|
||||
// Schaltpult-Präzision: REC-Glühen, Info-Kacheln, Segment-Anzeige mit
|
||||
// großer Prozentzahl, Mono-Protokoll, LED-Fußleiste.
|
||||
//
|
||||
// Seit 5.5.0 ist diese Datei nur noch der RAHMEN (Separation of Concerns):
|
||||
// Kopfzeile, Bereichswahl, Fußleiste — die Bereiche selbst liegen in
|
||||
// uebersicht/, einstellungen/, einrichtung/, die Dialoge in dialoge/, das
|
||||
// Gemeinsame in bausteine.tsx. Die Regeln bleiben: EINE Einstellungs-Quelle
|
||||
// über den Kern (§ 6.7), R2 (nichts verschwindet ohne Auskunft), R4 (Fehler
|
||||
// stehen im Fenster — bei Systemfehlern als rote Karte).
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { HauptStatus } from '../gemeinsam/nachrichten'
|
||||
import {
|
||||
FensterKnopf,
|
||||
IconDisc,
|
||||
IconMaximieren,
|
||||
IconMinimieren,
|
||||
IconSchliessen,
|
||||
IconWiederherstellen,
|
||||
LAUFENDE_PHASEN,
|
||||
gbKurz,
|
||||
} from './bausteine'
|
||||
import { ErstEinrichtung } from './einrichtung/ErstEinrichtung'
|
||||
import { Einstellungen } from './einstellungen/Einstellungen'
|
||||
import { LEERE_VERBINDUNG, aufVerbindung, verbindungStarten, type Verbindung } from './kernverbindung'
|
||||
import { SEHR_WENIG_FREI_BYTES, WENIG_FREI_BYTES } from './uebersicht/SpeicherKarte'
|
||||
import { Uebersicht } from './uebersicht/Uebersicht'
|
||||
|
||||
type Bereich = 'uebersicht' | 'einstellungen'
|
||||
|
||||
function istHauptStatus(wert: unknown): wert is HauptStatus {
|
||||
return typeof wert === 'object' && wert !== null && typeof (wert as Record<string, unknown>).version === 'string'
|
||||
}
|
||||
|
||||
/** Eine LED der Fußleiste (5.5.0): grün/gelb/rot/grau plus Klartext —
|
||||
* statt PID und Ping-Millisekunden aus dem Gerüst-Beweis. */
|
||||
function Led({ ton, text, titel }: { ton: 'gruen' | 'warn' | 'rot' | 'grau'; text: string; titel?: string }) {
|
||||
const farbe = ton === 'gruen' ? 'bg-gruen' : ton === 'warn' ? 'bg-warn' : ton === 'rot' ? 'bg-rot' : 'bg-tinte/25'
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5" title={titel}>
|
||||
<span className={`inline-block h-1.5 w-1.5 rounded-full ${farbe}`} />
|
||||
{text}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [haupt, setHaupt] = useState<HauptStatus | null>(null)
|
||||
// #einstellungen als Startanker — der Smoke-Beweis fotografiert damit
|
||||
// auch die anderen Bereiche. `start` springt in den Einstellungen zu
|
||||
// einer Karte („alle unter Roh-Dateien →").
|
||||
const [bereich, setBereich] = useState<{ name: Bereich; start?: string }>(() => {
|
||||
const anker = window.location.hash.replace('#', '')
|
||||
return { name: anker === 'einstellungen' ? 'einstellungen' : 'uebersicht' }
|
||||
})
|
||||
const [verbindung, setVerbindung] = useState<Verbindung>(LEERE_VERBINDUNG)
|
||||
|
||||
useEffect(() => {
|
||||
window.rippy.aufStatus((status) => {
|
||||
if (istHauptStatus(status)) setHaupt(status)
|
||||
})
|
||||
verbindungStarten()
|
||||
return aufVerbindung(setVerbindung)
|
||||
}, [])
|
||||
|
||||
// 5.6.0 Hell-Modus: die Einstellung „design" schaltet data-design auf
|
||||
// <html> (stil.css überschreibt dann die Farb-Tokens); „system" folgt
|
||||
// Windows.
|
||||
const design = verbindung.einstellungen['design'] ?? 'dunkel'
|
||||
useEffect(() => {
|
||||
const anwenden = (): void => {
|
||||
const hell = design === 'hell' || (design === 'system' && window.matchMedia('(prefers-color-scheme: light)').matches)
|
||||
document.documentElement.dataset['design'] = hell ? 'hell' : 'dunkel'
|
||||
}
|
||||
anwenden()
|
||||
if (design !== 'system') return
|
||||
const mq = window.matchMedia('(prefers-color-scheme: light)')
|
||||
mq.addEventListener('change', anwenden)
|
||||
return () => mq.removeEventListener('change', anwenden)
|
||||
}, [design])
|
||||
|
||||
const db = verbindung.kern?.datenbank ?? null
|
||||
const einRipLaeuft = Object.values(verbindung.rips).some((r) => LAUFENDE_PHASEN.includes(r.phase))
|
||||
|
||||
// R4: Systemfehler stehen als rote Karte in der Übersicht — die
|
||||
// LED-Leiste unten zeigt nur den Daumenwert.
|
||||
const systemFehler: string[] = []
|
||||
if (haupt !== null && !haupt.leine.ok) systemFehler.push(`Prozess-Leine nicht gesetzt: ${haupt.leine.fehler ?? '?'}`)
|
||||
if (db !== null && !db.ok) systemFehler.push(`Datenbank: ${db.fehler}`)
|
||||
if (haupt !== null && !haupt.kern.laeuft && verbindung.kern === null) systemFehler.push('Der Kern läuft nicht.')
|
||||
|
||||
// § 6.9: Die Erst-Einrichtung läuft NACH der Installation, im Programm —
|
||||
// solange sie nicht abgeschlossen ist, ist sie die ganze Ansicht.
|
||||
// `#einrichtung` erzwingt sie (RIPPY_START_BEREICH).
|
||||
const setupOffen =
|
||||
window.location.hash === '#einrichtung' || (Object.keys(verbindung.einstellungen).length > 0 && verbindung.einstellungen['setup.done'] !== 'true')
|
||||
|
||||
const knopf = (ziel: Bereich, text: string): React.JSX.Element => (
|
||||
<button
|
||||
onClick={() => setBereich({ name: ziel })}
|
||||
className={`rounded-full px-4 py-1.5 text-[13px] ${
|
||||
bereich.name === ziel ? 'border border-gold/40 bg-gold/15 font-semibold text-gold-hell' : 'border border-transparent text-dunst hover:bg-tinte/5 hover:text-schnee'
|
||||
}`}
|
||||
>
|
||||
{text}
|
||||
</button>
|
||||
)
|
||||
|
||||
// ── Fußleiste (5.5.0): der Daumenwert des Systems in sechs LEDs ─────
|
||||
const w = verbindung.werkzeuge
|
||||
const werkzeugTon = w === null ? 'grau' : w.makemkvcon.length > 0 && w.handbrake.length > 0 ? 'gruen' : w.makemkvcon.length > 0 ? 'warn' : 'rot'
|
||||
const s = verbindung.schluessel
|
||||
// 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 speicherTon = arbeit === null ? 'grau' : !arbeit.ok ? 'rot' : arbeit.freiBytes < SEHR_WENIG_FREI_BYTES ? 'rot' : arbeit.freiBytes < WENIG_FREI_BYTES ? 'warn' : 'gruen'
|
||||
const updateTon = haupt === null ? 'grau' : haupt.update.neueVersion.length > 0 ? 'warn' : haupt.update.letzteErfolgreichePruefung.length > 0 ? 'gruen' : 'grau'
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-screen flex-col bg-buehne text-schnee">
|
||||
<div className="buehnenlicht pointer-events-none fixed inset-0" />
|
||||
<div className="filmkorn pointer-events-none fixed inset-0" />
|
||||
|
||||
{/* Die Kopfzeile IST die Titelleiste: Sie ist die Ziehfläche des
|
||||
rahmenlosen Fensters (§ 4.2). Alles Bedienbare darin trägt
|
||||
`nicht-ziehbar`, sonst schluckt der Griff den Klick. */}
|
||||
{/* 5.6.0: Die Kopfzeile bleibt beim Scrollen stehen (Commander) —
|
||||
sticky statt relative, mit Bühnen-Hintergrund und Weichzeichner. */}
|
||||
<header className="ziehbar sticky top-0 z-40 flex-none border-b border-tinte/8 bg-buehne/85 backdrop-blur-md">
|
||||
<div className="mx-auto flex w-full max-w-[1600px] items-center gap-3 py-2 pl-5 pr-0 sm:pl-7">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<IconDisc groesse={28} farbe="oklch(0.83 0.11 75)" />
|
||||
<h1 className="font-display text-[22px] font-bold tracking-tight sm:text-[25px]">Rippy</h1>
|
||||
<span className="hidden rounded-md border border-tinte/12 px-2 py-0.5 font-mono text-[10px] text-nebel md:inline">v{haupt?.version ?? '…'}</span>
|
||||
{einRipLaeuft && (
|
||||
<span className="inline-flex flex-none items-center gap-2 rounded-md border border-rec/45 bg-rec/12 px-2.5 py-0.5 font-mono text-[10px] tracking-[0.15em] text-[oklch(0.83_0.12_50)]">
|
||||
<span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-rec shadow-[0_0_9px_oklch(0.72_0.17_45/0.9)]" />
|
||||
REC
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<nav className="nicht-ziehbar ml-auto flex flex-wrap justify-end gap-1.5">
|
||||
{knopf('uebersicht', 'Übersicht')}
|
||||
{knopf('einstellungen', 'Einstellungen')}
|
||||
</nav>
|
||||
<div className="nicht-ziehbar ml-1 flex flex-none items-center self-start">
|
||||
<FensterKnopf titel="Minimieren" aufKlick={() => void window.rippy.fensterSteuern('minimieren')}>
|
||||
<IconMinimieren />
|
||||
</FensterKnopf>
|
||||
<FensterKnopf titel={haupt?.fensterMaximiert === true ? 'Wiederherstellen' : 'Maximieren'} aufKlick={() => void window.rippy.fensterSteuern('maximieren')}>
|
||||
{haupt?.fensterMaximiert === true ? <IconWiederherstellen /> : <IconMaximieren />}
|
||||
</FensterKnopf>
|
||||
{/* Schließen versteckt nur — Rippy lebt im Tray weiter, ein
|
||||
laufender Rip merkt davon nichts (§ 4.1 Punkt 3). */}
|
||||
<FensterKnopf titel="In den Infobereich (Rippy läuft weiter)" rot aufKlick={() => void window.rippy.fensterSteuern('schliessen')}>
|
||||
<IconSchliessen />
|
||||
</FensterKnopf>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="relative mx-auto w-full max-w-[1600px] flex-1 space-y-6 px-5 py-6 sm:px-7 sm:py-7">
|
||||
{setupOffen && <ErstEinrichtung verbindung={verbindung} />}
|
||||
{!setupOffen && bereich.name === 'uebersicht' && (
|
||||
<Uebersicht verbindung={verbindung} haupt={haupt} systemFehler={systemFehler} aufRohDateien={() => setBereich({ name: 'einstellungen', start: 'roh' })} />
|
||||
)}
|
||||
{!setupOffen && bereich.name === 'einstellungen' && <Einstellungen verbindung={verbindung} haupt={haupt} start={bereich.start} />}
|
||||
</main>
|
||||
|
||||
<footer className="relative flex flex-wrap items-center gap-x-6 gap-y-1 border-t border-tinte/8 px-7 py-3 font-mono text-[10px] tracking-[0.08em] text-nebel">
|
||||
<Led ton={verbindung.kern !== null ? 'gruen' : haupt?.kern.laeuft === true ? 'warn' : 'rot'} text="KERN" titel={verbindung.kern !== null ? `läuft (PID ${verbindung.kern.pid})` : 'wartet'} />
|
||||
<Led ton={db === null ? 'grau' : db.ok ? 'gruen' : 'rot'} text="DATENBANK" titel={db !== null && db.ok ? db.pfad : db?.fehler} />
|
||||
<Led
|
||||
ton={werkzeugTon}
|
||||
text="WERKZEUGE"
|
||||
titel={w === null ? 'werden gesucht' : `makemkvcon ${w.makemkvcon.length > 0 ? 'da' : 'FEHLT'} · HandBrake ${w.handbrake.length > 0 ? 'da' : 'fehlt'}`}
|
||||
/>
|
||||
<Led ton={schluesselTon} text="SCHLÜSSEL" titel={s?.text} />
|
||||
<Led
|
||||
ton={speicherTon}
|
||||
text={arbeit !== null && arbeit.ok ? `FREI ${gbKurz(arbeit.freiBytes)}` : 'SPEICHER'}
|
||||
titel={arbeit === null ? 'wird gemessen' : arbeit.ok ? `${verbindung.speicher?.getrennt === true ? 'Arbeitsordner' : 'Ablage'}: ${arbeit.pfad}` : arbeit.fehler}
|
||||
/>
|
||||
<Led
|
||||
ton={updateTon}
|
||||
text={haupt !== null && haupt.update.neueVersion.length > 0 ? `UPDATE ${haupt.update.neueVersion}` : `v${haupt?.version ?? '…'}${haupt !== null && haupt.update.letzteErfolgreichePruefung.length > 0 ? ' AKTUELL' : ''}`}
|
||||
titel={haupt?.update.text}
|
||||
/>
|
||||
<span className="ml-auto">Rippy v5 · LucyAI · Claude · KrBrZ</span>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,514 +0,0 @@
|
||||
// Die Bausteine der Oberfläche (5.5.0, Separation of Concerns): alles,
|
||||
// was mehr als ein Bereich braucht — Formatierer, Symbole, Badge, Kacheln,
|
||||
// Segment-Anzeige, Schalter, Felder, Karten. Der Kino-Mix (Design-Entscheid
|
||||
// des Commanders, 31.08.2026) lebt in den Tokens von stil.css; hier sind
|
||||
// nur die Klassen, die ihn tragen.
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { DiscInfoStand, GeraeteInfo, RipStatus, SprachEintrag } from '../gemeinsam/nachrichten'
|
||||
|
||||
// ── Formatierer ─────────────────────────────────────────────────────────
|
||||
export function gb(bytes: number): string {
|
||||
return `${(bytes / 1e9).toFixed(2).replace('.', ',')} GB`
|
||||
}
|
||||
|
||||
/** Größen mit passender Einheit: 412 GB, 2,2 TB. */
|
||||
export function gbKurz(bytes: number): string {
|
||||
if (bytes >= 1e12) return `${(bytes / 1e12).toFixed(1).replace('.', ',')} TB`
|
||||
if (bytes >= 1e9) return `${Math.round(bytes / 1e9)} GB`
|
||||
return `${(bytes / 1e6).toFixed(0)} MB`
|
||||
}
|
||||
|
||||
export function dauerText(s: number): string {
|
||||
const m = Math.floor(s / 60)
|
||||
return m >= 60
|
||||
? `${Math.floor(m / 60)}:${String(m % 60).padStart(2, '0')} h`
|
||||
: `${m}:${String(Math.round(s % 60)).padStart(2, '0')} min`
|
||||
}
|
||||
|
||||
/** „1 h 42 min" für Statistik und Verlauf. */
|
||||
export function dauerGrob(s: number): string {
|
||||
const m = Math.round(s / 60)
|
||||
if (m < 60) return `${m} min`
|
||||
return `${Math.floor(m / 60)} h ${String(m % 60).padStart(2, '0')} min`
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
if (iso === null || iso.length < 16) return '—'
|
||||
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 =
|
||||
* noch keine Angabe. */
|
||||
export function restText(restS: number | undefined): string {
|
||||
if (restS === undefined || restS < 0) return 'Restzeit wird berechnet …'
|
||||
if (restS < 60) return `noch ${Math.max(1, Math.round(restS))} s`
|
||||
if (restS < 3600) return `noch ~${Math.ceil(restS / 60)} min`
|
||||
const h = Math.floor(restS / 3600)
|
||||
const m = Math.round((restS - h * 3600) / 60)
|
||||
return `noch ~${h} h ${String(m).padStart(2, '0')} min`
|
||||
}
|
||||
|
||||
export function restzeitText(prozent: number, startMs: number | undefined): string {
|
||||
if (startMs === undefined || prozent <= 2 || prozent >= 100) return ''
|
||||
const verstrichen = (Date.now() - startMs) / 1000
|
||||
const restS = Math.round((verstrichen * (100 - prozent)) / prozent)
|
||||
if (restS < 90) return `REST ~${restS} S`
|
||||
return `REST ~${Math.round(restS / 60)} MIN`
|
||||
}
|
||||
|
||||
// ── TMDb-Bilder: Größen aus /configuration gemessen (01.09.2026) ────────
|
||||
export function posterUrlKlein(pfad: string): string {
|
||||
return bildUrl(pfad, 'w154')
|
||||
}
|
||||
export function posterUrlGross(pfad: string): string {
|
||||
return bildUrl(pfad, 'w342')
|
||||
}
|
||||
export function backdropUrl(pfad: string): string {
|
||||
return bildUrl(pfad, 'w780')
|
||||
}
|
||||
/** Freigestellter Schriftzug — SVG-Logos zeigt Chromium direkt. */
|
||||
export function logoUrl(pfad: string): string {
|
||||
return bildUrl(pfad, 'w300')
|
||||
}
|
||||
export function profilUrl(pfad: string): string {
|
||||
return bildUrl(pfad, 'w185')
|
||||
}
|
||||
function bildUrl(pfad: string, groesse: string): string {
|
||||
if (pfad.length === 0) return ''
|
||||
if (pfad.startsWith('http')) return pfad
|
||||
return `https://image.tmdb.org/t/p/${groesse}${pfad}`
|
||||
}
|
||||
|
||||
/** „1988 · FSK 16 · 124 Min · Animation · ★ 7,9" — nur, was da ist. */
|
||||
export function metaZeile(info: DiscInfoStand): string {
|
||||
const teile: string[] = []
|
||||
if (info.jahr !== null) teile.push(String(info.jahr))
|
||||
if (info.fsk.length > 0) teile.push(`FSK ${info.fsk}`)
|
||||
if (info.laufzeitMinuten > 0) teile.push(`${info.laufzeitMinuten} Min`)
|
||||
if (info.genres.length > 0) teile.push(info.genres.slice(0, 3).join(' · '))
|
||||
if (info.bewertung > 0) teile.push(`★ ${info.bewertung.toFixed(1).replace('.', ',')}`)
|
||||
return teile.join(' · ')
|
||||
}
|
||||
|
||||
export function sicherheitsWort(confidence: number): string {
|
||||
if (confidence >= 0.9) return 'sehr wahrscheinlich'
|
||||
if (confidence >= 0.8) return 'wahrscheinlich'
|
||||
if (confidence >= 0.6) return 'Vorschlag'
|
||||
return 'unsicher'
|
||||
}
|
||||
|
||||
/** 'Staffel 1 · Disc 2 · 2 Folgen' — was die Disc über ihre Lage in der
|
||||
* Ausgabe sagt. Die Folgenzahl stammt aus ihrem eigenen
|
||||
* Inhaltsverzeichnis, ist also gemessen und nicht geraten. '' wenn die
|
||||
* Disc nichts davon verrät. */
|
||||
export function discHerkunft(info: DiscInfoStand): string {
|
||||
const teile: string[] = []
|
||||
if (info.staffel !== null) teile.push(`Staffel ${info.staffel}`)
|
||||
if (info.discNr !== null) teile.push(`Disc ${info.discNr}`)
|
||||
if (info.folgen > 0) teile.push(info.folgen === 1 ? '1 Folge' : `${info.folgen} Folgen`)
|
||||
return teile.join(' · ')
|
||||
}
|
||||
|
||||
/** Vorbelegung der Sprachen je Titel: die Einstellung, soweit der Titel
|
||||
* sie hat — sonst alles, was er hat (japanischer Ton statt gar keiner). */
|
||||
export function sprachenVorbelegt(vorhanden: readonly SprachEintrag[], einstellung: string): string[] {
|
||||
const da = vorhanden.map((s) => s.lang)
|
||||
const wunsch = einstellung
|
||||
.split(',')
|
||||
.map((s) => s.trim().toLowerCase())
|
||||
.filter((s) => s.length > 0)
|
||||
const treffer = wunsch.filter((w) => da.includes(w))
|
||||
return treffer.length > 0 ? treffer : da
|
||||
}
|
||||
|
||||
export const TYP_NAMEN: Record<GeraeteInfo['typ'], string> = {
|
||||
cd: 'Audio-CD',
|
||||
dvd: 'DVD',
|
||||
bluray: 'Blu-ray',
|
||||
uhd: '4K-UHD',
|
||||
unknown: 'unbekannt',
|
||||
}
|
||||
|
||||
export const PHASEN_MONO: Record<RipStatus['phase'], string> = {
|
||||
'liest-info': 'LIEST TITEL-INFO',
|
||||
rettet: 'RETTUNGS-ABBILD — LIEST SEKTOR FÜR SEKTOR',
|
||||
rippt: 'RIPPT (VERLUSTFREI)',
|
||||
komprimiert: 'KOMPRIMIERT',
|
||||
'legt-ab': 'LEGT AB',
|
||||
fertig: 'FERTIG',
|
||||
fehler: 'FEHLER',
|
||||
abgebrochen: 'ABGEBROCHEN',
|
||||
}
|
||||
|
||||
export const LAUFENDE_PHASEN: readonly string[] = ['liest-info', 'rettet', 'rippt', 'komprimiert', 'legt-ab']
|
||||
|
||||
export const FELD_KLASSE =
|
||||
'mt-1 w-full rounded-lg border border-tinte/12 bg-samt px-3 py-2 font-mono text-xs text-schnee placeholder:text-nebel focus:border-gold/60 focus:outline-none'
|
||||
|
||||
export const KNOPF_LEISE = 'rounded-lg border border-tinte/15 bg-samt/60 px-3 py-1.5 text-xs text-dunst hover:text-schnee disabled:opacity-35'
|
||||
export const KNOPF_GOLD =
|
||||
'rounded-xl bg-gradient-to-b from-gold-hell to-gold-tief px-5 py-1.5 text-xs font-bold text-buehne shadow-[inset_0_1px_0_oklch(1_0_0/0.4)] hover:brightness-105 disabled:opacity-35'
|
||||
|
||||
// ── Ikonografie: eine Handvoll Strich-Symbole, alle aus demselben Guss ──
|
||||
export function IconDisc({ groesse, farbe }: { groesse: number; farbe: string }) {
|
||||
return (
|
||||
<svg width={groesse} height={groesse} viewBox="0 0 24 24" fill="none" stroke={farbe} strokeWidth="1.6">
|
||||
<circle cx="12" cy="12" r="9"></circle>
|
||||
<circle cx="12" cy="12" r="2.6"></circle>
|
||||
<path d="M12 3v3.4M12 17.6V21"></path>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function IconPlay() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M7 4.5v15l13-7.5z"></path>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function IconStop() {
|
||||
return (
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor">
|
||||
<rect x="6" y="6" width="12" height="12" rx="1.5"></rect>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function IconEject() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M5 17h14M12 4l6 9H6z"></path>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function IconStift() {
|
||||
return (
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M4 20l4.5-1L20 7.5 16.5 4 5 15.5 4 20z"></path>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Eigene Fensterknöpfe (§ 4.2, seit 5.1.3) ────────────────────────────
|
||||
// Bewusst schlicht und in Windows-Maßen (46 × 32) — wer sie sucht, findet
|
||||
// sie dort, wo er sie erwartet. Sie sitzen in der Ziehfläche und müssen
|
||||
// deshalb `nicht-ziehbar` sein, sonst verschluckt der Fenstergriff den Klick.
|
||||
export const IconMinimieren = (): React.JSX.Element => (
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true">
|
||||
<path d="M0 5h10" stroke="currentColor" strokeWidth="1.1" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const IconMaximieren = (): React.JSX.Element => (
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" aria-hidden="true">
|
||||
<rect x="0.6" y="0.6" width="8.8" height="8.8" stroke="currentColor" strokeWidth="1.1" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const IconWiederherstellen = (): React.JSX.Element => (
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" aria-hidden="true">
|
||||
<rect x="0.6" y="2.6" width="6.8" height="6.8" stroke="currentColor" strokeWidth="1.1" />
|
||||
<path d="M2.7 2.6V0.6h6.7v6.7h-2" stroke="currentColor" strokeWidth="1.1" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const IconSchliessen = (): React.JSX.Element => (
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true">
|
||||
<path d="M0.6 0.6l8.8 8.8M9.4 0.6l-8.8 8.8" stroke="currentColor" strokeWidth="1.1" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export function FensterKnopf({
|
||||
titel,
|
||||
aufKlick,
|
||||
rot = false,
|
||||
children,
|
||||
}: {
|
||||
titel: string
|
||||
aufKlick: () => void
|
||||
rot?: boolean
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={aufKlick}
|
||||
title={titel}
|
||||
aria-label={titel}
|
||||
className={`flex h-8 w-[46px] items-center justify-center text-dunst transition-colors ${
|
||||
rot ? 'hover:bg-rot hover:text-[#fff]' : 'hover:bg-tinte/10 hover:text-schnee'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Kleine Bausteine des Schaltpults ────────────────────────────────────
|
||||
export function Badge({ text, ton }: { text: string; ton: 'gruen' | 'gelb' | 'blau' | 'grau' }) {
|
||||
const farben = {
|
||||
gruen: 'border-gruen/40 bg-gruen/10 text-gruen',
|
||||
gelb: 'border-warn/40 bg-warn/10 text-warn',
|
||||
blau: 'border-gold/40 bg-gold/10 text-gold-hell',
|
||||
grau: 'border-tinte/15 bg-tinte/5 text-dunst',
|
||||
}
|
||||
return (
|
||||
<span className={`inline-block rounded-full border px-2.5 py-0.5 text-[11px] leading-4 ${farben[ton]}`}>
|
||||
{text}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** Die kleinen Mess-Kacheln aus dem Schaltpult (MEDIUM / GRÖSSE / …). */
|
||||
export function InfoKachel({ label, wert, farbe }: { label: string; wert: string; farbe?: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-tinte/10 bg-samt/70 px-3 py-2 min-w-0">
|
||||
<div className="font-mono text-[9px] tracking-[0.14em] text-nebel">{label}</div>
|
||||
<div className={`mt-0.5 truncate text-[13px] font-semibold ${farbe ?? 'text-schnee'}`} title={wert}>
|
||||
{wert}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Die goldene Aussteuerungsanzeige — 20 Segmente, gefüllt nach Prozent. */
|
||||
export function SegmentBalken({ prozent }: { prozent: number }) {
|
||||
const segmente = 20
|
||||
const anteil = Math.max(0, Math.min(100, prozent)) / 100
|
||||
const voll = Math.floor(anteil * segmente)
|
||||
const halb = anteil * segmente - voll >= 0.3
|
||||
return (
|
||||
<div className="flex gap-1">
|
||||
{Array.from({ length: segmente }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`h-3.5 flex-1 rounded-[3px] ${
|
||||
i < voll ? 'segment-gluehen' : i === voll && halb ? 'bg-gold/45' : 'bg-tinte/8'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Ein schmaler Füllbalken (Speicher): Anteil belegt, Farbe nach Lage. */
|
||||
export function FuellBalken({ anteil, ton }: { anteil: number; ton: 'gold' | 'warn' | 'rot' }) {
|
||||
const farbe = ton === 'gold' ? 'from-gold-tief to-gold-hell' : ton === 'warn' ? 'from-warn to-warn' : 'from-rot to-rot'
|
||||
return (
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-tinte/8">
|
||||
<div className={`h-full rounded-full bg-gradient-to-r ${farbe} transition-all`} style={{ width: `${Math.max(0, Math.min(100, anteil * 100))}%` }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Kicker({ text }: { text: string }) {
|
||||
return <h2 className="font-mono text-[10px] font-semibold tracking-[0.22em] text-gold/80">{text}</h2>
|
||||
}
|
||||
|
||||
/** Ein richtiger Schalter statt einer nackten Checkbox. */
|
||||
export function Schalter({ an, aufWechsel, label }: { an: boolean; aufWechsel: (an: boolean) => void; label: string }) {
|
||||
return (
|
||||
<button onClick={() => aufWechsel(!an)} className="flex items-center gap-3 text-left" role="switch" aria-checked={an}>
|
||||
<span
|
||||
className={`relative inline-block h-5 w-9 flex-none rounded-full transition-colors ${
|
||||
an ? 'bg-gradient-to-b from-gold-hell to-gold-tief' : 'bg-tinte/12'
|
||||
}`}
|
||||
>
|
||||
<span className={`absolute top-0.5 h-4 w-4 rounded-full bg-schnee shadow transition-all ${an ? 'left-[18px]' : 'left-0.5'}`} />
|
||||
</span>
|
||||
<span className="text-xs leading-5 text-dunst">{label}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/** Eine Einstellungs-Karte: Kicker, ein Satz Einordnung, dann die Felder. */
|
||||
export function SektionsKarte({
|
||||
id,
|
||||
titel,
|
||||
intro,
|
||||
children,
|
||||
}: {
|
||||
id: string
|
||||
titel: string
|
||||
intro: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<section id={id} className="scroll-mt-4 rounded-2xl border border-tinte/10 bg-karte/50 p-5">
|
||||
<Kicker text={titel} />
|
||||
<p className="mt-1.5 max-w-xl text-xs leading-5 text-nebel">{intro}</p>
|
||||
<div className="mt-4 space-y-3">{children}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/** Kleiner Status-Punkt für Schlüssel-Felder: hinterlegt oder leer. */
|
||||
export function HinterlegtPunkt({ wert }: { wert: string }) {
|
||||
return (
|
||||
<span className={`ml-2 font-mono text-[10px] ${wert.length > 0 ? 'text-gruen' : 'text-nebel'}`}>
|
||||
{wert.length > 0 ? '● hinterlegt' : '○ fehlt'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function Feld({
|
||||
name,
|
||||
wert,
|
||||
hinweis,
|
||||
geheim,
|
||||
platzhalter,
|
||||
aufAendern,
|
||||
}: {
|
||||
name: string
|
||||
wert: string
|
||||
hinweis?: string
|
||||
geheim?: boolean
|
||||
platzhalter?: string
|
||||
aufAendern: (wert: string) => void
|
||||
}) {
|
||||
const [entwurf, setEntwurf] = useState(wert)
|
||||
useEffect(() => setEntwurf(wert), [wert])
|
||||
// 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 (
|
||||
<label className="block">
|
||||
<span className="text-xs font-semibold text-dunst">{name}</span>
|
||||
{hinweis !== undefined && <span className="ml-2 text-xs text-nebel">{hinweis}</span>}
|
||||
<input
|
||||
type={geheim === true ? 'password' : 'text'}
|
||||
value={entwurf}
|
||||
placeholder={platzhalter}
|
||||
onChange={(e) => setEntwurf(e.target.value)}
|
||||
onBlur={speichern}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') speichern()
|
||||
}}
|
||||
className={FELD_KLASSE}
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
/** Ein Pfad-Feld mit Durchsuchen (Ordner oder Programm) — die echten
|
||||
* Windows-Dialoge, keine nachgebauten (§ 7). */
|
||||
export function PfadFeld({
|
||||
name,
|
||||
wert,
|
||||
hinweis,
|
||||
art,
|
||||
dialogTitel,
|
||||
aufAendern,
|
||||
}: {
|
||||
name: string
|
||||
wert: string
|
||||
hinweis?: string
|
||||
art: 'ordner' | 'programm'
|
||||
dialogTitel: string
|
||||
aufAendern: (wert: string) => void
|
||||
}) {
|
||||
const waehlen = async (): Promise<void> => {
|
||||
const gewaehlt = art === 'ordner' ? await window.rippy.ordnerWaehlen(dialogTitel) : await window.rippy.dateiWaehlen(dialogTitel)
|
||||
if (gewaehlt !== null) aufAendern(gewaehlt)
|
||||
}
|
||||
return (
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<Feld name={name} wert={wert} hinweis={hinweis} aufAendern={aufAendern} />
|
||||
</div>
|
||||
<button onClick={() => void waehlen()} className={`${KNOPF_LEISE} py-2`}>
|
||||
Durchsuchen …
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Nur ein Klick, der WIRKLICH auf der Hintergrundfläche begann, schließt
|
||||
* einen Dialog.
|
||||
*
|
||||
* Commander-Fund (01.09.2026): „es gibt einen kleinen bug im UI wenn man
|
||||
* selbst den titel sucht das suchfenster schließt sich dann einfach."
|
||||
* Der Grund ist eine bekannte Falle: Wer im Eingabefeld Text markiert und
|
||||
* dabei über den Dialogrand hinauszieht, drückt INNEN und lässt AUSSEN
|
||||
* los. Der Browser meldet als Ziel des Klicks dann den gemeinsamen
|
||||
* Vorfahren — die Hintergrundfläche. Ein reiner onClick-Horcher darauf
|
||||
* schließt den Dialog also mitten im Tippen.
|
||||
*
|
||||
* Deshalb wird der Anfang gemerkt: Nur wenn Drücken UND Loslassen auf der
|
||||
* Fläche selbst passierten, ist es ein Klick daneben. */
|
||||
export function useHintergrundKlick(schliessen: () => void): {
|
||||
onMouseDown: (e: React.MouseEvent) => void
|
||||
onClick: (e: React.MouseEvent) => void
|
||||
} {
|
||||
const begannDraussen = useRef(false)
|
||||
return {
|
||||
onMouseDown: (e) => {
|
||||
begannDraussen.current = e.target === e.currentTarget
|
||||
},
|
||||
onClick: (e) => {
|
||||
if (begannDraussen.current && e.target === e.currentTarget) schliessen()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Der Rahmen jedes Dialogs: abgedunkelte Bühne, Karte in der Mitte. */
|
||||
export function DialogRahmen({
|
||||
schliessen,
|
||||
breite,
|
||||
children,
|
||||
}: {
|
||||
schliessen: () => void
|
||||
breite: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const hintergrund = useHintergrundKlick(schliessen)
|
||||
// 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 (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-6 backdrop-blur-sm" {...hintergrund}>
|
||||
<div
|
||||
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()}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
// § 5 Schritt 4: „nein → fragen" — Suche + Auswahl der richtigen Metadaten.
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { MetaVorschlag } from '../../gemeinsam/nachrichten'
|
||||
import { DialogRahmen, IconDisc, posterUrlKlein } from '../bausteine'
|
||||
import { metadatenSuchen, zuordnungSetzen } from '../kernverbindung'
|
||||
|
||||
export function AendernDialog({
|
||||
id,
|
||||
startText,
|
||||
vorschlaege,
|
||||
schliessen,
|
||||
}: {
|
||||
id: string
|
||||
startText: string
|
||||
vorschlaege: MetaVorschlag[]
|
||||
schliessen: () => void
|
||||
}) {
|
||||
const [text, setText] = useState(startText)
|
||||
useEffect(() => {
|
||||
metadatenSuchen(id, startText)
|
||||
// Nur beim Öffnen — weitere Suchen löst der Knopf aus.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
return (
|
||||
<DialogRahmen schliessen={schliessen} breite="max-w-lg">
|
||||
<h3 className="mb-3 font-display text-lg font-bold text-schnee">Was ist das wirklich?</h3>
|
||||
<div className="mb-3 flex gap-2">
|
||||
<input
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') metadatenSuchen(id, text)
|
||||
}}
|
||||
placeholder="Titel suchen …"
|
||||
className="flex-1 rounded-lg border border-tinte/12 bg-samt px-3 py-2 font-mono text-xs text-schnee placeholder:text-nebel focus:border-gold/60 focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
onClick={() => metadatenSuchen(id, text)}
|
||||
className="rounded-lg border border-gold/50 bg-gold/10 px-4 py-2 text-xs font-semibold text-gold-hell hover:bg-gold/20"
|
||||
>
|
||||
Suchen
|
||||
</button>
|
||||
</div>
|
||||
{vorschlaege.length === 0 ? (
|
||||
<p className="font-mono text-xs text-nebel">Keine Treffer — anderen Suchtext probieren.</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{vorschlaege.map((v) => (
|
||||
<button
|
||||
key={`${v.typ}-${v.tmdbId}`}
|
||||
onClick={() => {
|
||||
zuordnungSetzen(id, v)
|
||||
schliessen()
|
||||
}}
|
||||
className="flex w-full items-center gap-3 rounded-xl border border-tinte/10 bg-karte/60 p-2 text-left hover:border-gold/50 hover:bg-karte"
|
||||
>
|
||||
{v.posterPfad.length > 0 ? (
|
||||
<img src={posterUrlKlein(v.posterPfad)} alt="" className="h-16 w-11 rounded-md object-cover" />
|
||||
) : (
|
||||
<span className="flex h-16 w-11 items-center justify-center rounded-md bg-samt">
|
||||
<IconDisc groesse={20} farbe="oklch(0.55 0.015 80)" />
|
||||
</span>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-schnee">
|
||||
{v.titel}
|
||||
{v.jahr !== null ? ` (${v.jahr})` : ''}
|
||||
</p>
|
||||
<p className="font-mono text-[10px] tracking-wider text-nebel">{v.typ === 'tv' ? 'SERIE' : 'FILM'}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4 text-right">
|
||||
<button onClick={schliessen} className="rounded-lg border border-tinte/15 px-4 py-1.5 text-xs text-dunst hover:bg-tinte/5">
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
</DialogRahmen>
|
||||
)
|
||||
}
|
||||
@@ -1,663 +0,0 @@
|
||||
// ── Auswahl: WAS soll gerippt werden? (§ 6.2) ───────────────────────────
|
||||
// Bis zum 01.09.2026 rippte Rippy IMMER alles — Trailer, Werbung und
|
||||
// Alterskennzeichen inklusive. Der Info-Lauf dauert 20–120 s, deshalb
|
||||
// beginnt er erst hier und nicht schon beim Einlegen.
|
||||
// 5.4.0: Sprachen je Titel, Staffel/Folge von Hand, Zielpfad-Vorschau.
|
||||
// 5.5.0: Platz-Prüfung vor dem Rip und der Serien-Merker (Folge von Disc 1
|
||||
// zählt auf Disc 2 weiter).
|
||||
// 5.6.0 (Commander): Je Extra WELCHE Untertitel-Spur beim Abspielen
|
||||
// vorgewählt ist (weiche Spur, nie eingebrannt, immer optional) — und je
|
||||
// Titel „anderer Film …": Double Feature, Trilogie-Disc, Bonus-Film bekommen
|
||||
// eigenen Roh-Ordner, Vorgang und Zielordner.
|
||||
// 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 { sichererName, zielVorschau } from '../../gemeinsam/ablagepfad'
|
||||
import { platzUrteil } from '../../gemeinsam/platz'
|
||||
import type {
|
||||
DiscInfoStand,
|
||||
DiscTitelVorschlag,
|
||||
GeraeteInfo,
|
||||
MetaVorschlag,
|
||||
SpeicherStand,
|
||||
SprachEintrag,
|
||||
SpurAngabe,
|
||||
TitelRolle,
|
||||
TitelStand,
|
||||
} from '../../gemeinsam/nachrichten'
|
||||
import { angeboten, sprachenDerSpuren, spurenVorbelegt, spurLabel } from '../../gemeinsam/spuren'
|
||||
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
|
||||
* (ein Knopf IM Label würde die Checkbox mit umschalten). */
|
||||
function SprachChips({
|
||||
name,
|
||||
vorhanden,
|
||||
gewaehlt,
|
||||
aufWechsel,
|
||||
}: {
|
||||
name: string
|
||||
vorhanden: readonly SprachEintrag[]
|
||||
gewaehlt: readonly string[]
|
||||
aufWechsel: (lang: string) => void
|
||||
}) {
|
||||
if (vorhanden.length === 0) return null
|
||||
return (
|
||||
<span className="inline-flex flex-wrap items-center gap-1">
|
||||
<span className="mr-0.5 font-mono text-[10px] tracking-wider text-nebel">{name}</span>
|
||||
{vorhanden.map((s) => {
|
||||
const an = gewaehlt.includes(s.lang)
|
||||
return (
|
||||
<button
|
||||
key={s.lang}
|
||||
type="button"
|
||||
onClick={() => aufWechsel(s.lang)}
|
||||
className={`rounded-md border px-1.5 py-0.5 font-mono text-[10.5px] ${
|
||||
an ? 'border-gold/50 bg-gold/15 text-gold-hell' : 'border-tinte/12 text-nebel hover:text-dunst'
|
||||
}`}
|
||||
title={`${s.sprache}${s.spuren > 1 ? ` (${s.spuren} Spuren)` : ''}`}
|
||||
>
|
||||
{an ? '✓ ' : ''}
|
||||
{s.lang}
|
||||
{s.spuren > 1 ? `×${s.spuren}` : ''}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** 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
|
||||
* kern/ablauf/auftrag.ts sprachwahl) — '' wenn nichts. */
|
||||
function regelVorwahl(audio: readonly string[], untertitel: readonly string[], wunsch: string): string {
|
||||
if (wunsch.length === 0 || audio.length === 0 || audio[0] === wunsch) return ''
|
||||
return untertitel.includes(wunsch) ? wunsch : ''
|
||||
}
|
||||
|
||||
export function TitelWahlDialog({
|
||||
id,
|
||||
typ,
|
||||
stand,
|
||||
info,
|
||||
einstellungen,
|
||||
regel,
|
||||
speicher,
|
||||
vorschlaege,
|
||||
schliessen,
|
||||
}: {
|
||||
id: string
|
||||
typ: GeraeteInfo['typ']
|
||||
stand: TitelStand | undefined
|
||||
info: DiscInfoStand | undefined
|
||||
einstellungen: Record<string, string>
|
||||
/** Klartext der Aufbewahrungs-Regel — damit klar ist, was mit dem
|
||||
* Roh-Material passiert. */
|
||||
regel: string
|
||||
speicher: SpeicherStand | null
|
||||
/** Treffer der Metadaten-Suche (für „anderer Film …"). */
|
||||
vorschlaege: MetaVorschlag[]
|
||||
schliessen: () => void
|
||||
}) {
|
||||
const [gewaehlt, setGewaehlt] = useState<Set<number> | null>(null)
|
||||
// Eigene Rollen-Wahl je Titel; was hier nicht drinsteht, behaelt Rippys
|
||||
// Vorschlag ('hauptfilm' beim Film, 'folge' bei der Serie, sonst 'extra').
|
||||
const [rollen, setRollen] = useState<Record<number, TitelRolle>>({})
|
||||
// Sprachen je Titel (5.4.0): Was hier nicht drinsteht, ist vorbelegt
|
||||
// (Einstellung, soweit der Titel sie hat — sonst alles, was er hat).
|
||||
const [sprachen, setSprachen] = useState<Record<number, { audio: string[]; untertitel: string[] }>>({})
|
||||
// 5.6.0: Untertitel-Vorwahl je Extra — fehlt: Rippys Regel; null: keine;
|
||||
// sonst die Sprache. Immer optional, nie eingebrannt.
|
||||
const [vorwahl, setVorwahl] = useState<Record<number, string | null>>({})
|
||||
// 5.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.
|
||||
const [filme, setFilme] = useState<Record<number, MetaVorschlag>>({})
|
||||
const [filmSuche, setFilmSuche] = useState<{ nr: number; text: string } | null>(null)
|
||||
// Staffel und erste Folge von Hand (5.4.0) — die Staffel kommt von der
|
||||
// Disc, die erste Folge vom Serien-Merker (5.5.0: die vorige Disc dieser
|
||||
// Serie) oder vom Nutzer („auf Disc 2 sind 3–4").
|
||||
const merker = info?.serienMerker ?? null
|
||||
const merkerGilt = merker !== null && (info?.discNr === null || info === undefined || (info.discNr ?? 1) > 1)
|
||||
const [staffel, setStaffel] = useState<string>(
|
||||
info !== undefined && info.staffel !== null ? String(info.staffel) : merker !== null ? String(merker.staffel) : '',
|
||||
)
|
||||
const [ersteFolge, setErsteFolge] = useState(merkerGilt && merker !== null ? String(merker.naechsteFolge) : '')
|
||||
// Rippys Vorauswahl gilt, bis der Nutzer sie anfasst.
|
||||
const aktiv = gewaehlt ?? new Set((stand?.titel ?? []).filter((t) => t.gewaehlt).map((t) => t.nr))
|
||||
const rolleVon = (t: DiscTitelVorschlag): TitelRolle => rollen[t.nr] ?? t.rolle
|
||||
const sprachenVon = (t: DiscTitelVorschlag): { audio: string[]; untertitel: string[] } =>
|
||||
sprachen[t.nr] ?? {
|
||||
audio: sprachenVorbelegt(t.sprachen.audio, einstellungen['audioSprachen'] ?? ''),
|
||||
untertitel: sprachenVorbelegt(t.sprachen.untertitel, einstellungen['untertitelSprachen'] ?? ''),
|
||||
}
|
||||
const sprachWechsel = (t: DiscTitelVorschlag, art: 'audio' | 'untertitel', lang: string): void => {
|
||||
const jetzt = sprachenVon(t)
|
||||
const liste = jetzt[art].includes(lang) ? jetzt[art].filter((l) => l !== lang) : [...jetzt[art], lang]
|
||||
setSprachen({ ...sprachen, [t.nr]: { ...jetzt, [art]: liste } })
|
||||
}
|
||||
const 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 neu = new Set(aktiv)
|
||||
if (neu.has(nr)) neu.delete(nr)
|
||||
else neu.add(nr)
|
||||
setGewaehlt(neu)
|
||||
}
|
||||
const wunschSprache = (einstellungen['audioSprachen'] ?? '').split(',')[0]?.trim().toLowerCase() ?? ''
|
||||
const alle = stand?.titel ?? []
|
||||
const gewaehlteTitel = alle.filter((t) => aktiv.has(t.nr))
|
||||
const gesamtBytes = gewaehlteTitel.reduce((s, t) => s + t.groesseBytes, 0)
|
||||
const alsSerie = gewaehlteTitel.some((t) => rolleVon(t) === 'folge')
|
||||
const eigeneTitel = gewaehlteTitel.filter((t) => filme[t.nr] === undefined)
|
||||
const hauptTitel = eigeneTitel.filter((t) => rolleVon(t) !== 'extra').length
|
||||
const extras = eigeneTitel.filter((t) => rolleVon(t) === 'extra').length
|
||||
// Andere Filme (5.6.0): je Film die Zahl der Titel, für die Vorschau.
|
||||
const andereFilme = new Map<number, { film: MetaVorschlag; titel: number }>()
|
||||
for (const t of gewaehlteTitel) {
|
||||
const f = filme[t.nr]
|
||||
if (f === undefined) continue
|
||||
const e = andereFilme.get(f.tmdbId)
|
||||
if (e !== undefined) e.titel += 1
|
||||
else andereFilme.set(f.tmdbId, { film: f, titel: 1 })
|
||||
}
|
||||
const zahlOderNull = (text: string, mindestens: number): number | null => {
|
||||
const n = Number(text.trim())
|
||||
return text.trim().length > 0 && Number.isInteger(n) && n >= mindestens ? n : null
|
||||
}
|
||||
const staffelZahl = zahlOderNull(staffel, 0)
|
||||
const folgeZahl = zahlOderNull(ersteFolge, 1)
|
||||
const ablage = einstellungen['ablage'] ?? 'Videos\\Rippy'
|
||||
const vorschau = zielVorschau({
|
||||
ablage,
|
||||
alsSerie,
|
||||
titel: info?.titel ?? '',
|
||||
jahr: info?.jahr ?? null,
|
||||
staffel: staffelZahl,
|
||||
ersteFolge: folgeZahl,
|
||||
hauptTitel,
|
||||
extras,
|
||||
vorgangsName: `${typ}-<Zeitstempel>`,
|
||||
})
|
||||
// Platz-Prüfung (5.5.0): gegen den Arbeitsordner — dort landen Roh und Encode.
|
||||
const komprimiert = einstellungen['transcodeEnabled'] !== 'false'
|
||||
const platz = speicher !== null && speicher.arbeit.ok ? platzUrteil(speicher.arbeit.freiBytes, gesamtBytes, komprimiert) : { text: '', hart: false }
|
||||
const filmTreffer = vorschlaege.filter((v) => v.typ === 'movie')
|
||||
|
||||
return (
|
||||
<DialogRahmen schliessen={schliessen} breite="max-w-3xl">
|
||||
<h3 className="font-display text-xl font-bold text-schnee">Was soll gerippt werden?</h3>
|
||||
|
||||
{stand === undefined || stand.laeuft ? (
|
||||
<div className="mt-4">
|
||||
<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>
|
||||
{/* 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 ? (
|
||||
<p className="mt-4 font-mono text-xs text-rot">✗ {stand.fehler}</p>
|
||||
) : (
|
||||
<>
|
||||
<p className={`mt-1 text-xs ${stand.unsicher ? 'text-warn' : 'text-dunst'}`}>
|
||||
{stand.unsicher ? '⚠ ' : ''}
|
||||
{stand.einordnung}
|
||||
</p>
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
<button
|
||||
onClick={() => setGewaehlt(new Set(alle.filter((t) => t.gewaehlt).map((t) => t.nr)))}
|
||||
className="rounded-lg border border-gold/40 bg-gold/12 px-2.5 py-1 text-[11px] text-gold-hell"
|
||||
>
|
||||
Rippys Vorschlag
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
setGewaehlt(
|
||||
// Alles Sehenswerte: Hauptinhalt und Zugaben, aber weder
|
||||
// Sammeltitel noch Logos/Warnungen.
|
||||
new Set(alle.filter((t) => !t.grund.startsWith('Sammeltitel') && !t.grund.startsWith('zu kurz')).map((t) => t.nr)),
|
||||
)
|
||||
}
|
||||
className="rounded-lg border border-tinte/15 px-2.5 py-1 text-[11px] text-dunst hover:text-schnee"
|
||||
>
|
||||
Hauptinhalt + Extras
|
||||
</button>
|
||||
<button onClick={() => setGewaehlt(new Set(alle.map((t) => t.nr)))} className="rounded-lg border border-tinte/15 px-2.5 py-1 text-[11px] text-dunst hover:text-schnee">
|
||||
Alles
|
||||
</button>
|
||||
<button onClick={() => setGewaehlt(new Set())} className="rounded-lg border border-tinte/15 px-2.5 py-1 text-[11px] text-dunst hover:text-schnee">
|
||||
Nichts
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 space-y-1">
|
||||
{alle.map((t) => {
|
||||
const an = aktiv.has(t.nr)
|
||||
const wahl = sprachenVon(t)
|
||||
const rolle = rolleVon(t)
|
||||
const film = filme[t.nr]
|
||||
const regelWahl = regelVorwahl(wahl.audio.length > 0 ? wahl.audio : t.sprachen.audio.map((s) => s.lang), wahl.untertitel, wunschSprache)
|
||||
const vorwahlWert = t.nr in vorwahl ? (vorwahl[t.nr] === null ? 'keine' : vorwahl[t.nr]!) : 'regel'
|
||||
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 (
|
||||
<div key={t.nr} className={`rounded-lg border ${an ? 'border-gold/40 bg-gold/8' : 'border-tinte/8 bg-samt/40'}`}>
|
||||
<label className="flex cursor-pointer items-center gap-3 p-2">
|
||||
<input type="checkbox" checked={an} onChange={() => umschalten(t.nr)} className="accent-[oklch(0.83_0.11_75)]" />
|
||||
<span className="w-16 flex-none font-mono text-[11px] text-nebel">Titel {t.nr}</span>
|
||||
<span className="w-24 flex-none font-mono text-xs text-schnee">{dauerText(t.dauerS)}</span>
|
||||
<span className="w-20 flex-none font-mono text-[11px] text-dunst">{gb(t.groesseBytes)}</span>
|
||||
<span className="hidden w-20 flex-none font-mono text-[11px] text-nebel sm:inline">{t.kapitel} Kap.</span>
|
||||
{an ? (
|
||||
<select
|
||||
value={rolle}
|
||||
onChange={(e) => setRollen({ ...rollen, [t.nr]: e.target.value as TitelRolle })}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex-none rounded-lg border border-tinte/15 bg-samt/70 px-2 py-0.5 text-[11px] text-dunst"
|
||||
title="Bestimmt, WOHIN der Titel abgelegt wird"
|
||||
>
|
||||
<option value="hauptfilm">Hauptfilm</option>
|
||||
<option value="extra">Extra</option>
|
||||
<option value="folge">Folge</option>
|
||||
</select>
|
||||
) : (
|
||||
<span className="min-w-0 flex-1 truncate text-[11px] text-nebel">{t.grund}</span>
|
||||
)}
|
||||
</label>
|
||||
{/* 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
|
||||
Titel gemessen hat — anhaken, was in die Datei soll.
|
||||
Der Commander-Fall: japanischer Ton, deutsche Untertitel.
|
||||
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">
|
||||
<SprachChips name="TON" vorhanden={t.sprachen.audio} gewaehlt={wahl.audio} aufWechsel={(lang) => sprachWechsel(t, 'audio', lang)} />
|
||||
<SprachChips name="UNTERTITEL" vorhanden={t.sprachen.untertitel} gewaehlt={wahl.untertitel} aufWechsel={(lang) => sprachWechsel(t, 'untertitel', lang)} />
|
||||
{wahl.audio.length === 0 && t.sprachen.audio.length > 0 && (
|
||||
<span className="font-mono text-[10.5px] text-warn">kein Ton gewählt — HandBrake nimmt die erste Spur</span>
|
||||
)}
|
||||
{/* 5.6.0: Vorwahl NUR bei Extras, IMMER optional — eine
|
||||
weiche Spur, die der Spieler von selbst einblendet;
|
||||
eingebrannt wird nie. */}
|
||||
{rolle === 'extra' && wahl.untertitel.length > 0 && (
|
||||
<label className="inline-flex items-center gap-1.5" title="Weiche Untertitel-Spur, die beim Abspielen schon an ist — nichts wird eingebrannt. Leer lassen ist immer erlaubt.">
|
||||
<span className="font-mono text-[10px] tracking-wider text-nebel">BEIM ABSPIELEN AN</span>
|
||||
<select
|
||||
value={vorwahlWert}
|
||||
onChange={(e) => {
|
||||
const w = e.target.value
|
||||
const neu = { ...vorwahl }
|
||||
if (w === 'regel') delete neu[t.nr]
|
||||
else neu[t.nr] = w === 'keine' ? null : w
|
||||
setVorwahl(neu)
|
||||
}}
|
||||
className="rounded-md border border-tinte/15 bg-samt/70 px-1.5 py-0.5 font-mono text-[10.5px] text-dunst"
|
||||
>
|
||||
<option value="regel">{regelWahl.length > 0 ? `Rippys Regel (${regelWahl})` : 'Rippys Regel (keine)'}</option>
|
||||
<option value="keine">keine</option>
|
||||
{wahl.untertitel.map((u) => (
|
||||
<option key={u} value={u}>
|
||||
{u}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* 5.6.0: „anderer Film …" — Double Feature, Trilogie-Disc,
|
||||
Bonus-Film: eigener Roh-Ordner, Vorgang und Zielordner. */}
|
||||
{an && rolle !== 'folge' && (
|
||||
<div className="flex flex-wrap items-center gap-2 border-t border-tinte/8 px-2 py-1.5 pl-9">
|
||||
{film !== undefined ? (
|
||||
<>
|
||||
<span className="inline-flex items-center gap-1.5 rounded-md border border-gold/40 bg-gold/12 px-2 py-0.5 text-[11px] text-gold-hell">
|
||||
→ eigener Film: {film.titel}
|
||||
{film.jahr !== null ? ` (${film.jahr})` : ''}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const neu = { ...filme }
|
||||
delete neu[t.nr]
|
||||
setFilme(neu)
|
||||
}}
|
||||
className="ml-1 text-nebel hover:text-rot"
|
||||
title="wieder zur Disc-Zuordnung"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFilmSuche(filmSuche?.nr === t.nr ? null : { nr: t.nr, text: '' })}
|
||||
className="rounded-md border border-tinte/15 px-2 py-0.5 text-[11px] text-nebel hover:text-schnee"
|
||||
title="Dieser Titel ist ein anderer Film (Double Feature, Bonus-Film): eigener Ordner mit Poster und Infodatei"
|
||||
>
|
||||
anderer Film …
|
||||
</button>
|
||||
)}
|
||||
{filmSuche?.nr === t.nr && (
|
||||
<div className="w-full rounded-lg border border-tinte/10 bg-buehne/70 p-2">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={filmSuche.text}
|
||||
autoFocus
|
||||
onChange={(e) => setFilmSuche({ nr: t.nr, text: e.target.value })}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') metadatenSuchen(id, filmSuche.text)
|
||||
}}
|
||||
placeholder="Filmtitel suchen …"
|
||||
className="flex-1 rounded-lg border border-tinte/12 bg-samt px-3 py-1.5 font-mono text-xs text-schnee placeholder:text-nebel focus:border-gold/60 focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => metadatenSuchen(id, filmSuche.text)}
|
||||
className="rounded-lg border border-gold/50 bg-gold/10 px-3 py-1.5 text-xs font-semibold text-gold-hell hover:bg-gold/20"
|
||||
>
|
||||
Suchen
|
||||
</button>
|
||||
</div>
|
||||
{filmTreffer.length === 0 ? (
|
||||
<p className="mt-2 font-mono text-[10.5px] text-nebel">Noch keine Treffer — Titel eingeben und suchen (braucht den TMDb-Schlüssel).</p>
|
||||
) : (
|
||||
<div className="mt-2 max-h-48 space-y-1 overflow-y-auto">
|
||||
{filmTreffer.map((v) => (
|
||||
<button
|
||||
key={v.tmdbId}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setFilme({ ...filme, [t.nr]: v })
|
||||
setFilmSuche(null)
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-lg border border-tinte/10 bg-karte/60 p-1.5 text-left hover:border-gold/50"
|
||||
>
|
||||
{v.posterPfad.length > 0 ? (
|
||||
<img src={posterUrlKlein(v.posterPfad)} alt="" className="h-10 w-7 rounded object-cover" />
|
||||
) : (
|
||||
<span className="flex h-10 w-7 items-center justify-center rounded bg-samt">
|
||||
<IconDisc groesse={14} farbe="oklch(0.55 0.015 80)" />
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-schnee">
|
||||
{v.titel}
|
||||
{v.jahr !== null ? ` (${v.jahr})` : ''}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{alle.length === 0 && <p className="font-mono text-xs text-warn">Die Disc gab keine Titel her.</p>}
|
||||
</div>
|
||||
|
||||
{alsSerie && (
|
||||
<div className="mt-3 flex flex-wrap items-end gap-3 rounded-xl border border-tinte/8 bg-samt/40 p-3">
|
||||
<label className="block">
|
||||
<span className="font-mono text-[10px] tracking-wider text-nebel">STAFFEL</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={staffel}
|
||||
onChange={(e) => setStaffel(e.target.value)}
|
||||
placeholder={info !== undefined && info.staffel !== null ? String(info.staffel) : '1'}
|
||||
className="mt-1 block w-20 rounded-lg border border-tinte/12 bg-buehne px-2 py-1 font-mono text-xs text-schnee"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="font-mono text-[10px] tracking-wider text-nebel">AB FOLGE</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={ersteFolge}
|
||||
onChange={(e) => setErsteFolge(e.target.value)}
|
||||
placeholder="automatisch"
|
||||
className="mt-1 block w-28 rounded-lg border border-tinte/12 bg-buehne px-2 py-1 font-mono text-xs text-schnee"
|
||||
/>
|
||||
</label>
|
||||
<p className="min-w-[200px] flex-1 text-[11px] leading-4 text-nebel">
|
||||
{merkerGilt && merker !== null
|
||||
? `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.'}
|
||||
{info !== undefined && info.discNr !== null && ` Die Disc nennt sich selbst Disc ${info.discNr}.`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Vorschau (5.4.0): Rolle, Serie, Staffel und Extras entscheiden
|
||||
einiges — zwei Zeilen Zielpfad ersparen Überraschungen. 5.6.0:
|
||||
jeder weitere Film bekommt seine Zeile. */}
|
||||
<div className="mt-3 rounded-xl border border-tinte/8 bg-buehne/70 p-3 font-mono text-[11px] leading-5">
|
||||
<p className="text-[10px] tracking-wider text-nebel">LANDET IN</p>
|
||||
{eigeneTitel.length > 0 && (
|
||||
<>
|
||||
<p className="break-all text-gold-hell">{vorschau[0]}</p>
|
||||
<p className="text-dunst">{vorschau[1]}</p>
|
||||
</>
|
||||
)}
|
||||
{[...andereFilme.values()].map(({ film, titel }) => (
|
||||
<p key={film.tmdbId} className="break-all text-gold-hell">
|
||||
{`${ablage.replace(/[\\/]+$/, '')}\\Filme\\${sichererName(film.titel, film.jahr) || film.titel}\\`}
|
||||
<span className="text-dunst">
|
||||
{' '}
|
||||
— {titel} {titel === 1 ? 'Titel' : 'Titel'}, eigener Vorgang mit Poster und Infodatei
|
||||
</span>
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Platz-Prüfung (5.5.0): 40 Minuten Lesen sollen nicht an einem
|
||||
vollen Laufwerk scheitern. */}
|
||||
{speicher !== null && speicher.arbeit.ok && (
|
||||
<p className={`mt-2 font-mono text-[11px] ${platz.hart ? 'text-rot' : platz.text.length > 0 ? 'text-warn' : 'text-nebel'}`}>
|
||||
{platz.text.length > 0
|
||||
? `${platz.hart ? '✗' : '⚠'} ${platz.text}`
|
||||
: `Platz: ${gbKurz(speicher.arbeit.freiBytes)} frei im Arbeitsordner — reicht für ${gb(gesamtBytes)} verlustfrei${komprimiert ? ' plus Kompression' : ''}.`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="mt-3 font-mono text-[11px] text-nebel">
|
||||
{aktiv.size} von {alle.length} gewählt · zusammen {gb(gesamtBytes)} verlustfrei
|
||||
{regel.length > 0 ? ` · ${regel}` : ''}
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] text-nebel">
|
||||
{alsSerie
|
||||
? 'Folgen landen unter Serien/… /Season NN. Ohne Vorgabe setzt Rippy die Episoden-Nummern nur, wenn die Laufzeiten eindeutig passen — sonst behalten die Dateien ihre Namen.'
|
||||
: 'Der Hauptfilm landet in seinem eigenen Ordner, Extras daneben im Unterordner „extras" — so lesen Jellyfin, Emby und Kodi sie als Zugaben.'}{' '}
|
||||
Das Laufwerk ist frei, sobald die Roh-Dateien liegen — die Kompression läuft danach im Hintergrund.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mt-5 flex justify-between gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
// 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
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
ripStarten(
|
||||
id,
|
||||
gewaehlteTitel.map((t) => {
|
||||
const wahl = sprachenVon(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 {
|
||||
nr: t.nr,
|
||||
rolle,
|
||||
dauerS: t.dauerS,
|
||||
groesseBytes: t.groesseBytes,
|
||||
audioSprachen: wahl.audio,
|
||||
untertitelSprachen: wahl.untertitel,
|
||||
...(rolle === 'extra' && t.nr in vorwahl ? { untertitelVorwahl: vorwahl[t.nr] } : {}),
|
||||
...(filme[t.nr] !== undefined ? { film: filme[t.nr] } : {}),
|
||||
}
|
||||
}),
|
||||
{ staffel: alsSerie ? staffelZahl : null, ersteFolge: alsSerie ? folgeZahl : null },
|
||||
)
|
||||
schliessen()
|
||||
}}
|
||||
disabled={aktiv.size === 0 || stand === undefined || stand.laeuft || platz.hart}
|
||||
className={KNOPF_GOLD}
|
||||
title={platz.hart ? platz.text : ''}
|
||||
>
|
||||
{aktiv.size === 1 ? 'Diesen Titel rippen' : `${aktiv.size} Titel rippen`}
|
||||
</button>
|
||||
</div>
|
||||
</DialogRahmen>
|
||||
)
|
||||
}
|
||||
@@ -1,262 +0,0 @@
|
||||
// ── Erst-Einrichtung (§ 6.9): nach der Installation, im Programm ────────
|
||||
// Zwei Regeln aus dem alten Setup: NUR ein Fehler blockiert (kein
|
||||
// Laufwerk ist eine WARNUNG — eine reine Komprimier-Maschine ist ein
|
||||
// vorgesehener Betriebsfall), und Erfolg wird am BESTAND gemessen.
|
||||
// 5.5.0: dieselben Bausteine wie die Einstellungen (Commander: „All diese
|
||||
// Dinge müssen auch in den FirstRun mit rein") — Ablage und Arbeitsordner,
|
||||
// Werkzeuge mit Durchsuchen, Kompression mit Sprach-Auswahl, TMDb,
|
||||
// Benachrichtigung, Automatik und Roh-Regel. Dazu der Poster-Hintergrund.
|
||||
import { useEffect, useState } from 'react'
|
||||
import { DEFAULT_HB_PRESET, empfehlungText, presetEmpfehlung } from '../../gemeinsam/preset-empfehlung'
|
||||
import { Badge, FELD_KLASSE, Feld, KNOPF_GOLD, KNOPF_LEISE, PfadFeld, Schalter, gbKurz } from '../bausteine'
|
||||
import { SprachAuswahl } from '../einstellungen/SprachAuswahl'
|
||||
import { benachrichtigungTesten, einstellungSetzen, posterLaden, speicherLaden, type Verbindung } from '../kernverbindung'
|
||||
import { PosterHintergrund } from './PosterHintergrund'
|
||||
|
||||
const SCHRITTE = ['Willkommen', 'Prüfung', 'Ordner', 'Qualität', 'Titel', 'Meldungen', 'Automatik', 'Fertig']
|
||||
|
||||
export function ErstEinrichtung({ verbindung }: { verbindung: Verbindung }) {
|
||||
const [schritt, setSchritt] = useState(0)
|
||||
const werte = verbindung.einstellungen
|
||||
const werkzeuge = verbindung.werkzeuge
|
||||
const speicher = verbindung.speicher
|
||||
const setzen = einstellungSetzen
|
||||
const makemkvFehlt = werkzeuge !== null && werkzeuge.makemkvcon.length === 0
|
||||
const tmdbDa = (werte['tmdbApiKey'] ?? '').length > 0
|
||||
|
||||
// Poster, sobald ein TMDb-Schlüssel da ist — auch, wenn er gerade erst
|
||||
// eingetragen wurde.
|
||||
useEffect(() => {
|
||||
if (tmdbDa) posterLaden()
|
||||
}, [tmdbDa])
|
||||
useEffect(() => {
|
||||
speicherLaden()
|
||||
}, [schritt])
|
||||
|
||||
const pruefzeilen: Array<{ ok: boolean | null; pflicht: boolean; text: string }> = [
|
||||
{
|
||||
ok: werkzeuge === null ? null : werkzeuge.makemkvcon.length > 0,
|
||||
pflicht: true,
|
||||
text:
|
||||
werkzeuge === null
|
||||
? 'MakeMKV wird gesucht …'
|
||||
: werkzeuge.makemkvcon.length > 0
|
||||
? `MakeMKV gefunden: ${werkzeuge.makemkvcon}`
|
||||
: 'MakeMKV FEHLT — ohne makemkvcon kann Rippy keine Video-Disc lesen. Von makemkv.com/download installieren, dann findet Rippy es von selbst — oder unten den Pfad wählen.',
|
||||
},
|
||||
{
|
||||
ok: werkzeuge === null ? null : werkzeuge.handbrake.length > 0,
|
||||
pflicht: false,
|
||||
text: werkzeuge === null ? 'HandBrakeCLI wird gesucht …' : werkzeuge.handbrake.length > 0 ? `HandBrakeCLI dabei: ${werkzeuge.handbrake}` : 'HandBrakeCLI fehlt — Rippen geht, Komprimieren nicht.',
|
||||
},
|
||||
{
|
||||
ok: werkzeuge === null ? null : werkzeuge.flac.length > 0,
|
||||
pflicht: false,
|
||||
text: werkzeuge === null ? 'FLAC wird gesucht …' : werkzeuge.flac.length > 0 ? `FLAC dabei: ${werkzeuge.flac}` : 'FLAC fehlt — nur für Audio-CDs nötig.',
|
||||
},
|
||||
{
|
||||
ok: verbindung.laufwerke.length > 0 ? true : null,
|
||||
pflicht: false,
|
||||
text:
|
||||
verbindung.laufwerke.length > 0
|
||||
? `${verbindung.laufwerke.length} optisches Laufwerk gefunden (${verbindung.laufwerke.map((l) => l.id + ':').join(', ')})`
|
||||
: 'Kein optisches Laufwerk — nur eine Warnung: Eine reine Komprimier-Maschine ist ein vorgesehener Betriebsfall.',
|
||||
},
|
||||
]
|
||||
|
||||
// Was Rippy an DIESER Maschine gemessen hat — die Grundlage der
|
||||
// Preset-Empfehlung (Commander-Fund 01.09.2026: sie war unsichtbar).
|
||||
const backends = werkzeuge?.backends ?? []
|
||||
const presets = werkzeuge?.presets ?? []
|
||||
const nurCpu = backends.length > 0 && backends.every((b) => b.startsWith('cpu'))
|
||||
const EMPFEHLUNGEN: ReadonlyArray<readonly [string, string]> = [
|
||||
['DVD', 'dvd'],
|
||||
['Blu-ray', 'bluray'],
|
||||
['4K-UHD', 'uhd'],
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<PosterHintergrund pfade={verbindung.poster} />
|
||||
<div className="relative mx-auto max-w-2xl space-y-7 rounded-2xl border border-tinte/10 bg-buehne/80 p-6 pt-5 shadow-2xl shadow-black/60 backdrop-blur">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{SCHRITTE.map((name, i) => (
|
||||
<span
|
||||
key={name}
|
||||
className={`rounded-full px-3 py-1 text-xs ${i === schritt ? 'border border-gold/40 bg-gold/15 text-gold-hell' : i < schritt ? 'text-gruen' : 'text-nebel'}`}
|
||||
>
|
||||
{i + 1} {name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{schritt === 0 && (
|
||||
<div className="space-y-3 text-sm text-dunst">
|
||||
<h2 className="font-display text-3xl font-bold text-schnee">Willkommen bei Rippy</h2>
|
||||
<p>Rippy sichert deine DVDs, Blu-rays und Audio-CDs — erst verlustfrei, dann komprimiert.</p>
|
||||
<p>Disc rein genügt: Rippy erkennt, was drauf ist, und startet bei sicherer Erkennung von selbst.</p>
|
||||
<p>Fertige Filme landen so abgelegt, dass Jellyfin, Emby, Kodi und Plex sie sofort erkennen.</p>
|
||||
<p>Rippy prüft gleich seine Werkzeuge und fragt dich danach ein paar Dinge: wohin, wie schnell, woher die Titel kommen, wie es sich meldet. Dauert zwei Minuten — ändern kannst du alles später.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{schritt === 1 && (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
{pruefzeilen.map((zeile, i) => (
|
||||
<p key={i} className={`font-mono text-xs ${zeile.ok === true ? 'text-gruen' : zeile.ok === null ? 'text-nebel' : zeile.pflicht ? 'text-rot' : 'text-warn'}`}>
|
||||
{zeile.ok === true ? '✓' : zeile.ok === null ? '…' : zeile.pflicht ? '✗' : '⚠'} {zeile.text}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
{makemkvFehlt && (
|
||||
<PfadFeld
|
||||
name="makemkvcon.exe von Hand wählen"
|
||||
hinweis="falls MakeMKV an einem ungewöhnlichen Ort liegt"
|
||||
wert={werte['werkzeug.makemkv'] ?? ''}
|
||||
art="programm"
|
||||
dialogTitel="makemkvcon.exe wählen"
|
||||
aufAendern={(w) => setzen('werkzeug.makemkv', w)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{schritt === 2 && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-dunst">Wohin sollen fertige Filme, Musik und Abbilder? Ein Netzlaufwerk geht — Rippy baut darin selbst die Struktur.</p>
|
||||
<PfadFeld name="Ablage-Ordner" wert={werte['ablage'] ?? ''} art="ordner" dialogTitel="Ablage-Ordner wählen" aufAendern={(w) => setzen('ablage', w)} />
|
||||
{speicher !== null && speicher.ablage.ok && <p className="font-mono text-[10.5px] text-nebel">{gbKurz(speicher.ablage.freiBytes)} frei von {gbKurz(speicher.ablage.gesamtBytes)}</p>}
|
||||
<p className="text-sm text-dunst">
|
||||
Liegt die Ablage auf einem Server, wähle einen <span className="text-schnee">Arbeitsordner auf einer lokalen Platte</span>: Dort liegen die Roh-Dateien
|
||||
(30 GB und mehr je Blu-ray) und dort rechnet HandBrake. Nur das fertige Ergebnis wandert übers Netz.
|
||||
</p>
|
||||
<PfadFeld
|
||||
name="Arbeitsordner (lokal, optional)"
|
||||
hinweis="leer = unter der Ablage"
|
||||
wert={werte['arbeitsordner'] ?? ''}
|
||||
art="ordner"
|
||||
dialogTitel="Arbeitsordner wählen (lokale Platte)"
|
||||
aufAendern={(w) => setzen('arbeitsordner', w)}
|
||||
/>
|
||||
{speicher !== null && speicher.getrennt && speicher.arbeit.ok && (
|
||||
<p className="font-mono text-[10.5px] text-nebel">{gbKurz(speicher.arbeit.freiBytes)} frei von {gbKurz(speicher.arbeit.gesamtBytes)} — eine Blu-ray braucht rund 50 GB.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{schritt === 3 && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-dunst">Rippy sichert eine Disc immer erst verlustfrei. Danach kann es eine kleinere Fassung berechnen — die kostet Zeit, spart aber viel Platz.</p>
|
||||
<Schalter an={werte['transcodeEnabled'] !== 'false'} aufWechsel={(an) => setzen('transcodeEnabled', an ? 'true' : 'false')} label="Nach dem Rip automatisch komprimieren" />
|
||||
<div className="rounded-xl border border-tinte/8 bg-samt/50 p-3">
|
||||
{werkzeuge === null ? (
|
||||
<p className="font-mono text-xs text-nebel">Encoder werden gemessen …</p>
|
||||
) : backends.length === 0 ? (
|
||||
<p className="font-mono text-xs text-warn">Keine Encoder gemessen — ohne HandBrakeCLI bleibt es beim verlustfreien Rip.</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="font-mono text-[10px] tracking-wider text-nebel">AUF DIESER MASCHINE GEMESSEN:</span>
|
||||
{backends.map((backend) => (
|
||||
<Badge key={backend} text={backend} ton={backend.startsWith('cpu') ? 'grau' : 'blau'} />
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-dunst">Danach wählt Rippy von selbst — Grafikkarte vor CPU, wo es eine gibt:</p>
|
||||
<div className="mt-2 grid gap-2 sm:grid-cols-3">
|
||||
{EMPFEHLUNGEN.map(([name, typ]) => (
|
||||
<div key={typ} className="rounded-lg border border-tinte/8 bg-buehne/60 p-2">
|
||||
<p className="font-mono text-[10px] tracking-wider text-nebel">{name}</p>
|
||||
<p className="mt-0.5 font-mono text-xs text-gold-hell">{empfehlungText(typ, presetEmpfehlung(typ, backends, presets) || DEFAULT_HB_PRESET)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{nurCpu && <p className="mt-2 text-xs text-warn">Nur CPU-Encoder gefunden — das Komprimieren dauert dann deutlich länger. Der verlustfreie Rip ist davon nicht betroffen.</p>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<SprachAuswahl name="Tonspuren" hinweis="die erste ist deine Sprache" wert={werte['audioSprachen'] ?? ''} aufAendern={(w) => setzen('audioSprachen', w)} />
|
||||
<SprachAuswahl name="Untertitel" hinweis="vorgewählt bei fremdem Ton" wert={werte['untertitelSprachen'] ?? ''} aufAendern={(w) => setzen('untertitelSprachen', w)} />
|
||||
</div>
|
||||
<p className="text-xs text-nebel">Je Disc-Typ ein eigenes Preset geht später unter „Einstellungen → Kompression" — deine Wahl schlägt die Empfehlung immer.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{schritt === 4 && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-dunst">Woher sollen Titel, Jahr, Poster und Beschreibung kommen? Dafür braucht Rippy einen kostenlosen TMDb-Schlüssel.</p>
|
||||
<Feld name="TMDb-API-Key" hinweis="kostenlos auf themoviedb.org → Einstellungen → API; in zwei Minuten geholt" wert={werte['tmdbApiKey'] ?? ''} aufAendern={(w) => setzen('tmdbApiKey', w)} />
|
||||
{tmdbDa && verbindung.poster.length > 0 && <p className="font-mono text-xs text-gruen">✓ Der Schlüssel funktioniert — die Poster im Hintergrund kommen von TMDb.</p>}
|
||||
<p className="text-xs text-nebel">Ohne Schlüssel rippt Rippy trotzdem — die Filme heißen dann so, wie die Disc sich selbst nennt, und es gibt keine Poster. Nachtragen geht jederzeit unter „Einstellungen → Metadaten".</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{schritt === 5 && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-dunst">Soll Rippy sich aufs Handy melden, wenn ein Rip fertig ist oder etwas schiefgeht? Windows-Meldungen kommen immer — das hier ist freiwillig.</p>
|
||||
<Feld name="Discord-Webhook" hinweis="Servereinstellungen → Integrationen → Webhooks" geheim wert={werte['discordWebhook'] ?? ''} aufAendern={(w) => setzen('discordWebhook', w)} />
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Feld name="Telegram-Bot-Token" hinweis="von @BotFather" geheim wert={werte['telegramToken'] ?? ''} aufAendern={(w) => setzen('telegramToken', w)} />
|
||||
<Feld name="Telegram-Chat-ID" wert={werte['telegramChatId'] ?? ''} aufAendern={(w) => setzen('telegramChatId', w)} />
|
||||
</div>
|
||||
<button onClick={() => benachrichtigungTesten()} className={KNOPF_LEISE}>
|
||||
Testnachricht schicken
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{schritt === 6 && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-dunst">Wie selbstständig soll Rippy sein?</p>
|
||||
<Schalter an={werte['automatik'] !== 'aus'} aufWechsel={(an) => setzen('automatik', an ? 'an' : 'aus')} label="Automatik: bei sicherer Erkennung (ab 90 %) nach einem Countdown von selbst rippen — mit Stopp-Knopf" />
|
||||
<label className="block max-w-xs">
|
||||
<span className="text-xs font-semibold text-dunst">Countdown</span>
|
||||
<select value={werte['automatikSekunden'] ?? '30'} onChange={(e) => setzen('automatikSekunden', e.target.value)} className={FELD_KLASSE}>
|
||||
<option value="10">10 Sekunden</option>
|
||||
<option value="30">30 Sekunden (Vorgabe)</option>
|
||||
<option value="60">1 Minute</option>
|
||||
<option value="120">2 Minuten</option>
|
||||
</select>
|
||||
</label>
|
||||
<Schalter an={werte['autoAuswurf'] !== 'false'} aufWechsel={(an) => setzen('autoAuswurf', an ? 'true' : 'false')} label="Disc nach dem Rip auswerfen — die nächste kann rein, während die Kompression läuft" />
|
||||
<label className="block max-w-sm">
|
||||
<span className="text-xs font-semibold text-dunst">Roh-Dateien nach gelungener Kompression</span>
|
||||
<select value={werte['rohAufbewahrung'] ?? '7'} onChange={(e) => setzen('rohAufbewahrung', e.target.value)} className={FELD_KLASSE}>
|
||||
<option value="sofort">sofort löschen</option>
|
||||
<option value="7">nach 7 Tagen löschen (Vorgabe)</option>
|
||||
<option value="14">nach 14 Tagen löschen</option>
|
||||
<option value="30">nach 30 Tagen löschen</option>
|
||||
<option value="behalten">behalten — nur von Hand löschen</option>
|
||||
</select>
|
||||
</label>
|
||||
<p className="text-xs text-nebel">Die Roh-Dateien sind der Grund, warum ein Preset-Wechsel kein Neulesen kostet — und gelöscht wird nur, wenn das fertige Ergebnis nachweislich da ist.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{schritt === 7 && (
|
||||
<div className="space-y-3 text-sm text-dunst">
|
||||
<p>Das war's. Disc einlegen — Rippy meldet sich.</p>
|
||||
<p className="text-xs text-nebel">Rippy lebt im Infobereich neben der Uhr: Fenster zu heißt weiterlaufen, beendet wird über das Tray-Menü.</p>
|
||||
{makemkvFehlt && <p className="text-xs text-rot">Denk an MakeMKV: Ohne makemkvcon bleibt der Video-Weg zu (Einstellungen → Werkzeuge zeigt den Stand, sobald es installiert ist).</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between">
|
||||
<button onClick={() => setSchritt((s) => Math.max(0, s - 1))} disabled={schritt === 0} className="rounded-lg border border-tinte/15 px-4 py-1.5 text-xs text-dunst hover:text-schnee disabled:opacity-35">
|
||||
Zurück
|
||||
</button>
|
||||
{schritt < SCHRITTE.length - 1 ? (
|
||||
<button onClick={() => setSchritt((s) => s + 1)} className={KNOPF_GOLD}>
|
||||
Weiter
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={() => setzen('setup.done', 'true')} className={KNOPF_GOLD}>
|
||||
Los geht's
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
// Der Poster-Hintergrund der Erst-Einrichtung (5.5.0, Commander: „diverse
|
||||
// Movie Cover die … im Hintergrund in nem Grid langsam von oben nach unten
|
||||
// laufen"). Vor dem TMDb-Schlüssel ein ruhiges Raster leerer Film-Karten,
|
||||
// danach echte Poster beliebter Filme (kern: erkennung.posterLaden). Die
|
||||
// Spalten laufen langsam, jede in eigenem Tempo, die Liste ist verdoppelt,
|
||||
// damit der Lauf nahtlos ist (Keyframes in stil.css: poster-lauf).
|
||||
import { IconDisc, posterUrlGross } from '../bausteine'
|
||||
|
||||
const SPALTEN = 7
|
||||
const JE_SPALTE = 6
|
||||
|
||||
export function PosterHintergrund({ pfade }: { pfade: string[] }) {
|
||||
const spalten: string[][] = Array.from({ length: SPALTEN }, () => [])
|
||||
pfade.forEach((p, i) => spalten[i % SPALTEN].push(p))
|
||||
return (
|
||||
<div className="pointer-events-none fixed inset-0 overflow-hidden" aria-hidden="true">
|
||||
<div className="absolute inset-0 flex gap-4 px-4 opacity-[0.5]">
|
||||
{spalten.map((spalte, i) => {
|
||||
// Auffüllen mit leeren Karten, damit jede Spalte voll ist.
|
||||
const karten: Array<string | null> = [...spalte]
|
||||
while (karten.length < JE_SPALTE) karten.push(null)
|
||||
const doppelt = [...karten, ...karten]
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="poster-spalte flex flex-1 flex-col gap-4"
|
||||
style={{
|
||||
animationDuration: `${70 + (i % 3) * 25}s`,
|
||||
animationDelay: `${-i * 9}s`,
|
||||
marginTop: `${(i % 2) * -120}px`,
|
||||
}}
|
||||
>
|
||||
{doppelt.map((p, j) =>
|
||||
p !== null ? (
|
||||
<img key={j} src={posterUrlGross(p)} alt="" className="aspect-[2/3] w-full rounded-xl object-cover ring-1 ring-gold/25" loading="lazy" />
|
||||
) : (
|
||||
<div key={j} className="flex aspect-[2/3] w-full items-center justify-center rounded-xl bg-gradient-to-br from-[oklch(0.27_0.03_75)] to-[oklch(0.19_0.02_75)] ring-1 ring-gold/30">
|
||||
<IconDisc groesse={28} farbe="oklch(0.6 0.08 75)" />
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{/* Der Verlauf ins Bühnen-Schwarz — die Poster bleiben Kulisse, der
|
||||
Assistent bleibt lesbar. */}
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-buehne/35 via-buehne/55 to-buehne/90" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
// Die Einstellungen — Sektionswahl links (oder oben im schmalen Fenster),
|
||||
// die Karten rechts. Jede Karte ist eine Datei in Karten.tsx (5.5.0).
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { HauptStatus } from '../../gemeinsam/nachrichten'
|
||||
import type { Verbindung } from '../kernverbindung'
|
||||
import {
|
||||
AblageKarte,
|
||||
AllgemeinKarte,
|
||||
AutomatikKarte,
|
||||
BenachrichtigungKarte,
|
||||
KompressionKarte,
|
||||
MakemkvKarte,
|
||||
MedienserverKarte,
|
||||
MetadatenKarte,
|
||||
RohDateienKarte,
|
||||
UpdateKarte,
|
||||
WerkzeugeKarte,
|
||||
} from './Karten'
|
||||
|
||||
const SEKTIONEN: Array<[string, string]> = [
|
||||
['ablage', 'Ablage'],
|
||||
['kompression', 'Kompression'],
|
||||
['roh', 'Roh-Dateien'],
|
||||
['automatik', 'Automatik'],
|
||||
['metadaten', 'Metadaten'],
|
||||
['makemkv', 'MakeMKV'],
|
||||
['medienserver', 'Medienserver'],
|
||||
['benachrichtigung', 'Benachrichtigung'],
|
||||
['allgemein', 'Allgemein'],
|
||||
['update', 'Update'],
|
||||
['werkzeuge', 'Werkzeuge'],
|
||||
]
|
||||
|
||||
export function Einstellungen({ verbindung, haupt, start }: { verbindung: Verbindung; haupt: HauptStatus | null; start?: string }) {
|
||||
const [aktiv, setAktiv] = useState(start ?? 'ablage')
|
||||
|
||||
const springe = (id: string): void => {
|
||||
setAktiv(id)
|
||||
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
// „alle unter Roh-Dateien →" aus der Übersicht springt direkt zur Karte.
|
||||
useEffect(() => {
|
||||
if (start !== undefined) setTimeout(() => springe(start), 50)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [start])
|
||||
|
||||
// Schmales Fenster: die Sektionswahl legt sich als Zeile über die Karten.
|
||||
// Ab lg steht sie wieder als mitlaufende Spalte daneben (Commander-Fund
|
||||
// 01.09.2026: „alles gequetscht").
|
||||
return (
|
||||
<div className="flex flex-col gap-5 lg:flex-row lg:gap-8">
|
||||
<nav className="flex flex-row flex-wrap gap-1 lg:sticky lg:top-[76px] lg:h-fit lg:w-44 lg:flex-none lg:flex-col">
|
||||
{SEKTIONEN.map(([id, name]) => (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => springe(id)}
|
||||
className={`rounded-lg px-3 py-1.5 text-left text-[13px] ${
|
||||
aktiv === id ? 'border border-gold/40 bg-gold/12 font-semibold text-gold-hell' : 'border border-transparent text-dunst hover:bg-tinte/5 hover:text-schnee'
|
||||
}`}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Kein max-w mehr: Der 672-px-Deckel war der Grund, warum in einem
|
||||
breiten Fenster alles links klebte und rechts nichts stand. */}
|
||||
<div className="min-w-0 flex-1 space-y-5">
|
||||
<AblageKarte verbindung={verbindung} />
|
||||
<KompressionKarte verbindung={verbindung} />
|
||||
<RohDateienKarte verbindung={verbindung} aktivFuer={start === 'roh'} />
|
||||
<AutomatikKarte verbindung={verbindung} />
|
||||
<MetadatenKarte verbindung={verbindung} />
|
||||
<MakemkvKarte verbindung={verbindung} />
|
||||
<MedienserverKarte verbindung={verbindung} />
|
||||
<BenachrichtigungKarte verbindung={verbindung} />
|
||||
<AllgemeinKarte verbindung={verbindung} haupt={haupt} />
|
||||
<UpdateKarte verbindung={verbindung} haupt={haupt} />
|
||||
<WerkzeugeKarte verbindung={verbindung} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,734 +0,0 @@
|
||||
// Die Einstellungs-Karten (5.5.0, je Karte eine Funktion): Ablage,
|
||||
// Kompression, Roh-Dateien, Automatik, Metadaten, MakeMKV, Medienserver,
|
||||
// Benachrichtigung, Allgemein, Update, Werkzeuge. Alle schreiben über
|
||||
// einstellungSetzen in die EINE Quelle (§ 6.7).
|
||||
import { useState } from 'react'
|
||||
import type { GeraeteInfo, HauptStatus, VorgangEintrag } from '../../gemeinsam/nachrichten'
|
||||
import { empfehlungText, presetEmpfehlung } from '../../gemeinsam/preset-empfehlung'
|
||||
import {
|
||||
Badge,
|
||||
FELD_KLASSE,
|
||||
Feld,
|
||||
HinterlegtPunkt,
|
||||
KNOPF_LEISE,
|
||||
LAUFENDE_PHASEN,
|
||||
PfadFeld,
|
||||
Schalter,
|
||||
SektionsKarte,
|
||||
TYP_NAMEN,
|
||||
datumKurz,
|
||||
gb,
|
||||
gbKurz,
|
||||
} from '../bausteine'
|
||||
import {
|
||||
benachrichtigungTesten,
|
||||
beschaffungAntworten,
|
||||
einstellungSetzen,
|
||||
fremdenOrdnerLoeschen,
|
||||
kompressionAbbrechen,
|
||||
makemkvUpdate,
|
||||
rohAufraeumen,
|
||||
schluesselPruefen,
|
||||
vorgaengeLaden,
|
||||
vorgangNeuKomprimieren,
|
||||
vorgangRohLoeschen,
|
||||
type Verbindung,
|
||||
} from '../kernverbindung'
|
||||
import { phasenTon } from '../uebersicht/VerlaufKarte'
|
||||
import { SprachAuswahl } from './SprachAuswahl'
|
||||
|
||||
const setzen = einstellungSetzen
|
||||
|
||||
export function AblageKarte({ verbindung }: { verbindung: Verbindung }) {
|
||||
const werte = verbindung.einstellungen
|
||||
const speicher = verbindung.speicher
|
||||
return (
|
||||
<SektionsKarte
|
||||
id="ablage"
|
||||
titel="ABLAGE"
|
||||
intro="Die Ablage ist, wo Fertiges landet — auch ein Netzlaufwerk. Der Arbeitsordner ist, wo Rippy rechnet: Roh-Dateien und Kompression. Liegt die Ablage auf dem Server, gehört der Arbeitsordner auf eine lokale Platte."
|
||||
>
|
||||
<PfadFeld name="Ablage-Ordner" wert={werte['ablage'] ?? ''} art="ordner" dialogTitel="Ablage-Ordner wählen" aufAendern={(w) => setzen('ablage', w)} />
|
||||
{speicher !== null && speicher.ablage.ok && (
|
||||
<p className="font-mono text-[10.5px] text-nebel">
|
||||
{gbKurz(speicher.ablage.freiBytes)} frei von {gbKurz(speicher.ablage.gesamtBytes)}
|
||||
</p>
|
||||
)}
|
||||
<PfadFeld
|
||||
name="Arbeitsordner (lokal, optional)"
|
||||
hinweis="leer = unter der Ablage; Roh-Dateien und Kompression liegen dann dort"
|
||||
wert={werte['arbeitsordner'] ?? ''}
|
||||
art="ordner"
|
||||
dialogTitel="Arbeitsordner wählen (lokale Platte)"
|
||||
aufAendern={(w) => setzen('arbeitsordner', w)}
|
||||
/>
|
||||
{speicher !== null && speicher.getrennt && speicher.arbeit.ok && (
|
||||
<p className="font-mono text-[10.5px] text-nebel">
|
||||
{gbKurz(speicher.arbeit.freiBytes)} frei von {gbKurz(speicher.arbeit.gesamtBytes)} — eine Blu-ray braucht rund 50 GB.
|
||||
</p>
|
||||
)}
|
||||
<div className="rounded-xl border border-tinte/8 bg-buehne/60 p-3 font-mono text-[11px] leading-5 text-nebel">
|
||||
<p className="text-dunst">So legt Rippy ab:</p>
|
||||
<p>
|
||||
├─ Filme\Akira (1988)\ <span className="text-nebel/70">— Film, Poster und Infodatei beieinander</span>
|
||||
</p>
|
||||
<p>
|
||||
├─ Serien\Spartacus\Season 01\ <span className="text-nebel/70">— Folgen, wie Jellyfin sie erwartet</span>
|
||||
</p>
|
||||
<p>
|
||||
├─ Musik\ <span className="text-nebel/70">— Audio-CDs als FLAC, benannt nach MusicBrainz</span>
|
||||
</p>
|
||||
<p>
|
||||
├─ ISO\ <span className="text-nebel/70">— unerkannte Discs als 1:1-Abbild</span>
|
||||
</p>
|
||||
<p>
|
||||
└─ roh\ <span className="text-nebel/70">— im Arbeitsordner: verlustfreies Roh-Material, aufgeräumt nach der Regel unter „Roh-Dateien"</span>
|
||||
</p>
|
||||
</div>
|
||||
<Schalter
|
||||
an={werte['autoAuswurf'] !== 'false'}
|
||||
aufWechsel={(an) => setzen('autoAuswurf', an ? 'true' : 'false')}
|
||||
label="Disc nach dem Rip auswerfen — die nächste kann rein, während die Kompression läuft"
|
||||
/>
|
||||
</SektionsKarte>
|
||||
)
|
||||
}
|
||||
|
||||
function PresetWahl({
|
||||
name,
|
||||
schluessel,
|
||||
wert,
|
||||
presets,
|
||||
empfehlung,
|
||||
}: {
|
||||
name: string
|
||||
schluessel: string
|
||||
wert: string
|
||||
presets: string[]
|
||||
empfehlung: string
|
||||
}) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="text-xs font-semibold text-dunst">{name}</span>
|
||||
<select value={wert} onChange={(e) => setzen(schluessel, e.target.value)} className={FELD_KLASSE}>
|
||||
<option value="">{empfehlung.length > 0 ? `automatisch: ${empfehlung}` : 'automatisch'}</option>
|
||||
<option value="keine">NICHT komprimieren (verlustfrei lassen)</option>
|
||||
{presets.map((preset) => (
|
||||
<option key={preset} value={preset}>
|
||||
{preset}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
export function KompressionKarte({ verbindung }: { verbindung: Verbindung }) {
|
||||
const werte = verbindung.einstellungen
|
||||
const presets = verbindung.werkzeuge?.presets ?? []
|
||||
const backends = verbindung.werkzeuge?.backends ?? []
|
||||
const empfehlungFuer = (typ: string): string => empfehlungText(typ, presetEmpfehlung(typ, backends, presets))
|
||||
return (
|
||||
<SektionsKarte
|
||||
id="kompression"
|
||||
titel="KOMPRESSION"
|
||||
intro="Nach dem verlustfreien Rip rechnet HandBrake die Filme auf Arbeitsgröße — der Rohling bleibt bis zum Erfolg liegen, es geht nie etwas verloren."
|
||||
>
|
||||
{verbindung.werkzeuge !== null && verbindung.werkzeuge.handbrake.length === 0 && (
|
||||
<p className="font-mono text-xs text-warn">⚠ HandBrakeCLI fehlt — Rippen geht, Komprimieren nicht (siehe Werkzeuge).</p>
|
||||
)}
|
||||
<Schalter an={werte['transcodeEnabled'] !== 'false'} aufWechsel={(an) => setzen('transcodeEnabled', an ? 'true' : 'false')} label="Nach dem Rip automatisch komprimieren" />
|
||||
{backends.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="font-mono text-[10px] tracking-wider text-nebel">GEMESSENE ENCODER:</span>
|
||||
{backends.map((backend) => (
|
||||
<Badge key={backend} text={backend} ton={backend.startsWith('cpu') ? 'grau' : 'blau'} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<PresetWahl name="DVD" schluessel="transcodePresetDvd" wert={werte['transcodePresetDvd'] ?? ''} presets={presets} empfehlung={empfehlungFuer('dvd')} />
|
||||
<PresetWahl name="Blu-ray" schluessel="transcodePresetBluray" wert={werte['transcodePresetBluray'] ?? ''} presets={presets} empfehlung={empfehlungFuer('bluray')} />
|
||||
<PresetWahl name="4K-UHD" schluessel="transcodePresetUhd" wert={werte['transcodePresetUhd'] ?? ''} presets={presets} empfehlung={empfehlungFuer('uhd')} />
|
||||
</div>
|
||||
<p className="text-[11px] leading-4 text-nebel">
|
||||
„automatisch" nimmt die Empfehlung aus der gemessenen Liste deines HandBrake — auf dieser Maschine die Grafikkarte, wo es sie gibt. Eine eigene Wahl
|
||||
schlägt die Empfehlung immer. 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>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<SprachAuswahl
|
||||
name="Tonspuren"
|
||||
hinweis="Reihenfolge = Wunschreihenfolge; die erste ist deine Sprache"
|
||||
wert={werte['audioSprachen'] ?? ''}
|
||||
aufAendern={(w) => setzen('audioSprachen', w)}
|
||||
/>
|
||||
<SprachAuswahl name="Untertitel" hinweis="werden vorgewählt, wenn der Ton nicht deine Sprache ist" wert={werte['untertitelSprachen'] ?? ''} aufAendern={(w) => setzen('untertitelSprachen', w)} />
|
||||
</div>
|
||||
</SektionsKarte>
|
||||
)
|
||||
}
|
||||
|
||||
export function AutomatikKarte({ verbindung }: { verbindung: Verbindung }) {
|
||||
const werte = verbindung.einstellungen
|
||||
const an = werte['automatik'] !== 'aus'
|
||||
return (
|
||||
<SektionsKarte
|
||||
id="automatik"
|
||||
titel="AUTOMATIK"
|
||||
intro="Disc rein genügt: Ist Rippy sicher, was das ist, liest es die Titel und startet nach einem Countdown von selbst — mit seinem Vorschlag. Wo es unsicher ist, wartet es auf dich."
|
||||
>
|
||||
<Schalter an={an} aufWechsel={(a) => setzen('automatik', a ? 'an' : 'aus')} label="Automatik: bei sicherer Erkennung (ab 90 %) von selbst rippen" />
|
||||
<label className="block max-w-xs">
|
||||
<span className="text-xs font-semibold text-dunst">Countdown</span>
|
||||
<select value={werte['automatikSekunden'] ?? '30'} onChange={(e) => setzen('automatikSekunden', e.target.value)} disabled={!an} className={FELD_KLASSE}>
|
||||
<option value="10">10 Sekunden</option>
|
||||
<option value="30">30 Sekunden (Vorgabe)</option>
|
||||
<option value="60">1 Minute</option>
|
||||
<option value="120">2 Minuten</option>
|
||||
</select>
|
||||
</label>
|
||||
<p className="text-[11px] leading-4 text-nebel">
|
||||
Der Countdown steht in der Laufwerks-Kachel, mit „Stopp" und „Jetzt". Eine Disc, die schon einmal gerippt wurde, startet nie von selbst.
|
||||
</p>
|
||||
</SektionsKarte>
|
||||
)
|
||||
}
|
||||
|
||||
export function RohDateienKarte({ verbindung, aktivFuer }: { verbindung: Verbindung; aktivFuer?: boolean }) {
|
||||
const werte = verbindung.einstellungen
|
||||
const v = verbindung.vorgaenge
|
||||
const [presetWahl, setPresetWahl] = useState<Record<string, string>>({})
|
||||
const [alleZeigen, setAlleZeigen] = useState(aktivFuer === true)
|
||||
// 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 regelWert = werte['rohAufbewahrung'] ?? '7'
|
||||
const phasenText: Record<VorgangEintrag['phase'], string> = {
|
||||
rippt: 'rippt',
|
||||
wartet: 'wartet auf Kompression',
|
||||
komprimiert: 'komprimiert',
|
||||
'legt-ab': 'legt ab',
|
||||
fertig: 'fertig',
|
||||
fehler: 'Fehler',
|
||||
abgebrochen: 'abgebrochen',
|
||||
}
|
||||
const liste = v?.vorgaenge ?? []
|
||||
const sichtbar = alleZeigen ? liste : liste.slice(0, 12)
|
||||
|
||||
return (
|
||||
<SektionsKarte
|
||||
id="roh"
|
||||
titel="ROH-DATEIEN"
|
||||
intro="Jede Blu-ray lässt 30 GB und mehr verlustfreies Roh-Material liegen — genau dafür, dass ein Preset-Wechsel kein Neulesen kostet. Aufgeräumt wird nach Regel, und nur, wenn das fertige Ergebnis nachweislich da ist."
|
||||
>
|
||||
<div className="grid gap-3 sm:grid-cols-[1fr_auto] sm:items-end">
|
||||
<label className="block">
|
||||
<span className="text-xs font-semibold text-dunst">Nach gelungener Kompression</span>
|
||||
<select value={regelWert} onChange={(e) => setzen('rohAufbewahrung', e.target.value)} className={FELD_KLASSE}>
|
||||
<option value="sofort">sofort löschen</option>
|
||||
<option value="7">nach 7 Tagen löschen (Vorgabe)</option>
|
||||
<option value="14">nach 14 Tagen löschen</option>
|
||||
<option value="30">nach 30 Tagen löschen</option>
|
||||
<option value="behalten">behalten — nur von Hand löschen</option>
|
||||
</select>
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => rohAufraeumen()} className={`${KNOPF_LEISE} py-2`} title="Die Regel jetzt anwenden, statt auf den täglichen Takt zu warten">
|
||||
Jetzt aufräumen
|
||||
</button>
|
||||
<button onClick={() => vorgaengeLaden()} className={`${KNOPF_LEISE} py-2`} title="Belegung neu messen">
|
||||
Aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-tinte/8 bg-buehne/60 p-3 font-mono text-[11px] leading-5 text-nebel">
|
||||
{v === null ? (
|
||||
<p>Belegung wird gemessen …</p>
|
||||
) : (
|
||||
<>
|
||||
<p>
|
||||
<span className="text-dunst">Belegung jetzt:</span> <span className="text-gold-hell">{gb(v.rohGesamtBytes)}</span> in{' '}
|
||||
{liste.filter((e) => !e.rohGeloescht).length + v.fremde.length} Roh-Ordnern
|
||||
</p>
|
||||
<p>{v.regel}</p>
|
||||
<p className="text-nebel/70">Unkomprimierte Rips (z. B. 4K verlustfrei) liegen im Zielordner, nicht hier — sie sind nie betroffen.</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{liste.length === 0 && v !== null && <p className="font-mono text-xs text-nebel">Noch kein Vorgang — der erste Rip legt hier einen Eintrag an.</p>}
|
||||
<div className="space-y-2">
|
||||
{sichtbar.map((e) => {
|
||||
const ruhig = e.phase === 'fertig' || e.phase === 'fehler' || e.phase === 'abgebrochen'
|
||||
return (
|
||||
<div key={e.id} className="rounded-xl border border-tinte/8 bg-buehne/60 p-3">
|
||||
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||
<span className="font-display text-[14px] font-bold text-schnee">
|
||||
{e.titel}
|
||||
{e.jahr !== null && <span className="ml-1 font-medium text-dunst">({e.jahr})</span>}
|
||||
</span>
|
||||
<Badge text={phasenText[e.phase]} ton={phasenTon(e.phase)} />
|
||||
<span className="font-mono text-[10.5px] text-nebel">
|
||||
{(TYP_NAMEN[e.typ as GeraeteInfo['typ']] ?? e.typ).toUpperCase()} · {datumKurz(e.begonnen)}
|
||||
{e.titelZahl > 0 && ` · ${e.titelZahl} Titel`}
|
||||
{e.preset.length > 0 && ` · ${e.preset}`}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 font-mono text-[11px] leading-5 text-nebel">
|
||||
<span className="text-dunst">Roh:</span> {e.rohGeloescht ? 'gelöscht' : gb(e.rohBytes)}
|
||||
{e.faelligAm.length > 0 && ` · fällig ${datumKurz(e.faelligAm)}`}
|
||||
{' · '}
|
||||
<span className="text-dunst">Fertig:</span>{' '}
|
||||
{e.phase === 'fertig' ? (
|
||||
<span className="break-all">
|
||||
{e.fertigBytes > 0 ? `${gb(e.fertigBytes)} · ` : ''}
|
||||
{e.zielOrdner}
|
||||
{!e.komprimiert && ' (verlustfrei, ohne Kompression)'}
|
||||
</span>
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
</p>
|
||||
{e.fehler.length > 0 && <p className="mt-1 break-all font-mono text-[11px] text-warn">⚠ {e.fehler}</p>}
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
{e.neuKomprimierbar && (
|
||||
<>
|
||||
<select
|
||||
value={presetWahl[e.id] ?? ''}
|
||||
onChange={(ev) => setPresetWahl({ ...presetWahl, [e.id]: ev.target.value })}
|
||||
className="rounded-lg border border-tinte/15 bg-samt/70 px-2 py-1 text-[11px] text-dunst"
|
||||
title="Preset für die Wiederholung — leer: wie eingestellt"
|
||||
>
|
||||
<option value="">Preset wie eingestellt</option>
|
||||
{presets.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{p}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={() => vorgangNeuKomprimieren(e.id, presetWahl[e.id] ?? '')}
|
||||
className="rounded-lg border border-gold/40 bg-gold/12 px-3 py-1 text-[11px] font-semibold text-gold-hell hover:bg-gold/20"
|
||||
title="Die Roh-Dateien liegen genau dafür da — kein Neulesen der Disc"
|
||||
>
|
||||
Neu komprimieren
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{!e.rohGeloescht && ruhig && loeschfrage !== e.id && (
|
||||
<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 …
|
||||
</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 && (
|
||||
<button onClick={() => void window.rippy.ordnerOeffnen(e.zielOrdner)} className="rounded-lg border border-tinte/15 px-3 py-1 text-[11px] text-dunst hover:text-schnee">
|
||||
Ordner öffnen
|
||||
</button>
|
||||
)}
|
||||
{(e.phase === 'wartet' || e.phase === 'komprimiert' || e.phase === 'legt-ab') && (
|
||||
<button onClick={() => kompressionAbbrechen(e.id)} className="rounded-lg border border-tinte/15 px-3 py-1 text-[11px] text-dunst hover:text-rot">
|
||||
Kompression abbrechen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{liste.length > sichtbar.length && (
|
||||
<button onClick={() => setAlleZeigen(true)} className="font-mono text-[10.5px] text-nebel hover:text-dunst">
|
||||
▸ alle {liste.length} Vorgänge zeigen
|
||||
</button>
|
||||
)}
|
||||
|
||||
{v !== null && v.fremde.length > 0 && (
|
||||
<div className="rounded-xl border border-warn/30 bg-warn/5 p-3">
|
||||
<p className="font-mono text-[10.5px] tracking-wider text-warn">ORDNER OHNE VORGANG</p>
|
||||
<p className="mt-1 text-[11px] leading-4 text-nebel">Aus der Zeit vor 5.4.0 oder von Hand hineingelegt — Rippy kennt sie nicht und löscht sie deshalb NIE von selbst.</p>
|
||||
<div className="mt-2 space-y-1">
|
||||
{v.fremde.map((f) => (
|
||||
<div key={f.ordner} className="flex items-center justify-between gap-3">
|
||||
<span className="min-w-0 break-all font-mono text-[11px] text-dunst">
|
||||
{f.ordner} · {gb(f.bytes)}
|
||||
</span>
|
||||
{loeschfrage !== f.ordner ? (
|
||||
<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">
|
||||
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>
|
||||
)}
|
||||
</SektionsKarte>
|
||||
)
|
||||
}
|
||||
|
||||
export function MetadatenKarte({ verbindung }: { verbindung: Verbindung }) {
|
||||
const werte = verbindung.einstellungen
|
||||
return (
|
||||
<SektionsKarte
|
||||
id="metadaten"
|
||||
titel="METADATEN"
|
||||
intro="TMDb erkennt Titel, Jahr, Poster, Logo und Beschreibung; OMDb hilft bei Ausreißern nach. Beide Schlüssel sind kostenlos und in zwei Minuten geholt."
|
||||
>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<Feld name="TMDb-API-Key" geheim wert={werte['tmdbApiKey'] ?? ''} aufAendern={(w) => setzen('tmdbApiKey', w)} />
|
||||
<p className="mt-1 font-mono text-[10px] text-nebel">
|
||||
www.themoviedb.org → Einstellungen → API
|
||||
<HinterlegtPunkt wert={werte['tmdbApiKey'] ?? ''} />
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Feld name="OMDb-API-Key" geheim wert={werte['omdbApiKey'] ?? ''} aufAendern={(w) => setzen('omdbApiKey', w)} />
|
||||
<p className="mt-1 font-mono text-[10px] text-nebel">
|
||||
www.omdbapi.com/apikey.aspx
|
||||
<HinterlegtPunkt wert={werte['omdbApiKey'] ?? ''} />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[11px] leading-4 text-nebel">
|
||||
Ohne Schlüssel rippt Rippy trotzdem — die Filme heißen dann wie das Disc-Label, und „Ändern …" an der Laufwerks-Kachel bleibt ohne Suche.
|
||||
</p>
|
||||
</SektionsKarte>
|
||||
)
|
||||
}
|
||||
|
||||
export function MakemkvKarte({ verbindung }: { verbindung: Verbindung }) {
|
||||
const werte = verbindung.einstellungen
|
||||
const s = verbindung.schluessel
|
||||
const b = verbindung.beschaffung
|
||||
return (
|
||||
<SektionsKarte
|
||||
id="makemkv"
|
||||
titel="MAKEMKV"
|
||||
intro="MakeMKV liest die Discs. Rippy holt den frei veröffentlichten Beta-Key automatisch aus dem Hersteller-Forum, hinterlegt ihn und fragt MakeMKV selbst, ob er angenommen wird — täglich."
|
||||
>
|
||||
<div className="rounded-xl border border-tinte/8 bg-buehne/60 p-3">
|
||||
{s === null ? (
|
||||
<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">
|
||||
<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.angenommen === null ? 'text-nebel' : 'text-rot'}>
|
||||
{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>
|
||||
{s.version.length > 0 && <span className="ml-auto text-nebel">MakeMKV {s.version}</span>}
|
||||
</p>
|
||||
<p className="mt-1.5 text-xs leading-5 text-dunst">{s.text}</p>
|
||||
<p className="mt-1 font-mono text-[10px] text-nebel">
|
||||
zuletzt geprüft: {datumKurz(s.geholtAm)}
|
||||
{s.gueltigBis.length > 0 && ` · Key gültig bis ${s.gueltigBis}`}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{s !== null && s.angenommen === false && (
|
||||
<button
|
||||
onClick={() => makemkvUpdate()}
|
||||
disabled={b !== null && b.laeuft}
|
||||
className="rounded-lg bg-gradient-to-b from-warn to-[oklch(0.72_0.12_78)] px-3 py-1.5 text-xs font-bold text-buehne shadow-[inset_0_1px_0_oklch(1_0_0/0.35)] hover:brightness-105 disabled:opacity-40"
|
||||
title="Holt den offiziellen Installer über die Quellenkette, prüft ihn und startet ihn — Windows fragt einmal nach Adminrechten."
|
||||
>
|
||||
MakeMKV aktualisieren …
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => schluesselPruefen()} disabled={b !== null && b.laeuft} className={KNOPF_LEISE} title="Forum-Key holen, hinterlegen und MakeMKV selbst fragen, ob er angenommen wird">
|
||||
Schlüssel neu prüfen
|
||||
</button>
|
||||
</div>
|
||||
{b !== null && (
|
||||
<div className="rounded-lg border border-tinte/10 bg-buehne/70 p-2.5">
|
||||
<p className={`font-mono text-[11px] leading-5 ${b.fehler ? 'text-rot' : b.laeuft ? 'text-gold-hell' : 'text-gruen'}`}>{b.text}</p>
|
||||
{b.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 && (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Feld
|
||||
name="Eigener Schlüssel (Dauerlizenz)"
|
||||
hinweis="schlägt den automatischen Beta-Key — leer lassen, wenn keiner gekauft ist; nach dem Eintragen prüft Rippy sofort"
|
||||
geheim
|
||||
wert={werte['makemkvAppKey'] ?? ''}
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
export function MedienserverKarte({ verbindung }: { verbindung: Verbindung }) {
|
||||
const werte = verbindung.einstellungen
|
||||
return (
|
||||
<SektionsKarte
|
||||
id="medienserver"
|
||||
titel="MEDIENSERVER"
|
||||
intro="Nach jedem fertigen Rip sagt Rippy deinem Server Bescheid, damit der Film sofort auftaucht — ohne Warten auf den nächsten Bibliotheks-Scan."
|
||||
>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<label className="block">
|
||||
<span className="text-xs font-semibold text-dunst">System</span>
|
||||
<select value={werte['mediaServer'] ?? 'none'} onChange={(e) => setzen('mediaServer', e.target.value)} className={FELD_KLASSE}>
|
||||
<option value="none">keiner</option>
|
||||
<option value="jellyfin">Jellyfin</option>
|
||||
<option value="emby">Emby</option>
|
||||
<option value="kodi">Kodi</option>
|
||||
<option value="plex">Plex (nur Benennung)</option>
|
||||
</select>
|
||||
</label>
|
||||
<Feld name="Adresse" hinweis="http(s)://…" wert={werte['mediaServerUrl'] ?? ''} aufAendern={(w) => setzen('mediaServerUrl', w)} />
|
||||
<Feld name="API-Key" geheim hinweis="Kodi: benutzer:passwort" wert={werte['mediaServerApiKey'] ?? ''} aufAendern={(w) => setzen('mediaServerApiKey', w)} />
|
||||
</div>
|
||||
<p className="text-[11px] leading-4 text-nebel">Bei Plex benennt Rippy nur nach dessen Regeln — angestoßen wird dort nichts. Die Ordnerstruktur aus „Ablage" passt für alle vier.</p>
|
||||
</SektionsKarte>
|
||||
)
|
||||
}
|
||||
|
||||
export function BenachrichtigungKarte({ verbindung }: { verbindung: Verbindung }) {
|
||||
const werte = verbindung.einstellungen
|
||||
return (
|
||||
<SektionsKarte
|
||||
id="benachrichtigung"
|
||||
titel="BENACHRICHTIGUNG"
|
||||
intro="Rippy meldet sich aufs Handy, wenn ein Rip oder eine Kompression fertig ist oder etwas schiefgeht — über einen Discord-Webhook oder einen Telegram-Bot. Windows-Meldungen kommen immer."
|
||||
>
|
||||
<Schalter an={werte['benachrichtigungen'] !== 'aus'} aufWechsel={(an) => setzen('benachrichtigungen', an ? 'an' : 'aus')} label="Benachrichtigungen verschicken" />
|
||||
<Feld
|
||||
name="Discord-Webhook"
|
||||
hinweis="Discord → Servereinstellungen → Integrationen → Webhooks → URL kopieren"
|
||||
geheim
|
||||
wert={werte['discordWebhook'] ?? ''}
|
||||
aufAendern={(w) => setzen('discordWebhook', w)}
|
||||
/>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Feld name="Telegram-Bot-Token" hinweis="von @BotFather" geheim wert={werte['telegramToken'] ?? ''} aufAendern={(w) => setzen('telegramToken', w)} />
|
||||
<Feld name="Telegram-Chat-ID" hinweis="deine ID, z. B. von @userinfobot" wert={werte['telegramChatId'] ?? ''} aufAendern={(w) => setzen('telegramChatId', w)} />
|
||||
</div>
|
||||
<button onClick={() => benachrichtigungTesten()} className={KNOPF_LEISE}>
|
||||
Testnachricht schicken
|
||||
</button>
|
||||
</SektionsKarte>
|
||||
)
|
||||
}
|
||||
|
||||
export function AllgemeinKarte({ verbindung, haupt }: { verbindung: Verbindung; haupt: HauptStatus | null }) {
|
||||
const werte = verbindung.einstellungen
|
||||
const kern = verbindung.kern
|
||||
const db = kern?.datenbank ?? null
|
||||
const dbText = db === null ? '—' : db.ok ? db.pfad : `FEHLER — ${db.fehler}`
|
||||
return (
|
||||
<SektionsKarte id="allgemein" titel="ALLGEMEIN" intro="Rippy lebt im Infobereich neben der Uhr: Fenster zu heißt weiterlaufen, beendet wird über das Tray-Menü.">
|
||||
<Schalter an={haupt?.autostart === true} aufWechsel={(an) => void window.rippy.autostartSetzen(an)} label="Rippy mit Windows starten (Autostart im Benutzerkonto — kein Dienst, keine Adminrechte)" />
|
||||
<label className="block max-w-sm">
|
||||
<span className="text-xs font-semibold text-dunst">Titel lesen — ohne Lebenszeichen 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 still sein (kein Fortschritt, keine Meldung), bevor Rippy das Titel-Lesen beendet. Solange MakeMKV arbeitet und Fortschritt meldet — etwa beim Durchlesen einer DVD mit beschädigter IFO-Datei, was 20–60 Minuten dauern kann — wartet Rippy; „Abbrechen" im Dialog geht jederzeit.</span>
|
||||
</label>
|
||||
<label className="block max-w-sm">
|
||||
<span className="text-xs font-semibold text-dunst">Erscheinungsbild</span>
|
||||
<select value={werte['design'] ?? 'dunkel'} onChange={(e) => setzen('design', e.target.value)} className={FELD_KLASSE}>
|
||||
<option value="dunkel">Dunkel — der Kinosaal (Vorgabe)</option>
|
||||
<option value="hell">Hell — Papier und Gold</option>
|
||||
<option value="system">Wie Windows eingestellt</option>
|
||||
</select>
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button onClick={() => void window.rippy.protokollOeffnen()} className={KNOPF_LEISE} title="Eine Datei je Tag, 14 Tage aufgehoben — Haupt und Kern schreiben hinein">
|
||||
Protokoll öffnen
|
||||
</button>
|
||||
<button onClick={() => void window.rippy.ordnerOeffnen(werte['ablage'] ?? '')} className={KNOPF_LEISE}>
|
||||
Ablage im Explorer
|
||||
</button>
|
||||
{(werte['arbeitsordner'] ?? '').length > 0 && (
|
||||
<button onClick={() => void window.rippy.ordnerOeffnen(werte['arbeitsordner'] ?? '')} className={KNOPF_LEISE}>
|
||||
Arbeitsordner im Explorer
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-xl border border-tinte/8 bg-buehne/60 p-3 font-mono text-[11px] leading-5 text-nebel">
|
||||
<p>
|
||||
Rippy v{haupt?.version ?? '…'}
|
||||
{kern !== null && ` · Kern: Node ${kern.nodeVersion} (PID ${kern.pid})`}
|
||||
{haupt !== null && ` · Leine ${haupt.leine.ok ? 'gesetzt' : 'NICHT gesetzt'}`}
|
||||
{verbindung.pongLatenzMs !== null && ` · Antwortzeit ${verbindung.pongLatenzMs} ms`}
|
||||
</p>
|
||||
<p className="break-all">Datenbank: {dbText}</p>
|
||||
<p className="break-all">Protokoll: {haupt?.protokollOrdner ?? '—'}</p>
|
||||
</div>
|
||||
</SektionsKarte>
|
||||
)
|
||||
}
|
||||
|
||||
export function UpdateKarte({ verbindung, haupt }: { verbindung: Verbindung; haupt: HauptStatus | null }) {
|
||||
// § 6.8: Der Prüf-Knopf ist während eines Rips aus — nicht, weil er
|
||||
// nicht könnte, sondern weil Rippy dann bewusst nicht sucht.
|
||||
const ripLaeuftGerade = Object.values(verbindung.rips).some((r) => LAUFENDE_PHASEN.includes(r.phase))
|
||||
return (
|
||||
<SektionsKarte id="update" titel="UPDATE" intro="Rippy holt neue Fassungen selbst aus dem Gitea — geprüft, geladen, beim nächsten Beenden installiert. Nie mitten in einem Rip oder einer Kompression.">
|
||||
{/* Das Feld „Update-Adresse" ist am 01.09.2026 auf Zuruf des
|
||||
Commanders verschwunden: Updates kommen immer vom Gitea, ein
|
||||
Eingabefeld dafür war nur Ballast. Der Einstellungs-Schlüssel
|
||||
`updateUrl` gilt weiterhin — wer ihn wirklich braucht, setzt
|
||||
ihn in der Datenbank, und § 6.8 prüft ihn wie zuvor (nur
|
||||
https://). */}
|
||||
{haupt !== null && (
|
||||
<div className="rounded-xl border border-tinte/8 bg-buehne/60 p-3 font-mono text-[11px] leading-5 text-nebel">
|
||||
<p>
|
||||
<span className="text-dunst">Stand:</span> {haupt.update.text}
|
||||
</p>
|
||||
{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>}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
onClick={() => void window.rippy.updatePruefen()}
|
||||
disabled={ripLaeuftGerade || haupt?.update.laeuft === true}
|
||||
className={KNOPF_LEISE}
|
||||
title={ripLaeuftGerade ? 'Während eines Rips wird nicht gesucht — das ist Absicht (§ 6.8).' : 'Fragt sofort beim Update-Server nach.'}
|
||||
>
|
||||
{haupt?.update.laeuft === true ? 'sucht …' : 'Jetzt auf Updates prüfen'}
|
||||
</button>
|
||||
</div>
|
||||
</SektionsKarte>
|
||||
)
|
||||
}
|
||||
|
||||
function WerkzeugZeile({
|
||||
name,
|
||||
pfad,
|
||||
fehlt,
|
||||
pflicht,
|
||||
schluessel,
|
||||
wert,
|
||||
}: {
|
||||
name: string
|
||||
pfad: string | null
|
||||
fehlt: string
|
||||
pflicht: boolean
|
||||
schluessel: string
|
||||
wert: string
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-xl border border-tinte/8 bg-samt/50 p-3">
|
||||
<p className={`break-all font-mono text-xs ${pfad === null ? 'text-nebel' : pfad.length > 0 ? 'text-gruen' : pflicht ? 'text-rot' : 'text-warn'}`}>
|
||||
{name}: {pfad === null ? 'wird gesucht …' : pfad.length > 0 ? pfad : fehlt}
|
||||
</p>
|
||||
<PfadFeld
|
||||
name="Eigener Pfad"
|
||||
hinweis="leer = automatische Suche; ein eingetragener Pfad gilt exakt"
|
||||
wert={wert}
|
||||
art="programm"
|
||||
dialogTitel={`${name} wählen`}
|
||||
aufAendern={(w) => setzen(schluessel, w)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function WerkzeugeKarte({ verbindung }: { verbindung: Verbindung }) {
|
||||
const werte = verbindung.einstellungen
|
||||
const backends = verbindung.werkzeuge?.backends ?? []
|
||||
return (
|
||||
<SektionsKarte
|
||||
id="werkzeuge"
|
||||
titel="WERKZEUGE"
|
||||
intro="Rippy findet seine Werkzeuge von allein: mitgeliefert, selbst geholt, installiert oder im PATH — in dieser Reihenfolge. Wer einen eigenen Pfad einträgt, bekommt genau den."
|
||||
>
|
||||
<WerkzeugZeile
|
||||
name="makemkvcon"
|
||||
pfad={verbindung.werkzeuge === null ? null : verbindung.werkzeuge.makemkvcon}
|
||||
fehlt="NICHT GEFUNDEN — MakeMKV von makemkv.com installieren, Rippy findet es danach von allein"
|
||||
pflicht={true}
|
||||
schluessel="werkzeug.makemkv"
|
||||
wert={werte['werkzeug.makemkv'] ?? ''}
|
||||
/>
|
||||
<WerkzeugZeile
|
||||
name="HandBrakeCLI"
|
||||
pfad={verbindung.werkzeuge === null ? null : verbindung.werkzeuge.handbrake}
|
||||
fehlt="nicht gefunden — Rippen geht, Kompression nicht"
|
||||
pflicht={false}
|
||||
schluessel="werkzeug.handbrake"
|
||||
wert={werte['werkzeug.handbrake'] ?? ''}
|
||||
/>
|
||||
<WerkzeugZeile
|
||||
name="flac"
|
||||
pfad={verbindung.werkzeuge === null ? null : verbindung.werkzeuge.flac}
|
||||
fehlt="nicht gefunden — nur für Audio-CDs nötig"
|
||||
pflicht={false}
|
||||
schluessel="werkzeug.flac"
|
||||
wert={werte['werkzeug.flac'] ?? ''}
|
||||
/>
|
||||
{verbindung.werkzeuge !== null && (
|
||||
<p className="font-mono text-[10.5px] text-nebel">
|
||||
HandBrake gemessen: {verbindung.werkzeuge.presets.length} Presets · Encoder: {backends.length > 0 ? backends.join(', ') : '—'}
|
||||
</p>
|
||||
)}
|
||||
</SektionsKarte>
|
||||
)
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
// Die Sprach-Auswahl (5.5.0, Commander: „bei Tonspuren und Untertiteln
|
||||
// können Dropdowns hin") — gewählte Sprachen als Chips in Wunschreihenfolge,
|
||||
// dazu ein Dropdown zum Hinzufügen. Die erste Tonsprache ist die
|
||||
// Wunschsprache (auftrag.ts sprachwahl). Leer heißt „alle".
|
||||
import { SPRACHEN, sprachName, sprachlisteAus, sprachlisteText } from '../../gemeinsam/sprachen'
|
||||
|
||||
export function SprachAuswahl({
|
||||
name,
|
||||
hinweis,
|
||||
wert,
|
||||
aufAendern,
|
||||
}: {
|
||||
name: string
|
||||
hinweis: string
|
||||
wert: string
|
||||
aufAendern: (wert: string) => void
|
||||
}) {
|
||||
const gewaehlt = sprachlisteAus(wert)
|
||||
const frei = SPRACHEN.filter((s) => !gewaehlt.includes(s.code))
|
||||
const setzen = (codes: string[]): void => aufAendern(sprachlisteText(codes))
|
||||
const verschieben = (code: string, richtung: -1 | 1): void => {
|
||||
const i = gewaehlt.indexOf(code)
|
||||
const j = i + richtung
|
||||
if (i < 0 || j < 0 || j >= gewaehlt.length) return
|
||||
const neu = [...gewaehlt]
|
||||
;[neu[i], neu[j]] = [neu[j], neu[i]]
|
||||
setzen(neu)
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<span className="text-xs font-semibold text-dunst">{name}</span>
|
||||
<span className="ml-2 text-xs text-nebel">{hinweis}</span>
|
||||
<div className="mt-1 flex min-h-[38px] flex-wrap items-center gap-1.5 rounded-lg border border-tinte/12 bg-samt px-2 py-1.5">
|
||||
{gewaehlt.length === 0 && <span className="font-mono text-[11px] text-nebel">alle Sprachen der Disc</span>}
|
||||
{gewaehlt.map((code, i) => (
|
||||
<span key={code} className="inline-flex items-center gap-1 rounded-md border border-gold/40 bg-gold/12 px-2 py-0.5 text-[11px] text-gold-hell">
|
||||
<span className="font-mono text-[10px] text-gold/70">{i + 1}.</span>
|
||||
<span>{sprachName(code)}</span>
|
||||
<span className="font-mono text-[10px] text-nebel">{code}</span>
|
||||
<button type="button" onClick={() => verschieben(code, -1)} disabled={i === 0} className="ml-1 text-nebel hover:text-schnee disabled:opacity-30" title="nach vorn">
|
||||
‹
|
||||
</button>
|
||||
<button type="button" onClick={() => verschieben(code, 1)} disabled={i === gewaehlt.length - 1} className="text-nebel hover:text-schnee disabled:opacity-30" title="nach hinten">
|
||||
›
|
||||
</button>
|
||||
<button type="button" onClick={() => setzen(gewaehlt.filter((c) => c !== code))} className="ml-0.5 text-nebel hover:text-rot" title="entfernen">
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<select
|
||||
value=""
|
||||
onChange={(e) => {
|
||||
if (e.target.value.length > 0) setzen([...gewaehlt, e.target.value])
|
||||
}}
|
||||
className="ml-auto rounded-md border border-tinte/12 bg-buehne px-2 py-1 font-mono text-[11px] text-dunst"
|
||||
>
|
||||
<option value="">+ Sprache …</option>
|
||||
{frei.map((s) => (
|
||||
<option key={s.code} value={s.code}>
|
||||
{s.name} ({s.code})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
// Was die Brücke (src/bruecke) der Seite unter window.rippy bereitstellt.
|
||||
export {}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
rippy: {
|
||||
bereit(): void
|
||||
aufStatus(horcher: (status: unknown) => void): void
|
||||
melden(art: string): void
|
||||
ordnerWaehlen(titel: string): Promise<string | null>
|
||||
dateiWaehlen(titel: string): Promise<string | null>
|
||||
ordnerOeffnen(pfad: string): Promise<string>
|
||||
protokollOeffnen(): Promise<string>
|
||||
autostartSetzen(an: boolean): Promise<boolean>
|
||||
updatePruefen(): Promise<unknown>
|
||||
updateInstallieren(): Promise<{ ok: boolean; grund: string }>
|
||||
fensterSteuern(was: 'minimieren' | 'maximieren' | 'schliessen'): Promise<boolean>
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import App from './App'
|
||||
// Die drei Schriften des Kino-Mixes reisen im Paket mit (OFL-lizenziert,
|
||||
// bau/lizenzen) — Rippy lädt zur Laufzeit NICHTS aus dem Netz.
|
||||
import '@fontsource-variable/bricolage-grotesque'
|
||||
import '@fontsource/instrument-sans/400.css'
|
||||
import '@fontsource/instrument-sans/500.css'
|
||||
import '@fontsource/instrument-sans/600.css'
|
||||
import '@fontsource/jetbrains-mono/400.css'
|
||||
import '@fontsource/jetbrains-mono/500.css'
|
||||
import '@fontsource/jetbrains-mono/700.css'
|
||||
import './stil.css'
|
||||
|
||||
createRoot(document.getElementById('wurzel')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -1,12 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Rippy</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="wurzel"></div>
|
||||
<script type="module" src="./haupt.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,450 +0,0 @@
|
||||
// Hält den EINEN MessagePort zum Kern — bewusst außerhalb von React:
|
||||
// Die Port-Übergabe vom Haupt passiert genau einmal je Fenster-Ladung;
|
||||
// React-Re-Mounts (StrictMode im Dev, HMR) dürfen sie nicht verlieren.
|
||||
//
|
||||
// R2 gilt auch hier: Der letzte bekannte Stand bleibt stehen und wird
|
||||
// jedem neuen Abonnenten sofort wiedergegeben — er wird nie auf »leer«
|
||||
// zurückgesetzt, nur weil gerade keine Nachricht kam. Insbesondere die
|
||||
// Laufwerksliste: Sie ändert sich NUR durch eine neue Liste vom Kern.
|
||||
import {
|
||||
istKernNachricht,
|
||||
type AutomatikStand,
|
||||
type BeschaffungStand,
|
||||
type DiscInfoStand,
|
||||
type FensterNachricht,
|
||||
type GeraeteInfo,
|
||||
type KernNachricht,
|
||||
type LaufwerkBericht,
|
||||
type MetaVorschlag,
|
||||
type RipAuftragOptionen,
|
||||
type RipAuftragTitel,
|
||||
type RipStatus,
|
||||
type SpeicherStand,
|
||||
type TitelStand,
|
||||
type VorgaengeStand,
|
||||
type WerkzeugAuskunft,
|
||||
} from '../gemeinsam/nachrichten'
|
||||
|
||||
export interface Verbindung {
|
||||
verbunden: boolean
|
||||
kern: Extract<KernNachricht, { art: 'kern-bereit' }> | null
|
||||
pongLatenzMs: number | null
|
||||
laufwerke: GeraeteInfo[]
|
||||
/** Letzter Rip-Stand je Laufwerks-Kennung. Bleibt nach dem Ende stehen,
|
||||
* bis ein neuer Rip startet (R2: kein Verschwinden ohne neue Auskunft). */
|
||||
rips: Record<string, RipStatus>
|
||||
/** Was Rippy über die eingelegte Disc weiß (§ 5 Schritt 3). */
|
||||
discInfos: Record<string, DiscInfoStand>
|
||||
/** Suchergebnisse des Ändern-Dialogs je Laufwerk (§ 5 Schritt 4). */
|
||||
vorschlaege: Record<string, MetaVorschlag[]>
|
||||
/** Die Titel der Disc je Laufwerk (§ 6.2) — Ergebnis des Info-Laufs. */
|
||||
discTitel: Record<string, TitelStand>
|
||||
/** Wann der laufende Vorgang je Laufwerk begann (für die Restzeit). */
|
||||
ripStart: Record<string, number>
|
||||
/** Statuszeilen des (letzten) Vorgangs je Laufwerk — das aufklappbare
|
||||
* Protokoll der Kachel. Bleibt nach dem Ende stehen (R2), bis ein neuer
|
||||
* Vorgang beginnt. */
|
||||
ripProtokoll: Record<string, string[]>
|
||||
einstellungen: Record<string, string>
|
||||
werkzeuge: WerkzeugAuskunft | null
|
||||
schluessel: {
|
||||
quelle: string
|
||||
angenommen: boolean | null
|
||||
weiterBetrieb: boolean
|
||||
gueltigBis: string
|
||||
geholtAm: string
|
||||
text: string
|
||||
version: string
|
||||
} | null
|
||||
/** Stand der MakeMKV-Beschaffung (§ 9) — null, solange nie eine lief. */
|
||||
beschaffung: BeschaffungStand | null
|
||||
/** Vorgänge, Roh-Belegung und Kompressions-Warteschlange (5.4.0) — null,
|
||||
* solange der Kern noch nichts geschickt hat. */
|
||||
vorgaenge: VorgaengeStand | null
|
||||
/** Was das Windows-Protokoll je Laufwerk sagt (5.4.0) — bleibt stehen. */
|
||||
gesundheit: Record<string, LaufwerkBericht>
|
||||
/** Freier Platz an Arbeitsordner und Ablage (5.5.0). */
|
||||
speicher: SpeicherStand | null
|
||||
/** Automatik-Countdown je Laufwerk (5.5.0). */
|
||||
automatik: Record<string, AutomatikStand>
|
||||
/** Poster für den Hintergrund der Erst-Einrichtung (5.5.0). */
|
||||
poster: string[]
|
||||
/** Kurze Ereigniszeilen (neueste zuerst): Disc rein/raus, Auswurf, Fehler. */
|
||||
meldungen: string[]
|
||||
}
|
||||
|
||||
type Horcher = (stand: Verbindung) => void
|
||||
|
||||
const MAX_MELDUNGEN = 30
|
||||
|
||||
export const LEERE_VERBINDUNG: Verbindung = {
|
||||
verbunden: false,
|
||||
kern: null,
|
||||
pongLatenzMs: null,
|
||||
laufwerke: [],
|
||||
rips: {},
|
||||
discInfos: {},
|
||||
discTitel: {},
|
||||
vorschlaege: {},
|
||||
ripStart: {},
|
||||
ripProtokoll: {},
|
||||
einstellungen: {},
|
||||
werkzeuge: null,
|
||||
schluessel: null,
|
||||
beschaffung: null,
|
||||
vorgaenge: null,
|
||||
gesundheit: {},
|
||||
speicher: null,
|
||||
automatik: {},
|
||||
poster: [],
|
||||
meldungen: [],
|
||||
}
|
||||
|
||||
const stand: Verbindung = { ...LEERE_VERBINDUNG }
|
||||
const horcher = new Set<Horcher>()
|
||||
let gestartet = false
|
||||
let port: MessagePort | null = null
|
||||
|
||||
function kopie(): Verbindung {
|
||||
return {
|
||||
...stand,
|
||||
laufwerke: [...stand.laufwerke],
|
||||
rips: { ...stand.rips },
|
||||
discInfos: { ...stand.discInfos },
|
||||
vorschlaege: { ...stand.vorschlaege },
|
||||
einstellungen: { ...stand.einstellungen },
|
||||
gesundheit: { ...stand.gesundheit },
|
||||
automatik: { ...stand.automatik },
|
||||
poster: [...stand.poster],
|
||||
meldungen: [...stand.meldungen],
|
||||
}
|
||||
}
|
||||
|
||||
function melden(): void {
|
||||
for (const h of horcher) h(kopie())
|
||||
}
|
||||
|
||||
function meldung(text: string): void {
|
||||
stand.meldungen = [text, ...stand.meldungen].slice(0, MAX_MELDUNGEN)
|
||||
}
|
||||
|
||||
function kernNachricht(nachricht: KernNachricht): void {
|
||||
switch (nachricht.art) {
|
||||
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
|
||||
break
|
||||
case 'pong':
|
||||
stand.pongLatenzMs = Math.max(0, Date.now() - nachricht.zeit)
|
||||
// Der Beweis für den Smoke-Lauf: Ping ging hin, Pong kam zurück.
|
||||
window.rippy.melden('kern-pong')
|
||||
break
|
||||
case 'kern-fehler':
|
||||
meldung(`⚠ ${nachricht.text}`)
|
||||
break
|
||||
case 'kern-hinweis':
|
||||
meldung(nachricht.text)
|
||||
break
|
||||
case 'laufwerke':
|
||||
stand.laufwerke = nachricht.liste
|
||||
for (const ereignis of nachricht.ereignisse) {
|
||||
if (ereignis.art === 'disc-eingelegt') {
|
||||
meldung(`Disc erkannt in ${ereignis.geraet.name} — ${ereignis.geraet.typ}`)
|
||||
} else if (ereignis.art === 'disc-entnommen') {
|
||||
meldung(`Disc entnommen aus ${ereignis.geraet.name}`)
|
||||
const { [ereignis.geraet.id]: _weg, ...rest } = stand.automatik
|
||||
stand.automatik = rest
|
||||
} else if (ereignis.art === 'laufwerk-weg') {
|
||||
meldung(`Laufwerk ${ereignis.id}: nicht mehr da`)
|
||||
}
|
||||
}
|
||||
break
|
||||
case 'auswurf-ergebnis': {
|
||||
const text =
|
||||
nachricht.grund === 'ok'
|
||||
? `Laufwerk ${nachricht.id}: ausgeworfen (nachgesehen)`
|
||||
: `Laufwerk ${nachricht.id}: Auswurf ${nachricht.grund}${nachricht.fehler !== null ? ` — ${nachricht.fehler}` : ''}`
|
||||
meldung(nachricht.grund === 'ok' ? text : `⚠ ${text}`)
|
||||
break
|
||||
}
|
||||
case 'disc-info': {
|
||||
const vorher = stand.discInfos[nachricht.info.id]
|
||||
stand.discInfos = { ...stand.discInfos, [nachricht.info.id]: nachricht.info }
|
||||
if (nachricht.info.titel.length > 0) {
|
||||
// Dieselbe Erkennung nicht zweimal melden — die Nachprüfung nach
|
||||
// dem Info-Lauf schickt den Stand erneut, mit Hinweisen.
|
||||
const neuErkannt = vorher === undefined || vorher.titel !== nachricht.info.titel
|
||||
if (neuErkannt) {
|
||||
meldung(
|
||||
`Laufwerk ${nachricht.info.id}: erkannt als „${nachricht.info.titel}${nachricht.info.jahr !== null ? ` (${nachricht.info.jahr})` : ''}“ · ${Math.round(nachricht.info.confidence * 100)} %`,
|
||||
)
|
||||
}
|
||||
for (const h of nachricht.info.hinweise) {
|
||||
if (vorher === undefined || !vorher.hinweise.includes(h)) meldung(`Laufwerk ${nachricht.info.id}: ${h}`)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'disc-titel':
|
||||
stand.discTitel = { ...stand.discTitel, [nachricht.stand.id]: nachricht.stand }
|
||||
if (nachricht.stand.fehler.length > 0) meldung(`⚠ Titel lesen: ${nachricht.stand.fehler}`)
|
||||
break
|
||||
case 'metadaten-vorschlaege':
|
||||
stand.vorschlaege = { ...stand.vorschlaege, [nachricht.id]: nachricht.vorschlaege }
|
||||
break
|
||||
case 'einstellungen':
|
||||
stand.einstellungen = nachricht.werte
|
||||
break
|
||||
case 'werkzeuge':
|
||||
stand.werkzeuge = nachricht.auskunft
|
||||
break
|
||||
case 'schluessel-status':
|
||||
stand.schluessel = nachricht.stand
|
||||
if (nachricht.stand.angenommen === false) meldung(`⚠ MakeMKV-Schlüssel: ${nachricht.stand.text}`)
|
||||
break
|
||||
case 'beschaffung-status':
|
||||
stand.beschaffung = nachricht.stand
|
||||
if (nachricht.stand.fehler) meldung(`⚠ MakeMKV-Update: ${nachricht.stand.text}`)
|
||||
break
|
||||
case 'vorgaenge':
|
||||
stand.vorgaenge = nachricht.stand
|
||||
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':
|
||||
meldung(`${nachricht.ok ? '' : '⚠ '}Kompression „${nachricht.titel}": ${nachricht.text}`)
|
||||
break
|
||||
case 'laufwerk-gesundheit':
|
||||
stand.gesundheit = { ...stand.gesundheit, [nachricht.id]: nachricht.bericht }
|
||||
if (nachricht.bericht.urteil === 'hardware' || nachricht.bericht.urteil === 'disc') {
|
||||
meldung(`Laufwerk ${nachricht.id}: ${nachricht.bericht.text}`)
|
||||
}
|
||||
break
|
||||
case 'speicher':
|
||||
stand.speicher = nachricht.stand
|
||||
break
|
||||
case 'automatik':
|
||||
stand.automatik = { ...stand.automatik, [nachricht.stand.id]: nachricht.stand }
|
||||
break
|
||||
case 'poster-vorschau':
|
||||
stand.poster = nachricht.pfade
|
||||
break
|
||||
case 'rip-status': {
|
||||
stand.rips = { ...stand.rips, [nachricht.status.id]: nachricht.status }
|
||||
const laeuft = ['liest-info', 'rettet', 'rippt', 'komprimiert', 'legt-ab'].includes(nachricht.status.phase)
|
||||
if (laeuft && stand.ripStart[nachricht.status.id] === undefined) {
|
||||
stand.ripStart = { ...stand.ripStart, [nachricht.status.id]: Date.now() }
|
||||
// Ein NEUER Vorgang beginnt — das Protokoll des alten weicht.
|
||||
stand.ripProtokoll = { ...stand.ripProtokoll, [nachricht.status.id]: [] }
|
||||
} else if (!laeuft) {
|
||||
const { [nachricht.status.id]: _weg, ...rest } = stand.ripStart
|
||||
stand.ripStart = rest
|
||||
}
|
||||
// Jede NEUE Statuszeile wandert ins Kachel-Protokoll (Prozent-Ticks
|
||||
// wiederholen den Text — nur echte Änderungen zählen).
|
||||
const bisher = stand.ripProtokoll[nachricht.status.id] ?? []
|
||||
if (nachricht.status.text.length > 0 && bisher[bisher.length - 1] !== nachricht.status.text) {
|
||||
stand.ripProtokoll = {
|
||||
...stand.ripProtokoll,
|
||||
[nachricht.status.id]: [...bisher, nachricht.status.text].slice(-80),
|
||||
}
|
||||
}
|
||||
if (nachricht.status.phase === 'fertig') {
|
||||
meldung(
|
||||
`Laufwerk ${nachricht.status.id}: Rip fertig — ${nachricht.status.dateien.length} Datei(en)` +
|
||||
(nachricht.status.lesefehler ? ' ⚠ mit Lesefehlern' : ''),
|
||||
)
|
||||
} else if (nachricht.status.phase === 'fehler') {
|
||||
meldung(`⚠ Laufwerk ${nachricht.status.id}: ${nachricht.status.text}`)
|
||||
} else if (nachricht.status.phase === 'abgebrochen') {
|
||||
meldung(`Laufwerk ${nachricht.status.id}: Rip abgebrochen`)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'installer-starten':
|
||||
// Nur fürs Haupt gedacht — im Fenster nichts zu tun.
|
||||
break
|
||||
}
|
||||
melden()
|
||||
}
|
||||
|
||||
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
|
||||
stand.verbunden = true
|
||||
neuerPort.onmessage = (ereignis: MessageEvent) => {
|
||||
const nachricht: unknown = ereignis.data
|
||||
if (istKernNachricht(nachricht)) kernNachricht(nachricht)
|
||||
}
|
||||
neuerPort.start()
|
||||
senden({ art: 'ping', zeit: Date.now() })
|
||||
senden({ art: 'einstellungen-laden' })
|
||||
senden({ art: 'werkzeuge-laden' })
|
||||
senden({ art: 'vorgaenge-laden' })
|
||||
senden({ art: 'speicher-laden' })
|
||||
melden()
|
||||
}
|
||||
|
||||
/** Idempotent — der erste Aufruf registriert den Horcher und meldet der
|
||||
* Brücke, dass gepufferte Ports jetzt zugestellt werden können. */
|
||||
export function verbindungStarten(): void {
|
||||
if (gestartet) return
|
||||
gestartet = true
|
||||
window.addEventListener('message', (ereignis: MessageEvent) => {
|
||||
const daten: unknown = ereignis.data
|
||||
if (
|
||||
typeof daten === 'object' &&
|
||||
daten !== null &&
|
||||
(daten as Record<string, unknown>).art === 'kern-port' &&
|
||||
ereignis.ports.length > 0
|
||||
) {
|
||||
portAnnehmen(ereignis.ports[0])
|
||||
}
|
||||
})
|
||||
window.rippy.bereit()
|
||||
}
|
||||
|
||||
export function aufVerbindung(h: Horcher): () => void {
|
||||
horcher.add(h)
|
||||
h(kopie())
|
||||
return () => horcher.delete(h)
|
||||
}
|
||||
|
||||
function senden(nachricht: FensterNachricht): void {
|
||||
port?.postMessage(nachricht)
|
||||
}
|
||||
|
||||
/** Schickt eine Nachricht — und sagt es, wenn kein Kern da ist (R4). */
|
||||
function anKern(nachricht: FensterNachricht, wasWar: string): void {
|
||||
if (port === null) {
|
||||
meldung(`⚠ ${wasWar}: keine Verbindung zum Kern`)
|
||||
melden()
|
||||
return
|
||||
}
|
||||
port.postMessage(nachricht)
|
||||
}
|
||||
|
||||
export function auswerfen(id: string): void {
|
||||
anKern({ art: 'auswerfen', id }, `Auswerfen ${id}`)
|
||||
}
|
||||
|
||||
/** Den Info-Lauf anstoßen (20–120 s) — erst auf Zuruf, nie beim Einlegen. */
|
||||
export function titelLesen(id: string): void {
|
||||
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). */
|
||||
export function ripStarten(id: string, auftrag: RipAuftragTitel[] = [], optionen: RipAuftragOptionen = {}): void {
|
||||
anKern({ art: 'rip-start', id, auftrag, optionen }, `Rip ${id}`)
|
||||
}
|
||||
|
||||
/** Den laufenden Titel aufgeben und mit dem nächsten weitermachen. */
|
||||
export function ripUeberspringen(id: string): void {
|
||||
anKern({ art: 'rip-ueberspringen', id }, `Titel überspringen ${id}`)
|
||||
}
|
||||
|
||||
export function ripAbbrechen(id: string): void {
|
||||
anKern({ art: 'rip-abbruch', id }, `Rip-Abbruch ${id}`)
|
||||
}
|
||||
|
||||
export function rettungStarten(id: string): void {
|
||||
anKern({ art: 'rettung-start', id }, `Rettungs-Abbild ${id}`)
|
||||
}
|
||||
|
||||
export function metadatenSuchen(id: string, text: string): void {
|
||||
senden({ art: 'metadaten-suchen', id, text })
|
||||
}
|
||||
|
||||
export function zuordnungSetzen(id: string, vorschlag: MetaVorschlag): void {
|
||||
senden({ art: 'zuordnung-setzen', id, vorschlag })
|
||||
}
|
||||
|
||||
export function makemkvUpdate(): void {
|
||||
anKern({ art: 'makemkv-update' }, 'MakeMKV-Update')
|
||||
}
|
||||
|
||||
export function schluesselPruefen(): void {
|
||||
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 {
|
||||
anKern({ art: 'einstellung-setzen', schluessel, wert }, `Einstellung ${schluessel}`)
|
||||
}
|
||||
|
||||
// ── 5.4.0: Vorgänge, Roh-Dateien, Warteschlange, Laufwerks-Gesundheit ──
|
||||
export function vorgaengeLaden(): void {
|
||||
senden({ art: 'vorgaenge-laden' })
|
||||
}
|
||||
|
||||
export function vorgangRohLoeschen(vorgangId: string): void {
|
||||
anKern({ art: 'vorgang-roh-loeschen', vorgangId }, 'Roh-Dateien löschen')
|
||||
}
|
||||
|
||||
export function vorgangNeuKomprimieren(vorgangId: string, preset: string): void {
|
||||
anKern({ art: 'vorgang-neu-komprimieren', vorgangId, preset }, 'Neu komprimieren')
|
||||
}
|
||||
|
||||
export function kompressionAbbrechen(vorgangId: string): void {
|
||||
anKern({ art: 'kompression-abbrechen', vorgangId }, 'Kompression abbrechen')
|
||||
}
|
||||
|
||||
export function rohAufraeumen(): void {
|
||||
anKern({ art: 'roh-aufraeumen' }, 'Roh-Dateien aufräumen')
|
||||
}
|
||||
|
||||
export function fremdenOrdnerLoeschen(ordner: string): void {
|
||||
anKern({ art: 'fremden-ordner-loeschen', ordner }, 'Ordner löschen')
|
||||
}
|
||||
|
||||
export function laufwerkPruefen(id: string): void {
|
||||
anKern({ art: 'laufwerk-pruefen', id }, `Laufwerk ${id} prüfen`)
|
||||
}
|
||||
|
||||
// ── 5.5.0 ───────────────────────────────────────────────────────────────
|
||||
export function kompressionPause(an: boolean): void {
|
||||
anKern({ art: 'kompression-pause', an }, an ? 'Kompression anhalten' : 'Kompression weiter')
|
||||
}
|
||||
|
||||
export function automatikStopp(id: string): void {
|
||||
senden({ art: 'automatik-stopp', id })
|
||||
}
|
||||
|
||||
export function automatikJetzt(id: string): void {
|
||||
anKern({ art: 'automatik-jetzt', id }, 'Automatik jetzt')
|
||||
}
|
||||
|
||||
export function benachrichtigungTesten(): void {
|
||||
anKern({ art: 'benachrichtigung-testen' }, 'Testnachricht')
|
||||
}
|
||||
|
||||
export function posterLaden(): void {
|
||||
senden({ art: 'poster-laden' })
|
||||
}
|
||||
|
||||
export function speicherLaden(): void {
|
||||
senden({ art: 'speicher-laden' })
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
/* Der Kinosaal+Schaltpult-Mix (Design-Entscheid des Commanders, 31.08.2026):
|
||||
warmes Schwarz mit Saallicht und Korn, Gold als Akzent, dazu die
|
||||
Geräte-Präzision — Segmente, Mono-Caps, LED-Punkte. Die Tokens hier sind
|
||||
die EINE Quelle der Farbwelt; alles Weitere sind Tailwind-Klassen. */
|
||||
@theme {
|
||||
--font-display: 'Bricolage Grotesque Variable', Georgia, serif;
|
||||
--font-sans: 'Instrument Sans', system-ui, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', Consolas, monospace;
|
||||
|
||||
/* Bühne und Panels — warm abgetönte Schwarztöne, nie kaltes Grau. */
|
||||
--color-buehne: oklch(0.13 0.012 60);
|
||||
--color-karte: oklch(0.17 0.015 60);
|
||||
--color-samt: oklch(0.16 0.015 60);
|
||||
|
||||
/* Premierengold, in drei Stufen für Verläufe und Ränder. */
|
||||
--color-gold-hell: oklch(0.88 0.09 80);
|
||||
--color-gold: oklch(0.83 0.11 75);
|
||||
--color-gold-tief: oklch(0.74 0.13 60);
|
||||
|
||||
/* Die Aufnahme-LED aus dem Schaltpult. */
|
||||
--color-rec: oklch(0.72 0.17 45);
|
||||
|
||||
/* Schriftstufen — warmes Weiß bis Nebel. */
|
||||
--color-schnee: oklch(0.93 0.01 80);
|
||||
--color-dunst: oklch(0.68 0.015 80);
|
||||
--color-nebel: oklch(0.55 0.015 80);
|
||||
|
||||
/* Zustandsfarben, alle in der warmen Familie. */
|
||||
--color-gruen: oklch(0.78 0.13 150);
|
||||
--color-warn: oklch(0.8 0.12 85);
|
||||
--color-rot: oklch(0.68 0.19 25);
|
||||
|
||||
/* Linien und Flächen mit Alpha: im Dunkeln Weiß, im Hellen Tinte (5.6.0). */
|
||||
--color-tinte: oklch(1 0 0);
|
||||
}
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* Das Saallicht: zwei warme Glühherde und ein Grundschimmer — liegt als
|
||||
festes Bühnenbild hinter allem. */
|
||||
.buehnenlicht {
|
||||
background:
|
||||
radial-gradient(900px 520px at 78% 0%, oklch(0.28 0.07 45 / 0.5), transparent 70%),
|
||||
radial-gradient(1100px 700px at 0% 100%, oklch(0.24 0.09 25 / 0.55), transparent 65%),
|
||||
radial-gradient(700px 400px at 45% 45%, oklch(0.2 0.04 60 / 0.45), transparent 70%);
|
||||
}
|
||||
|
||||
/* Filmkorn — ein feines Turbulenz-Muster, hauchdünn über die Bühne gelegt. */
|
||||
.filmkorn {
|
||||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2'/></filter><rect width='160' height='160' filter='url(%23n)'/></svg>");
|
||||
opacity: 0.05;
|
||||
mix-blend-mode: overlay;
|
||||
}
|
||||
|
||||
/* Volle Segmente der Aussteuerungsanzeige glühen golden. */
|
||||
.segment-gluehen {
|
||||
background: linear-gradient(180deg, var(--color-gold-hell), var(--color-gold-tief));
|
||||
box-shadow: 0 0 8px oklch(0.8 0.12 70 / 0.45);
|
||||
}
|
||||
|
||||
/* Eigene Titelleiste (§ 4.2): Das Fenster hat seit 5.1.3 keinen
|
||||
Windows-Rahmen mehr (frame:false) — gezogen wird an der Kopfzeile.
|
||||
`drag` macht eine Fläche zum Fenstergriff; ALLES Bedienbare darin
|
||||
braucht `nicht-ziehbar`, sonst verschluckt der Griff den Klick.
|
||||
Doppelklick auf den Griff maximiert, das macht Windows von selbst. */
|
||||
.ziehbar {
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
.nicht-ziehbar {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
/* Dezente Scrollbalken — helle Standardbalken zerschneiden die Bühne. */
|
||||
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
::-webkit-scrollbar-thumb { background: color-mix(in oklch, var(--color-tinte) 14%, transparent); border-radius: 999px; border: 2px solid transparent; background-clip: content-box; }
|
||||
::-webkit-scrollbar-thumb:hover { background-color: color-mix(in oklch, var(--color-tinte) 24%, transparent); }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
|
||||
/* Der Poster-Lauf der Erst-Einrichtung (5.5.0): Jede Spalte wandert langsam
|
||||
nach oben; die Liste ist verdoppelt, bei -50 % beginnt sie nahtlos von
|
||||
vorn. Dauer und Versatz setzt die Komponente je Spalte. */
|
||||
@keyframes poster-lauf {
|
||||
from { transform: translateY(0); }
|
||||
to { transform: translateY(-50%); }
|
||||
}
|
||||
.poster-spalte {
|
||||
animation-name: poster-lauf;
|
||||
animation-timing-function: linear;
|
||||
animation-iteration-count: infinite;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* ── Hell-Modus (5.6.0, Commander: „White Mode") ─────────────────────────
|
||||
Dieselben Tokens, andere Werte: warmes Papier statt Kinosaal, Gold als
|
||||
dunklerer Akzent, Tinte statt Weiß für Linien und Flächen. Geschaltet
|
||||
über data-design auf <html> (App.tsx, Einstellung „design": dunkel /
|
||||
hell / system). */
|
||||
:root[data-design="hell"] {
|
||||
color-scheme: light;
|
||||
--color-buehne: oklch(0.965 0.008 80);
|
||||
--color-karte: oklch(0.99 0.004 80);
|
||||
--color-samt: oklch(0.94 0.01 80);
|
||||
--color-gold-hell: oklch(0.6 0.12 72);
|
||||
--color-gold: oklch(0.66 0.13 70);
|
||||
--color-gold-tief: oklch(0.56 0.13 58);
|
||||
--color-rec: oklch(0.6 0.19 40);
|
||||
--color-schnee: oklch(0.2 0.012 60);
|
||||
--color-dunst: oklch(0.4 0.015 60);
|
||||
--color-nebel: oklch(0.55 0.015 60);
|
||||
--color-gruen: oklch(0.52 0.15 150);
|
||||
--color-warn: oklch(0.58 0.13 80);
|
||||
--color-rot: oklch(0.55 0.2 25);
|
||||
--color-tinte: oklch(0.15 0.01 60);
|
||||
}
|
||||
:root[data-design="hell"] .buehnenlicht {
|
||||
background:
|
||||
radial-gradient(900px 520px at 78% 0%, oklch(0.9 0.06 80 / 0.6), transparent 70%),
|
||||
radial-gradient(1100px 700px at 0% 100%, oklch(0.9 0.05 40 / 0.5), transparent 65%);
|
||||
}
|
||||
:root[data-design="hell"] .filmkorn {
|
||||
opacity: 0.03;
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
// Die Kompressions-Warteschlange in der Übersicht (5.4.0): Rip und
|
||||
// Kompression sind entkoppelt — hier steht, was gerade rechnet und was
|
||||
// noch wartet, mit Abbruch je Auftrag. 5.5.0: Pause — der laufende Film
|
||||
// wird fertig, der nächste wartet.
|
||||
import type { VorgaengeStand } from '../../gemeinsam/nachrichten'
|
||||
import { leisteAusKompression } from '../../gemeinsam/vorgangsleiste'
|
||||
import { restText } from '../bausteine'
|
||||
import { VorgangsLeiste } from './VorgangsLeiste'
|
||||
import { kompressionAbbrechen, kompressionPause } from '../kernverbindung'
|
||||
|
||||
export function KompressionsKarte({ stand }: { stand: VorgaengeStand['kompression'] }) {
|
||||
if (stand.laeuft === null && stand.wartend.length === 0 && !stand.pausiert) return null
|
||||
const l = stand.laeuft
|
||||
return (
|
||||
<div className="rounded-2xl border border-tinte/10 bg-karte/50 p-5">
|
||||
<div className="flex flex-wrap items-center gap-2.5 font-mono text-[10.5px] tracking-[0.12em] text-nebel">
|
||||
<span className={`inline-block h-2 w-2 rounded-full ${l !== null ? 'animate-pulse bg-gold' : stand.pausiert ? 'bg-warn' : 'bg-tinte/25'}`} />
|
||||
<span className="text-dunst">KOMPRESSION</span>
|
||||
<span>·</span>
|
||||
<span>
|
||||
{l !== null ? 'läuft' : stand.pausiert ? 'angehalten' : 'wartet'}
|
||||
{stand.wartend.length > 0 && ` · ${stand.wartend.length} in der Warteschlange`}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => kompressionPause(!stand.pausiert)}
|
||||
className={`ml-auto rounded-lg border px-3 py-1 text-[11px] ${
|
||||
stand.pausiert ? 'border-gold/40 bg-gold/12 font-semibold text-gold-hell hover:bg-gold/20' : 'border-tinte/15 text-dunst hover:text-schnee'
|
||||
}`}
|
||||
title={stand.pausiert ? 'Die Warteschlange läuft weiter' : 'Der laufende Film wird fertig gerechnet, der nächste wartet — zum Beispiel beim Spielen'}
|
||||
>
|
||||
{stand.pausiert ? '▶ Weiter' : '⏸ Pausieren'}
|
||||
</button>
|
||||
</div>
|
||||
{l !== null && (
|
||||
<div className="mt-3 rounded-xl border border-tinte/10 bg-samt/80 p-4">
|
||||
<div className="flex items-end justify-between gap-5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-display text-[17px] font-bold text-schnee">{l.titel}</p>
|
||||
<div className="mt-2">
|
||||
<VorgangsLeiste stand={leisteAusKompression(l)} restText={restText(l.restS)} />
|
||||
</div>
|
||||
<p className="mt-2 truncate font-mono text-[11px] text-nebel" title={l.text}>
|
||||
> {l.text}
|
||||
</p>
|
||||
{stand.pausiert && <p className="mt-1 font-mono text-[10.5px] text-warn">Pause: Dieser Film wird fertig, danach wartet die Schlange.</p>}
|
||||
</div>
|
||||
{l.prozent >= 0 && (
|
||||
<div className="flex-none font-mono text-[42px] font-bold leading-none text-gold-hell">
|
||||
{l.prozent}
|
||||
<span className="text-lg text-gold/70">%</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => kompressionAbbrechen(l.vorgangId)}
|
||||
className="mt-3 rounded-lg border border-rot/50 bg-rot/10 px-3 py-1 text-[11px] font-semibold text-rot hover:bg-rot/20"
|
||||
title="Die Roh-Dateien bleiben liegen — unter Einstellungen → Roh-Dateien geht es später weiter"
|
||||
>
|
||||
Kompression abbrechen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{stand.wartend.length > 0 && (
|
||||
<div className="mt-3 space-y-1">
|
||||
{stand.wartend.map((w, i) => (
|
||||
<div key={w.vorgangId} className="flex items-center justify-between gap-3 rounded-lg border border-tinte/8 bg-samt/40 px-3 py-1.5">
|
||||
<VorgangsLeiste stand={leisteAusKompression(w)} kompakt />
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-dunst">
|
||||
Platz {i + 1} · {w.titel}
|
||||
{stand.pausiert ? ' · wartet auf Weiter' : ''}
|
||||
</span>
|
||||
<button onClick={() => kompressionAbbrechen(w.vorgangId)} className="flex-none font-mono text-[10.5px] text-nebel hover:text-rot">
|
||||
aus der Schlange nehmen
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,478 +0,0 @@
|
||||
// Die Bühne: Backdrop als Panorama, Poster mit Goldkante, Plakat-Titel —
|
||||
// und beim Arbeiten die Aussteuerungsanzeige samt Mono-Protokoll.
|
||||
// 5.4.0: Kino-Banner v2 (Logo, Tagline, Meta-Zeile, Darsteller), Laufwerks-
|
||||
// Gesundheit, Stand der Kompression nach dem Rip. 5.5.0: Automatik-Countdown
|
||||
// und das Rettungs-Abbild — nur, wenn Rippy Lesefehler gesehen hat.
|
||||
import { useState } from 'react'
|
||||
import type {
|
||||
AutomatikStand,
|
||||
DiscInfoStand,
|
||||
GeraeteInfo,
|
||||
KompressionStand,
|
||||
LaufwerkBericht,
|
||||
RipStatus,
|
||||
VorgangEintrag,
|
||||
} from '../../gemeinsam/nachrichten'
|
||||
import { DEFAULT_HB_PRESET, PRESET_KEINE, empfehlungText, presetEmpfehlung } from '../../gemeinsam/preset-empfehlung'
|
||||
import {
|
||||
Badge,
|
||||
IconDisc,
|
||||
IconEject,
|
||||
IconPlay,
|
||||
IconStift,
|
||||
IconStop,
|
||||
InfoKachel,
|
||||
LAUFENDE_PHASEN,
|
||||
PHASEN_MONO,
|
||||
TYP_NAMEN,
|
||||
backdropUrl,
|
||||
datumKurz,
|
||||
discHerkunft,
|
||||
gb,
|
||||
logoUrl,
|
||||
metaZeile,
|
||||
posterUrlGross,
|
||||
profilUrl,
|
||||
restzeitText,
|
||||
sicherheitsWort,
|
||||
} from '../bausteine'
|
||||
import {
|
||||
automatikJetzt,
|
||||
automatikStopp,
|
||||
auswerfen,
|
||||
laufwerkPruefen,
|
||||
rettungStarten,
|
||||
ripAbbrechen,
|
||||
ripUeberspringen,
|
||||
type Verbindung,
|
||||
} from '../kernverbindung'
|
||||
import { leisteAusRip } from '../../gemeinsam/vorgangsleiste'
|
||||
import { VorgangsLeiste } from './VorgangsLeiste'
|
||||
|
||||
/** Welches Preset diese Disc bekäme — dieselbe Kette wie im Kern
|
||||
* (eigene Wahl → Empfehlung → Default). */
|
||||
export function presetAnzeige(typ: GeraeteInfo['typ'], verbindung: Verbindung): string {
|
||||
if (typ === 'cd') return 'FLAC (verlustfrei)'
|
||||
if (typ === 'unknown') return 'ISO-Abbild'
|
||||
const werte = verbindung.einstellungen
|
||||
if (werte['transcodeEnabled'] === 'false') return 'ohne Kompression'
|
||||
const schluessel = typ === 'dvd' ? 'transcodePresetDvd' : typ === 'uhd' ? 'transcodePresetUhd' : 'transcodePresetBluray'
|
||||
const eigene = werte[schluessel] ?? ''
|
||||
if (eigene === PRESET_KEINE) return 'ohne Kompression'
|
||||
if (eigene.length > 0) return eigene
|
||||
const empfehlung = presetEmpfehlung(typ, verbindung.werkzeuge?.backends ?? [], verbindung.werkzeuge?.presets ?? [])
|
||||
if (empfehlung === PRESET_KEINE) return typ === 'uhd' ? 'verlustfrei (nur CPU-Encoder)' : 'ohne Kompression'
|
||||
return empfehlungText(typ, empfehlung.length > 0 ? empfehlung : DEFAULT_HB_PRESET)
|
||||
}
|
||||
|
||||
export function LaufwerkKachel({
|
||||
geraet,
|
||||
rip,
|
||||
info,
|
||||
ripStartMs,
|
||||
protokoll,
|
||||
verbindung,
|
||||
bericht,
|
||||
vorgang,
|
||||
kompression,
|
||||
wartePlatz,
|
||||
automatik,
|
||||
aufRippen,
|
||||
aufAendern,
|
||||
}: {
|
||||
geraet: GeraeteInfo
|
||||
rip: RipStatus | undefined
|
||||
info: DiscInfoStand | undefined
|
||||
ripStartMs: number | undefined
|
||||
protokoll: string[]
|
||||
verbindung: Verbindung
|
||||
/** Was das Windows-Protokoll zu diesem Laufwerk sagt (5.4.0). */
|
||||
bericht: LaufwerkBericht | undefined
|
||||
/** Der Vorgang des letzten Rips — für den Stand der Kompression (5.4.0). */
|
||||
vorgang: VorgangEintrag | undefined
|
||||
kompression: KompressionStand | null
|
||||
wartePlatz: number
|
||||
/** Der Automatik-Countdown (5.5.0). */
|
||||
automatik: AutomatikStand | undefined
|
||||
/** Die Dialoge leben im App-Zustand (5.4.0): Ein leerer Takt der Wache
|
||||
* räumte sonst Kachel UND Dialog ab. */
|
||||
aufRippen: () => void
|
||||
aufAendern: () => void
|
||||
}) {
|
||||
const [protokollOffen, setProtokollOffen] = useState(false)
|
||||
const [berichtOffen, setBerichtOffen] = useState(false)
|
||||
const ripLaeuft = rip !== undefined && LAUFENDE_PHASEN.includes(rip.phase)
|
||||
const discDa = geraet.status === 'ready'
|
||||
const backdrop = discDa && info !== undefined ? backdropUrl(info.backdropPfad) : ''
|
||||
const poster = discDa && info !== undefined ? posterUrlGross(info.posterPfad) : ''
|
||||
const erkannt = discDa && info !== undefined && info.titel.length > 0
|
||||
const logo = erkannt ? logoUrl(info.logoPfad) : ''
|
||||
const rettungMoeglich = discDa && !ripLaeuft && rip !== undefined && rip.rettungMoeglich === true
|
||||
const berichtTon =
|
||||
bericht === undefined
|
||||
? ''
|
||||
: bericht.urteil === 'ruhig'
|
||||
? 'text-gruen border-gruen/40 bg-gruen/10'
|
||||
: bericht.urteil === 'disc'
|
||||
? 'text-warn border-warn/45 bg-warn/10'
|
||||
: bericht.urteil === 'hardware'
|
||||
? 'text-rot border-rot/45 bg-rot/10'
|
||||
: 'text-dunst border-tinte/12 bg-samt/60'
|
||||
|
||||
return (
|
||||
<div className="relative overflow-hidden rounded-2xl border border-tinte/10 bg-karte/50">
|
||||
{backdrop.length > 0 && (
|
||||
<>
|
||||
<img src={backdrop} alt="" className="absolute inset-0 h-full w-full object-cover" />
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-buehne via-buehne/85 to-buehne/45" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-buehne via-transparent to-transparent" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="relative p-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2.5 font-mono text-[10.5px] tracking-[0.12em] text-nebel">
|
||||
<span
|
||||
className={`inline-block h-2 w-2 rounded-full ${
|
||||
ripLaeuft
|
||||
? 'animate-pulse bg-rec shadow-[0_0_9px_oklch(0.72_0.17_45/0.9)]'
|
||||
: discDa
|
||||
? 'bg-gruen'
|
||||
: geraet.status === 'empty'
|
||||
? 'bg-tinte/25'
|
||||
: 'bg-rot'
|
||||
}`}
|
||||
/>
|
||||
<span className="text-dunst">LAUFWERK {geraet.id}:</span>
|
||||
<span>·</span>
|
||||
<span className="truncate">{geraet.modell.length > 0 ? geraet.modell : 'MODELL UNBEKANNT'}</span>
|
||||
</div>
|
||||
<div className="flex flex-none gap-2">
|
||||
{ripLaeuft ? (
|
||||
<button
|
||||
onClick={() => ripAbbrechen(geraet.id)}
|
||||
className="inline-flex items-center gap-2 rounded-xl border border-rot/60 bg-rot/10 px-4 py-1.5 text-xs font-semibold text-rot hover:bg-rot/20"
|
||||
>
|
||||
<IconStop />
|
||||
Abbrechen
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={aufRippen}
|
||||
disabled={!discDa}
|
||||
className="inline-flex items-center gap-2 rounded-xl bg-gradient-to-b from-gold-hell to-gold-tief px-5 py-1.5 text-xs font-bold text-buehne shadow-[inset_0_1px_0_oklch(1_0_0/0.4),0_8px_24px_oklch(0.75_0.13_60/0.3)] hover:from-gold-hell hover:to-gold disabled:cursor-not-allowed disabled:opacity-35 disabled:shadow-none"
|
||||
title="Titel der Disc lesen und auswählen — danach verlustfrei rippen"
|
||||
>
|
||||
<IconPlay />
|
||||
Rippen
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => auswerfen(geraet.id)}
|
||||
disabled={!discDa || ripLaeuft}
|
||||
className="inline-flex items-center gap-2 rounded-xl border border-tinte/15 bg-gradient-to-b from-karte/80 to-samt/80 px-4 py-1.5 text-xs text-dunst shadow-[inset_0_1px_0_oklch(1_0_0/0.07)] hover:text-schnee disabled:cursor-not-allowed disabled:opacity-35"
|
||||
title="Entriegeln, auswerfen, nachsehen — in dieser Reihenfolge"
|
||||
>
|
||||
<IconEject />
|
||||
Auswerfen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!discDa && (
|
||||
<div className="mt-5 flex items-center gap-5 py-5">
|
||||
<IconDisc groesse={52} farbe={geraet.status === 'empty' ? 'oklch(0.4 0.02 75)' : 'oklch(0.68 0.19 25)'} />
|
||||
<p className={`max-w-2xl font-mono text-xs leading-5 ${geraet.status === 'empty' ? 'text-nebel' : 'text-rot'}`}>
|
||||
{geraet.status === 'empty' ? 'Keine Disc eingelegt — Disc rein genügt, Rippy meldet sich.' : geraet.grund}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{discDa && (
|
||||
<div className="mt-4 flex gap-5">
|
||||
{poster.length > 0 ? (
|
||||
<img src={poster} alt="" className="h-44 w-[118px] flex-none rounded-xl object-cover shadow-2xl shadow-black/60 ring-1 ring-gold/35" />
|
||||
) : (
|
||||
<div className="flex h-44 w-[118px] flex-none items-center justify-center rounded-xl bg-gradient-to-br from-karte to-samt ring-1 ring-tinte/10">
|
||||
<IconDisc groesse={40} farbe="oklch(0.5 0.05 75)" />
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
{/* Kino-Banner v2 (5.4.0): Logo statt Text, wo TMDb eines hat —
|
||||
der größte optische Sprung. Titel und Jahr bleiben lesbar. */}
|
||||
{erkannt ? (
|
||||
logo.length > 0 ? (
|
||||
<div className="flex flex-wrap items-end gap-x-3 gap-y-1">
|
||||
<img src={logo} alt={info.titel} className="max-h-16 max-w-[380px] object-contain object-left drop-shadow-[0_2px_12px_rgba(0,0,0,0.75)]" />
|
||||
<span className="pb-0.5 text-sm text-dunst">
|
||||
{info.titel}
|
||||
{info.jahr !== null && ` (${info.jahr})`}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<h3 className="truncate font-display text-[26px] font-bold leading-tight text-schnee drop-shadow">
|
||||
{info.titel}
|
||||
{info.jahr !== null && <span className="ml-2 font-medium text-dunst">({info.jahr})</span>}
|
||||
</h3>
|
||||
)
|
||||
) : (
|
||||
<h3 className="font-display text-[26px] font-bold leading-tight text-schnee">{TYP_NAMEN[geraet.typ]}</h3>
|
||||
)}
|
||||
{erkannt && info.tagline.length > 0 && <p className="mt-1 font-display text-[15px] italic text-gold-hell/90">{info.tagline}</p>}
|
||||
{erkannt && metaZeile(info).length > 0 && <p className="mt-1 font-mono text-[11px] tracking-wide text-dunst">{metaZeile(info)}</p>}
|
||||
{erkannt && info.beschreibung.length > 0 && <p className="mt-1.5 line-clamp-2 max-w-2xl text-xs leading-5 text-dunst">{info.beschreibung}</p>}
|
||||
{!erkannt && (
|
||||
<p className="mt-1.5 font-mono text-xs text-nebel">
|
||||
{geraet.typ === 'cd' ? 'Titel kommen beim Rippen von MusicBrainz.' : 'Noch keine Zuordnung — „Ändern …" hilft nach.'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Zwei Kacheln nebeneinander im schmalen Fenster, vier im
|
||||
breiten — vorher immer vier, also im schmalen gequetscht. */}
|
||||
<div className="mt-3 grid max-w-3xl grid-cols-2 gap-2.5 sm:grid-cols-4">
|
||||
<InfoKachel label="MEDIUM" wert={TYP_NAMEN[geraet.typ]} />
|
||||
<InfoKachel label="GRÖSSE" wert={geraet.groesseBytes > 0 ? gb(geraet.groesseBytes) : '—'} />
|
||||
<InfoKachel
|
||||
label="ERKENNUNG"
|
||||
wert={erkannt ? `${Math.round(info.confidence * 100)} %${info.quelle.length > 0 ? ` · ${info.quelle}` : ''}` : '—'}
|
||||
farbe={erkannt ? (info.confidence >= 0.8 ? 'text-gruen' : 'text-warn') : undefined}
|
||||
/>
|
||||
<InfoKachel label="PRESET" wert={presetAnzeige(geraet.typ, verbindung)} />
|
||||
</div>
|
||||
|
||||
{/* Darsteller-Streifen (5.4.0) */}
|
||||
{erkannt && info.besetzung.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-2">
|
||||
{info.besetzung.map((d) => (
|
||||
<div key={`${d.name}-${d.rolle}`} className="flex items-center gap-2" title={d.rolle}>
|
||||
{d.bildPfad.length > 0 ? (
|
||||
<img src={profilUrl(d.bildPfad)} alt="" className="h-9 w-9 rounded-full object-cover ring-1 ring-gold/30" />
|
||||
) : (
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-full bg-samt font-mono text-[11px] text-nebel ring-1 ring-tinte/10">
|
||||
{d.name
|
||||
.split(' ')
|
||||
.map((w) => w[0] ?? '')
|
||||
.join('')
|
||||
.slice(0, 2)
|
||||
.toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
<span className="leading-tight">
|
||||
<span className="block text-[11px] text-schnee">{d.name}</span>
|
||||
{d.rolle.length > 0 && <span className="block text-[10px] text-nebel">{d.rolle}</span>}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
onClick={aufAendern}
|
||||
disabled={ripLaeuft}
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-tinte/15 bg-samt/60 px-3 py-1 text-xs text-dunst hover:text-schnee disabled:opacity-35"
|
||||
title="Falsch erkannt? Selbst suchen und wählen — die Ablage folgt deiner Wahl."
|
||||
>
|
||||
<IconStift />
|
||||
Ändern …
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setBerichtOffen(true)
|
||||
laufwerkPruefen(geraet.id)
|
||||
}}
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-tinte/15 bg-samt/60 px-3 py-1 text-xs text-dunst hover:text-schnee"
|
||||
title="Liest die cdrom-Ereignisse aus dem Windows-Protokoll: Disc oder Hardware?"
|
||||
>
|
||||
Laufwerk prüfen
|
||||
</button>
|
||||
{rettungMoeglich && (
|
||||
<button
|
||||
onClick={() => rettungStarten(geraet.id)}
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-warn/50 bg-warn/12 px-3 py-1 text-xs font-semibold text-warn hover:bg-warn/20"
|
||||
title="Rippy hat an dieser Disc Lesefehler gesehen: erst ein Abbild Sektor für Sektor (Unlesbares mit Nullen), dann derselbe Rip aus dem Abbild — es kann nicht mehr hängen. Braucht doppelten Platz."
|
||||
>
|
||||
Rettungs-Abbild …
|
||||
</button>
|
||||
)}
|
||||
{erkannt && <Badge text={`${sicherheitsWort(info.confidence)}`} ton={info.confidence >= 0.8 ? 'gruen' : 'gelb'} />}
|
||||
{info !== undefined && discHerkunft(info).length > 0 && <Badge text={discHerkunft(info)} ton="blau" />}
|
||||
{info !== undefined && info.schonGerippt !== null && <Badge text={`schon gerippt am ${datumKurz(info.schonGerippt).slice(0, 10)}`} ton="gelb" />}
|
||||
</div>
|
||||
{/* Was Rippy NACH dem Info-Lauf dazugelernt hat (Erkennung v2). */}
|
||||
{erkannt && info.hinweise.length > 0 && (
|
||||
<div className="mt-2 space-y-0.5">
|
||||
{info.hinweise.map((h) => (
|
||||
<p
|
||||
key={h}
|
||||
className={`font-mono text-[11px] ${h.includes('NICHT') ? 'text-warn' : h.includes('bestätigt') || h.includes('passt') ? 'text-gruen' : 'text-nebel'}`}
|
||||
>
|
||||
{h.includes('NICHT') ? '⚠ ' : h.includes('bestätigt') ? '✓ ' : '· '}
|
||||
{h}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{geraet.grund.length > 0 && <p className="mt-2 font-mono text-xs text-warn">{geraet.grund}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Automatik (5.5.0): Countdown mit Stopp und Jetzt — oder der
|
||||
Grund, warum Rippy wartet. */}
|
||||
{discDa && !ripLaeuft && automatik !== undefined && automatik.text.length > 0 && (
|
||||
<div className={`mt-4 flex flex-wrap items-center gap-3 rounded-xl border p-3 ${automatik.aktiv ? 'border-gold/45 bg-gold/10' : 'border-tinte/10 bg-samt/60'}`}>
|
||||
{automatik.aktiv && (
|
||||
<span className="font-mono text-[28px] font-bold leading-none text-gold-hell">
|
||||
{automatik.sekunden}
|
||||
<span className="text-sm text-gold/70"> s</span>
|
||||
</span>
|
||||
)}
|
||||
<p className={`min-w-0 flex-1 font-mono text-[11px] leading-5 ${automatik.aktiv ? 'text-gold-hell' : 'text-nebel'}`}>{automatik.text}</p>
|
||||
{automatik.aktiv && (
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => automatikJetzt(geraet.id)} className="rounded-lg border border-gold/40 bg-gold/15 px-3 py-1 text-[11px] font-semibold text-gold-hell hover:bg-gold/25">
|
||||
Jetzt
|
||||
</button>
|
||||
<button onClick={() => automatikStopp(geraet.id)} className="rounded-lg border border-tinte/15 px-3 py-1 text-[11px] text-dunst hover:text-schnee">
|
||||
Stopp
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Laufwerks-Gesundheit (5.4.0, Punkt 8): Disc oder Hardware? Kommt
|
||||
von selbst nach einem gescheiterten Rip — oder auf Knopfdruck. */}
|
||||
{bericht !== undefined && (berichtOffen || bericht.urteil !== 'ruhig') && (
|
||||
<div className={`mt-4 rounded-xl border p-3 ${berichtTon}`}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<p className="font-mono text-[11px] leading-5">
|
||||
<span className="font-semibold">
|
||||
{bericht.urteil === 'ruhig' ? 'LAUFWERK RUHIG' : bericht.urteil === 'disc' ? 'VERDACHT: DIE DISC' : bericht.urteil === 'hardware' ? 'VERDACHT: DIE HARDWARE' : 'PROTOKOLL'}
|
||||
</span>{' '}
|
||||
— {bericht.text}
|
||||
</p>
|
||||
<button onClick={() => setBerichtOffen(false)} className="flex-none font-mono text-[10.5px] text-nebel hover:text-dunst">
|
||||
ausblenden
|
||||
</button>
|
||||
</div>
|
||||
{bericht.letzte.length > 0 && (
|
||||
<div className="mt-1.5 space-y-0.5">
|
||||
{bericht.letzte.map((z, i) => (
|
||||
<p key={i} className="font-mono text-[10.5px] text-nebel">
|
||||
{z}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rip !== undefined && (
|
||||
<div className="mt-4 rounded-xl border border-tinte/10 bg-samt/80 p-4 backdrop-blur">
|
||||
<div className="flex items-end justify-between gap-5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-2 flex items-baseline justify-between gap-3">
|
||||
<span
|
||||
className={`font-mono text-[10.5px] tracking-[0.12em] ${
|
||||
rip.phase === 'fehler' ? 'text-rot' : rip.phase === 'fertig' ? (rip.lesefehler ? 'text-warn' : 'text-gruen') : 'text-gold-hell'
|
||||
}`}
|
||||
>
|
||||
{PHASEN_MONO[rip.phase]}
|
||||
{rip.lesefehler && rip.phase === 'fertig' ? ' — MIT LESEFEHLERN' : ''}
|
||||
</span>
|
||||
{ripLaeuft && <span className="font-mono text-[10.5px] text-nebel">{restzeitText(rip.prozent, ripStartMs)}</span>}
|
||||
</div>
|
||||
{/* 5.6.0: die Vorgangs-Leiste — der ganze Weg, hier mit den
|
||||
Details der Laufwerks-Stationen; nach dem Rip nur der Stand. */}
|
||||
<VorgangsLeiste stand={leisteAusRip(rip, vorgang, wartePlatz)} />
|
||||
{ripLaeuft && (rip.titelGesamt ?? 0) > 1 && (
|
||||
<p className="mt-1.5 font-mono text-[10.5px] text-nebel">
|
||||
Titel {rip.titelNr} von {rip.titelGesamt}
|
||||
</p>
|
||||
)}
|
||||
{rip.rettung !== undefined && (
|
||||
<p className="mt-1.5 font-mono text-[10.5px] text-warn">
|
||||
Rettungs-Abbild: {rip.rettung.unlesbareSektoren} unlesbare Sektoren an {rip.rettung.stellen} Stellen mit Nullen gefüllt
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Der Defekt-Waechter (§ 6.2): Dieselbe Stelle wieder und
|
||||
wieder ist kein Fortschritt, sondern ein physischer
|
||||
Defekt. Vorher lief MakeMKV dort stundenlang weiter,
|
||||
waehrend identische Zeilen durchs Protokoll rauschten. */}
|
||||
{rip.haengt != null && (
|
||||
<div className="mt-3 rounded-xl border border-warn/45 bg-warn/10 p-3">
|
||||
<p className="font-mono text-[11px] font-semibold text-warn">Die Disc hat an einer Stelle einen Defekt.</p>
|
||||
<p className="mt-1 font-mono text-[11px] leading-5 text-dunst">
|
||||
MakeMKV versucht es seit {rip.haengt.wiederholungen} Meldungen immer wieder an derselben Stelle (Byte {rip.haengt.offset}). Das wird nicht
|
||||
besser — der Rest der Disc ist davon nicht betroffen. Nach dem Abbruch bietet Rippy das Rettungs-Abbild an.
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
onClick={() => ripUeberspringen(geraet.id)}
|
||||
className="rounded-lg border border-warn/50 bg-warn/15 px-3 py-1 text-[11px] font-semibold text-warn hover:bg-warn/25"
|
||||
title="Diesen Titel aufgeben und mit dem nächsten weitermachen"
|
||||
>
|
||||
Titel überspringen
|
||||
</button>
|
||||
<span className="font-mono text-[10.5px] text-nebel">oder weiter versuchen — abbrechen geht oben.</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="mt-2 truncate font-mono text-[11px] text-nebel" title={rip.text}>
|
||||
> {rip.text}
|
||||
</p>
|
||||
{rip.dateien.map((datei) => (
|
||||
<p key={datei} className="break-all font-mono text-[11px] text-nebel/80">
|
||||
{datei}
|
||||
</p>
|
||||
))}
|
||||
|
||||
{/* Nach dem Rip (5.6.0, Commander: „Nur in der Kompressions-Karte"):
|
||||
hier nur noch EINE Zeile, wo es weitergeht — Balken, Prozent,
|
||||
Restzeit und Ziel stehen in der Kompressions-Karte, danach im
|
||||
Verlauf. Fehler bleiben sichtbar (R4). */}
|
||||
{rip.phase === 'fertig' && vorgang !== undefined && (
|
||||
<p className={`mt-2 font-mono text-[11px] ${vorgang.phase === 'fehler' || vorgang.phase === 'abgebrochen' ? 'text-warn' : 'text-nebel'}`}>
|
||||
{kompression !== null
|
||||
? 'Kompression läuft — Stand in der Kompressions-Karte unten.'
|
||||
: wartePlatz > 0
|
||||
? `Kompression wartet — Platz ${wartePlatz} in der Warteschlange (Karte unten).`
|
||||
: vorgang.phase === 'fertig'
|
||||
? 'Kompression fertig — das Ergebnis steht im Verlauf.'
|
||||
: vorgang.phase === 'fehler' || vorgang.phase === 'abgebrochen'
|
||||
? `⚠ ${vorgang.fehler.length > 0 ? vorgang.fehler : `Kompression ${vorgang.phase}`}`
|
||||
: `Kompression: ${vorgang.phase}`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{ripLaeuft && rip.prozent >= 0 && (
|
||||
<div className="flex-none font-mono text-[42px] font-bold leading-none text-gold-hell">
|
||||
{rip.prozent}
|
||||
<span className="text-lg text-gold/70">%</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{protokoll.length > 1 && (
|
||||
<button onClick={() => setProtokollOffen((o) => !o)} className="mt-2 font-mono text-[10.5px] text-nebel hover:text-dunst">
|
||||
{protokollOffen ? '▾ PROTOKOLL AUSBLENDEN' : `▸ PROTOKOLL (${protokoll.length} ZEILEN)`}
|
||||
</button>
|
||||
)}
|
||||
{protokollOffen && (
|
||||
<div className="mt-1 max-h-40 space-y-0.5 overflow-y-auto rounded-lg bg-buehne p-2">
|
||||
{protokoll.map((zeile, i) => (
|
||||
<p key={i} className="break-all font-mono text-[10.5px] leading-4 text-nebel">
|
||||
> {zeile}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
// Speicher und Statistik in der Übersicht (5.5.0, Commander: „Ist mir zu
|
||||
// leer … wie viel Speicher noch auf dem Server oder auf dem Arbeitsordner
|
||||
// frei ist"). Gemessen im Kern mit fs.statfs — lokal wie auf der Freigabe.
|
||||
import type { SpeicherPlatz, SpeicherStand, Statistik } from '../../gemeinsam/nachrichten'
|
||||
import { FuellBalken, dauerGrob, gbKurz } from '../bausteine'
|
||||
import { speicherLaden } from '../kernverbindung'
|
||||
|
||||
/** Ab hier wird gewarnt: eine Blu-ray braucht rund 50 GB (Roh + Kompression). */
|
||||
export const WENIG_FREI_BYTES = 60e9
|
||||
export const SEHR_WENIG_FREI_BYTES = 20e9
|
||||
|
||||
function Platz({ name, platz, hinweis }: { name: string; platz: SpeicherPlatz; hinweis: string }) {
|
||||
const ton = !platz.ok ? 'rot' : platz.freiBytes < SEHR_WENIG_FREI_BYTES ? 'rot' : platz.freiBytes < WENIG_FREI_BYTES ? 'warn' : 'gold'
|
||||
const belegt = platz.ok && platz.gesamtBytes > 0 ? 1 - platz.freiBytes / platz.gesamtBytes : 0
|
||||
return (
|
||||
<div className="rounded-xl border border-tinte/8 bg-samt/50 p-3">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="font-mono text-[10px] tracking-[0.14em] text-nebel">{name}</span>
|
||||
<span className={`font-mono text-[13px] font-semibold ${ton === 'rot' ? 'text-rot' : ton === 'warn' ? 'text-warn' : 'text-schnee'}`}>
|
||||
{platz.ok ? `${gbKurz(platz.freiBytes)} frei` : 'nicht messbar'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<FuellBalken anteil={belegt} ton={ton} />
|
||||
</div>
|
||||
<p className="mt-1.5 break-all font-mono text-[10.5px] text-nebel" title={platz.pfad}>
|
||||
{platz.pfad}
|
||||
{platz.ok && ` · ${gbKurz(platz.gesamtBytes)} gesamt`}
|
||||
{!platz.ok && platz.fehler.length > 0 && ` · ${platz.fehler}`}
|
||||
</p>
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-2">
|
||||
<span className="text-[10.5px] text-nebel/80">{hinweis}</span>
|
||||
{platz.ok && (
|
||||
<button onClick={() => void window.rippy.ordnerOeffnen(platz.pfad)} className="ml-auto font-mono text-[10.5px] text-nebel hover:text-dunst" title="Im Explorer zeigen">
|
||||
Ordner öffnen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SpeicherKarte({ speicher, statistik }: { speicher: SpeicherStand | null; statistik: Statistik | null }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-tinte/10 bg-karte/50 p-5">
|
||||
<div className="flex items-center gap-2.5 font-mono text-[10.5px] tracking-[0.12em] text-nebel">
|
||||
<span className="text-dunst">SPEICHER UND BILANZ</span>
|
||||
<button onClick={() => speicherLaden()} className="ml-auto font-mono text-[10.5px] text-nebel hover:text-dunst" title="Jetzt neu messen">
|
||||
neu messen
|
||||
</button>
|
||||
</div>
|
||||
{speicher === null ? (
|
||||
<p className="mt-3 font-mono text-xs text-nebel">wird gemessen …</p>
|
||||
) : (
|
||||
<div className={`mt-3 grid gap-3 ${speicher.getrennt ? 'sm:grid-cols-2' : ''}`}>
|
||||
{speicher.getrennt ? (
|
||||
<>
|
||||
<Platz name="ARBEITSORDNER (LOKAL)" platz={speicher.arbeit} hinweis="Roh-Dateien und Kompression — eine Blu-ray braucht rund 50 GB." />
|
||||
<Platz name="ABLAGE (SERVER)" platz={speicher.ablage} hinweis="Fertige Filme, Serien und Musik." />
|
||||
</>
|
||||
) : (
|
||||
<Platz name="ABLAGE UND ARBEIT" platz={speicher.ablage} hinweis="Roh-Dateien, Kompression und Fertiges am selben Ort — ein eigener Arbeitsordner geht unter Einstellungen → Ablage." />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{statistik !== null && statistik.discs > 0 && (
|
||||
<div className="mt-3 grid grid-cols-2 gap-2.5 sm:grid-cols-4">
|
||||
<Zahl label="DISCS" wert={String(statistik.discs)} zusatz={`${statistik.fertige} fertig`} />
|
||||
<Zahl label="VERLUSTFREI GESICHERT" wert={gbKurz(statistik.rohBytes)} zusatz="Summe aller Rips" />
|
||||
<Zahl label="FERTIG ABGELEGT" wert={gbKurz(statistik.fertigBytes)} zusatz="komprimiert oder verschoben" />
|
||||
<Zahl label="JE DISC" wert={statistik.fertige > 0 ? dauerGrob(statistik.dauerS / statistik.fertige) : '—'} zusatz="Rip bis Ablage, im Schnitt" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Zahl({ label, wert, zusatz }: { label: string; wert: string; zusatz: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-tinte/10 bg-samt/70 px-3 py-2">
|
||||
<div className="font-mono text-[9px] tracking-[0.14em] text-nebel">{label}</div>
|
||||
<div className="mt-0.5 font-display text-[19px] font-bold leading-tight text-gold-hell">{wert}</div>
|
||||
<div className="font-mono text-[10px] text-nebel/80">{zusatz}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
// Die Übersicht — Laufwerke, Warteschlange, Speicher und Bilanz, Verlauf,
|
||||
// Ereignisse. Und die Dialoge: Sie leben im Zustand DIESES Bereichs, nicht
|
||||
// in der Kachel (5.4.0: ein leerer Takt der Wache räumte sonst Kachel UND
|
||||
// Dialog ab).
|
||||
import { useState } from 'react'
|
||||
import type { HauptStatus } from '../../gemeinsam/nachrichten'
|
||||
import { IconDisc, LAUFENDE_PHASEN } from '../bausteine'
|
||||
import { AendernDialog } from '../dialoge/AendernDialog'
|
||||
import { TitelWahlDialog } from '../dialoge/TitelWahlDialog'
|
||||
import { automatikStopp, titelLesen, type Verbindung } from '../kernverbindung'
|
||||
import { KompressionsKarte } from './KompressionsKarte'
|
||||
import { LaufwerkKachel } from './LaufwerkKachel'
|
||||
import { SpeicherKarte } from './SpeicherKarte'
|
||||
import { VerlaufKarte } from './VerlaufKarte'
|
||||
|
||||
export function Uebersicht({
|
||||
verbindung,
|
||||
haupt,
|
||||
systemFehler,
|
||||
aufRohDateien,
|
||||
}: {
|
||||
verbindung: Verbindung
|
||||
haupt: HauptStatus | null
|
||||
systemFehler: string[]
|
||||
aufRohDateien: () => void
|
||||
}) {
|
||||
// Sagt ein Update-Knopf NEIN, steht der Grund daneben (R4) — ein Knopf,
|
||||
// der nichts tut und nichts sagt, ist schlimmer als kein Knopf.
|
||||
const [updateHinweis, setUpdateHinweis] = useState('')
|
||||
const [dialog, setDialog] = useState<{ art: 'titel' | 'aendern'; id: string } | null>(null)
|
||||
const einRipLaeuft = Object.values(verbindung.rips).some((r) => LAUFENDE_PHASEN.includes(r.phase))
|
||||
|
||||
return (
|
||||
<>
|
||||
{systemFehler.length > 0 && (
|
||||
<div className="rounded-2xl border border-rot/40 bg-rot/10 p-4">
|
||||
{systemFehler.map((zeile, i) => (
|
||||
<p key={i} className="break-all font-mono text-xs text-rot">
|
||||
✗ {zeile}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* § 6.8: Ein verfügbares Update ist eine SICHTBARE Karte, kein
|
||||
Satz in den Einstellungen. Der Commander musste es am
|
||||
01.09.2026 suchen — gefunden hat er es nicht. */}
|
||||
{haupt !== null && haupt.update.neueVersion.length > 0 && (
|
||||
<div className="rounded-2xl border border-gold/40 bg-gold/10 p-4">
|
||||
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||
<span className="font-display text-lg text-gold-hell">Update verfügbar — Version {haupt.update.neueVersion}</span>
|
||||
<span className="font-mono text-xs text-nebel">du hast {haupt.version}</span>
|
||||
</div>
|
||||
<p className="mt-1 font-mono text-xs text-dunst">{haupt.update.text}</p>
|
||||
{haupt.update.fortschritt >= 0 && !haupt.update.bereit && (
|
||||
<div className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-tinte/10">
|
||||
<div className="h-full rounded-full bg-gold transition-all" style={{ width: `${Math.max(0, Math.min(100, haupt.update.fortschritt))}%` }} />
|
||||
</div>
|
||||
)}
|
||||
{haupt.update.neuerungen.length > 0 && (
|
||||
<div className="mt-3 rounded-xl border border-tinte/8 bg-buehne/60 p-3">
|
||||
<p className="text-[11px] uppercase tracking-wider text-nebel">Was neu ist</p>
|
||||
<p className="mt-1 whitespace-pre-wrap font-mono text-xs leading-5 text-dunst">{haupt.update.neuerungen}</p>
|
||||
</div>
|
||||
)}
|
||||
{haupt.update.bereit && (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setUpdateHinweis('')
|
||||
void window.rippy.updateInstallieren().then((urteil) => {
|
||||
if (!urteil.ok) setUpdateHinweis(urteil.grund)
|
||||
})
|
||||
}}
|
||||
disabled={einRipLaeuft}
|
||||
className="rounded-lg border border-gold/40 bg-gold/15 px-3 py-1.5 text-xs font-semibold text-gold-hell hover:bg-gold/25 disabled:opacity-35"
|
||||
title={einRipLaeuft ? 'Erst wenn der Rip fertig ist — Rippy startet dafür neu.' : 'Rippy beendet sich, installiert und startet wieder.'}
|
||||
>
|
||||
Jetzt neu starten und installieren
|
||||
</button>
|
||||
<span className="font-mono text-[11px] text-nebel">… oder einfach nichts tun: Beim nächsten Beenden passiert es von selbst.</span>
|
||||
</div>
|
||||
)}
|
||||
{updateHinweis.length > 0 && <p className="mt-2 font-mono text-xs text-warn">{updateHinweis}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section>
|
||||
{verbindung.laufwerke.length === 0 ? (
|
||||
<div className="flex items-center gap-5 rounded-2xl border border-tinte/10 bg-karte/50 p-6">
|
||||
<IconDisc groesse={44} farbe="oklch(0.4 0.02 75)" />
|
||||
<p className="font-mono text-xs text-nebel">{verbindung.verbunden ? 'Kein optisches Laufwerk gefunden.' : 'Warte auf den Kern …'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{verbindung.laufwerke.map((geraet) => {
|
||||
const ripVorgangId = verbindung.rips[geraet.id]?.vorgangId ?? ''
|
||||
const laeuft = verbindung.vorgaenge?.kompression.laeuft ?? null
|
||||
const wartend = verbindung.vorgaenge?.kompression.wartend ?? []
|
||||
return (
|
||||
<LaufwerkKachel
|
||||
key={geraet.id}
|
||||
geraet={geraet}
|
||||
rip={verbindung.rips[geraet.id]}
|
||||
info={verbindung.discInfos[geraet.id]}
|
||||
ripStartMs={verbindung.ripStart[geraet.id]}
|
||||
protokoll={verbindung.ripProtokoll[geraet.id] ?? []}
|
||||
verbindung={verbindung}
|
||||
bericht={verbindung.gesundheit[geraet.id]}
|
||||
vorgang={verbindung.vorgaenge?.vorgaenge.find((v) => v.id === ripVorgangId)}
|
||||
kompression={laeuft !== null && laeuft.vorgangId === ripVorgangId ? laeuft : null}
|
||||
wartePlatz={wartend.findIndex((w) => w.vorgangId === ripVorgangId) + 1}
|
||||
automatik={verbindung.automatik[geraet.id]}
|
||||
aufRippen={() => {
|
||||
// Der Nutzer entscheidet selbst — ein Countdown endet.
|
||||
automatikStopp(geraet.id)
|
||||
// Erst die Titel lesen (20-120 s), dann waehlen lassen.
|
||||
setDialog({ art: 'titel', id: geraet.id })
|
||||
titelLesen(geraet.id)
|
||||
}}
|
||||
aufAendern={() => {
|
||||
automatikStopp(geraet.id)
|
||||
setDialog({ art: 'aendern', id: geraet.id })
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{verbindung.vorgaenge !== null && (
|
||||
<section>
|
||||
<KompressionsKarte stand={verbindung.vorgaenge.kompression} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section>
|
||||
<SpeicherKarte speicher={verbindung.speicher} statistik={verbindung.vorgaenge?.statistik ?? null} />
|
||||
</section>
|
||||
|
||||
{verbindung.vorgaenge !== null && (
|
||||
<section>
|
||||
<VerlaufKarte vorgaenge={verbindung.vorgaenge.vorgaenge} aufAlle={aufRohDateien} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{dialog !== null && dialog.art === 'titel' && (
|
||||
<TitelWahlDialog
|
||||
id={dialog.id}
|
||||
typ={verbindung.laufwerke.find((l) => l.id === dialog.id)?.typ ?? 'bluray'}
|
||||
stand={verbindung.discTitel[dialog.id]}
|
||||
info={verbindung.discInfos[dialog.id]}
|
||||
einstellungen={verbindung.einstellungen}
|
||||
regel={verbindung.vorgaenge?.regel ?? ''}
|
||||
speicher={verbindung.speicher}
|
||||
vorschlaege={verbindung.vorschlaege[dialog.id] ?? []}
|
||||
schliessen={() => setDialog(null)}
|
||||
/>
|
||||
)}
|
||||
{dialog !== null && dialog.art === 'aendern' && (
|
||||
<AendernDialog
|
||||
id={dialog.id}
|
||||
startText={verbindung.discInfos[dialog.id]?.titel ?? ''}
|
||||
vorschlaege={verbindung.vorschlaege[dialog.id] ?? []}
|
||||
schliessen={() => setDialog(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{verbindung.meldungen.length > 0 && (
|
||||
<section>
|
||||
<div className="mb-2.5 font-mono text-[10px] tracking-[0.22em] text-nebel">EREIGNISSE</div>
|
||||
<div className="max-h-56 space-y-1 overflow-y-auto rounded-2xl border border-tinte/10 bg-karte/50 p-4">
|
||||
{verbindung.meldungen.map((zeile, i) => (
|
||||
<p key={i} className="break-all font-mono text-[11px] text-dunst">
|
||||
{zeile}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
// Der Verlauf (5.5.0) — die letzten Vorgänge auf einen Blick, statt der
|
||||
// Bibliothek (Commander: „Brauchen wir die wirklich?" — nein: Jellyfin IST
|
||||
// die Bibliothek; die Wiedererkennung braucht keine Ansicht).
|
||||
import type { GeraeteInfo, VorgangEintrag } from '../../gemeinsam/nachrichten'
|
||||
import { leisteAusVorgang } from '../../gemeinsam/vorgangsleiste'
|
||||
import { Badge, TYP_NAMEN, datumKurz, gb } from '../bausteine'
|
||||
import { VorgangsLeiste } from './VorgangsLeiste'
|
||||
|
||||
const PHASEN_TEXT: Record<VorgangEintrag['phase'], string> = {
|
||||
rippt: 'rippt',
|
||||
wartet: 'wartet',
|
||||
komprimiert: 'komprimiert',
|
||||
'legt-ab': 'legt ab',
|
||||
fertig: 'fertig',
|
||||
fehler: 'Fehler',
|
||||
abgebrochen: 'abgebrochen',
|
||||
}
|
||||
|
||||
export function phasenTon(p: VorgangEintrag['phase']): 'gruen' | 'gelb' | 'blau' | 'grau' {
|
||||
return p === 'fertig' ? 'gruen' : p === 'fehler' || p === 'abgebrochen' ? 'gelb' : p === 'wartet' ? 'grau' : 'blau'
|
||||
}
|
||||
|
||||
export function VerlaufKarte({ vorgaenge, aufAlle }: { vorgaenge: VorgangEintrag[]; aufAlle: () => void }) {
|
||||
if (vorgaenge.length === 0) return null
|
||||
const letzte = vorgaenge.slice(0, 6)
|
||||
return (
|
||||
<div className="rounded-2xl border border-tinte/10 bg-karte/50 p-5">
|
||||
<div className="flex items-center gap-2.5 font-mono text-[10.5px] tracking-[0.12em] text-nebel">
|
||||
<span className="text-dunst">VERLAUF</span>
|
||||
<span>·</span>
|
||||
<span>die letzten {letzte.length} Vorgänge</span>
|
||||
<button onClick={aufAlle} className="ml-auto font-mono text-[10.5px] text-nebel hover:text-dunst">
|
||||
alle unter Roh-Dateien →
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-3 space-y-1">
|
||||
{letzte.map((v) => (
|
||||
<div key={v.id} className="flex flex-wrap items-center gap-x-3 gap-y-1 rounded-lg border border-tinte/8 bg-samt/40 px-3 py-2">
|
||||
<span className="min-w-0 flex-1 truncate font-display text-[14px] font-bold text-schnee" title={v.titel}>
|
||||
{v.titel}
|
||||
{v.jahr !== null && <span className="ml-1 font-medium text-dunst">({v.jahr})</span>}
|
||||
</span>
|
||||
<VorgangsLeiste stand={leisteAusVorgang(v)} kompakt />
|
||||
<Badge text={PHASEN_TEXT[v.phase]} ton={phasenTon(v.phase)} />
|
||||
<span className="font-mono text-[10.5px] text-nebel">
|
||||
{(TYP_NAMEN[v.typ as GeraeteInfo['typ']] ?? v.typ).toUpperCase()} · {datumKurz(v.begonnen)}
|
||||
{v.phase === 'fertig' && v.fertigBytes > 0 && ` · ${gb(v.fertigBytes)}`}
|
||||
{!v.rohGeloescht && v.rohBytes > 0 && ` · Roh ${gb(v.rohBytes)}`}
|
||||
</span>
|
||||
{v.phase === 'fertig' && v.zielOrdner.length > 0 && (
|
||||
<button
|
||||
onClick={() => void window.rippy.ordnerOeffnen(v.zielOrdner)}
|
||||
className="font-mono text-[10.5px] text-nebel hover:text-dunst"
|
||||
title={v.zielOrdner}
|
||||
>
|
||||
Ordner öffnen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
// Die Vorgangs-Leiste (5.6.0) — der ganze Weg einer Disc in Stationen, mit
|
||||
// dem Segment-Balken in der aktiven Station. Logik in
|
||||
// gemeinsam/vorgangsleiste.ts; hier nur das Zeichnen. `kompakt` = Punkte
|
||||
// (Verlauf, Warteschlange).
|
||||
import { Fragment } from 'react'
|
||||
import { STATIONS_NAMEN, stationen, stationsZustand, type LeisteStand } from '../../gemeinsam/vorgangsleiste'
|
||||
import { SegmentBalken } from '../bausteine'
|
||||
|
||||
export function VorgangsLeiste({ stand, restText, kompakt }: { stand: LeisteStand; restText?: string; kompakt?: boolean }) {
|
||||
const liste = stationen(stand.rettung)
|
||||
const zustaende = stationsZustand(liste, stand.station, stand.zustand)
|
||||
const titel = `${STATIONS_NAMEN[stand.station]}: ${
|
||||
stand.zustand === 'laeuft' ? 'läuft' : stand.zustand === 'wartet' ? 'wartet' : stand.zustand === 'fertig' ? 'fertig' : stand.zustand === 'fehler' ? 'Fehler' : 'abgebrochen'
|
||||
}`
|
||||
|
||||
if (kompakt) {
|
||||
return (
|
||||
<span className="inline-flex flex-none items-center gap-1" title={titel} aria-label={titel}>
|
||||
{liste.map((s, i) => (
|
||||
<span
|
||||
key={s}
|
||||
className={`inline-block h-1.5 w-1.5 rounded-full ${
|
||||
zustaende[i] === 'fertig'
|
||||
? 'bg-gold'
|
||||
: zustaende[i] === 'aktiv'
|
||||
? stand.zustand === 'wartet'
|
||||
? 'bg-gold/50'
|
||||
: 'animate-pulse bg-gold-hell'
|
||||
: zustaende[i] === 'fehler'
|
||||
? 'bg-rot'
|
||||
: 'bg-tinte/20'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5" title={titel}>
|
||||
{liste.map((s, i) => {
|
||||
const z = zustaende[i]
|
||||
const aktiv = z === 'aktiv'
|
||||
const rahmen =
|
||||
z === 'fehler'
|
||||
? 'border-rot/50 bg-rot/10'
|
||||
: aktiv
|
||||
? 'min-w-[200px] flex-1 border-gold/50 bg-gold/10'
|
||||
: z === 'fertig'
|
||||
? 'border-gold/30 bg-gold/5'
|
||||
: 'border-tinte/10'
|
||||
return (
|
||||
<Fragment key={s}>
|
||||
{i > 0 && <span className={`h-px w-3 flex-none ${zustaende[i - 1] === 'fertig' ? 'bg-gold/60' : 'bg-tinte/15'}`} />}
|
||||
<div className={`flex items-center gap-2 rounded-lg border px-2 py-1 ${rahmen}`}>
|
||||
<span
|
||||
className={`flex h-4 w-4 flex-none items-center justify-center rounded-full font-mono text-[9px] ${
|
||||
z === 'fertig'
|
||||
? 'bg-gold text-buehne'
|
||||
: aktiv
|
||||
? 'bg-gold-hell text-buehne'
|
||||
: z === 'fehler'
|
||||
? 'bg-rot text-buehne'
|
||||
: 'border border-tinte/20 text-nebel'
|
||||
}`}
|
||||
>
|
||||
{z === 'fertig' ? '✓' : z === 'fehler' ? '!' : String(i + 1)}
|
||||
</span>
|
||||
<span className={`font-mono text-[10px] tracking-[0.12em] ${aktiv ? 'text-gold-hell' : z === 'fertig' ? 'text-gold/80' : z === 'fehler' ? 'text-rot' : 'text-nebel'}`}>
|
||||
{STATIONS_NAMEN[s].toUpperCase()}
|
||||
</span>
|
||||
{aktiv && stand.zustand === 'laeuft' && stand.prozent >= 0 && (
|
||||
<div className="min-w-[80px] flex-1">
|
||||
<SegmentBalken prozent={stand.prozent} />
|
||||
</div>
|
||||
)}
|
||||
{aktiv && stand.zustand === 'laeuft' && stand.prozent >= 0 && <span className="font-mono text-[10.5px] text-gold-hell">{stand.prozent} %</span>}
|
||||
{aktiv && stand.zustand === 'laeuft' && stand.prozent < 0 && stand.hinweis.length === 0 && (
|
||||
<span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-gold-hell" />
|
||||
)}
|
||||
{aktiv && restText !== undefined && restText.length > 0 && <span className="font-mono text-[10px] text-nebel">{restText}</span>}
|
||||
{aktiv && stand.hinweis.length > 0 && <span className="font-mono text-[10px] text-nebel">{stand.hinweis}</span>}
|
||||
{z === 'fehler' && <span className="font-mono text-[10px] text-rot">{stand.zustand === 'abgebrochen' ? 'abgebrochen' : 'Fehler'}</span>}
|
||||
</div>
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
// Ablage-Namen und die Zielpfad-Vorschau — PUR, ohne Dateisystem, damit
|
||||
// Kern UND Fenster dieselbe Regel benutzen (5.4.0: „Vorschau, welche Datei
|
||||
// landet wo" im Auswahl-Dialog). Die Kern-Seite (kern/ablage/struktur.ts)
|
||||
// baut auf diesen Namen ihre echten Ordner.
|
||||
|
||||
// Zeichen, die in Ordnernamen auf SMB/NTFS/ext4 Ärger machen.
|
||||
// eslint-Hinweis unnötig: bewusst inklusive Steuerzeichen.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const VERBOTEN = /[<>:"/\\|?*\x00-\x1f]/g
|
||||
|
||||
/** Titel → dateisystem-tauglicher Ordnername (pur). */
|
||||
export function sichererName(titel: string, jahr?: number | string | null): string {
|
||||
let name = (titel ?? '').replace(VERBOTEN, '').trim().replace(/\.+$/, '')
|
||||
name = name.replace(/\s+/g, ' ').slice(0, 150).trim()
|
||||
if (name.length === 0) return ''
|
||||
// Kein doppeltes Jahr: OMDb-Titel tragen es teils schon im Namen.
|
||||
if (jahr !== undefined && jahr !== null && String(jahr).length > 0 && !name.endsWith(`(${jahr})`)) {
|
||||
name = `${name} (${jahr})`
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
export function staffelOrdnerName(staffel: number): string {
|
||||
return `Season ${String(Math.trunc(staffel)).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function folgenDateiname(serie: string, staffel: number, folge: number): string {
|
||||
const s = String(Math.trunc(staffel)).padStart(2, '0')
|
||||
const e = String(Math.trunc(folge)).padStart(2, '0')
|
||||
return `${sichererName(serie) || 'Serie'} S${s}E${e}.mkv`
|
||||
}
|
||||
|
||||
export interface VorschauEingabe {
|
||||
ablage: string
|
||||
alsSerie: boolean
|
||||
titel: string
|
||||
jahr: number | null
|
||||
staffel: number | null
|
||||
ersteFolge: number | null
|
||||
/** Zahl der gewählten Folgen (Serie) bzw. Hauptfilme (Film). */
|
||||
hauptTitel: number
|
||||
extras: number
|
||||
/** Der Vorgangsname — gilt, wenn kein Titel erkannt ist. */
|
||||
vorgangsName: string
|
||||
}
|
||||
|
||||
/** Zwei Zeilen: Zielordner und was darin landet (pur). Windows-Trenner,
|
||||
* weil die Vorschau eine Windows-Ablage beschreibt. */
|
||||
export function zielVorschau(e: VorschauEingabe): [string, string] {
|
||||
const wurzel = e.ablage.replace(/[\\/]+$/, '')
|
||||
const titel = e.titel.trim().length > 0 ? e.titel.trim() : e.vorgangsName
|
||||
if (e.alsSerie) {
|
||||
const staffel = e.staffel ?? 1
|
||||
const ordner = `${wurzel}\\Serien\\${sichererName(titel) || 'Unbekannte Serie'}\\${staffelOrdnerName(staffel)}\\`
|
||||
const n = Math.max(1, e.hauptTitel)
|
||||
let inhalt: string
|
||||
if (e.ersteFolge !== null && e.ersteFolge > 0) {
|
||||
const erste = folgenDateiname(titel, staffel, e.ersteFolge)
|
||||
inhalt =
|
||||
n === 1
|
||||
? erste
|
||||
: `${erste} … ${folgenDateiname(titel, staffel, e.ersteFolge + n - 1)} (${n} Folgen, fortlaufend)`
|
||||
} else {
|
||||
inhalt = `${n} ${n === 1 ? 'Folge' : 'Folgen'} — Nummern aus den TMDb-Laufzeiten, sonst bleiben die MakeMKV-Namen`
|
||||
}
|
||||
if (e.extras > 0) inhalt += ` · extras\\ (${e.extras})`
|
||||
return [ordner, inhalt]
|
||||
}
|
||||
const ordner = `${wurzel}\\Filme\\${sichererName(titel, e.jahr) || e.vorgangsName}\\`
|
||||
const teile: string[] = []
|
||||
if (e.hauptTitel > 0) teile.push(e.hauptTitel === 1 ? 'der Hauptfilm' : `${e.hauptTitel} Hauptfilme`)
|
||||
if (e.extras > 0) teile.push(`extras\\ (${e.extras} ${e.extras === 1 ? 'Zugabe' : 'Zugaben'})`)
|
||||
return [ordner, teile.length > 0 ? teile.join(' · ') : 'nichts gewählt']
|
||||
}
|
||||
@@ -1,805 +0,0 @@
|
||||
// Das Nachrichten-Schema zwischen den drei Prozessen (KONZEPT-WINDOWS.md § 4.1).
|
||||
// NUR Typen und Prüf-Funktionen — keine Logik, keine Abhängigkeiten, denn diese
|
||||
// Datei läuft im Fenster UND im Kern.
|
||||
//
|
||||
// Wege:
|
||||
// Kern → Haupt über process.parentPort (KernNachricht)
|
||||
// Kern ↔ Fenster über einen direkten MessagePort (KernNachricht/FensterNachricht)
|
||||
// Haupt → Fenster über IPC-Kanal 'haupt-status' (HauptStatus)
|
||||
// Haupt → Kern über utilityProcess.postMessage (HauptNachricht)
|
||||
//
|
||||
// Regel seit dem 01.09.2026 (test/waechter.test.ts): JEDE `art:` einer Union
|
||||
// steht wörtlich im zugehörigen Prüfer — sonst verwirft der Kern die
|
||||
// Nachricht, und niemand erfährt es.
|
||||
|
||||
/** Zustand der Datenbank, wie der Kern ihn beim Start gemessen hat. */
|
||||
export type DbStatus = { ok: true; pfad: string } | { ok: false; fehler: string }
|
||||
|
||||
// ── Laufwerke (Etappe W-1) ──────────────────────────────────────────────
|
||||
export type DiscTyp = 'cd' | 'dvd' | 'bluray' | 'uhd' | 'unknown'
|
||||
export type LaufwerkStatus = 'ready' | 'empty' | 'unknown'
|
||||
|
||||
export interface GeraeteInfo {
|
||||
/** Kurze Kennung fürs UI und für Befehle — der Laufwerksbuchstabe. */
|
||||
id: string
|
||||
name: string
|
||||
typ: DiscTyp
|
||||
pfad: string
|
||||
status: LaufwerkStatus
|
||||
modell: string
|
||||
seriennummer: string
|
||||
/** Größe des eingelegten Mediums in Bytes; 0, wenn unbekannt/leer. */
|
||||
groesseBytes: number
|
||||
/** Leer, solange alles geht — sonst steht hier, WARUM „unknown" dasteht. */
|
||||
grund: string
|
||||
}
|
||||
|
||||
export type WacheEreignis =
|
||||
| { art: 'disc-eingelegt'; geraet: GeraeteInfo }
|
||||
| { art: 'disc-entnommen'; geraet: GeraeteInfo }
|
||||
| { art: 'laufwerk-neu'; geraet: GeraeteInfo }
|
||||
| { art: 'laufwerk-weg'; id: string }
|
||||
|
||||
export type AuswurfGrund = 'ok' | 'kein-zugriff' | 'abgelehnt' | 'bleibt-drin'
|
||||
|
||||
// ── Rippen (Etappe W-2) ─────────────────────────────────────────────────
|
||||
export type RipPhase =
|
||||
| 'liest-info'
|
||||
/** 5.5.0: Das Rettungs-Abbild wird gelesen — Sektor für Sektor, Nullen für Unlesbares. */
|
||||
| 'rettet'
|
||||
| 'rippt'
|
||||
| 'komprimiert'
|
||||
| 'legt-ab'
|
||||
| 'fertig'
|
||||
| 'fehler'
|
||||
| 'abgebrochen'
|
||||
|
||||
export interface RipStatus {
|
||||
/** Laufwerks-Kennung, auf der der Rip läuft. */
|
||||
id: string
|
||||
phase: RipPhase
|
||||
/** 0–100; -1 heißt „noch keine Angabe" (ein echtes 0 % bleibt sichtbar). */
|
||||
prozent: number
|
||||
text: string
|
||||
dateien: string[]
|
||||
lesefehler: boolean
|
||||
/** Der wievielte gewählte Titel läuft gerade (1-basiert) und wie viele es
|
||||
* insgesamt sind — Rippy rippt Titel für Titel. Fehlt bei Audio-CD, ISO
|
||||
* und in Fehler-Meldungen; die kennen keine Titel. */
|
||||
titelNr?: number
|
||||
titelGesamt?: number
|
||||
/** Der Lauf klemmt an einer festen Stelle der Disc — ein physischer
|
||||
* Defekt, kein Fortschrittsproblem. Fehlt/null heißt: alles in Ordnung. */
|
||||
haengt?: { offset: string; wiederholungen: number } | null
|
||||
/** Der Vorgang, zu dem dieser Rip gehört (5.4.0) — die Kompression läuft
|
||||
* danach unter dieser Kennung in der Warteschlange weiter. */
|
||||
vorgangId?: string
|
||||
/** 5.5.0: Rippy hat an dieser Disc Lesefehler GESEHEN (Defekt-Wächter,
|
||||
* Lesefehler-Meldung, Leseabbruch) — erst dann wird das Rettungs-Abbild
|
||||
* angeboten. Eine heile Disc braucht es nicht (Commander 01.09.2026). */
|
||||
rettungMoeglich?: boolean
|
||||
/** 5.5.0: Stand des Rettungs-Abbilds — unlesbare Sektoren und Stellen. */
|
||||
rettung?: { unlesbareSektoren: number; stellen: number }
|
||||
}
|
||||
|
||||
/** Was ein Titel IST — davon hängt ab, WOHIN er abgelegt wird:
|
||||
* hauptfilm → Filme/<Titel> (Jahr)/<Titel> (Jahr).mkv
|
||||
* extra → Filme/<Titel> (Jahr)/extras/… (Jellyfin/Emby/Kodi)
|
||||
* folge → Serien/<Titel>/Season NN/… S01E02.mkv
|
||||
* EINE Definition für Kern und Fenster. */
|
||||
export type TitelRolle = 'hauptfilm' | 'extra' | 'folge'
|
||||
|
||||
/** Eine Sprache, wie der Info-Lauf sie an einem Titel gemessen hat
|
||||
* (SINFO-Attribute 3/4, parser.ts). `spuren` zählt, wie viele Spuren
|
||||
* dieser Sprache es gibt (Stereo + 5.1 = 2). */
|
||||
export interface SprachEintrag {
|
||||
lang: string
|
||||
sprache: string
|
||||
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.
|
||||
* Die Dauer reist mit, weil die Episoden-Zuordnung sie braucht: Rippy
|
||||
* gleicht die Laufzeiten gegen die Staffel bei TMDb ab und benennt NUR
|
||||
* bei eindeutiger Zuordnung um (§ 4.2). */
|
||||
export interface RipAuftragTitel {
|
||||
nr: number
|
||||
rolle: TitelRolle
|
||||
dauerS: number
|
||||
/** Größe laut Info-Lauf — gewichtet den durchgehenden Fortschrittsbalken
|
||||
* (5.4.0). Fehlt: alle Titel zählen gleich. */
|
||||
groesseBytes?: number
|
||||
/** Sprachen JE TITEL (5.4.0, Commander-Wunsch: japanischer Ton, deutsche
|
||||
* Untertitel). Leer/fehlend heißt: die globale Einstellung gilt. */
|
||||
audioSprachen?: string[]
|
||||
untertitelSprachen?: string[]
|
||||
/** 5.6.0, nur für Extras: WELCHE Untertitel-Sprache beim Abspielen
|
||||
* vorgewählt ist (weiche Spur, nie eingebrannt). null = keine Vorwahl;
|
||||
* fehlend = Rippys Regel (fremder Ton → Wunschsprache). */
|
||||
untertitelVorwahl?: string | null
|
||||
/** 5.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
|
||||
* (Double Feature) — er bekommt eigenen Roh-Ordner, Vorgang und Zielordner. */
|
||||
film?: MetaVorschlag
|
||||
}
|
||||
|
||||
/** Was der Nutzer im Auswahl-Dialog über die GANZE Disc sagt (5.4.0):
|
||||
* Staffel und erste Folge von Hand — Rippy rät bewusst nicht, aber der
|
||||
* Nutzer weiß, dass auf Disc 2 die Folgen 3–4 sind. */
|
||||
export interface RipAuftragOptionen {
|
||||
/** null/fehlend: was die Disc sagt (bdmt/Label), sonst 1. */
|
||||
staffel?: number | null
|
||||
/** Fortlaufend ab dieser Folge in Disc-Reihenfolge. null/fehlend:
|
||||
* Zuordnung über die TMDb-Laufzeiten wie bisher. */
|
||||
ersteFolge?: number | null
|
||||
}
|
||||
|
||||
/** Ein Titel der Disc, wie ihn der Auswahl-Dialog zeigt (§ 6.2). */
|
||||
export interface DiscTitelVorschlag {
|
||||
nr: number
|
||||
dauerS: number
|
||||
groesseBytes: number
|
||||
kapitel: number
|
||||
/** Von Rippy vorausgewählt. */
|
||||
gewaehlt: boolean
|
||||
/** Als was Rippy ihn einsortieren würde — im Dialog änderbar. */
|
||||
rolle: TitelRolle
|
||||
/** Warum — Klartext („Folge", „Sammeltitel …", „zu kurz …"). */
|
||||
grund: string
|
||||
/** Die gemessenen Tonspuren und Untertitel DIESES Titels (5.4.0). */
|
||||
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,
|
||||
* deshalb trägt der Stand ein eigenes `laeuft` — das Fenster steht in der
|
||||
* Zeit nicht stumm da (R2: nichts ohne Auskunft). */
|
||||
export interface TitelStand {
|
||||
id: string
|
||||
laeuft: boolean
|
||||
titel: DiscTitelVorschlag[]
|
||||
/** Einordnung („Serie: 2 Folgen erwartet, 2 gefunden — passt."). */
|
||||
einordnung: string
|
||||
/** Rippy ist unsicher — das Fenster fragt dann deutlicher nach. */
|
||||
unsicher: boolean
|
||||
fehler: string
|
||||
}
|
||||
|
||||
/** Ein Darsteller fürs Kino-Banner (TMDb credits.cast, 5.4.0 gemessen). */
|
||||
export interface Darsteller {
|
||||
name: string
|
||||
rolle: string
|
||||
/** TMDb-Profilbild (relativ, z. B. /xCAs….jpg) — '' wenn keins. */
|
||||
bildPfad: string
|
||||
}
|
||||
|
||||
/** Was Rippy über die eingelegte Disc herausgefunden hat (§ 5 Schritt 3). */
|
||||
export interface DiscInfoStand {
|
||||
id: string
|
||||
titel: string
|
||||
jahr: number | null
|
||||
/** 0–1; die Schwelle für „sicher genug" ist einstellbar. */
|
||||
confidence: number
|
||||
quelle: string
|
||||
posterPfad: string
|
||||
/** TMDb-Hintergrundbild (Querformat) fürs Kachel-Panorama; '' wenn keins. */
|
||||
backdropPfad: string
|
||||
/** Kurzbeschreibung (TMDb-Overview) — '' wenn keine. */
|
||||
beschreibung: string
|
||||
/** Datum des früheren Rips dieser Disc — die Bibliothek erkennt sie am
|
||||
* Fingerabdruck wieder (§ 6.6); null wenn neu. */
|
||||
schonGerippt: string | null
|
||||
/** Nummer der Disc in einer Mehr-Disc-Ausgabe — null, wenn keine dasteht. */
|
||||
discNr: number | null
|
||||
/** Staffel-Nummer aus Titel oder Label — null, wenn keine dasteht. */
|
||||
staffel: number | null
|
||||
/** Folgen laut dem Inhaltsverzeichnis der Disc selbst — 0, wenn es keines
|
||||
* gibt. Ab zwei gilt „Serie" als bewiesen (FOLGEN_FUER_SERIE). */
|
||||
folgen: number
|
||||
/** 'movie' | 'tv' | '' — was die Zuordnung sagt. */
|
||||
typ: string
|
||||
// ── Kino-Banner v2 (5.4.0, alles aus DERSELBEN TMDb-Anfrage gemessen) ──
|
||||
/** Freigestellter Schriftzug (TMDb images.logos) — '' wenn keiner. */
|
||||
logoPfad: string
|
||||
tagline: string
|
||||
/** TMDb vote_average (0 = keine). */
|
||||
bewertung: number
|
||||
/** Deutsche Freigabe („16", „18") — '' wenn unbekannt. */
|
||||
fsk: string
|
||||
laufzeitMinuten: number
|
||||
genres: string[]
|
||||
besetzung: Darsteller[]
|
||||
/** Was Rippy NACH dem Info-Lauf noch dazugelernt hat („Laufzeit
|
||||
* bestätigt: 124 min"), Klartext-Zeilen. */
|
||||
hinweise: string[]
|
||||
/** 5.5.0 Serien-Merker: Was Rippy von der VORIGEN Disc dieser Serie weiß —
|
||||
* Staffel und die Folge, mit der es weitergeht. null, wenn nichts. */
|
||||
serienMerker: { staffel: number; naechsteFolge: number; wann: string } | null
|
||||
}
|
||||
|
||||
/** Ein Eintrag der Bibliothek (§ 6.6) — seit 5.5.0 nur noch die
|
||||
* Wiedererkennung („schon gerippt am …"), keine eigene Ansicht mehr. */
|
||||
export interface BibliothekEintrag {
|
||||
fingerprint: string
|
||||
titel: string
|
||||
jahr: number | null
|
||||
typ: string
|
||||
groesseBytes: number
|
||||
ablageort: string
|
||||
datum: string
|
||||
dauerS: number
|
||||
/** TMDb-Posterpfad — '' wenn keiner bekannt. */
|
||||
posterPfad: string
|
||||
}
|
||||
|
||||
/** Ein wählbarer Metadaten-Vorschlag (§ 5 Schritt 4: „nein → fragen"). */
|
||||
export interface MetaVorschlag {
|
||||
typ: 'movie' | 'tv'
|
||||
tmdbId: number
|
||||
titel: string
|
||||
jahr: number | null
|
||||
posterPfad: string
|
||||
}
|
||||
|
||||
/** Auskunft über die Werkzeuge (Katalog + gemessene HandBrake-Listen). */
|
||||
export interface WerkzeugAuskunft {
|
||||
makemkvcon: string
|
||||
handbrake: string
|
||||
flac: string
|
||||
presets: string[]
|
||||
backends: string[]
|
||||
}
|
||||
|
||||
/** Stand einer laufenden Werkzeug-Beschaffung (§ 9: MakeMKV-Update). */
|
||||
export interface BeschaffungStand {
|
||||
/** Welches Werkzeug — bisher nur 'makemkv'. */
|
||||
werkzeug: string
|
||||
laeuft: boolean
|
||||
text: string
|
||||
/** 0–1 während des Downloads; null wenn gerade nicht bezifferbar. */
|
||||
anteil: number | null
|
||||
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) ─────────────────────
|
||||
/** Der Lebenslauf eines Vorgangs. `wartet` = Roh-Dateien liegen da, die
|
||||
* Kompression ist eingereiht; `fertig` = abgelegt (komprimiert ODER
|
||||
* verschoben). */
|
||||
export type VorgangPhase = 'rippt' | 'wartet' | 'komprimiert' | 'legt-ab' | 'fertig' | 'fehler' | 'abgebrochen'
|
||||
|
||||
/** Ein Vorgang, wie das Fenster ihn zeigt (Einstellungen → Roh-Dateien,
|
||||
* Übersicht → Verlauf). */
|
||||
export interface VorgangEintrag {
|
||||
id: string
|
||||
laufwerk: string
|
||||
typ: string
|
||||
titel: string
|
||||
jahr: number | null
|
||||
phase: VorgangPhase
|
||||
begonnen: string
|
||||
beendet: string | null
|
||||
rohOrdner: string
|
||||
/** Was der Roh-Ordner JETZT belegt (gemessen, nicht gemerkt). */
|
||||
rohBytes: number
|
||||
rohGeloescht: boolean
|
||||
zielOrdner: string
|
||||
fertigBytes: number
|
||||
komprimiert: boolean
|
||||
preset: string
|
||||
fehler: string
|
||||
titelZahl: number
|
||||
/** Die Roh-Dateien liegen noch da UND der Auftrag ist bekannt. */
|
||||
neuKomprimierbar: boolean
|
||||
/** Wann die Frist abläuft — '' wenn nie (behalten / nicht fertig). */
|
||||
faelligAm: string
|
||||
}
|
||||
|
||||
export interface KompressionStand {
|
||||
vorgangId: string
|
||||
titel: string
|
||||
/** 0–100; -1 = keine Angabe. */
|
||||
prozent: number
|
||||
text: string
|
||||
phase: VorgangPhase
|
||||
/** 5.6.0: Restzeit der laufenden Datei in Sekunden, wie HandBrake sie
|
||||
* meldet (ETA); fehlend/-1 = noch keine Angabe. */
|
||||
restS?: number
|
||||
}
|
||||
|
||||
/** Ein Ordner unter roh\, den kein Vorgang kennt (Rest aus der Zeit vor
|
||||
* 5.4.0 oder von Hand hineingelegt) — nur von Hand löschbar. */
|
||||
export interface FremderOrdner {
|
||||
ordner: string
|
||||
bytes: number
|
||||
}
|
||||
|
||||
/** 5.5.0: Was Rippy insgesamt geschafft hat (aus den Vorgängen). */
|
||||
export interface Statistik {
|
||||
/** Vorgänge insgesamt (Video-Discs). */
|
||||
discs: number
|
||||
/** Davon fertig abgelegt. */
|
||||
fertige: number
|
||||
/** Verlustfrei gesichert, Summe aller Rips (auch wenn Roh inzwischen weg ist). */
|
||||
rohBytes: number
|
||||
/** Fertig abgelegt, Summe. */
|
||||
fertigBytes: number
|
||||
/** Summe der Dauer je fertigem Vorgang (Rip-Beginn bis Ablage-Ende) in Sekunden. */
|
||||
dauerS: number
|
||||
}
|
||||
|
||||
export interface VorgaengeStand {
|
||||
vorgaenge: VorgangEintrag[]
|
||||
/** Summe aller Roh-Ordner, die noch da sind. */
|
||||
rohGesamtBytes: number
|
||||
fremde: FremderOrdner[]
|
||||
/** Die Aufbewahrungs-Regel, die gerade gilt (Einstellung rohAufbewahrung). */
|
||||
regel: string
|
||||
kompression: { laeuft: KompressionStand | null; wartend: KompressionStand[]; pausiert: boolean }
|
||||
statistik: Statistik
|
||||
}
|
||||
|
||||
/** Was das Windows-Ereignisprotokoll über ein Laufwerk sagt (5.4.0). */
|
||||
export interface LaufwerkBericht {
|
||||
/** 'ruhig' | 'disc' | 'hardware' | 'unklar' | 'nicht-lesbar' */
|
||||
urteil: string
|
||||
text: string
|
||||
fehlerhafteBloecke: number
|
||||
controllerfehler: number
|
||||
sonstige: number
|
||||
/** Die jüngsten Zeilen, neueste zuerst („2026-09-01 20:32 · fehlerhafter Block"). */
|
||||
letzte: string[]
|
||||
/** Betrachteter Zeitraum in Stunden. */
|
||||
stunden: number
|
||||
}
|
||||
|
||||
/** 5.5.0: Freier Platz an EINEM Ort — gemessen mit fs.statfs (Node 24). */
|
||||
export interface SpeicherPlatz {
|
||||
pfad: string
|
||||
ok: boolean
|
||||
freiBytes: number
|
||||
gesamtBytes: number
|
||||
/** '' wenn ok — sonst, warum nicht messbar (Pfad fehlt, Netz weg). */
|
||||
fehler: string
|
||||
}
|
||||
|
||||
export interface SpeicherStand {
|
||||
/** Wo Roh-Dateien und Kompression liegen (Arbeitsordner oder Ablage). */
|
||||
arbeit: SpeicherPlatz
|
||||
/** Wo Fertiges landet (die Ablage — beim Commander der Server). */
|
||||
ablage: SpeicherPlatz
|
||||
/** true, wenn ein eigener Arbeitsordner gesetzt ist. */
|
||||
getrennt: boolean
|
||||
}
|
||||
|
||||
/** 5.5.0 Automatik: Countdown bis zum Rip ohne Klick (KONZEPT § 5 Zero-Click). */
|
||||
export interface AutomatikStand {
|
||||
id: string
|
||||
aktiv: boolean
|
||||
sekunden: number
|
||||
text: string
|
||||
}
|
||||
|
||||
/** Nachrichten, die der Kern verschickt. */
|
||||
export type KernNachricht =
|
||||
| { art: 'kern-bereit'; pid: number; nodeVersion: string; datenbank: DbStatus }
|
||||
| { art: 'pong'; zeit: number }
|
||||
| { art: 'kern-fehler'; text: string }
|
||||
/** Eine Zeile fürs Ereignis-Protokoll OHNE Warnzeichen (5.4.0): „Roh-
|
||||
* Ordner von X gelöscht", „Kompression eingereiht" — Auskunft, kein Alarm. */
|
||||
| { art: 'kern-hinweis'; text: string }
|
||||
| { art: 'laufwerke'; liste: GeraeteInfo[]; ereignisse: WacheEreignis[] }
|
||||
| { art: 'auswurf-ergebnis'; id: string; grund: AuswurfGrund; fehler: string | null }
|
||||
| { art: 'rip-status'; status: RipStatus }
|
||||
| { art: 'disc-info'; info: DiscInfoStand }
|
||||
| { art: 'disc-titel'; stand: TitelStand }
|
||||
| { art: 'metadaten-vorschlaege'; id: string; vorschlaege: MetaVorschlag[] }
|
||||
| { art: 'einstellungen'; werte: Record<string, string> }
|
||||
| { art: 'werkzeuge'; auskunft: WerkzeugAuskunft }
|
||||
| { art: 'beschaffung-status'; stand: BeschaffungStand }
|
||||
/** Nur fürs Haupt: Der Kern hat einen geprüften Installer abgelegt —
|
||||
* starten kann ihn nur das Haupt (shell/UAC, § 9). */
|
||||
| { art: 'installer-starten'; pfad: string }
|
||||
| {
|
||||
art: 'schluessel-status'
|
||||
stand: {
|
||||
quelle: string
|
||||
angenommen: boolean | null
|
||||
/** Rips laufen trotzdem (Beta-Modus) — Warnung, kein Ausfall. */
|
||||
weiterBetrieb: boolean
|
||||
gueltigBis: string
|
||||
geholtAm: string
|
||||
text: string
|
||||
/** Die installierte MakeMKV-Fassung (aus MSG 1005) — '' wenn unbekannt. */
|
||||
version: string
|
||||
}
|
||||
}
|
||||
/** Alle Vorgänge, die Roh-Belegung und die Kompressions-Warteschlange
|
||||
* (5.4.0) — geht auch ans Haupt (Taskleiste, Updates warten). */
|
||||
| { 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. */
|
||||
| { art: 'kompression-ergebnis'; vorgangId: string; titel: string; ok: boolean; text: string }
|
||||
| { art: 'laufwerk-gesundheit'; id: string; bericht: LaufwerkBericht }
|
||||
/** 5.5.0: freier Platz an Arbeitsordner und Ablage. */
|
||||
| { art: 'speicher'; stand: SpeicherStand }
|
||||
/** 5.5.0: Automatik-Countdown je Laufwerk. */
|
||||
| { art: 'automatik'; stand: AutomatikStand }
|
||||
/** 5.5.0: Poster beliebter Filme für den Hintergrund der Erst-Einrichtung. */
|
||||
| { art: 'poster-vorschau'; pfade: string[] }
|
||||
|
||||
/** Nachrichten, die das Fenster über den MessagePort an den Kern schickt. */
|
||||
export type FensterNachricht =
|
||||
| { art: 'ping'; zeit: number }
|
||||
| { art: 'auswerfen'; id: string }
|
||||
/** Titel-Lauf anstoßen (§ 6.2): dauert 20–120 s, deshalb erst auf Zuruf
|
||||
* und nicht schon beim Einlegen (der 30.08.-Fund „das Erkennen dauert
|
||||
* sehr sehr lange"). */
|
||||
| { 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. */
|
||||
| { art: 'rip-start'; id: string; auftrag?: RipAuftragTitel[]; optionen?: RipAuftragOptionen }
|
||||
| { art: 'rip-abbruch'; id: string }
|
||||
/** Den gerade laufenden Titel aufgeben und mit dem nächsten weitermachen
|
||||
* — der Ausweg, wenn die Disc an einer Stelle einen Defekt hat. */
|
||||
| { art: 'rip-ueberspringen'; id: string }
|
||||
| { art: 'einstellung-setzen'; schluessel: string; wert: string }
|
||||
| { art: 'metadaten-suchen'; id: string; text: string }
|
||||
| { art: 'zuordnung-setzen'; id: string; vorschlag: MetaVorschlag }
|
||||
| { art: 'einstellungen-laden' }
|
||||
| { art: 'werkzeuge-laden' }
|
||||
/** § 9: MakeMKV über die Quellenkette holen und installieren lassen. */
|
||||
| { art: 'makemkv-update' }
|
||||
/** Schlüsselkette sofort neu laufen lassen (z. B. nach einer Installation). */
|
||||
| { 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 ──
|
||||
| { art: 'vorgaenge-laden' }
|
||||
/** Den Roh-Ordner EINES Vorgangs jetzt löschen (von Hand). */
|
||||
| { art: 'vorgang-roh-loeschen'; vorgangId: string }
|
||||
/** „Wiederholen ab Kompression": Roh-Dateien liegen da, ein Preset-Wechsel
|
||||
* kostet kein Neulesen mehr. `preset` leer = Einstellung gilt. */
|
||||
| { art: 'vorgang-neu-komprimieren'; vorgangId: string; preset?: string }
|
||||
/** Einen wartenden oder laufenden Kompressions-Auftrag abbrechen. */
|
||||
| { art: 'kompression-abbrechen'; vorgangId: string }
|
||||
/** Die Aufräum-Regel sofort anwenden (statt auf den Takt zu warten). */
|
||||
| { art: 'roh-aufraeumen' }
|
||||
/** Einen Ordner unter roh\, den kein Vorgang kennt, löschen. */
|
||||
| { art: 'fremden-ordner-loeschen'; ordner: string }
|
||||
/** Das Windows-Ereignisprotokoll zu diesem Laufwerk lesen. */
|
||||
| { art: 'laufwerk-pruefen'; id: string }
|
||||
// ── 5.5.0 ──
|
||||
/** Die Kompressions-Warteschlange anhalten (an) oder weiterlaufen lassen. */
|
||||
| { art: 'kompression-pause'; an: boolean }
|
||||
/** Den Automatik-Countdown dieses Laufwerks stoppen bzw. sofort rippen. */
|
||||
| { art: 'automatik-stopp'; id: string }
|
||||
| { art: 'automatik-jetzt'; id: string }
|
||||
/** Testnachricht über die eingerichteten Kanäle (Discord, Telegram). */
|
||||
| { art: 'benachrichtigung-testen' }
|
||||
/** Poster beliebter Filme laden (Erst-Einrichtung). */
|
||||
| { art: 'poster-laden' }
|
||||
/** Rettungs-Abbild für eine Disc mit GESEHENEN Lesefehlern. */
|
||||
| { art: 'rettung-start'; id: string }
|
||||
/** Freien Platz neu messen. */
|
||||
| { art: 'speicher-laden' }
|
||||
|
||||
/** Nachrichten, die das Haupt an den Kern schickt. */
|
||||
export type HauptNachricht =
|
||||
| { art: 'fenster-port' }
|
||||
/** Antwort auf 'installer-starten': '' bzw. null heißt gestartet. */
|
||||
| { art: 'installer-ergebnis'; fehler: string | null }
|
||||
|
||||
/** Zustand des Haupt-Prozesses fürs Fenster (Kanal 'haupt-status'). */
|
||||
export interface HauptStatus {
|
||||
version: string
|
||||
/** Prozess-Leine (Job Object, § 3.3): gesetzt oder mit Fehlertext. */
|
||||
leine: { ok: boolean; fehler?: string }
|
||||
kern: { laeuft: boolean; pid?: number; neustarts: number }
|
||||
autostart: boolean
|
||||
/** Selbst-Update (§ 6.8). */
|
||||
update: UpdateStand
|
||||
/** Für die eigene Titelleiste (§ 4.2, seit 5.1.3): Ist das Fenster
|
||||
* gerade maximiert? Entscheidet nur, welches Symbol der Knopf zeigt. */
|
||||
fensterMaximiert: boolean
|
||||
/** 5.5.0: Wo die Protokoll-Dateien liegen — für „Protokoll öffnen". */
|
||||
protokollOrdner: string
|
||||
}
|
||||
|
||||
/** Der Stand des Selbst-Updates (§ 6.8) — EINE Definition, die sich Haupt
|
||||
* und Fenster teilen. Seit 01.09.2026 trägt sie auch die gefundene
|
||||
* Version, ihre Änderungsnotizen und den Ladefortschritt: Der Kanal
|
||||
* arbeitete vorher korrekt, aber unsichtbar (Commander-Fund). */
|
||||
export interface UpdateStand {
|
||||
/** Wann die letzte Prüfung GELANG — '' wenn noch nie. */
|
||||
letzteErfolgreichePruefung: string
|
||||
text: string
|
||||
/** Die gefundene neue Version — '' wenn keine bereitsteht. */
|
||||
neueVersion: string
|
||||
/** Was in ihr neu ist (aus latest.yml) — '' wenn nichts hinterlegt ist. */
|
||||
neuerungen: string
|
||||
/** Geladen und wartet auf den Neustart. */
|
||||
bereit: boolean
|
||||
/** Eine Prüfung oder ein Download läuft gerade. */
|
||||
laeuft: boolean
|
||||
/** Ladefortschritt in Prozent; -1 heißt: kein Download unterwegs. */
|
||||
fortschritt: number
|
||||
}
|
||||
|
||||
function istObjekt(wert: unknown): wert is Record<string, unknown> {
|
||||
return typeof wert === 'object' && wert !== null
|
||||
}
|
||||
|
||||
function istText(wert: unknown): wert is string {
|
||||
return typeof wert === 'string'
|
||||
}
|
||||
|
||||
function istKennung(wert: unknown): wert is string {
|
||||
return typeof wert === 'string' && wert.length > 0
|
||||
}
|
||||
|
||||
export function istKernNachricht(wert: unknown): wert is KernNachricht {
|
||||
if (!istObjekt(wert)) return false
|
||||
switch (wert.art) {
|
||||
case 'kern-bereit':
|
||||
return (
|
||||
typeof wert.pid === 'number' &&
|
||||
typeof wert.nodeVersion === 'string' &&
|
||||
istDbStatus(wert.datenbank)
|
||||
)
|
||||
case 'pong':
|
||||
return typeof wert.zeit === 'number'
|
||||
case 'kern-fehler':
|
||||
case 'kern-hinweis':
|
||||
return typeof wert.text === 'string'
|
||||
case 'laufwerke':
|
||||
return Array.isArray(wert.liste) && Array.isArray(wert.ereignisse)
|
||||
case 'auswurf-ergebnis':
|
||||
return (
|
||||
typeof wert.id === 'string' &&
|
||||
typeof wert.grund === 'string' &&
|
||||
(wert.fehler === null || typeof wert.fehler === 'string')
|
||||
)
|
||||
case 'rip-status':
|
||||
return istRipStatus(wert.status)
|
||||
case 'disc-info':
|
||||
return (
|
||||
istObjekt(wert.info) &&
|
||||
typeof (wert.info as Record<string, unknown>).id === 'string' &&
|
||||
typeof (wert.info as Record<string, unknown>).titel === 'string' &&
|
||||
typeof (wert.info as Record<string, unknown>).confidence === 'number'
|
||||
)
|
||||
case 'disc-titel':
|
||||
return (
|
||||
istObjekt(wert.stand) &&
|
||||
typeof (wert.stand as Record<string, unknown>).id === 'string' &&
|
||||
typeof (wert.stand as Record<string, unknown>).laeuft === 'boolean' &&
|
||||
Array.isArray((wert.stand as Record<string, unknown>).titel)
|
||||
)
|
||||
case 'einstellungen':
|
||||
return istObjekt(wert.werte)
|
||||
case 'werkzeuge':
|
||||
return istObjekt(wert.auskunft) && Array.isArray((wert.auskunft as Record<string, unknown>).presets)
|
||||
case 'schluessel-status':
|
||||
return istObjekt(wert.stand) && typeof (wert.stand as Record<string, unknown>).text === 'string'
|
||||
case 'beschaffung-status':
|
||||
return (
|
||||
istObjekt(wert.stand) &&
|
||||
typeof (wert.stand as Record<string, unknown>).werkzeug === 'string' &&
|
||||
typeof (wert.stand as Record<string, unknown>).text === 'string' &&
|
||||
typeof (wert.stand as Record<string, unknown>).laeuft === 'boolean'
|
||||
)
|
||||
case 'installer-starten':
|
||||
return typeof wert.pfad === 'string' && wert.pfad.length > 0
|
||||
case 'metadaten-vorschlaege':
|
||||
return typeof wert.id === 'string' && Array.isArray(wert.vorschlaege)
|
||||
case 'vorgaenge':
|
||||
return (
|
||||
istObjekt(wert.stand) &&
|
||||
Array.isArray((wert.stand as Record<string, unknown>).vorgaenge) &&
|
||||
typeof (wert.stand as Record<string, unknown>).rohGesamtBytes === 'number' &&
|
||||
istObjekt((wert.stand as Record<string, unknown>).kompression) &&
|
||||
istObjekt((wert.stand as Record<string, unknown>).statistik)
|
||||
)
|
||||
case 'kompression-stand':
|
||||
return istObjekt(wert.kompression) && Array.isArray((wert.kompression as Record<string, unknown>).wartend)
|
||||
case 'kompression-ergebnis':
|
||||
return (
|
||||
istKennung(wert.vorgangId) &&
|
||||
typeof wert.titel === 'string' &&
|
||||
typeof wert.ok === 'boolean' &&
|
||||
typeof wert.text === 'string'
|
||||
)
|
||||
case 'laufwerk-gesundheit':
|
||||
return (
|
||||
istKennung(wert.id) &&
|
||||
istObjekt(wert.bericht) &&
|
||||
typeof (wert.bericht as Record<string, unknown>).urteil === 'string' &&
|
||||
typeof (wert.bericht as Record<string, unknown>).text === 'string' &&
|
||||
Array.isArray((wert.bericht as Record<string, unknown>).letzte)
|
||||
)
|
||||
case 'speicher':
|
||||
return (
|
||||
istObjekt(wert.stand) &&
|
||||
istObjekt((wert.stand as Record<string, unknown>).arbeit) &&
|
||||
istObjekt((wert.stand as Record<string, unknown>).ablage) &&
|
||||
typeof (wert.stand as Record<string, unknown>).getrennt === 'boolean'
|
||||
)
|
||||
case 'automatik':
|
||||
return (
|
||||
istObjekt(wert.stand) &&
|
||||
istKennung((wert.stand as Record<string, unknown>).id) &&
|
||||
typeof (wert.stand as Record<string, unknown>).aktiv === 'boolean' &&
|
||||
typeof (wert.stand as Record<string, unknown>).sekunden === 'number' &&
|
||||
typeof (wert.stand as Record<string, unknown>).text === 'string'
|
||||
)
|
||||
case 'poster-vorschau':
|
||||
return Array.isArray(wert.pfade) && wert.pfade.every(istText)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function istMetaVorschlag(wert: unknown): wert is MetaVorschlag {
|
||||
if (!istObjekt(wert)) return false
|
||||
return (
|
||||
(wert.typ === 'movie' || wert.typ === 'tv') &&
|
||||
typeof wert.tmdbId === 'number' &&
|
||||
typeof wert.titel === 'string' &&
|
||||
(wert.jahr === null || typeof wert.jahr === 'number') &&
|
||||
typeof wert.posterPfad === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
export function istRipStatus(wert: unknown): wert is RipStatus {
|
||||
if (!istObjekt(wert)) return false
|
||||
return (
|
||||
typeof wert.id === 'string' &&
|
||||
typeof wert.phase === 'string' &&
|
||||
typeof wert.prozent === 'number' &&
|
||||
typeof wert.text === 'string' &&
|
||||
Array.isArray(wert.dateien) &&
|
||||
typeof wert.lesefehler === 'boolean'
|
||||
)
|
||||
}
|
||||
|
||||
export function istDbStatus(wert: unknown): wert is DbStatus {
|
||||
if (!istObjekt(wert)) return false
|
||||
if (wert.ok === true) return typeof wert.pfad === 'string'
|
||||
if (wert.ok === false) return typeof wert.fehler === 'string'
|
||||
return false
|
||||
}
|
||||
|
||||
/** Ein Rip-Auftragstitel, wie das Fenster ihn schickt — die Sprachlisten
|
||||
* sind optional, aber wenn sie da sind, müssen es Textlisten sein. */
|
||||
export function istRipAuftragTitel(wert: unknown): wert is RipAuftragTitel {
|
||||
if (!istObjekt(wert)) return false
|
||||
if (typeof wert.nr !== 'number' || typeof wert.dauerS !== 'number') return false
|
||||
if (wert.rolle !== 'hauptfilm' && wert.rolle !== 'extra' && wert.rolle !== 'folge') return false
|
||||
if (wert.groesseBytes !== undefined && typeof wert.groesseBytes !== 'number') return false
|
||||
for (const feld of ['audioSprachen', 'untertitelSprachen']) {
|
||||
const liste = wert[feld]
|
||||
if (liste === undefined) continue
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
if (!istObjekt(wert)) return false
|
||||
if (wert.art === 'ping') return typeof wert.zeit === 'number'
|
||||
if (wert.art === 'rip-start') {
|
||||
if (!istKennung(wert.id)) return false
|
||||
if (wert.auftrag !== undefined && (!Array.isArray(wert.auftrag) || !wert.auftrag.every(istRipAuftragTitel))) {
|
||||
return false
|
||||
}
|
||||
if (wert.optionen !== undefined) {
|
||||
if (!istObjekt(wert.optionen)) return false
|
||||
const o = wert.optionen
|
||||
const zahlOderNull = (v: unknown): boolean => v === undefined || v === null || typeof v === 'number'
|
||||
if (!zahlOderNull(o.staffel) || !zahlOderNull(o.ersteFolge)) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (
|
||||
wert.art === 'auswerfen' ||
|
||||
wert.art === 'titel-lesen' ||
|
||||
wert.art === 'titel-abbruch' ||
|
||||
wert.art === 'rip-abbruch' ||
|
||||
wert.art === 'rip-ueberspringen' ||
|
||||
wert.art === 'laufwerk-pruefen' ||
|
||||
wert.art === 'automatik-stopp' ||
|
||||
wert.art === 'automatik-jetzt' ||
|
||||
wert.art === 'rettung-start'
|
||||
) {
|
||||
return istKennung(wert.id)
|
||||
}
|
||||
if (wert.art === 'kompression-pause') {
|
||||
return typeof wert.an === 'boolean'
|
||||
}
|
||||
if (wert.art === 'beschaffung-antwort') {
|
||||
return typeof wert.ja === 'boolean'
|
||||
}
|
||||
if (wert.art === 'einstellung-setzen') {
|
||||
return istKennung(wert.schluessel) && typeof wert.wert === 'string'
|
||||
}
|
||||
if (wert.art === 'metadaten-suchen') {
|
||||
return istKennung(wert.id) && typeof wert.text === 'string'
|
||||
}
|
||||
if (wert.art === 'zuordnung-setzen') {
|
||||
return istKennung(wert.id) && istMetaVorschlag(wert.vorschlag)
|
||||
}
|
||||
if (wert.art === 'vorgang-roh-loeschen' || wert.art === 'kompression-abbrechen') {
|
||||
return istKennung(wert.vorgangId)
|
||||
}
|
||||
if (wert.art === 'vorgang-neu-komprimieren') {
|
||||
return istKennung(wert.vorgangId) && (wert.preset === undefined || typeof wert.preset === 'string')
|
||||
}
|
||||
if (wert.art === 'fremden-ordner-loeschen') {
|
||||
return istKennung(wert.ordner)
|
||||
}
|
||||
return (
|
||||
wert.art === 'einstellungen-laden' ||
|
||||
wert.art === 'werkzeuge-laden' ||
|
||||
wert.art === 'makemkv-update' ||
|
||||
wert.art === 'schluessel-pruefen' ||
|
||||
wert.art === 'vorgaenge-laden' ||
|
||||
wert.art === 'roh-aufraeumen' ||
|
||||
wert.art === 'benachrichtigung-testen' ||
|
||||
wert.art === 'poster-laden' ||
|
||||
wert.art === 'speicher-laden'
|
||||
)
|
||||
}
|
||||
|
||||
export function istHauptNachricht(wert: unknown): wert is HauptNachricht {
|
||||
if (!istObjekt(wert)) return false
|
||||
if (wert.art === 'fenster-port') return true
|
||||
if (wert.art === 'installer-ergebnis') return wert.fehler === null || typeof wert.fehler === 'string'
|
||||
return false
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
// Die Platz-Prüfung vor dem Rip (5.5.0) — EINE Rechnung für Kern und
|
||||
// Fenster: Roh plus geschätzt 25 % für die Kompression, plus 2 GB Luft.
|
||||
// Der Kern urteilt beim Start (index.ts ripMitPlatz — auch für die
|
||||
// Automatik), der Dialog zeigt dasselbe Urteil schon vor dem Klick
|
||||
// (TitelWahlDialog).
|
||||
export const PLATZ_LUFT_BYTES = 2e9
|
||||
export const KOMPRESSION_ANTEIL = 0.25
|
||||
|
||||
export interface PlatzUrteil {
|
||||
/** Leer, wenn der Platz reicht — sonst der Grund in Klartext. */
|
||||
text: string
|
||||
/** true: nicht einmal der verlustfreie Rip passt — der Start wird verweigert. */
|
||||
hart: boolean
|
||||
}
|
||||
|
||||
export function gbText(bytes: number): string {
|
||||
return `${(bytes / 1e9).toFixed(1).replace('.', ',')} GB`
|
||||
}
|
||||
|
||||
export function platzUrteil(freiBytes: number, rohBytes: number, komprimiert: boolean): PlatzUrteil {
|
||||
const noetig = rohBytes + (komprimiert ? rohBytes * KOMPRESSION_ANTEIL : 0) + PLATZ_LUFT_BYTES
|
||||
if (freiBytes >= noetig) return { text: '', hart: false }
|
||||
if (freiBytes < rohBytes + PLATZ_LUFT_BYTES) {
|
||||
return {
|
||||
text: `Zu wenig Platz: ${gbText(freiBytes)} frei, der Rip braucht ${gbText(rohBytes)} verlustfrei. Das geht schief — erst Platz schaffen (Einstellungen → Roh-Dateien).`,
|
||||
hart: true,
|
||||
}
|
||||
}
|
||||
return {
|
||||
text: `Knapp: ${gbText(freiBytes)} frei, gebraucht werden etwa ${gbText(noetig)} (Roh plus Kompression). Aufräumen unter Roh-Dateien hilft.`,
|
||||
hart: false,
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
// Die Preset-Empfehlung — GEMESSEN, nicht behauptet (Commander-Fund
|
||||
// 30.08.2026: „Das Komprimieren passiert auf der CPU, nicht auf der
|
||||
// deutlich schnelleren GPU"). Der alte Default „H.265 MKV 1080p30" ist
|
||||
// ein CPU-x265-Preset; auf dem Commander-PC kodiert die RX 9070 XT
|
||||
// (VCN) denselben Film in 2 statt 60+ Minuten.
|
||||
//
|
||||
// Regeln:
|
||||
// * Empfohlen wird NUR ein Preset, das in der vom eigenen HandBrake
|
||||
// gemessenen Liste WIRKLICH steht (encoder.ts) — nie ein geratener
|
||||
// Name (die „Invalid preset"-Falle).
|
||||
// * Familienreihenfolge nach den gemessenen Backends: VCN (AMD) →
|
||||
// NVENC (NVIDIA) → QSV (Intel) → CPU-Default.
|
||||
// * HandBrake-Presets DECKELN die Auflösung nur — eine DVD bleibt SD,
|
||||
// auch unter „…1080p" (deshalb ist die 1080p-Empfehlung auch für
|
||||
// DVDs richtig; hochskaliert wird nichts).
|
||||
// Liegt hier in gemeinsam/, weil Kern (Pipeline-Fallback) UND Fenster
|
||||
// (Anzeige „automatisch: …") dieselbe Rechnung brauchen.
|
||||
|
||||
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]> = [
|
||||
['vce', 'VCN'],
|
||||
['nvenc', 'NVENC'],
|
||||
['qsv', 'QSV'],
|
||||
]
|
||||
|
||||
/** Das empfohlene Preset für diesen Disc-Typ — aus den GEMESSENEN
|
||||
* 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(
|
||||
discTyp: string,
|
||||
backends: readonly string[],
|
||||
presets: readonly string[],
|
||||
): string {
|
||||
const gewuenscht = discTyp === 'uhd' ? '2160p 4K' : '1080p'
|
||||
for (const [backend, kennung] of FAMILIEN) {
|
||||
if (!backends.includes(backend)) continue
|
||||
const treffer = presets.find((p) => p.startsWith('H.265 ') && p.includes(kennung) && p.includes(gewuenscht))
|
||||
if (treffer !== undefined) return treffer
|
||||
// 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))
|
||||
if (kleiner !== undefined) return kleiner
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
// Die Protokoll-Datei (5.5.0, aus ARM: LOGPATH/LOGLIFE) — reines Node, damit
|
||||
// Haupt UND Kern sie schreiben können. Eine Datei je Tag unter
|
||||
// <Profil>\logs\rippy-JJJJ-MM-TT.log, ältere als 14 Tage werden beim Start
|
||||
// entfernt. Der Grund: Der Datenbank-Befund vom 01.09.2026 (eine Datenbank,
|
||||
// in die seit dem 30.08. niemand geschrieben hatte) wäre mit einer Zeile
|
||||
// „Datenbank geöffnet: <Pfad>" in einer Datei sofort sichtbar gewesen —
|
||||
// die Konsole eines Tray-Programms sieht niemand.
|
||||
//
|
||||
// Schreibfehler werden NICHT geworfen (das Protokoll darf nie den Betrieb
|
||||
// kippen), aber gezählt und beim nächsten Erfolg vermerkt.
|
||||
import { appendFileSync, existsSync, mkdirSync, readdirSync, unlinkSync, statSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
export const PROTOKOLL_TAGE = 14
|
||||
|
||||
export function protokollDateiname(datum: Date): string {
|
||||
const j = datum.getFullYear()
|
||||
const m = String(datum.getMonth() + 1).padStart(2, '0')
|
||||
const t = String(datum.getDate()).padStart(2, '0')
|
||||
return `rippy-${j}-${m}-${t}.log`
|
||||
}
|
||||
|
||||
/** Zeitstempel lokal, sekundengenau — so, wie der Commander die Uhr liest. */
|
||||
export function zeitstempel(datum: Date): string {
|
||||
const z = (n: number): string => String(n).padStart(2, '0')
|
||||
return `${datum.getFullYear()}-${z(datum.getMonth() + 1)}-${z(datum.getDate())} ${z(datum.getHours())}:${z(datum.getMinutes())}:${z(datum.getSeconds())}`
|
||||
}
|
||||
|
||||
/** Welche Dateien älter als `tage` sind (pur, über den Dateinamen). */
|
||||
export function veraltet(namen: readonly string[], jetzt: Date, tage = PROTOKOLL_TAGE): string[] {
|
||||
const grenze = jetzt.getTime() - tage * 24 * 60 * 60 * 1000
|
||||
return namen.filter((n) => {
|
||||
const treffer = /^rippy-(\d{4})-(\d{2})-(\d{2})\.log$/.exec(n)
|
||||
if (treffer === null) return false
|
||||
const datum = new Date(Number(treffer[1]), Number(treffer[2]) - 1, Number(treffer[3]))
|
||||
return datum.getTime() < grenze
|
||||
})
|
||||
}
|
||||
|
||||
export class Protokoll {
|
||||
private fehlschlaege = 0
|
||||
|
||||
constructor(
|
||||
readonly ordner: string,
|
||||
private readonly quelle: string,
|
||||
private readonly jetzt: () => Date = () => new Date(),
|
||||
) {}
|
||||
|
||||
/** Eine Zeile anhängen: „2026-09-02 21:13:05 [kern] Text". */
|
||||
schreiben(text: string): void {
|
||||
try {
|
||||
if (!existsSync(this.ordner)) mkdirSync(this.ordner, { recursive: true })
|
||||
const datum = this.jetzt()
|
||||
const nachtrag = this.fehlschlaege > 0 ? ` (davor ${this.fehlschlaege} Zeilen nicht schreibbar)` : ''
|
||||
this.fehlschlaege = 0
|
||||
appendFileSync(join(this.ordner, protokollDateiname(datum)), `${zeitstempel(datum)} [${this.quelle}] ${text}${nachtrag}\n`)
|
||||
} catch {
|
||||
this.fehlschlaege += 1
|
||||
}
|
||||
}
|
||||
|
||||
/** Dateien älter als PROTOKOLL_TAGE entfernen — beim Start. */
|
||||
aufraeumen(): number {
|
||||
let weg = 0
|
||||
try {
|
||||
if (!existsSync(this.ordner)) return 0
|
||||
for (const name of veraltet(readdirSync(this.ordner), this.jetzt())) {
|
||||
try {
|
||||
const pfad = join(this.ordner, name)
|
||||
if (statSync(pfad).isFile()) {
|
||||
unlinkSync(pfad)
|
||||
weg += 1
|
||||
}
|
||||
} catch {
|
||||
// Eine gesperrte Datei bleibt bis zum nächsten Start liegen.
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Nicht lesbar: nichts zu tun, nichts zu melden — das Protokoll ist
|
||||
// Komfort.
|
||||
}
|
||||
return weg
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
// Sprachen für die Auswahl-Felder (5.5.0, Commander: „bei Tonspuren und
|
||||
// Untertiteln können Dropdowns hin") — ISO-639-2-Codes, wie MakeMKV
|
||||
// (SINFO-Attribut 3) und HandBrake (--audio-lang-list) sie sprechen, mit
|
||||
// deutschen Namen. Die REIHENFOLGE der Wahl ist die Wunschreihenfolge: die
|
||||
// erste Tonsprache ist die, deren Fehlen die Untertitel vorwählt
|
||||
// (kern/ablauf/auftrag.ts sprachwahl).
|
||||
export interface Sprache {
|
||||
code: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export const SPRACHEN: readonly Sprache[] = [
|
||||
{ code: 'deu', name: 'Deutsch' },
|
||||
{ code: 'eng', name: 'Englisch' },
|
||||
{ code: 'jpn', name: 'Japanisch' },
|
||||
{ code: 'fra', name: 'Französisch' },
|
||||
{ code: 'ita', name: 'Italienisch' },
|
||||
{ code: 'spa', name: 'Spanisch' },
|
||||
{ code: 'por', name: 'Portugiesisch' },
|
||||
{ code: 'nld', name: 'Niederländisch' },
|
||||
{ code: 'pol', name: 'Polnisch' },
|
||||
{ code: 'tur', name: 'Türkisch' },
|
||||
{ code: 'rus', name: 'Russisch' },
|
||||
{ code: 'kor', name: 'Koreanisch' },
|
||||
{ code: 'zho', name: 'Chinesisch' },
|
||||
{ code: 'swe', name: 'Schwedisch' },
|
||||
{ code: 'dan', name: 'Dänisch' },
|
||||
{ code: 'nor', name: 'Norwegisch' },
|
||||
{ code: 'fin', name: 'Finnisch' },
|
||||
{ code: 'ces', name: 'Tschechisch' },
|
||||
{ code: 'hun', name: 'Ungarisch' },
|
||||
{ code: 'ell', name: 'Griechisch' },
|
||||
{ code: 'heb', name: 'Hebräisch' },
|
||||
{ code: 'ara', name: 'Arabisch' },
|
||||
{ code: 'hin', name: 'Hindi' },
|
||||
{ code: 'tha', name: 'Thai' },
|
||||
]
|
||||
|
||||
/** „deu, eng , ,DEU" → ['deu','eng'] — dieselbe Regel wie im Kern (presets.ts). */
|
||||
export function sprachlisteAus(wert: string): string[] {
|
||||
const sauber: string[] = []
|
||||
for (const teil of wert.split(',')) {
|
||||
const code = teil.trim().toLowerCase()
|
||||
if (code.length > 0 && !sauber.includes(code)) sauber.push(code)
|
||||
}
|
||||
return sauber
|
||||
}
|
||||
|
||||
export function sprachName(code: string): string {
|
||||
return SPRACHEN.find((s) => s.code === code)?.name ?? code
|
||||
}
|
||||
|
||||
/** Die Liste als Einstellung zurück: „deu,eng". */
|
||||
export function sprachlisteText(codes: readonly string[]): string {
|
||||
return codes.join(',')
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
// Die Vorgangs-Leiste (5.6.0, Commander: „Phase 1–4 plus Komprimierung —
|
||||
// irgendwie zu einem machen, der Phasen-Balken gefällt mir") — EIN Weg für
|
||||
// jede Disc, vier Stationen: Titel lesen → Rippen → Komprimieren → Ablegen,
|
||||
// bei Bedarf das Rettungs-Abbild als Zwischenstation vor dem Rippen. Die
|
||||
// Leiste zeigt immer den GANZEN Weg, aber nur an einer Stelle die Details:
|
||||
// in der Laufwerks-Kachel, solange das Laufwerk arbeitet (Stationen 1–2),
|
||||
// in der Kompressions-Karte danach (3–4), im Verlauf als Punkte.
|
||||
//
|
||||
// Hier liegt die REINE Logik (welche Station ist fertig, aktiv, offen,
|
||||
// gescheitert) — testbar ohne React; gezeichnet wird in
|
||||
// fenster/uebersicht/VorgangsLeiste.tsx.
|
||||
import type { KompressionStand, RipStatus, VorgangEintrag } from './nachrichten'
|
||||
|
||||
export type Station = 'titel' | 'rettung' | 'rip' | 'kompression' | 'ablage'
|
||||
export type LeisteZustand = 'laeuft' | 'wartet' | 'fertig' | 'fehler' | 'abgebrochen'
|
||||
export type StationsZustand = 'fertig' | 'aktiv' | 'offen' | 'fehler'
|
||||
|
||||
export interface LeisteStand {
|
||||
station: Station
|
||||
zustand: LeisteZustand
|
||||
/** 0–100 in der aktiven Station, -1 = keine Angabe. */
|
||||
prozent: number
|
||||
/** Das Rettungs-Abbild als Zwischenstation zeigen. */
|
||||
rettung: boolean
|
||||
/** Kurztext in der aktiven Station („Platz 2", „läuft — Karte unten"). */
|
||||
hinweis: string
|
||||
}
|
||||
|
||||
export const STATIONS_NAMEN: Record<Station, string> = {
|
||||
titel: 'Titel lesen',
|
||||
rettung: 'Rettungs-Abbild',
|
||||
rip: 'Rippen',
|
||||
kompression: 'Komprimieren',
|
||||
ablage: 'Ablegen',
|
||||
}
|
||||
|
||||
export function stationen(rettung: boolean): Station[] {
|
||||
return rettung ? ['titel', 'rettung', 'rip', 'kompression', 'ablage'] : ['titel', 'rip', 'kompression', 'ablage']
|
||||
}
|
||||
|
||||
/** Zustand je Station: alles vor der aktiven ist fertig, die aktive läuft
|
||||
* (oder ist gescheitert), alles danach ist offen. `fertig` = alle gold. */
|
||||
export function stationsZustand(liste: readonly Station[], station: Station, zustand: LeisteZustand): StationsZustand[] {
|
||||
const i = liste.indexOf(station)
|
||||
return liste.map((_, k) => {
|
||||
if (zustand === 'fertig') return 'fertig'
|
||||
if (k < i) return 'fertig'
|
||||
if (k === i) return zustand === 'fehler' || zustand === 'abgebrochen' ? 'fehler' : 'aktiv'
|
||||
return 'offen'
|
||||
})
|
||||
}
|
||||
|
||||
function stand(station: Station, zustand: LeisteZustand, prozent = -1, rettung = false, hinweis = ''): LeisteStand {
|
||||
return { station, zustand, prozent, rettung, hinweis }
|
||||
}
|
||||
|
||||
/** Die Leiste in der Laufwerks-Kachel: aus dem Rip-Stand und — nach dem
|
||||
* Rip — aus dem Vorgang (ohne Prozent: die Details stehen in der
|
||||
* Kompressions-Karte). */
|
||||
export function leisteAusRip(
|
||||
rip: Pick<RipStatus, 'phase' | 'prozent' | 'rettung'>,
|
||||
vorgang: Pick<VorgangEintrag, 'phase'> | undefined,
|
||||
wartePlatz: number,
|
||||
): LeisteStand {
|
||||
const rettung = rip.phase === 'rettet' || rip.rettung !== undefined
|
||||
switch (rip.phase) {
|
||||
case 'liest-info':
|
||||
return stand('titel', 'laeuft', -1, rettung)
|
||||
case 'rettet':
|
||||
return stand('rettung', 'laeuft', rip.prozent, true)
|
||||
case 'rippt':
|
||||
return stand('rip', 'laeuft', rip.prozent, rettung)
|
||||
case 'komprimiert':
|
||||
return stand('kompression', 'laeuft', rip.prozent, rettung)
|
||||
case 'legt-ab':
|
||||
return stand('ablage', 'laeuft', rip.prozent, rettung)
|
||||
case 'fehler':
|
||||
return stand('rip', 'fehler', -1, rettung)
|
||||
case 'abgebrochen':
|
||||
return stand('rip', 'abgebrochen', -1, rettung)
|
||||
case 'fertig':
|
||||
break
|
||||
}
|
||||
// Der Rip ist durch — wo steht der Vorgang?
|
||||
if (vorgang === undefined) return stand('kompression', 'wartet', -1, rettung, 'eingereiht')
|
||||
switch (vorgang.phase) {
|
||||
case 'rippt':
|
||||
return stand('rip', 'laeuft', -1, rettung)
|
||||
case 'wartet':
|
||||
return stand('kompression', 'wartet', -1, rettung, wartePlatz > 0 ? `Platz ${wartePlatz}` : 'wartet')
|
||||
case 'komprimiert':
|
||||
return stand('kompression', 'laeuft', -1, rettung, 'läuft — Karte unten')
|
||||
case 'legt-ab':
|
||||
return stand('ablage', 'laeuft', -1, rettung, 'läuft — Karte unten')
|
||||
case 'fertig':
|
||||
return stand('ablage', 'fertig', 100, rettung)
|
||||
case 'fehler':
|
||||
return stand('kompression', 'fehler', -1, rettung)
|
||||
case 'abgebrochen':
|
||||
return stand('kompression', 'abgebrochen', -1, rettung)
|
||||
}
|
||||
}
|
||||
|
||||
/** Die Leiste eines Vorgangs im Verlauf (kompakt). Bei Fehlern ohne
|
||||
* Roh-Bytes scheiterte schon der Rip, sonst die Kompression. */
|
||||
export function leisteAusVorgang(v: Pick<VorgangEintrag, 'phase' | 'rohBytes'>): LeisteStand {
|
||||
switch (v.phase) {
|
||||
case 'rippt':
|
||||
return stand('rip', 'laeuft')
|
||||
case 'wartet':
|
||||
return stand('kompression', 'wartet', -1, false, 'wartet')
|
||||
case 'komprimiert':
|
||||
return stand('kompression', 'laeuft')
|
||||
case 'legt-ab':
|
||||
return stand('ablage', 'laeuft')
|
||||
case 'fertig':
|
||||
return stand('ablage', 'fertig', 100)
|
||||
case 'fehler':
|
||||
return stand(v.rohBytes > 0 ? 'kompression' : 'rip', 'fehler')
|
||||
case 'abgebrochen':
|
||||
return stand(v.rohBytes > 0 ? 'kompression' : 'rip', 'abgebrochen')
|
||||
}
|
||||
}
|
||||
|
||||
/** Die Leiste in der Kompressions-Karte: Stationen 1–2 sind fertig. */
|
||||
export function leisteAusKompression(k: Pick<KompressionStand, 'phase' | 'prozent'>): LeisteStand {
|
||||
switch (k.phase) {
|
||||
case 'legt-ab':
|
||||
return stand('ablage', 'laeuft', k.prozent)
|
||||
case 'wartet':
|
||||
return stand('kompression', 'wartet', -1, false, 'wartet')
|
||||
case 'fertig':
|
||||
return stand('ablage', 'fertig', 100)
|
||||
case 'fehler':
|
||||
return stand('kompression', 'fehler')
|
||||
case 'abgebrochen':
|
||||
return stand('kompression', 'abgebrochen')
|
||||
case 'rippt':
|
||||
return stand('rip', 'laeuft', k.prozent)
|
||||
case 'komprimiert':
|
||||
return stand('kompression', 'laeuft', k.prozent)
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
// Autostart über HKCU\...\Run (KONZEPT § 8) — via Electrons eigener
|
||||
// API (app.setLoginItemSettings nutzt unter Windows genau diesen
|
||||
// Registry-Zweig). Kein Dienst: Ein Dienst kostete bei jeder Installation
|
||||
// eine Administrator-Abfrage und könnte kein Tray zeigen.
|
||||
import { app } from 'electron'
|
||||
|
||||
export function autostartSetzen(an: boolean): void {
|
||||
app.setLoginItemSettings({ openAtLogin: an })
|
||||
}
|
||||
|
||||
export function autostartAktiv(): boolean {
|
||||
return app.getLoginItemSettings().openAtLogin
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
// Fenster anlegen (KONZEPT § 4.2). Das Fenster zeigt an, sonst nichts —
|
||||
// kein Node-Zugriff, contextIsolation an, alles läuft über die Brücke
|
||||
// (src/bruecke) und den MessagePort zum Kern.
|
||||
//
|
||||
// 5.5.0: Die Größe hängt am Bildschirm (fensterlage.ts) — 80 % des
|
||||
// Arbeitsbereichs beim ersten Start, danach die zuletzt gemerkte Lage,
|
||||
// sofern sie noch auf einem Bildschirm liegt.
|
||||
import { BrowserWindow, screen } from 'electron'
|
||||
import { join } from 'node:path'
|
||||
import { fensterrandEntfernen } from './fensterrand'
|
||||
import { anfangsLage, lageLesen, lageSchreiben, MINDEST_BREITE, MINDEST_HOEHE, type Lage } from './fensterlage'
|
||||
|
||||
export function fensterErstellen(profilOrdner: string): BrowserWindow {
|
||||
const lageDatei = join(profilOrdner, 'fenster-lage.json')
|
||||
const lage = anfangsLage(
|
||||
lageLesen(lageDatei),
|
||||
screen.getPrimaryDisplay().workArea,
|
||||
screen.getAllDisplays().map((d) => d.workArea),
|
||||
)
|
||||
const fenster = new BrowserWindow({
|
||||
x: lage.x,
|
||||
y: lage.y,
|
||||
width: lage.width,
|
||||
height: lage.height,
|
||||
minWidth: Math.min(MINDEST_BREITE, 860),
|
||||
minHeight: Math.min(MINDEST_HOEHE, 560),
|
||||
show: true,
|
||||
autoHideMenuBar: true,
|
||||
// Eigene Titelleiste (Commander-Wunsch 01.09.2026): Der Windows-Rahmen
|
||||
// ist weg, Minimieren/Maximieren/Schließen sitzen im UI und gezogen
|
||||
// wird an der Kopfzeile (.ziehbar in fenster/stil.css).
|
||||
//
|
||||
// Wichtig: GRÖSSE ÄNDERN bleibt möglich. Electron setzt für rahmenlose
|
||||
// Fenster unter Windows weiterhin WS_THICKFRAME (Option `thickFrame`,
|
||||
// Standard true) — die Kanten lassen sich also weiter ziehen, und
|
||||
// Doppelklick auf die Ziehfläche maximiert wie gewohnt.
|
||||
frame: false,
|
||||
// Dunkler Grund SOFORT — sonst blitzt beim Start ein weißes Rechteck,
|
||||
// bevor React lädt. Das warme Bühnen-Schwarz des Kino-Mixes
|
||||
// (oklch(0.13 0.012 60), siehe fenster/stil.css).
|
||||
backgroundColor: '#171412',
|
||||
title: 'Rippy',
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../bruecke/index.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
},
|
||||
})
|
||||
if (lage.maximiert) fenster.maximize()
|
||||
|
||||
// Lage merken — verzögert, damit ein Ziehen nicht hundertmal schreibt.
|
||||
let zeiger: NodeJS.Timeout | null = null
|
||||
const merken = (): void => {
|
||||
if (zeiger !== null) clearTimeout(zeiger)
|
||||
zeiger = setTimeout(() => {
|
||||
if (fenster.isDestroyed()) return
|
||||
const maximiert = fenster.isMaximized()
|
||||
// Im maximierten Zustand die NORMALE Lage behalten (getNormalBounds),
|
||||
// sonst würde beim nächsten Start ein riesiges Fenster entmaximiert.
|
||||
const b = maximiert ? fenster.getNormalBounds() : fenster.getBounds()
|
||||
const neu: Lage = { x: b.x, y: b.y, width: b.width, height: b.height, maximiert }
|
||||
lageSchreiben(lageDatei, neu)
|
||||
}, 400)
|
||||
}
|
||||
fenster.on('resize', merken)
|
||||
fenster.on('move', merken)
|
||||
fenster.on('maximize', merken)
|
||||
fenster.on('unmaximize', merken)
|
||||
|
||||
// Der DWM-Rand von Windows 11 (die feine helle Linie ums Fenster) — er
|
||||
// gehört nicht zum Fensterinhalt, mit CSS ist er nicht zu erreichen.
|
||||
// Ein Fehlschlag ist kein Ausfall: Unter Windows 10 gibt es ihn nicht.
|
||||
const rand = fensterrandEntfernen(fenster.getNativeWindowHandle())
|
||||
if (!rand.ok) console.warn(`[haupt] Fensterrand bleibt: ${rand.fehler}`)
|
||||
|
||||
// electron-vite: Im dev-Modus bedient ein Vite-Server das Fenster
|
||||
// (ELECTRON_RENDERER_URL), gebaut wird aus out/fenster geladen.
|
||||
// RIPPY_START_BEREICH öffnet einen bestimmten Bereich (#einstellungen /
|
||||
// #einrichtung) — der Smoke-Beweis fotografiert damit auch diese Seiten.
|
||||
const devUrl = process.env['ELECTRON_RENDERER_URL']
|
||||
const startBereich = process.env['RIPPY_START_BEREICH'] ?? ''
|
||||
if (devUrl !== undefined && devUrl.length > 0) {
|
||||
void fenster.loadURL(startBereich.length > 0 ? `${devUrl}#${startBereich}` : devUrl)
|
||||
} else {
|
||||
void fenster.loadFile(
|
||||
join(__dirname, '../fenster/index.html'),
|
||||
startBereich.length > 0 ? { hash: startBereich } : undefined,
|
||||
)
|
||||
}
|
||||
return fenster
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
// Die Fensterlage (5.5.0, Commander: „Das Programm sollte immer in einer
|
||||
// angenehmen Größe öffnen, abhängig von der Auflösung des Monitors") —
|
||||
// PUR, damit die Ampel die Rechnung prüft:
|
||||
// * Ohne gemerkte Lage: 80 % des nutzbaren Bildschirms (Arbeitsbereich
|
||||
// ohne Taskleiste), mittig, mindestens 1100 × 720, nie größer als der
|
||||
// Bildschirm.
|
||||
// * Mit gemerkter Lage: wiederherstellen — aber nur, wenn sie auf einem
|
||||
// heutigen Bildschirm liegt (ein abgestöpselter Zweitmonitor darf das
|
||||
// Fenster nicht ins Nirgendwo schieben).
|
||||
// Gemerkt wird in <Profil>\fenster-lage.json — keine Nutzer-Einstellung,
|
||||
// sondern Geometrie; die EINE Einstellungs-Quelle (§ 6.7) bleibt der Kern.
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
|
||||
export interface Lage {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
maximiert: boolean
|
||||
}
|
||||
|
||||
export interface Bereich {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export const MINDEST_BREITE = 1100
|
||||
export const MINDEST_HOEHE = 720
|
||||
export const ANTEIL = 0.8
|
||||
|
||||
/** Die Startlage aus dem Arbeitsbereich des Hauptbildschirms (pur). */
|
||||
export function startLage(arbeitsbereich: Bereich): Lage {
|
||||
const width = Math.max(Math.min(arbeitsbereich.width, MINDEST_BREITE), Math.round(arbeitsbereich.width * ANTEIL))
|
||||
const height = Math.max(Math.min(arbeitsbereich.height, MINDEST_HOEHE), Math.round(arbeitsbereich.height * ANTEIL))
|
||||
return {
|
||||
x: arbeitsbereich.x + Math.round((arbeitsbereich.width - width) / 2),
|
||||
y: arbeitsbereich.y + Math.round((arbeitsbereich.height - height) / 2),
|
||||
width: Math.min(width, arbeitsbereich.width),
|
||||
height: Math.min(height, arbeitsbereich.height),
|
||||
maximiert: false,
|
||||
}
|
||||
}
|
||||
|
||||
/** Liegt die gemerkte Lage sichtbar auf einem der Bildschirme? (pur) */
|
||||
export function liegtSichtbar(lage: Lage, bildschirme: readonly Bereich[]): boolean {
|
||||
if (lage.width < 400 || lage.height < 300) return false
|
||||
return bildschirme.some((b) => {
|
||||
const rechts = Math.min(lage.x + lage.width, b.x + b.width)
|
||||
const links = Math.max(lage.x, b.x)
|
||||
const unten = Math.min(lage.y + lage.height, b.y + b.height)
|
||||
const oben = Math.max(lage.y, b.y)
|
||||
// Mindestens 200 × 100 sichtbar — genug, um das Fenster anzufassen.
|
||||
return rechts - links >= 200 && unten - oben >= 100
|
||||
})
|
||||
}
|
||||
|
||||
/** Gemerkt oder Start — was das Fenster beim Öffnen bekommt (pur). */
|
||||
export function anfangsLage(gemerkt: Lage | null, arbeitsbereich: Bereich, bildschirme: readonly Bereich[]): Lage {
|
||||
if (gemerkt !== null && liegtSichtbar(gemerkt, bildschirme)) return gemerkt
|
||||
return startLage(arbeitsbereich)
|
||||
}
|
||||
|
||||
export function lageLesen(pfad: string): Lage | null {
|
||||
try {
|
||||
if (!existsSync(pfad)) return null
|
||||
const roh: unknown = JSON.parse(readFileSync(pfad, 'utf8'))
|
||||
if (typeof roh !== 'object' || roh === null) return null
|
||||
const l = roh as Record<string, unknown>
|
||||
if ([l.x, l.y, l.width, l.height].some((v) => typeof v !== 'number' || !Number.isFinite(v))) return null
|
||||
return { x: l.x as number, y: l.y as number, width: l.width as number, height: l.height as number, maximiert: l.maximiert === true }
|
||||
} catch {
|
||||
// Eine kaputte Datei heißt: Startlage. Sie wird beim nächsten Speichern ersetzt.
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function lageSchreiben(pfad: string, lage: Lage): void {
|
||||
try {
|
||||
writeFileSync(pfad, JSON.stringify(lage))
|
||||
} catch {
|
||||
// Nicht schreibbar: Das Fenster öffnet beim nächsten Mal in der Startlage — kein Grund für Lärm.
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
// Den Windows-11-Fensterrand loswerden.
|
||||
//
|
||||
// Commander-Fund (01.09.2026): „Ich hab noch den rand ich glaube der kommt
|
||||
// aber von windows selbst oder?" — Genau so ist es. Ein rahmenloses Fenster
|
||||
// (`frame: false`) behält unter Windows 11 trotzdem den DWM-Rand: eine
|
||||
// hauchfeine Linie in der Systemfarbe, gezeichnet vom Fenstermanager, nicht
|
||||
// von der Seite. Mit CSS ist da nichts zu holen, das liegt außerhalb des
|
||||
// Fensterinhalts.
|
||||
//
|
||||
// DwmSetWindowAttribute kennt dafür zwei Schalter (dwmapi.h, beide erst ab
|
||||
// Windows 11 Build 22000):
|
||||
// DWMWA_BORDER_COLOR (34) = DWMWA_COLOR_NONE (0xFFFFFFFE)
|
||||
// DWMWA_WINDOW_CORNER_PREFERENCE (33) = DWMWCP_DONOTROUND (1)
|
||||
//
|
||||
// Ältere Windows antworten mit einem Fehler-HRESULT. Das ist KEIN Problem
|
||||
// und wird als Wert zurückgegeben, nicht verschluckt (R4): Unter Windows 10
|
||||
// gibt es diesen Rand schlicht nicht.
|
||||
//
|
||||
// Dies ist neben `leine.ts` und `kern/laufwerk/win32.ts` der DRITTE erlaubte
|
||||
// koffi-Ort; der Wächter-Test R1 kennt genau diese drei. Begründung wie
|
||||
// dort: ein Win32-Aufruf, der nirgendwo sonst hingehört — und er MUSS im
|
||||
// Haupt liegen, weil nur dort das Fensterhandle existiert.
|
||||
import koffi from 'koffi'
|
||||
|
||||
const DWMWA_WINDOW_CORNER_PREFERENCE = 33
|
||||
const DWMWA_BORDER_COLOR = 34
|
||||
/** „Zeichne gar keinen Rand" — nicht zu verwechseln mit Schwarz. */
|
||||
const DWMWA_COLOR_NONE = 0xfffffffe
|
||||
const DWMWCP_DONOTROUND = 1
|
||||
|
||||
export interface RandErgebnis {
|
||||
ok: boolean
|
||||
/** '' wenn alles saß; sonst der HRESULT-Grund im Klartext. */
|
||||
fehler: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Rand entfernen. `eckenKantig` schaltet zusätzlich die runden Ecken ab —
|
||||
* standardmäßig AUS, weil runde Ecken unter Windows 11 das Übliche sind
|
||||
* und Rippy sich nicht fremder anfühlen soll, als es muss.
|
||||
*
|
||||
* @param handle Das native Fensterhandle (`BrowserWindow.getNativeWindowHandle()`).
|
||||
*/
|
||||
export function fensterrandEntfernen(handle: Buffer, eckenKantig = false): RandErgebnis {
|
||||
if (handle.length < 8) return { ok: false, fehler: 'Fensterhandle unbrauchbar' }
|
||||
try {
|
||||
const dwmapi = koffi.load('dwmapi.dll')
|
||||
// Das Handle steckt als Zeiger IM Puffer. Auf x64 wandert ein Zeiger im
|
||||
// selben Register wie ein uint64 — deshalb ist das hier gefahrlos.
|
||||
const DwmSetWindowAttribute = dwmapi.func(
|
||||
'int32_t __stdcall DwmSetWindowAttribute(uint64_t hwnd, uint32_t dwAttribute, void *pvAttribute, uint32_t cbAttribute)',
|
||||
)
|
||||
const hwnd = handle.readBigUInt64LE(0)
|
||||
|
||||
const farbe = Buffer.alloc(4)
|
||||
farbe.writeUInt32LE(DWMWA_COLOR_NONE, 0)
|
||||
const randErgebnis = DwmSetWindowAttribute(hwnd, DWMWA_BORDER_COLOR, farbe, 4)
|
||||
|
||||
let eckenErgebnis = 0
|
||||
if (eckenKantig) {
|
||||
const ecken = Buffer.alloc(4)
|
||||
ecken.writeUInt32LE(DWMWCP_DONOTROUND, 0)
|
||||
eckenErgebnis = DwmSetWindowAttribute(hwnd, DWMWA_WINDOW_CORNER_PREFERENCE, ecken, 4)
|
||||
}
|
||||
|
||||
if (randErgebnis !== 0 || eckenErgebnis !== 0) {
|
||||
// Ein negatives HRESULT heißt hier fast immer: älteres Windows, das
|
||||
// diese Schalter nicht kennt. Rippy läuft weiter, nur mit Rand.
|
||||
return {
|
||||
ok: false,
|
||||
fehler: `DwmSetWindowAttribute: HRESULT 0x${(randErgebnis >>> 0).toString(16)} (Rand) / 0x${(eckenErgebnis >>> 0).toString(16)} (Ecken) — vermutlich Windows 10`,
|
||||
}
|
||||
}
|
||||
return { ok: true, fehler: '' }
|
||||
} catch (fehler) {
|
||||
return { ok: false, fehler: `koffi/dwmapi nicht verfügbar: ${String(fehler)}` }
|
||||
}
|
||||
}
|
||||
@@ -1,484 +0,0 @@
|
||||
// Rippy v5 — Haupt-Prozess (KONZEPT-WINDOWS.md § 4.1).
|
||||
// Ablauf: Einzelinstanz-Sperre → Prozess-Leine → Kern starten → Fenster
|
||||
// öffnen → Fenster und Kern über einen MessagePort verdrahten. Seit W-5
|
||||
// mit Tray: Fenster zu heißt weiterlaufen (§ 4.1 Punkt 3), beendet wird
|
||||
// über das Tray-Menü; dazu Windows-Toasts und Taskleisten-Fortschritt (§ 7).
|
||||
//
|
||||
// Smoke-Modus (`electron . --smoke`): beweist das W-0-Fertigkriterium
|
||||
// („Ein Fenster geht auf, der Kern läuft") maschinell — alle drei Prozesse,
|
||||
// beide IPC-Wege und die Datenbank — und endet mit Code 0 (grün) oder 2.
|
||||
// RIPPY_SMOKE_BILD=<pfad.png> legt zusätzlich einen Screenshot ab.
|
||||
import { app, dialog, ipcMain, Notification, shell, type BrowserWindow } from 'electron'
|
||||
import { writeFile } from 'node:fs/promises'
|
||||
import { join, win32 } from 'node:path'
|
||||
import type { HauptStatus, KernNachricht } from '../gemeinsam/nachrichten'
|
||||
import { werkzeugOrdner } from '../kern/werkzeuge/katalog'
|
||||
import { Protokoll } from '../gemeinsam/protokoll'
|
||||
import { autostartAktiv, autostartSetzen } from './autostart'
|
||||
import { fensterErstellen } from './fenster'
|
||||
import { KernVerwaltung } from './kernstart'
|
||||
import { leineSetzen, type LeineStatus } from './leine'
|
||||
import { trayErstellen, trayHinweis } from './tray'
|
||||
import { updateEinrichten, updateInstallierenJetzt, updatePruefenJetzt, updateStand } from './update'
|
||||
|
||||
const smokeModus = process.argv.includes('--smoke')
|
||||
// Der Klick-Beweis (bau/klick-smoke.mjs, 5.4.0): das ECHTE Programm mit
|
||||
// eigenem Profil und nachgebauten Werkzeugen — ohne Tray, Updater und
|
||||
// Toasts, aber mit allem, was klickbar ist. Beendet wird er von außen.
|
||||
const klickSmoke = process.argv.includes('--klick-smoke')
|
||||
const profilArg = process.argv.find((a) => a.startsWith('--profil='))
|
||||
/** Smoke UND Klick-Beweis: keine Nebenwirkungen auf dem Rechner. */
|
||||
const ohneNebenwirkungen = smokeModus || klickSmoke
|
||||
|
||||
// Der Smoke-Lauf fasst das echte Profil nicht an — er hinterlässt nichts.
|
||||
if (smokeModus) {
|
||||
app.setPath('userData', join(app.getPath('temp'), 'rippy-smoke'))
|
||||
} else if (profilArg !== undefined && profilArg.length > '--profil='.length) {
|
||||
app.setPath('userData', profilArg.slice('--profil='.length))
|
||||
}
|
||||
|
||||
// Nur EIN Rippy je Benutzer (§ 4.2: Einzelinstanz-Sperre). Die zweite
|
||||
// Instanz beendet sich; die erste holt ihr Fenster nach vorn. Die Sperre
|
||||
// hängt am Profil — ein Beweislauf mit eigenem Profil stört das laufende
|
||||
// Rippy nicht und wird von ihm nicht gestört.
|
||||
if (!app.requestSingleInstanceLock()) {
|
||||
app.exit(0)
|
||||
} else {
|
||||
start()
|
||||
}
|
||||
|
||||
function start(): void {
|
||||
// 5.5.0: Die Protokoll-Datei (aus ARM) — Haupt und Kern schreiben in
|
||||
// denselben Ordner, eine Datei je Tag, 14 Tage aufgehoben.
|
||||
const protokollOrdner = join(app.getPath('userData'), 'logs')
|
||||
const protokoll = new Protokoll(protokollOrdner, 'haupt')
|
||||
protokoll.aufraeumen()
|
||||
protokoll.schreiben(`Rippy ${app.getVersion()} startet (Electron ${process.versions.electron}, Profil ${app.getPath('userData')})`)
|
||||
// Die Leine kommt VOR dem ersten Kindprozess — Mitgliedschaft in der
|
||||
// Arbeitsgruppe wird nur beim Start eines Kindes vererbt (§ 3.3).
|
||||
const leine: LeineStatus = leineSetzen()
|
||||
if (!leine.ok) {
|
||||
// R4: laut, nicht still — zusätzlich steht es im Fenster-Status.
|
||||
console.error(`[haupt] Prozess-Leine NICHT gesetzt: ${leine.fehler}`)
|
||||
protokoll.schreiben(`Prozess-Leine NICHT gesetzt: ${leine.fehler}`)
|
||||
}
|
||||
|
||||
let fenster: BrowserWindow | null = null
|
||||
let kernBereit: Extract<KernNachricht, { art: 'kern-bereit' }> | null = null
|
||||
let beendenGewollt = smokeModus
|
||||
// § 6.8 Regel 2: Der Haupt weiß, ob irgendwo gerippt wird — Updates
|
||||
// warten dann.
|
||||
const aktiveVorgaenge = new Set<string>()
|
||||
let updateGestartet = false
|
||||
|
||||
const smoke = {
|
||||
fenster: false,
|
||||
kern: false,
|
||||
datenbank: false,
|
||||
leine: leine.ok,
|
||||
pong: false,
|
||||
}
|
||||
|
||||
const kernVerwaltung = new KernVerwaltung(
|
||||
join(app.getPath('userData'), 'rippy.db'),
|
||||
(nachricht) => {
|
||||
if (nachricht.art === 'kern-bereit') {
|
||||
kernBereit = nachricht
|
||||
smoke.kern = true
|
||||
smoke.datenbank = nachricht.datenbank.ok
|
||||
if (!nachricht.datenbank.ok) {
|
||||
console.error(`[haupt] Kern meldet Datenbank-Fehler: ${nachricht.datenbank.fehler}`)
|
||||
protokoll.schreiben(`Kern meldet Datenbank-Fehler: ${nachricht.datenbank.fehler}`)
|
||||
}
|
||||
statusSenden()
|
||||
smokePruefen()
|
||||
} else if (nachricht.art === 'rip-status') {
|
||||
ripStatusVerarbeiten(nachricht.status)
|
||||
} else if (nachricht.art === 'einstellungen') {
|
||||
// Einmalig das Selbst-Update einrichten — die Adresse kommt aus
|
||||
// der EINEN Einstellungs-Quelle im Kern (§ 6.7/§ 6.8).
|
||||
if (!updateGestartet && !ohneNebenwirkungen) {
|
||||
updateGestartet = true
|
||||
updateEinrichten(
|
||||
nachricht.werte['updateUrl'] ?? '',
|
||||
() => aktiveVorgaenge.size > 0,
|
||||
(text) => {
|
||||
protokoll.schreiben(`Update: ${text}`)
|
||||
toast('Rippy-Update', text)
|
||||
},
|
||||
// Jede Änderung am Update-Stand geht SOFORT ans Fenster —
|
||||
// vorher lag sie still im Haupt und war erst beim nächsten
|
||||
// Laden zu sehen (Commander-Fund 01.09.2026).
|
||||
() => statusSenden(),
|
||||
// Genau der Weg des Tray-Menüs: beendenGewollt setzen, damit
|
||||
// der close-Horcher das Fenster nicht bloß versteckt.
|
||||
() => beenden(),
|
||||
)
|
||||
}
|
||||
} else if (nachricht.art === 'installer-starten') {
|
||||
void installerStarten(nachricht.pfad)
|
||||
} else if (nachricht.art === 'vorgaenge') {
|
||||
// 5.4.0: Die Kompression läuft OHNE Laufwerk weiter — Updates
|
||||
// warten auch auf sie, und die Taskleiste zeigt ihren Fortschritt.
|
||||
kompressionVerarbeiten(nachricht.stand.kompression.laeuft)
|
||||
} else if (nachricht.art === 'kompression-stand') {
|
||||
// 5.7.0 (F7): der leichte Fortschritts-Tick — dieselbe Wirkung.
|
||||
kompressionVerarbeiten(nachricht.kompression.laeuft)
|
||||
} else if (nachricht.art === 'kompression-ergebnis' && !ohneNebenwirkungen) {
|
||||
toast(nachricht.ok ? 'Rippy ist fertig' : 'Rippy: Kompression fehlgeschlagen', `${nachricht.titel}: ${nachricht.text}`)
|
||||
} else if (nachricht.art === 'disc-info' && nachricht.info.titel.length > 0 && !ohneNebenwirkungen) {
|
||||
// Windows-Toast beim Erkennen (§ 7) — Klick holt das Fenster hoch.
|
||||
// 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(
|
||||
'Disc erkannt',
|
||||
`${nachricht.info.titel}${nachricht.info.jahr !== null ? ` (${nachricht.info.jahr})` : ''} · ${Math.round(nachricht.info.confidence * 100)} %` +
|
||||
(nachricht.info.schonGerippt !== null
|
||||
? ` — schon einmal gerippt (${nachricht.info.schonGerippt.slice(0, 10)})`
|
||||
: ''),
|
||||
)
|
||||
}
|
||||
},
|
||||
(zustand) => {
|
||||
if (!zustand.laeuft) protokoll.schreiben(`Kern nicht aktiv (Neustarts: ${zustand.neustarts})`)
|
||||
statusSenden()
|
||||
},
|
||||
smokeModus,
|
||||
)
|
||||
|
||||
// Die laufende Kompression (5.4.0) — ein eigener Schlüssel in den
|
||||
// aktiven Vorgängen, weil sie an keinem Laufwerk mehr hängt.
|
||||
const KOMPRESSION = 'kompression'
|
||||
let kompressionStand: { titel: string; prozent: number } | null = null
|
||||
/** 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 {
|
||||
kompressionStand = laeuft === null ? null : { titel: laeuft.titel, prozent: laeuft.prozent }
|
||||
if (laeuft === null) aktiveVorgaenge.delete(KOMPRESSION)
|
||||
else aktiveVorgaenge.add(KOMPRESSION)
|
||||
anzeigeAktualisieren()
|
||||
}
|
||||
|
||||
/** Taskleiste und Tray aus dem, was gerade läuft: ein Rip zuerst, sonst
|
||||
* die Kompression — sonst nichts. */
|
||||
let letzterRip: Extract<KernNachricht, { art: 'rip-status' }>['status'] | null = null
|
||||
function anzeigeAktualisieren(): void {
|
||||
// 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 (rip !== null && rip.prozent >= 0) {
|
||||
fenster.setProgressBar(rip.prozent / 100)
|
||||
} else if (letzterRip !== null && letzterRip.phase === 'fehler') {
|
||||
fenster.setProgressBar(1, { mode: 'error' })
|
||||
} else if (kompressionStand !== null && kompressionStand.prozent >= 0) {
|
||||
fenster.setProgressBar(kompressionStand.prozent / 100)
|
||||
} else {
|
||||
fenster.setProgressBar(-1)
|
||||
}
|
||||
}
|
||||
if (rip !== null) {
|
||||
trayHinweis(`rippt Laufwerk ${rip.id}${rip.prozent >= 0 ? ` (${rip.prozent} %)` : ''}`)
|
||||
} else if (kompressionStand !== null) {
|
||||
trayHinweis(`komprimiert „${kompressionStand.titel}"${kompressionStand.prozent >= 0 ? ` (${kompressionStand.prozent} %)` : ''}`)
|
||||
} else {
|
||||
trayHinweis('')
|
||||
}
|
||||
}
|
||||
|
||||
// § 7: Toasts, Taskleisten-Fortschritt und Tray-Zustand aus dem
|
||||
// Rip-Status — die Information, die man will, ohne das Fenster zu öffnen.
|
||||
function ripStatusVerarbeiten(status: Extract<KernNachricht, { art: 'rip-status' }>['status']): void {
|
||||
if (status.phase === 'rippt' || status.phase === 'rettet' || status.phase === 'komprimiert' || status.phase === 'legt-ab' || status.phase === 'liest-info') {
|
||||
aktiveVorgaenge.add(status.id)
|
||||
} else {
|
||||
aktiveVorgaenge.delete(status.id)
|
||||
}
|
||||
letzterRip = status
|
||||
anzeigeAktualisieren()
|
||||
if (ohneNebenwirkungen) return
|
||||
if (status.phase === 'fertig') {
|
||||
toast('Rippy: Rip fertig', status.text)
|
||||
} else if (status.phase === 'fehler') {
|
||||
toast('Rippy: Fehler', status.text)
|
||||
}
|
||||
}
|
||||
|
||||
// § 9: Der Kern hat einen geprüften Installer abgelegt — gestartet wird
|
||||
// er HIER über die Shell. Zwei Gründe: (1) MakeMKVs Installer fordert im
|
||||
// Manifest Administratorrechte an — CreateProcess bricht dann mit
|
||||
// ERROR_ELEVATION_REQUIRED ab, nur der Shell-Weg zeigt die UAC-Abfrage
|
||||
// (beschaffen.py, am Commander-PC gemessen). (2) Die Erhöhung läuft über
|
||||
// den AppInfo-Dienst, der Installer ist dadurch KEIN Kind von Rippy —
|
||||
// er hängt nicht an der Prozess-Leine und überlebt ein Beenden.
|
||||
async function installerStarten(pfad: string): Promise<void> {
|
||||
// Nur, was der Kern selbst geladen und geprüft hat: eine .exe aus
|
||||
// Rippys eigenem Downloads-Ordner.
|
||||
const downloads = win32.join(werkzeugOrdner(), 'downloads') + '\\'
|
||||
if (!pfad.startsWith(downloads) || !pfad.toLowerCase().endsWith('.exe')) {
|
||||
kernVerwaltung.senden({ art: 'installer-ergebnis', fehler: `Pfad außerhalb des Downloads-Ordners: ${pfad}` })
|
||||
return
|
||||
}
|
||||
const fehler = await shell.openPath(pfad)
|
||||
kernVerwaltung.senden({ art: 'installer-ergebnis', fehler: fehler.length > 0 ? fehler : null })
|
||||
}
|
||||
|
||||
function toast(titel: string, text: string): void {
|
||||
try {
|
||||
if (!Notification.isSupported()) return
|
||||
const meldung = new Notification({ title: titel, body: text.slice(0, 200) })
|
||||
meldung.on('click', () => fensterZeigen())
|
||||
meldung.show()
|
||||
} catch (fehler) {
|
||||
console.error(`[haupt] Toast fehlgeschlagen: ${String(fehler)}`)
|
||||
}
|
||||
}
|
||||
|
||||
function statusSenden(): void {
|
||||
if (fenster === null || fenster.isDestroyed()) return
|
||||
const status: HauptStatus = {
|
||||
version: app.getVersion(),
|
||||
leine,
|
||||
kern: kernVerwaltung.zustand(),
|
||||
autostart: autostartAktiv(),
|
||||
update: updateStand(),
|
||||
// Für die eigene Titelleiste: Das Knopf-Symbol muss wissen, ob das
|
||||
// Fenster gerade maximiert ist.
|
||||
fensterMaximiert: fenster !== null && !fenster.isDestroyed() && fenster.isMaximized(),
|
||||
protokollOrdner,
|
||||
}
|
||||
fenster.webContents.send('haupt-status', status)
|
||||
}
|
||||
|
||||
function fensterVerdrahten(): void {
|
||||
if (fenster === null) return
|
||||
fenster.webContents.on('did-finish-load', () => {
|
||||
smoke.fenster = true
|
||||
// Verdrahtung NACH dem Laden: Die Seite lauscht erst dann auf den Port.
|
||||
kernVerwaltung.fensterVerbinden(fenster!.webContents)
|
||||
statusSenden()
|
||||
smokePruefen()
|
||||
})
|
||||
// Die eigene Titelleiste zeichnet ihr Maximieren-Symbol nach diesem
|
||||
// Zustand — auch wenn per Doppelklick oder Windows-Tastenkürzel
|
||||
// maximiert wird, nicht nur über unseren Knopf.
|
||||
fenster.on('maximize', () => statusSenden())
|
||||
fenster.on('unmaximize', () => statusSenden())
|
||||
// § 4.1 Punkt 3: Fenster zu, Rip läuft weiter — Rippy lebt im Tray.
|
||||
fenster.on('close', (ereignis) => {
|
||||
if (beendenGewollt) return
|
||||
ereignis.preventDefault()
|
||||
fenster?.hide()
|
||||
})
|
||||
}
|
||||
|
||||
function fensterZeigen(): void {
|
||||
if (fenster === null || fenster.isDestroyed()) {
|
||||
fenster = fensterErstellen(app.getPath('userData'))
|
||||
fensterVerdrahten()
|
||||
return
|
||||
}
|
||||
if (fenster.isMinimized()) fenster.restore()
|
||||
fenster.show()
|
||||
fenster.focus()
|
||||
}
|
||||
|
||||
function beenden(): void {
|
||||
beendenGewollt = true
|
||||
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
|
||||
// Smoke-Lauf ist 'kern-pong' der Beweis, dass der MessagePort
|
||||
// Fenster ↔ Kern in BEIDE Richtungen trägt.
|
||||
ipcMain.on('fenster-meldung', (_ereignis, art: unknown) => {
|
||||
if (art === 'kern-pong') {
|
||||
smoke.pong = true
|
||||
smokePruefen()
|
||||
}
|
||||
})
|
||||
|
||||
// Eigene Titelleiste (§ 4.2): Das Fenster hat keinen Windows-Rahmen
|
||||
// mehr, die drei Knöpfe sitzen im UI und melden sich hier.
|
||||
ipcMain.handle('fenster-steuern', (_ereignis, was: unknown) => {
|
||||
if (fenster === null || fenster.isDestroyed()) return false
|
||||
if (was === 'minimieren') {
|
||||
fenster.minimize()
|
||||
} else if (was === 'maximieren') {
|
||||
if (fenster.isMaximized()) fenster.unmaximize()
|
||||
else fenster.maximize()
|
||||
} else if (was === 'schliessen') {
|
||||
// Genau der Weg des alten Fensterkreuzes: Der close-Horcher weiter
|
||||
// unten versteckt nur — Rippy lebt im Tray weiter (§ 4.1 Punkt 3),
|
||||
// ein laufender Rip darf davon nichts merken.
|
||||
fenster.close()
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
return fenster.isDestroyed() ? false : fenster.isMaximized()
|
||||
})
|
||||
|
||||
// Der Knopf „Jetzt prüfen" (§ 6.8). Der Stand meldet sich über
|
||||
// beiAenderung von selbst ans Fenster — hier gibt es nichts zurück.
|
||||
ipcMain.handle('update-pruefen', () => {
|
||||
updatePruefenJetzt()
|
||||
return updateStand()
|
||||
})
|
||||
|
||||
// Der Knopf „Jetzt neu starten und installieren". Sagt NEIN mit Grund,
|
||||
// statt still nichts zu tun (R4).
|
||||
ipcMain.handle('update-installieren', () => {
|
||||
const urteil = updateInstallierenJetzt()
|
||||
if (!urteil.ok) statusSenden()
|
||||
return urteil
|
||||
})
|
||||
|
||||
// Autostart (§ 8): HKCU\...\Run über Electrons eigene API.
|
||||
ipcMain.handle('autostart-setzen', (_ereignis, an: unknown) => {
|
||||
autostartSetzen(an === true)
|
||||
statusSenden()
|
||||
return autostartAktiv()
|
||||
})
|
||||
|
||||
// Echter Windows-Ordnerdialog (§ 7) — der Nutzer sieht seine
|
||||
// Netzlaufwerke und Schnellzugriffe statt eines nachgebauten Browsers.
|
||||
ipcMain.handle('ordner-waehlen', async (_ereignis, titel: unknown) => {
|
||||
if (fenster === null) return null
|
||||
const ergebnis = await dialog.showOpenDialog(fenster, {
|
||||
title: typeof titel === 'string' && titel.length > 0 ? titel : 'Ordner wählen',
|
||||
properties: ['openDirectory', 'createDirectory'],
|
||||
})
|
||||
return ergebnis.canceled || ergebnis.filePaths.length === 0 ? null : ergebnis.filePaths[0]
|
||||
})
|
||||
|
||||
// Echter Windows-Dateidialog (5.5.0: Werkzeuge → Durchsuchen) — nur
|
||||
// Programme, sonst nichts.
|
||||
ipcMain.handle('datei-waehlen', async (_ereignis, titel: unknown) => {
|
||||
if (fenster === null) return null
|
||||
const ergebnis = await dialog.showOpenDialog(fenster, {
|
||||
title: typeof titel === 'string' && titel.length > 0 ? titel : 'Programm wählen',
|
||||
properties: ['openFile'],
|
||||
filters: [
|
||||
{ name: 'Programme', extensions: ['exe'] },
|
||||
{ name: 'Alle Dateien', extensions: ['*'] },
|
||||
],
|
||||
})
|
||||
return ergebnis.canceled || ergebnis.filePaths.length === 0 ? null : ergebnis.filePaths[0]
|
||||
})
|
||||
|
||||
// Einen Ordner im Explorer zeigen (5.5.0, aus ARM) — nur absolute Pfade,
|
||||
// und shell.openPath sagt selbst, wenn es ihn nicht gibt.
|
||||
ipcMain.handle('ordner-oeffnen', async (_ereignis, pfad: unknown) => {
|
||||
if (typeof pfad !== 'string' || !win32.isAbsolute(pfad)) return 'Kein gültiger Pfad.'
|
||||
protokoll.schreiben(`Ordner geöffnet: ${pfad}`)
|
||||
return await shell.openPath(pfad)
|
||||
})
|
||||
|
||||
ipcMain.handle('protokoll-oeffnen', async () => {
|
||||
return await shell.openPath(protokollOrdner)
|
||||
})
|
||||
|
||||
app.on('second-instance', () => {
|
||||
fensterZeigen()
|
||||
})
|
||||
|
||||
// Mit Tray beendet ein geschlossenes Fenster Rippy NICHT mehr — es läuft
|
||||
// im Hintergrund weiter. Beendet wird über das Tray-Menü.
|
||||
app.on('window-all-closed', () => {
|
||||
if (beendenGewollt) app.quit()
|
||||
})
|
||||
|
||||
app.on('before-quit', () => {
|
||||
beendenGewollt = true
|
||||
protokoll.schreiben('Rippy beendet sich')
|
||||
kernVerwaltung.beenden()
|
||||
})
|
||||
|
||||
void app.whenReady().then(() => {
|
||||
kernVerwaltung.starten()
|
||||
if (!ohneNebenwirkungen) {
|
||||
trayErstellen(fensterZeigen, beendenMitRueckfrage)
|
||||
}
|
||||
fenster = fensterErstellen(app.getPath('userData'))
|
||||
fensterVerdrahten()
|
||||
})
|
||||
|
||||
let smokeAbgeschlossen = false
|
||||
|
||||
function smokePruefen(): void {
|
||||
if (!smokeModus || smokeAbgeschlossen) return
|
||||
const fehlt = Object.entries(smoke)
|
||||
.filter(([, wert]) => !wert)
|
||||
.map(([name]) => name)
|
||||
if (fehlt.length > 0) return
|
||||
smokeAbgeschlossen = true
|
||||
void smokeAbschliessen()
|
||||
}
|
||||
|
||||
async function smokeAbschliessen(): Promise<void> {
|
||||
const bildPfad = process.env['RIPPY_SMOKE_BILD']
|
||||
if (bildPfad !== undefined && bildPfad.length > 0 && fenster !== null) {
|
||||
try {
|
||||
// Der Pong kommt schneller, als der Compositor das erste Bild malt —
|
||||
// ein sofortiges capturePage lieferte hier »UnknownVizError«
|
||||
// (gemessen 30.08.2026). Kurz warten, bis wirklich gemalt ist;
|
||||
// RIPPY_SMOKE_WARTE_MS verlängert das für Bild-Beweise, die auf
|
||||
// späte Kern-Antworten warten.
|
||||
const warteMs = Number(process.env['RIPPY_SMOKE_WARTE_MS'] ?? '750')
|
||||
await new Promise((fertig) => setTimeout(fertig, Number.isFinite(warteMs) ? warteMs : 750))
|
||||
const bild = await fenster.webContents.capturePage()
|
||||
await writeFile(bildPfad, bild.toPNG())
|
||||
console.log(`SMOKE: Bild abgelegt unter ${bildPfad}`)
|
||||
} catch (fehler) {
|
||||
console.error(`SMOKE: Bild fehlgeschlagen: ${String(fehler)}`)
|
||||
}
|
||||
}
|
||||
const kernInfo =
|
||||
kernBereit !== null ? `kern=pid:${kernBereit.pid},node:${kernBereit.nodeVersion}` : 'kern=?'
|
||||
console.log(
|
||||
`SMOKE: OK — fenster=geladen ${kernInfo} datenbank=ok leine=gesetzt pong=ok version=${app.getVersion()}`,
|
||||
)
|
||||
// app.exit() überspringt before-quit — den Kern deshalb hier gewollt
|
||||
// beenden, sonst deutet der exit-Wächter das als Absturz und startet neu.
|
||||
kernVerwaltung.beenden()
|
||||
setTimeout(() => app.exit(0), 100)
|
||||
}
|
||||
|
||||
if (smokeModus) {
|
||||
setTimeout(() => {
|
||||
if (smokeAbgeschlossen) return
|
||||
smokeAbgeschlossen = true
|
||||
const fehlt = Object.entries(smoke)
|
||||
.filter(([, wert]) => !wert)
|
||||
.map(([name]) => name)
|
||||
console.error(`SMOKE: FEHLGESCHLAGEN — es fehlt: ${fehlt.join(', ')}`)
|
||||
kernVerwaltung.beenden()
|
||||
setTimeout(() => app.exit(2), 100)
|
||||
}, 25_000)
|
||||
}
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
// Startet und überwacht den Kern (utilityProcess) — KONZEPT § 4.1.
|
||||
// utilityProcess statt child_process.fork ist Electrons eigene Empfehlung;
|
||||
// nur er kann einen MessagePort DIREKT zwischen Kern und Fenster aufspannen
|
||||
// (Electron-Doku »utilityProcess« / »MessagePorts in Electron«).
|
||||
import { app, MessageChannelMain, utilityProcess, type UtilityProcess, type WebContents } from 'electron'
|
||||
import { join } from 'node:path'
|
||||
import { istKernNachricht, type HauptNachricht, type KernNachricht } from '../gemeinsam/nachrichten'
|
||||
|
||||
export interface KernZustand {
|
||||
laeuft: boolean
|
||||
pid?: number
|
||||
neustarts: number
|
||||
}
|
||||
|
||||
// Stirbt der Kern öfter als so, stimmt etwas Grundsätzliches — dann wird
|
||||
// nicht weiter neu gestartet, sondern der Zustand im Fenster gezeigt.
|
||||
const MAX_NEUSTARTS_JE_MINUTE = 3
|
||||
|
||||
export class KernVerwaltung {
|
||||
private kern: UtilityProcess | null = null
|
||||
private beendenGewollt = false
|
||||
private neustarts = 0
|
||||
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(
|
||||
private readonly datenbankPfad: string,
|
||||
private readonly beiNachricht: (nachricht: KernNachricht) => void,
|
||||
private readonly beiZustand: (zustand: KernZustand) => void,
|
||||
private readonly smokeModus = false,
|
||||
) {}
|
||||
|
||||
starten(): void {
|
||||
const kernDatei = join(__dirname, 'kern.js')
|
||||
const args = [`--datenbank=${this.datenbankPfad}`]
|
||||
// Der Smoke-Beweis läuft ohne Netz und ohne MakeMKV — der Kern lässt
|
||||
// die Schlüsselkette dann aus.
|
||||
if (this.smokeModus) args.push('--smoke-kern')
|
||||
// Im installierten Rippy liegen die mitgelieferten Werkzeuge unter
|
||||
// resources\vendor — der Kern findet sie über RIPPY_VENDOR (§ 9).
|
||||
const umgebung: NodeJS.ProcessEnv = { ...process.env }
|
||||
if (app.isPackaged) {
|
||||
umgebung['RIPPY_VENDOR'] = join(process.resourcesPath, 'vendor')
|
||||
}
|
||||
const kern = utilityProcess.fork(kernDatei, args, {
|
||||
serviceName: 'Rippy-Kern',
|
||||
stdio: 'inherit',
|
||||
env: umgebung,
|
||||
})
|
||||
this.kern = kern
|
||||
|
||||
kern.on('message', (nachricht: unknown) => {
|
||||
if (istKernNachricht(nachricht)) this.beiNachricht(nachricht)
|
||||
})
|
||||
|
||||
kern.on('spawn', () => {
|
||||
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) => {
|
||||
this.kern = null
|
||||
if (this.beendenGewollt) return
|
||||
// R4: Ein gestorbener Kern ist ein lautes Ereignis, kein stilles.
|
||||
console.error(`[haupt] Kern beendet mit Code ${code} — Neustart wird geprüft`)
|
||||
const jetzt = Date.now()
|
||||
this.neustartZeiten = this.neustartZeiten.filter((t) => jetzt - t < 60_000)
|
||||
if (this.neustartZeiten.length >= MAX_NEUSTARTS_JE_MINUTE) {
|
||||
console.error('[haupt] Kern stirbt wiederholt — kein weiterer Neustart')
|
||||
this.beiZustand(this.zustand())
|
||||
return
|
||||
}
|
||||
this.neustartZeiten.push(jetzt)
|
||||
this.neustarts += 1
|
||||
this.starten()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Spannt einen frischen MessagePort direkt zwischen Kern und Fenster auf.
|
||||
* Der Fortschritt eines Rips geht damit später ohne Umweg über das Haupt
|
||||
* ins Fenster (§ 4.1).
|
||||
*/
|
||||
fensterVerbinden(webContents: WebContents): void {
|
||||
this.letztesFenster = webContents
|
||||
if (this.kern === null) return
|
||||
const { port1, port2 } = new MessageChannelMain()
|
||||
const nachricht: HauptNachricht = { art: 'fenster-port' }
|
||||
this.kern.postMessage(nachricht, [port1])
|
||||
webContents.postMessage('kern-port', null, [port2])
|
||||
}
|
||||
|
||||
/** Eine Nachricht an den Kern (z. B. die Antwort auf 'installer-starten'). */
|
||||
senden(nachricht: HauptNachricht): void {
|
||||
this.kern?.postMessage(nachricht)
|
||||
}
|
||||
|
||||
zustand(): KernZustand {
|
||||
return {
|
||||
laeuft: this.kern !== null,
|
||||
pid: this.kern?.pid,
|
||||
neustarts: this.neustarts,
|
||||
}
|
||||
}
|
||||
|
||||
beenden(): void {
|
||||
this.beendenGewollt = true
|
||||
this.kern?.kill()
|
||||
this.kern = null
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
// Windows-Kommandozeilen bauen — PUR, ohne koffi und ohne Electron, damit
|
||||
// die Ampel das auf Linux prüfen kann (BAUEN.md).
|
||||
//
|
||||
// CreateProcessW bekommt EINE Zeichenkette, keine Argumentliste. Wer sie
|
||||
// naiv mit Leerzeichen zusammenklebt, verliert jeden Pfad mit Leerzeichen:
|
||||
// Aus `C:\Users\Tobi Neu\…\RippySetup.exe` würden zwei Argumente, und der
|
||||
// Installer startet nie. Die Regeln sind die von CommandLineToArgvW —
|
||||
// dieselben, nach denen die Gegenseite die Zeichenkette wieder zerlegt.
|
||||
|
||||
/** Ein Argument nach den Regeln von CommandLineToArgvW einpacken. */
|
||||
export function argumentQuoten(arg: string): string {
|
||||
// Ohne Leerzeichen, Tabulator und Anführungszeichen bleibt alles, wie es
|
||||
// ist — auch der leere Fall braucht Anführungszeichen, sonst verschwindet er.
|
||||
if (arg.length > 0 && !/["\s]/.test(arg)) return arg
|
||||
|
||||
let ergebnis = '"'
|
||||
let rueckstriche = 0
|
||||
for (const zeichen of arg) {
|
||||
if (zeichen === '\\') {
|
||||
rueckstriche += 1
|
||||
continue
|
||||
}
|
||||
if (zeichen === '"') {
|
||||
// Vor einem Anführungszeichen zählen Rückstriche doppelt, und das
|
||||
// Anführungszeichen selbst bekommt einen eigenen davor.
|
||||
ergebnis += '\\'.repeat(rueckstriche * 2 + 1) + '"'
|
||||
rueckstriche = 0
|
||||
continue
|
||||
}
|
||||
ergebnis += '\\'.repeat(rueckstriche) + zeichen
|
||||
rueckstriche = 0
|
||||
}
|
||||
// Am Ende steht das schließende Anführungszeichen — die Rückstriche
|
||||
// davor zählen deshalb ebenfalls doppelt.
|
||||
return ergebnis + '\\'.repeat(rueckstriche * 2) + '"'
|
||||
}
|
||||
|
||||
/** Programm + Argumente zu der einen Zeichenkette, die CreateProcessW will. */
|
||||
export function kommandozeileBauen(programm: string, argumente: readonly string[]): string {
|
||||
return [programm, ...argumente].map(argumentQuoten).join(' ')
|
||||
}
|
||||
|
||||
/** Die Argumente, mit denen der NSIS-Installer still durchläuft und Rippy
|
||||
* danach wieder startet. Gemessen an der installierten Bibliothek
|
||||
* (electron-updater `NsisUpdater.js`, `doInstall`): `--updated` immer,
|
||||
* `/S` für „still", `--force-run` für „danach starten". Genau diese drei
|
||||
* benutzt electron-updater selbst — wir starten den Installer nur auf
|
||||
* einem anderen Weg (außerhalb der Prozess-Leine). */
|
||||
export const INSTALLER_ARGUMENTE: readonly string[] = ['--updated', '/S', '--force-run']
|
||||
@@ -1,186 +0,0 @@
|
||||
// Die Prozess-Leine (Windows-Arbeitsgruppe / Job Object) — KONZEPT § 3.3,
|
||||
// gemessen in beweise/leine.js am 30.08.2026. Der Befund dahinter
|
||||
// (SAVEPOINT v4.0-rc10): Windows räumt Kindprozesse NICHT auf. Ein hart
|
||||
// beendeter Rippy hinterließe ein makemkvcon, das das Laufwerk festhält.
|
||||
//
|
||||
// Mechanik: Dieser Prozess legt die Arbeitsgruppe an, setzt
|
||||
// KILL_ON_JOB_CLOSE und hängt SICH SELBST hinein. Kinder (der Kern, später
|
||||
// makemkvcon/HandBrakeCLI im Kern) erben die Mitgliedschaft. Stirbt der
|
||||
// Haupt-Prozess — Absturz, taskkill /F, Drüber-Installieren —, schließt
|
||||
// Windows sein einziges Handle auf die Gruppe, und ALLE Mitglieder sterben
|
||||
// mit (§ 4.1: „hält die Prozess-Leine — ALLES stirbt mit ihm").
|
||||
//
|
||||
// WARUM die Leine HIER liegt und nicht in kern/werkzeuge/leine.ts (wie die
|
||||
// Ordnerliste § 4.2 sie einsortiert): Das Handle muss bei dem Prozess liegen,
|
||||
// dessen Tod alles mitreißen soll — und das ist laut § 4.1 der Haupt-Prozess.
|
||||
// Hielte der Kern das Handle, wäre ein hart beendetes Haupt wieder die
|
||||
// rc10-Waisen-Falle. Damit ist diese Datei neben kern/laufwerk/win32.ts der
|
||||
// ZWEITE erlaubte koffi-Ort; der Wächter-Test R1 kennt genau diese zwei.
|
||||
import koffi from 'koffi'
|
||||
import { kommandozeileBauen } from './kommandozeile'
|
||||
|
||||
// Konstanten aus winnt.h — dieselbe Herleitung wie beweise/leine.js:
|
||||
const JOB_OBJECT_EXTENDED_LIMIT_INFORMATION_KLASSE = 9 // JobObjectExtendedLimitInformation
|
||||
const JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000
|
||||
|
||||
// Erlaubt AUSDRÜCKLICHES Ausklinken. Für sich genommen ändert dieses Recht
|
||||
// NICHTS an der Leine: Kinder erben die Mitgliedschaft weiterhin, es sei
|
||||
// denn, sie verlangen das Ausklinken beim Anlegen selbst
|
||||
// (CREATE_BREAKAWAY_FROM_JOB). Ohne das Recht scheitert genau das mit
|
||||
// ERROR_ACCESS_DENIED. makemkvcon, HandBrakeCLI und der Kern bleiben also
|
||||
// angeleint — die rc10-Waisen-Falle (§ 3.3) ist unverändert zu.
|
||||
//
|
||||
// WOFÜR (01.09.2026, am lebenden Objekt bezahlt): Der Update-Installer von
|
||||
// electron-updater wird per `spawn(…, { detached: true })` gestartet
|
||||
// (BaseUpdater.js, an der installierten Fassung nachgelesen). `detached`
|
||||
// setzt unter Windows KEIN Breakaway — der Installer wurde damit Mitglied
|
||||
// der Leine und starb in genau der Sekunde, in der Rippy sich beendete, um
|
||||
// ihm Platz zu machen. Befund des Commanders: „Rippy wird zwar sauber
|
||||
// beendet aber startet NICHT neu in der neuen version."
|
||||
const JOB_OBJECT_LIMIT_BREAKAWAY_OK = 0x0800
|
||||
|
||||
/** Nur ein Kind, das das ausdrücklich verlangt, verlässt die Leine. */
|
||||
const CREATE_BREAKAWAY_FROM_JOB = 0x01000000
|
||||
|
||||
// Eigene (unsichtbare) Konsole statt der des Elternprozesses. Für den
|
||||
// NSIS-Installer (ein Fenster-Programm) wirkungslos, aber die Lehre aus
|
||||
// dem ersten Messlauf zu `beweise/leine-breakaway.js` (01.09.2026): Ein
|
||||
// Kind, das sich die Konsole des Elternprozesses teilt, stirbt MIT IHR —
|
||||
// und sieht dann aus, als hätte das Ausklinken nicht gewirkt. Die Messung
|
||||
// zeigte deshalb erst ein falsches Negativ.
|
||||
const CREATE_NO_WINDOW = 0x08000000
|
||||
|
||||
// STARTUPINFOW ist auf x64 104 Byte, `cb` steht als DWORD an Offset 0;
|
||||
// PROCESS_INFORMATION ist 24 Byte (zwei Handles + zwei DWORDs).
|
||||
const STARTUPINFO_GROESSE = 104
|
||||
const PROCESS_INFORMATION_GROESSE = 24
|
||||
|
||||
// JOBOBJECT_EXTENDED_LIMIT_INFORMATION ist 144 Byte auf x64 (Rippy v5 ist
|
||||
// x64-only, § 8); LimitFlags liegt im eingebetteten BASIC_LIMIT_INFORMATION
|
||||
// bei Offset 16.
|
||||
const STRUKTUR_GROESSE = 144
|
||||
const LIMIT_FLAGS_OFFSET = 16
|
||||
|
||||
export type LeineStatus = { ok: true } | { ok: false; fehler: string }
|
||||
|
||||
let gesetzt: LeineStatus | null = null
|
||||
|
||||
/**
|
||||
* Setzt die Prozess-Leine für DIESEN Prozess. Einmal pro Programmlauf,
|
||||
* VOR dem Start des Kerns — Kinder erben die Mitgliedschaft nur, wenn sie
|
||||
* nach dem Anlegen gestartet werden.
|
||||
*
|
||||
* Ein Fehlschlag wird als Wert zurückgegeben, nie verschluckt (R4): Der
|
||||
* Aufrufer protokolliert ihn und zeigt ihn im Fenster an.
|
||||
*/
|
||||
export function leineSetzen(): LeineStatus {
|
||||
if (gesetzt !== null) return gesetzt
|
||||
try {
|
||||
const kernel32 = koffi.load('kernel32.dll')
|
||||
const CreateJobObjectW = kernel32.func(
|
||||
'void* __stdcall CreateJobObjectW(void *lpJobAttributes, str16 lpName)',
|
||||
)
|
||||
const SetInformationJobObject = kernel32.func(
|
||||
'bool __stdcall SetInformationJobObject(void *hJob, int JobObjectInformationClass, void *lpJobObjectInformation, uint32_t cbJobObjectInformationLength)',
|
||||
)
|
||||
const AssignProcessToJobObject = kernel32.func(
|
||||
'bool __stdcall AssignProcessToJobObject(void *hJob, void *hProcess)',
|
||||
)
|
||||
const GetCurrentProcess = kernel32.func('void* __stdcall GetCurrentProcess()')
|
||||
const GetLastError = kernel32.func('uint32_t __stdcall GetLastError()')
|
||||
|
||||
const job = CreateJobObjectW(null, null)
|
||||
if (koffi.address(job) === 0n) {
|
||||
gesetzt = { ok: false, fehler: `CreateJobObjectW: Win32-Fehler ${GetLastError()}` }
|
||||
return gesetzt
|
||||
}
|
||||
|
||||
const info = Buffer.alloc(STRUKTUR_GROESSE)
|
||||
info.writeUInt32LE(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | JOB_OBJECT_LIMIT_BREAKAWAY_OK, LIMIT_FLAGS_OFFSET)
|
||||
if (
|
||||
!SetInformationJobObject(
|
||||
job,
|
||||
JOB_OBJECT_EXTENDED_LIMIT_INFORMATION_KLASSE,
|
||||
info,
|
||||
STRUKTUR_GROESSE,
|
||||
)
|
||||
) {
|
||||
gesetzt = { ok: false, fehler: `SetInformationJobObject: Win32-Fehler ${GetLastError()}` }
|
||||
return gesetzt
|
||||
}
|
||||
|
||||
if (!AssignProcessToJobObject(job, GetCurrentProcess())) {
|
||||
gesetzt = { ok: false, fehler: `AssignProcessToJobObject: Win32-Fehler ${GetLastError()}` }
|
||||
return gesetzt
|
||||
}
|
||||
|
||||
// Das Job-Handle wird ABSICHTLICH nie geschlossen — sein Zuschnappen beim
|
||||
// Prozess-Tod IST der Mechanismus.
|
||||
gesetzt = { ok: true }
|
||||
return gesetzt
|
||||
} catch (fehler) {
|
||||
gesetzt = { ok: false, fehler: `koffi/kernel32 nicht verfügbar: ${String(fehler)}` }
|
||||
return gesetzt
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Programm starten, das die Prozess-Leine AUSDRÜCKLICH verlässt.
|
||||
*
|
||||
* Gedacht für genau einen Fall: den Update-Installer. Er ist das einzige
|
||||
* Kind, das Rippys eigenen Tod überleben MUSS — er installiert ja gerade
|
||||
* über Rippy drüber und startet es danach wieder. Alles andere (Kern,
|
||||
* makemkvcon, HandBrakeCLI) bleibt angeleint, § 3.3 ist unverändert.
|
||||
*
|
||||
* Warum nicht `shell.openPath` wie beim MakeMKV-Installer (§ 9)? Der
|
||||
* entkommt der Leine nur, WEIL er Adminrechte verlangt und deshalb über
|
||||
* den AppInfo-Dienst startet. Rippys eigener Installer ist bewusst
|
||||
* benutzerlokal und ohne Erhöhung (Entscheid 5 / § 8; in der
|
||||
* `update-info.json` steht `isAdminRightsRequired: false`) — er liefe also
|
||||
* wieder als Kind und stürbe wieder mit.
|
||||
*/
|
||||
export function ausserhalbDerLeineStarten(
|
||||
programm: string,
|
||||
argumente: readonly string[],
|
||||
): { ok: boolean; fehler: string } {
|
||||
try {
|
||||
const kernel32 = koffi.load('kernel32.dll')
|
||||
const CreateProcessW = kernel32.func(
|
||||
'bool __stdcall CreateProcessW(str16 lpApplicationName, void *lpCommandLine, void *lpProcessAttributes, void *lpThreadAttributes, bool bInheritHandles, uint32_t dwCreationFlags, void *lpEnvironment, str16 lpCurrentDirectory, void *lpStartupInfo, void *lpProcessInformation)',
|
||||
)
|
||||
const GetLastError = kernel32.func('uint32_t __stdcall GetLastError()')
|
||||
|
||||
const startupInfo = Buffer.alloc(STARTUPINFO_GROESSE)
|
||||
startupInfo.writeUInt32LE(STARTUPINFO_GROESSE, 0)
|
||||
const prozessInfo = Buffer.alloc(PROCESS_INFORMATION_GROESSE)
|
||||
// CreateProcessW DARF diese Zeichenkette verändern — deshalb ein
|
||||
// beschreibbarer Puffer und nicht die Zeichenkette selbst.
|
||||
const zeile = Buffer.from(`${kommandozeileBauen(programm, argumente)}\0`, 'utf16le')
|
||||
|
||||
const gestartet = CreateProcessW(
|
||||
programm,
|
||||
zeile,
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
CREATE_BREAKAWAY_FROM_JOB | CREATE_NO_WINDOW,
|
||||
null,
|
||||
null,
|
||||
startupInfo,
|
||||
prozessInfo,
|
||||
)
|
||||
if (!gestartet) {
|
||||
// Win32-Fehler 5 (ERROR_ACCESS_DENIED) heißt hier: Das Ausklinken war
|
||||
// nicht erlaubt — dann fehlt JOB_OBJECT_LIMIT_BREAKAWAY_OK, oder eine
|
||||
// ÄUSSERE Arbeitsgruppe verbietet es (Sandbox, Fernwartung).
|
||||
return { ok: false, fehler: `CreateProcessW: Win32-Fehler ${GetLastError()}` }
|
||||
}
|
||||
// Die beiden Handles in prozessInfo bleiben offen: Rippy beendet sich
|
||||
// unmittelbar danach, Windows schließt sie dabei mit. Sie hier zu
|
||||
// schließen hieße, sie erst aus dem Puffer zurückzudecodieren — Aufwand
|
||||
// ohne Wirkung.
|
||||
return { ok: true, fehler: '' }
|
||||
} catch (fehler) {
|
||||
return { ok: false, fehler: `koffi/kernel32 nicht verfügbar: ${String(fehler)}` }
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
// Tray-Symbol mit Zustand (KONZEPT § 7): auf einen Blick, ob ein Rip
|
||||
// läuft und wie weit. Mit dem Tray ändert sich das Schließen-Verhalten:
|
||||
// Fenster zu heißt „im Hintergrund weiterarbeiten" (§ 4.1 Punkt 3) —
|
||||
// beendet wird über das Kontextmenü oder den Menüpunkt.
|
||||
import { Menu, nativeImage, Tray } from 'electron'
|
||||
import { join } from 'node:path'
|
||||
|
||||
let tray: Tray | null = null
|
||||
|
||||
export function trayErstellen(oeffnen: () => void, beenden: () => void): Tray | null {
|
||||
try {
|
||||
// Das Bau-Icon liegt neben dem Bundle: out/haupt → ../../bau/tray.png
|
||||
// im Quell-Lauf; im gepackten Programm über resourcesPath (W-6 legt es
|
||||
// per extraResources dorthin).
|
||||
const kandidaten = [
|
||||
join(__dirname, '..', '..', 'bau', 'tray.png'),
|
||||
join(process.resourcesPath ?? '', 'bau', 'tray.png'),
|
||||
]
|
||||
let bild = nativeImage.createEmpty()
|
||||
for (const kandidat of kandidaten) {
|
||||
const versuch = nativeImage.createFromPath(kandidat)
|
||||
if (!versuch.isEmpty()) {
|
||||
bild = versuch
|
||||
break
|
||||
}
|
||||
}
|
||||
tray = new Tray(bild)
|
||||
tray.setToolTip('Rippy — bereit')
|
||||
tray.setContextMenu(
|
||||
Menu.buildFromTemplate([
|
||||
{ label: 'Rippy öffnen', click: oeffnen },
|
||||
{ type: 'separator' },
|
||||
{ label: 'Beenden', click: beenden },
|
||||
]),
|
||||
)
|
||||
tray.on('click', oeffnen)
|
||||
return tray
|
||||
} catch (fehler) {
|
||||
// R4: Ohne Tray läuft Rippy trotzdem — aber der Grund steht im Log.
|
||||
console.error(`[haupt] Tray nicht erstellt: ${String(fehler)}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function trayHinweis(text: string): void {
|
||||
tray?.setToolTip(text.length > 0 ? `Rippy — ${text}` : 'Rippy — bereit')
|
||||
}
|
||||
|
||||
export function trayWeg(): void {
|
||||
tray?.destroy()
|
||||
tray = null
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
// Die reinen Update-Regeln (§ 6.8) — getrennt von update.ts, damit die
|
||||
// Tests sie ohne Electron laden können.
|
||||
|
||||
export const STANDARD_UPDATE_URL =
|
||||
'https://git.tobisniceshomelab.ddnsfree.com/Hitonabi/rippy/releases/download/aktuell'
|
||||
|
||||
/** Prüft die Update-Adresse: NUR https:// ist zulässig (§ 6.8 Regel 1) —
|
||||
* ohne Code-Signing (Entscheid 5) ist der Transportweg die einzige
|
||||
* Absicherung; eine http://-Adresse wird ABGELEHNT, nicht still
|
||||
* akzeptiert. */
|
||||
export function updateUrlPruefen(url: string): { ok: boolean; grund: string } {
|
||||
const sauber = url.trim()
|
||||
if (sauber.length === 0) return { ok: true, grund: '' } // Standard gilt
|
||||
if (sauber.startsWith('https://')) return { ok: true, grund: '' }
|
||||
return {
|
||||
ok: false,
|
||||
grund:
|
||||
'Die Update-Adresse muss mit https:// beginnen — ohne Signatur ist der sichere Transportweg die einzige Absicherung (KONZEPT § 6.8).',
|
||||
}
|
||||
}
|
||||
|
||||
/** So viel Änderungstext zeigt das Fenster höchstens — der Rest wird
|
||||
* abgeschnitten, damit eine ausufernde Datei die Karte nicht sprengt. */
|
||||
export const NEUERUNGEN_HOECHSTENS = 4000
|
||||
|
||||
/** Die Änderungsnotizen aus `latest.yml` in reinen Text.
|
||||
*
|
||||
* `UpdateInfo.releaseNotes` ist laut builder-util-runtime
|
||||
* `string | Array<ReleaseNoteInfo> | null` — bei uns ein String
|
||||
* (electron-builder legt den Inhalt von `bau/release-notes.md` hinein),
|
||||
* bei mehreren übersprungenen Versionen eine Liste. Beides muss hier
|
||||
* ankommen können, sonst steht im Fenster „[object Object]".
|
||||
*
|
||||
* HTML-Marken fliegen raus: Der Text kommt von einem Server, und auch
|
||||
* wenn React ihn ohnehin nur als Text setzt, wird hier nichts
|
||||
* weitergereicht, was wie Auszeichnung aussieht. */
|
||||
export function neuerungenText(roh: unknown): string {
|
||||
const saubern = (t: string): string =>
|
||||
t
|
||||
.replace(/<[^>]*>/g, ' ')
|
||||
.split('\n')
|
||||
.map((zeile) => zeile.replace(/[ \t]+$/, ''))
|
||||
.join('\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
|
||||
if (typeof roh === 'string') return saubern(roh).slice(0, NEUERUNGEN_HOECHSTENS)
|
||||
if (Array.isArray(roh)) {
|
||||
const teile: string[] = []
|
||||
for (const eintrag of roh) {
|
||||
if (eintrag === null || typeof eintrag !== 'object') continue
|
||||
const e = eintrag as { version?: unknown; note?: unknown }
|
||||
const version = typeof e.version === 'string' ? e.version : ''
|
||||
const notiz = typeof e.note === 'string' ? saubern(e.note) : ''
|
||||
if (notiz.length === 0) continue
|
||||
teile.push(version.length > 0 ? `${version}\n${notiz}` : notiz)
|
||||
}
|
||||
return teile.join('\n\n').slice(0, NEUERUNGEN_HOECHSTENS)
|
||||
}
|
||||
return ''
|
||||
}
|
||||
@@ -1,246 +0,0 @@
|
||||
// Selbst-Update (KONZEPT § 6.8) — electron-updater gegen die Releases im
|
||||
// öffentlichen Gitea (Entscheid 6). Die vier Regeln:
|
||||
// 1. NUR HTTPS — ohne Code-Signing (Entscheid 5) ist der Transportweg
|
||||
// die einzige Absicherung; eine http://-Adresse wird ABGELEHNT.
|
||||
// 2. NIE während eines Rips — weder prüfen, laden noch installieren.
|
||||
// 3. Ein Fehlschlag ist sichtbar, aber nicht laut: Der Zeitpunkt der
|
||||
// letzten erfolgreichen Prüfung steht im Status.
|
||||
// 4. Die laufende Version ist ablesbar (Fenster + Protokollkopf).
|
||||
//
|
||||
// Ergänzt am 01.09.2026 auf Zuruf des Commanders („irgendwie fehlt der
|
||||
// Knopf ‚Auf Updates prüfen' und die Meldung beim Start"): Der Kanal
|
||||
// arbeitete korrekt, war aber UNSICHTBAR — der Stand lag im Haupt und
|
||||
// wurde nur beim Laden des Fensters einmal mitgeschickt. Jetzt trägt er
|
||||
// zusätzlich die gefundene Version, ihre Änderungsnotizen und den
|
||||
// Ladefortschritt, und JEDE Änderung meldet sich sofort ans Fenster.
|
||||
import { app } from 'electron'
|
||||
import electronUpdater from 'electron-updater'
|
||||
import { INSTALLER_ARGUMENTE } from './kommandozeile'
|
||||
import { ausserhalbDerLeineStarten } from './leine'
|
||||
import { neuerungenText, updateUrlPruefen } from './update-regeln'
|
||||
|
||||
export {
|
||||
NEUERUNGEN_HOECHSTENS,
|
||||
STANDARD_UPDATE_URL,
|
||||
neuerungenText,
|
||||
updateUrlPruefen,
|
||||
} from './update-regeln'
|
||||
|
||||
const { autoUpdater } = electronUpdater
|
||||
|
||||
// EINE Definition für Haupt und Fenster (§ 6.7 sinngemäß: eine Quelle).
|
||||
export type { UpdateStand } from '../gemeinsam/nachrichten'
|
||||
import type { UpdateStand } from '../gemeinsam/nachrichten'
|
||||
|
||||
const ANFANG: UpdateStand = {
|
||||
letzteErfolgreichePruefung: '',
|
||||
text: 'noch nicht geprüft',
|
||||
neueVersion: '',
|
||||
neuerungen: '',
|
||||
bereit: false,
|
||||
laeuft: false,
|
||||
fortschritt: -1,
|
||||
}
|
||||
|
||||
let stand: UpdateStand = { ...ANFANG }
|
||||
let aenderungMelden: () => void = () => {
|
||||
// Vor updateEinrichten() gibt es noch kein Fenster, das zuhören könnte.
|
||||
}
|
||||
let ripLaeuftJetzt: () => boolean = () => false
|
||||
let scharf = false
|
||||
/** Der geladene Installer — leer, solange nichts bereitliegt. */
|
||||
let installerPfad = ''
|
||||
/** Einmal gestartet reicht: Der Knopf und der Beenden-Weg dürfen sich
|
||||
* nicht gegenseitig zwei Installer auf den Hals hetzen. */
|
||||
let installerGestartet = false
|
||||
let rippyBeenden: () => void = () => app.quit()
|
||||
|
||||
export function updateStand(): UpdateStand {
|
||||
return { ...stand }
|
||||
}
|
||||
|
||||
/** Stand ändern UND das Fenster benachrichtigen — nie das eine ohne das
|
||||
* andere: Ein Stand, den niemand erfährt, ist der Zustand von vorher
|
||||
* (der 26.07.-Fund „ein still scheiternder Hintergrund-Prozess"). */
|
||||
function setzen(teil: Partial<UpdateStand>): void {
|
||||
stand = { ...stand, ...teil }
|
||||
aenderungMelden()
|
||||
}
|
||||
|
||||
export function updateEinrichten(
|
||||
eigeneUrl: string,
|
||||
ripLaeuft: () => boolean,
|
||||
melden: (text: string) => void,
|
||||
beiAenderung: () => void = () => {
|
||||
// ohne Fenster kein Empfänger — der Stand bleibt trotzdem abrufbar
|
||||
},
|
||||
beenden: () => void = () => app.quit(),
|
||||
): void {
|
||||
aenderungMelden = beiAenderung
|
||||
ripLaeuftJetzt = ripLaeuft
|
||||
rippyBeenden = beenden
|
||||
if (!app.isPackaged) {
|
||||
setzen({ text: 'Entwicklungslauf — Selbst-Update ist nur im installierten Rippy aktiv.' })
|
||||
return
|
||||
}
|
||||
const pruefung = updateUrlPruefen(eigeneUrl)
|
||||
if (!pruefung.ok) {
|
||||
setzen({ text: pruefung.grund })
|
||||
melden(pruefung.grund)
|
||||
return
|
||||
}
|
||||
if (eigeneUrl.trim().length > 0) {
|
||||
autoUpdater.setFeedURL({ provider: 'generic', url: eigeneUrl.trim() })
|
||||
}
|
||||
autoUpdater.autoDownload = false
|
||||
// WIR installieren, nicht electron-updater (01.09.2026, am lebenden
|
||||
// Objekt bezahlt): Sein eigener Weg startet den Installer per
|
||||
// `spawn(…, { detached: true })`, und `detached` setzt unter Windows KEIN
|
||||
// Breakaway. Der Installer wurde damit Mitglied der Prozess-Leine (§ 3.3)
|
||||
// und starb in genau der Sekunde, in der Rippy sich für ihn beendete.
|
||||
// Befund: „Rippy wird zwar sauber beendet aber startet NICHT neu."
|
||||
autoUpdater.autoInstallOnAppQuit = false
|
||||
scharf = true
|
||||
|
||||
// Beim Beenden — egal ob über das Tray, den Update-Knopf oder das
|
||||
// Herunterfahren von Windows — bekommt ein bereitliegendes Update noch
|
||||
// seinen Start. CreateProcessW ist synchron: Der Installer LEBT, bevor
|
||||
// diese Zeile zurückkommt.
|
||||
app.on('will-quit', () => {
|
||||
if (!stand.bereit || ripLaeuftJetzt()) return
|
||||
installerAnwerfen()
|
||||
})
|
||||
|
||||
autoUpdater.on('update-available', (info) => {
|
||||
const neuerungen = neuerungenText(info.releaseNotes)
|
||||
if (ripLaeuft()) {
|
||||
// Regel 2: nicht einmal herunterladen, solange gerippt wird — der
|
||||
// tägliche Takt versucht es wieder.
|
||||
setzen({
|
||||
neueVersion: info.version,
|
||||
neuerungen,
|
||||
laeuft: false,
|
||||
fortschritt: -1,
|
||||
text: `Version ${info.version} ist da — sie wird geladen, sobald kein Rip mehr läuft.`,
|
||||
})
|
||||
melden(stand.text)
|
||||
return
|
||||
}
|
||||
setzen({
|
||||
neueVersion: info.version,
|
||||
neuerungen,
|
||||
laeuft: true,
|
||||
fortschritt: 0,
|
||||
text: `Version ${info.version} wird geladen …`,
|
||||
})
|
||||
// Der Commander wollte die Meldung sehen, nicht suchen müssen.
|
||||
melden(`Update verfügbar: Version ${info.version} wird geladen.`)
|
||||
void autoUpdater
|
||||
.downloadUpdate()
|
||||
.then((pfade: string[]) => {
|
||||
// downloadUpdate liefert die abgelegten Dateien zurück
|
||||
// (AppUpdater.d.ts: „Paths to downloaded files") — die .exe darin
|
||||
// ist der Installer, den wir gleich selbst starten. Liegt die Datei
|
||||
// vom letzten Lauf schon im Zwischenspeicher, kommt sie hier
|
||||
// ebenfalls heraus, ohne erneut zu laden.
|
||||
const exe = (pfade ?? []).find((p) => p.toLowerCase().endsWith('.exe')) ?? ''
|
||||
if (exe.length > 0) installerPfad = exe
|
||||
})
|
||||
.catch((fehler: unknown) => {
|
||||
setzen({
|
||||
laeuft: false,
|
||||
fortschritt: -1,
|
||||
text: `Update-Download fehlgeschlagen: ${String(fehler).slice(0, 160)}`,
|
||||
})
|
||||
})
|
||||
})
|
||||
autoUpdater.on('download-progress', (fortschritt) => {
|
||||
setzen({ laeuft: true, fortschritt: Math.round(fortschritt.percent) })
|
||||
})
|
||||
autoUpdater.on('update-downloaded', (info) => {
|
||||
setzen({
|
||||
neueVersion: info.version,
|
||||
neuerungen: neuerungenText(info.releaseNotes) || stand.neuerungen,
|
||||
bereit: true,
|
||||
laeuft: false,
|
||||
fortschritt: 100,
|
||||
text: `Version ${info.version} liegt bereit — sie wird beim nächsten Start installiert.`,
|
||||
})
|
||||
melden(stand.text)
|
||||
})
|
||||
autoUpdater.on('update-not-available', () => {
|
||||
setzen({
|
||||
letzteErfolgreichePruefung: new Date().toISOString(),
|
||||
neueVersion: '',
|
||||
neuerungen: '',
|
||||
bereit: false,
|
||||
laeuft: false,
|
||||
fortschritt: -1,
|
||||
text: 'Rippy ist aktuell.',
|
||||
})
|
||||
})
|
||||
autoUpdater.on('error', (fehler) => {
|
||||
// Regel 3: still scheitern (WLAN weg, Server aus) — aber ablesbar.
|
||||
setzen({ laeuft: false, fortschritt: -1, text: `Letzte Prüfung scheiterte: ${String(fehler).slice(0, 160)}` })
|
||||
})
|
||||
|
||||
pruefen()
|
||||
setInterval(pruefen, 24 * 60 * 60 * 1000).unref?.()
|
||||
}
|
||||
|
||||
function pruefen(): void {
|
||||
if (ripLaeuftJetzt()) return
|
||||
setzen({ laeuft: true, text: 'Suche nach Updates …' })
|
||||
void autoUpdater.checkForUpdates().catch(() => {
|
||||
// der error-Handler oben trägt den Grund ein
|
||||
})
|
||||
}
|
||||
|
||||
/** Der Knopf „Jetzt prüfen" im Fenster. Antwortet IMMER etwas — ein
|
||||
* Knopf, der nichts sagt, ist schlimmer als keiner. */
|
||||
export function updatePruefenJetzt(): void {
|
||||
if (!scharf) {
|
||||
// Im Entwicklungslauf steht der Grund schon im Text; ihn erneut zu
|
||||
// melden hält den Knopf ehrlich, statt ihn ins Leere laufen zu lassen.
|
||||
aenderungMelden()
|
||||
return
|
||||
}
|
||||
if (ripLaeuftJetzt()) {
|
||||
setzen({ text: 'Während eines Rips wird nicht nach Updates gesucht — das ist Absicht (§ 6.8).' })
|
||||
return
|
||||
}
|
||||
pruefen()
|
||||
}
|
||||
|
||||
/** Den Installer AUSSERHALB der Prozess-Leine anwerfen. Gibt zurück, ob
|
||||
* er wirklich läuft — „nichts passiert" darf nicht als Erfolg durchgehen. */
|
||||
function installerAnwerfen(): { ok: boolean; grund: string } {
|
||||
if (installerGestartet) return { ok: true, grund: '' }
|
||||
if (installerPfad.length === 0) {
|
||||
return { ok: false, grund: 'Es liegt kein geladener Installer bereit.' }
|
||||
}
|
||||
const urteil = ausserhalbDerLeineStarten(installerPfad, INSTALLER_ARGUMENTE)
|
||||
if (!urteil.ok) {
|
||||
setzen({ text: `Update konnte nicht gestartet werden: ${urteil.fehler}` })
|
||||
return { ok: false, grund: urteil.fehler }
|
||||
}
|
||||
installerGestartet = true
|
||||
return { ok: true, grund: '' }
|
||||
}
|
||||
|
||||
/** Der Knopf „Jetzt neu starten und installieren". Nur sinnvoll, wenn
|
||||
* wirklich etwas bereitliegt — und nie mitten in einem Rip. */
|
||||
export function updateInstallierenJetzt(): { ok: boolean; grund: string } {
|
||||
if (!scharf || !stand.bereit) {
|
||||
return { ok: false, grund: 'Es liegt kein geladenes Update bereit.' }
|
||||
}
|
||||
if (ripLaeuftJetzt()) {
|
||||
return { ok: false, grund: 'Erst wenn der Rip fertig ist — Rippy startet dafür neu (§ 6.8).' }
|
||||
}
|
||||
// Erst den Installer, DANN beenden: Er wartet von sich aus, bis Rippy
|
||||
// weg ist, und startet es hinterher wieder (`--force-run`).
|
||||
const urteil = installerAnwerfen()
|
||||
if (!urteil.ok) return urteil
|
||||
rippyBeenden()
|
||||
return { ok: true, grund: '' }
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
// Dateinamen in der Ablage (5.6.0, Commander: „die Ordner … müssen direkt
|
||||
// den Filmnamen haben" — und die Dateien darin auch): Statt MakeMKVs
|
||||
// title_t00.mkv heißt der Hauptfilm „Akira (1988).mkv", zwei Fassungen
|
||||
// „Akira (1988) - Fassung 1.mkv" und „… - Fassung 2.mkv", Zugaben „Akira
|
||||
// (1988) - Extra 01.mkv" im Unterordner extras. Folgen behalten ihren
|
||||
// Namen, bis die Episoden-Zuordnung sie umbenennt (struktur.ts) — ohne
|
||||
// Zuordnung bleiben sie, wie sie sind (lieber gar nicht als falsch). Ohne
|
||||
// erkannten Titel bleibt alles beim MakeMKV-Namen. PUR.
|
||||
import { extname } from 'node:path'
|
||||
import { sichererName } from '../../gemeinsam/ablagepfad'
|
||||
import type { TitelRolle } from '../../gemeinsam/nachrichten'
|
||||
|
||||
export interface NamensEingabe {
|
||||
titel: string
|
||||
jahr: number | null
|
||||
alsSerie: boolean
|
||||
rolle: TitelRolle
|
||||
/** 1-basierte Nummer unter den Dateien GLEICHER Rolle (Ablage-Reihenfolge). */
|
||||
nummer: number
|
||||
/** Wie viele Dateien dieser Rolle es gibt. */
|
||||
anzahl: number
|
||||
/** MakeMKVs Dateiname (mit Endung). */
|
||||
original: string
|
||||
}
|
||||
|
||||
export function zielDateiname(e: NamensEingabe): string {
|
||||
const endung = extname(e.original) || '.mkv'
|
||||
const name = e.alsSerie ? sichererName(e.titel) : sichererName(e.titel, e.jahr)
|
||||
if (e.titel.trim().length === 0 || name.length === 0) return e.original
|
||||
const nn = String(e.nummer).padStart(2, '0')
|
||||
if (e.rolle === 'extra') return `${name} - Extra ${nn}${endung}`
|
||||
if (e.alsSerie) return e.original
|
||||
if (e.rolle === 'hauptfilm') return e.anzahl > 1 ? `${name} - Fassung ${e.nummer}${endung}` : `${name}${endung}`
|
||||
return e.original
|
||||
}
|
||||
|
||||
/** Nummern je Rolle in Ablage-Reihenfolge (pur): [nummer, anzahl] je Index. */
|
||||
export function rollenNummern(rollen: readonly TitelRolle[]): Array<[number, number]> {
|
||||
const zaehler = new Map<TitelRolle, number>()
|
||||
const gesamt = new Map<TitelRolle, number>()
|
||||
for (const r of rollen) gesamt.set(r, (gesamt.get(r) ?? 0) + 1)
|
||||
return rollen.map((r) => {
|
||||
const n = (zaehler.get(r) ?? 0) + 1
|
||||
zaehler.set(r, n)
|
||||
return [n, gesamt.get(r) ?? 1]
|
||||
})
|
||||
}
|
||||
|
||||
/** 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}`
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
// Bibliotheks-Refresh des Medienservers anstoßen (KONZEPT § 6.10) —
|
||||
// Wege aus medien.py: Jellyfin/Emby per POST /Library/Refresh mit
|
||||
// X-Emby-Token; Kodi per JSON-RPC VideoLibrary.Scan mit Basic-Auth
|
||||
// (api_key = "user:passwort"). Wirft NIE — der Refresh ist Komfort,
|
||||
// nicht Teil des Rips (R4: das Ergebnis wird trotzdem GEMELDET).
|
||||
|
||||
export type MedienServer = 'jellyfin' | 'emby' | 'kodi' | 'plex' | 'none'
|
||||
|
||||
export async function bibliothekRefresh(
|
||||
server: string,
|
||||
url: string,
|
||||
apiKey: string,
|
||||
holen: typeof fetch = fetch,
|
||||
): Promise<string> {
|
||||
if (url.length === 0 || !['jellyfin', 'emby', 'kodi'].includes(server)) return ''
|
||||
try {
|
||||
if ((server === 'jellyfin' || server === 'emby') && apiKey.length > 0) {
|
||||
const antwort = await holen(url.replace(/\/+$/, '') + '/Library/Refresh', {
|
||||
method: 'POST',
|
||||
headers: { 'X-Emby-Token': apiKey },
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
})
|
||||
if (antwort.status < 300) {
|
||||
return `${server[0].toUpperCase()}${server.slice(1)}-Bibliothek aktualisiert (${url})`
|
||||
}
|
||||
return `Bibliotheks-Refresh: HTTP ${antwort.status} von ${url}`
|
||||
}
|
||||
if (server === 'kodi') {
|
||||
const kopf: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||
if (apiKey.includes(':')) {
|
||||
kopf['Authorization'] = 'Basic ' + Buffer.from(apiKey).toString('base64')
|
||||
}
|
||||
const antwort = await holen(url.replace(/\/+$/, '') + '/jsonrpc', {
|
||||
method: 'POST',
|
||||
headers: kopf,
|
||||
body: JSON.stringify({ jsonrpc: '2.0', method: 'VideoLibrary.Scan', id: 1 }),
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
})
|
||||
if (antwort.status < 300) return `Kodi-Bibliothek aktualisiert (${url})`
|
||||
return `Kodi-Refresh: HTTP ${antwort.status} von ${url}`
|
||||
}
|
||||
} catch (fehler) {
|
||||
return `Bibliotheks-Refresh fehlgeschlagen: ${String(fehler)}`
|
||||
}
|
||||
return ''
|
||||
}
|
||||