Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f671ff160 | ||
|
|
512b38cced | ||
|
|
f5dab5d1c1 | ||
|
|
76ed3b337a | ||
|
|
2ae79fceb4 | ||
|
|
3dc6798c79 | ||
|
|
6678c81592 | ||
|
|
2c74dc6958 | ||
|
|
6555562a4c | ||
|
|
c46a280b1f | ||
|
|
6dd1b0bc7a | ||
|
|
e3ce8220f1 | ||
|
|
3f7f962dff | ||
|
|
d70a81d194 | ||
|
|
7c489f8404 | ||
|
|
1f678c036d | ||
|
|
6d149212c9 | ||
|
|
9622f4e6ef | ||
|
|
306b090ccf | ||
|
|
7ce06889f8 | ||
|
|
043052b453 | ||
|
|
a8e8b2bef3 | ||
|
|
1253148652 | ||
|
|
2549b9643b | ||
|
|
663127f854 | ||
|
|
45402e887e | ||
|
|
7a4f92036d | ||
|
|
051cab3afa | ||
|
|
1d53e8526f | ||
|
|
827910fd63 | ||
|
|
0c38754c09 | ||
|
|
c0a7618e33 | ||
|
|
7d496cf654 | ||
|
|
4f943e2c4f | ||
|
|
38a3a87f70 | ||
|
|
673f7ff3e7 | ||
|
|
5522a50425 |
|
After Width: | Height: | Size: 290 KiB |
|
After Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 340 KiB |
|
After Width: | Height: | Size: 711 KiB |
|
After Width: | Height: | Size: 712 KiB |
|
After Width: | Height: | Size: 749 KiB |
|
After Width: | Height: | Size: 341 KiB |
|
After Width: | Height: | Size: 465 KiB |
|
After Width: | Height: | Size: 458 KiB |
|
After Width: | Height: | Size: 447 KiB |
@@ -0,0 +1,114 @@
|
||||
// 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()
|
||||
|
After Width: | Height: | Size: 295 KiB |
|
After Width: | Height: | Size: 239 KiB |
|
After Width: | Height: | Size: 319 KiB |
|
After Width: | Height: | Size: 244 KiB |
@@ -34,6 +34,7 @@ als npm-Skript hinterlegt: `npm run werkzeug:electron`.
|
||||
| `npm run build` | Typprüfung (beide tsconfig) + Bau nach `out/` |
|
||||
| `npm test` | Vitest: Wächter-Tests (§ 4.3), Datenbank, Nachrichten-Schema |
|
||||
| `npm run smoke` | **Der W-0-Beweis:** baut, startet das echte Programm mit `--smoke`, prüft Fenster + Kern + Datenbank + Leine + Ping/Pong über den MessagePort und endet mit Code 0 (grün) oder 2. `RIPPY_SMOKE_BILD=<pfad.png>` legt zusätzlich einen Screenshot ab. Läuft nur unter Windows (Electron-Binary + koffi). |
|
||||
| `npm run klick` | **Der Klick-Beweis (5.4.0):** baut, startet das echte Programm mit eigenem Profil (`--profil=…`, `--klick-smoke`: ohne Tray, Updater, Toasts), einem nachgebauten Laufwerk (`RIPPY_FAKE_LAUFWERK=Q`) und nachgebauten Werkzeugen (`bau/klick/fake-*.cjs`, laufen über `kern/werkzeuge/aufruf.ts`) — und klickt per Playwright durch 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.
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
// 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('Sprachen je Titel: jpn/deu Ton, deu/eng Untertitel als Chips', async () => {
|
||||
await dialog().getByRole('button', { name: /jpn/ }).first().waitFor()
|
||||
await dialog().getByRole('button', { name: /✓ deu/ }).first().waitFor()
|
||||
})
|
||||
|
||||
await schritt('Titel 0 bekommt japanischen Ton (deu abwählen, jpn anwählen)', async () => {
|
||||
const chips = dialog().locator('button', { hasText: /^✓ deu$|^deu$/ })
|
||||
await chips.first().click()
|
||||
await dialog().getByRole('button', { name: /^jpn$/ }).first().click()
|
||||
await dialog().getByRole('button', { name: /^✓ jpn$/ }).first().waitFor()
|
||||
})
|
||||
|
||||
await schritt('„Hauptinhalt + Extras" wählt beide langen Titel, der Schnipsel bleibt draußen', async () => {
|
||||
// Rippy wählt bei zwei fast gleich langen Titeln nur EINEN vor („bitte
|
||||
// nachsehen") — der Knopf holt den zweiten dazu, Titel 2 (35 s) nicht.
|
||||
await dialog().getByRole('button', { name: 'Hauptinhalt + Extras' }).click()
|
||||
await dialog().getByText(/2 von 3 gewählt/).waitFor()
|
||||
})
|
||||
|
||||
await schritt('5.6.0: Untertitel-Vorwahl erscheint NUR bei Extras, „anderer Film …" je Titel', async () => {
|
||||
// Rippys Vorschlag macht bei zwei langen Titeln den zweiten zum Extra —
|
||||
// erst beide auf Hauptfilm, dann ist klar: keine Vorwahl bei Hauptfilmen.
|
||||
const rollen = dialog().locator('select')
|
||||
await rollen.nth(0).selectOption('hauptfilm')
|
||||
await rollen.nth(1).selectOption('hauptfilm')
|
||||
if ((await dialog().getByText('BEIM ABSPIELEN AN').count()) !== 0) throw new Error('Vorwahl schon bei Hauptfilmen sichtbar')
|
||||
await rollen.nth(1).selectOption('extra')
|
||||
await dialog().getByText('BEIM ABSPIELEN AN').waitFor()
|
||||
const vorwahl = dialog().locator('select').nth(2)
|
||||
await vorwahl.selectOption('deu')
|
||||
const knoepfe = await dialog().getByRole('button', { name: 'anderer Film …' }).count()
|
||||
if (knoepfe < 2) throw new Error(`nur ${knoepfe} „anderer Film"-Knöpfe`)
|
||||
await dialog().getByRole('button', { name: 'anderer Film …' }).first().click()
|
||||
await dialog().getByPlaceholder('Filmtitel suchen …').waitFor()
|
||||
await dialog().getByRole('button', { name: 'anderer Film …' }).first().click()
|
||||
await rollen.nth(1).selectOption('hauptfilm')
|
||||
if ((await dialog().getByText('BEIM ABSPIELEN AN').count()) !== 0) throw new Error('Vorwahl bleibt nach Rollenwechsel sichtbar')
|
||||
})
|
||||
await bild('dialog-vorwahl')
|
||||
|
||||
await schritt('Rollen auf Folge → Staffel/Folge-Felder erscheinen', async () => {
|
||||
const rollen = dialog().locator('select')
|
||||
await rollen.nth(0).selectOption('folge')
|
||||
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 → „gelöscht"', async () => {
|
||||
await seite.getByRole('button', { name: 'Roh-Dateien 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').locator('select').first().selectOption('hell')
|
||||
await seite.locator('html[data-design="hell"]').waitFor()
|
||||
})
|
||||
await bild('hell-modus')
|
||||
|
||||
await schritt('zurück in den Kinosaal (dunkel)', async () => {
|
||||
await seite.locator('#allgemein').locator('select').first().selectOption('dunkel')
|
||||
await seite.locator('html[data-design="dunkel"]').waitFor()
|
||||
})
|
||||
|
||||
await schritt('Ereignisse nennen die Kompression', async () => {
|
||||
await seite.getByRole('button', { name: 'Übersicht' }).click()
|
||||
await seite.getByText(/Kompression „/).first().waitFor()
|
||||
})
|
||||
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, Sprachen, Folgen, Platz, Rip, Warteschlange, Kompression, Arbeitsordner, Ablage, Verlauf, Bilanz, Roh-Dateien, Einstellungen, Vorwahl, anderer Film, Vorgangs-Leiste, Restzeit, Hell-Modus, Erst-Einrichtung: alles geklickt.')
|
||||
await app.close().catch(() => {})
|
||||
process.exit(0)
|
||||
@@ -0,0 +1,98 @@
|
||||
// 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('{')
|
||||
out('"AudioList": [')
|
||||
out('{ "Description": "Japanese (AAC LC, 2.0 ch)", "Language": "Japanese", "LanguageCode": "jpn", "TrackNumber": 1 },')
|
||||
out('{ "Description": "Deutsch (AAC LC, 2.0 ch)", "Language": "Deutsch", "LanguageCode": "deu", "TrackNumber": 2 }')
|
||||
out('],')
|
||||
out('"Duration": { "Hours": 0, "Minutes": 52, "Seconds": 47, "Ticks": 542070 },')
|
||||
out('"Index": 1,')
|
||||
out(`"Name": ${JSON.stringify(eingabe)},`)
|
||||
out('"SubtitleList": [')
|
||||
out('{ "Format": "bitmap", "Language": "Deutsch", "LanguageCode": "deu", "SourceName": "PGS", "TrackNumber": 1 },')
|
||||
out('{ "Format": "bitmap", "Language": "English", "LanguageCode": "eng", "SourceName": "PGS", "TrackNumber": 2 }')
|
||||
out(']')
|
||||
out('}')
|
||||
out(']')
|
||||
out('}')
|
||||
out('HandBrake has exited.')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const eingabe = wert('--input') ?? wert('-i')
|
||||
const ausgabe = wert('--output') ?? wert('-o')
|
||||
if (eingabe === undefined || ausgabe === undefined) {
|
||||
out('Missing input or output file name. Run HandBrakeCLI --help for syntax.')
|
||||
process.exit(1)
|
||||
}
|
||||
out('[12:00:00] hb_init: starting libhb thread')
|
||||
out('Scanning title 1 of 1, preview 10, 100.00 %')
|
||||
let schritt = 0
|
||||
const stufen = [5, 30, 55, 80, 100]
|
||||
const tick = () => {
|
||||
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)
|
||||
@@ -0,0 +1,79 @@
|
||||
// Nachgebautes makemkvcon für den Klick-Beweis (bau/klick-smoke.mjs).
|
||||
// Spricht den Robot-Modus so, wie der Parser (kern/rip/parser.ts) ihn an
|
||||
// echten Discs gemessen hat: TINFO (8 Kapitel, 9 Dauer, 11 Bytes), SINFO
|
||||
// (1 Typ, 3 Sprachcode, 4 Sprachname), PRGV (aktuell,gesamt,max), MSG.
|
||||
//
|
||||
// info dev:X: → drei Titel: zwei Folgen (52:47 / 54:09, jpn+deu
|
||||
// Ton, deu+eng Untertitel) und ein Logo-Schnipsel
|
||||
// mkv dev:X: <nr> <ziel> → Fortschritt in Stufen, dann title_t0N.mkv
|
||||
//
|
||||
// Läuft über die eigene Node-Laufzeit (kern/werkzeuge/aufruf.ts) — kein EXE.
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const befehl = args.find((a) => a === 'info' || a === 'mkv')
|
||||
const out = (zeile) => process.stdout.write(zeile + '\n')
|
||||
|
||||
out('MSG:1005,0,1,"MakeMKV v1.18.4 win(x86-release) gestartet","%1 gestartet","MakeMKV v1.18.4 win(x86-release)"')
|
||||
|
||||
const TITEL = [
|
||||
{ nr: 0, dauer: '0:52:47', bytes: 16283000000, kapitel: 7 },
|
||||
{ nr: 1, dauer: '0:54:09', bytes: 16670000000, kapitel: 7 },
|
||||
{ nr: 2, dauer: '0:00:35', bytes: 20000000, kapitel: 1 },
|
||||
]
|
||||
|
||||
if (befehl === 'info') {
|
||||
out(`TCOUNT:${TITEL.length}`)
|
||||
for (const t of TITEL) {
|
||||
out(`TINFO:${t.nr},8,0,"${t.kapitel}"`)
|
||||
out(`TINFO:${t.nr},9,0,"${t.dauer}"`)
|
||||
out(`TINFO:${t.nr},11,0,"${t.bytes}"`)
|
||||
out(`SINFO:${t.nr},0,1,6201,"Video"`)
|
||||
if (t.nr < 2) {
|
||||
out(`SINFO:${t.nr},1,1,6202,"Audio"`)
|
||||
out(`SINFO:${t.nr},1,3,0,"jpn"`)
|
||||
out(`SINFO:${t.nr},1,4,0,"Japanese"`)
|
||||
out(`SINFO:${t.nr},2,1,6202,"Audio"`)
|
||||
out(`SINFO:${t.nr},2,3,0,"deu"`)
|
||||
out(`SINFO:${t.nr},2,4,0,"German"`)
|
||||
out(`SINFO:${t.nr},3,1,6203,"Subtitles"`)
|
||||
out(`SINFO:${t.nr},3,3,0,"deu"`)
|
||||
out(`SINFO:${t.nr},3,4,0,"German"`)
|
||||
out(`SINFO:${t.nr},4,1,6203,"Subtitles"`)
|
||||
out(`SINFO:${t.nr},4,3,0,"eng"`)
|
||||
out(`SINFO:${t.nr},4,4,0,"English"`)
|
||||
}
|
||||
}
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
if (befehl === 'mkv') {
|
||||
const i = args.indexOf('mkv')
|
||||
const nr = Number(args[i + 2])
|
||||
const ziel = args[i + 3]
|
||||
if (!Number.isInteger(nr) || typeof ziel !== 'string') {
|
||||
out('MSG:5010,0,0,"Falscher Aufruf"')
|
||||
process.exit(1)
|
||||
}
|
||||
let schritt = 0
|
||||
const stufen = [10, 35, 60, 85, 100]
|
||||
const tick = () => {
|
||||
out(`PRGV:${stufen[schritt]},${stufen[schritt]},100`)
|
||||
schritt += 1
|
||||
if (schritt < stufen.length) {
|
||||
setTimeout(tick, 250)
|
||||
return
|
||||
}
|
||||
fs.mkdirSync(ziel, { recursive: true })
|
||||
const datei = path.join(ziel, `title_t${String(nr).padStart(2, '0')}.mkv`)
|
||||
// 1 MB „Roh-Material" — groß genug, dass Größen sichtbar sind.
|
||||
fs.writeFileSync(datei, Buffer.alloc(1024 * 1024, nr + 1))
|
||||
out(`MSG:5003,0,1,"1 Titel gespeichert","%1 Titel gespeichert","1"`)
|
||||
process.exit(0)
|
||||
}
|
||||
setTimeout(tick, 200)
|
||||
} else {
|
||||
out('MSG:5010,0,0,"Unbekannter Befehl"')
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
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.
|
||||
@@ -25,8 +25,11 @@ function cspNurImBau(): Plugin {
|
||||
return html.replace(
|
||||
'<head>',
|
||||
// img-src erlaubt TMDb-Poster (Kachel + Ändern-Dialog) — sonst
|
||||
// bleibt alles bei 'self'.
|
||||
`<head>\n <meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src 'self' data: https://image.tmdb.org" />`,
|
||||
// bleibt alles bei 'self'. font-src data: (5.4.0, im Klick-Beweis
|
||||
// gesehen): Vite bettet kleine Schrift-Teile der fontsource-Pakete
|
||||
// als data:-URI ein, und die blockte die CSP — als Fehler in der
|
||||
// Konsole, unsichtbar im Fenster.
|
||||
`<head>\n <meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src 'self' data: https://image.tmdb.org; font-src 'self' data:" />`,
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "rippy",
|
||||
"version": "5.0.0-w0",
|
||||
"version": "5.6.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "rippy",
|
||||
"version": "5.0.0-w0",
|
||||
"version": "5.6.0",
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"electron-updater": "^6.8.9",
|
||||
@@ -24,6 +24,7 @@
|
||||
"electron": "44.0.0",
|
||||
"electron-builder": "^26.15.3",
|
||||
"electron-vite": "^5.0.0",
|
||||
"playwright": "^1.62.1",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"tailwindcss": "^4.3.3",
|
||||
@@ -2229,6 +2230,72 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
|
||||
"version": "1.11.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.2",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.2",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.4",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@tybys/wasm-util": "^0.10.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emnapi/core": "^1.7.1",
|
||||
"@emnapi/runtime": "^1.7.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.2",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
|
||||
@@ -5546,6 +5613,53 @@
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
|
||||
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.62.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
|
||||
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/plist": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "rippy",
|
||||
"productName": "Rippy",
|
||||
"version": "5.1.0",
|
||||
"version": "5.6.0",
|
||||
"description": "Rippy v5 — Disc-Ripping als eigenständiges Windows-Programm (KONZEPT-WINDOWS.md)",
|
||||
"main": "out/haupt/index.js",
|
||||
"author": "KrBrZ",
|
||||
@@ -13,6 +13,7 @@
|
||||
"build": "npm run typecheck && electron-vite build",
|
||||
"test": "vitest run",
|
||||
"smoke": "electron-vite build && electron . --smoke",
|
||||
"klick": "electron-vite build && node bau/klick-smoke.mjs",
|
||||
"paket": "npm run build && electron-builder --win --config electron-builder.yml",
|
||||
"werkzeug:electron": "node node_modules/electron/install.js"
|
||||
},
|
||||
@@ -32,6 +33,7 @@
|
||||
"electron": "44.0.0",
|
||||
"electron-builder": "^26.15.3",
|
||||
"electron-vite": "^5.0.0",
|
||||
"playwright": "^1.62.1",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"tailwindcss": "^4.3.3",
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
// Kontextgrenze — contextBridge kann keine Ports übertragen)
|
||||
// 2. den Haupt-Status (Kanal 'haupt-status')
|
||||
// 3. rippy.melden(art) für kurze Rückmeldungen ans Haupt
|
||||
// … plus die Windows-Dialoge und Shell-Wege, die nur das Haupt öffnen kann
|
||||
// (Ordner/Datei wählen, Ordner im Explorer zeigen, Protokoll öffnen).
|
||||
//
|
||||
// Beides wird GEPUFFERT: Das Haupt schickt Port und Status direkt nach
|
||||
// did-finish-load — ob die React-Seite ihre Horcher da schon registriert
|
||||
@@ -52,8 +54,33 @@ contextBridge.exposeInMainWorld('rippy', {
|
||||
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 }>
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,473 @@
|
||||
// Die Bausteine der Oberfläche (5.5.0, Separation of Concerns): alles,
|
||||
// was mehr als ein Bereich braucht — Formatierer, Symbole, Badge, Kacheln,
|
||||
// Segment-Anzeige, Schalter, Felder, Karten. Der Kino-Mix (Design-Entscheid
|
||||
// des Commanders, 31.08.2026) lebt in den Tokens von stil.css; hier sind
|
||||
// nur die Klassen, die ihn tragen.
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { DiscInfoStand, GeraeteInfo, RipStatus, SprachEintrag } from '../gemeinsam/nachrichten'
|
||||
|
||||
// ── Formatierer ─────────────────────────────────────────────────────────
|
||||
export function gb(bytes: number): string {
|
||||
return `${(bytes / 1e9).toFixed(2).replace('.', ',')} GB`
|
||||
}
|
||||
|
||||
/** Größen mit passender Einheit: 412 GB, 2,2 TB. */
|
||||
export function gbKurz(bytes: number): string {
|
||||
if (bytes >= 1e12) return `${(bytes / 1e12).toFixed(1).replace('.', ',')} TB`
|
||||
if (bytes >= 1e9) return `${Math.round(bytes / 1e9)} GB`
|
||||
return `${(bytes / 1e6).toFixed(0)} MB`
|
||||
}
|
||||
|
||||
export function dauerText(s: number): string {
|
||||
const m = Math.floor(s / 60)
|
||||
return m >= 60
|
||||
? `${Math.floor(m / 60)}:${String(m % 60).padStart(2, '0')} h`
|
||||
: `${m}:${String(Math.round(s % 60)).padStart(2, '0')} min`
|
||||
}
|
||||
|
||||
/** „1 h 42 min" für Statistik und Verlauf. */
|
||||
export function dauerGrob(s: number): string {
|
||||
const m = Math.round(s / 60)
|
||||
if (m < 60) return `${m} min`
|
||||
return `${Math.floor(m / 60)} h ${String(m % 60).padStart(2, '0')} min`
|
||||
}
|
||||
|
||||
export function datumKurz(iso: string | null): string {
|
||||
if (iso === null || iso.length < 16) return '—'
|
||||
return iso.slice(0, 16).replace('T', ' ')
|
||||
}
|
||||
|
||||
/** 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])
|
||||
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={() => {
|
||||
if (entwurf !== wert) aufAendern(entwurf)
|
||||
}}
|
||||
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)
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-6 backdrop-blur-sm" {...hintergrund}>
|
||||
<div
|
||||
className={`max-h-[85vh] w-full ${breite} overflow-y-auto rounded-2xl border border-tinte/12 bg-buehne p-5 shadow-2xl shadow-black/60`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// § 5 Schritt 4: „nein → fragen" — Suche + Auswahl der richtigen Metadaten.
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { MetaVorschlag } from '../../gemeinsam/nachrichten'
|
||||
import { DialogRahmen, IconDisc, posterUrlKlein } from '../bausteine'
|
||||
import { metadatenSuchen, zuordnungSetzen } from '../kernverbindung'
|
||||
|
||||
export function AendernDialog({
|
||||
id,
|
||||
startText,
|
||||
vorschlaege,
|
||||
schliessen,
|
||||
}: {
|
||||
id: string
|
||||
startText: string
|
||||
vorschlaege: MetaVorschlag[]
|
||||
schliessen: () => void
|
||||
}) {
|
||||
const [text, setText] = useState(startText)
|
||||
useEffect(() => {
|
||||
metadatenSuchen(id, startText)
|
||||
// Nur beim Öffnen — weitere Suchen löst der Knopf aus.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
// ── 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.
|
||||
import { useState } from 'react'
|
||||
import { sichererName, zielVorschau } from '../../gemeinsam/ablagepfad'
|
||||
import { platzUrteil } from '../../gemeinsam/platz'
|
||||
import type {
|
||||
DiscInfoStand,
|
||||
DiscTitelVorschlag,
|
||||
GeraeteInfo,
|
||||
MetaVorschlag,
|
||||
SpeicherStand,
|
||||
SprachEintrag,
|
||||
TitelRolle,
|
||||
TitelStand,
|
||||
} from '../../gemeinsam/nachrichten'
|
||||
import { DialogRahmen, IconDisc, KNOPF_GOLD, dauerText, gb, gbKurz, posterUrlKlein, sprachenVorbelegt } from '../bausteine'
|
||||
import { metadatenSuchen, ripStarten } from '../kernverbindung'
|
||||
|
||||
/** Sprach-Chips eines Titels — anklickbar, außerhalb des Auswahl-Labels
|
||||
* (ein Knopf IM Label würde die Checkbox mit umschalten). */
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
/** 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.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 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 ? (
|
||||
<p className="mt-4 font-mono text-xs text-nebel">
|
||||
Rippy liest die Titel der Disc … das dauert bis zu zwei Minuten. Solange dreht das Laufwerk.
|
||||
</p>
|
||||
) : 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'
|
||||
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>
|
||||
{/* Sprachen je Titel (5.4.0): Was der Info-Lauf an DIESEM
|
||||
Titel gemessen hat — anhaken, was in die Datei soll.
|
||||
Der Commander-Fall: japanischer Ton, deutsche Untertitel. */}
|
||||
{an && (t.sprachen.audio.length > 0 || t.sprachen.untertitel.length > 0) && (
|
||||
<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} (${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={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)
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
// ── Erst-Einrichtung (§ 6.9): nach der Installation, im Programm ────────
|
||||
// Zwei Regeln aus dem alten Setup: NUR ein Fehler blockiert (kein
|
||||
// Laufwerk ist eine WARNUNG — eine reine Komprimier-Maschine ist ein
|
||||
// vorgesehener Betriebsfall), und Erfolg wird am BESTAND gemessen.
|
||||
// 5.5.0: dieselben Bausteine wie die Einstellungen (Commander: „All diese
|
||||
// Dinge müssen auch in den FirstRun mit rein") — Ablage und Arbeitsordner,
|
||||
// Werkzeuge mit Durchsuchen, Kompression mit Sprach-Auswahl, TMDb,
|
||||
// Benachrichtigung, Automatik und Roh-Regel. Dazu der Poster-Hintergrund.
|
||||
import { useEffect, useState } from 'react'
|
||||
import { DEFAULT_HB_PRESET, presetEmpfehlung } from '../../gemeinsam/preset-empfehlung'
|
||||
import { Badge, FELD_KLASSE, Feld, KNOPF_GOLD, KNOPF_LEISE, PfadFeld, Schalter, gbKurz } from '../bausteine'
|
||||
import { SprachAuswahl } from '../einstellungen/SprachAuswahl'
|
||||
import { benachrichtigungTesten, einstellungSetzen, posterLaden, speicherLaden, type Verbindung } from '../kernverbindung'
|
||||
import { PosterHintergrund } from './PosterHintergrund'
|
||||
|
||||
const SCHRITTE = ['Willkommen', 'Prüfung', 'Ordner', 'Qualität', 'Titel', 'Meldungen', 'Automatik', 'Fertig']
|
||||
|
||||
export function ErstEinrichtung({ verbindung }: { verbindung: Verbindung }) {
|
||||
const [schritt, setSchritt] = useState(0)
|
||||
const werte = verbindung.einstellungen
|
||||
const werkzeuge = verbindung.werkzeuge
|
||||
const speicher = verbindung.speicher
|
||||
const setzen = einstellungSetzen
|
||||
const makemkvFehlt = werkzeuge !== null && werkzeuge.makemkvcon.length === 0
|
||||
const tmdbDa = (werte['tmdbApiKey'] ?? '').length > 0
|
||||
|
||||
// Poster, sobald ein TMDb-Schlüssel da ist — auch, wenn er gerade erst
|
||||
// eingetragen wurde.
|
||||
useEffect(() => {
|
||||
if (tmdbDa) posterLaden()
|
||||
}, [tmdbDa])
|
||||
useEffect(() => {
|
||||
speicherLaden()
|
||||
}, [schritt])
|
||||
|
||||
const pruefzeilen: Array<{ ok: boolean | null; pflicht: boolean; text: string }> = [
|
||||
{
|
||||
ok: werkzeuge === null ? null : werkzeuge.makemkvcon.length > 0,
|
||||
pflicht: true,
|
||||
text:
|
||||
werkzeuge === null
|
||||
? 'MakeMKV wird gesucht …'
|
||||
: werkzeuge.makemkvcon.length > 0
|
||||
? `MakeMKV gefunden: ${werkzeuge.makemkvcon}`
|
||||
: 'MakeMKV FEHLT — ohne makemkvcon kann Rippy keine Video-Disc lesen. Von makemkv.com/download installieren, dann findet Rippy es von selbst — oder unten den Pfad wählen.',
|
||||
},
|
||||
{
|
||||
ok: werkzeuge === null ? null : werkzeuge.handbrake.length > 0,
|
||||
pflicht: false,
|
||||
text: werkzeuge === null ? 'HandBrakeCLI wird gesucht …' : werkzeuge.handbrake.length > 0 ? `HandBrakeCLI dabei: ${werkzeuge.handbrake}` : 'HandBrakeCLI fehlt — Rippen geht, Komprimieren nicht.',
|
||||
},
|
||||
{
|
||||
ok: werkzeuge === null ? null : werkzeuge.flac.length > 0,
|
||||
pflicht: false,
|
||||
text: werkzeuge === null ? 'FLAC wird gesucht …' : werkzeuge.flac.length > 0 ? `FLAC dabei: ${werkzeuge.flac}` : 'FLAC fehlt — nur für Audio-CDs nötig.',
|
||||
},
|
||||
{
|
||||
ok: verbindung.laufwerke.length > 0 ? true : null,
|
||||
pflicht: false,
|
||||
text:
|
||||
verbindung.laufwerke.length > 0
|
||||
? `${verbindung.laufwerke.length} optisches Laufwerk gefunden (${verbindung.laufwerke.map((l) => l.id + ':').join(', ')})`
|
||||
: 'Kein optisches Laufwerk — nur eine Warnung: Eine reine Komprimier-Maschine ist ein vorgesehener Betriebsfall.',
|
||||
},
|
||||
]
|
||||
|
||||
// Was Rippy an DIESER Maschine gemessen hat — die Grundlage der
|
||||
// Preset-Empfehlung (Commander-Fund 01.09.2026: sie war unsichtbar).
|
||||
const backends = werkzeuge?.backends ?? []
|
||||
const presets = werkzeuge?.presets ?? []
|
||||
const nurCpu = backends.length > 0 && backends.every((b) => b.startsWith('cpu'))
|
||||
const EMPFEHLUNGEN: ReadonlyArray<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">{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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// Der Poster-Hintergrund der Erst-Einrichtung (5.5.0, Commander: „diverse
|
||||
// Movie Cover die … im Hintergrund in nem Grid langsam von oben nach unten
|
||||
// laufen"). Vor dem TMDb-Schlüssel ein ruhiges Raster leerer Film-Karten,
|
||||
// danach echte Poster beliebter Filme (kern: erkennung.posterLaden). Die
|
||||
// Spalten laufen langsam, jede in eigenem Tempo, die Liste ist verdoppelt,
|
||||
// damit der Lauf nahtlos ist (Keyframes in stil.css: poster-lauf).
|
||||
import { IconDisc, posterUrlGross } from '../bausteine'
|
||||
|
||||
const SPALTEN = 7
|
||||
const JE_SPALTE = 6
|
||||
|
||||
export function PosterHintergrund({ pfade }: { pfade: string[] }) {
|
||||
const spalten: string[][] = Array.from({ length: SPALTEN }, () => [])
|
||||
pfade.forEach((p, i) => spalten[i % SPALTEN].push(p))
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,664 @@
|
||||
// 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 { 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,
|
||||
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 => 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.
|
||||
</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)
|
||||
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 && (
|
||||
<button onClick={() => vorgangRohLoeschen(e.id)} className="rounded-lg border border-rot/40 bg-rot/10 px-3 py-1 text-[11px] text-rot hover:bg-rot/20" title="Den Roh-Ordner dieses Vorgangs jetzt löschen">
|
||||
Roh-Dateien löschen
|
||||
</button>
|
||||
)}
|
||||
{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>
|
||||
<button onClick={() => fremdenOrdnerLoeschen(f.ordner)} className="flex-none rounded-lg border border-rot/40 bg-rot/10 px-2.5 py-0.5 text-[10.5px] text-rot hover:bg-rot/20">
|
||||
Löschen
|
||||
</button>
|
||||
</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>
|
||||
) : (
|
||||
<>
|
||||
<p className="flex items-center gap-2 font-mono text-[10.5px] tracking-wider">
|
||||
<span className={`inline-block h-2 w-2 rounded-full ${s.angenommen === true ? 'bg-gruen' : s.weiterBetrieb ? 'bg-warn' : 'bg-rot'}`} />
|
||||
<span className={s.angenommen === true ? 'text-gruen' : s.weiterBetrieb ? 'text-warn' : 'text-rot'}>
|
||||
{s.angenommen === true ? 'SCHLÜSSEL ANGENOMMEN' : s.weiterBetrieb ? 'BETA-MODUS — RIPS LAUFEN' : 'AUSFALL'}
|
||||
</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: {s.geholtAm.slice(0, 16).replace('T', ' ')}
|
||||
{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.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"
|
||||
geheim
|
||||
wert={werte['makemkvAppKey'] ?? ''}
|
||||
aufAendern={(w) => setzen('makemkvAppKey', w)}
|
||||
/>
|
||||
</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">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: {haupt.update.letzteErfolgreichePruefung.slice(0, 16).replace('T', ' ')}</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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// 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>
|
||||
)
|
||||
}
|
||||
@@ -8,7 +8,13 @@ declare global {
|
||||
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>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,13 +8,20 @@
|
||||
// Laufwerksliste: Sie ändert sich NUR durch eine neue Liste vom Kern.
|
||||
import {
|
||||
istKernNachricht,
|
||||
type AutomatikStand,
|
||||
type BeschaffungStand,
|
||||
type BibliothekEintrag,
|
||||
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'
|
||||
|
||||
@@ -30,6 +37,8 @@ export interface Verbindung {
|
||||
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
|
||||
@@ -38,7 +47,6 @@ export interface Verbindung {
|
||||
ripProtokoll: Record<string, string[]>
|
||||
einstellungen: Record<string, string>
|
||||
werkzeuge: WerkzeugAuskunft | null
|
||||
bibliothek: BibliothekEintrag[]
|
||||
schluessel: {
|
||||
quelle: string
|
||||
angenommen: boolean | null
|
||||
@@ -50,50 +58,72 @@ export interface Verbindung {
|
||||
} | null
|
||||
/** Stand der MakeMKV-Beschaffung (§ 9) — null, solange nie eine lief. */
|
||||
beschaffung: BeschaffungStand | null
|
||||
/** Vorgänge, Roh-Belegung und Kompressions-Warteschlange (5.4.0) — null,
|
||||
* solange der Kern noch nichts geschickt hat. */
|
||||
vorgaenge: VorgaengeStand | null
|
||||
/** Was das Windows-Protokoll je Laufwerk sagt (5.4.0) — bleibt stehen. */
|
||||
gesundheit: Record<string, LaufwerkBericht>
|
||||
/** 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 = 20
|
||||
const MAX_MELDUNGEN = 30
|
||||
|
||||
const stand: Verbindung = {
|
||||
export const LEERE_VERBINDUNG: Verbindung = {
|
||||
verbunden: false,
|
||||
kern: null,
|
||||
pongLatenzMs: null,
|
||||
laufwerke: [],
|
||||
rips: {},
|
||||
discInfos: {},
|
||||
discTitel: {},
|
||||
vorschlaege: {},
|
||||
ripStart: {},
|
||||
ripProtokoll: {},
|
||||
einstellungen: {},
|
||||
werkzeuge: null,
|
||||
bibliothek: [],
|
||||
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 melden(): void {
|
||||
for (const h of horcher) {
|
||||
h({
|
||||
...stand,
|
||||
laufwerke: [...stand.laufwerke],
|
||||
rips: { ...stand.rips },
|
||||
discInfos: { ...stand.discInfos },
|
||||
vorschlaege: { ...stand.vorschlaege },
|
||||
einstellungen: { ...stand.einstellungen },
|
||||
bibliothek: [...stand.bibliothek],
|
||||
meldungen: [...stand.meldungen],
|
||||
})
|
||||
function kopie(): Verbindung {
|
||||
return {
|
||||
...stand,
|
||||
laufwerke: [...stand.laufwerke],
|
||||
rips: { ...stand.rips },
|
||||
discInfos: { ...stand.discInfos },
|
||||
vorschlaege: { ...stand.vorschlaege },
|
||||
einstellungen: { ...stand.einstellungen },
|
||||
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)
|
||||
}
|
||||
@@ -111,6 +141,9 @@ function kernNachricht(nachricht: KernNachricht): void {
|
||||
case 'kern-fehler':
|
||||
meldung(`⚠ ${nachricht.text}`)
|
||||
break
|
||||
case 'kern-hinweis':
|
||||
meldung(nachricht.text)
|
||||
break
|
||||
case 'laufwerke':
|
||||
stand.laufwerke = nachricht.liste
|
||||
for (const ereignis of nachricht.ereignisse) {
|
||||
@@ -118,6 +151,8 @@ function kernNachricht(nachricht: KernNachricht): void {
|
||||
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`)
|
||||
}
|
||||
@@ -132,14 +167,27 @@ function kernNachricht(nachricht: KernNachricht): void {
|
||||
break
|
||||
}
|
||||
case 'disc-info': {
|
||||
const vorher = stand.discInfos[nachricht.info.id]
|
||||
stand.discInfos = { ...stand.discInfos, [nachricht.info.id]: nachricht.info }
|
||||
if (nachricht.info.titel.length > 0) {
|
||||
meldung(
|
||||
`Laufwerk ${nachricht.info.id}: erkannt als „${nachricht.info.titel}${nachricht.info.jahr !== null ? ` (${nachricht.info.jahr})` : ''}“ · ${Math.round(nachricht.info.confidence * 100)} %`,
|
||||
)
|
||||
// Dieselbe Erkennung nicht zweimal melden — die Nachprüfung nach
|
||||
// dem Info-Lauf schickt den Stand erneut, mit Hinweisen.
|
||||
const neuErkannt = vorher === undefined || vorher.titel !== nachricht.info.titel
|
||||
if (neuErkannt) {
|
||||
meldung(
|
||||
`Laufwerk ${nachricht.info.id}: erkannt als „${nachricht.info.titel}${nachricht.info.jahr !== null ? ` (${nachricht.info.jahr})` : ''}“ · ${Math.round(nachricht.info.confidence * 100)} %`,
|
||||
)
|
||||
}
|
||||
for (const h of nachricht.info.hinweise) {
|
||||
if (vorher === undefined || !vorher.hinweise.includes(h)) meldung(`Laufwerk ${nachricht.info.id}: ${h}`)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
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
|
||||
@@ -149,9 +197,6 @@ function kernNachricht(nachricht: KernNachricht): void {
|
||||
case 'werkzeuge':
|
||||
stand.werkzeuge = nachricht.auskunft
|
||||
break
|
||||
case 'bibliothek':
|
||||
stand.bibliothek = nachricht.eintraege
|
||||
break
|
||||
case 'schluessel-status':
|
||||
stand.schluessel = nachricht.stand
|
||||
if (nachricht.stand.angenommen === false) meldung(`⚠ MakeMKV-Schlüssel: ${nachricht.stand.text}`)
|
||||
@@ -160,9 +205,30 @@ function kernNachricht(nachricht: KernNachricht): void {
|
||||
stand.beschaffung = nachricht.stand
|
||||
if (nachricht.stand.fehler) meldung(`⚠ MakeMKV-Update: ${nachricht.stand.text}`)
|
||||
break
|
||||
case 'vorgaenge':
|
||||
stand.vorgaenge = nachricht.stand
|
||||
break
|
||||
case 'kompression-ergebnis':
|
||||
meldung(`${nachricht.ok ? '' : '⚠ '}Kompression „${nachricht.titel}": ${nachricht.text}`)
|
||||
break
|
||||
case 'laufwerk-gesundheit':
|
||||
stand.gesundheit = { ...stand.gesundheit, [nachricht.id]: nachricht.bericht }
|
||||
if (nachricht.bericht.urteil === 'hardware' || nachricht.bericht.urteil === 'disc') {
|
||||
meldung(`Laufwerk ${nachricht.id}: ${nachricht.bericht.text}`)
|
||||
}
|
||||
break
|
||||
case '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', 'rippt', 'komprimiert', 'legt-ab'].includes(nachricht.status.phase)
|
||||
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.
|
||||
@@ -192,6 +258,9 @@ function kernNachricht(nachricht: KernNachricht): void {
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'installer-starten':
|
||||
// Nur fürs Haupt gedacht — im Fenster nichts zu tun.
|
||||
break
|
||||
}
|
||||
melden()
|
||||
}
|
||||
@@ -204,10 +273,11 @@ function portAnnehmen(neuerPort: MessagePort): void {
|
||||
if (istKernNachricht(nachricht)) kernNachricht(nachricht)
|
||||
}
|
||||
neuerPort.start()
|
||||
neuerPort.postMessage({ art: 'ping', zeit: Date.now() })
|
||||
neuerPort.postMessage({ art: 'einstellungen-laden' })
|
||||
neuerPort.postMessage({ art: 'werkzeuge-laden' })
|
||||
neuerPort.postMessage({ art: 'bibliothek-laden' })
|
||||
senden({ art: 'ping', zeit: Date.now() })
|
||||
senden({ art: 'einstellungen-laden' })
|
||||
senden({ art: 'werkzeuge-laden' })
|
||||
senden({ art: 'vorgaenge-laden' })
|
||||
senden({ art: 'speicher-laden' })
|
||||
melden()
|
||||
}
|
||||
|
||||
@@ -232,20 +302,16 @@ export function verbindungStarten(): void {
|
||||
|
||||
export function aufVerbindung(h: Horcher): () => void {
|
||||
horcher.add(h)
|
||||
h({
|
||||
...stand,
|
||||
laufwerke: [...stand.laufwerke],
|
||||
rips: { ...stand.rips },
|
||||
discInfos: { ...stand.discInfos },
|
||||
vorschlaege: { ...stand.vorschlaege },
|
||||
einstellungen: { ...stand.einstellungen },
|
||||
bibliothek: [...stand.bibliothek],
|
||||
meldungen: [...stand.meldungen],
|
||||
})
|
||||
h(kopie())
|
||||
return () => horcher.delete(h)
|
||||
}
|
||||
|
||||
function anKern(nachricht: { art: string; id: string }, wasWar: string): void {
|
||||
function senden(nachricht: FensterNachricht): void {
|
||||
port?.postMessage(nachricht)
|
||||
}
|
||||
|
||||
/** Schickt eine Nachricht — und sagt es, wenn kein Kern da ist (R4). */
|
||||
function anKern(nachricht: FensterNachricht, wasWar: string): void {
|
||||
if (port === null) {
|
||||
meldung(`⚠ ${wasWar}: keine Verbindung zum Kern`)
|
||||
melden()
|
||||
@@ -258,43 +324,99 @@ export function auswerfen(id: string): void {
|
||||
anKern({ art: 'auswerfen', id }, `Auswerfen ${id}`)
|
||||
}
|
||||
|
||||
export function ripStarten(id: string): void {
|
||||
anKern({ art: 'rip-start', id }, `Rip ${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}`)
|
||||
}
|
||||
|
||||
/** `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 {
|
||||
if (port === null) return
|
||||
port.postMessage({ art: 'metadaten-suchen', id, text })
|
||||
senden({ art: 'metadaten-suchen', id, text })
|
||||
}
|
||||
|
||||
export function zuordnungSetzen(id: string, vorschlag: MetaVorschlag): void {
|
||||
if (port === null) return
|
||||
port.postMessage({ art: 'zuordnung-setzen', id, vorschlag })
|
||||
senden({ art: 'zuordnung-setzen', id, vorschlag })
|
||||
}
|
||||
|
||||
export function makemkvUpdate(): void {
|
||||
if (port === null) {
|
||||
meldung('⚠ MakeMKV-Update: keine Verbindung zum Kern')
|
||||
melden()
|
||||
return
|
||||
}
|
||||
port.postMessage({ art: 'makemkv-update' })
|
||||
anKern({ art: 'makemkv-update' }, 'MakeMKV-Update')
|
||||
}
|
||||
|
||||
export function schluesselPruefen(): void {
|
||||
if (port === null) return
|
||||
port.postMessage({ art: 'schluessel-pruefen' })
|
||||
senden({ art: 'schluessel-pruefen' })
|
||||
}
|
||||
|
||||
export function einstellungSetzen(schluessel: string, wert: string): void {
|
||||
if (port === null) {
|
||||
meldung(`⚠ Einstellung ${schluessel}: keine Verbindung zum Kern`)
|
||||
melden()
|
||||
return
|
||||
}
|
||||
port.postMessage({ art: 'einstellung-setzen', schluessel, wert })
|
||||
anKern({ art: 'einstellung-setzen', schluessel, wert }, `Einstellung ${schluessel}`)
|
||||
}
|
||||
|
||||
// ── 5.4.0: Vorgänge, Roh-Dateien, Warteschlange, Laufwerks-Gesundheit ──
|
||||
export function vorgaengeLaden(): void {
|
||||
senden({ art: 'vorgaenge-laden' })
|
||||
}
|
||||
|
||||
export function vorgangRohLoeschen(vorgangId: string): void {
|
||||
anKern({ art: 'vorgang-roh-loeschen', vorgangId }, 'Roh-Dateien löschen')
|
||||
}
|
||||
|
||||
export function vorgangNeuKomprimieren(vorgangId: string, preset: string): void {
|
||||
anKern({ art: 'vorgang-neu-komprimieren', vorgangId, preset }, 'Neu komprimieren')
|
||||
}
|
||||
|
||||
export function kompressionAbbrechen(vorgangId: string): void {
|
||||
anKern({ art: 'kompression-abbrechen', vorgangId }, 'Kompression abbrechen')
|
||||
}
|
||||
|
||||
export function rohAufraeumen(): void {
|
||||
anKern({ art: 'roh-aufraeumen' }, 'Roh-Dateien aufräumen')
|
||||
}
|
||||
|
||||
export function fremdenOrdnerLoeschen(ordner: string): void {
|
||||
anKern({ art: 'fremden-ordner-loeschen', ordner }, 'Ordner löschen')
|
||||
}
|
||||
|
||||
export function laufwerkPruefen(id: string): void {
|
||||
anKern({ art: 'laufwerk-pruefen', id }, `Laufwerk ${id} prüfen`)
|
||||
}
|
||||
|
||||
// ── 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' })
|
||||
}
|
||||
|
||||
@@ -31,6 +31,9 @@
|
||||
--color-gruen: oklch(0.78 0.13 150);
|
||||
--color-warn: oklch(0.8 0.12 85);
|
||||
--color-rot: oklch(0.68 0.19 25);
|
||||
|
||||
/* Linien und Flächen mit Alpha: im Dunkeln Weiß, im Hellen Tinte (5.6.0). */
|
||||
--color-tinte: oklch(1 0 0);
|
||||
}
|
||||
|
||||
:root {
|
||||
@@ -59,8 +62,65 @@
|
||||
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: oklch(1 0 0 / 0.12); border-radius: 999px; border: 2px solid transparent; background-clip: content-box; }
|
||||
::-webkit-scrollbar-thumb:hover { background-color: oklch(1 0 0 / 0.2); }
|
||||
::-webkit-scrollbar-thumb { background: color-mix(in oklch, var(--color-tinte) 14%, transparent); border-radius: 999px; border: 2px solid transparent; background-clip: content-box; }
|
||||
::-webkit-scrollbar-thumb:hover { background-color: color-mix(in oklch, var(--color-tinte) 24%, transparent); }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
|
||||
/* Der Poster-Lauf der Erst-Einrichtung (5.5.0): Jede Spalte wandert langsam
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
// 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
// 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, presetEmpfehlung } from '../../gemeinsam/preset-empfehlung'
|
||||
import {
|
||||
Badge,
|
||||
IconDisc,
|
||||
IconEject,
|
||||
IconPlay,
|
||||
IconStift,
|
||||
IconStop,
|
||||
InfoKachel,
|
||||
LAUFENDE_PHASEN,
|
||||
PHASEN_MONO,
|
||||
TYP_NAMEN,
|
||||
backdropUrl,
|
||||
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] ?? ''
|
||||
const empfehlung = presetEmpfehlung(typ, verbindung.werkzeuge?.backends ?? [], verbindung.werkzeuge?.presets ?? [])
|
||||
const gewaehlt = eigene.length > 0 ? eigene : empfehlung.length > 0 ? empfehlung : DEFAULT_HB_PRESET
|
||||
return gewaehlt === 'keine' ? 'ohne Kompression' : gewaehlt
|
||||
}
|
||||
|
||||
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 auswaehlen — 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 ${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 naechsten weitermachen"
|
||||
>
|
||||
Titel ueberspringen
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
// 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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// Die Vorgangs-Leiste (5.6.0) — der ganze Weg einer Disc in Stationen, mit
|
||||
// dem Segment-Balken in der aktiven Station. Logik in
|
||||
// gemeinsam/vorgangsleiste.ts; hier nur das Zeichnen. `kompakt` = Punkte
|
||||
// (Verlauf, Warteschlange).
|
||||
import { Fragment } from 'react'
|
||||
import { STATIONS_NAMEN, stationen, stationsZustand, type LeisteStand } from '../../gemeinsam/vorgangsleiste'
|
||||
import { SegmentBalken } from '../bausteine'
|
||||
|
||||
export function VorgangsLeiste({ stand, restText, kompakt }: { stand: LeisteStand; restText?: string; kompakt?: boolean }) {
|
||||
const liste = stationen(stand.rettung)
|
||||
const zustaende = stationsZustand(liste, stand.station, stand.zustand)
|
||||
const titel = `${STATIONS_NAMEN[stand.station]}: ${
|
||||
stand.zustand === 'laeuft' ? 'läuft' : stand.zustand === 'wartet' ? 'wartet' : stand.zustand === 'fertig' ? 'fertig' : stand.zustand === 'fehler' ? 'Fehler' : 'abgebrochen'
|
||||
}`
|
||||
|
||||
if (kompakt) {
|
||||
return (
|
||||
<span className="inline-flex flex-none items-center gap-1" title={titel} aria-label={titel}>
|
||||
{liste.map((s, i) => (
|
||||
<span
|
||||
key={s}
|
||||
className={`inline-block h-1.5 w-1.5 rounded-full ${
|
||||
zustaende[i] === 'fertig'
|
||||
? 'bg-gold'
|
||||
: zustaende[i] === 'aktiv'
|
||||
? stand.zustand === 'wartet'
|
||||
? 'bg-gold/50'
|
||||
: 'animate-pulse bg-gold-hell'
|
||||
: zustaende[i] === 'fehler'
|
||||
? 'bg-rot'
|
||||
: 'bg-tinte/20'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5" title={titel}>
|
||||
{liste.map((s, i) => {
|
||||
const z = zustaende[i]
|
||||
const aktiv = z === 'aktiv'
|
||||
const rahmen =
|
||||
z === 'fehler'
|
||||
? 'border-rot/50 bg-rot/10'
|
||||
: aktiv
|
||||
? 'min-w-[200px] flex-1 border-gold/50 bg-gold/10'
|
||||
: z === 'fertig'
|
||||
? 'border-gold/30 bg-gold/5'
|
||||
: 'border-tinte/10'
|
||||
return (
|
||||
<Fragment key={s}>
|
||||
{i > 0 && <span className={`h-px w-3 flex-none ${zustaende[i - 1] === 'fertig' ? 'bg-gold/60' : 'bg-tinte/15'}`} />}
|
||||
<div className={`flex items-center gap-2 rounded-lg border px-2 py-1 ${rahmen}`}>
|
||||
<span
|
||||
className={`flex h-4 w-4 flex-none items-center justify-center rounded-full font-mono text-[9px] ${
|
||||
z === 'fertig'
|
||||
? 'bg-gold text-buehne'
|
||||
: aktiv
|
||||
? 'bg-gold-hell text-buehne'
|
||||
: z === 'fehler'
|
||||
? 'bg-rot text-buehne'
|
||||
: 'border border-tinte/20 text-nebel'
|
||||
}`}
|
||||
>
|
||||
{z === 'fertig' ? '✓' : z === 'fehler' ? '!' : String(i + 1)}
|
||||
</span>
|
||||
<span className={`font-mono text-[10px] tracking-[0.12em] ${aktiv ? 'text-gold-hell' : z === 'fertig' ? 'text-gold/80' : z === 'fehler' ? 'text-rot' : 'text-nebel'}`}>
|
||||
{STATIONS_NAMEN[s].toUpperCase()}
|
||||
</span>
|
||||
{aktiv && stand.zustand === 'laeuft' && stand.prozent >= 0 && (
|
||||
<div className="min-w-[80px] flex-1">
|
||||
<SegmentBalken prozent={stand.prozent} />
|
||||
</div>
|
||||
)}
|
||||
{aktiv && stand.zustand === 'laeuft' && stand.prozent >= 0 && <span className="font-mono text-[10.5px] text-gold-hell">{stand.prozent} %</span>}
|
||||
{aktiv && stand.zustand === 'laeuft' && stand.prozent < 0 && stand.hinweis.length === 0 && (
|
||||
<span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-gold-hell" />
|
||||
)}
|
||||
{aktiv && restText !== undefined && restText.length > 0 && <span className="font-mono text-[10px] text-nebel">{restText}</span>}
|
||||
{aktiv && stand.hinweis.length > 0 && <span className="font-mono text-[10px] text-nebel">{stand.hinweis}</span>}
|
||||
{z === 'fehler' && <span className="font-mono text-[10px] text-rot">{stand.zustand === 'abgebrochen' ? 'abgebrochen' : 'Fehler'}</span>}
|
||||
</div>
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// Ablage-Namen und die Zielpfad-Vorschau — PUR, ohne Dateisystem, damit
|
||||
// Kern UND Fenster dieselbe Regel benutzen (5.4.0: „Vorschau, welche Datei
|
||||
// landet wo" im Auswahl-Dialog). Die Kern-Seite (kern/ablage/struktur.ts)
|
||||
// baut auf diesen Namen ihre echten Ordner.
|
||||
|
||||
// Zeichen, die in Ordnernamen auf SMB/NTFS/ext4 Ärger machen.
|
||||
// eslint-Hinweis unnötig: bewusst inklusive Steuerzeichen.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const VERBOTEN = /[<>:"/\\|?*\x00-\x1f]/g
|
||||
|
||||
/** Titel → dateisystem-tauglicher Ordnername (pur). */
|
||||
export function sichererName(titel: string, jahr?: number | string | null): string {
|
||||
let name = (titel ?? '').replace(VERBOTEN, '').trim().replace(/\.+$/, '')
|
||||
name = name.replace(/\s+/g, ' ').slice(0, 150).trim()
|
||||
if (name.length === 0) return ''
|
||||
// Kein doppeltes Jahr: OMDb-Titel tragen es teils schon im Namen.
|
||||
if (jahr !== undefined && jahr !== null && String(jahr).length > 0 && !name.endsWith(`(${jahr})`)) {
|
||||
name = `${name} (${jahr})`
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
export function staffelOrdnerName(staffel: number): string {
|
||||
return `Season ${String(Math.trunc(staffel)).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function folgenDateiname(serie: string, staffel: number, folge: number): string {
|
||||
const s = String(Math.trunc(staffel)).padStart(2, '0')
|
||||
const e = String(Math.trunc(folge)).padStart(2, '0')
|
||||
return `${sichererName(serie) || 'Serie'} S${s}E${e}.mkv`
|
||||
}
|
||||
|
||||
export interface VorschauEingabe {
|
||||
ablage: string
|
||||
alsSerie: boolean
|
||||
titel: string
|
||||
jahr: number | null
|
||||
staffel: number | null
|
||||
ersteFolge: number | null
|
||||
/** Zahl der gewählten Folgen (Serie) bzw. Hauptfilme (Film). */
|
||||
hauptTitel: number
|
||||
extras: number
|
||||
/** Der Vorgangsname — gilt, wenn kein Titel erkannt ist. */
|
||||
vorgangsName: string
|
||||
}
|
||||
|
||||
/** Zwei Zeilen: Zielordner und was darin landet (pur). Windows-Trenner,
|
||||
* weil die Vorschau eine Windows-Ablage beschreibt. */
|
||||
export function zielVorschau(e: VorschauEingabe): [string, string] {
|
||||
const wurzel = e.ablage.replace(/[\\/]+$/, '')
|
||||
const titel = e.titel.trim().length > 0 ? e.titel.trim() : e.vorgangsName
|
||||
if (e.alsSerie) {
|
||||
const staffel = e.staffel ?? 1
|
||||
const ordner = `${wurzel}\\Serien\\${sichererName(titel) || 'Unbekannte Serie'}\\${staffelOrdnerName(staffel)}\\`
|
||||
const n = Math.max(1, e.hauptTitel)
|
||||
let inhalt: string
|
||||
if (e.ersteFolge !== null && e.ersteFolge > 0) {
|
||||
const erste = folgenDateiname(titel, staffel, e.ersteFolge)
|
||||
inhalt =
|
||||
n === 1
|
||||
? erste
|
||||
: `${erste} … ${folgenDateiname(titel, staffel, e.ersteFolge + n - 1)} (${n} Folgen, fortlaufend)`
|
||||
} else {
|
||||
inhalt = `${n} ${n === 1 ? 'Folge' : 'Folgen'} — Nummern aus den TMDb-Laufzeiten, sonst bleiben die MakeMKV-Namen`
|
||||
}
|
||||
if (e.extras > 0) inhalt += ` · extras\\ (${e.extras})`
|
||||
return [ordner, inhalt]
|
||||
}
|
||||
const ordner = `${wurzel}\\Filme\\${sichererName(titel, e.jahr) || e.vorgangsName}\\`
|
||||
const teile: string[] = []
|
||||
if (e.hauptTitel > 0) teile.push(e.hauptTitel === 1 ? 'der Hauptfilm' : `${e.hauptTitel} Hauptfilme`)
|
||||
if (e.extras > 0) teile.push(`extras\\ (${e.extras} ${e.extras === 1 ? 'Zugabe' : 'Zugaben'})`)
|
||||
return [ordner, teile.length > 0 ? teile.join(' · ') : 'nichts gewählt']
|
||||
}
|
||||
@@ -7,6 +7,10 @@
|
||||
// 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 }
|
||||
@@ -41,6 +45,8 @@ 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'
|
||||
@@ -57,6 +63,112 @@ export interface RipStatus {
|
||||
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
|
||||
}
|
||||
|
||||
/** 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.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[] }
|
||||
}
|
||||
|
||||
/** 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). */
|
||||
@@ -75,9 +187,36 @@ export interface DiscInfoStand {
|
||||
/** 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). */
|
||||
/** 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
|
||||
@@ -87,7 +226,7 @@ export interface BibliothekEintrag {
|
||||
ablageort: string
|
||||
datum: string
|
||||
dauerS: number
|
||||
/** TMDb-Posterpfad fürs Bibliotheks-Regal — '' wenn keiner bekannt. */
|
||||
/** TMDb-Posterpfad — '' wenn keiner bekannt. */
|
||||
posterPfad: string
|
||||
}
|
||||
|
||||
@@ -120,19 +259,140 @@ export interface BeschaffungStand {
|
||||
fehler: boolean
|
||||
}
|
||||
|
||||
// ── 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: 'bibliothek'; eintraege: BibliothekEintrag[] }
|
||||
| { 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). */
|
||||
@@ -151,23 +411,71 @@ export type KernNachricht =
|
||||
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 }
|
||||
/** 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 }
|
||||
| { art: 'rip-start'; 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 }
|
||||
/** `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' }
|
||||
| { art: 'bibliothek-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.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 =
|
||||
@@ -182,14 +490,47 @@ export interface HauptStatus {
|
||||
leine: { ok: boolean; fehler?: string }
|
||||
kern: { laeuft: boolean; pid?: number; neustarts: number }
|
||||
autostart: boolean
|
||||
/** Selbst-Update (§ 6.8): letzter Prüf-Erfolg + Klartext-Stand. */
|
||||
update: { letzteErfolgreichePruefung: string; text: string }
|
||||
/** 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) {
|
||||
@@ -202,6 +543,7 @@ export function istKernNachricht(wert: unknown): wert is KernNachricht {
|
||||
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)
|
||||
@@ -220,12 +562,17 @@ export function istKernNachricht(wert: unknown): wert is KernNachricht {
|
||||
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 'bibliothek':
|
||||
return Array.isArray(wert.eintraege)
|
||||
case 'schluessel-status':
|
||||
return istObjekt(wert.stand) && typeof (wert.stand as Record<string, unknown>).text === 'string'
|
||||
case 'beschaffung-status':
|
||||
@@ -239,6 +586,46 @@ export function istKernNachricht(wert: unknown): wert is KernNachricht {
|
||||
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-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
|
||||
}
|
||||
@@ -274,27 +661,80 @@ export function istDbStatus(wert: unknown): wert is DbStatus {
|
||||
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
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
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 === 'auswerfen' || wert.art === 'rip-start' || wert.art === 'rip-abbruch') {
|
||||
return typeof wert.id === 'string' && wert.id.length > 0
|
||||
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 === '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 === 'einstellung-setzen') {
|
||||
return typeof wert.schluessel === 'string' && wert.schluessel.length > 0 && typeof wert.wert === 'string'
|
||||
return istKennung(wert.schluessel) && typeof wert.wert === 'string'
|
||||
}
|
||||
if (wert.art === 'metadaten-suchen') {
|
||||
return typeof wert.id === 'string' && wert.id.length > 0 && typeof wert.text === 'string'
|
||||
return istKennung(wert.id) && typeof wert.text === 'string'
|
||||
}
|
||||
if (wert.art === 'zuordnung-setzen') {
|
||||
return typeof wert.id === 'string' && wert.id.length > 0 && istMetaVorschlag(wert.vorschlag)
|
||||
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 === 'bibliothek-laden' ||
|
||||
wert.art === 'makemkv-update' ||
|
||||
wert.art === 'schluessel-pruefen'
|
||||
wert.art === 'schluessel-pruefen' ||
|
||||
wert.art === 'vorgaenge-laden' ||
|
||||
wert.art === 'roh-aufraeumen' ||
|
||||
wert.art === 'benachrichtigung-testen' ||
|
||||
wert.art === 'poster-laden' ||
|
||||
wert.art === 'speicher-laden'
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// 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(',')
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
// 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,18 +1,40 @@
|
||||
// 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.
|
||||
// „Zustand merken" (Position/Größe) kommt mit den Einstellungen im UI (W-5).
|
||||
import { BrowserWindow } from 'electron'
|
||||
//
|
||||
// 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(): BrowserWindow {
|
||||
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({
|
||||
width: 1000,
|
||||
height: 700,
|
||||
minWidth: 720,
|
||||
minHeight: 480,
|
||||
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).
|
||||
@@ -25,11 +47,37 @@ export function fensterErstellen(): BrowserWindow {
|
||||
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 /
|
||||
// #bibliothek) — der Smoke-Beweis fotografiert damit auch diese Seiten.
|
||||
// #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) {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
// 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.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// 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)}` }
|
||||
}
|
||||
}
|
||||
@@ -13,22 +13,34 @@ 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, updateStand } from './update'
|
||||
import { updateEinrichten, updateInstallierenJetzt, updatePruefenJetzt, updateStand } from './update'
|
||||
|
||||
const smokeModus = process.argv.includes('--smoke')
|
||||
// Der Klick-Beweis (bau/klick-smoke.mjs, 5.4.0): das ECHTE Programm mit
|
||||
// eigenem Profil und nachgebauten Werkzeugen — ohne Tray, Updater und
|
||||
// Toasts, aber mit allem, was klickbar ist. Beendet wird er von außen.
|
||||
const klickSmoke = process.argv.includes('--klick-smoke')
|
||||
const profilArg = process.argv.find((a) => a.startsWith('--profil='))
|
||||
/** Smoke UND Klick-Beweis: keine Nebenwirkungen auf dem Rechner. */
|
||||
const ohneNebenwirkungen = smokeModus || klickSmoke
|
||||
|
||||
// Der Smoke-Lauf fasst das echte Profil nicht an — er hinterlässt nichts.
|
||||
if (smokeModus) {
|
||||
app.setPath('userData', join(app.getPath('temp'), 'rippy-smoke'))
|
||||
} else if (profilArg !== undefined && profilArg.length > '--profil='.length) {
|
||||
app.setPath('userData', profilArg.slice('--profil='.length))
|
||||
}
|
||||
|
||||
// Nur EIN Rippy je Benutzer (§ 4.2: Einzelinstanz-Sperre). Die zweite
|
||||
// Instanz beendet sich; die erste holt ihr Fenster nach vorn.
|
||||
// Instanz beendet sich; die erste holt ihr Fenster nach vorn. Die Sperre
|
||||
// hängt am Profil — ein Beweislauf mit eigenem Profil stört das laufende
|
||||
// Rippy nicht und wird von ihm nicht gestört.
|
||||
if (!app.requestSingleInstanceLock()) {
|
||||
app.exit(0)
|
||||
} else {
|
||||
@@ -36,12 +48,19 @@ if (!app.requestSingleInstanceLock()) {
|
||||
}
|
||||
|
||||
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
|
||||
@@ -69,6 +88,7 @@ function start(): void {
|
||||
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()
|
||||
@@ -77,17 +97,33 @@ function start(): void {
|
||||
} else if (nachricht.art === 'einstellungen') {
|
||||
// Einmalig das Selbst-Update einrichten — die Adresse kommt aus
|
||||
// der EINEN Einstellungs-Quelle im Kern (§ 6.7/§ 6.8).
|
||||
if (!updateGestartet && !smokeModus) {
|
||||
if (!updateGestartet && !ohneNebenwirkungen) {
|
||||
updateGestartet = true
|
||||
updateEinrichten(
|
||||
nachricht.werte['updateUrl'] ?? '',
|
||||
() => aktiveVorgaenge.size > 0,
|
||||
(text) => toast('Rippy-Update', text),
|
||||
(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 === 'disc-info' && nachricht.info.titel.length > 0 && !smokeModus) {
|
||||
} else if (nachricht.art === 'vorgaenge') {
|
||||
// 5.4.0: Die Kompression läuft OHNE Laufwerk weiter — Updates
|
||||
// warten auch auf sie, und die Taskleiste zeigt ihren Fortschritt.
|
||||
kompressionVerarbeiten(nachricht.stand.kompression.laeuft)
|
||||
} else if (nachricht.art === 'kompression-ergebnis' && !ohneNebenwirkungen) {
|
||||
toast(nachricht.ok ? 'Rippy ist fertig' : 'Rippy: Kompression fehlgeschlagen', `${nachricht.titel}: ${nachricht.text}`)
|
||||
} else if (nachricht.art === 'disc-info' && nachricht.info.titel.length > 0 && !ohneNebenwirkungen) {
|
||||
// Windows-Toast beim Erkennen (§ 7) — Klick holt das Fenster hoch.
|
||||
toast(
|
||||
'Disc erkannt',
|
||||
@@ -98,10 +134,50 @@ function start(): void {
|
||||
)
|
||||
}
|
||||
},
|
||||
() => statusSenden(),
|
||||
(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
|
||||
|
||||
function kompressionVerarbeiten(laeuft: { titel: string; prozent: number } | null): void {
|
||||
kompressionStand = laeuft === null ? null : { titel: laeuft.titel, prozent: laeuft.prozent }
|
||||
if (laeuft === null) aktiveVorgaenge.delete(KOMPRESSION)
|
||||
else aktiveVorgaenge.add(KOMPRESSION)
|
||||
anzeigeAktualisieren()
|
||||
}
|
||||
|
||||
/** Taskleiste und Tray aus dem, was gerade läuft: ein Rip zuerst, sonst
|
||||
* die Kompression — sonst nichts. */
|
||||
let letzterRip: Extract<KernNachricht, { art: 'rip-status' }>['status'] | null = null
|
||||
function anzeigeAktualisieren(): void {
|
||||
const rip = letzterRip !== null && letzterRip.phase === 'rippt' ? letzterRip : null
|
||||
if (fenster !== null && !fenster.isDestroyed()) {
|
||||
if (rip !== null && rip.prozent >= 0) {
|
||||
fenster.setProgressBar(rip.prozent / 100)
|
||||
} else if (letzterRip !== null && letzterRip.phase === 'fehler') {
|
||||
fenster.setProgressBar(1, { mode: 'error' })
|
||||
} else if (kompressionStand !== null && kompressionStand.prozent >= 0) {
|
||||
fenster.setProgressBar(kompressionStand.prozent / 100)
|
||||
} else {
|
||||
fenster.setProgressBar(-1)
|
||||
}
|
||||
}
|
||||
if (rip !== null) {
|
||||
trayHinweis(`rippt Laufwerk ${rip.id}${rip.prozent >= 0 ? ` (${rip.prozent} %)` : ''}`)
|
||||
} else if (kompressionStand !== null) {
|
||||
trayHinweis(`komprimiert „${kompressionStand.titel}"${kompressionStand.prozent >= 0 ? ` (${kompressionStand.prozent} %)` : ''}`)
|
||||
} else {
|
||||
trayHinweis('')
|
||||
}
|
||||
}
|
||||
|
||||
// § 7: Toasts, Taskleisten-Fortschritt und Tray-Zustand aus dem
|
||||
// Rip-Status — die Information, die man will, ohne das Fenster zu öffnen.
|
||||
function ripStatusVerarbeiten(status: Extract<KernNachricht, { art: 'rip-status' }>['status']): void {
|
||||
@@ -110,25 +186,11 @@ function start(): void {
|
||||
} else {
|
||||
aktiveVorgaenge.delete(status.id)
|
||||
}
|
||||
if (fenster !== null && !fenster.isDestroyed()) {
|
||||
if ((status.phase === 'rippt' || status.phase === 'komprimiert') && status.prozent >= 0) {
|
||||
fenster.setProgressBar(status.prozent / 100)
|
||||
} else if (status.phase === 'fehler') {
|
||||
fenster.setProgressBar(1, { mode: 'error' })
|
||||
} else {
|
||||
fenster.setProgressBar(-1)
|
||||
}
|
||||
}
|
||||
if (status.phase === 'rippt') {
|
||||
trayHinweis(`rippt Laufwerk ${status.id}${status.prozent >= 0 ? ` (${status.prozent} %)` : ''}`)
|
||||
} else if (status.phase === 'komprimiert') {
|
||||
trayHinweis(`komprimiert Laufwerk ${status.id}${status.prozent >= 0 ? ` (${status.prozent} %)` : ''}`)
|
||||
} else {
|
||||
trayHinweis('')
|
||||
}
|
||||
if (smokeModus) return
|
||||
letzterRip = status
|
||||
anzeigeAktualisieren()
|
||||
if (ohneNebenwirkungen) return
|
||||
if (status.phase === 'fertig') {
|
||||
toast('Rippy ist fertig', status.text)
|
||||
toast('Rippy: Rip fertig', status.text)
|
||||
} else if (status.phase === 'fehler') {
|
||||
toast('Rippy: Fehler', status.text)
|
||||
}
|
||||
@@ -172,6 +234,10 @@ function start(): void {
|
||||
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)
|
||||
}
|
||||
@@ -185,6 +251,11 @@ function start(): void {
|
||||
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
|
||||
@@ -195,7 +266,7 @@ function start(): void {
|
||||
|
||||
function fensterZeigen(): void {
|
||||
if (fenster === null || fenster.isDestroyed()) {
|
||||
fenster = fensterErstellen()
|
||||
fenster = fensterErstellen(app.getPath('userData'))
|
||||
fensterVerdrahten()
|
||||
return
|
||||
}
|
||||
@@ -219,6 +290,41 @@ function start(): void {
|
||||
}
|
||||
})
|
||||
|
||||
// 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)
|
||||
@@ -237,6 +343,33 @@ function start(): void {
|
||||
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()
|
||||
})
|
||||
@@ -249,15 +382,16 @@ function start(): void {
|
||||
|
||||
app.on('before-quit', () => {
|
||||
beendenGewollt = true
|
||||
protokoll.schreiben('Rippy beendet sich')
|
||||
kernVerwaltung.beenden()
|
||||
})
|
||||
|
||||
void app.whenReady().then(() => {
|
||||
kernVerwaltung.starten()
|
||||
if (!smokeModus) {
|
||||
if (!ohneNebenwirkungen) {
|
||||
trayErstellen(fensterZeigen, beenden)
|
||||
}
|
||||
fenster = fensterErstellen()
|
||||
fenster = fensterErstellen(app.getPath('userData'))
|
||||
fensterVerdrahten()
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// 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']
|
||||
@@ -17,11 +17,44 @@
|
||||
// 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.
|
||||
@@ -63,7 +96,7 @@ export function leineSetzen(): LeineStatus {
|
||||
}
|
||||
|
||||
const info = Buffer.alloc(STRUKTUR_GROESSE)
|
||||
info.writeUInt32LE(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, LIMIT_FLAGS_OFFSET)
|
||||
info.writeUInt32LE(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | JOB_OBJECT_LIMIT_BREAKAWAY_OK, LIMIT_FLAGS_OFFSET)
|
||||
if (
|
||||
!SetInformationJobObject(
|
||||
job,
|
||||
@@ -90,3 +123,64 @@ export function leineSetzen(): LeineStatus {
|
||||
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)}` }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,3 +18,44 @@ export function updateUrlPruefen(url: string): { ok: boolean; grund: string } {
|
||||
'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 ''
|
||||
}
|
||||
|
||||
@@ -2,42 +2,90 @@
|
||||
// ö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 laden noch installieren.
|
||||
// 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 { updateUrlPruefen } from './update-regeln'
|
||||
import { INSTALLER_ARGUMENTE } from './kommandozeile'
|
||||
import { ausserhalbDerLeineStarten } from './leine'
|
||||
import { neuerungenText, updateUrlPruefen } from './update-regeln'
|
||||
|
||||
export { STANDARD_UPDATE_URL, updateUrlPruefen } from './update-regeln'
|
||||
export {
|
||||
NEUERUNGEN_HOECHSTENS,
|
||||
STANDARD_UPDATE_URL,
|
||||
neuerungenText,
|
||||
updateUrlPruefen,
|
||||
} from './update-regeln'
|
||||
|
||||
const { autoUpdater } = electronUpdater
|
||||
|
||||
export interface UpdateStand {
|
||||
/** Wann die letzte Prüfung GELANG — '' wenn noch nie. */
|
||||
letzteErfolgreichePruefung: string
|
||||
text: string
|
||||
// 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,
|
||||
}
|
||||
|
||||
const stand: UpdateStand = { letzteErfolgreichePruefung: '', text: 'noch nicht geprüft' }
|
||||
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) {
|
||||
stand.text = 'Entwicklungslauf — Selbst-Update ist nur im installierten Rippy aktiv.'
|
||||
setzen({ text: 'Entwicklungslauf — Selbst-Update ist nur im installierten Rippy aktiv.' })
|
||||
return
|
||||
}
|
||||
const pruefung = updateUrlPruefen(eigeneUrl)
|
||||
if (!pruefung.ok) {
|
||||
stand.text = pruefung.grund
|
||||
setzen({ text: pruefung.grund })
|
||||
melden(pruefung.grund)
|
||||
return
|
||||
}
|
||||
@@ -45,39 +93,154 @@ export function updateEinrichten(
|
||||
autoUpdater.setFeedURL({ provider: 'generic', url: eigeneUrl.trim() })
|
||||
}
|
||||
autoUpdater.autoDownload = false
|
||||
// Installiert wird beim NÄCHSTEN Start — nie mitten in einem Rip.
|
||||
autoUpdater.autoInstallOnAppQuit = true
|
||||
// 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
|
||||
|
||||
autoUpdater.on('update-available', () => {
|
||||
// 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.
|
||||
stand.text = 'Update verfügbar — wird geladen, sobald kein Rip läuft.'
|
||||
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
|
||||
}
|
||||
void autoUpdater.downloadUpdate().catch((fehler: unknown) => {
|
||||
stand.text = `Update-Download fehlgeschlagen: ${String(fehler)}`
|
||||
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) => {
|
||||
stand.text = `Version ${info.version} liegt bereit — sie wird beim nächsten Start installiert.`
|
||||
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', () => {
|
||||
stand.letzteErfolgreichePruefung = new Date().toISOString()
|
||||
stand.text = 'Rippy ist aktuell.'
|
||||
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.
|
||||
stand.text = `Letzte Prüfung scheiterte: ${String(fehler).slice(0, 160)}`
|
||||
setzen({ laeuft: false, fortschritt: -1, text: `Letzte Prüfung scheiterte: ${String(fehler).slice(0, 160)}` })
|
||||
})
|
||||
|
||||
const pruefen = (): void => {
|
||||
if (ripLaeuft()) return
|
||||
void autoUpdater.checkForUpdates().catch(() => {
|
||||
// der error-Handler oben trägt den Grund ein
|
||||
})
|
||||
}
|
||||
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: '' }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// Dateinamen in der Ablage (5.6.0, Commander: „die Ordner … müssen direkt
|
||||
// den Filmnamen haben" — und die Dateien darin auch): Statt MakeMKVs
|
||||
// title_t00.mkv heißt der Hauptfilm „Akira (1988).mkv", zwei Fassungen
|
||||
// „Akira (1988) - Fassung 1.mkv" und „… - Fassung 2.mkv", Zugaben „Akira
|
||||
// (1988) - Extra 01.mkv" im Unterordner extras. Folgen behalten ihren
|
||||
// Namen, bis die Episoden-Zuordnung sie umbenennt (struktur.ts) — ohne
|
||||
// Zuordnung bleiben sie, wie sie sind (lieber gar nicht als falsch). Ohne
|
||||
// erkannten Titel bleibt alles beim MakeMKV-Namen. PUR.
|
||||
import { extname } from 'node:path'
|
||||
import { sichererName } from '../../gemeinsam/ablagepfad'
|
||||
import type { TitelRolle } from '../../gemeinsam/nachrichten'
|
||||
|
||||
export interface NamensEingabe {
|
||||
titel: string
|
||||
jahr: number | null
|
||||
alsSerie: boolean
|
||||
rolle: TitelRolle
|
||||
/** 1-basierte Nummer unter den Dateien GLEICHER Rolle (Ablage-Reihenfolge). */
|
||||
nummer: number
|
||||
/** Wie viele Dateien dieser Rolle es gibt. */
|
||||
anzahl: number
|
||||
/** MakeMKVs Dateiname (mit Endung). */
|
||||
original: string
|
||||
}
|
||||
|
||||
export function zielDateiname(e: NamensEingabe): string {
|
||||
const endung = extname(e.original) || '.mkv'
|
||||
const name = e.alsSerie ? sichererName(e.titel) : sichererName(e.titel, e.jahr)
|
||||
if (e.titel.trim().length === 0 || name.length === 0) return e.original
|
||||
const nn = String(e.nummer).padStart(2, '0')
|
||||
if (e.rolle === 'extra') return `${name} - Extra ${nn}${endung}`
|
||||
if (e.alsSerie) return e.original
|
||||
if (e.rolle === 'hauptfilm') return e.anzahl > 1 ? `${name} - Fassung ${e.nummer}${endung}` : `${name}${endung}`
|
||||
return e.original
|
||||
}
|
||||
|
||||
/** Nummern je Rolle in Ablage-Reihenfolge (pur): [nummer, anzahl] je Index. */
|
||||
export function rollenNummern(rollen: readonly TitelRolle[]): Array<[number, number]> {
|
||||
const zaehler = new Map<TitelRolle, number>()
|
||||
const gesamt = new Map<TitelRolle, number>()
|
||||
for (const r of rollen) gesamt.set(r, (gesamt.get(r) ?? 0) + 1)
|
||||
return rollen.map((r) => {
|
||||
const n = (zaehler.get(r) ?? 0) + 1
|
||||
zaehler.set(r, n)
|
||||
return [n, gesamt.get(r) ?? 1]
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
// Roh-Dateien aufräumen (5.4.0) — PUR bis auf injizierte Dateizugriffe.
|
||||
//
|
||||
// Der Befund: Jede Blu-ray ließ 30+ GB unter roh\ liegen, niemand räumte
|
||||
// auf. Seit W-3 stand im Code „Löschen bekommt erst mit der Einstellung im
|
||||
// UI eine Stimme". Das ist diese Stimme. Die Regel des Commanders
|
||||
// (01.09.2026): „Nach 7 Tagen löschen, nur bei gelungener Kompression".
|
||||
//
|
||||
// Drei Sicherungen, alle bewusst:
|
||||
// 1. Gelöscht wird NUR ein Roh-Ordner, dessen Vorgang FERTIG und
|
||||
// KOMPRIMIERT ist. Unkomprimierte Rips (4K verlustfrei) sind verschoben
|
||||
// worden, ihr Roh-Ordner ist leer — es gibt dort nichts zu löschen.
|
||||
// 2. Vorher wird nachgesehen, ob JEDE fertige Datei noch da ist und nicht
|
||||
// leer — eine gelöschte Roh-Datei ohne heiles Ergebnis wäre der einzige
|
||||
// Weg, wie Rippy Daten verlieren könnte.
|
||||
// 3. Ordner unter roh\, die KEIN Vorgang kennt (aus der Zeit vor 5.4.0),
|
||||
// werden nur gezeigt — löschen darf sie nur ein Klick des Nutzers.
|
||||
import { existsSync, readdirSync, rmSync, statSync } from 'node:fs'
|
||||
// Plattform-Pfade: Laufzeit-Wurzeln (Ablage-Einstellung), CI testet auf Linux.
|
||||
import { join } from 'node:path'
|
||||
import type { FremderOrdner } from '../../gemeinsam/nachrichten'
|
||||
|
||||
export type AufbewahrungsRegel = { art: 'behalten' } | { art: 'sofort' } | { art: 'tage'; tage: number }
|
||||
|
||||
/** Einstellungs-Schlüssel und Vorgabe (Commander-Wahl 01.09.2026). */
|
||||
export const AUFBEWAHRUNG_SCHLUESSEL = 'rohAufbewahrung'
|
||||
export const STANDARD_AUFBEWAHRUNG = '7'
|
||||
|
||||
/** 'behalten' | 'sofort' | '<Tage>' → Regel. Unbekanntes = Vorgabe. */
|
||||
export function regelLesen(wert: string | null | undefined): AufbewahrungsRegel {
|
||||
const text = (wert ?? '').trim().toLowerCase()
|
||||
if (text === 'behalten' || text === 'nie') return { art: 'behalten' }
|
||||
if (text === 'sofort' || text === '0') return { art: 'sofort' }
|
||||
const tage = Number(text.length > 0 ? text : STANDARD_AUFBEWAHRUNG)
|
||||
if (Number.isInteger(tage) && tage > 0 && tage <= 3650) return { art: 'tage', tage }
|
||||
return { art: 'tage', tage: Number(STANDARD_AUFBEWAHRUNG) }
|
||||
}
|
||||
|
||||
export function regelText(regel: AufbewahrungsRegel): string {
|
||||
if (regel.art === 'behalten') return 'Roh-Dateien werden behalten — aufgeräumt wird nur von Hand.'
|
||||
if (regel.art === 'sofort') return 'Roh-Dateien werden sofort nach gelungener Kompression gelöscht.'
|
||||
return `Roh-Dateien werden ${regel.tage} Tage nach gelungener Kompression gelöscht.`
|
||||
}
|
||||
|
||||
/** Wann die Frist eines fertigen, komprimierten Vorgangs abläuft — '' wenn nie. */
|
||||
export function faelligAm(beendetIso: string | null, regel: AufbewahrungsRegel): string {
|
||||
if (beendetIso === null || beendetIso.length === 0 || regel.art === 'behalten') return ''
|
||||
const beendet = new Date(beendetIso)
|
||||
if (Number.isNaN(beendet.getTime())) return ''
|
||||
if (regel.art === 'sofort') return beendet.toISOString()
|
||||
return new Date(beendet.getTime() + regel.tage * 24 * 60 * 60 * 1000).toISOString()
|
||||
}
|
||||
|
||||
/** Das, was das Aufräumen über einen Vorgang wissen muss. */
|
||||
export interface AufraeumKandidat {
|
||||
id: string
|
||||
phase: string
|
||||
komprimiert: boolean
|
||||
rohOrdner: string
|
||||
rohGeloeschtAm: string | null
|
||||
beendet: string | null
|
||||
fertigeDateien: readonly string[]
|
||||
}
|
||||
|
||||
export interface AufraeumZugriff {
|
||||
existiert: (pfad: string) => boolean
|
||||
/** Dateigröße in Bytes; 0 wenn nicht lesbar. */
|
||||
groesse: (pfad: string) => number
|
||||
ordnerLoeschen: (pfad: string) => void
|
||||
}
|
||||
|
||||
export const ECHTER_ZUGRIFF: AufraeumZugriff = {
|
||||
existiert: existsSync,
|
||||
groesse: (pfad) => {
|
||||
try {
|
||||
return statSync(pfad).size
|
||||
} catch {
|
||||
// Nicht lesbar zählt wie „nicht da" — und dann wird NICHT gelöscht.
|
||||
return 0
|
||||
}
|
||||
},
|
||||
ordnerLoeschen: (pfad) => rmSync(pfad, { recursive: true, force: true }),
|
||||
}
|
||||
|
||||
/** Warum ein Vorgang (noch) nicht gelöscht wird — '' heißt: löschbar. */
|
||||
export function loeschHindernis(
|
||||
v: AufraeumKandidat,
|
||||
regel: AufbewahrungsRegel,
|
||||
jetzt: Date,
|
||||
zugriff: AufraeumZugriff = ECHTER_ZUGRIFF,
|
||||
): string {
|
||||
if (v.rohGeloeschtAm !== null) return 'schon gelöscht'
|
||||
if (v.phase !== 'fertig') return `nicht fertig (${v.phase})`
|
||||
if (!v.komprimiert) return 'nicht komprimiert — die Roh-Dateien SIND das Ergebnis'
|
||||
if (!zugriff.existiert(v.rohOrdner)) return 'Roh-Ordner ist schon weg'
|
||||
if (v.fertigeDateien.length === 0) return 'keine fertige Datei bekannt'
|
||||
for (const datei of v.fertigeDateien) {
|
||||
if (!zugriff.existiert(datei)) return `fertige Datei fehlt: ${datei}`
|
||||
if (zugriff.groesse(datei) <= 0) return `fertige Datei ist leer: ${datei}`
|
||||
}
|
||||
if (regel.art === 'behalten') return 'Regel: behalten'
|
||||
const frist = faelligAm(v.beendet, regel)
|
||||
if (frist.length === 0) return 'kein Ende-Zeitpunkt bekannt'
|
||||
if (new Date(frist).getTime() > jetzt.getTime()) return `Frist läuft bis ${frist.slice(0, 16).replace('T', ' ')}`
|
||||
return ''
|
||||
}
|
||||
|
||||
export interface AufraeumErgebnis {
|
||||
geloescht: string[]
|
||||
uebersprungen: Array<{ id: string; grund: string }>
|
||||
fehler: Array<{ id: string; grund: string }>
|
||||
}
|
||||
|
||||
/** Wendet die Regel an. Liefert, was gelöscht wurde und warum der Rest blieb. */
|
||||
export function aufraeumen(
|
||||
vorgaenge: readonly AufraeumKandidat[],
|
||||
regel: AufbewahrungsRegel,
|
||||
jetzt: Date,
|
||||
zugriff: AufraeumZugriff = ECHTER_ZUGRIFF,
|
||||
): AufraeumErgebnis {
|
||||
const ergebnis: AufraeumErgebnis = { geloescht: [], uebersprungen: [], fehler: [] }
|
||||
for (const v of vorgaenge) {
|
||||
const hindernis = loeschHindernis(v, regel, jetzt, zugriff)
|
||||
if (hindernis.length > 0) {
|
||||
ergebnis.uebersprungen.push({ id: v.id, grund: hindernis })
|
||||
continue
|
||||
}
|
||||
try {
|
||||
zugriff.ordnerLoeschen(v.rohOrdner)
|
||||
ergebnis.geloescht.push(v.id)
|
||||
} catch (fehler) {
|
||||
// R4: Ein Ordner, der sich nicht löschen lässt (offene Datei?), wird
|
||||
// gesagt — beim nächsten Takt kommt er wieder dran.
|
||||
ergebnis.fehler.push({ id: v.id, grund: String(fehler) })
|
||||
}
|
||||
}
|
||||
return ergebnis
|
||||
}
|
||||
|
||||
/** Belegung eines Ordners in Bytes (rekursiv). 0, wenn es ihn nicht gibt. */
|
||||
export function ordnerGroesse(pfad: string): number {
|
||||
let summe = 0
|
||||
const gehen = (ordner: string): void => {
|
||||
let eintraege: import('node:fs').Dirent[]
|
||||
try {
|
||||
eintraege = readdirSync(ordner, { withFileTypes: true })
|
||||
} catch {
|
||||
// Nicht lesbar zählt nicht mit — die Anzeige ist dann zu klein, nie
|
||||
// erfunden.
|
||||
return
|
||||
}
|
||||
for (const e of eintraege) {
|
||||
const voll = join(ordner, e.name)
|
||||
if (e.isDirectory()) gehen(voll)
|
||||
else {
|
||||
try {
|
||||
summe += statSync(voll).size
|
||||
} catch {
|
||||
// siehe oben
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (existsSync(pfad)) gehen(pfad)
|
||||
return summe
|
||||
}
|
||||
|
||||
/** Ordner unter roh\, die kein Vorgang kennt — nur zeigen, nie von selbst löschen. */
|
||||
export function fremdeOrdner(rohWurzel: string, bekannte: ReadonlySet<string>): FremderOrdner[] {
|
||||
if (!existsSync(rohWurzel)) return []
|
||||
const liste: FremderOrdner[] = []
|
||||
let namen: string[]
|
||||
try {
|
||||
namen = readdirSync(rohWurzel, { withFileTypes: true })
|
||||
.filter((e) => e.isDirectory())
|
||||
.map((e) => e.name)
|
||||
} catch (fehler) {
|
||||
// R4: Ein nicht lesbarer Roh-Ordner ist eine laute Zeile — die Liste
|
||||
// bleibt dann leer, weil wirklich nichts gesehen wurde.
|
||||
console.error(`[roh] ${rohWurzel} nicht lesbar: ${String(fehler)}`)
|
||||
return liste
|
||||
}
|
||||
for (const name of namen) {
|
||||
const voll = join(rohWurzel, name)
|
||||
if (bekannte.has(voll) || bekannte.has(name)) continue
|
||||
liste.push({ ordner: voll, bytes: ordnerGroesse(voll) })
|
||||
}
|
||||
return liste.sort((a, b) => b.bytes - a.bytes)
|
||||
}
|
||||
|
||||
/** Ist dieser Pfad ein Ordner DIREKT unter der Roh-Wurzel? Nur solche
|
||||
* darf „fremden Ordner löschen" anfassen — nie etwas anderes. */
|
||||
export function istUnterRohWurzel(rohWurzel: string, pfad: string): boolean {
|
||||
const wurzel = rohWurzel.replace(/[\\/]+$/, '')
|
||||
const kandidat = pfad.replace(/[\\/]+$/, '')
|
||||
if (!kandidat.toLowerCase().startsWith(wurzel.toLowerCase())) return false
|
||||
const rest = kandidat.slice(wurzel.length)
|
||||
return /^[\\/][^\\/]+$/.test(rest)
|
||||
}
|
||||
@@ -11,23 +11,11 @@ import { existsSync, readdirSync, renameSync } from 'node:fs'
|
||||
// müssen dort physisch funktionieren. Feste Windows-Muster (Katalog,
|
||||
// %ProgramFiles%) bleiben dagegen bei path.win32.
|
||||
import { join } from 'node:path'
|
||||
// EINE Namensregel für Kern und Fenster (5.4.0: die Zielpfad-Vorschau im
|
||||
// Auswahl-Dialog rechnet mit derselben Funktion).
|
||||
import { sichererName, staffelOrdnerName } from '../../gemeinsam/ablagepfad'
|
||||
|
||||
// Zeichen, die in Ordnernamen auf SMB/NTFS/ext4 Ärger machen.
|
||||
// eslint-Hinweis unnötig: bewusst inklusive Steuerzeichen.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const VERBOTEN = /[<>:"/\\|?*\x00-\x1f]/g
|
||||
|
||||
/** Titel → dateisystem-tauglicher Ordnername (pur). */
|
||||
export function sichererName(titel: string, jahr?: number | string | null): string {
|
||||
let name = (titel ?? '').replace(VERBOTEN, '').trim().replace(/\.+$/, '')
|
||||
name = name.replace(/\s+/g, ' ').slice(0, 150).trim()
|
||||
if (name.length === 0) return ''
|
||||
// Kein doppeltes Jahr: OMDb-Titel tragen es teils schon im Namen.
|
||||
if (jahr !== undefined && jahr !== null && String(jahr).length > 0 && !name.endsWith(`(${jahr})`)) {
|
||||
name = `${name} (${jahr})`
|
||||
}
|
||||
return name
|
||||
}
|
||||
export { sichererName }
|
||||
|
||||
/** Zielordner: sprechender Name; existiert er schon (Disc doppelt
|
||||
* gerippt), wird die Job-Kennung angehängt statt zu überschreiben. */
|
||||
@@ -48,7 +36,7 @@ export function zielordner(
|
||||
/** Jellyfin-Konvention: <basis>/<Serie>/Season NN (pur). */
|
||||
export function serienOrdner(basis: string, serie: string, staffel: number): string {
|
||||
const name = sichererName(serie) || 'Unbekannte Serie'
|
||||
return join(basis, name, `Season ${String(Math.trunc(staffel)).padStart(2, '0')}`)
|
||||
return join(basis, name, staffelOrdnerName(staffel))
|
||||
}
|
||||
|
||||
/** Ordnet Dateien Episoden zu — über Laufzeiten, ordnungserhaltend.
|
||||
|
||||
@@ -0,0 +1,472 @@
|
||||
// Kompression + Ablage EINES Vorgangs (5.4.0) — die zweite Hälfte der
|
||||
// Kette aus pipeline.ts, herausgelöst, damit sie OHNE Laufwerk läuft:
|
||||
// * in der Warteschlange, während das Laufwerk schon die nächste Disc
|
||||
// liest (Commander-Wunsch: „Nächste Disc rippen, während die vorige
|
||||
// noch komprimiert"),
|
||||
// * und Tage später noch einmal — „Wiederholen ab Kompression": Die
|
||||
// Roh-Dateien liegen genau dafür da, ein Preset-Wechsel kostet kein
|
||||
// Neulesen mehr.
|
||||
//
|
||||
// Was hier NEU ist gegenüber 5.3.x:
|
||||
// * Ohne Kompression (4K verlustfrei, kein HandBrake) werden die Roh-
|
||||
// Dateien in den Zielordner VERSCHOBEN — mit NFO, Poster, Bibliothek.
|
||||
// Vorher blieben sie unter roh\ liegen, und nichts wusste von ihnen.
|
||||
// * Sprachen JE DATEI (auftrag.ts sprachwahl) statt einer globalen Vorgabe.
|
||||
// * Vor der Kompression wird die Roh-Datei GEFRAGT (HandBrake-Scan), ob
|
||||
// die gewünschten Sprachen drin sind — fehlt eine, steht es im Protokoll.
|
||||
// * Folgen von Hand („Staffel 1, ab Folge 3") schlagen die Laufzeit-Raterei;
|
||||
// TMDb gibt dann nur noch eine Warnung.
|
||||
// * Durchgehender Fortschritt, nach Dateigröße gewichtet.
|
||||
// * 5.5.0 Arbeitsordner: Liegt die Ablage auf dem Server, rechnet HandBrake
|
||||
// LOKAL (Arbeitsordner\fertig\<Vorgang>) und erst das Ergebnis wandert
|
||||
// über das Netz — kein Encode gegen eine SMB-Freigabe.
|
||||
import {
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs'
|
||||
// Plattform-Pfade wie in struktur.ts: Laufzeit-Wurzeln, CI testet auf Linux.
|
||||
import { basename, join } from 'node:path'
|
||||
import type { BibliothekEintrag, KompressionStand } from '../../gemeinsam/nachrichten'
|
||||
import { rollenNummern, zielDateiname } from '../ablage/dateinamen'
|
||||
import { baueNfo } from '../ablage/nfo'
|
||||
import { bibliothekRefresh } from '../ablage/medienserver'
|
||||
import { posterSpeichern } from '../ablage/poster'
|
||||
import { episodenUmbenennen, matcheEpisoden, serienOrdner, zielordner } from '../ablage/struktur'
|
||||
import { komprimieren } from '../komprimieren/handbrake'
|
||||
import { komprimierenFuer, presetFuer, sprachliste, type Einstellungen } from '../komprimieren/presets'
|
||||
import { fehlendeSprachen, spurenScannen, type HbSpuren } from '../komprimieren/scan'
|
||||
import {
|
||||
folgenNummern,
|
||||
folgenPlausibel,
|
||||
gewichteterFortschritt,
|
||||
sprachwahl,
|
||||
type KompressionsAuftrag,
|
||||
} from './auftrag'
|
||||
|
||||
export interface AblageUmgebung {
|
||||
/** Pfad zu HandBrakeCLI — oder '' (dann werden die Roh-Dateien verschoben). */
|
||||
handbrake: () => string
|
||||
/** Wurzel der Ablage. */
|
||||
ablage: () => string
|
||||
/** 5.5.0: eigener lokaler Arbeitsordner — '' wenn keiner (dann wird
|
||||
* direkt in die Ablage gerechnet, wie bisher). */
|
||||
arbeitsordner?: () => string
|
||||
einstellungen: () => Einstellungen
|
||||
/** Gemessene Preset-Empfehlung (Hardware zuerst) für diesen Disc-Typ. */
|
||||
presetEmpfehlung?: (discTyp: string) => string
|
||||
/** Laufzeiten einer Staffel von TMDb: [Episodennummer, Sekunden]. */
|
||||
episodenLaufzeiten?: (
|
||||
tmdbId: number,
|
||||
staffel: number,
|
||||
) => Promise<ReadonlyArray<readonly [number, number]>>
|
||||
bibliothekEintragen?: (eintrag: BibliothekEintrag) => void
|
||||
jetzt?: () => Date
|
||||
/** Datei verschieben — injizierbar für Tests. */
|
||||
verschieben?: (von: string, nach: string) => void
|
||||
/** Spuren der Roh-Datei messen — injizierbar (Standard: HandBrake-Scan). */
|
||||
spurenScannen?: (programm: string, datei: string) => Promise<HbSpuren | null>
|
||||
}
|
||||
|
||||
export interface AblageErgebnis {
|
||||
status: 'fertig' | 'fehler' | 'abgebrochen'
|
||||
zielOrdner: string
|
||||
dateien: string[]
|
||||
text: string
|
||||
komprimiert: boolean
|
||||
preset: string
|
||||
fertigBytes: number
|
||||
hinweise: string[]
|
||||
/** 5.5.0: Welche Folgen-Nummern vergeben wurden (leer bei Filmen oder ohne
|
||||
* Zuordnung) — der Serien-Merker zählt daran weiter. */
|
||||
folgenVergeben: number[]
|
||||
}
|
||||
|
||||
export type AblageMeldung = (stand: Pick<KompressionStand, 'phase' | 'prozent' | 'text' | 'restS'>) => void
|
||||
|
||||
/** Verschieben auf demselben Datenträger ist ein Umbenennen; über
|
||||
* Datenträger-Grenzen (EXDEV) hilft nur Kopieren + Löschen. */
|
||||
export function dateiVerschieben(von: string, nach: string): void {
|
||||
try {
|
||||
renameSync(von, nach)
|
||||
} catch (fehler) {
|
||||
const code = (fehler as NodeJS.ErrnoException).code
|
||||
if (code !== 'EXDEV') throw fehler
|
||||
copyFileSync(von, nach)
|
||||
unlinkSync(von)
|
||||
}
|
||||
}
|
||||
|
||||
function groesseVon(pfad: string): number {
|
||||
try {
|
||||
return statSync(pfad).size
|
||||
} catch {
|
||||
// Größe ist Anzeige-Komfort — eine unlesbare Datei ändert nichts am
|
||||
// Ergebnis selbst.
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
export async function komprimierenUndAblegen(
|
||||
auftrag: KompressionsAuftrag,
|
||||
umgebung: AblageUmgebung,
|
||||
melden: AblageMeldung,
|
||||
abbruch: AbortSignal,
|
||||
): Promise<AblageErgebnis> {
|
||||
const einstellungen = umgebung.einstellungen()
|
||||
const handbrake = umgebung.handbrake()
|
||||
const soll = komprimierenFuer(auftrag.discTyp, einstellungen)
|
||||
const komprimiert = soll && handbrake.length > 0
|
||||
const preset =
|
||||
auftrag.preset.length > 0
|
||||
? auftrag.preset
|
||||
: presetFuer(auftrag.discTyp, einstellungen, umgebung.presetEmpfehlung?.(auftrag.discTyp) ?? '')
|
||||
const zuordnung = auftrag.zuordnung
|
||||
const hinweise: string[] = [...auftrag.hinweise]
|
||||
const dateien = [...auftrag.dateien].sort((a, b) => a.pfad.localeCompare(b.pfad))
|
||||
const fehlschlag = (text: string, statusPreset: string, ist: boolean): AblageErgebnis => ({
|
||||
status: 'fehler',
|
||||
zielOrdner: fertigOrdner,
|
||||
dateien: [],
|
||||
text,
|
||||
komprimiert: ist,
|
||||
preset: statusPreset,
|
||||
fertigBytes: 0,
|
||||
hinweise,
|
||||
folgenVergeben: [],
|
||||
})
|
||||
|
||||
// ── WOHIN? (§ 4.2) ──────────────────────────────────────────────────
|
||||
// Serien gehören NICHT unter Filme/ — sonst sieht Jellyfin lauter
|
||||
// Einzelfilme statt einer Staffel. Serie ist es, wenn der Auftrag
|
||||
// Folgen enthält (der Nutzer hat es im Dialog so bestätigt).
|
||||
const alsSerie = dateien.some((d) => d.rolle === 'folge')
|
||||
const staffel = auftrag.staffel ?? 1
|
||||
const serienTitel = zuordnung !== null && zuordnung.titel.length > 0 ? zuordnung.titel : auftrag.vorgangId
|
||||
const fertigOrdner =
|
||||
auftrag.zielOrdner.length > 0
|
||||
? auftrag.zielOrdner
|
||||
: alsSerie
|
||||
? serienOrdner(join(umgebung.ablage(), 'Serien'), serienTitel, staffel)
|
||||
: zuordnung !== null && zuordnung.titel.length > 0
|
||||
? zielordner(join(umgebung.ablage(), 'Filme'), zuordnung.titel, zuordnung.jahr, auftrag.vorgangId)
|
||||
: zielordner(join(umgebung.ablage(), 'Filme'), auftrag.vorgangId, null, auftrag.vorgangId)
|
||||
try {
|
||||
mkdirSync(fertigOrdner, { recursive: true })
|
||||
} catch (fehler) {
|
||||
return fehlschlag(`Zielordner nicht anlegbar (${fertigOrdner}): ${String(fehler)} — ist die Ablage erreichbar?`, '', komprimiert)
|
||||
}
|
||||
|
||||
// Extras liegen im Unterordner „extras" NEBEN dem Hauptfilm — so lesen
|
||||
// Jellyfin, Emby und Kodi sie als Zugaben und nicht als zweiten Film.
|
||||
const extrasOrdner = join(fertigOrdner, 'extras')
|
||||
const zielFuer = (rolle: string): string => (rolle === 'extra' ? extrasOrdner : fertigOrdner)
|
||||
if (dateien.some((d) => d.rolle === 'extra')) mkdirSync(extrasOrdner, { recursive: true })
|
||||
|
||||
// 5.5.0: Eigener Arbeitsordner → HandBrake schreibt LOKAL, danach wird
|
||||
// verschoben. Ohne ihn wie bisher direkt ins Ziel.
|
||||
const arbeit = (umgebung.arbeitsordner?.() ?? '').trim()
|
||||
const zwischen = komprimiert && arbeit.length > 0 ? join(arbeit, 'fertig', auftrag.vorgangId) : ''
|
||||
if (zwischen.length > 0) mkdirSync(zwischen, { recursive: true })
|
||||
|
||||
const global = {
|
||||
audio: sprachliste(String(einstellungen['audioSprachen'] ?? '')),
|
||||
untertitel: sprachliste(String(einstellungen['untertitelSprachen'] ?? '')),
|
||||
}
|
||||
const gewichte = dateien.map((d) => d.groesseBytes)
|
||||
const scannen = umgebung.spurenScannen ?? spurenScannen
|
||||
const verschieben = umgebung.verschieben ?? dateiVerschieben
|
||||
|
||||
const fertigeDateien: string[] = []
|
||||
/** Lokal gerechnet, noch nicht im Ziel: [von, nach]. */
|
||||
const nachzuziehen: Array<[string, string]> = []
|
||||
// Laufzeiten der FOLGEN in Ablage-Reihenfolge — Grundlage der
|
||||
// Episoden-Zuordnung weiter unten.
|
||||
const folgenSekunden: number[] = []
|
||||
// 5.6.0: Dateien heißen wie der Film (dateinamen.ts) — Folgen behalten
|
||||
// ihren Namen bis zur Episoden-Zuordnung.
|
||||
const nummern = rollenNummern(dateien.map((d) => d.rolle))
|
||||
for (let i = 0; i < dateien.length; i++) {
|
||||
const datei = dateien[i]
|
||||
const [nummer, anzahl] = nummern[i]
|
||||
const zielDatei = join(
|
||||
zielFuer(datei.rolle),
|
||||
zielDateiname({
|
||||
titel: zuordnung?.titel ?? '',
|
||||
jahr: zuordnung?.jahr ?? null,
|
||||
alsSerie,
|
||||
rolle: datei.rolle,
|
||||
nummer,
|
||||
anzahl,
|
||||
original: basename(datei.pfad),
|
||||
}),
|
||||
)
|
||||
if (abbruch.aborted) return abgebrochen(fertigOrdner, fertigeDateien, preset, komprimiert, hinweise)
|
||||
if (!komprimiert) {
|
||||
melden({
|
||||
phase: 'legt-ab',
|
||||
prozent: gewichteterFortschritt(gewichte, i, 0),
|
||||
text: `Verschiebe ${i + 1}/${dateien.length}: ${basename(datei.pfad)} (ohne Kompression) …`,
|
||||
})
|
||||
try {
|
||||
verschieben(datei.pfad, zielDatei)
|
||||
} catch (fehler) {
|
||||
return fehlschlag(
|
||||
`Verschieben fehlgeschlagen: ${String(fehler)} — die Roh-Dateien bleiben stehen (${auftrag.rohOrdner}).`,
|
||||
'',
|
||||
false,
|
||||
)
|
||||
}
|
||||
fertigeDateien.push(zielDatei)
|
||||
if (datei.rolle === 'folge') folgenSekunden.push(datei.dauerS)
|
||||
continue
|
||||
}
|
||||
|
||||
const wahl = sprachwahl(datei, global)
|
||||
// Die Roh-Datei fragen, ob die gewünschten Sprachen wirklich drin sind
|
||||
// (scan.ts). Nur wenn der Nutzer etwas JE TITEL gewählt hat — die
|
||||
// globale Vorgabe war schon immer „was da ist".
|
||||
if (datei.audioSprachen.length > 0 || datei.untertitelSprachen.length > 0) {
|
||||
const spuren = await scannen(handbrake, datei.pfad)
|
||||
if (spuren !== null) {
|
||||
const fehlt = fehlendeSprachen(spuren, wahl)
|
||||
if (fehlt.audio.length > 0) {
|
||||
hinweise.push(
|
||||
`${basename(datei.pfad)}: Tonspur ${fehlt.audio.join(', ')} ist NICHT in der Roh-Datei — MakeMKV hat sie nicht mitgenommen (Auswahlregel prüfen).`,
|
||||
)
|
||||
}
|
||||
if (fehlt.untertitel.length > 0) {
|
||||
hinweise.push(`${basename(datei.pfad)}: Untertitel ${fehlt.untertitel.join(', ')} sind NICHT in der Roh-Datei.`)
|
||||
}
|
||||
} else {
|
||||
hinweise.push(`${basename(datei.pfad)}: Spuren der Roh-Datei ließen sich nicht messen — komprimiert wie gewünscht.`)
|
||||
}
|
||||
}
|
||||
|
||||
const ausgabe = zwischen.length > 0 ? join(zwischen, basename(datei.pfad)) : zielDatei
|
||||
melden({
|
||||
phase: 'komprimiert',
|
||||
prozent: gewichteterFortschritt(gewichte, i, 0),
|
||||
restS: -1,
|
||||
text: `Komprimiere ${i + 1}/${dateien.length}: ${basename(datei.pfad)} („${preset}")${
|
||||
wahl.audio.length > 0 ? ` · Ton ${wahl.audio.join(',')}` : ''
|
||||
}${wahl.untertitel.length > 0 ? ` · Untertitel ${wahl.untertitel.join(',')}${wahl.untertitelStandard ? ' (vorgewählt)' : ''}` : ''} …`,
|
||||
})
|
||||
const encode = await komprimieren({
|
||||
programm: handbrake,
|
||||
eingabe: datei.pfad,
|
||||
ausgabe,
|
||||
preset,
|
||||
audioSprachen: wahl.audio,
|
||||
untertitelSprachen: wahl.untertitel,
|
||||
untertitelStandard: wahl.untertitelStandard,
|
||||
abbruch,
|
||||
aufFortschritt: (prozent, restS) => {
|
||||
melden({
|
||||
phase: 'komprimiert',
|
||||
prozent: gewichteterFortschritt(gewichte, i, prozent),
|
||||
restS,
|
||||
text: `Komprimiere ${i + 1}/${dateien.length}: ${basename(datei.pfad)} („${preset}") …`,
|
||||
})
|
||||
},
|
||||
})
|
||||
if (encode.status === 'cancelled') return abgebrochen(fertigOrdner, fertigeDateien, preset, komprimiert, hinweise)
|
||||
if (encode.status === 'error') {
|
||||
return fehlschlag(
|
||||
`Kompression fehlgeschlagen: ${encode.fehler} — der verlustfreie Rip bleibt stehen (${auftrag.rohOrdner}).`,
|
||||
preset,
|
||||
true,
|
||||
)
|
||||
}
|
||||
if (zwischen.length > 0) nachzuziehen.push([encode.ausgabe, zielDatei])
|
||||
fertigeDateien.push(zielDatei)
|
||||
if (datei.rolle === 'folge') folgenSekunden.push(datei.dauerS)
|
||||
}
|
||||
|
||||
// 5.5.0: Lokal Gerechnetes auf die Ablage bringen — erst jetzt, da alle
|
||||
// Encodes gelungen sind. Ein Netzfehler hier kostet keinen Encode: Die
|
||||
// Dateien liegen im Arbeitsordner und der Vorgang sagt, wo.
|
||||
for (let i = 0; i < nachzuziehen.length; i++) {
|
||||
const [von, nach] = nachzuziehen[i]
|
||||
if (abbruch.aborted) return abgebrochen(fertigOrdner, [], preset, komprimiert, hinweise)
|
||||
melden({ phase: 'legt-ab', prozent: 100, text: `Verschiebe auf die Ablage ${i + 1}/${nachzuziehen.length}: ${basename(nach)} …` })
|
||||
try {
|
||||
verschieben(von, nach)
|
||||
} catch (fehler) {
|
||||
return fehlschlag(
|
||||
`Verschieben auf die Ablage fehlgeschlagen: ${String(fehler)} — die fertigen Dateien liegen unter ${zwischen}, die Roh-Dateien unter ${auftrag.rohOrdner}.`,
|
||||
preset,
|
||||
true,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (zwischen.length > 0) {
|
||||
try {
|
||||
rmSync(zwischen, { recursive: true, force: true })
|
||||
} catch {
|
||||
// Ein leerer Zwischenordner, der stehen bleibt, stört nicht.
|
||||
}
|
||||
}
|
||||
|
||||
// ── Wiederholen: die alte Fassung weicht der neuen — JETZT erst, da alle
|
||||
// neuen Dateien da sind. Vorher hätte ein gescheiterter Lauf die
|
||||
// erste Fassung gekostet; danach stünde sie bei Serien der
|
||||
// Umbenennung im Weg („existiert schon"). ────────────────────────
|
||||
for (const alt of auftrag.ersetzt ?? []) {
|
||||
if (fertigeDateien.includes(alt) || !existsSync(alt)) continue
|
||||
try {
|
||||
unlinkSync(alt)
|
||||
hinweise.push(`Alte Fassung ersetzt: ${basename(alt)}`)
|
||||
} catch (fehler) {
|
||||
hinweise.push(`Alte Fassung nicht gelöscht (${basename(alt)}): ${String(fehler)}`)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Serien-Staffelablage (§ 4.2) ────────────────────────────────────
|
||||
// Von Hand gesetzte Folgen („ab Folge 3") schlagen die Laufzeit-
|
||||
// Zuordnung; TMDb darf dann nur noch warnen. Ohne Vorgabe wird
|
||||
// umbenannt NUR bei EINDEUTIGER Zuordnung — lieber gar nicht als falsch
|
||||
// (die offene Wunde #395 von ARM war genau dieses Ratespiel).
|
||||
let folgenVergeben: number[] = []
|
||||
if (alsSerie && folgenSekunden.length > 0) {
|
||||
const tmdbId = typeof zuordnung?.meta.id === 'number' ? zuordnung.meta.id : null
|
||||
const laufzeiten = tmdbId !== null ? ((await umgebung.episodenLaufzeiten?.(tmdbId, staffel)) ?? []) : []
|
||||
let episoden: number[] | null = null
|
||||
if (auftrag.ersteFolge !== null && auftrag.ersteFolge > 0) {
|
||||
episoden = folgenNummern(folgenSekunden.length, auftrag.ersteFolge)
|
||||
hinweise.push(`Folgen von Hand: Staffel ${staffel}, ab Folge ${auftrag.ersteFolge}.`)
|
||||
hinweise.push(...folgenPlausibel(folgenSekunden, episoden, laufzeiten))
|
||||
} else {
|
||||
episoden = laufzeiten.length > 0 ? matcheEpisoden(folgenSekunden, laufzeiten) : null
|
||||
}
|
||||
if (episoden !== null) {
|
||||
hinweise.push(...episodenUmbenennen(fertigOrdner, serienTitel, staffel, episoden))
|
||||
folgenVergeben = episoden
|
||||
} else {
|
||||
hinweise.push(
|
||||
laufzeiten.length === 0
|
||||
? 'Episoden-Nummern unbekannt (keine Staffel-Laufzeiten von TMDb) — die Dateien behalten ihre Namen. Tipp: „Staffel und Folge von Hand" im Auswahl-Dialog.'
|
||||
: 'Episoden ließen sich nicht EINDEUTIG zuordnen — die Dateien behalten ihre Namen, lieber gar nicht als falsch.',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Ablage: NFO und Poster daneben ───────────────────────────────────
|
||||
melden({ phase: 'legt-ab', prozent: 100, text: 'Lege ab …' })
|
||||
const anzeigeTitel = zuordnung !== null && zuordnung.titel.length > 0 ? zuordnung.titel : auftrag.vorgangId
|
||||
try {
|
||||
const nfoName = zuordnung?.meta.typ === 'tv' ? 'tvshow.nfo' : 'movie.nfo'
|
||||
writeFileSync(
|
||||
join(fertigOrdner, nfoName),
|
||||
baueNfo(
|
||||
{
|
||||
typ: zuordnung?.meta.typ === 'tv' ? 'serie' : 'film',
|
||||
beschreibung: zuordnung?.meta.beschreibung,
|
||||
genres: zuordnung?.meta.genres,
|
||||
laufzeitMinuten: zuordnung?.meta.laufzeitMinuten,
|
||||
quelle:
|
||||
zuordnung !== null && zuordnung.meta.quelle.length > 0 ? `Rippy v5 (${zuordnung.meta.quelle})` : 'Rippy v5',
|
||||
},
|
||||
anzeigeTitel,
|
||||
zuordnung?.jahr ?? null,
|
||||
),
|
||||
)
|
||||
} catch (fehler) {
|
||||
// Ein fehlendes NFO darf einen gelungenen Rip nicht kippen — aber es
|
||||
// wird GESAGT (R4).
|
||||
hinweise.push(`NFO fehlgeschlagen: ${String(fehler)}`)
|
||||
}
|
||||
if (zuordnung !== null && zuordnung.meta.posterPfad.length > 0) {
|
||||
await posterSpeichern(zuordnung.meta.posterPfad, join(fertigOrdner, 'poster.jpg'))
|
||||
}
|
||||
|
||||
const server = String(einstellungen['mediaServer'] ?? '')
|
||||
const serverUrl = String(einstellungen['mediaServerUrl'] ?? '')
|
||||
const serverKey = String(einstellungen['mediaServerApiKey'] ?? '')
|
||||
const refresh = await bibliothekRefresh(server, serverUrl, serverKey)
|
||||
|
||||
// Umbenannte Folgen: Die Liste der fertigen Dateien muss den NEUEN
|
||||
// Namen tragen — sonst prüft das Aufräumen später auf Dateien, die es
|
||||
// unter dem alten Namen nicht mehr gibt.
|
||||
const fertigJetzt = fertigeDateien.map((d) => (existsSync(d) ? d : '')).filter((d) => d.length > 0)
|
||||
const endgueltig = fertigJetzt.length === fertigeDateien.length ? fertigeDateien : vorhandeneMkv(fertigOrdner, extrasOrdner)
|
||||
let fertigBytes = 0
|
||||
for (const datei of endgueltig) fertigBytes += groesseVon(datei)
|
||||
|
||||
// § 6.6: Merken — beim nächsten Einlegen wird die Disc wiedererkannt.
|
||||
const jetzt = umgebung.jetzt?.() ?? new Date()
|
||||
if (auftrag.fingerprint !== null && umgebung.bibliothekEintragen !== undefined) {
|
||||
const begonnen = auftrag.begonnen.length > 0 ? new Date(auftrag.begonnen) : jetzt
|
||||
umgebung.bibliothekEintragen({
|
||||
fingerprint: auftrag.fingerprint,
|
||||
titel: anzeigeTitel,
|
||||
jahr: zuordnung?.jahr ?? null,
|
||||
typ: auftrag.discTyp,
|
||||
groesseBytes: fertigBytes,
|
||||
ablageort: fertigOrdner,
|
||||
datum: begonnen.toISOString(),
|
||||
dauerS: Math.max(0, Math.round((jetzt.getTime() - begonnen.getTime()) / 1000)),
|
||||
posterPfad: zuordnung?.meta.posterPfad ?? '',
|
||||
})
|
||||
}
|
||||
|
||||
const text =
|
||||
(komprimiert ? `Fertig — komprimiert nach ${fertigOrdner}.` : `Fertig — verlustfrei abgelegt unter ${fertigOrdner} (ohne Kompression).`) +
|
||||
(refresh.length > 0 ? ` ${refresh}` : '') +
|
||||
(auftrag.lesefehler
|
||||
? ' ⚠ Die Disc ließ sich stellenweise nicht lesen — mindestens ein Titel könnte fehlen (makemkv.com/errors/read).'
|
||||
: '')
|
||||
return {
|
||||
status: 'fertig',
|
||||
zielOrdner: fertigOrdner,
|
||||
dateien: endgueltig,
|
||||
text,
|
||||
komprimiert,
|
||||
preset: komprimiert ? preset : '',
|
||||
fertigBytes,
|
||||
hinweise,
|
||||
folgenVergeben,
|
||||
}
|
||||
}
|
||||
|
||||
function abgebrochen(
|
||||
zielOrdner: string,
|
||||
dateien: string[],
|
||||
preset: string,
|
||||
komprimiert: boolean,
|
||||
hinweise: string[],
|
||||
): AblageErgebnis {
|
||||
return {
|
||||
status: 'abgebrochen',
|
||||
zielOrdner,
|
||||
dateien,
|
||||
text: 'Abgebrochen — der verlustfreie Rip bleibt stehen.',
|
||||
komprimiert,
|
||||
preset,
|
||||
fertigBytes: 0,
|
||||
hinweise,
|
||||
folgenVergeben: [],
|
||||
}
|
||||
}
|
||||
|
||||
/** Alle MKVs im Zielordner (und in extras\) — nach dem Umbenennen der Folgen
|
||||
* ist das die einzige verlässliche Liste. */
|
||||
function vorhandeneMkv(ordner: string, extras: string): string[] {
|
||||
const liste: string[] = []
|
||||
for (const o of [ordner, extras]) {
|
||||
if (!existsSync(o)) continue
|
||||
try {
|
||||
for (const name of readdirSync(o)) if (name.toLowerCase().endsWith('.mkv')) liste.push(join(o, name))
|
||||
} catch {
|
||||
// Ein nicht lesbarer Ordner liefert keine Dateien — die Bibliothek
|
||||
// zeigt dann 0 Bytes, das Ergebnis selbst bleibt ein Erfolg.
|
||||
}
|
||||
}
|
||||
return liste.sort()
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
// Der Kompressions-Auftrag (5.4.0) — PUR, ohne Laufwerk und ohne Netz.
|
||||
//
|
||||
// Er ist das Bindeglied zwischen zwei Phasen, die seit 5.4.0 getrennt
|
||||
// laufen: Der RIP belegt das Laufwerk und endet, sobald die Roh-Dateien
|
||||
// liegen; die KOMPRESSION läuft danach in einer Warteschlange und braucht
|
||||
// nur noch diese Beschreibung. Deshalb wird sie als JSON in der Datenbank
|
||||
// gemerkt — so kann „Wiederholen ab Kompression" sie Tage später wieder
|
||||
// aufnehmen, ohne die Disc noch einmal zu lesen (40 Minuten je Blu-ray).
|
||||
import type { MetaVorschlag, TitelRolle } from '../../gemeinsam/nachrichten'
|
||||
import type { Zuordnung } from '../metadaten/zuordnung'
|
||||
|
||||
export interface RohDatei {
|
||||
pfad: string
|
||||
titelNr: number
|
||||
rolle: TitelRolle
|
||||
dauerS: number
|
||||
/** Größe der Roh-Datei — gewichtet den Fortschrittsbalken. */
|
||||
groesseBytes: number
|
||||
/** Sprachen je Datei (leer = die globale Einstellung gilt). */
|
||||
audioSprachen: string[]
|
||||
untertitelSprachen: string[]
|
||||
/** 5.6.0 (Extras): vorgewählte Untertitel-Sprache; null = keine; fehlend = Regel. */
|
||||
untertitelVorwahl?: string | null
|
||||
/** 5.6.0: der Film, zu dem diese Datei gehört, wenn nicht die Disc-Zuordnung. */
|
||||
film?: MetaVorschlag
|
||||
}
|
||||
|
||||
export interface KompressionsAuftrag {
|
||||
vorgangId: string
|
||||
laufwerk: string
|
||||
discTyp: string
|
||||
rohOrdner: string
|
||||
dateien: RohDatei[]
|
||||
/** Schnappschuss der Metadaten-Zuordnung zum Zeitpunkt des Rips. */
|
||||
zuordnung: Zuordnung | null
|
||||
/** Staffel (Nutzer > Disc > 1) und erste Folge (nur vom Nutzer). */
|
||||
staffel: number | null
|
||||
ersteFolge: number | null
|
||||
discNr: number | null
|
||||
fingerprint: string | null
|
||||
lesefehler: boolean
|
||||
/** Klartext aus dem Rip („Übersprungen: …", „Nicht gesichert: …"). */
|
||||
hinweise: string[]
|
||||
/** Beginn des Rips (ISO) — die Bibliothek merkt sich diesen Zeitpunkt. */
|
||||
begonnen: string
|
||||
/** Preset-Vorgabe beim Wiederholen; '' = die Einstellung gilt. */
|
||||
preset: string
|
||||
/** Beim Wiederholen: der Zielordner des ERSTEN Durchlaufs — die neue
|
||||
* Fassung ersetzt die alte, statt einen zweiten Ordner anzulegen.
|
||||
* '' = Zielordner wird frisch bestimmt. */
|
||||
zielOrdner: string
|
||||
/** Beim Wiederholen: die fertigen Dateien des ERSTEN Durchlaufs. Sie
|
||||
* werden gelöscht, sobald die neuen fertig sind — vorher nicht, damit
|
||||
* ein gescheiterter zweiter Lauf die erste Fassung nicht kostet. Bei
|
||||
* Serien stünden sie sonst der Umbenennung im Weg („existiert schon"). */
|
||||
ersetzt?: string[]
|
||||
}
|
||||
|
||||
export function auftragZuJson(auftrag: KompressionsAuftrag): string {
|
||||
return JSON.stringify(auftrag)
|
||||
}
|
||||
|
||||
/** Liest einen gemerkten Auftrag zurück — null, wenn er nicht die Form hat
|
||||
* (eine Datenbank aus einer späteren Fassung, ein Fremdeintrag). */
|
||||
export function auftragAusJson(text: string): KompressionsAuftrag | null {
|
||||
let roh: unknown
|
||||
try {
|
||||
roh = JSON.parse(text)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (typeof roh !== 'object' || roh === null) return null
|
||||
const a = roh as Record<string, unknown>
|
||||
if (typeof a.vorgangId !== 'string' || typeof a.rohOrdner !== 'string' || !Array.isArray(a.dateien)) return null
|
||||
const dateien: RohDatei[] = []
|
||||
for (const d of a.dateien) {
|
||||
if (typeof d !== 'object' || d === null) return null
|
||||
const e = d as Record<string, unknown>
|
||||
if (typeof e.pfad !== 'string') return null
|
||||
dateien.push({
|
||||
pfad: e.pfad,
|
||||
titelNr: typeof e.titelNr === 'number' ? e.titelNr : 0,
|
||||
rolle: e.rolle === 'extra' || e.rolle === 'folge' ? e.rolle : 'hauptfilm',
|
||||
dauerS: typeof e.dauerS === 'number' ? e.dauerS : 0,
|
||||
groesseBytes: typeof e.groesseBytes === 'number' ? e.groesseBytes : 0,
|
||||
audioSprachen: Array.isArray(e.audioSprachen) ? e.audioSprachen.map(String) : [],
|
||||
untertitelSprachen: Array.isArray(e.untertitelSprachen) ? e.untertitelSprachen.map(String) : [],
|
||||
...(e.untertitelVorwahl === null || typeof e.untertitelVorwahl === 'string' ? { untertitelVorwahl: e.untertitelVorwahl } : {}),
|
||||
...(filmAus(e.film) !== null ? { film: filmAus(e.film)! } : {}),
|
||||
})
|
||||
}
|
||||
return {
|
||||
vorgangId: a.vorgangId,
|
||||
laufwerk: typeof a.laufwerk === 'string' ? a.laufwerk : '',
|
||||
discTyp: typeof a.discTyp === 'string' ? a.discTyp : 'bluray',
|
||||
rohOrdner: a.rohOrdner,
|
||||
dateien,
|
||||
zuordnung: typeof a.zuordnung === 'object' && a.zuordnung !== null ? (a.zuordnung as Zuordnung) : null,
|
||||
staffel: typeof a.staffel === 'number' ? a.staffel : null,
|
||||
ersteFolge: typeof a.ersteFolge === 'number' ? a.ersteFolge : null,
|
||||
discNr: typeof a.discNr === 'number' ? a.discNr : null,
|
||||
fingerprint: typeof a.fingerprint === 'string' ? a.fingerprint : null,
|
||||
lesefehler: a.lesefehler === true,
|
||||
hinweise: Array.isArray(a.hinweise) ? a.hinweise.map(String) : [],
|
||||
begonnen: typeof a.begonnen === 'string' ? a.begonnen : '',
|
||||
preset: typeof a.preset === 'string' ? a.preset : '',
|
||||
zielOrdner: typeof a.zielOrdner === 'string' ? a.zielOrdner : '',
|
||||
ersetzt: Array.isArray(a.ersetzt) ? a.ersetzt.map(String) : [],
|
||||
}
|
||||
}
|
||||
|
||||
/** Ein Film-Vorschlag aus dem JSON — null, wenn er nicht die Form hat. */
|
||||
export function filmAus(wert: unknown): MetaVorschlag | null {
|
||||
if (typeof wert !== 'object' || wert === null) return null
|
||||
const f = wert as Record<string, unknown>
|
||||
if (typeof f.tmdbId !== 'number' || typeof f.titel !== 'string') return null
|
||||
return {
|
||||
typ: f.typ === 'tv' ? 'tv' : 'movie',
|
||||
tmdbId: f.tmdbId,
|
||||
titel: f.titel,
|
||||
jahr: typeof f.jahr === 'number' ? f.jahr : null,
|
||||
posterPfad: typeof f.posterPfad === 'string' ? f.posterPfad : '',
|
||||
}
|
||||
}
|
||||
|
||||
/** Was HandBrake für EINE Datei bekommt. */
|
||||
export interface SprachWahl {
|
||||
audio: string[]
|
||||
untertitel: string[]
|
||||
/** Die erste Untertitelspur vorwählen — weil der Ton nicht in der
|
||||
* Wunschsprache ist. */
|
||||
untertitelStandard: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Sprachwahl je Datei (pur): Was der Nutzer am Titel gewählt hat, schlägt
|
||||
* die globale Einstellung. Und die Regel des Commanders (01.09.2026):
|
||||
* „japanischer Ton und braucht dringend die deutschen untertitel" —
|
||||
* spielt die ERSTE Tonspur nicht in der Wunschsprache (der ersten
|
||||
* globalen Tonsprache), wird die Untertitelspur der Wunschsprache nach
|
||||
* vorn gestellt und als Standard markiert. HandBrake ordnet die Spuren in
|
||||
* Listen-Reihenfolge und `--subtitle-default=1` trifft die erste (gemessen,
|
||||
* komprimieren/handbrake.ts).
|
||||
*/
|
||||
export function sprachwahl(
|
||||
datei: { audioSprachen: readonly string[]; untertitelSprachen: readonly string[]; untertitelVorwahl?: string | null },
|
||||
global: { audio: readonly string[]; untertitel: readonly string[] },
|
||||
): SprachWahl {
|
||||
const audio = datei.audioSprachen.length > 0 ? [...datei.audioSprachen] : [...global.audio]
|
||||
let untertitel = datei.untertitelSprachen.length > 0 ? [...datei.untertitelSprachen] : [...global.untertitel]
|
||||
// 5.6.0: Die Vorwahl des Nutzers (nur Extras, immer optional) schlägt die
|
||||
// Regel — null heißt ausdrücklich „keine Vorwahl".
|
||||
if (datei.untertitelVorwahl === null) return { audio, untertitel, untertitelStandard: false }
|
||||
if (typeof datei.untertitelVorwahl === 'string' && datei.untertitelVorwahl.length > 0) {
|
||||
const v = datei.untertitelVorwahl
|
||||
return { audio, untertitel: [v, ...untertitel.filter((u) => u !== v)], untertitelStandard: true }
|
||||
}
|
||||
const wunsch = global.audio[0] ?? ''
|
||||
const tonFremd = wunsch.length > 0 && audio.length > 0 && audio[0] !== wunsch
|
||||
const untertitelStandard = tonFremd && untertitel.includes(wunsch)
|
||||
if (untertitelStandard) untertitel = [wunsch, ...untertitel.filter((u) => u !== wunsch)]
|
||||
return { audio, untertitel, untertitelStandard }
|
||||
}
|
||||
|
||||
/** Folgen-Nummern von Hand: fortlaufend ab der ersten, in Disc-Reihenfolge. */
|
||||
export function folgenNummern(anzahl: number, ersteFolge: number): number[] {
|
||||
const start = Math.max(1, Math.trunc(ersteFolge))
|
||||
return Array.from({ length: Math.max(0, anzahl) }, (_, i) => start + i)
|
||||
}
|
||||
|
||||
/**
|
||||
* Durchgehender Fortschritt über mehrere Teile, nach Gewicht (pur).
|
||||
* Vorher sprang der Balken je Titel auf null zurück — bei 2 × 16 GB stand
|
||||
* er nach der Hälfte wieder bei 0 %. Fehlt ein Gewicht (0), zählen alle
|
||||
* Teile gleich. `prozent` < 0 heißt „vom laufenden Teil noch keine Angabe".
|
||||
*/
|
||||
export function gewichteterFortschritt(gewichte: readonly number[], index: number, prozent: number): number {
|
||||
if (gewichte.length === 0) return -1
|
||||
const gleich = gewichte.some((g) => !(g > 0))
|
||||
const g = gleich ? gewichte.map(() => 1) : [...gewichte]
|
||||
const gesamt = g.reduce((s, w) => s + w, 0)
|
||||
if (gesamt <= 0) return -1
|
||||
const i = Math.min(Math.max(0, index), g.length - 1)
|
||||
const erledigt = g.slice(0, i).reduce((s, w) => s + w, 0)
|
||||
const laufend = prozent < 0 ? 0 : (g[i] * Math.min(100, prozent)) / 100
|
||||
return Math.max(0, Math.min(100, Math.floor(((erledigt + laufend) / gesamt) * 100)))
|
||||
}
|
||||
|
||||
/** Passen die von Hand gesetzten Folgen zu den TMDb-Laufzeiten? Nur eine
|
||||
* Warnung — der Nutzer weiß, was auf seiner Disc ist. Rückgabe: Klartext
|
||||
* je Abweichung (leer = alles plausibel oder nichts zum Vergleichen). */
|
||||
export function folgenPlausibel(
|
||||
dateiSekunden: readonly number[],
|
||||
nummern: readonly number[],
|
||||
laufzeiten: ReadonlyArray<readonly [nummer: number, sekunden: number]>,
|
||||
toleranz = 0.15,
|
||||
): string[] {
|
||||
const warnungen: string[] = []
|
||||
for (let i = 0; i < nummern.length; i++) {
|
||||
const soll = laufzeiten.find(([nr]) => nr === nummern[i])
|
||||
const ist = dateiSekunden[i] ?? 0
|
||||
if (soll === undefined || soll[1] <= 0 || ist <= 0) continue
|
||||
if (Math.abs(ist - soll[1]) / soll[1] > toleranz) {
|
||||
warnungen.push(
|
||||
`Folge ${nummern[i]}: Datei ${Math.round(ist / 60)} min, TMDb ${Math.round(soll[1] / 60)} min — bitte nachsehen.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
return warnungen
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// Mehrere Filme auf einer Disc und Ordner mit Filmnamen (5.6.0, Commander:
|
||||
// „je Titel eigener Film im Dialog" und „die Ordner, die Rippy generiert,
|
||||
// müssen direkt den Filmnamen haben, z. B. Akira (Jahr)") — PUR, damit die
|
||||
// Ampel die Regeln prüft:
|
||||
// * rohOrdnerName: Der Roh-Ordner heißt wie der Film (oder die Serie mit
|
||||
// Staffel und Disc), bei Wiederholung mit „(2)", „(3)" …; ohne Zuordnung
|
||||
// bleibt der Zeitstempel-Name des Vorgangs.
|
||||
// * filmGruppen: Titel, die der Nutzer einem ANDEREN Film zugeordnet hat,
|
||||
// werden zu eigenen Gruppen — jede bekommt eigenen Roh-Ordner, Vorgang
|
||||
// und Zielordner. Hat kein Titel die Disc-Zuordnung behalten, wird die
|
||||
// erste Gruppe zum Hauptvorgang (der Roh-Ordner ist schon da).
|
||||
// * zuordnungAusVorschlag: Notnagel ohne TMDb-Details.
|
||||
import type { MetaVorschlag } from '../../gemeinsam/nachrichten'
|
||||
import { sichererName } from '../../gemeinsam/ablagepfad'
|
||||
import { LEERER_TREFFER, type Zuordnung } from '../metadaten/zuordnung'
|
||||
import type { RohDatei } from './auftrag'
|
||||
|
||||
export interface DiscAngaben {
|
||||
staffel: number | null
|
||||
discNr: number | null
|
||||
}
|
||||
|
||||
export function rohOrdnerName(
|
||||
zuordnung: Zuordnung | null,
|
||||
angaben: DiscAngaben | null,
|
||||
vorgangsName: string,
|
||||
existiert: (name: string) => boolean,
|
||||
): string {
|
||||
if (zuordnung === null || zuordnung.titel.trim().length === 0) return vorgangsName
|
||||
let basis: string
|
||||
if (zuordnung.meta.typ === 'tv') {
|
||||
basis = sichererName(zuordnung.titel)
|
||||
if (basis.length > 0 && angaben?.staffel !== null && angaben?.staffel !== undefined) basis += ` - Staffel ${angaben.staffel}`
|
||||
if (basis.length > 0 && angaben?.discNr !== null && angaben?.discNr !== undefined) basis += ` Disc ${angaben.discNr}`
|
||||
} else {
|
||||
basis = sichererName(zuordnung.titel, zuordnung.jahr)
|
||||
}
|
||||
if (basis.length === 0) return vorgangsName
|
||||
if (!existiert(basis)) return basis
|
||||
for (let n = 2; n < 100; n++) {
|
||||
const kandidat = `${basis} (${n})`
|
||||
if (!existiert(kandidat)) return kandidat
|
||||
}
|
||||
return vorgangsName
|
||||
}
|
||||
|
||||
export interface FilmGruppe {
|
||||
film: MetaVorschlag
|
||||
dateien: RohDatei[]
|
||||
}
|
||||
|
||||
export interface FilmGruppen {
|
||||
/** Die Dateien des Hauptvorgangs (Disc-Zuordnung). */
|
||||
primaer: RohDatei[]
|
||||
/** Gesetzt, wenn KEIN Titel die Disc-Zuordnung behielt: Der Hauptvorgang
|
||||
* übernimmt diesen Film. */
|
||||
primaerFilm: MetaVorschlag | null
|
||||
weitere: FilmGruppe[]
|
||||
}
|
||||
|
||||
export function filmGruppen(dateien: readonly RohDatei[], discFilmId: number | null): FilmGruppen {
|
||||
const primaer: RohDatei[] = []
|
||||
const weitere: FilmGruppe[] = []
|
||||
for (const d of dateien) {
|
||||
if (d.film === undefined || (d.film.typ === 'movie' && d.film.tmdbId === discFilmId)) {
|
||||
primaer.push(d)
|
||||
continue
|
||||
}
|
||||
const gruppe = weitere.find((g) => g.film.tmdbId === d.film!.tmdbId && g.film.typ === d.film!.typ)
|
||||
if (gruppe !== undefined) gruppe.dateien.push(d)
|
||||
else weitere.push({ film: d.film, dateien: [d] })
|
||||
}
|
||||
if (primaer.length === 0 && weitere.length > 0) {
|
||||
const erste = weitere.shift()!
|
||||
return { primaer: erste.dateien, primaerFilm: erste.film, weitere }
|
||||
}
|
||||
return { primaer, primaerFilm: null, weitere }
|
||||
}
|
||||
|
||||
/** Eine Zuordnung aus dem Vorschlag allein — wenn TMDb-Details nicht zu
|
||||
* holen sind (kein Netz, kein Schlüssel). Vom Nutzer gewählt = sicher. */
|
||||
export function zuordnungAusVorschlag(v: MetaVorschlag): Zuordnung {
|
||||
return {
|
||||
titel: v.titel,
|
||||
jahr: v.jahr,
|
||||
confidence: 1,
|
||||
meta: { ...LEERER_TREFFER, typ: v.typ, id: v.tmdbId, titel: v.titel, jahr: v.jahr, posterPfad: v.posterPfad, quelle: 'tmdb' },
|
||||
}
|
||||
}
|
||||
@@ -3,34 +3,64 @@
|
||||
// AbortController. Alle Werkzeuge laufen als Kinder dieses Kerns und
|
||||
// hängen damit an der Prozess-Leine (R6).
|
||||
//
|
||||
// Bis W-4 (Metadaten-Automatik) heißt der fertige Film wie sein
|
||||
// Roh-Ordner; die echten Titel ersetzen das dann. Roh-Dateien werden in
|
||||
// dieser Etappe GRUNDSÄTZLICH BEHALTEN — Löschen bekommt erst mit der
|
||||
// Einstellung im UI (W-5) eine Stimme.
|
||||
import { copyFileSync, mkdirSync, statSync, writeFileSync } from 'node:fs'
|
||||
// Seit 5.4.0 ist die Kette an EINER Stelle geteilt: Diese Datei führt den
|
||||
// RIP (das Laufwerk ist belegt) und übergibt danach einen Kompressions-
|
||||
// Auftrag (auftrag.ts) an die Warteschlange (warteschlange.ts →
|
||||
// ablegen.ts). Das Laufwerk ist damit frei, sobald die Roh-Dateien
|
||||
// liegen — die nächste Disc kann rein, während die vorige noch rechnet
|
||||
// (Commander-Wahl 01.09.2026). Der Vorgang selbst ist ein Datensatz
|
||||
// (speicher/db.ts, Tabelle vorgaenge): So weiß Rippy hinterher, welcher
|
||||
// Roh-Ordner zu welchem Film gehört — und kann aufräumen, wiederholen und
|
||||
// nach einem Abbruch nachsehen.
|
||||
//
|
||||
// 5.5.0: das Rettungs-Abbild (rip/rettung.ts) für Discs, an denen Rippy
|
||||
// Lesefehler GESEHEN hat — erst das Abbild mit Nullen, dann derselbe Rip
|
||||
// aus dem Abbild (iso:), das nicht mehr hängen kann.
|
||||
import { existsSync, mkdirSync, readdirSync, renameSync, rmSync, statSync, unlinkSync } from 'node:fs'
|
||||
// Plattform-Pfade wie in struktur.ts: Laufzeit-Wurzeln, CI testet auf Linux.
|
||||
import { basename, join } from 'node:path'
|
||||
import type { BibliothekEintrag, GeraeteInfo, RipStatus } from '../../gemeinsam/nachrichten'
|
||||
import { baueNfo } from '../ablage/nfo'
|
||||
import { bibliothekRefresh } from '../ablage/medienserver'
|
||||
import { posterSpeichern } from '../ablage/poster'
|
||||
import { zielordner } from '../ablage/struktur'
|
||||
import { komprimieren } from '../komprimieren/handbrake'
|
||||
import { komprimierenFuer, presetFuer, sprachliste, type Einstellungen } from '../komprimieren/presets'
|
||||
import type {
|
||||
BibliothekEintrag,
|
||||
GeraeteInfo,
|
||||
RipAuftragOptionen,
|
||||
RipAuftragTitel,
|
||||
RipStatus,
|
||||
TitelRolle,
|
||||
} from '../../gemeinsam/nachrichten'
|
||||
import type { Einstellungen } from '../komprimieren/presets'
|
||||
import type { Zuordnung } from '../metadaten/zuordnung'
|
||||
import { abfragen, albumOrdner, kennungAusSpuren, tagsJeSpur } from '../metadaten/musicbrainz'
|
||||
import { dateiname, flacBefehl, sauberName, wavKopf, SEKTOR_BYTES, type Spur } from '../rip/audiocd'
|
||||
import { isoSichern } from '../rip/iso'
|
||||
import { rippen } from '../rip/makemkv'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { mkdtempSync, rmSync, createWriteStream } from 'node:fs'
|
||||
import { rettungsAbbildDatei } from '../rip/rettung'
|
||||
import { werkzeugSpawn } from '../werkzeuge/aufruf'
|
||||
import { mkdtempSync, createWriteStream } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { gewichteterFortschritt, type KompressionsAuftrag, type RohDatei } from './auftrag'
|
||||
import { filmGruppen, rohOrdnerName, zuordnungAusVorschlag } from './filme'
|
||||
|
||||
interface LaufenderVorgang {
|
||||
/** Beendet den GANZEN Auftrag. */
|
||||
abbruch: AbortController
|
||||
/** Beendet nur den gerade laufenden Titel (überspringen). */
|
||||
titelAbbruch?: AbortController
|
||||
/** Der laufende Titel wurde übersprungen, nicht der Auftrag abgebrochen. */
|
||||
uebersprungen?: boolean
|
||||
status: RipStatus
|
||||
}
|
||||
|
||||
/** Was der Kern über einen beginnenden Vorgang wissen will (Datenbank). */
|
||||
export interface VorgangBeginn {
|
||||
id: string
|
||||
laufwerk: string
|
||||
typ: string
|
||||
titel: string
|
||||
jahr: number | null
|
||||
rohOrdner: string
|
||||
begonnen: string
|
||||
}
|
||||
|
||||
export interface PipelineUmgebung {
|
||||
/** Pfad zu makemkvcon — oder '' wenn nicht gefunden. */
|
||||
makemkvcon: () => string
|
||||
@@ -50,25 +80,52 @@ export interface PipelineUmgebung {
|
||||
) => number
|
||||
/** Aktueller Stand des Laufwerks (für Typ und Vorab-Blick). */
|
||||
geraet: (id: string) => GeraeteInfo | null
|
||||
/** Wurzel der Ablage (Roh-Ordner und fertige Filme liegen darunter). */
|
||||
/** Wurzel der Ablage (fertige Filme, Musik, ISO liegen darunter). */
|
||||
ablage: () => string
|
||||
/** 5.5.0: Wo die Roh-Ordner liegen (Arbeitsordner\roh). Fehlt: Ablage\roh. */
|
||||
rohWurzel?: () => string
|
||||
einstellungen: () => Einstellungen
|
||||
/** Gemessene Preset-Empfehlung (Hardware zuerst) für diesen Disc-Typ —
|
||||
* greift nur, wenn der Nutzer nichts eingestellt hat. */
|
||||
presetEmpfehlung?: (discTyp: string) => string
|
||||
melden: (status: RipStatus) => void
|
||||
/** Die letzte Metadaten-Zuordnung dieses Laufwerks (W-4) — oder null. */
|
||||
zuordnung?: (id: string) => Zuordnung | null
|
||||
/** Disc-Fingerabdruck fürs Wiedererkennen (§ 6.6) — oder null. */
|
||||
fingerprint?: (id: string) => string | null
|
||||
/** Trägt einen fertigen Vorgang in die Bibliothek ein (§ 6.6). */
|
||||
/** Was die Disc über sich selbst sagt — für die Staffel-Nummer (§ 4.2). */
|
||||
discAngaben?: (id: string) => { staffel: number | null; discNr: number | null } | null
|
||||
/** Trägt einen fertigen Vorgang in die Bibliothek ein (§ 6.6) — für
|
||||
* Audio-CD und ISO, die keine Kompression durchlaufen. */
|
||||
bibliothekEintragen?: (eintrag: BibliothekEintrag) => void
|
||||
/** Zeitstempel für Ordnernamen — injizierbar für Tests. */
|
||||
jetzt?: () => Date
|
||||
/** 5.4.0: Der Rip ist durch — der Kern merkt sich den Vorgang und reiht
|
||||
* die Kompression ein. Liefert den Platz in der Schlange (1 = jetzt). */
|
||||
vorgangEinreihen?: (auftrag: KompressionsAuftrag, titel: string) => number
|
||||
/** 5.6.0: die volle Zuordnung zu einem TMDb-Vorschlag (zweiter Film einer
|
||||
* Disc) — null, wenn nichts zu holen ist. */
|
||||
zuordnungFuer?: (vorschlag: NonNullable<RohDatei['film']>) => Promise<Zuordnung | null>
|
||||
/** 5.4.0: Ein Vorgang beginnt — Datensatz anlegen. */
|
||||
vorgangBeginnt?: (vorgang: VorgangBeginn) => void
|
||||
/** 5.4.0: Der Rip endete ohne Auftrag — Fehler oder Abbruch. */
|
||||
vorgangEndet?: (id: string, phase: 'fehler' | 'abgebrochen', text: string) => void
|
||||
/** 5.4.0: Nach dem Rip auswerfen (Einstellung autoAuswurf) — die nächste
|
||||
* Disc soll rein, während die Kompression läuft. */
|
||||
nachRipAuswerfen?: (id: string) => void
|
||||
/** 5.5.0: Das Rettungs-Abbild lesen — injizierbar für Tests. */
|
||||
rettungsAbbild?: typeof rettungsAbbildDatei
|
||||
}
|
||||
|
||||
/** Der letzte Auftrag je Laufwerk — die Rettung wiederholt genau ihn. */
|
||||
interface LetzterAuftrag {
|
||||
auftrag: RipAuftragTitel[]
|
||||
optionen: RipAuftragOptionen
|
||||
}
|
||||
|
||||
export class Pipeline {
|
||||
private laufend = new Map<string, LaufenderVorgang>()
|
||||
/** 5.5.0: Rippy hat an diesem Laufwerk Lesefehler GESEHEN — nur dann
|
||||
* wird das Rettungs-Abbild angeboten (Commander: nicht bei heilen Discs). */
|
||||
private fehlerGesehen = new Map<string, string>()
|
||||
private letzterAuftrag = new Map<string, LetzterAuftrag>()
|
||||
|
||||
constructor(private readonly umgebung: PipelineUmgebung) {}
|
||||
|
||||
@@ -76,18 +133,60 @@ export class Pipeline {
|
||||
return this.laufend.has(id)
|
||||
}
|
||||
|
||||
/** Warum eine Rettung angeboten wird — '' wenn keine Fehler gesehen wurden. */
|
||||
rettungGrund(id: string): string {
|
||||
return this.fehlerGesehen.get(id) ?? ''
|
||||
}
|
||||
|
||||
/** Beim Entnehmen der Disc: Was gesehen wurde, gilt nicht mehr. */
|
||||
discEntnommen(id: string): void {
|
||||
this.fehlerGesehen.delete(id)
|
||||
this.letzterAuftrag.delete(id)
|
||||
}
|
||||
|
||||
private melden(status: RipStatus): void {
|
||||
const eintrag = this.laufend.get(status.id)
|
||||
if (eintrag !== undefined) eintrag.status = status
|
||||
this.umgebung.melden(status)
|
||||
const mitRettung: RipStatus = { ...status, rettungMoeglich: this.fehlerGesehen.has(status.id) }
|
||||
if (eintrag !== undefined) eintrag.status = mitRettung
|
||||
this.umgebung.melden(mitRettung)
|
||||
}
|
||||
|
||||
private fehler(id: string, text: string): RipStatus {
|
||||
return { id, phase: 'fehler', prozent: -1, text, dateien: [], lesefehler: false }
|
||||
}
|
||||
|
||||
/** Startet die Kette für die ganze Disc ('all'). */
|
||||
async starten(id: string): Promise<void> {
|
||||
/** Bricht nur den GERADE LAUFENDEN Titel ab und macht mit dem nächsten
|
||||
* weiter — der Ausweg bei einem Disc-Defekt (Commander 01.09.2026:
|
||||
* MakeMKV hing eine halbe Stunde an derselben Stelle in Folge 1,
|
||||
* während Folge 2 heil daneben lag). */
|
||||
ueberspringen(id: string): void {
|
||||
const vorgang = this.laufend.get(id)
|
||||
if (vorgang === undefined || vorgang.titelAbbruch === undefined) return
|
||||
vorgang.uebersprungen = true
|
||||
vorgang.titelAbbruch.abort()
|
||||
}
|
||||
|
||||
/** 5.5.0: Rettungs-Abbild + Rip daraus — mit dem letzten Auftrag dieses
|
||||
* Laufwerks. Nur, wenn Rippy vorher Lesefehler gesehen hat. */
|
||||
async rettungStarten(id: string): Promise<void> {
|
||||
const grund = this.fehlerGesehen.get(id)
|
||||
if (grund === undefined) {
|
||||
this.umgebung.melden(
|
||||
this.fehler(id, 'Rettungs-Abbild nur bei GESEHENEN Lesefehlern — an dieser Disc hat Rippy keine bemerkt. Erst normal rippen.'),
|
||||
)
|
||||
return
|
||||
}
|
||||
const letzter = this.letzterAuftrag.get(id)
|
||||
await this.starten(id, letzter?.auftrag ?? [], letzter?.optionen ?? {}, true)
|
||||
}
|
||||
|
||||
/** Startet die Kette. `auftrag` leer heißt: alles rippen (wie früher). */
|
||||
async starten(
|
||||
id: string,
|
||||
auftrag: readonly RipAuftragTitel[] = [],
|
||||
optionen: RipAuftragOptionen = {},
|
||||
rettung = false,
|
||||
): Promise<void> {
|
||||
if (this.laufend.has(id)) {
|
||||
this.umgebung.melden(this.fehler(id, `Auf Laufwerk ${id}: läuft schon ein Vorgang.`))
|
||||
return
|
||||
@@ -125,8 +224,7 @@ export class Pipeline {
|
||||
|
||||
const abbruch = new AbortController()
|
||||
const startZeit = this.umgebung.jetzt?.() ?? new Date()
|
||||
const zeit = startZeit.toISOString().replaceAll(/[:.]/g, '-').slice(0, 19)
|
||||
const vorgangsName = `${geraet.typ}-${zeit}`
|
||||
const vorgangsName = vorgangsNameFuer(geraet, startZeit)
|
||||
|
||||
// § 5 Schritt 2: Musik und Daten gehen eigene Wege (W-7).
|
||||
if (geraet.typ === 'cd') {
|
||||
@@ -142,208 +240,367 @@ export class Pipeline {
|
||||
return
|
||||
}
|
||||
|
||||
const rohOrdner = join(this.umgebung.ablage(), 'roh', vorgangsName)
|
||||
|
||||
const start: RipStatus = {
|
||||
id,
|
||||
phase: 'rippt',
|
||||
prozent: -1,
|
||||
text: `Rippe verlustfrei nach ${rohOrdner} …`,
|
||||
dateien: [],
|
||||
lesefehler: false,
|
||||
}
|
||||
this.laufend.set(id, { abbruch, status: start })
|
||||
this.melden(start)
|
||||
|
||||
const rip = await rippen({
|
||||
programm,
|
||||
geraetepfad: geraet.pfad,
|
||||
zielOrdner: rohOrdner,
|
||||
titel: 'all',
|
||||
abbruch: abbruch.signal,
|
||||
aufFortschritt: (prozent) => {
|
||||
const alt = this.laufend.get(id)?.status
|
||||
if (alt !== undefined) this.melden({ ...alt, prozent })
|
||||
},
|
||||
aufMeldung: (_code, text) => {
|
||||
const alt = this.laufend.get(id)?.status
|
||||
if (alt !== undefined) this.melden({ ...alt, text })
|
||||
},
|
||||
this.letzterAuftrag.set(id, { auftrag: [...auftrag], optionen: { ...optionen } })
|
||||
// 5.6.0: Der Roh-Ordner heißt wie der Film (filme.ts) — der Vorgang
|
||||
// behält seinen Zeitstempel-Namen als Kennung.
|
||||
const rohWurzel = this.umgebung.rohWurzel?.() ?? join(this.umgebung.ablage(), 'roh')
|
||||
const zuordnung = this.umgebung.zuordnung?.(id) ?? null
|
||||
const startAngaben = this.umgebung.discAngaben?.(id) ?? null
|
||||
const rohOrdner = join(
|
||||
rohWurzel,
|
||||
rohOrdnerName(
|
||||
zuordnung,
|
||||
{ staffel: optionen.staffel ?? startAngaben?.staffel ?? null, discNr: startAngaben?.discNr ?? null },
|
||||
vorgangsName,
|
||||
(name) => existsSync(join(rohWurzel, name)),
|
||||
),
|
||||
)
|
||||
mkdirSync(rohOrdner, { recursive: true })
|
||||
const anzeigeTitel = zuordnung !== null && zuordnung.titel.length > 0 ? zuordnung.titel : vorgangsName
|
||||
this.umgebung.vorgangBeginnt?.({
|
||||
id: vorgangsName,
|
||||
laufwerk: id,
|
||||
typ: geraet.typ,
|
||||
titel: anzeigeTitel,
|
||||
jahr: zuordnung?.jahr ?? null,
|
||||
rohOrdner,
|
||||
begonnen: startZeit.toISOString(),
|
||||
})
|
||||
|
||||
if (rip.status === 'cancelled') {
|
||||
const alleDateien = new Set<string>()
|
||||
let lesefehlerGesamt = false
|
||||
const hinweise: string[] = []
|
||||
|
||||
this.laufend.set(id, {
|
||||
abbruch,
|
||||
status: { id, phase: 'rippt', prozent: -1, text: 'Beginne …', dateien: [], lesefehler: false, vorgangId: vorgangsName },
|
||||
})
|
||||
|
||||
const beenden = (phase: 'fehler' | 'abgebrochen', text: string): void => {
|
||||
this.laufend.delete(id)
|
||||
this.melden({ id, phase: 'abgebrochen', prozent: -1, text: 'Abgebrochen.', dateien: [], lesefehler: false })
|
||||
return
|
||||
}
|
||||
if (rip.status === 'error') {
|
||||
this.laufend.delete(id)
|
||||
this.melden({ id, phase: 'fehler', prozent: -1, text: rip.fehler, dateien: [], lesefehler: rip.lesefehler })
|
||||
return
|
||||
}
|
||||
|
||||
const lesefehlerHinweis = rip.lesefehler
|
||||
? ' ⚠ Die Disc ließ sich stellenweise nicht lesen — mindestens ein Titel könnte fehlen (makemkv.com/errors/read).'
|
||||
: ''
|
||||
|
||||
// ── Kompression (W-3) ────────────────────────────────────────────────
|
||||
const einstellungen = this.umgebung.einstellungen()
|
||||
const handbrake = this.umgebung.handbrake()
|
||||
const soll = komprimierenFuer(geraet.typ, einstellungen)
|
||||
|
||||
if (!soll || handbrake.length === 0) {
|
||||
const grund = !soll
|
||||
? `Kompression für ${geraet.typ} ist abgewählt — verlustfreier Rip bleibt stehen.`
|
||||
: 'HandBrakeCLI nicht gefunden — verlustfreier Rip bleibt stehen.'
|
||||
this.laufend.delete(id)
|
||||
this.melden({
|
||||
id,
|
||||
phase: 'fertig',
|
||||
prozent: 100,
|
||||
text: `Fertig. ${grund}${lesefehlerHinweis}`,
|
||||
dateien: rip.dateien,
|
||||
lesefehler: rip.lesefehler,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const preset = presetFuer(geraet.typ, einstellungen, this.umgebung.presetEmpfehlung?.(geraet.typ) ?? '')
|
||||
// Mit Metadaten (W-4) heißt der Ordner „<Titel> (Jahr)" — daran
|
||||
// erkennen Jellyfin & Co. den Film; ohne bleibt der Vorgangsname.
|
||||
const zuordnung = this.umgebung.zuordnung?.(id) ?? null
|
||||
const fertigOrdner =
|
||||
zuordnung !== null && zuordnung.titel.length > 0
|
||||
? zielordner(join(this.umgebung.ablage(), 'Filme'), zuordnung.titel, zuordnung.jahr, vorgangsName)
|
||||
: zielordner(join(this.umgebung.ablage(), 'Filme'), vorgangsName, null, vorgangsName)
|
||||
mkdirSync(fertigOrdner, { recursive: true })
|
||||
|
||||
const fertigeDateien: string[] = []
|
||||
for (let i = 0; i < rip.dateien.length; i++) {
|
||||
const eingabe = rip.dateien[i]
|
||||
const ausgabe = join(fertigOrdner, basename(eingabe))
|
||||
const alt = this.laufend.get(id)?.status
|
||||
if (alt !== undefined) {
|
||||
this.melden({
|
||||
...alt,
|
||||
phase: 'komprimiert',
|
||||
prozent: -1,
|
||||
text: `Komprimiere ${i + 1}/${rip.dateien.length}: ${basename(eingabe)} („${preset}") …`,
|
||||
})
|
||||
}
|
||||
const encode = await komprimieren({
|
||||
programm: handbrake,
|
||||
eingabe,
|
||||
ausgabe,
|
||||
preset,
|
||||
audioSprachen: sprachliste(String(einstellungen['audioSprachen'] ?? '')),
|
||||
untertitelSprachen: sprachliste(String(einstellungen['untertitelSprachen'] ?? '')),
|
||||
abbruch: abbruch.signal,
|
||||
aufFortschritt: (prozent) => {
|
||||
const stand = this.laufend.get(id)?.status
|
||||
if (stand !== undefined) {
|
||||
this.melden({ ...stand, prozent: Math.floor((i * 100 + prozent) / rip.dateien.length) })
|
||||
}
|
||||
},
|
||||
})
|
||||
if (encode.status === 'cancelled') {
|
||||
this.laufend.delete(id)
|
||||
this.melden({
|
||||
id,
|
||||
phase: 'abgebrochen',
|
||||
prozent: -1,
|
||||
text: 'Abgebrochen — der verlustfreie Rip bleibt stehen.',
|
||||
dateien: rip.dateien,
|
||||
lesefehler: rip.lesefehler,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (encode.status === 'error') {
|
||||
this.laufend.delete(id)
|
||||
this.melden({
|
||||
id,
|
||||
phase: 'fehler',
|
||||
prozent: -1,
|
||||
text: `Kompression fehlgeschlagen: ${encode.fehler} — der verlustfreie Rip bleibt stehen (${rohOrdner}).`,
|
||||
dateien: rip.dateien,
|
||||
lesefehler: rip.lesefehler,
|
||||
})
|
||||
return
|
||||
}
|
||||
fertigeDateien.push(encode.ausgabe)
|
||||
}
|
||||
|
||||
// ── Ablage (W-3): NFO daneben; echte Titel kommen mit W-4 ───────────
|
||||
const alt = this.laufend.get(id)?.status
|
||||
if (alt !== undefined) {
|
||||
this.melden({ ...alt, phase: 'legt-ab', prozent: 100, text: 'Lege ab …', dateien: fertigeDateien })
|
||||
}
|
||||
try {
|
||||
const nfoName = zuordnung?.meta.typ === 'tv' ? 'tvshow.nfo' : 'movie.nfo'
|
||||
writeFileSync(
|
||||
join(fertigOrdner, nfoName),
|
||||
baueNfo(
|
||||
{
|
||||
typ: zuordnung?.meta.typ === 'tv' ? 'serie' : 'film',
|
||||
beschreibung: zuordnung?.meta.beschreibung,
|
||||
genres: zuordnung?.meta.genres,
|
||||
laufzeitMinuten: zuordnung?.meta.laufzeitMinuten,
|
||||
quelle: zuordnung !== null && zuordnung.meta.quelle.length > 0 ? `Rippy v5 (${zuordnung.meta.quelle})` : 'Rippy v5',
|
||||
},
|
||||
zuordnung !== null && zuordnung.titel.length > 0 ? zuordnung.titel : vorgangsName,
|
||||
zuordnung?.jahr ?? null,
|
||||
),
|
||||
)
|
||||
} catch (fehler) {
|
||||
// Ein fehlendes NFO darf einen gelungenen Rip nicht kippen — aber es
|
||||
// wird GESAGT (R4).
|
||||
const stand = this.laufend.get(id)?.status
|
||||
if (stand !== undefined) this.melden({ ...stand, text: `NFO fehlgeschlagen: ${String(fehler)}` })
|
||||
}
|
||||
if (zuordnung !== null && zuordnung.meta.posterPfad.length > 0) {
|
||||
await posterSpeichern(zuordnung.meta.posterPfad, join(fertigOrdner, 'poster.jpg'))
|
||||
}
|
||||
|
||||
const server = String(einstellungen['mediaServer'] ?? '')
|
||||
const serverUrl = String(einstellungen['mediaServerUrl'] ?? '')
|
||||
const serverKey = String(einstellungen['mediaServerApiKey'] ?? '')
|
||||
const refresh = await bibliothekRefresh(server, serverUrl, serverKey)
|
||||
|
||||
// § 6.6: Merken — beim nächsten Einlegen wird die Disc wiedererkannt.
|
||||
const fingerprint = this.umgebung.fingerprint?.(id) ?? null
|
||||
if (fingerprint !== null && this.umgebung.bibliothekEintragen !== undefined) {
|
||||
let groesse = 0
|
||||
for (const datei of fertigeDateien) {
|
||||
// Ein Roh-Ordner ohne eine einzige brauchbare Datei ist Müll (5.4.0,
|
||||
// Punkt „Aufräumen nach Abbruch").
|
||||
if (alleDateien.size === 0) {
|
||||
try {
|
||||
groesse += statSync(datei).size
|
||||
} catch {
|
||||
// Größe ist Anzeige-Komfort — eine unlesbare Datei ändert nichts
|
||||
// am Eintrag selbst.
|
||||
rmSync(rohOrdner, { recursive: true, force: true })
|
||||
} catch (fehler) {
|
||||
text += ` (Roh-Ordner ließ sich nicht entfernen: ${String(fehler)})`
|
||||
}
|
||||
}
|
||||
this.umgebung.bibliothekEintragen({
|
||||
fingerprint,
|
||||
titel: zuordnung !== null && zuordnung.titel.length > 0 ? zuordnung.titel : vorgangsName,
|
||||
jahr: zuordnung?.jahr ?? null,
|
||||
typ: geraet.typ,
|
||||
groesseBytes: groesse,
|
||||
ablageort: fertigOrdner,
|
||||
datum: startZeit.toISOString(),
|
||||
dauerS: Math.round(((this.umgebung.jetzt?.() ?? new Date()).getTime() - startZeit.getTime()) / 1000),
|
||||
posterPfad: zuordnung?.meta.posterPfad ?? '',
|
||||
this.umgebung.vorgangEndet?.(vorgangsName, phase, text)
|
||||
this.melden({
|
||||
id,
|
||||
phase,
|
||||
prozent: -1,
|
||||
text,
|
||||
dateien: [...alleDateien],
|
||||
lesefehler: lesefehlerGesamt,
|
||||
vorgangId: vorgangsName,
|
||||
})
|
||||
}
|
||||
|
||||
// ── 5.5.0: Rettungs-Abbild — die Quelle des Rips wird die ISO ────────
|
||||
let quellePfad = geraet.pfad
|
||||
if (rettung) {
|
||||
const isoPfad = join(rohOrdner, 'rettung.iso')
|
||||
this.melden({
|
||||
id,
|
||||
phase: 'rettet',
|
||||
prozent: 0,
|
||||
text: `Rettungs-Abbild: lese die Disc Sektor für Sektor nach ${isoPfad} — Unlesbares wird mit Nullen gefüllt …`,
|
||||
dateien: [],
|
||||
lesefehler: false,
|
||||
vorgangId: vorgangsName,
|
||||
rettung: { unlesbareSektoren: 0, stellen: 0 },
|
||||
})
|
||||
const lesen = this.umgebung.rettungsAbbild ?? rettungsAbbildDatei
|
||||
const ergebnis = await lesen(
|
||||
geraet.pfad,
|
||||
isoPfad,
|
||||
geraet.groesseBytes,
|
||||
(prozent, unlesbar, stellen) => {
|
||||
const alt = this.laufend.get(id)?.status
|
||||
if (alt !== undefined) {
|
||||
this.melden({
|
||||
...alt,
|
||||
phase: 'rettet',
|
||||
prozent,
|
||||
text: `Rettungs-Abbild: ${prozent} % — ${unlesbar} unlesbare Sektoren an ${stellen} Stellen (mit Nullen gefüllt)`,
|
||||
rettung: { unlesbareSektoren: unlesbar, stellen },
|
||||
})
|
||||
}
|
||||
},
|
||||
abbruch.signal,
|
||||
)
|
||||
if (ergebnis.status === 'cancelled') {
|
||||
beenden('abgebrochen', 'Rettungs-Abbild abgebrochen.')
|
||||
return
|
||||
}
|
||||
if (ergebnis.status === 'error') {
|
||||
beenden('fehler', `Rettungs-Abbild gescheitert: ${ergebnis.fehler}`)
|
||||
return
|
||||
}
|
||||
hinweise.push(
|
||||
`Rettungs-Abbild: ${ergebnis.unlesbareSektoren} unlesbare Sektoren an ${ergebnis.stellen.length} Stellen mit Nullen gefüllt (Bericht: rettung.iso.rettung.txt).`,
|
||||
)
|
||||
quellePfad = isoPfad
|
||||
this.melden({
|
||||
id,
|
||||
phase: 'rippt',
|
||||
prozent: -1,
|
||||
text: `Rettungs-Abbild fertig (${ergebnis.unlesbareSektoren} Sektoren genullt) — rippe jetzt aus dem Abbild …`,
|
||||
dateien: [],
|
||||
lesefehler: false,
|
||||
vorgangId: vorgangsName,
|
||||
rettung: { unlesbareSektoren: ergebnis.unlesbareSektoren, stellen: ergebnis.stellen.length },
|
||||
})
|
||||
}
|
||||
|
||||
// TITEL FÜR TITEL statt einem 'all'-Aufruf (Commander-Wunsch
|
||||
// 01.09.2026). Zwei Gründe, und beide brauchen denselben Umbau:
|
||||
// * Auswählen geht nur so — vorher rippte Rippy IMMER alles, samt
|
||||
// Trailer, Werbung und Alterskennzeichen.
|
||||
// * Ein Titel, der an einem Disc-Defekt hängen bleibt, reißt nicht
|
||||
// mehr den ganzen Auftrag mit. Bei der Spartacus-Disc lag der
|
||||
// Defekt in Folge 1 — Folge 2 war heil und ging trotzdem verloren.
|
||||
const laeufe = auftrag.length > 0 ? auftrag.map((a) => String(a.nr)) : ['all']
|
||||
// Durchgehender Balken (5.4.0): nach der Größe der Titel gewichtet —
|
||||
// vorher sprang er je Titel zurück auf null.
|
||||
const gewichte = auftrag.length > 0 ? auftrag.map((a) => a.groesseBytes ?? 0) : [1]
|
||||
// Welche Rolle bekam welche Datei? Die Zuordnung ist EXAKT, weil je
|
||||
// Lauf genau ein Titel dazukommt — nicht aus Dateinamen geraten.
|
||||
const auftragJeDatei = new Map<string, RipAuftragTitel | undefined>()
|
||||
const gescheitert: string[] = []
|
||||
const uebersprungen: string[] = []
|
||||
const aufgeraeumt: string[] = []
|
||||
|
||||
for (let i = 0; i < laeufe.length; i++) {
|
||||
const welcher = laeufe[i]
|
||||
// Eigener Abbruch je Titel: „überspringen" beendet NUR diesen Lauf,
|
||||
// „abbrechen" beendet den ganzen Auftrag — und reicht nach unten durch.
|
||||
const titelAbbruch = new AbortController()
|
||||
const weiterreichen = (): void => titelAbbruch.abort()
|
||||
abbruch.signal.addEventListener('abort', weiterreichen, { once: true })
|
||||
const vorgang = this.laufend.get(id)
|
||||
if (vorgang !== undefined) {
|
||||
vorgang.titelAbbruch = titelAbbruch
|
||||
vorgang.uebersprungen = false
|
||||
}
|
||||
|
||||
const beschriftung =
|
||||
welcher === 'all' ? 'alle Titel' : `Titel ${welcher} (${i + 1} von ${laeufe.length})`
|
||||
this.melden({
|
||||
id,
|
||||
phase: 'rippt',
|
||||
prozent: gewichteterFortschritt(gewichte, i, -1),
|
||||
text: `Rippe ${beschriftung} verlustfrei nach ${rohOrdner} …${rettung ? ' (aus dem Rettungs-Abbild)' : ''}`,
|
||||
dateien: [...alleDateien],
|
||||
lesefehler: lesefehlerGesamt,
|
||||
titelNr: i + 1,
|
||||
titelGesamt: laeufe.length,
|
||||
haengt: null,
|
||||
vorgangId: vorgangsName,
|
||||
})
|
||||
|
||||
// Was VOR diesem Titel schon da lag — alles Neue nach einem Abbruch
|
||||
// oder Fehler ist eine halbe Datei und wird entfernt (5.4.0).
|
||||
const vorher = dateienIn(rohOrdner)
|
||||
const rip = await rippen({
|
||||
programm,
|
||||
geraetepfad: quellePfad,
|
||||
zielOrdner: rohOrdner,
|
||||
titel: welcher,
|
||||
abbruch: titelAbbruch.signal,
|
||||
aufFortschritt: (prozent) => {
|
||||
const alt = this.laufend.get(id)?.status
|
||||
if (alt !== undefined) this.melden({ ...alt, prozent: gewichteterFortschritt(gewichte, i, prozent) })
|
||||
},
|
||||
aufMeldung: (_code, text) => {
|
||||
const alt = this.laufend.get(id)?.status
|
||||
if (alt !== undefined) this.melden({ ...alt, text })
|
||||
},
|
||||
aufHaengt: (klemmt) => {
|
||||
// Der Defekt-Wächter hat angeschlagen: Ab jetzt ist die Rettung
|
||||
// eine Option (5.5.0).
|
||||
if (klemmt !== null && !rettung) this.fehlerGesehen.set(id, `Defekt-Wächter: hängt an Byte ${klemmt.offset}`)
|
||||
const alt = this.laufend.get(id)?.status
|
||||
if (alt !== undefined) this.melden({ ...alt, haengt: klemmt })
|
||||
},
|
||||
})
|
||||
abbruch.signal.removeEventListener('abort', weiterreichen)
|
||||
|
||||
if (rip.status === 'cancelled') {
|
||||
aufgeraeumt.push(...halbeDateienEntfernen(rohOrdner, vorher, rettung ? ['rettung.iso', 'rettung.iso.rettung.txt'] : []))
|
||||
// Nur dieser Titel — oder der ganze Auftrag?
|
||||
if (this.laufend.get(id)?.uebersprungen !== true) {
|
||||
beenden(
|
||||
'abgebrochen',
|
||||
'Abgebrochen.' +
|
||||
(aufgeraeumt.length > 0 ? ` Halbe Datei entfernt: ${aufgeraeumt.join(', ')}.` : '') +
|
||||
(alleDateien.size > 0 ? ` ${alleDateien.size} fertige Datei(en) bleiben unter ${rohOrdner}.` : ''),
|
||||
)
|
||||
return
|
||||
}
|
||||
uebersprungen.push(beschriftung)
|
||||
continue
|
||||
}
|
||||
|
||||
lesefehlerGesamt = lesefehlerGesamt || rip.lesefehler
|
||||
if (rip.lesefehler && !rettung) this.fehlerGesehen.set(id, 'MakeMKV meldete Lesefehler')
|
||||
if (rip.status === 'error') {
|
||||
// EIN kaputter Titel beendet den Auftrag nicht — die anderen sind
|
||||
// vielleicht heil. Der Grund wird gesammelt und am Ende genannt.
|
||||
// Was dieser Lauf hinterlassen hat, ist nicht vertrauenswürdig.
|
||||
if (!rettung) this.fehlerGesehen.set(id, `Titel gescheitert: ${rip.fehler.slice(0, 80)}`)
|
||||
aufgeraeumt.push(...halbeDateienEntfernen(rohOrdner, vorher, rettung ? ['rettung.iso', 'rettung.iso.rettung.txt'] : []))
|
||||
gescheitert.push(`${beschriftung}: ${rip.fehler}`)
|
||||
continue
|
||||
}
|
||||
// rippen() liefert den ganzen Zielordner zurück, also auch die
|
||||
// Dateien der vorigen Titel — neu ist, was noch nicht bekannt war.
|
||||
const neu = rip.dateien.filter((d) => !alleDateien.has(d) && d.toLowerCase().endsWith('.mkv'))
|
||||
for (const d of rip.dateien) if (d.toLowerCase().endsWith('.mkv')) alleDateien.add(d)
|
||||
for (const d of neu) auftragJeDatei.set(d, auftrag[i])
|
||||
if (neu.length === 0) {
|
||||
// MakeMKV kann mit Code 0 enden und trotzdem nichts abgelegt haben
|
||||
// (30.08.2026 gemessen) — dann ist dieser Titel NICHT gesichert.
|
||||
gescheitert.push(`${beschriftung}: keine Datei entstanden`)
|
||||
}
|
||||
}
|
||||
|
||||
const nachLauf = this.laufend.get(id)
|
||||
if (nachLauf !== undefined) nachLauf.titelAbbruch = undefined
|
||||
|
||||
if (alleDateien.size === 0) {
|
||||
beenden('fehler', gescheitert.length > 0 ? gescheitert.join(' · ') : 'Kein Titel konnte gesichert werden.')
|
||||
return
|
||||
}
|
||||
|
||||
if (uebersprungen.length > 0) hinweise.push(`Übersprungen: ${uebersprungen.join(', ')}.`)
|
||||
if (gescheitert.length > 0) hinweise.push(`Nicht gesichert: ${gescheitert.join(' · ')}`)
|
||||
if (aufgeraeumt.length > 0) hinweise.push(`Halbe Dateien entfernt: ${aufgeraeumt.join(', ')}.`)
|
||||
|
||||
// ── Übergabe an die Warteschlange (5.4.0) ──────────────────────────
|
||||
const dateien: RohDatei[] = [...alleDateien].sort().map((pfad) => {
|
||||
const a = auftragJeDatei.get(pfad)
|
||||
const rolle: TitelRolle = a?.rolle ?? 'hauptfilm'
|
||||
return {
|
||||
pfad,
|
||||
titelNr: a?.nr ?? -1,
|
||||
rolle,
|
||||
dauerS: a?.dauerS ?? 0,
|
||||
groesseBytes: groesseVon(pfad),
|
||||
audioSprachen: a?.audioSprachen ?? [],
|
||||
untertitelSprachen: a?.untertitelSprachen ?? [],
|
||||
...(a?.untertitelVorwahl === null || typeof a?.untertitelVorwahl === 'string' ? { untertitelVorwahl: a.untertitelVorwahl } : {}),
|
||||
...(a?.film !== undefined ? { film: a.film } : {}),
|
||||
}
|
||||
})
|
||||
const angaben = this.umgebung.discAngaben?.(id) ?? null
|
||||
|
||||
// ── 5.6.0: Titel eines ANDEREN Films bekommen eigenen Roh-Ordner und
|
||||
// Vorgang — jeder Film seinen Ordner (Commander, Double Feature). ──
|
||||
const gruppen = filmGruppen(dateien, zuordnung !== null && zuordnung.meta.typ === 'movie' ? Number(zuordnung.meta.id) : null)
|
||||
let hauptDateien = gruppen.primaer
|
||||
let hauptZuordnung = zuordnung
|
||||
if (gruppen.primaerFilm !== null) {
|
||||
hauptZuordnung = (await this.umgebung.zuordnungFuer?.(gruppen.primaerFilm)) ?? zuordnungAusVorschlag(gruppen.primaerFilm)
|
||||
}
|
||||
const weitereAuftraege: Array<{ auftrag: Omit<KompressionsAuftrag, 'vorgangId' | 'rohOrdner' | 'dateien' | 'zuordnung'> & Pick<KompressionsAuftrag, 'vorgangId' | 'rohOrdner' | 'dateien' | 'zuordnung'>; titel: string }> = []
|
||||
for (let n = 0; n < gruppen.weitere.length; n++) {
|
||||
const gruppe = gruppen.weitere[n]
|
||||
const gruppenZuordnung = (await this.umgebung.zuordnungFuer?.(gruppe.film)) ?? zuordnungAusVorschlag(gruppe.film)
|
||||
const gruppenId = `${vorgangsName}-${n + 2}`
|
||||
const gruppenOrdner = join(rohWurzel, rohOrdnerName(gruppenZuordnung, null, gruppenId, (name) => existsSync(join(rohWurzel, name))))
|
||||
try {
|
||||
mkdirSync(gruppenOrdner, { recursive: true })
|
||||
for (const d of gruppe.dateien) {
|
||||
const neu = join(gruppenOrdner, basename(d.pfad))
|
||||
renameSync(d.pfad, neu)
|
||||
d.pfad = neu
|
||||
}
|
||||
} catch (fehler) {
|
||||
hinweise.push(`„${gruppe.film.titel}" bekam keinen eigenen Roh-Ordner (${String(fehler)}) — die Titel bleiben beim ersten Film.`)
|
||||
hauptDateien = [...hauptDateien, ...gruppe.dateien]
|
||||
continue
|
||||
}
|
||||
this.umgebung.vorgangBeginnt?.({
|
||||
id: gruppenId,
|
||||
laufwerk: id,
|
||||
typ: geraet.typ,
|
||||
titel: gruppenZuordnung.titel,
|
||||
jahr: gruppenZuordnung.jahr,
|
||||
rohOrdner: gruppenOrdner,
|
||||
begonnen: startZeit.toISOString(),
|
||||
})
|
||||
weitereAuftraege.push({
|
||||
titel: gruppenZuordnung.titel,
|
||||
auftrag: {
|
||||
vorgangId: gruppenId,
|
||||
laufwerk: id,
|
||||
discTyp: geraet.typ,
|
||||
rohOrdner: gruppenOrdner,
|
||||
dateien: gruppe.dateien,
|
||||
zuordnung: gruppenZuordnung,
|
||||
staffel: null,
|
||||
ersteFolge: null,
|
||||
discNr: angaben?.discNr ?? null,
|
||||
fingerprint: null,
|
||||
lesefehler: lesefehlerGesamt,
|
||||
hinweise: [],
|
||||
begonnen: startZeit.toISOString(),
|
||||
preset: '',
|
||||
zielOrdner: '',
|
||||
},
|
||||
})
|
||||
}
|
||||
const hauptTitel = hauptZuordnung !== null && hauptZuordnung.titel.length > 0 ? hauptZuordnung.titel : anzeigeTitel
|
||||
|
||||
const kompression: KompressionsAuftrag = {
|
||||
vorgangId: vorgangsName,
|
||||
laufwerk: id,
|
||||
discTyp: geraet.typ,
|
||||
rohOrdner,
|
||||
dateien: hauptDateien,
|
||||
zuordnung: hauptZuordnung,
|
||||
staffel: optionen.staffel ?? angaben?.staffel ?? null,
|
||||
ersteFolge: optionen.ersteFolge ?? null,
|
||||
discNr: angaben?.discNr ?? null,
|
||||
fingerprint: this.umgebung.fingerprint?.(id) ?? null,
|
||||
lesefehler: lesefehlerGesamt,
|
||||
hinweise,
|
||||
begonnen: startZeit.toISOString(),
|
||||
preset: '',
|
||||
zielOrdner: '',
|
||||
}
|
||||
const platz = this.umgebung.vorgangEinreihen?.(kompression, hauptTitel) ?? 0
|
||||
const weitereTitel: string[] = []
|
||||
for (const w of weitereAuftraege) {
|
||||
this.umgebung.vorgangEinreihen?.(w.auftrag, w.titel)
|
||||
weitereTitel.push(w.titel)
|
||||
}
|
||||
const rohBytes = dateien.reduce((s, d) => s + d.groesseBytes, 0)
|
||||
|
||||
// Das Laufwerk ist FREI — die Kompression läuft ohne es weiter.
|
||||
this.laufend.delete(id)
|
||||
this.umgebung.nachRipAuswerfen?.(id)
|
||||
this.melden({
|
||||
id,
|
||||
phase: 'fertig',
|
||||
prozent: 100,
|
||||
text:
|
||||
`Fertig — komprimiert nach ${fertigOrdner}.` +
|
||||
(refresh.length > 0 ? ` ${refresh}` : '') +
|
||||
lesefehlerHinweis,
|
||||
dateien: fertigeDateien,
|
||||
lesefehler: rip.lesefehler,
|
||||
`Verlustfrei gesichert: ${dateien.length} Titel, ${(rohBytes / 1e9).toFixed(2).replace('.', ',')} GB — ` +
|
||||
(platz > 0
|
||||
? `Kompression eingereiht${platz > 1 ? ` (Platz ${platz})` : ''}, das Laufwerk ist frei.`
|
||||
: 'Kompression nicht eingereiht (kein Empfänger) — die Roh-Dateien liegen unter ' + rohOrdner + '.') +
|
||||
(weitereTitel.length > 0 ? ` Weitere${weitereTitel.length === 1 ? 'r' : ''} Film${weitereTitel.length === 1 ? '' : 'e'} mit eigenem Vorgang: ${weitereTitel.join(', ')}.` : '') +
|
||||
(lesefehlerGesamt
|
||||
? ' ⚠ Die Disc ließ sich stellenweise nicht lesen — mindestens ein Titel könnte fehlen (makemkv.com/errors/read).'
|
||||
: '') +
|
||||
(hinweise.length > 0 ? ` ⚠ ${hinweise.join(' ')}` : ''),
|
||||
dateien: dateien.map((d) => d.pfad),
|
||||
lesefehler: lesefehlerGesamt,
|
||||
vorgangId: vorgangsName,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -435,13 +692,7 @@ export class Pipeline {
|
||||
const fingerprint = kennung.length > 0 ? `mb:${kennung}` : (this.umgebung.fingerprint?.(id) ?? null)
|
||||
if (fingerprint !== null && this.umgebung.bibliothekEintragen !== undefined) {
|
||||
let groesse = 0
|
||||
for (const datei of dateien) {
|
||||
try {
|
||||
groesse += statSync(datei).size
|
||||
} catch {
|
||||
// Anzeige-Komfort — siehe oben.
|
||||
}
|
||||
}
|
||||
for (const datei of dateien) groesse += groesseVon(datei)
|
||||
this.umgebung.bibliothekEintragen({
|
||||
fingerprint,
|
||||
titel: ordnerName,
|
||||
@@ -504,22 +755,54 @@ export class Pipeline {
|
||||
}
|
||||
}
|
||||
|
||||
function vorgangsNameFuer(geraet: GeraeteInfo, startZeit: Date): string {
|
||||
export function vorgangsNameFuer(geraet: GeraeteInfo, startZeit: Date): string {
|
||||
return `${geraet.typ}-${startZeit.toISOString().replaceAll(/[:.]/g, '-').slice(0, 19)}`
|
||||
}
|
||||
|
||||
function groesseVon(pfad: string): number {
|
||||
try {
|
||||
return statSync(pfad).size
|
||||
} catch {
|
||||
// Größe ist Anzeige-Komfort — eine unlesbare Datei ändert nichts an
|
||||
// der Liste selbst.
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
function dateienIn(ordner: string): Set<string> {
|
||||
try {
|
||||
return new Set(readdirSync(ordner))
|
||||
} catch {
|
||||
// Ein nicht lesbarer Ordner: dann gilt „nichts war vorher da" — und
|
||||
// im Zweifel wird nichts gelöscht, weil auch nachher nichts lesbar ist.
|
||||
return new Set()
|
||||
}
|
||||
}
|
||||
|
||||
/** Entfernt, was seit `vorher` im Ordner dazukam — die halbe Datei eines
|
||||
* abgebrochenen oder gescheiterten Titels. `schonen` bleibt immer liegen
|
||||
* (das Rettungs-Abbild). Liefert die Namen. */
|
||||
export function halbeDateienEntfernen(ordner: string, vorher: ReadonlySet<string>, schonen: readonly string[] = []): string[] {
|
||||
const entfernt: string[] = []
|
||||
for (const name of dateienIn(ordner)) {
|
||||
if (vorher.has(name) || schonen.includes(name)) continue
|
||||
try {
|
||||
unlinkSync(join(ordner, name))
|
||||
entfernt.push(name)
|
||||
} catch (fehler) {
|
||||
// Nicht löschbar (noch offen?) — das steht dann als Name mit Grund da.
|
||||
entfernt.push(`${name} (nicht gelöscht: ${String(fehler).slice(0, 60)})`)
|
||||
}
|
||||
}
|
||||
return entfernt
|
||||
}
|
||||
|
||||
function lauf(programm: string, args: string[]): Promise<{ code: number | null; fehler: string }> {
|
||||
return new Promise((fertig) => {
|
||||
const kind = spawn(programm, args, { windowsHide: true, stdio: ['ignore', 'ignore', 'pipe'] })
|
||||
const kind = werkzeugSpawn(programm, args, { windowsHide: true, stdio: ['ignore', 'ignore', 'pipe'] })
|
||||
const stuecke: Buffer[] = []
|
||||
kind.stderr.on('data', (s: Buffer) => stuecke.push(s))
|
||||
kind.on('error', (fehler) => fertig({ code: null, fehler: String(fehler) }))
|
||||
kind.on('close', (code) => fertig({ code, fehler: Buffer.concat(stuecke).toString('utf8') }))
|
||||
})
|
||||
}
|
||||
|
||||
// Für einen späteren Wiederanlauf „ab Kompression" (W-5/W-7): eine schon
|
||||
// gerippte Datei ohne Laufwerk durch Kompression + Ablage schicken.
|
||||
export function kopierenStattEncode(eingabe: string, ausgabe: string): void {
|
||||
copyFileSync(eingabe, ausgabe)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
// Die Kompressions-Warteschlange (5.4.0) — KONZEPT § 4.2 nannte sie
|
||||
// `auftraege.ts`: Warteschlange, Slots. Seit 5.4.0 sind Rip und Kompression
|
||||
// entkoppelt (Commander-Wahl 01.09.2026: „Nächste Disc rippen, während
|
||||
// die vorige noch komprimiert"). Das Laufwerk ist frei, sobald die
|
||||
// Roh-Dateien liegen; die Kompressionen laufen hier der Reihe nach —
|
||||
// EINE zur Zeit, denn zwei HandBrakes teilen sich dieselbe Grafikkarte und
|
||||
// werden zusammen nicht schneller.
|
||||
//
|
||||
// 5.5.0: Pause (Commander: „zum Beispiel beim Spielen") — der laufende
|
||||
// Film wird fertig gerechnet, der nächste wartet, bis es weitergeht.
|
||||
//
|
||||
// PUR: Was ausgeführt wird, kommt injiziert (ablegen.ts) — die Ampel
|
||||
// prüft das Verhalten der Schlange auf Linux ohne HandBrake.
|
||||
import type { KompressionStand } from '../../gemeinsam/nachrichten'
|
||||
import type { KompressionsAuftrag } from './auftrag'
|
||||
import type { AblageErgebnis, AblageMeldung } from './ablegen'
|
||||
|
||||
export type Ausfuehren = (
|
||||
auftrag: KompressionsAuftrag,
|
||||
melden: AblageMeldung,
|
||||
abbruch: AbortSignal,
|
||||
) => Promise<AblageErgebnis>
|
||||
|
||||
interface Eintrag {
|
||||
auftrag: KompressionsAuftrag
|
||||
titel: string
|
||||
abbruch: AbortController
|
||||
}
|
||||
|
||||
export interface WarteschlangenStand {
|
||||
laeuft: KompressionStand | null
|
||||
wartend: KompressionStand[]
|
||||
}
|
||||
|
||||
export class KompressionsWarteschlange {
|
||||
private wartend: Eintrag[] = []
|
||||
private laufend: Eintrag | null = null
|
||||
private laufendStand: KompressionStand | null = null
|
||||
private arbeitet = false
|
||||
private pausiert = false
|
||||
|
||||
constructor(
|
||||
private readonly ausfuehren: Ausfuehren,
|
||||
/** Jede Änderung — Einreihen, Fortschritt, Ende — geht hier hinaus. */
|
||||
private readonly beiStand: (stand: WarteschlangenStand) => void,
|
||||
/** Ein Auftrag ist zu Ende (auch abgebrochen/gescheitert). */
|
||||
private readonly beiErgebnis: (auftrag: KompressionsAuftrag, ergebnis: AblageErgebnis) => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
/** Reiht ein und liefert den Platz (1 = läuft jetzt oder als Nächstes).
|
||||
* Ein Vorgang, der schon drin ist, wird nicht doppelt eingereiht. */
|
||||
einreihen(auftrag: KompressionsAuftrag, titel: string): number {
|
||||
if (this.laufend?.auftrag.vorgangId === auftrag.vorgangId) return 1
|
||||
const schon = this.wartend.findIndex((e) => e.auftrag.vorgangId === auftrag.vorgangId)
|
||||
if (schon >= 0) return schon + (this.laufend === null ? 1 : 2)
|
||||
this.wartend.push({ auftrag, titel, abbruch: new AbortController() })
|
||||
const platz = this.wartend.length + (this.laufend === null ? 0 : 1)
|
||||
this.beiStand(this.stand())
|
||||
void this.schleife()
|
||||
return platz
|
||||
}
|
||||
|
||||
/** Bricht einen wartenden (entfernt) oder laufenden (Signal) Auftrag ab. */
|
||||
abbrechen(vorgangId: string): boolean {
|
||||
const i = this.wartend.findIndex((e) => e.auftrag.vorgangId === vorgangId)
|
||||
if (i >= 0) {
|
||||
const [weg] = this.wartend.splice(i, 1)
|
||||
this.beiStand(this.stand())
|
||||
void this.beiErgebnis(weg.auftrag, {
|
||||
status: 'abgebrochen',
|
||||
zielOrdner: '',
|
||||
dateien: [],
|
||||
text: 'Aus der Warteschlange genommen — die Roh-Dateien bleiben stehen.',
|
||||
komprimiert: false,
|
||||
preset: '',
|
||||
fertigBytes: 0,
|
||||
hinweise: [],
|
||||
folgenVergeben: [],
|
||||
})
|
||||
return true
|
||||
}
|
||||
if (this.laufend?.auftrag.vorgangId === vorgangId) {
|
||||
this.laufend.abbruch.abort()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Pause: Der laufende Auftrag wird fertig, der nächste startet erst nach
|
||||
* `pausieren(false)`. */
|
||||
pausieren(an: boolean): void {
|
||||
this.pausiert = an
|
||||
if (!an) void this.schleife()
|
||||
}
|
||||
|
||||
istPausiert(): boolean {
|
||||
return this.pausiert
|
||||
}
|
||||
|
||||
stand(): WarteschlangenStand {
|
||||
return {
|
||||
laeuft: this.laufendStand === null ? null : { ...this.laufendStand },
|
||||
wartend: this.wartend.map((e) => ({
|
||||
vorgangId: e.auftrag.vorgangId,
|
||||
titel: e.titel,
|
||||
prozent: -1,
|
||||
text: this.pausiert ? 'wartet (Pause)' : 'wartet',
|
||||
phase: 'wartet',
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
laeuftEtwas(): boolean {
|
||||
return this.laufend !== null || this.wartend.length > 0
|
||||
}
|
||||
|
||||
/** Liegt dieser Vorgang gerade in der Schlange (wartend oder laufend)? */
|
||||
kennt(vorgangId: string): boolean {
|
||||
return this.laufend?.auftrag.vorgangId === vorgangId || this.wartend.some((e) => e.auftrag.vorgangId === vorgangId)
|
||||
}
|
||||
|
||||
private async schleife(): Promise<void> {
|
||||
if (this.arbeitet) return
|
||||
this.arbeitet = true
|
||||
try {
|
||||
while (this.wartend.length > 0 && !this.pausiert) {
|
||||
const eintrag = this.wartend.shift()!
|
||||
this.laufend = eintrag
|
||||
this.laufendStand = {
|
||||
vorgangId: eintrag.auftrag.vorgangId,
|
||||
titel: eintrag.titel,
|
||||
prozent: -1,
|
||||
text: 'Beginne …',
|
||||
phase: 'komprimiert',
|
||||
}
|
||||
this.beiStand(this.stand())
|
||||
let ergebnis: AblageErgebnis
|
||||
try {
|
||||
ergebnis = await this.ausfuehren(
|
||||
eintrag.auftrag,
|
||||
(stand) => {
|
||||
if (this.laufendStand === null) return
|
||||
this.laufendStand = { ...this.laufendStand, ...stand }
|
||||
this.beiStand(this.stand())
|
||||
},
|
||||
eintrag.abbruch.signal,
|
||||
)
|
||||
} catch (fehler) {
|
||||
// R4: Eine Ausnahme mitten im Encode ist ein lauter Fehlschlag,
|
||||
// kein stilles Ende der Schlange.
|
||||
ergebnis = {
|
||||
status: 'fehler',
|
||||
zielOrdner: '',
|
||||
dateien: [],
|
||||
text: `Kompression abgestürzt: ${String(fehler)} — die Roh-Dateien bleiben stehen.`,
|
||||
komprimiert: false,
|
||||
preset: '',
|
||||
fertigBytes: 0,
|
||||
hinweise: [],
|
||||
folgenVergeben: [],
|
||||
}
|
||||
}
|
||||
this.laufend = null
|
||||
this.laufendStand = null
|
||||
try {
|
||||
await this.beiErgebnis(eintrag.auftrag, ergebnis)
|
||||
} catch (fehler) {
|
||||
console.error(`[warteschlange] Ergebnis-Verarbeitung: ${String(fehler)}`)
|
||||
}
|
||||
this.beiStand(this.stand())
|
||||
}
|
||||
} finally {
|
||||
this.arbeitet = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
// Die Automatik (5.5.0, aus ARM: Auto-Modus; KONZEPT § 5 Schritt 4:
|
||||
// „sicher genug → sofort losrippen, nur Bescheid sagen — Zero-Click").
|
||||
//
|
||||
// Ablauf: Disc rein → Erkennung sicher (≥ 90 %) und nicht schon gerippt →
|
||||
// Rippy liest die Titel von selbst → ist die Vorauswahl eindeutig, läuft
|
||||
// ein Countdown (Vorgabe 30 s, Einstellung automatikSekunden) → dann der
|
||||
// Rip mit Rippys Vorschlag. Ein Klick auf „Stopp" oder das Öffnen des
|
||||
// Dialogs beendet den Countdown; „Jetzt" überspringt ihn. Wo Rippy unsicher
|
||||
// ist, passiert NICHTS von selbst — es sagt nur, warum.
|
||||
//
|
||||
// PUR bis auf die Uhr: Die Abhängigkeiten kommen injiziert, die Ampel
|
||||
// prüft den Ablauf mit einer Fake-Uhr.
|
||||
import type { AutomatikStand, GeraeteInfo, RipAuftragOptionen, RipAuftragTitel, TitelStand } from '../../gemeinsam/nachrichten'
|
||||
import type { KernKontext } from '../kontext'
|
||||
import { ABGELOEST } from '../rip/makemkv'
|
||||
import type { Zuordnung } from '../metadaten/zuordnung'
|
||||
|
||||
export const AUTOMATIK_SICHER = 0.9
|
||||
|
||||
export interface AutomatikAbhaengigkeiten {
|
||||
an(): boolean
|
||||
sekunden(): number
|
||||
ripLaeuft(id: string): boolean
|
||||
titelLesen(id: string): Promise<TitelStand>
|
||||
ripStarten(id: string, auftrag: RipAuftragTitel[], optionen: RipAuftragOptionen): void
|
||||
discAngaben(id: string): { staffel: number | null; discNr: number | null } | null
|
||||
serienMerker(zuordnung: Zuordnung): { staffel: number; naechsteFolge: number } | null
|
||||
benachrichtigen(titel: string, text: string): void
|
||||
/** Uhr — injizierbar. */
|
||||
takt?: (fn: () => void, ms: number) => NodeJS.Timeout
|
||||
taktStoppen?: (zeiger: NodeJS.Timeout) => void
|
||||
}
|
||||
|
||||
/** Rippys Vorschlag als Rip-Auftrag (pur): die vorausgewählten Titel mit
|
||||
* ihren Rollen; Sprachen leer = die globale Einstellung gilt. */
|
||||
export function auftragAusVorschlag(stand: TitelStand): RipAuftragTitel[] {
|
||||
return stand.titel
|
||||
.filter((t) => t.gewaehlt)
|
||||
.map((t) => ({ nr: t.nr, rolle: t.rolle, dauerS: t.dauerS, groesseBytes: t.groesseBytes }))
|
||||
}
|
||||
|
||||
/** Staffel und erste Folge für den Automatik-Rip (pur): Disc-Angabe vor
|
||||
* Merker; „ab Folge" nur vom Merker, und nur, wenn die Disc nicht die
|
||||
* erste ihrer Ausgabe ist (sonst würde Disc 1 bei Folge 3 anfangen). */
|
||||
export function optionenAus(
|
||||
angaben: { staffel: number | null; discNr: number | null } | null,
|
||||
merker: { staffel: number; naechsteFolge: number } | null,
|
||||
): RipAuftragOptionen {
|
||||
const staffel = angaben?.staffel ?? merker?.staffel ?? null
|
||||
const ersteFolge = merker !== null && (angaben?.discNr === null || angaben === null || angaben.discNr > 1) ? merker.naechsteFolge : null
|
||||
return { staffel, ersteFolge }
|
||||
}
|
||||
|
||||
export class AutomatikDienst {
|
||||
private zeiger = new Map<string, NodeJS.Timeout>()
|
||||
private staende = new Map<string, AutomatikStand>()
|
||||
|
||||
constructor(
|
||||
private readonly kontext: KernKontext,
|
||||
private readonly abh: AutomatikAbhaengigkeiten,
|
||||
) {}
|
||||
|
||||
stand(id: string): AutomatikStand | null {
|
||||
return this.staende.get(id) ?? null
|
||||
}
|
||||
|
||||
private melden(id: string, aktiv: boolean, sekunden: number, text: string): void {
|
||||
const stand = { id, aktiv, sekunden, text }
|
||||
this.staende.set(id, stand)
|
||||
this.kontext.anFenster({ art: 'automatik', stand })
|
||||
}
|
||||
|
||||
/** Disc rein, Erkennung fertig — soll die Automatik übernehmen? */
|
||||
async pruefen(geraet: GeraeteInfo, zuordnung: Zuordnung | null, schonGerippt: string | null): Promise<void> {
|
||||
const id = geraet.id
|
||||
if (!this.abh.an()) return
|
||||
if (geraet.typ === 'cd' || geraet.typ === 'unknown') return
|
||||
if (this.abh.ripLaeuft(id)) return
|
||||
if (zuordnung === null || zuordnung.confidence < AUTOMATIK_SICHER) {
|
||||
this.melden(id, false, 0, 'Automatik wartet: Die Erkennung ist nicht sicher genug — bitte selbst starten oder „Ändern …".')
|
||||
return
|
||||
}
|
||||
if (schonGerippt !== null) {
|
||||
this.melden(id, false, 0, `Automatik wartet: Diese Disc wurde schon gerippt (${schonGerippt.slice(0, 10)}).`)
|
||||
return
|
||||
}
|
||||
this.melden(id, false, 0, 'Automatik: liest die Titel …')
|
||||
const titel = await this.abh.titelLesen(id)
|
||||
if (titel.fehler === ABGELOEST) return
|
||||
if (this.abh.ripLaeuft(id)) return
|
||||
if (titel.fehler.length > 0) {
|
||||
this.melden(id, false, 0, `Automatik wartet: ${titel.fehler}`)
|
||||
return
|
||||
}
|
||||
if (titel.unsicher || !titel.titel.some((t) => t.gewaehlt)) {
|
||||
this.melden(id, false, 0, `Automatik wartet: ${titel.einordnung}`)
|
||||
return
|
||||
}
|
||||
this.countdown(id, titel)
|
||||
}
|
||||
|
||||
private countdown(id: string, titel: TitelStand): void {
|
||||
this.stopp(id, '')
|
||||
let rest = this.abh.sekunden()
|
||||
const n = titel.titel.filter((t) => t.gewaehlt).length
|
||||
const text = (s: number): string => `Automatik: Rip von ${n} ${n === 1 ? 'Titel' : 'Titeln'} startet in ${s} s — Rippys Vorschlag.`
|
||||
this.melden(id, true, rest, text(rest))
|
||||
const takt = this.abh.takt ?? ((fn, ms) => setInterval(fn, ms))
|
||||
const zeiger = takt(() => {
|
||||
rest -= 1
|
||||
if (rest > 0) {
|
||||
this.melden(id, true, rest, text(rest))
|
||||
return
|
||||
}
|
||||
this.stopp(id, '')
|
||||
this.starten(id, titel)
|
||||
}, 1000)
|
||||
this.zeiger.set(id, zeiger)
|
||||
}
|
||||
|
||||
private starten(id: string, titel: TitelStand): void {
|
||||
if (this.abh.ripLaeuft(id)) return
|
||||
const auftrag = auftragAusVorschlag(titel)
|
||||
if (auftrag.length === 0) return
|
||||
const zuordnungTitel = titel.titel.some((t) => t.gewaehlt && t.rolle === 'folge')
|
||||
const optionen = optionenAus(this.abh.discAngaben(id), zuordnungTitel ? this.merkerFuer(id) : null)
|
||||
this.melden(id, false, 0, `Automatik: Rip gestartet (${auftrag.length} ${auftrag.length === 1 ? 'Titel' : 'Titel'}).`)
|
||||
this.kontext.hinweis(`Automatik: Rip auf ${id} gestartet — Rippys Vorschlag, ${auftrag.length} Titel.`)
|
||||
this.abh.benachrichtigen('Rippy rippt', `Automatik hat auf Laufwerk ${id} gestartet: ${auftrag.length} Titel.`)
|
||||
this.abh.ripStarten(id, auftrag, optionen)
|
||||
}
|
||||
|
||||
private merkerZuordnung = new Map<string, Zuordnung>()
|
||||
|
||||
/** Die Zuordnung merken, damit der Serien-Merker beim Start gefragt werden kann. */
|
||||
zuordnungMerken(id: string, zuordnung: Zuordnung | null): void {
|
||||
if (zuordnung === null) this.merkerZuordnung.delete(id)
|
||||
else this.merkerZuordnung.set(id, zuordnung)
|
||||
}
|
||||
|
||||
private merkerFuer(id: string): { staffel: number; naechsteFolge: number } | null {
|
||||
const z = this.merkerZuordnung.get(id)
|
||||
return z === undefined ? null : this.abh.serienMerker(z)
|
||||
}
|
||||
|
||||
/** Countdown beenden — vom Nutzer (Grund wird gesagt) oder intern (leer). */
|
||||
stopp(id: string, grund: string): void {
|
||||
const zeiger = this.zeiger.get(id)
|
||||
if (zeiger !== undefined) {
|
||||
;(this.abh.taktStoppen ?? ((z) => clearInterval(z)))(zeiger)
|
||||
this.zeiger.delete(id)
|
||||
}
|
||||
if (grund.length > 0) this.melden(id, false, 0, grund)
|
||||
}
|
||||
|
||||
/** Der Kasten in der Kachel verschwindet — ein Rip hat begonnen (5.6.0):
|
||||
* Countdown weg, Text weg. Ohne das stand „Automatik gestoppt — du
|
||||
* entscheidest." noch unter dem fertigen Rip. */
|
||||
leeren(id: string): void {
|
||||
this.stopp(id, '')
|
||||
if (this.staende.has(id)) this.melden(id, false, 0, '')
|
||||
}
|
||||
|
||||
/** „Jetzt": den Countdown überspringen. */
|
||||
jetzt(id: string): void {
|
||||
const stand = this.staende.get(id)
|
||||
if (stand === undefined || !stand.aktiv) return
|
||||
this.stopp(id, '')
|
||||
const titel = this.letzteTitel.get(id)
|
||||
if (titel !== undefined) this.starten(id, titel)
|
||||
}
|
||||
|
||||
private letzteTitel = new Map<string, TitelStand>()
|
||||
|
||||
/** Der Titel-Stand, aus dem gestartet wird — die Erkennung reicht ihn durch. */
|
||||
titelMerken(id: string, stand: TitelStand): void {
|
||||
this.letzteTitel.set(id, stand)
|
||||
}
|
||||
|
||||
discEntnommen(id: string): void {
|
||||
this.stopp(id, '')
|
||||
this.staende.delete(id)
|
||||
this.letzteTitel.delete(id)
|
||||
this.merkerZuordnung.delete(id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// Benachrichtigung aufs Handy (5.5.0, aus ARM: Apprise/Pushover/…) — zwei
|
||||
// Wege, beide ohne Bibliothek, beide dokumentiert (Regel D):
|
||||
// Discord-Webhook: POST <webhook-url> mit JSON { content } —
|
||||
// discord.com/developers/docs/resources/webhook („Execute Webhook":
|
||||
// content ist eine Zeichenkette bis 2000 Zeichen).
|
||||
// Telegram-Bot: POST https://api.telegram.org/bot<TOKEN>/sendMessage mit
|
||||
// JSON { chat_id, text } — core.telegram.org/bots/api#sendmessage.
|
||||
// Ein Fehlschlag wird GESAGT (Ereignis-Zeile), kippt aber nie den Vorgang,
|
||||
// der ihn ausgelöst hat.
|
||||
import type { KernKontext } from '../kontext'
|
||||
import type { EinstellungsDienst } from './einstellungen'
|
||||
|
||||
export interface Kanaele {
|
||||
discordWebhook: string
|
||||
telegramToken: string
|
||||
telegramChatId: string
|
||||
}
|
||||
|
||||
/** Was an Discord geht (pur, testbar). */
|
||||
export function discordAnfrage(webhook: string, titel: string, text: string): { url: string; body: string } {
|
||||
const inhalt = `**${titel}**\n${text}`.slice(0, 1990)
|
||||
return { url: webhook, body: JSON.stringify({ content: inhalt, username: 'Rippy' }) }
|
||||
}
|
||||
|
||||
/** Was an Telegram geht (pur, testbar). */
|
||||
export function telegramAnfrage(token: string, chatId: string, titel: string, text: string): { url: string; body: string } {
|
||||
return {
|
||||
url: `https://api.telegram.org/bot${token}/sendMessage`,
|
||||
body: JSON.stringify({ chat_id: chatId, text: `${titel}\n${text}`.slice(0, 4000) }),
|
||||
}
|
||||
}
|
||||
|
||||
export function kanaeleAus(lesen: (schluessel: string) => string): Kanaele {
|
||||
return {
|
||||
discordWebhook: lesen('discordWebhook').trim(),
|
||||
telegramToken: lesen('telegramToken').trim(),
|
||||
telegramChatId: lesen('telegramChatId').trim(),
|
||||
}
|
||||
}
|
||||
|
||||
export function kanaeleEingerichtet(k: Kanaele): boolean {
|
||||
return k.discordWebhook.length > 0 || (k.telegramToken.length > 0 && k.telegramChatId.length > 0)
|
||||
}
|
||||
|
||||
export class BenachrichtigungsDienst {
|
||||
constructor(
|
||||
private readonly kontext: KernKontext,
|
||||
private readonly einstellungen: EinstellungsDienst,
|
||||
private readonly holen: typeof fetch = fetch,
|
||||
) {}
|
||||
|
||||
eingeschaltet(): boolean {
|
||||
return this.einstellungen.lesen('benachrichtigungen', 'an') !== 'aus'
|
||||
}
|
||||
|
||||
/** Schickt über alle eingerichteten Kanäle. Liefert die Fehler (leer = alles gut). */
|
||||
async senden(titel: string, text: string, erzwingen = false): Promise<string[]> {
|
||||
if (!erzwingen && !this.eingeschaltet()) return []
|
||||
const k = kanaeleAus((s) => this.einstellungen.lesen(s))
|
||||
const fehler: string[] = []
|
||||
const anfragen: Array<{ name: string; url: string; body: string }> = []
|
||||
if (k.discordWebhook.length > 0) anfragen.push({ name: 'Discord', ...discordAnfrage(k.discordWebhook, titel, text) })
|
||||
if (k.telegramToken.length > 0 && k.telegramChatId.length > 0) {
|
||||
anfragen.push({ name: 'Telegram', ...telegramAnfrage(k.telegramToken, k.telegramChatId, titel, text) })
|
||||
}
|
||||
for (const a of anfragen) {
|
||||
try {
|
||||
const antwort = await this.holen(a.url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: a.body,
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
})
|
||||
if (antwort.status >= 300) fehler.push(`${a.name}: HTTP ${antwort.status}`)
|
||||
} catch (e) {
|
||||
fehler.push(`${a.name}: ${String((e as Error).message ?? e).slice(0, 120)}`)
|
||||
}
|
||||
}
|
||||
for (const f of fehler) this.kontext.fehler(`Benachrichtigung ${f}`)
|
||||
if (anfragen.length > 0 && fehler.length === 0) this.kontext.protokoll.schreiben(`Benachrichtigt (${anfragen.map((a) => a.name).join(', ')}): ${titel}`)
|
||||
return fehler
|
||||
}
|
||||
|
||||
/** Der Knopf „Testnachricht" — antwortet IMMER etwas. */
|
||||
async testen(): Promise<void> {
|
||||
const k = kanaeleAus((s) => this.einstellungen.lesen(s))
|
||||
if (!kanaeleEingerichtet(k)) {
|
||||
this.kontext.fehler('Benachrichtigung: kein Kanal eingerichtet — Discord-Webhook oder Telegram-Bot eintragen.')
|
||||
return
|
||||
}
|
||||
const fehler = await this.senden('Rippy meldet sich', 'Testnachricht — die Verbindung steht.', true)
|
||||
if (fehler.length === 0) this.kontext.hinweis('Testnachricht verschickt.')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
// Einstellungen (§ 6.7): Das Fenster liest und schreibt NUR über den Kern;
|
||||
// erlaubt sind genau die Schlüssel hier. Dazu die Pfad-Regeln: Ablage
|
||||
// (Fertiges — beim Commander der Server) und seit 5.5.0 der Arbeitsordner
|
||||
// (Roh-Dateien und Kompression — lokal, schnell; Vorgabe: unter der Ablage,
|
||||
// also wie bisher).
|
||||
import { join } from 'node:path'
|
||||
import { AUFBEWAHRUNG_SCHLUESSEL } from '../ablage/rohaufraeumen'
|
||||
import { einstellung, type KernKontext } from '../kontext'
|
||||
|
||||
export const ERLAUBTE_EINSTELLUNGEN: ReadonlySet<string> = new Set([
|
||||
'ablage',
|
||||
'arbeitsordner',
|
||||
'transcodeEnabled',
|
||||
'transcodePresetDvd',
|
||||
'transcodePresetBluray',
|
||||
'transcodePresetUhd',
|
||||
'audioSprachen',
|
||||
'untertitelSprachen',
|
||||
'mediaServer',
|
||||
'mediaServerUrl',
|
||||
'mediaServerApiKey',
|
||||
'tmdbApiKey',
|
||||
'omdbApiKey',
|
||||
'makemkvAppKey',
|
||||
'updateUrl',
|
||||
'setup.done',
|
||||
'autoAuswurf',
|
||||
'werkzeug.makemkv',
|
||||
'werkzeug.handbrake',
|
||||
'werkzeug.flac',
|
||||
AUFBEWAHRUNG_SCHLUESSEL,
|
||||
// 5.5.0
|
||||
'automatik',
|
||||
'automatikSekunden',
|
||||
'benachrichtigungen',
|
||||
'discordWebhook',
|
||||
'telegramToken',
|
||||
'telegramChatId',
|
||||
'design',
|
||||
])
|
||||
|
||||
/** Die Schlüssel, die Rip und Kompression lesen (Pipeline/Ablage). Das
|
||||
* frühere „Allgemeine Preset" (transcodePreset) ist seit 5.5.0 raus —
|
||||
* Commander-Wunsch: je Disc-Typ reicht, ein unsichtbarer Übersteuerer
|
||||
* verwirrt nur. */
|
||||
const PIPELINE_EINSTELLUNGEN = [
|
||||
'transcodeEnabled',
|
||||
'transcodePresetDvd',
|
||||
'transcodePresetBluray',
|
||||
'transcodePresetUhd',
|
||||
'audioSprachen',
|
||||
'untertitelSprachen',
|
||||
'mediaServer',
|
||||
'mediaServerUrl',
|
||||
'mediaServerApiKey',
|
||||
] as const
|
||||
|
||||
export const AUTOMATIK_SEKUNDEN_VORGABE = 30
|
||||
|
||||
export class EinstellungsDienst {
|
||||
constructor(private readonly kontext: KernKontext) {}
|
||||
|
||||
lesen(schluessel: string, vorgabe = ''): string {
|
||||
return einstellung(this.kontext, schluessel, vorgabe)
|
||||
}
|
||||
|
||||
/** Wurzel der Ablage — Fertiges, Musik, ISO. */
|
||||
ablageOrdner(): string {
|
||||
const eingestellt = this.lesen('ablage')
|
||||
if (eingestellt.length > 0) return eingestellt
|
||||
return join(process.env['USERPROFILE'] ?? '.', 'Videos', 'Rippy')
|
||||
}
|
||||
|
||||
/** Ein eigener Arbeitsordner — '' wenn keiner gesetzt ist. */
|
||||
arbeitsordnerEigen(): string {
|
||||
return this.lesen('arbeitsordner').trim()
|
||||
}
|
||||
|
||||
/** Wo gearbeitet wird: der eigene Arbeitsordner, sonst die Ablage. */
|
||||
arbeitsordner(): string {
|
||||
const eigen = this.arbeitsordnerEigen()
|
||||
return eigen.length > 0 ? eigen : this.ablageOrdner()
|
||||
}
|
||||
|
||||
/** Wo die Roh-Ordner liegen. */
|
||||
rohWurzel(): string {
|
||||
return join(this.arbeitsordner(), 'roh')
|
||||
}
|
||||
|
||||
/** Was Pipeline und Ablage brauchen. */
|
||||
fuerPipeline(): Record<string, string> {
|
||||
const werte: Record<string, string> = {}
|
||||
for (const schluessel of PIPELINE_EINSTELLUNGEN) {
|
||||
const wert = this.kontext.db?.einstellungLesen(schluessel)
|
||||
if (wert !== null && wert !== undefined) werte[schluessel] = wert
|
||||
}
|
||||
return werte
|
||||
}
|
||||
|
||||
automatikAn(): boolean {
|
||||
return this.lesen('automatik', 'an') !== 'aus'
|
||||
}
|
||||
|
||||
automatikSekunden(): number {
|
||||
const n = Number(this.lesen('automatikSekunden', String(AUTOMATIK_SEKUNDEN_VORGABE)))
|
||||
return Number.isInteger(n) && n >= 5 && n <= 600 ? n : AUTOMATIK_SEKUNDEN_VORGABE
|
||||
}
|
||||
|
||||
/** Alle erlaubten Werte ans Fenster — AUCH ans Haupt (Update-Adresse, § 6.8). */
|
||||
senden(): void {
|
||||
const werte: Record<string, string> = { ablage: this.ablageOrdner() }
|
||||
for (const schluessel of ERLAUBTE_EINSTELLUNGEN) {
|
||||
const wert = this.kontext.db?.einstellungLesen(schluessel)
|
||||
if (wert !== null && wert !== undefined) werte[schluessel] = wert
|
||||
}
|
||||
this.kontext.anHauptUndFenster({ art: 'einstellungen', werte })
|
||||
}
|
||||
|
||||
/** Schreiben — nur erlaubte Schlüssel, nur mit Datenbank. Liefert, ob es klappte. */
|
||||
setzen(schluessel: string, wert: string): boolean {
|
||||
if (!ERLAUBTE_EINSTELLUNGEN.has(schluessel)) {
|
||||
this.kontext.fehler(`Unbekannter Einstellungs-Schlüssel: ${schluessel}`)
|
||||
return false
|
||||
}
|
||||
if (this.kontext.db === null) {
|
||||
this.kontext.fehler('Einstellung nicht gespeichert — die Datenbank ist nicht da.')
|
||||
return false
|
||||
}
|
||||
this.kontext.db.einstellungSchreiben(schluessel, wert)
|
||||
this.kontext.protokoll.schreiben(`Einstellung ${schluessel} gesetzt${istGeheim(schluessel) ? '' : `: ${wert.slice(0, 120)}`}`)
|
||||
this.senden()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
function istGeheim(schluessel: string): boolean {
|
||||
return /key|token|webhook|passwort/i.test(schluessel)
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
// Die Erkennung (W-4, § 5 Schritt 3): Was liegt da drin? Disc-Merkmale,
|
||||
// TMDb/OMDb-Zuordnung mit Sicherheitsgrad, die Nachprüfung nach dem
|
||||
// Info-Lauf (Erkennung v2, 5.4.0), der Titel-Lauf für den Auswahl-Dialog,
|
||||
// die Metadaten-Suche des Ändern-Dialogs — und seit 5.5.0 der Serien-Merker
|
||||
// (welche Folge auf der nächsten Disc weitergeht) und die Poster für den
|
||||
// Hintergrund der Erst-Einrichtung.
|
||||
import type {
|
||||
DiscInfoStand,
|
||||
DiscTitelVorschlag,
|
||||
GeraeteInfo,
|
||||
MetaVorschlag,
|
||||
TitelStand,
|
||||
} from '../../gemeinsam/nachrichten'
|
||||
import { einstellung, type KernKontext } from '../kontext'
|
||||
import { discFingerprint, discTitel, type DiscTitel } from '../metadaten/discmerkmale'
|
||||
import { OmdbClient } from '../metadaten/omdb'
|
||||
import { TmdbClient } from '../metadaten/tmdb'
|
||||
import { LEERER_TREFFER, ausDetails, nachpruefen, zuordnen, type Zuordnung } from '../metadaten/zuordnung'
|
||||
import { ABGELOEST, titelInfoLesen } from '../rip/makemkv'
|
||||
import { sprachenFuerTitel, type TitelInfo } from '../rip/parser'
|
||||
import { strukturHinweis, titelVorauswahl } from '../rip/titelwahl'
|
||||
|
||||
export interface ErkennungAbhaengigkeiten {
|
||||
ripLaeuft(id: string): boolean
|
||||
geraetInfoVon(id: string): GeraeteInfo | null
|
||||
makemkvcon(): string
|
||||
}
|
||||
|
||||
/** Der Serien-Merker (5.5.0): je TMDb-Serie, was die vorige Disc hinterließ. */
|
||||
export interface SerienMerker {
|
||||
titel: string
|
||||
staffel: number
|
||||
naechsteFolge: number
|
||||
wann: string
|
||||
}
|
||||
|
||||
export const SERIEN_MERKER_SCHLUESSEL = 'serienMerker'
|
||||
|
||||
/** Liest den Merker-Speicher (pur, robust gegen Unsinn). */
|
||||
export function serienMerkerAusJson(text: string | null): Record<string, SerienMerker> {
|
||||
if (text === null || text.length === 0) return {}
|
||||
try {
|
||||
const roh: unknown = JSON.parse(text)
|
||||
if (typeof roh !== 'object' || roh === null) return {}
|
||||
const ergebnis: Record<string, SerienMerker> = {}
|
||||
for (const [id, wert] of Object.entries(roh as Record<string, unknown>)) {
|
||||
if (typeof wert !== 'object' || wert === null) continue
|
||||
const m = wert as Record<string, unknown>
|
||||
if (typeof m.staffel !== 'number' || typeof m.naechsteFolge !== 'number') continue
|
||||
ergebnis[id] = {
|
||||
titel: typeof m.titel === 'string' ? m.titel : '',
|
||||
staffel: m.staffel,
|
||||
naechsteFolge: m.naechsteFolge,
|
||||
wann: typeof m.wann === 'string' ? m.wann : '',
|
||||
}
|
||||
}
|
||||
return ergebnis
|
||||
} catch {
|
||||
// Ein kaputter Merker ist kein Grund, nicht zu erkennen — er ist dann leer.
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function jahrAus(datum: string | undefined): number | null {
|
||||
if (datum === undefined || datum.length < 4) return null
|
||||
const jahr = Number(datum.slice(0, 4))
|
||||
return Number.isFinite(jahr) && jahr > 0 ? jahr : null
|
||||
}
|
||||
|
||||
export class ErkennungDienst {
|
||||
private zuordnungen = new Map<string, Zuordnung>()
|
||||
private fingerprints = new Map<string, string>()
|
||||
/** Was die Disc über sich selbst sagt (Disc-Nummer, Staffel, Folgenzahl) —
|
||||
* das bleibt wahr, auch wenn der Commander den Titel selbst neu wählt. */
|
||||
private merkmale = new Map<string, DiscTitel>()
|
||||
/** Der letzte Titel-Stand je Laufwerk — die Automatik (5.5.0) rippt daraus. */
|
||||
readonly letzteTitel = new Map<string, TitelStand>()
|
||||
// Je Laufwerk höchstens EIN Titel-Lauf, und nur der NEUESTE darf
|
||||
// antworten. Der Commander-Fund vom 01.09.2026: Die Titelliste kam
|
||||
// korrekt an — und 300 s später legte ein ÄLTERER, längst überholter Lauf
|
||||
// seinen Timeout darüber („keine Antwort nach 300 s"). Zwei makemkvcon
|
||||
// auf derselben Disc bremsen sich außerdem gegenseitig aus.
|
||||
private titelAbbrueche = new Map<string, AbortController>()
|
||||
private titelLaufZaehler = 0
|
||||
private titelLaufNummern = new Map<string, number>()
|
||||
|
||||
constructor(
|
||||
private readonly kontext: KernKontext,
|
||||
private readonly abh: ErkennungAbhaengigkeiten,
|
||||
) {}
|
||||
|
||||
private tmdb(): TmdbClient {
|
||||
return new TmdbClient(einstellung(this.kontext, 'tmdbApiKey'))
|
||||
}
|
||||
|
||||
private omdb(): OmdbClient {
|
||||
return new OmdbClient(einstellung(this.kontext, 'omdbApiKey'))
|
||||
}
|
||||
|
||||
zuordnung(id: string): Zuordnung | null {
|
||||
return this.zuordnungen.get(id) ?? null
|
||||
}
|
||||
|
||||
fingerprint(id: string): string | null {
|
||||
return this.fingerprints.get(id) ?? null
|
||||
}
|
||||
|
||||
discAngaben(id: string): { staffel: number | null; discNr: number | null } | null {
|
||||
const m = this.merkmale.get(id)
|
||||
return m === undefined ? null : { staffel: m.staffel, discNr: m.discNr }
|
||||
}
|
||||
|
||||
// ── Serien-Merker (5.5.0) ───────────────────────────────────────────────
|
||||
private alleMerker(): Record<string, SerienMerker> {
|
||||
return serienMerkerAusJson(this.kontext.db?.einstellungLesen(SERIEN_MERKER_SCHLUESSEL) ?? null)
|
||||
}
|
||||
|
||||
serienMerkerFuer(tmdbId: number | string | null): SerienMerker | null {
|
||||
if (tmdbId === null) return null
|
||||
return this.alleMerker()[String(tmdbId)] ?? null
|
||||
}
|
||||
|
||||
/** Nach einer fertigen Serien-Ablage: Mit welcher Folge geht es weiter? */
|
||||
serienMerkerSetzen(tmdbId: number | string, titel: string, staffel: number, naechsteFolge: number): void {
|
||||
if (this.kontext.db === null) return
|
||||
const alle = this.alleMerker()
|
||||
alle[String(tmdbId)] = { titel, staffel, naechsteFolge, wann: new Date().toISOString() }
|
||||
this.kontext.db.einstellungSchreiben(SERIEN_MERKER_SCHLUESSEL, JSON.stringify(alle))
|
||||
this.kontext.protokoll.schreiben(`Serien-Merker: „${titel}" Staffel ${staffel}, weiter mit Folge ${naechsteFolge}`)
|
||||
}
|
||||
|
||||
/** EINE Stelle, die aus Zuordnung + Disc-Merkmalen den Stand fürs Fenster
|
||||
* baut (5.4.0) — vorher stand dieselbe Struktur dreimal im Code. */
|
||||
discInfoStand(id: string, zuordnung: Zuordnung | null, quelle?: string): DiscInfoStand {
|
||||
const m = this.merkmale.get(id)
|
||||
const meta = zuordnung?.meta
|
||||
const merker = meta?.typ === 'tv' ? this.serienMerkerFuer(meta.id) : null
|
||||
return {
|
||||
id,
|
||||
titel: zuordnung?.titel ?? '',
|
||||
jahr: zuordnung?.jahr ?? null,
|
||||
confidence: zuordnung?.confidence ?? 0,
|
||||
quelle: quelle ?? meta?.quelle ?? '',
|
||||
posterPfad: meta?.posterPfad ?? '',
|
||||
backdropPfad: meta?.backdropPfad ?? '',
|
||||
beschreibung: meta?.beschreibung ?? '',
|
||||
// § 6.6: Rippy erkennt beim Einlegen, wenn eine Disc schon einmal
|
||||
// durchgelaufen ist — und fragt nach, statt stumm zu doppeln.
|
||||
schonGerippt:
|
||||
zuordnung === null ? null : (this.kontext.db?.bibliothekSchonGerippt(this.fingerprints.get(id) ?? '') ?? null),
|
||||
discNr: m?.discNr ?? null,
|
||||
staffel: m?.staffel ?? null,
|
||||
folgen: m?.folgen ?? 0,
|
||||
typ: meta?.typ === 'movie' || meta?.typ === 'tv' ? meta.typ : '',
|
||||
logoPfad: meta?.logoPfad ?? '',
|
||||
tagline: meta?.tagline ?? '',
|
||||
bewertung: meta?.bewertung ?? 0,
|
||||
fsk: meta?.fsk ?? '',
|
||||
laufzeitMinuten: meta?.laufzeitMinuten ?? 0,
|
||||
genres: meta?.genres ?? [],
|
||||
besetzung: meta?.besetzung ?? [],
|
||||
hinweise: zuordnung?.hinweise ?? [],
|
||||
serienMerker: merker === null ? null : { staffel: merker.staffel, naechsteFolge: merker.naechsteFolge, wann: merker.wann },
|
||||
}
|
||||
}
|
||||
|
||||
schonGerippt(id: string): string | null {
|
||||
return this.kontext.db?.bibliothekSchonGerippt(this.fingerprints.get(id) ?? '') ?? null
|
||||
}
|
||||
|
||||
/** § 5 Schritt 3: Disc rein → was ist das? Liefert die Zuordnung (für die
|
||||
* Automatik) — null, wenn die Erkennung scheiterte. */
|
||||
async discErmitteln(geraet: GeraeteInfo): Promise<Zuordnung | null> {
|
||||
try {
|
||||
const gefunden = discTitel(geraet.pfad)
|
||||
this.fingerprints.set(geraet.id, discFingerprint(geraet.pfad, geraet.groesseBytes))
|
||||
this.merkmale.set(geraet.id, gefunden)
|
||||
// Der Hinweis kommt von der Disc selbst (bdmt-Inhaltsverzeichnis) —
|
||||
// trägt sie Folgen, kann kein Filmtreffer mehr gewinnen. Und jede
|
||||
// Titel-Variante der Disc (bdmt_deu) wird mitgefragt (5.4.0).
|
||||
const zuordnung = await zuordnen(gefunden.titel, this.tmdb(), this.omdb(), {
|
||||
istSerie: gefunden.istSerie,
|
||||
alternativen: gefunden.alternativen,
|
||||
})
|
||||
this.zuordnungen.set(geraet.id, zuordnung)
|
||||
this.kontext.protokoll.schreiben(
|
||||
`Erkennung ${geraet.id}: „${gefunden.titel}" (${gefunden.quelle || 'kein Titel'}) → „${zuordnung.titel}" ${Math.round(zuordnung.confidence * 100)} % ${zuordnung.meta.typ}`,
|
||||
)
|
||||
this.kontext.anHauptUndFenster({ art: 'disc-info', info: this.discInfoStand(geraet.id, zuordnung) })
|
||||
return zuordnung
|
||||
} catch (fehler) {
|
||||
// R4: Eine gescheiterte Erkennung ist eine laute Zeile — gerippt
|
||||
// werden kann trotzdem (die Zuordnung ist Komfort, kein Torwächter).
|
||||
this.kontext.fehler(`Disc-Erkennung ${geraet.id}: ${String(fehler)}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
discVergessen(id: string): void {
|
||||
this.zuordnungen.delete(id)
|
||||
this.fingerprints.delete(id)
|
||||
this.merkmale.delete(id)
|
||||
this.letzteTitel.delete(id)
|
||||
this.titelAbbrueche.get(id)?.abort()
|
||||
this.kontext.anHauptUndFenster({ art: 'disc-info', info: this.discInfoStand(id, null) })
|
||||
}
|
||||
|
||||
// § 5 Schritt 4: „nein → fragen" — der Nutzer sucht und WÄHLT selbst.
|
||||
async metadatenSuchen(id: string, text: string): Promise<void> {
|
||||
try {
|
||||
const tmdb = this.tmdb()
|
||||
if (!tmdb.verfuegbar) {
|
||||
this.kontext.fehler('Metadaten-Suche braucht einen TMDb-Key (Einstellungen → Metadaten).')
|
||||
this.kontext.anFenster({ art: 'metadaten-vorschlaege', id, vorschlaege: [] })
|
||||
return
|
||||
}
|
||||
const [filme, serien] = await Promise.all([tmdb.searchMovie(text), tmdb.searchTv(text)])
|
||||
const vorschlaege: MetaVorschlag[] = [
|
||||
...(filme ?? []).slice(0, 6).map((f) => ({
|
||||
typ: 'movie' as const,
|
||||
tmdbId: f.id,
|
||||
titel: f.title ?? '',
|
||||
jahr: jahrAus(f.release_date),
|
||||
posterPfad: f.poster_path ?? '',
|
||||
})),
|
||||
...(serien ?? []).slice(0, 6).map((s) => ({
|
||||
typ: 'tv' as const,
|
||||
tmdbId: s.id,
|
||||
titel: s.name ?? '',
|
||||
jahr: jahrAus(s.first_air_date),
|
||||
posterPfad: s.poster_path ?? '',
|
||||
})),
|
||||
].filter((v) => v.titel.length > 0)
|
||||
this.kontext.anFenster({ art: 'metadaten-vorschlaege', id, vorschlaege })
|
||||
} catch (fehler) {
|
||||
this.kontext.fehler(`Metadaten-Suche: ${String(fehler)}`)
|
||||
this.kontext.anFenster({ art: 'metadaten-vorschlaege', id, vorschlaege: [] })
|
||||
}
|
||||
}
|
||||
|
||||
/** Aus einem TMDb-Vorschlag die volle Zuordnung (Details holen) — für
|
||||
* „Ändern …" und seit 5.6.0 für den zweiten Film einer Disc. Vom Nutzer
|
||||
* gewählt = sicher (confidence 1). Ohne Details bleibt der Vorschlag. */
|
||||
async zuordnungAus(vorschlag: MetaVorschlag): Promise<Zuordnung> {
|
||||
const tmdb = this.tmdb()
|
||||
let details = null
|
||||
try {
|
||||
details = vorschlag.typ === 'movie' ? await tmdb.movieDetails(vorschlag.tmdbId) : await tmdb.tvDetails(vorschlag.tmdbId)
|
||||
} catch (fehler) {
|
||||
this.kontext.fehler(`TMDb-Details für „${vorschlag.titel}": ${String(fehler)} — Zuordnung ohne Details.`)
|
||||
}
|
||||
const meta =
|
||||
details !== null
|
||||
? ausDetails(vorschlag.typ, vorschlag.tmdbId, details)
|
||||
: {
|
||||
...LEERER_TREFFER,
|
||||
typ: vorschlag.typ,
|
||||
id: vorschlag.tmdbId,
|
||||
titel: vorschlag.titel,
|
||||
jahr: vorschlag.jahr,
|
||||
posterPfad: vorschlag.posterPfad,
|
||||
quelle: 'tmdb' as const,
|
||||
}
|
||||
return {
|
||||
titel: meta.titel.length > 0 ? meta.titel : vorschlag.titel,
|
||||
jahr: meta.jahr ?? vorschlag.jahr,
|
||||
// Vom Nutzer gewählt = sicher. Genau dafür ist die Frage da (§ 5).
|
||||
confidence: 1,
|
||||
meta,
|
||||
}
|
||||
}
|
||||
|
||||
async zuordnungSetzen(id: string, vorschlag: MetaVorschlag): Promise<void> {
|
||||
try {
|
||||
const zuordnung = await this.zuordnungAus(vorschlag)
|
||||
this.zuordnungen.set(id, zuordnung)
|
||||
this.kontext.protokoll.schreiben(`Zuordnung ${id} vom Nutzer: „${zuordnung.titel}" (${zuordnung.meta.typ} ${String(zuordnung.meta.id)})`)
|
||||
this.kontext.anHauptUndFenster({ art: 'disc-info', info: this.discInfoStand(id, zuordnung, 'deine Wahl') })
|
||||
} catch (fehler) {
|
||||
this.kontext.fehler(`Zuordnung übernehmen: ${String(fehler)}`)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Titel der Disc lesen (§ 6.2) ────────────────────────────────────────
|
||||
// Der Info-Lauf dauert 20–120 s — deshalb geschieht er erst auf Zuruf und
|
||||
// nicht schon beim Einlegen (der 30.08.-Fund: „das Erkennen dauert sehr
|
||||
// sehr lange"). Das Fenster bekommt sofort `laeuft: true`, damit es in der
|
||||
// Zeit nicht stumm dasteht (R2). Liefert den End-Stand zurück (Automatik).
|
||||
async titelLesen(id: string): Promise<TitelStand> {
|
||||
const leer: TitelStand = { id, laeuft: false, titel: [], einordnung: '', unsicher: false, fehler: '' }
|
||||
const senden = (stand: TitelStand): TitelStand => {
|
||||
if (!stand.laeuft) this.letzteTitel.set(id, stand)
|
||||
this.kontext.anFenster({ art: 'disc-titel', stand })
|
||||
return stand
|
||||
}
|
||||
if (this.abh.ripLaeuft(id)) return senden({ ...leer, fehler: 'Auf diesem Laufwerk läuft schon ein Vorgang.' })
|
||||
const geraet = this.abh.geraetInfoVon(id)
|
||||
if (geraet === null) return senden({ ...leer, fehler: `Laufwerk ${id}: gibt es nicht.` })
|
||||
const programm = this.abh.makemkvcon()
|
||||
if (programm.length === 0) {
|
||||
return senden({ ...leer, fehler: 'makemkvcon wurde nicht gefunden — ist MakeMKV installiert?' })
|
||||
}
|
||||
// Einen noch laufenden Lauf ablösen: Er gibt das Laufwerk sofort frei,
|
||||
// und seine Antwort wird verworfen (nur die neueste Nummer zählt).
|
||||
this.titelAbbrueche.get(id)?.abort()
|
||||
const abbruch = new AbortController()
|
||||
this.titelAbbrueche.set(id, abbruch)
|
||||
const meineNummer = ++this.titelLaufZaehler
|
||||
this.titelLaufNummern.set(id, meineNummer)
|
||||
const nochAktuell = (): boolean => this.titelLaufNummern.get(id) === meineNummer
|
||||
|
||||
senden({ ...leer, laeuft: true })
|
||||
const start = Date.now()
|
||||
try {
|
||||
const info = await titelInfoLesen(programm, geraet.pfad, 300_000, abbruch.signal)
|
||||
if (!nochAktuell()) return { ...leer, fehler: ABGELOEST }
|
||||
const merkmale = this.merkmale.get(id)
|
||||
const wahl = titelVorauswahl(info.titel, {
|
||||
istSerie: merkmale?.istSerie ?? false,
|
||||
folgen: merkmale?.folgen ?? 0,
|
||||
})
|
||||
// Kam nichts heraus UND MakeMKV hat einen Grund genannt, dann ist der
|
||||
// Grund die Nachricht — nicht „keine Titel gefunden". Am 01.09.2026
|
||||
// gemessen: Beim 5042-Zustand antwortet der Info-Lauf nach 0,7 s mit
|
||||
// TCOUNT:0, und ohne diesen Zweig stünde im Fenster, die DISC gäbe
|
||||
// nichts her — dabei sieht MakeMKV das LAUFWERK nicht.
|
||||
const ohneErgebnis = info.titel.length === 0 && info.ursachen.length > 0
|
||||
this.kontext.protokoll.schreiben(
|
||||
`Titel-Lauf ${id}: ${((Date.now() - start) / 1000).toFixed(1)} s, ${info.titel.length} Titel — ${wahl.einordnung}${info.ursachen.length > 0 ? ` · ${info.ursachen.join(' · ')}` : ''}`,
|
||||
)
|
||||
const titel: DiscTitelVorschlag[] = wahl.titel.map((t) => ({
|
||||
...t,
|
||||
// Die Sprachen JE TITEL reisen mit (5.4.0) — der Dialog bietet sie zum Anhaken an.
|
||||
sprachen: sprachenFuerTitel(info.streams.get(t.nr)),
|
||||
}))
|
||||
const stand = senden({
|
||||
id,
|
||||
laeuft: false,
|
||||
titel,
|
||||
einordnung: wahl.einordnung,
|
||||
unsicher: wahl.unsicher,
|
||||
fehler: ohneErgebnis ? info.ursachen.join(' · ') : '',
|
||||
})
|
||||
// Erkennung v2 (5.4.0): Jetzt liegt die Laufzeit vor — nachprüfen.
|
||||
if (info.titel.length > 0) await this.nachpruefen(id, titel, info.titel)
|
||||
return stand
|
||||
} catch (fehler) {
|
||||
// Ein ABGELÖSTER Lauf schweigt: Ihn hat ein neuerer ersetzt, das ist
|
||||
// Absicht und kein Fehlschlag. Genau hier lag der Commander-Fund —
|
||||
// der alte Lauf überschrieb sonst die längst gelieferte Titelliste.
|
||||
if (String(fehler).includes(ABGELOEST) || !nochAktuell()) return { ...leer, fehler: ABGELOEST }
|
||||
// R4: sonst laut scheitern. „Konnte nicht nachsehen" ist etwas anderes
|
||||
// als „die Disc hat keine Titel" — beides zu vermischen erzeugt Unsinn.
|
||||
const grund = String(fehler).includes('keine Antwort nach')
|
||||
? 'MakeMKV hat die Titel nach fünf Minuten nicht gelesen. Bei einer beschädigten Disc kann das passieren — Rippy hat den Versuch beendet und das Laufwerk freigegeben.'
|
||||
: `Titel lesen: ${String(fehler).slice(0, 200)}`
|
||||
this.kontext.protokoll.schreiben(`Titel-Lauf ${id} gescheitert: ${grund}`)
|
||||
return senden({ ...leer, fehler: grund })
|
||||
} finally {
|
||||
// Nur aufräumen, wenn ICH noch der aktuelle Lauf bin.
|
||||
if (nochAktuell()) {
|
||||
this.titelAbbrueche.delete(id)
|
||||
this.titelLaufNummern.delete(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Erkennung v2 (5.4.0): Erkannt wird beim EINLEGEN, vor jeder Messung.
|
||||
* Nach dem Info-Lauf wird noch einmal gerechnet — Laufzeit gegen TMDb,
|
||||
* Titel-Struktur gegen den Treffer-Typ. Aus „wahrscheinlich" wird
|
||||
* „bestätigt", oder Rippy merkt selbst, dass es danebenlag. */
|
||||
private async nachpruefen(id: string, vorschlag: readonly DiscTitelVorschlag[], titel: readonly TitelInfo[]): Promise<void> {
|
||||
const bisher = this.zuordnungen.get(id)
|
||||
if (bisher === undefined) return
|
||||
const gewaehlt = vorschlag.filter((t) => t.gewaehlt)
|
||||
const folgen = gewaehlt.filter((t) => t.rolle === 'folge').map((t) => t.dauerS)
|
||||
const laengster = [...titel].sort((a, b) => b.dauerS - a.dauerS)[0]?.dauerS ?? 0
|
||||
const messung = {
|
||||
hauptS: folgen.length > 0 ? 0 : (gewaehlt[0]?.dauerS ?? laengster),
|
||||
folgenS: folgen,
|
||||
struktur: strukturHinweis(titel),
|
||||
}
|
||||
let zuordnung = bisher
|
||||
// Sagt die Struktur „Serie", der Treffer aber „Film" (und der Nutzer hat
|
||||
// nicht selbst gewählt): einmal als Serie fragen — das ist der
|
||||
// Fingerabdruck, der Serien auch ohne bdmt erkennt.
|
||||
if (messung.struktur === 'serie' && bisher.meta.typ !== 'tv' && bisher.confidence < 1) {
|
||||
const merkmale = this.merkmale.get(id)
|
||||
if (merkmale !== undefined && merkmale.titel.length > 0) {
|
||||
const nochmal = await zuordnen(merkmale.titel, this.tmdb(), this.omdb(), {
|
||||
istSerie: true,
|
||||
alternativen: merkmale.alternativen,
|
||||
})
|
||||
if (nochmal.meta.typ === 'tv') {
|
||||
zuordnung = { ...nochmal, hinweise: ['Serie erkannt an der Titel-Struktur (mehrere gleich lange Titel).'] }
|
||||
}
|
||||
}
|
||||
}
|
||||
const geprueft = nachpruefen(zuordnung, messung)
|
||||
const hinweise = [...(zuordnung.hinweise ?? []), ...(geprueft.hinweise ?? [])]
|
||||
const neu = { ...geprueft, hinweise }
|
||||
this.zuordnungen.set(id, neu)
|
||||
if (hinweise.length > 0) this.kontext.protokoll.schreiben(`Nachprüfung ${id}: ${hinweise.join(' · ')} → ${Math.round(neu.confidence * 100)} %`)
|
||||
this.kontext.anHauptUndFenster({ art: 'disc-info', info: this.discInfoStand(id, neu) })
|
||||
}
|
||||
|
||||
// ── Poster für die Erst-Einrichtung (5.5.0, Commander-Wunsch) ──────────
|
||||
// TMDb /movie/popular (de-DE) — dieselbe Schnittstelle wie die Suche,
|
||||
// die Antwort trägt poster_path je Film (gemessen: results[].poster_path).
|
||||
async posterLaden(): Promise<void> {
|
||||
const tmdb = this.tmdb()
|
||||
if (!tmdb.verfuegbar) {
|
||||
this.kontext.anFenster({ art: 'poster-vorschau', pfade: [] })
|
||||
return
|
||||
}
|
||||
const pfade = await tmdb.beliebtePoster(40)
|
||||
this.kontext.anFenster({ art: 'poster-vorschau', pfade })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
// Die Laufwerke (W-1, § 5 Schritt 1): Disc-Wache im 3-Sekunden-Takt,
|
||||
// Auswerfen mit Nachsehen (R3), Audio-CD-Zugriffe, die Laufwerks-Gesundheit
|
||||
// aus dem Windows-Protokoll (5.4.0) — und das nachgebaute Laufwerk des
|
||||
// Klick-Beweises.
|
||||
//
|
||||
// koffi wird erst HIER geladen, über einen echten dynamischen import():
|
||||
// Rollup legt win32.ts damit als eigenen Chunk neben kern.js (ein
|
||||
// wörtliches require überlebte das Bündeln nicht — im Smoke-Lauf am
|
||||
// 30.08.2026 sichtbar geworden). Scheitert das Laden (falsche Plattform,
|
||||
// kaputtes Paket), läuft der Kern weiter und der Grund steht im Fenster (R4).
|
||||
import type { GeraeteInfo, WacheEreignis } from '../../gemeinsam/nachrichten'
|
||||
import type { KernKontext } from '../kontext'
|
||||
import type { LaufwerkApi } from '../laufwerk/codes'
|
||||
import {
|
||||
audioSpurLesen,
|
||||
audioToc,
|
||||
auswerfenMitGrund,
|
||||
geraeteInfo,
|
||||
kennung,
|
||||
listOptischeLaufwerke,
|
||||
} from '../laufwerk/disc'
|
||||
import { beurteilen, ereignisseLesen } from '../laufwerk/ereignisse'
|
||||
import { DiscWache } from '../laufwerk/wache'
|
||||
import type { Spur } from '../rip/audiocd'
|
||||
|
||||
const GESUNDHEIT_STUNDEN = 24
|
||||
|
||||
export interface LaufwerkHorcher {
|
||||
discEingelegt(geraet: GeraeteInfo): void
|
||||
discEntnommen(geraet: GeraeteInfo): void
|
||||
}
|
||||
|
||||
export class LaufwerkDienst {
|
||||
private api: LaufwerkApi | null = null
|
||||
private wache: DiscWache | null = null
|
||||
fehler: string | null = null
|
||||
/** Das nachgebaute Laufwerk des Klick-Beweises (bau/klick-smoke.mjs): Ein
|
||||
* Buchstabe, den es nicht gibt (Q) — die Disc-Merkmale finden dann
|
||||
* nichts, und makemkvcon/HandBrake sind Node-Skripte (werkzeuge/aufruf.ts).
|
||||
* Leer im normalen Betrieb; die Win32-Schicht bleibt dann der einzige Weg. */
|
||||
readonly fake: string
|
||||
readonly bereit: Promise<void>
|
||||
|
||||
constructor(
|
||||
private readonly kontext: KernKontext,
|
||||
private readonly horcher: LaufwerkHorcher,
|
||||
/** Läuft auf diesem Laufwerk gerade ein Rip? (Auswurf wird dann abgelehnt.) */
|
||||
private readonly ripLaeuft: (id: string) => boolean,
|
||||
umgebung: NodeJS.ProcessEnv = process.env,
|
||||
) {
|
||||
this.fake = (umgebung['RIPPY_FAKE_LAUFWERK'] ?? '').replace(/:$/, '').toUpperCase()
|
||||
this.bereit = this.starten()
|
||||
}
|
||||
|
||||
private fakeGeraet(): GeraeteInfo {
|
||||
return {
|
||||
id: this.fake,
|
||||
name: `Laufwerk ${this.fake}:`,
|
||||
typ: 'bluray',
|
||||
pfad: `\\\\.\\${this.fake}:`,
|
||||
status: 'ready',
|
||||
modell: 'NACHGEBAUT (KLICK-BEWEIS)',
|
||||
seriennummer: 'KLICK',
|
||||
groesseBytes: 33_759_100_928,
|
||||
grund: '',
|
||||
}
|
||||
}
|
||||
|
||||
private wacheMeldung(meldung: { laufwerke: GeraeteInfo[]; ereignisse: WacheEreignis[] }): void {
|
||||
this.kontext.anHauptUndFenster({ art: 'laufwerke', liste: meldung.laufwerke, ereignisse: meldung.ereignisse })
|
||||
for (const ereignis of meldung.ereignisse) {
|
||||
if (ereignis.art === 'disc-eingelegt') {
|
||||
this.kontext.protokoll.schreiben(`Disc eingelegt in ${ereignis.geraet.id}: ${ereignis.geraet.typ}, ${ereignis.geraet.groesseBytes} Bytes`)
|
||||
this.horcher.discEingelegt(ereignis.geraet)
|
||||
} else if (ereignis.art === 'disc-entnommen') {
|
||||
this.kontext.protokoll.schreiben(`Disc entnommen aus ${ereignis.geraet.id}`)
|
||||
this.horcher.discEntnommen(ereignis.geraet)
|
||||
} else if (ereignis.art === 'laufwerk-weg') {
|
||||
this.kontext.protokoll.schreiben(`Laufwerk ${ereignis.id} nicht mehr da`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async starten(): Promise<void> {
|
||||
const wacheFehler = (fehler: unknown): void => {
|
||||
// R4: Eine gescheiterte Runde ist eine laute Zeile — der letzte
|
||||
// Stand im Fenster bleibt stehen (R2), die Wache läuft weiter.
|
||||
this.kontext.fehler(`Disc-Wache: ${String(fehler)}`)
|
||||
}
|
||||
try {
|
||||
if (this.fake.length > 0) {
|
||||
this.wache = new DiscWache(() => [this.fakeGeraet()], (m) => this.wacheMeldung(m), wacheFehler)
|
||||
this.wache.starten()
|
||||
return
|
||||
}
|
||||
if (process.platform !== 'win32') {
|
||||
throw new Error('Die Laufwerks-Schicht läuft nur unter Windows (KONZEPT § 14).')
|
||||
}
|
||||
const { win32Api } = await import('../laufwerk/win32')
|
||||
this.api = win32Api()
|
||||
this.wache = new DiscWache(
|
||||
() => listOptischeLaufwerke(this.api!).map((g) => geraeteInfo(this.api!, g)),
|
||||
(m) => this.wacheMeldung(m),
|
||||
wacheFehler,
|
||||
)
|
||||
this.wache.starten()
|
||||
} catch (fehler) {
|
||||
this.fehler = String(fehler)
|
||||
this.kontext.protokoll.schreiben(`Laufwerke: ${this.fehler}`)
|
||||
console.error(`[kern] Laufwerke: ${this.fehler}`)
|
||||
}
|
||||
}
|
||||
|
||||
aktuellerStand(): GeraeteInfo[] {
|
||||
return this.wache?.aktuellerStand() ?? []
|
||||
}
|
||||
|
||||
/** Der Geräte-Name zu einer Kennung — oder null. */
|
||||
geraetVon(id: string): string | null {
|
||||
if (this.api === null) return null
|
||||
return listOptischeLaufwerke(this.api).find((g) => kennung(g) === id) ?? null
|
||||
}
|
||||
|
||||
/** Der Geräte-Stand zu einer Kennung — echt oder nachgebaut. */
|
||||
geraetInfoVon(id: string): GeraeteInfo | null {
|
||||
if (this.fake.length > 0) return id === this.fake ? this.fakeGeraet() : null
|
||||
const geraet = this.geraetVon(id)
|
||||
if (geraet === null || this.api === null) return null
|
||||
return geraeteInfo(this.api, geraet)
|
||||
}
|
||||
|
||||
audioToc(id: string): Spur[] {
|
||||
const geraet = this.geraetVon(id)
|
||||
if (this.api === null || geraet === null) throw new Error(`Laufwerk ${id}: gibt es nicht`)
|
||||
return audioToc(this.api, geraet)
|
||||
}
|
||||
|
||||
audioSpurLesen(
|
||||
id: string,
|
||||
start: number,
|
||||
sektoren: number,
|
||||
schreiben: (daten: Buffer) => void,
|
||||
fortschritt?: (anteil: number) => void,
|
||||
): number {
|
||||
const geraet = this.geraetVon(id)
|
||||
if (this.api === null || geraet === null) throw new Error(`Laufwerk ${id}: gibt es nicht`)
|
||||
return audioSpurLesen(this.api, geraet, start, sektoren, schreiben, fortschritt)
|
||||
}
|
||||
|
||||
async auswerfen(id: string): Promise<void> {
|
||||
await this.bereit
|
||||
if (this.ripLaeuft(id)) {
|
||||
this.kontext.anFenster({
|
||||
art: 'auswurf-ergebnis',
|
||||
id,
|
||||
grund: 'abgelehnt',
|
||||
fehler: `Auf Laufwerk ${id} läuft ein Rip — erst abbrechen oder abwarten.`,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (this.api === null) {
|
||||
this.kontext.anFenster({ art: 'auswurf-ergebnis', id, grund: 'kein-zugriff', fehler: this.fehler })
|
||||
return
|
||||
}
|
||||
const geraet = this.geraetVon(id)
|
||||
if (geraet === null) {
|
||||
this.kontext.anFenster({ art: 'auswurf-ergebnis', id, grund: 'kein-zugriff', fehler: `Laufwerk ${id}: gibt es nicht` })
|
||||
return
|
||||
}
|
||||
const { grund, fehler } = await auswerfenMitGrund(this.api, geraet)
|
||||
this.kontext.protokoll.schreiben(`Auswurf ${id}: ${grund}${fehler !== null ? ` (${fehler})` : ''}`)
|
||||
this.kontext.anFenster({ art: 'auswurf-ergebnis', id, grund, fehler })
|
||||
// Die Wache merkt den neuen Zustand spätestens in der nächsten Runde;
|
||||
// eine sofortige Runde macht das Fenster ohne 3-Sekunden-Wartezeit frisch.
|
||||
this.wache?.runde()
|
||||
}
|
||||
|
||||
// ── Laufwerks-Gesundheit (5.4.0, Punkt 8) ─────────────────────────────
|
||||
async pruefen(id: string): Promise<void> {
|
||||
if (process.platform !== 'win32') return
|
||||
const ergebnis = await ereignisseLesen(GESUNDHEIT_STUNDEN)
|
||||
if ('fehler' in ergebnis) {
|
||||
this.kontext.anFenster({
|
||||
art: 'laufwerk-gesundheit',
|
||||
id,
|
||||
bericht: {
|
||||
urteil: 'nicht-lesbar',
|
||||
text: `Das Windows-Protokoll ließ sich nicht lesen: ${ergebnis.fehler}`,
|
||||
fehlerhafteBloecke: 0,
|
||||
controllerfehler: 0,
|
||||
sonstige: 0,
|
||||
letzte: [],
|
||||
stunden: GESUNDHEIT_STUNDEN,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
const bericht = beurteilen(ergebnis.ereignisse, GESUNDHEIT_STUNDEN)
|
||||
this.kontext.protokoll.schreiben(`Laufwerk ${id} geprüft: ${bericht.urteil} — ${bericht.text}`)
|
||||
this.kontext.anFenster({ art: 'laufwerk-gesundheit', id, bericht })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// Freier Platz (5.5.0, Commander: „Wie viel Speicher noch auf dem Server
|
||||
// oder auf dem Arbeitsordner frei ist") — GEMESSEN am 01.09.2026 mit Node
|
||||
// 24 (Regel D): fs.statfsSync liefert für Laufwerke (C:\, F:\), Ordner
|
||||
// und UNC-Freigaben (\\192.168.178.62\rippy) bavail/bsize/blocks; ein
|
||||
// Pfad, den es nicht gibt (Q:\), wirft ENOENT. Deshalb wird zum nächsten
|
||||
// vorhandenen Elternpfad hochgegangen: Die Ablage darf noch fehlen, ihr
|
||||
// Laufwerk gibt es trotzdem.
|
||||
import { existsSync, statfsSync } from 'node:fs'
|
||||
import { dirname } from 'node:path'
|
||||
import type { SpeicherPlatz, SpeicherStand } from '../../gemeinsam/nachrichten'
|
||||
import type { KernKontext } from '../kontext'
|
||||
import type { EinstellungsDienst } from './einstellungen'
|
||||
|
||||
export type Messen = (pfad: string) => { bavail: number; bsize: number; blocks: number }
|
||||
|
||||
const echtMessen: Messen = (pfad) => {
|
||||
const s = statfsSync(pfad)
|
||||
return { bavail: Number(s.bavail), bsize: Number(s.bsize), blocks: Number(s.blocks) }
|
||||
}
|
||||
|
||||
/** Der nächste Pfad, den es gibt — der Ordner selbst, sonst sein Elternpfad. */
|
||||
export function vorhandenerPfad(pfad: string, existiert: (p: string) => boolean = existsSync): string {
|
||||
let p = pfad
|
||||
for (let i = 0; i < 40; i++) {
|
||||
if (existiert(p)) return p
|
||||
const eltern = dirname(p)
|
||||
if (eltern === p) return p
|
||||
p = eltern
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
export function platzMessen(pfad: string, messen: Messen = echtMessen, existiert: (p: string) => boolean = existsSync): SpeicherPlatz {
|
||||
if (pfad.trim().length === 0) return { pfad, ok: false, freiBytes: 0, gesamtBytes: 0, fehler: 'kein Pfad' }
|
||||
try {
|
||||
const s = messen(vorhandenerPfad(pfad, existiert))
|
||||
return { pfad, ok: true, freiBytes: s.bavail * s.bsize, gesamtBytes: s.blocks * s.bsize, fehler: '' }
|
||||
} catch (fehler) {
|
||||
// R2: „konnte nicht messen" ist etwas anderes als „0 frei" — das
|
||||
// Fenster zeigt den Grund, nie eine erfundene Null.
|
||||
return { pfad, ok: false, freiBytes: 0, gesamtBytes: 0, fehler: String((fehler as Error).message ?? fehler).slice(0, 120) }
|
||||
}
|
||||
}
|
||||
|
||||
export { platzUrteil } from '../../gemeinsam/platz'
|
||||
|
||||
export class SpeicherDienst {
|
||||
private zeiger: NodeJS.Timeout | null = null
|
||||
private letzter: SpeicherStand | null = null
|
||||
|
||||
constructor(
|
||||
private readonly kontext: KernKontext,
|
||||
private readonly einstellungen: EinstellungsDienst,
|
||||
private readonly messen: Messen = echtMessen,
|
||||
) {}
|
||||
|
||||
stand(): SpeicherStand {
|
||||
const arbeit = this.einstellungen.arbeitsordner()
|
||||
const ablage = this.einstellungen.ablageOrdner()
|
||||
const s: SpeicherStand = {
|
||||
arbeit: platzMessen(arbeit, this.messen),
|
||||
ablage: platzMessen(ablage, this.messen),
|
||||
getrennt: this.einstellungen.arbeitsordnerEigen().length > 0,
|
||||
}
|
||||
this.letzter = s
|
||||
return s
|
||||
}
|
||||
|
||||
letzterStand(): SpeicherStand | null {
|
||||
return this.letzter
|
||||
}
|
||||
|
||||
senden(): void {
|
||||
try {
|
||||
this.kontext.anFenster({ art: 'speicher', stand: this.stand() })
|
||||
} catch (fehler) {
|
||||
this.kontext.fehler(`Speicher messen: ${String(fehler)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Jede Minute neu messen — ohne den Prozess-Exit aufzuhalten. */
|
||||
taktStarten(ms = 60_000): void {
|
||||
if (this.zeiger !== null) return
|
||||
this.zeiger = setInterval(() => this.senden(), ms)
|
||||
this.zeiger.unref?.()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
// Vorgänge (5.4.0): jeder Rip als Datensatz, die Kompressions-Warteschlange,
|
||||
// das Roh-Aufräumen, Wiederholen ab Kompression, Wiederaufnahme nach dem
|
||||
// Neustart — und seit 5.5.0: Pause, Statistik, Serien-Merker und die
|
||||
// Benachrichtigung, wenn eine Kompression zu Ende ist.
|
||||
import { existsSync, rmSync } from 'node:fs'
|
||||
import type { KompressionStand, Statistik, VorgaengeStand, VorgangEintrag } from '../../gemeinsam/nachrichten'
|
||||
import { komprimierenUndAblegen, type AblageErgebnis, type AblageUmgebung } from '../ablauf/ablegen'
|
||||
import { auftragAusJson, auftragZuJson, type KompressionsAuftrag } from '../ablauf/auftrag'
|
||||
import type { VorgangBeginn } from '../ablauf/pipeline'
|
||||
import { KompressionsWarteschlange } from '../ablauf/warteschlange'
|
||||
import {
|
||||
AUFBEWAHRUNG_SCHLUESSEL,
|
||||
aufraeumen,
|
||||
faelligAm,
|
||||
fremdeOrdner,
|
||||
istUnterRohWurzel,
|
||||
ordnerGroesse,
|
||||
regelLesen,
|
||||
regelText,
|
||||
} from '../ablage/rohaufraeumen'
|
||||
import { presetEmpfehlung } from '../../gemeinsam/preset-empfehlung'
|
||||
import type { KernKontext } from '../kontext'
|
||||
import { TmdbClient } from '../metadaten/tmdb'
|
||||
import type { Vorgang } from '../speicher/db'
|
||||
import type { BenachrichtigungsDienst } from './benachrichtigung'
|
||||
import type { EinstellungsDienst } from './einstellungen'
|
||||
import type { ErkennungDienst } from './erkennung'
|
||||
import type { WerkzeugDienst } from './werkzeuge'
|
||||
|
||||
/** Statistik aus den Vorgängen (pur). */
|
||||
export function statistikAus(liste: readonly Pick<Vorgang, 'phase' | 'rohBytes' | 'fertigBytes' | 'begonnen' | 'beendet'>[]): Statistik {
|
||||
let fertige = 0
|
||||
let rohBytes = 0
|
||||
let fertigBytes = 0
|
||||
let dauerS = 0
|
||||
for (const v of liste) {
|
||||
rohBytes += v.rohBytes
|
||||
if (v.phase !== 'fertig') continue
|
||||
fertige += 1
|
||||
fertigBytes += v.fertigBytes
|
||||
if (v.beendet !== null) {
|
||||
const d = (new Date(v.beendet).getTime() - new Date(v.begonnen).getTime()) / 1000
|
||||
if (Number.isFinite(d) && d > 0) dauerS += d
|
||||
}
|
||||
}
|
||||
return { discs: liste.length, fertige, rohBytes, fertigBytes, dauerS: Math.round(dauerS) }
|
||||
}
|
||||
|
||||
export function dateienAusJson(text: string, melden?: (grund: string) => void): string[] {
|
||||
const liste: string[] = []
|
||||
try {
|
||||
const roh: unknown = JSON.parse(text)
|
||||
if (Array.isArray(roh)) liste.push(...roh.map(String))
|
||||
} catch (fehler) {
|
||||
// R4: Ein unlesbarer Eintrag wird gesagt — und zählt als „keine
|
||||
// fertigen Dateien bekannt", womit das Aufräumen ihn nie anfasst.
|
||||
melden?.(`fertige Dateien nicht lesbar: ${String(fehler)}`)
|
||||
}
|
||||
return liste
|
||||
}
|
||||
|
||||
export class VorgangDienst {
|
||||
readonly warteschlange: KompressionsWarteschlange
|
||||
/** Nur bei einer PHASEN-Änderung in die Datenbank — nicht bei jedem Prozent-Tick. */
|
||||
private letztePhase = new Map<string, string>()
|
||||
|
||||
constructor(
|
||||
private readonly kontext: KernKontext,
|
||||
private readonly einstellungen: EinstellungsDienst,
|
||||
private readonly werkzeuge: WerkzeugDienst,
|
||||
private readonly erkennung: ErkennungDienst,
|
||||
private readonly benachrichtigung: BenachrichtigungsDienst,
|
||||
/** Nach Änderungen an Vorgängen auch den Speicher neu messen. */
|
||||
private readonly speicherSenden: () => void,
|
||||
) {
|
||||
this.warteschlange = new KompressionsWarteschlange(
|
||||
(auftrag, melden, abbruch) => komprimierenUndAblegen(auftrag, this.ablageUmgebung(), melden, abbruch),
|
||||
(stand) => this.standGeaendert(stand.laeuft),
|
||||
(auftrag, ergebnis) => this.ergebnis(auftrag, ergebnis),
|
||||
)
|
||||
}
|
||||
|
||||
private get db() {
|
||||
return this.kontext.db
|
||||
}
|
||||
|
||||
ablageUmgebung(): AblageUmgebung {
|
||||
return {
|
||||
handbrake: () => this.werkzeuge.handbrake(),
|
||||
ablage: () => this.einstellungen.ablageOrdner(),
|
||||
arbeitsordner: () => this.einstellungen.arbeitsordnerEigen(),
|
||||
einstellungen: () => this.einstellungen.fuerPipeline(),
|
||||
presetEmpfehlung: (typ) => presetEmpfehlung(typ, this.werkzeuge.auskunft.backends, this.werkzeuge.auskunft.presets),
|
||||
// Staffel-Laufzeiten von TMDb — Grundlage der Episoden-Zuordnung (§ 4.2).
|
||||
episodenLaufzeiten: async (tmdbId, staffel) => {
|
||||
const tmdb = new TmdbClient(this.einstellungen.lesen('tmdbApiKey'))
|
||||
if (!tmdb.verfuegbar) return []
|
||||
const antwort = await tmdb.tvStaffel(tmdbId, staffel)
|
||||
const paare: Array<[number, number]> = []
|
||||
for (const ep of antwort?.episodes ?? []) {
|
||||
if (typeof ep.runtime === 'number' && ep.runtime > 0) paare.push([ep.episode_number, ep.runtime * 60])
|
||||
}
|
||||
return paare
|
||||
},
|
||||
bibliothekEintragen: (eintrag) => this.bibliothekEintragen(eintrag),
|
||||
}
|
||||
}
|
||||
|
||||
bibliothekEintragen(eintrag: Parameters<NonNullable<AblageUmgebung['bibliothekEintragen']>>[0]): void {
|
||||
try {
|
||||
this.db?.bibliothekEintragen(eintrag)
|
||||
} catch (fehler) {
|
||||
// R4: Ein Bibliotheks-Fehler kippt keinen gelungenen Rip, wird aber gesagt.
|
||||
this.kontext.fehler(`Bibliothek: ${String(fehler)}`)
|
||||
}
|
||||
}
|
||||
|
||||
private standGeaendert(laeuft: KompressionStand | null): void {
|
||||
if (laeuft !== null && this.letztePhase.get(laeuft.vorgangId) !== laeuft.phase) {
|
||||
this.letztePhase.set(laeuft.vorgangId, laeuft.phase)
|
||||
this.db?.vorgangAktualisieren(laeuft.vorgangId, { phase: laeuft.phase === 'legt-ab' ? 'legt-ab' : 'komprimiert' })
|
||||
}
|
||||
this.senden()
|
||||
}
|
||||
|
||||
private async ergebnis(auftrag: KompressionsAuftrag, ergebnis: AblageErgebnis): Promise<void> {
|
||||
this.letztePhase.delete(auftrag.vorgangId)
|
||||
const jetzt = new Date().toISOString()
|
||||
this.db?.vorgangAktualisieren(auftrag.vorgangId, {
|
||||
phase: ergebnis.status,
|
||||
beendet: jetzt,
|
||||
zielOrdner: ergebnis.zielOrdner,
|
||||
fertigBytes: ergebnis.fertigBytes,
|
||||
komprimiert: ergebnis.komprimiert,
|
||||
preset: ergebnis.preset,
|
||||
fehler: ergebnis.status === 'fertig' ? '' : ergebnis.text,
|
||||
fertigeDateien: JSON.stringify(ergebnis.dateien),
|
||||
})
|
||||
const vorgang = this.db?.vorgangLesen(auftrag.vorgangId)
|
||||
const titel = vorgang?.titel ?? auftrag.vorgangId
|
||||
const text = ergebnis.text + (ergebnis.hinweise.length > 0 ? ` · ${ergebnis.hinweise.join(' · ')}` : '')
|
||||
this.kontext.protokoll.schreiben(`Kompression „${titel}": ${ergebnis.status} — ${text}`)
|
||||
this.kontext.anHauptUndFenster({
|
||||
art: 'kompression-ergebnis',
|
||||
vorgangId: auftrag.vorgangId,
|
||||
titel,
|
||||
ok: ergebnis.status === 'fertig',
|
||||
text,
|
||||
})
|
||||
// Serien-Merker (5.5.0): Mit welcher Folge geht die nächste Disc weiter?
|
||||
const tv = auftrag.zuordnung?.meta.typ === 'tv' ? auftrag.zuordnung.meta : null
|
||||
if (ergebnis.status === 'fertig' && tv !== null && tv.id !== null && ergebnis.folgenVergeben.length > 0) {
|
||||
const letzte = Math.max(...ergebnis.folgenVergeben)
|
||||
this.erkennung.serienMerkerSetzen(tv.id, auftrag.zuordnung?.titel ?? titel, auftrag.staffel ?? 1, letzte + 1)
|
||||
}
|
||||
if (ergebnis.status !== 'abgebrochen') {
|
||||
void this.benachrichtigung.senden(
|
||||
ergebnis.status === 'fertig' ? 'Rippy: fertig' : 'Rippy: Kompression fehlgeschlagen',
|
||||
`${titel}: ${ergebnis.text.slice(0, 300)}`,
|
||||
)
|
||||
}
|
||||
// Regel „sofort": direkt nach der gelungenen Kompression aufräumen.
|
||||
if (ergebnis.status === 'fertig') this.rohAufraeumenLauf()
|
||||
else this.senden()
|
||||
}
|
||||
|
||||
// ── Pipeline-Haken ──────────────────────────────────────────────────────
|
||||
vorgangBeginnt(v: VorgangBeginn): void {
|
||||
const vorgang: Vorgang = {
|
||||
id: v.id,
|
||||
laufwerk: v.laufwerk,
|
||||
typ: v.typ,
|
||||
titel: v.titel,
|
||||
jahr: v.jahr,
|
||||
phase: 'rippt',
|
||||
begonnen: v.begonnen,
|
||||
beendet: null,
|
||||
rohOrdner: v.rohOrdner,
|
||||
rohBytes: 0,
|
||||
rohGeloeschtAm: null,
|
||||
zielOrdner: '',
|
||||
fertigBytes: 0,
|
||||
komprimiert: false,
|
||||
preset: '',
|
||||
fehler: '',
|
||||
auftrag: '{}',
|
||||
lesefehler: false,
|
||||
fertigeDateien: '[]',
|
||||
}
|
||||
this.db?.vorgangAnlegen(vorgang)
|
||||
this.kontext.protokoll.schreiben(`Vorgang ${v.id} beginnt: „${v.titel}" auf ${v.laufwerk} → ${v.rohOrdner}`)
|
||||
this.senden()
|
||||
}
|
||||
|
||||
vorgangEinreihen(auftrag: KompressionsAuftrag, titel: string): number {
|
||||
this.db?.vorgangAktualisieren(auftrag.vorgangId, {
|
||||
phase: 'wartet',
|
||||
titel,
|
||||
jahr: auftrag.zuordnung?.jahr ?? null,
|
||||
rohBytes: auftrag.dateien.reduce((s, d) => s + d.groesseBytes, 0),
|
||||
lesefehler: auftrag.lesefehler,
|
||||
auftrag: auftragZuJson(auftrag),
|
||||
})
|
||||
const platz = this.warteschlange.einreihen(auftrag, titel)
|
||||
this.kontext.protokoll.schreiben(`Vorgang ${auftrag.vorgangId}: ${auftrag.dateien.length} Roh-Dateien, Kompression eingereiht (Platz ${platz})`)
|
||||
this.senden()
|
||||
return platz
|
||||
}
|
||||
|
||||
vorgangEndet(id: string, phase: 'fehler' | 'abgebrochen', text: string): void {
|
||||
this.db?.vorgangAktualisieren(id, { phase, beendet: new Date().toISOString(), fehler: text })
|
||||
this.kontext.protokoll.schreiben(`Vorgang ${id} ${phase}: ${text}`)
|
||||
this.senden()
|
||||
}
|
||||
|
||||
// ── Stand fürs Fenster ──────────────────────────────────────────────────
|
||||
stand(): VorgaengeStand {
|
||||
const regel = regelLesen(this.einstellungen.lesen(AUFBEWAHRUNG_SCHLUESSEL, ''))
|
||||
const liste = this.db?.vorgangListe() ?? []
|
||||
let rohGesamtBytes = 0
|
||||
const vorgaenge: VorgangEintrag[] = liste.map((v) => {
|
||||
const rohDa = v.rohGeloeschtAm === null && existsSync(v.rohOrdner)
|
||||
const rohBytes = rohDa ? ordnerGroesse(v.rohOrdner) : 0
|
||||
rohGesamtBytes += rohBytes
|
||||
const auftrag = auftragAusJson(v.auftrag)
|
||||
const ruhig = v.phase === 'fertig' || v.phase === 'fehler' || v.phase === 'abgebrochen'
|
||||
return {
|
||||
id: v.id,
|
||||
laufwerk: v.laufwerk,
|
||||
typ: v.typ,
|
||||
titel: v.titel,
|
||||
jahr: v.jahr,
|
||||
phase: v.phase,
|
||||
begonnen: v.begonnen,
|
||||
beendet: v.beendet,
|
||||
rohOrdner: v.rohOrdner,
|
||||
rohBytes,
|
||||
rohGeloescht: !rohDa,
|
||||
zielOrdner: v.zielOrdner,
|
||||
fertigBytes: v.fertigBytes,
|
||||
komprimiert: v.komprimiert,
|
||||
preset: v.preset,
|
||||
fehler: v.fehler,
|
||||
titelZahl: auftrag?.dateien.length ?? 0,
|
||||
neuKomprimierbar: rohDa && ruhig && auftrag !== null && auftrag.dateien.length > 0 && !this.warteschlange.kennt(v.id),
|
||||
faelligAm: v.phase === 'fertig' && v.komprimiert && rohDa ? faelligAm(v.beendet, regel) : '',
|
||||
}
|
||||
})
|
||||
const bekannte = new Set(liste.map((v) => v.rohOrdner))
|
||||
const fremde = fremdeOrdner(this.einstellungen.rohWurzel(), bekannte)
|
||||
for (const f of fremde) rohGesamtBytes += f.bytes
|
||||
return {
|
||||
vorgaenge,
|
||||
rohGesamtBytes,
|
||||
fremde,
|
||||
regel: regelText(regel),
|
||||
kompression: { ...this.warteschlange.stand(), pausiert: this.warteschlange.istPausiert() },
|
||||
statistik: statistikAus(liste),
|
||||
}
|
||||
}
|
||||
|
||||
senden(): void {
|
||||
try {
|
||||
this.kontext.anHauptUndFenster({ art: 'vorgaenge', stand: this.stand() })
|
||||
} catch (fehler) {
|
||||
this.kontext.fehler(`Vorgänge: ${String(fehler)}`)
|
||||
}
|
||||
this.speicherSenden()
|
||||
}
|
||||
|
||||
// ── Pause (5.5.0) ───────────────────────────────────────────────────────
|
||||
pause(an: boolean): void {
|
||||
this.warteschlange.pausieren(an)
|
||||
this.kontext.hinweis(an ? 'Kompression angehalten — der laufende Film wird fertig, danach wartet die Schlange.' : 'Kompression läuft weiter.')
|
||||
this.senden()
|
||||
}
|
||||
|
||||
// ── Roh-Aufräumen ───────────────────────────────────────────────────────
|
||||
rohAufraeumenLauf(): void {
|
||||
if (this.db === null) return
|
||||
try {
|
||||
const regel = regelLesen(this.einstellungen.lesen(AUFBEWAHRUNG_SCHLUESSEL, ''))
|
||||
const kandidaten = this.db.vorgangListe().map((v) => ({
|
||||
id: v.id,
|
||||
phase: v.phase,
|
||||
komprimiert: v.komprimiert,
|
||||
rohOrdner: v.rohOrdner,
|
||||
rohGeloeschtAm: v.rohGeloeschtAm,
|
||||
beendet: v.beendet,
|
||||
fertigeDateien: dateienAusJson(v.fertigeDateien, (g) => this.kontext.protokoll.schreiben(`${v.id}: ${g}`)),
|
||||
}))
|
||||
const ergebnis = aufraeumen(kandidaten, regel, new Date())
|
||||
const jetzt = new Date().toISOString()
|
||||
for (const id of ergebnis.geloescht) {
|
||||
this.db.vorgangAktualisieren(id, { rohGeloeschtAm: jetzt, rohBytes: this.db.vorgangLesen(id)?.rohBytes ?? 0 })
|
||||
this.kontext.hinweis(`Roh-Dateien von „${this.db.vorgangLesen(id)?.titel ?? id}" gelöscht (${regelText(regel)})`)
|
||||
}
|
||||
for (const f of ergebnis.fehler) this.kontext.fehler(`Roh-Ordner von ${f.id} nicht gelöscht: ${f.grund}`)
|
||||
} catch (fehler) {
|
||||
this.kontext.fehler(`Roh-Aufräumen: ${String(fehler)}`)
|
||||
}
|
||||
this.senden()
|
||||
}
|
||||
|
||||
rohLoeschen(vorgangId: string): void {
|
||||
const v = this.db?.vorgangLesen(vorgangId)
|
||||
if (v === null || v === undefined) {
|
||||
this.kontext.fehler(`Vorgang ${vorgangId}: unbekannt.`)
|
||||
return
|
||||
}
|
||||
if (v.phase === 'rippt' || v.phase === 'wartet' || v.phase === 'komprimiert' || v.phase === 'legt-ab' || this.warteschlange.kennt(vorgangId)) {
|
||||
this.kontext.fehler(`„${v.titel}" läuft noch (${v.phase}) — erst abbrechen, dann löschen.`)
|
||||
return
|
||||
}
|
||||
// Ein alter Roh-Ordner darf auch unter einer FRÜHEREN Roh-Wurzel liegen
|
||||
// (Arbeitsordner gewechselt) — geprüft wird nur, dass er ein Ordner
|
||||
// direkt unter irgendeinem roh\ ist.
|
||||
if (!/[\\/]roh[\\/][^\\/]+$/i.test(v.rohOrdner)) {
|
||||
this.kontext.fehler(`Roh-Ordner von „${v.titel}" liegt nicht unter einem roh\\-Ordner — nicht angefasst.`)
|
||||
return
|
||||
}
|
||||
try {
|
||||
rmSync(v.rohOrdner, { recursive: true, force: true })
|
||||
this.db?.vorgangAktualisieren(vorgangId, { rohGeloeschtAm: new Date().toISOString() })
|
||||
this.kontext.hinweis(`Roh-Dateien von „${v.titel}" gelöscht (von Hand).`)
|
||||
} catch (fehler) {
|
||||
this.kontext.fehler(`Roh-Ordner von „${v.titel}" nicht gelöscht: ${String(fehler)}`)
|
||||
}
|
||||
this.senden()
|
||||
}
|
||||
|
||||
fremdenOrdnerLoeschen(ordner: string): void {
|
||||
if (!istUnterRohWurzel(this.einstellungen.rohWurzel(), ordner)) {
|
||||
this.kontext.fehler(`„${ordner}" liegt nicht direkt unter ${this.einstellungen.rohWurzel()} — nicht angefasst.`)
|
||||
return
|
||||
}
|
||||
const bekannt = (this.db?.vorgangListe() ?? []).some((v) => v.rohOrdner.toLowerCase() === ordner.toLowerCase())
|
||||
if (bekannt) {
|
||||
this.kontext.fehler(`„${ordner}" gehört zu einem Vorgang — dort löschen.`)
|
||||
return
|
||||
}
|
||||
try {
|
||||
rmSync(ordner, { recursive: true, force: true })
|
||||
this.kontext.hinweis(`Ordner „${ordner}" gelöscht (von Hand).`)
|
||||
} catch (fehler) {
|
||||
this.kontext.fehler(`„${ordner}" nicht gelöscht: ${String(fehler)}`)
|
||||
}
|
||||
this.senden()
|
||||
}
|
||||
|
||||
/** „Wiederholen ab Kompression": Die Roh-Dateien liegen genau dafür da. */
|
||||
neuKomprimieren(vorgangId: string, preset: string): void {
|
||||
const v = this.db?.vorgangLesen(vorgangId)
|
||||
if (v === null || v === undefined) {
|
||||
this.kontext.fehler(`Vorgang ${vorgangId}: unbekannt.`)
|
||||
return
|
||||
}
|
||||
if (this.warteschlange.kennt(vorgangId)) {
|
||||
this.kontext.fehler(`„${v.titel}" ist schon in der Warteschlange.`)
|
||||
return
|
||||
}
|
||||
const auftrag = auftragAusJson(v.auftrag)
|
||||
if (auftrag === null) {
|
||||
this.kontext.fehler(`„${v.titel}": Der Auftrag ist nicht mehr lesbar — nur ein neuer Rip hilft.`)
|
||||
return
|
||||
}
|
||||
const vorhandene = auftrag.dateien.filter((d) => existsSync(d.pfad))
|
||||
if (vorhandene.length === 0) {
|
||||
this.kontext.fehler(`„${v.titel}": Die Roh-Dateien sind nicht mehr da (${v.rohOrdner}).`)
|
||||
return
|
||||
}
|
||||
if (vorhandene.length < auftrag.dateien.length) {
|
||||
this.kontext.hinweis(`„${v.titel}": ${auftrag.dateien.length - vorhandene.length} Roh-Datei(en) fehlen — der Rest wird komprimiert.`)
|
||||
}
|
||||
const alteFassung = v.phase === 'fertig' && v.zielOrdner.length > 0 && existsSync(v.zielOrdner)
|
||||
const neu: KompressionsAuftrag = {
|
||||
...auftrag,
|
||||
dateien: vorhandene,
|
||||
preset,
|
||||
// Die neue Fassung ersetzt die alte im selben Ordner — die alten
|
||||
// Dateien fallen erst, wenn die neuen fertig sind (ablegen.ts).
|
||||
zielOrdner: alteFassung ? v.zielOrdner : '',
|
||||
ersetzt: alteFassung ? dateienAusJson(v.fertigeDateien) : [],
|
||||
}
|
||||
this.db?.vorgangAktualisieren(vorgangId, { phase: 'wartet', fehler: '', auftrag: auftragZuJson(neu) })
|
||||
const platz = this.warteschlange.einreihen(neu, v.titel)
|
||||
this.kontext.hinweis(`„${v.titel}": Kompression eingereiht${platz > 1 ? ` (Platz ${platz})` : ''}${preset.length > 0 ? ` mit Preset „${preset}"` : ''}.`)
|
||||
this.senden()
|
||||
}
|
||||
|
||||
/** Nach einem Neustart: Was wartete noch? Was war mitten im Rip? */
|
||||
wiederaufnehmen(): void {
|
||||
if (this.db === null) return
|
||||
const jetzt = new Date().toISOString()
|
||||
for (const v of this.db.vorgangListe()) {
|
||||
if (v.phase === 'rippt') {
|
||||
this.db.vorgangAktualisieren(v.id, {
|
||||
phase: 'abgebrochen',
|
||||
beendet: jetzt,
|
||||
fehler: 'Rippy wurde beendet, bevor der Rip fertig war — die Roh-Dateien können unvollständig sein.',
|
||||
})
|
||||
this.kontext.protokoll.schreiben(`Vorgang ${v.id}: war beim letzten Beenden mitten im Rip — als abgebrochen vermerkt`)
|
||||
continue
|
||||
}
|
||||
if (v.phase === 'wartet' || v.phase === 'komprimiert' || v.phase === 'legt-ab') {
|
||||
const auftrag = auftragAusJson(v.auftrag)
|
||||
if (auftrag !== null && existsSync(v.rohOrdner) && auftrag.dateien.some((d) => existsSync(d.pfad))) {
|
||||
this.warteschlange.einreihen({ ...auftrag, dateien: auftrag.dateien.filter((d) => existsSync(d.pfad)) }, v.titel)
|
||||
this.kontext.hinweis(`„${v.titel}": Kompression nach dem Neustart wieder aufgenommen.`)
|
||||
} else {
|
||||
this.db.vorgangAktualisieren(v.id, {
|
||||
phase: 'fehler',
|
||||
beendet: jetzt,
|
||||
fehler: 'Nach dem Neustart fehlen die Roh-Dateien — die Kompression kann nicht wieder aufgenommen werden.',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
// Die Werkzeuge (§ 9): wo makemkvcon, HandBrakeCLI und flac liegen, die
|
||||
// gemessenen HandBrake-Listen (Presets, Encoder), die MakeMKV-Schlüsselkette
|
||||
// (§ 9.1) samt Auswahlregel (5.4.0) und das MakeMKV-Selbst-Update.
|
||||
import type { KernNachricht } from '../../gemeinsam/nachrichten'
|
||||
import type { KernKontext } from '../kontext'
|
||||
import { finden } from '../werkzeuge/katalog'
|
||||
import type { EinstellungsDienst } from './einstellungen'
|
||||
|
||||
export type SchluesselStand = Extract<KernNachricht, { art: 'schluessel-status' }>['stand']
|
||||
|
||||
export class WerkzeugDienst {
|
||||
/** Die gemessene Auskunft bleibt liegen — die Pipeline braucht sie für die
|
||||
* Preset-Empfehlung, auch wenn gerade kein Fenster offen ist. */
|
||||
auskunft: { presets: string[]; backends: string[] } = { presets: [], backends: [] }
|
||||
schluesselStand: SchluesselStand | null = null
|
||||
private beschaffungLaeuft = false
|
||||
private installerAntwort: ((fehler: string | null) => void) | null = null
|
||||
|
||||
constructor(
|
||||
private readonly kontext: KernKontext,
|
||||
private readonly einstellungen: EinstellungsDienst,
|
||||
) {}
|
||||
|
||||
makemkvcon(): string {
|
||||
return finden('makemkv', this.einstellungen.lesen('werkzeug.makemkv'))
|
||||
}
|
||||
|
||||
handbrake(): string {
|
||||
return finden('handbrake', this.einstellungen.lesen('werkzeug.handbrake'))
|
||||
}
|
||||
|
||||
flac(): string {
|
||||
return finden('flac', this.einstellungen.lesen('werkzeug.flac'))
|
||||
}
|
||||
|
||||
/** Werkzeuge suchen, HandBrake messen, ans Fenster. */
|
||||
async senden(): Promise<void> {
|
||||
const makemkvcon = this.makemkvcon()
|
||||
const handbrake = this.handbrake()
|
||||
const flac = this.flac()
|
||||
const { encoderMessen } = await import('../komprimieren/encoder')
|
||||
const gemessen = await encoderMessen(handbrake)
|
||||
this.auskunft = { presets: gemessen.presets, backends: gemessen.backends }
|
||||
this.kontext.anFenster({
|
||||
art: 'werkzeuge',
|
||||
auskunft: { makemkvcon, handbrake, flac, presets: gemessen.presets, backends: gemessen.backends },
|
||||
})
|
||||
this.kontext.protokoll.schreiben(
|
||||
`Werkzeuge: makemkvcon=${makemkvcon || 'FEHLT'} handbrake=${handbrake || 'FEHLT'} flac=${flac || 'FEHLT'} · ${gemessen.presets.length} Presets · Encoder ${gemessen.backends.join(',') || '—'}`,
|
||||
)
|
||||
}
|
||||
|
||||
// ── MakeMKV-Schlüsselkette (§ 9.1): beim Start und täglich ──────────────
|
||||
async schluesselLauf(): Promise<void> {
|
||||
if (process.platform !== 'win32') return
|
||||
try {
|
||||
const { schluesselKette } = await import('../werkzeuge/schluessel')
|
||||
this.schluesselStand = await schluesselKette(
|
||||
this.makemkvcon(),
|
||||
this.einstellungen.lesen('makemkvAppKey'),
|
||||
new Date().toISOString(),
|
||||
)
|
||||
this.kontext.anHauptUndFenster({ art: 'schluessel-status', stand: this.schluesselStand })
|
||||
this.kontext.protokoll.schreiben(`Schlüssel: ${this.schluesselStand.text}`)
|
||||
} catch (fehler) {
|
||||
// R4: Der Schlüssel-Lauf ist Komfort — sein Scheitern wird gesagt.
|
||||
this.kontext.fehler(`Schlüsselkette: ${String(fehler)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** MakeMKVs Spur-Auswahlregel sicherstellen (5.4.0) — sonst fehlen
|
||||
* Sprachen in der Roh-Datei, die der Dialog anbot. */
|
||||
async spurauswahlLauf(): Promise<void> {
|
||||
if (process.platform !== 'win32') return
|
||||
try {
|
||||
const { spurauswahlSicherstellen } = await import('../werkzeuge/spurauswahl')
|
||||
const stand = spurauswahlSicherstellen()
|
||||
if (stand.stand === 'eigen' || stand.stand === 'fehler') {
|
||||
this.kontext.fehler(`MakeMKV-Auswahlregel: ${stand.text}`)
|
||||
} else if (stand.stand === 'gesetzt') {
|
||||
this.kontext.hinweis(stand.text)
|
||||
} else {
|
||||
this.kontext.protokoll.schreiben(stand.text)
|
||||
}
|
||||
} catch (fehler) {
|
||||
this.kontext.fehler(`MakeMKV-Auswahlregel: ${String(fehler)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Antwort des Haupts auf 'installer-starten'. */
|
||||
installerErgebnis(fehler: string | null): void {
|
||||
this.installerAntwort?.(fehler ?? '')
|
||||
this.installerAntwort = null
|
||||
}
|
||||
|
||||
private beschaffungMelden(text: string, anteil: number | null, laeuft: boolean, fehler = false): void {
|
||||
this.kontext.anFenster({ art: 'beschaffung-status', stand: { werkzeug: 'makemkv', laeuft, text, anteil, fehler } })
|
||||
this.kontext.protokoll.schreiben(`MakeMKV-Update: ${text}`)
|
||||
}
|
||||
|
||||
// ── MakeMKV-Update (§ 9): holen, prüfen, installieren lassen ────────────
|
||||
// Der Kern lädt und PRÜFT die Datei; STARTEN kann sie nur das Haupt
|
||||
// (shell → UAC-Abfrage; der erhöhte Installer hängt dadurch nicht an der
|
||||
// Prozess-Leine und überlebt ein geschlossenes Rippy).
|
||||
async makemkvUpdateLauf(): Promise<void> {
|
||||
if (this.beschaffungLaeuft) {
|
||||
this.beschaffungMelden('Ein MakeMKV-Update läuft bereits.', null, true)
|
||||
return
|
||||
}
|
||||
this.beschaffungLaeuft = true
|
||||
try {
|
||||
const { istNeuer, makemkvNeuesteVersion, makemkvInstallerHolen, versionsangaben } = await import(
|
||||
'../werkzeuge/beschaffen'
|
||||
)
|
||||
this.beschaffungMelden('Nach der neuesten MakeMKV-Fassung wird gesucht …', null, true)
|
||||
const neueste = await makemkvNeuesteVersion()
|
||||
if (neueste.length === 0) {
|
||||
this.beschaffungMelden(
|
||||
'Keine der Quellen konnte sagen, welche Fassung aktuell ist (Hersteller-Seite, Forum, Archiv). ' +
|
||||
'Ausweg: MakeMKV von Hand von makemkv.com laden und installieren; Rippy findet es danach von allein.',
|
||||
null,
|
||||
false,
|
||||
true,
|
||||
)
|
||||
this.beschaffungLaeuft = false
|
||||
return
|
||||
}
|
||||
const installiert = this.schluesselStand?.version ?? ''
|
||||
if (installiert.length > 0 && !istNeuer(neueste, installiert)) {
|
||||
// Ehrlich bleiben (31.08.2026 gemessen: 1.18.4 ist installiert UND
|
||||
// die neueste angekündigte — die Beta ist nur abgelaufen): Ein
|
||||
// Download derselben Fassung würde nichts lösen.
|
||||
this.beschaffungMelden(
|
||||
`MakeMKV ${installiert} ist bereits die neueste angekündigte Fassung. ` +
|
||||
'Sobald der Hersteller eine neuere veröffentlicht, holt dieser Knopf sie — ' +
|
||||
'bis dahin rippt der Beta-Modus weiter.',
|
||||
null,
|
||||
false,
|
||||
)
|
||||
this.beschaffungLaeuft = false
|
||||
return
|
||||
}
|
||||
// Merken, woran eine gelungene Installation zu erkennen ist: Die
|
||||
// Versions-Ressource der gefundenen EXE ändert sich (oder es gibt
|
||||
// erstmals eine).
|
||||
const alteExe = this.makemkvcon()
|
||||
const alteVersion = alteExe.length > 0 ? versionsangaben(alteExe).version : ''
|
||||
|
||||
const ergebnis = await makemkvInstallerHolen(neueste, (text, anteil) => this.beschaffungMelden(text, anteil, true))
|
||||
this.beschaffungMelden(`Geprüft (Quelle: ${ergebnis.quelle}) — der Installer wird gestartet …`, null, true)
|
||||
|
||||
const startFehler = await new Promise<string | null>((fertig) => {
|
||||
this.installerAntwort = fertig
|
||||
process.parentPort.postMessage({ art: 'installer-starten', pfad: ergebnis.pfad })
|
||||
// Falls das Haupt nie antwortet, bleibt der Lauf nicht ewig hängen.
|
||||
setTimeout(() => fertig('Keine Antwort vom Haupt-Prozess'), 30_000).unref()
|
||||
})
|
||||
this.installerAntwort = null
|
||||
if (startFehler !== null && startFehler.length > 0) {
|
||||
this.beschaffungMelden(`Der Installer wurde nicht gestartet: ${startFehler}`, null, false, true)
|
||||
this.beschaffungLaeuft = false
|
||||
return
|
||||
}
|
||||
this.beschaffungMelden(
|
||||
`Der MakeMKV-Installer ${ergebnis.version} läuft — bitte die Windows-Nachfrage bestätigen und durchklicken. ` +
|
||||
'Rippy merkt die fertige Installation von allein und prüft den Schlüssel neu.',
|
||||
null,
|
||||
true,
|
||||
)
|
||||
// Warten, bis die Installation durch ist: Die Versions-Ressource der
|
||||
// EXE ändert sich. Alle 15 s nachsehen, höchstens 20 Minuten.
|
||||
let runden = 0
|
||||
const zeiger = setInterval(() => {
|
||||
runden += 1
|
||||
const exe = this.makemkvcon()
|
||||
const jetztVersion = exe.length > 0 ? versionsangaben(exe).version : ''
|
||||
if (jetztVersion.length > 0 && jetztVersion !== alteVersion) {
|
||||
clearInterval(zeiger)
|
||||
this.beschaffungLaeuft = false
|
||||
this.beschaffungMelden(`MakeMKV ${jetztVersion} ist installiert — der Schlüssel wird neu geprüft.`, null, false)
|
||||
void this.schluesselLauf().then(() => this.senden())
|
||||
} else if (runden >= 80) {
|
||||
clearInterval(zeiger)
|
||||
this.beschaffungLaeuft = false
|
||||
this.beschaffungMelden(
|
||||
'Die fertige Installation wurde nicht bemerkt — nach dem Durchklicken hilft „Schlüssel neu prüfen".',
|
||||
null,
|
||||
false,
|
||||
)
|
||||
}
|
||||
}, 15_000)
|
||||
zeiger.unref()
|
||||
} catch (fehler) {
|
||||
this.beschaffungMelden(fehler instanceof Error ? fehler.message : String(fehler), null, false, true)
|
||||
this.beschaffungLaeuft = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,55 +1,47 @@
|
||||
// Der Kern — läuft als Electron utilityProcess (KONZEPT-WINDOWS.md § 4.1).
|
||||
// Hier passiert die ganze Arbeit; das Fenster zeigt nur an. Stand W-1:
|
||||
// Datenbank (node:sqlite, § 3.4), Disc-Wache im 3-Sekunden-Takt (§ 5),
|
||||
// Auswerfen mit Nachsehen (R3), Ping/Pong über den direkten MessagePort.
|
||||
// Hier passiert die ganze Arbeit; das Fenster zeigt nur an.
|
||||
//
|
||||
// Seit 5.5.0 ist diese Datei nur noch der EINSTIEG (Separation of Concerns,
|
||||
// Commander-Wunsch): Datenbank öffnen, Protokoll anlegen, die Dienste
|
||||
// verdrahten und Nachrichten verteilen. Die Arbeit liegt in kern/dienste/:
|
||||
// einstellungen — die EINE Quelle (§ 6.7), Ablage/Arbeitsordner
|
||||
// laufwerke — Wache, Auswurf, Gesundheit, nachgebautes Laufwerk
|
||||
// erkennung — Disc-Merkmale, TMDb/OMDb, Titel-Lauf, Serien-Merker
|
||||
// vorgaenge — Datensätze, Warteschlange, Roh-Aufräumen, Statistik
|
||||
// werkzeuge — makemkvcon/HandBrake/flac, Schlüssel, MakeMKV-Update
|
||||
// speicher — freier Platz
|
||||
// benachrichtigung— Discord/Telegram
|
||||
// automatik — Zero-Click mit Countdown
|
||||
// und die Kette selbst in kern/ablauf/ (Pipeline → Warteschlange → Ablage).
|
||||
//
|
||||
// parentPort/MessagePortMain: Schnittstelle laut Electron-Doku »utilityProcess«
|
||||
// und »MessagePorts in Electron« — dieselbe Verdrahtung wie im gemessenen
|
||||
// Beweis beweise/sqlite-main.js.
|
||||
import type { MessagePortMain } from 'electron'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
istFensterNachricht,
|
||||
istHauptNachricht,
|
||||
type DbStatus,
|
||||
type KernNachricht,
|
||||
type MetaVorschlag,
|
||||
} from '../gemeinsam/nachrichten'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { istFensterNachricht, istHauptNachricht, type DbStatus, type KernNachricht, type RipAuftragOptionen, type RipAuftragTitel } from '../gemeinsam/nachrichten'
|
||||
import { Protokoll } from '../gemeinsam/protokoll'
|
||||
import { Pipeline } from './ablauf/pipeline'
|
||||
import type { LaufwerkApi } from './laufwerk/codes'
|
||||
import {
|
||||
audioSpurLesen,
|
||||
audioToc,
|
||||
auswerfenMitGrund,
|
||||
geraeteInfo,
|
||||
kennung,
|
||||
listOptischeLaufwerke,
|
||||
} from './laufwerk/disc'
|
||||
import { DiscWache } from './laufwerk/wache'
|
||||
import { discFingerprint, discTitel } from './metadaten/discmerkmale'
|
||||
import { OmdbClient } from './metadaten/omdb'
|
||||
import { TmdbClient } from './metadaten/tmdb'
|
||||
import { zuordnen, type Zuordnung } from './metadaten/zuordnung'
|
||||
import { presetEmpfehlung } from '../gemeinsam/preset-empfehlung'
|
||||
import { AUFBEWAHRUNG_SCHLUESSEL } from './ablage/rohaufraeumen'
|
||||
import { AutomatikDienst } from './dienste/automatik'
|
||||
import { BenachrichtigungsDienst } from './dienste/benachrichtigung'
|
||||
import { EinstellungsDienst } from './dienste/einstellungen'
|
||||
import { ErkennungDienst } from './dienste/erkennung'
|
||||
import { LaufwerkDienst } from './dienste/laufwerke'
|
||||
import { SpeicherDienst, platzUrteil } from './dienste/speicher'
|
||||
import { VorgangDienst } from './dienste/vorgaenge'
|
||||
import { WerkzeugDienst } from './dienste/werkzeuge'
|
||||
import { datenbankPfadAusArgv, type KernKontext } from './kontext'
|
||||
import { Datenbank } from './speicher/db'
|
||||
import { finden } from './werkzeuge/katalog'
|
||||
|
||||
// --datenbank=<pfad> kommt vom Haupt (der Kern kennt Electrons app.getPath
|
||||
// nicht — er ist ein reiner Node-Prozess).
|
||||
function datenbankPfadAusArgv(argv: readonly string[]): string | null {
|
||||
for (const arg of argv) {
|
||||
if (arg.startsWith('--datenbank=')) {
|
||||
const pfad = arg.slice('--datenbank='.length)
|
||||
if (pfad.length > 0) return pfad
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// ── Datenbank und Protokoll ─────────────────────────────────────────────
|
||||
let db: Datenbank | null = null
|
||||
let dbStatus: DbStatus
|
||||
|
||||
const pfad = datenbankPfadAusArgv(process.argv)
|
||||
const protokoll = new Protokoll(join(pfad !== null ? dirname(pfad) : join(process.env['TEMP'] ?? '.', 'rippy'), 'logs'), 'kern')
|
||||
protokoll.aufraeumen()
|
||||
protokoll.schreiben(`Kern startet (PID ${process.pid}, Node ${process.versions.node}) — Datenbank ${pfad ?? '?'}`)
|
||||
|
||||
if (pfad === null) {
|
||||
dbStatus = { ok: false, fehler: 'Kein --datenbank=<pfad> übergeben' }
|
||||
} else {
|
||||
@@ -69,486 +61,257 @@ if (pfad === null) {
|
||||
dbStatus = { ok: false, fehler: String(fehler) }
|
||||
}
|
||||
}
|
||||
protokoll.schreiben(dbStatus.ok ? `Datenbank geöffnet: ${dbStatus.pfad} (Schema ${db?.schemaVersion() ?? '?'})` : `DATENBANK-FEHLER: ${dbStatus.fehler}`)
|
||||
|
||||
const smokeKern = process.argv.includes('--smoke-kern')
|
||||
let fensterPort: MessagePortMain | null = null
|
||||
|
||||
function anFenster(nachricht: KernNachricht): void {
|
||||
fensterPort?.postMessage(nachricht)
|
||||
}
|
||||
|
||||
function anHauptUndFenster(nachricht: KernNachricht): void {
|
||||
process.parentPort.postMessage(nachricht)
|
||||
anFenster(nachricht)
|
||||
}
|
||||
|
||||
// ── Laufwerke (W-1) ─────────────────────────────────────────────────────
|
||||
// koffi wird erst HIER geladen, über einen echten dynamischen import():
|
||||
// Rollup legt win32.ts damit als eigenen Chunk neben kern.js (ein
|
||||
// wörtliches require überlebte das Bündeln nicht — im Smoke-Lauf am
|
||||
// 30.08.2026 sichtbar geworden). Scheitert das Laden (falsche Plattform,
|
||||
// kaputtes Paket), läuft der Kern weiter und der Grund steht im Fenster (R4).
|
||||
let laufwerkApi: LaufwerkApi | null = null
|
||||
let laufwerkFehler: string | null = null
|
||||
let wache: DiscWache | null = null
|
||||
|
||||
// ── Metadaten (W-4): Was liegt da drin? ─────────────────────────────────
|
||||
const discZuordnungen = new Map<string, Zuordnung>()
|
||||
const discFingerprints = new Map<string, string>()
|
||||
|
||||
async function discErmitteln(id: string, geraetepfad: string, groesseBytes: number): Promise<void> {
|
||||
try {
|
||||
const gefunden = discTitel(geraetepfad)
|
||||
const fingerprint = discFingerprint(geraetepfad, groesseBytes)
|
||||
discFingerprints.set(id, fingerprint)
|
||||
const tmdb = new TmdbClient(db?.einstellungLesen('tmdbApiKey') ?? '')
|
||||
const omdb = new OmdbClient(db?.einstellungLesen('omdbApiKey') ?? '')
|
||||
const zuordnung = await zuordnen(gefunden.titel, tmdb, omdb)
|
||||
discZuordnungen.set(id, zuordnung)
|
||||
anHauptUndFenster({
|
||||
art: 'disc-info',
|
||||
info: {
|
||||
id,
|
||||
titel: zuordnung.titel,
|
||||
jahr: zuordnung.jahr,
|
||||
confidence: zuordnung.confidence,
|
||||
quelle: zuordnung.meta.quelle,
|
||||
posterPfad: zuordnung.meta.posterPfad,
|
||||
backdropPfad: zuordnung.meta.backdropPfad,
|
||||
beschreibung: zuordnung.meta.beschreibung,
|
||||
// § 6.6: Rippy erkennt beim Einlegen, wenn eine Disc schon einmal
|
||||
// durchgelaufen ist — und fragt nach, statt stumm zu doppeln.
|
||||
schonGerippt: db?.bibliothekSchonGerippt(fingerprint) ?? null,
|
||||
},
|
||||
})
|
||||
} catch (fehler) {
|
||||
// R4: Eine gescheiterte Erkennung ist eine laute Zeile — gerippt
|
||||
// werden kann trotzdem (die Zuordnung ist Komfort, kein Torwächter).
|
||||
console.error(`[kern] Disc-Erkennung ${id}: ${String(fehler)}`)
|
||||
anFenster({ art: 'kern-fehler', text: `Disc-Erkennung ${id}: ${String(fehler)}` })
|
||||
}
|
||||
}
|
||||
|
||||
// § 5 Schritt 4: „nein → fragen" — der Nutzer sucht und WÄHLT selbst.
|
||||
function jahrAus(datum: string | undefined): number | null {
|
||||
if (datum === undefined || datum.length < 4) return null
|
||||
const jahr = Number(datum.slice(0, 4))
|
||||
return Number.isFinite(jahr) && jahr > 0 ? jahr : null
|
||||
}
|
||||
|
||||
async function metadatenSuchen(id: string, text: string): Promise<void> {
|
||||
try {
|
||||
const tmdb = new TmdbClient(db?.einstellungLesen('tmdbApiKey') ?? '')
|
||||
if (!tmdb.verfuegbar) {
|
||||
anFenster({ art: 'kern-fehler', text: 'Metadaten-Suche braucht einen TMDb-Key (Einstellungen → Metadaten).' })
|
||||
anFenster({ art: 'metadaten-vorschlaege', id, vorschlaege: [] })
|
||||
return
|
||||
}
|
||||
const [filme, serien] = await Promise.all([tmdb.searchMovie(text), tmdb.searchTv(text)])
|
||||
const vorschlaege: MetaVorschlag[] = [
|
||||
...(filme ?? []).slice(0, 6).map((f) => ({
|
||||
typ: 'movie' as const,
|
||||
tmdbId: f.id,
|
||||
titel: f.title ?? '',
|
||||
jahr: jahrAus(f.release_date),
|
||||
posterPfad: f.poster_path ?? '',
|
||||
})),
|
||||
...(serien ?? []).slice(0, 6).map((s) => ({
|
||||
typ: 'tv' as const,
|
||||
tmdbId: s.id,
|
||||
titel: s.name ?? '',
|
||||
jahr: jahrAus(s.first_air_date),
|
||||
posterPfad: s.poster_path ?? '',
|
||||
})),
|
||||
].filter((v) => v.titel.length > 0)
|
||||
anFenster({ art: 'metadaten-vorschlaege', id, vorschlaege })
|
||||
} catch (fehler) {
|
||||
anFenster({ art: 'kern-fehler', text: `Metadaten-Suche: ${String(fehler)}` })
|
||||
anFenster({ art: 'metadaten-vorschlaege', id, vorschlaege: [] })
|
||||
}
|
||||
}
|
||||
|
||||
async function zuordnungSetzen(id: string, vorschlag: MetaVorschlag): Promise<void> {
|
||||
try {
|
||||
const tmdb = new TmdbClient(db?.einstellungLesen('tmdbApiKey') ?? '')
|
||||
const details =
|
||||
vorschlag.typ === 'movie' ? await tmdb.movieDetails(vorschlag.tmdbId) : await tmdb.tvDetails(vorschlag.tmdbId)
|
||||
const zuordnung: Zuordnung = {
|
||||
titel: (vorschlag.typ === 'movie' ? details?.title : details?.name) ?? vorschlag.titel,
|
||||
jahr: jahrAus(vorschlag.typ === 'movie' ? details?.release_date : details?.first_air_date) ?? vorschlag.jahr,
|
||||
// Vom Nutzer gewählt = sicher. Genau dafür ist die Frage da (§ 5).
|
||||
confidence: 1,
|
||||
meta: {
|
||||
typ: vorschlag.typ,
|
||||
id: vorschlag.tmdbId,
|
||||
titel: (vorschlag.typ === 'movie' ? details?.title : details?.name) ?? vorschlag.titel,
|
||||
jahr: jahrAus(vorschlag.typ === 'movie' ? details?.release_date : details?.first_air_date) ?? vorschlag.jahr,
|
||||
beschreibung: details?.overview ?? '',
|
||||
posterPfad: details?.poster_path ?? vorschlag.posterPfad,
|
||||
backdropPfad: details?.backdrop_path ?? '',
|
||||
laufzeitMinuten: details?.runtime ?? 0,
|
||||
genres: (details?.genres ?? []).map((g) => g.name),
|
||||
quelle: 'tmdb',
|
||||
},
|
||||
}
|
||||
discZuordnungen.set(id, zuordnung)
|
||||
anHauptUndFenster({
|
||||
art: 'disc-info',
|
||||
info: {
|
||||
id,
|
||||
titel: zuordnung.titel,
|
||||
jahr: zuordnung.jahr,
|
||||
confidence: 1,
|
||||
quelle: 'deine Wahl',
|
||||
posterPfad: zuordnung.meta.posterPfad,
|
||||
backdropPfad: zuordnung.meta.backdropPfad,
|
||||
beschreibung: zuordnung.meta.beschreibung,
|
||||
schonGerippt: db?.bibliothekSchonGerippt(discFingerprints.get(id) ?? '') ?? null,
|
||||
},
|
||||
})
|
||||
} catch (fehler) {
|
||||
anFenster({ art: 'kern-fehler', text: `Zuordnung übernehmen: ${String(fehler)}` })
|
||||
}
|
||||
}
|
||||
|
||||
const laufwerkeBereit: Promise<void> = (async () => {
|
||||
try {
|
||||
if (process.platform !== 'win32') {
|
||||
throw new Error('Die Laufwerks-Schicht läuft nur unter Windows (KONZEPT § 14).')
|
||||
}
|
||||
const { win32Api } = await import('./laufwerk/win32')
|
||||
laufwerkApi = win32Api()
|
||||
wache = new DiscWache(
|
||||
() => listOptischeLaufwerke(laufwerkApi!).map((g) => geraeteInfo(laufwerkApi!, g)),
|
||||
(meldung) => {
|
||||
anHauptUndFenster({ art: 'laufwerke', liste: meldung.laufwerke, ereignisse: meldung.ereignisse })
|
||||
for (const ereignis of meldung.ereignisse) {
|
||||
if (ereignis.art === 'disc-eingelegt') {
|
||||
void discErmitteln(ereignis.geraet.id, ereignis.geraet.pfad, ereignis.geraet.groesseBytes)
|
||||
} else if (ereignis.art === 'disc-entnommen') {
|
||||
discZuordnungen.delete(ereignis.geraet.id)
|
||||
discFingerprints.delete(ereignis.geraet.id)
|
||||
anHauptUndFenster({
|
||||
art: 'disc-info',
|
||||
info: {
|
||||
id: ereignis.geraet.id,
|
||||
titel: '',
|
||||
jahr: null,
|
||||
confidence: 0,
|
||||
quelle: '',
|
||||
posterPfad: '',
|
||||
backdropPfad: '',
|
||||
beschreibung: '',
|
||||
schonGerippt: null,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
(fehler) => {
|
||||
// R4: Eine gescheiterte Runde ist eine laute Zeile — der letzte
|
||||
// Stand im Fenster bleibt stehen (R2), die Wache läuft weiter.
|
||||
console.error(`[kern] Disc-Wache: Runde fehlgeschlagen: ${String(fehler)}`)
|
||||
anFenster({ art: 'kern-fehler', text: `Disc-Wache: ${String(fehler)}` })
|
||||
},
|
||||
)
|
||||
wache.starten()
|
||||
} catch (fehler) {
|
||||
laufwerkFehler = String(fehler)
|
||||
console.error(`[kern] Laufwerke: ${laufwerkFehler}`)
|
||||
}
|
||||
})()
|
||||
|
||||
// ── Rippen (W-2) ────────────────────────────────────────────────────────
|
||||
// Die Ablage kommt aus der EINEN Einstellungsquelle (§ 6.7); solange dort
|
||||
// nichts steht, gilt Videos\Rippy im Benutzerprofil.
|
||||
function ablageOrdner(): string {
|
||||
const eingestellt = db?.einstellungLesen('ablage')
|
||||
if (eingestellt !== null && eingestellt !== undefined && eingestellt.length > 0) return eingestellt
|
||||
return join(process.env['USERPROFILE'] ?? '.', 'Videos', 'Rippy')
|
||||
}
|
||||
|
||||
// Die Einstellungs-Schlüssel, die die Pipeline liest — alle aus der EINEN
|
||||
// Quelle (§ 6.7). Das UI dafür kommt mit W-5; bis dahin gelten Defaults.
|
||||
const PIPELINE_EINSTELLUNGEN = [
|
||||
'transcodeEnabled',
|
||||
'transcodePreset',
|
||||
'transcodePresetDvd',
|
||||
'transcodePresetBluray',
|
||||
'transcodePresetUhd',
|
||||
'audioSprachen',
|
||||
'untertitelSprachen',
|
||||
'mediaServer',
|
||||
'mediaServerUrl',
|
||||
'mediaServerApiKey',
|
||||
] as const
|
||||
|
||||
function einstellungenLaden(): Record<string, string> {
|
||||
const werte: Record<string, string> = {}
|
||||
for (const schluessel of PIPELINE_EINSTELLUNGEN) {
|
||||
const wert = db?.einstellungLesen(schluessel)
|
||||
if (wert !== null && wert !== undefined) werte[schluessel] = wert
|
||||
}
|
||||
return werte
|
||||
}
|
||||
|
||||
function geraetVon(id: string): string | null {
|
||||
if (laufwerkApi === null) return null
|
||||
return listOptischeLaufwerke(laufwerkApi).find((g) => kennung(g) === id) ?? null
|
||||
}
|
||||
|
||||
const ripVerwaltung = new Pipeline({
|
||||
makemkvcon: () => finden('makemkv', db?.einstellungLesen('werkzeug.makemkv') ?? ''),
|
||||
handbrake: () => finden('handbrake', db?.einstellungLesen('werkzeug.handbrake') ?? ''),
|
||||
flac: () => finden('flac', db?.einstellungLesen('werkzeug.flac') ?? ''),
|
||||
audioToc: (id) => {
|
||||
const geraet = geraetVon(id)
|
||||
if (laufwerkApi === null || geraet === null) throw new Error(`Laufwerk ${id}: gibt es nicht`)
|
||||
return audioToc(laufwerkApi, geraet)
|
||||
const kontext: KernKontext = {
|
||||
db,
|
||||
dbStatus,
|
||||
smoke: smokeKern,
|
||||
protokoll,
|
||||
anFenster: (nachricht) => fensterPort?.postMessage(nachricht),
|
||||
anHauptUndFenster: (nachricht) => {
|
||||
process.parentPort.postMessage(nachricht)
|
||||
fensterPort?.postMessage(nachricht)
|
||||
},
|
||||
audioSpurLesen: (id, start, sektoren, schreiben, fortschritt) => {
|
||||
const geraet = geraetVon(id)
|
||||
if (laufwerkApi === null || geraet === null) throw new Error(`Laufwerk ${id}: gibt es nicht`)
|
||||
return audioSpurLesen(laufwerkApi, geraet, start, sektoren, schreiben, fortschritt)
|
||||
hinweis: (text) => {
|
||||
protokoll.schreiben(text)
|
||||
fensterPort?.postMessage({ art: 'kern-hinweis', text })
|
||||
},
|
||||
geraet: (id) => {
|
||||
if (laufwerkApi === null) return null
|
||||
const geraet = listOptischeLaufwerke(laufwerkApi).find((g) => kennung(g) === id)
|
||||
return geraet === undefined ? null : geraeteInfo(laufwerkApi, geraet)
|
||||
fehler: (text) => {
|
||||
protokoll.schreiben(`FEHLER: ${text}`)
|
||||
console.error(`[kern] ${text}`)
|
||||
fensterPort?.postMessage({ art: 'kern-fehler', text })
|
||||
},
|
||||
ablage: ablageOrdner,
|
||||
einstellungen: einstellungenLaden,
|
||||
presetEmpfehlung: (typ) => presetEmpfehlung(typ, werkzeugAuskunft.backends, werkzeugAuskunft.presets),
|
||||
melden: (status) => anHauptUndFenster({ art: 'rip-status', status }),
|
||||
zuordnung: (id) => discZuordnungen.get(id) ?? null,
|
||||
fingerprint: (id) => discFingerprints.get(id) ?? null,
|
||||
bibliothekEintragen: (eintrag) => {
|
||||
try {
|
||||
db?.bibliothekEintragen(eintrag)
|
||||
anFenster({ art: 'bibliothek', eintraege: db?.bibliothekListe() ?? [] })
|
||||
} catch (fehler) {
|
||||
// R4: Ein Bibliotheks-Fehler kippt keinen gelungenen Rip, wird aber
|
||||
// gesagt.
|
||||
anFenster({ art: 'kern-fehler', text: `Bibliothek: ${String(fehler)}` })
|
||||
}
|
||||
|
||||
// ── Dienste verdrahten ──────────────────────────────────────────────────
|
||||
const einstellungen = new EinstellungsDienst(kontext)
|
||||
const werkzeuge = new WerkzeugDienst(kontext, einstellungen)
|
||||
const speicher = new SpeicherDienst(kontext, einstellungen)
|
||||
const benachrichtigung = new BenachrichtigungsDienst(kontext, einstellungen)
|
||||
|
||||
// Die Pipeline und die Laufwerke brauchen einander: Auswurf nur, wenn kein
|
||||
// Rip läuft; Rip nur mit Gerätestand. Deshalb erst die Funktionen, dann
|
||||
// beide Objekte.
|
||||
let ripVerwaltung: Pipeline
|
||||
const laufwerke = new LaufwerkDienst(
|
||||
kontext,
|
||||
{
|
||||
discEingelegt: (geraet) => {
|
||||
void erkennung.discErmitteln(geraet).then((zuordnung) => {
|
||||
automatik.zuordnungMerken(geraet.id, zuordnung)
|
||||
return automatik.pruefen(geraet, zuordnung, erkennung.schonGerippt(geraet.id))
|
||||
})
|
||||
},
|
||||
discEntnommen: (geraet) => {
|
||||
erkennung.discVergessen(geraet.id)
|
||||
automatik.discEntnommen(geraet.id)
|
||||
ripVerwaltung.discEntnommen(geraet.id)
|
||||
},
|
||||
},
|
||||
(id) => ripVerwaltung.laeuft(id),
|
||||
)
|
||||
|
||||
const erkennung = new ErkennungDienst(kontext, {
|
||||
ripLaeuft: (id) => ripVerwaltung.laeuft(id),
|
||||
geraetInfoVon: (id) => laufwerke.geraetInfoVon(id),
|
||||
makemkvcon: () => werkzeuge.makemkvcon(),
|
||||
})
|
||||
|
||||
const vorgaenge = new VorgangDienst(kontext, einstellungen, werkzeuge, erkennung, benachrichtigung, () => speicher.senden())
|
||||
|
||||
ripVerwaltung = new Pipeline({
|
||||
makemkvcon: () => werkzeuge.makemkvcon(),
|
||||
handbrake: () => werkzeuge.handbrake(),
|
||||
flac: () => werkzeuge.flac(),
|
||||
audioToc: (id) => laufwerke.audioToc(id),
|
||||
audioSpurLesen: (id, start, sektoren, schreiben, fortschritt) => laufwerke.audioSpurLesen(id, start, sektoren, schreiben, fortschritt),
|
||||
geraet: (id) => laufwerke.geraetInfoVon(id),
|
||||
ablage: () => einstellungen.ablageOrdner(),
|
||||
rohWurzel: () => einstellungen.rohWurzel(),
|
||||
einstellungen: () => einstellungen.fuerPipeline(),
|
||||
melden: (status) => {
|
||||
kontext.anHauptUndFenster({ art: 'rip-status', status })
|
||||
if (status.phase === 'fertig' || status.phase === 'fehler' || status.phase === 'abgebrochen') {
|
||||
protokoll.schreiben(`Rip ${status.id} ${status.phase}: ${status.text.slice(0, 300)}`)
|
||||
}
|
||||
// Nach einem gescheiterten Rip (oder Lesefehlern) gleich das Windows-
|
||||
// Protokoll fragen: Disc oder Hardware? (5.4.0, Punkt 8)
|
||||
if (status.phase === 'fehler' || (status.phase === 'fertig' && status.lesefehler)) void laufwerke.pruefen(status.id)
|
||||
if (status.phase === 'fehler') void benachrichtigung.senden('Rippy: Rip fehlgeschlagen', `Laufwerk ${status.id}: ${status.text.slice(0, 300)}`)
|
||||
if (status.phase === 'fertig') void benachrichtigung.senden('Rippy: Rip fertig', `Laufwerk ${status.id}: ${status.text.slice(0, 300)}`)
|
||||
},
|
||||
zuordnung: (id) => erkennung.zuordnung(id),
|
||||
discAngaben: (id) => erkennung.discAngaben(id),
|
||||
fingerprint: (id) => erkennung.fingerprint(id),
|
||||
zuordnungFuer: (vorschlag) => erkennung.zuordnungAus(vorschlag),
|
||||
bibliothekEintragen: (eintrag) => vorgaenge.bibliothekEintragen(eintrag),
|
||||
vorgangBeginnt: (v) => vorgaenge.vorgangBeginnt(v),
|
||||
vorgangEinreihen: (auftrag, titel) => vorgaenge.vorgangEinreihen(auftrag, titel),
|
||||
vorgangEndet: (id, phase, text) => vorgaenge.vorgangEndet(id, phase, text),
|
||||
nachRipAuswerfen: (id) => {
|
||||
// Vorgabe: auswerfen — die Warteschlange will ein freies Laufwerk.
|
||||
if (einstellungen.lesen('autoAuswurf') === 'false') return
|
||||
void laufwerke.auswerfen(id)
|
||||
},
|
||||
})
|
||||
|
||||
// ── Einstellungen (§ 6.7): das Fenster liest und schreibt NUR über den
|
||||
// Kern; erlaubt sind genau diese Schlüssel ───────────────────────────
|
||||
const ERLAUBTE_EINSTELLUNGEN = new Set([
|
||||
'ablage',
|
||||
'transcodeEnabled',
|
||||
'transcodePreset',
|
||||
'transcodePresetDvd',
|
||||
'transcodePresetBluray',
|
||||
'transcodePresetUhd',
|
||||
'audioSprachen',
|
||||
'untertitelSprachen',
|
||||
'mediaServer',
|
||||
'mediaServerUrl',
|
||||
'mediaServerApiKey',
|
||||
'tmdbApiKey',
|
||||
'omdbApiKey',
|
||||
'makemkvAppKey',
|
||||
'updateUrl',
|
||||
'setup.done',
|
||||
'autoAuswurf',
|
||||
'werkzeug.makemkv',
|
||||
'werkzeug.handbrake',
|
||||
'werkzeug.flac',
|
||||
])
|
||||
const automatik = new AutomatikDienst(kontext, {
|
||||
an: () => einstellungen.automatikAn(),
|
||||
sekunden: () => einstellungen.automatikSekunden(),
|
||||
ripLaeuft: (id) => ripVerwaltung.laeuft(id),
|
||||
titelLesen: async (id) => {
|
||||
const stand = await erkennung.titelLesen(id)
|
||||
automatik.titelMerken(id, stand)
|
||||
return stand
|
||||
},
|
||||
ripStarten: (id, auftrag, optionen) => ripMitPlatz(id, auftrag, optionen),
|
||||
discAngaben: (id) => erkennung.discAngaben(id),
|
||||
serienMerker: (zuordnung) => {
|
||||
const m = zuordnung.meta.typ === 'tv' ? erkennung.serienMerkerFuer(zuordnung.meta.id) : null
|
||||
return m === null ? null : { staffel: m.staffel, naechsteFolge: m.naechsteFolge }
|
||||
},
|
||||
benachrichtigen: (titel, text) => void benachrichtigung.senden(titel, text),
|
||||
})
|
||||
|
||||
function einstellungenSenden(): void {
|
||||
const werte: Record<string, string> = { ablage: ablageOrdner() }
|
||||
for (const schluessel of ERLAUBTE_EINSTELLUNGEN) {
|
||||
const wert = db?.einstellungLesen(schluessel)
|
||||
if (wert !== null && wert !== undefined) werte[schluessel] = wert
|
||||
/** Der Rip-Start mit Platz-Prüfung (5.5.0): Der Dialog zeigt das Urteil
|
||||
* vorher, der Kern urteilt beim Start — auch für die Automatik. Zu wenig
|
||||
* für den verlustfreien Rip: kein Start, der Grund steht in den
|
||||
* Ereignissen. Knapp: Start mit Hinweis. Nicht messbar: Start ohne Urteil
|
||||
* (die Messung ist Komfort, nicht Pflicht). */
|
||||
function ripMitPlatz(id: string, auftrag: RipAuftragTitel[], optionen: RipAuftragOptionen): void {
|
||||
const rohBytes = auftrag.reduce((s, t) => s + (t.groesseBytes ?? 0), 0)
|
||||
const arbeit = speicher.stand().arbeit
|
||||
if (arbeit.ok && rohBytes > 0) {
|
||||
const urteil = platzUrteil(arbeit.freiBytes, rohBytes, einstellungen.lesen('transcodeEnabled') !== 'false')
|
||||
if (urteil.hart) {
|
||||
kontext.fehler(`Rip auf ${id} nicht gestartet — ${urteil.text}`)
|
||||
return
|
||||
}
|
||||
if (urteil.text.length > 0) kontext.hinweis(`Platz im Arbeitsordner: ${urteil.text}`)
|
||||
}
|
||||
// AUCH ans Haupt: Es braucht die Update-Adresse (§ 6.8) — die eine
|
||||
// Einstellungs-Quelle bleibt trotzdem der Kern.
|
||||
anHauptUndFenster({ art: 'einstellungen', werte })
|
||||
// Der Rip beginnt — der Automatik-Kasten in der Kachel hat ausgedient.
|
||||
automatik.leeren(id)
|
||||
void laufwerke.bereit.then(() => ripVerwaltung.starten(id, auftrag, optionen))
|
||||
}
|
||||
|
||||
// ── MakeMKV-Schlüsselkette (§ 9.1): beim Start und täglich ──────────────
|
||||
let schluesselStand: Extract<KernNachricht, { art: 'schluessel-status' }>['stand'] | null = null
|
||||
|
||||
async function schluesselLauf(): Promise<void> {
|
||||
if (process.platform !== 'win32') return
|
||||
try {
|
||||
const { schluesselKette } = await import('./werkzeuge/schluessel')
|
||||
const makemkvcon = finden('makemkv', db?.einstellungLesen('werkzeug.makemkv') ?? '')
|
||||
schluesselStand = await schluesselKette(
|
||||
makemkvcon,
|
||||
db?.einstellungLesen('makemkvAppKey') ?? '',
|
||||
new Date().toISOString(),
|
||||
)
|
||||
anHauptUndFenster({ art: 'schluessel-status', stand: schluesselStand })
|
||||
} catch (fehler) {
|
||||
// R4: Der Schlüssel-Lauf ist Komfort — sein Scheitern wird gesagt.
|
||||
console.error(`[kern] Schlüsselkette: ${String(fehler)}`)
|
||||
anFenster({ art: 'kern-fehler', text: `Schlüsselkette: ${String(fehler)}` })
|
||||
}
|
||||
}
|
||||
// Täglich neu prüfen (§ 9.1) — ohne den Prozess-Exit aufzuhalten.
|
||||
setInterval(() => void schluesselLauf(), 24 * 60 * 60 * 1000).unref()
|
||||
|
||||
// ── MakeMKV-Update (§ 9): holen, prüfen, installieren lassen ────────────
|
||||
// Der Kern lädt und PRÜFT die Datei; STARTEN kann sie nur das Haupt
|
||||
// (shell → UAC-Abfrage; der erhöhte Installer hängt dadurch nicht an der
|
||||
// Prozess-Leine und überlebt ein geschlossenes Rippy).
|
||||
let beschaffungLaeuft = false
|
||||
let installerAntwort: ((fehler: string | null) => void) | null = null
|
||||
|
||||
function beschaffungMelden(text: string, anteil: number | null, laeuft: boolean, fehler = false): void {
|
||||
anFenster({ art: 'beschaffung-status', stand: { werkzeug: 'makemkv', laeuft, text, anteil, fehler } })
|
||||
}
|
||||
|
||||
async function makemkvUpdateLauf(): Promise<void> {
|
||||
if (beschaffungLaeuft) {
|
||||
beschaffungMelden('Ein MakeMKV-Update läuft bereits.', null, true)
|
||||
// ── Nachrichten vom Fenster ─────────────────────────────────────────────
|
||||
function fensterNachricht(frage: unknown): void {
|
||||
if (!istFensterNachricht(frage)) {
|
||||
// R4: Eine verworfene Nachricht verschwindet NICHT still. Genau das
|
||||
// kostete am 01.09.2026 eine Runde — 'titel-lesen' fehlte im Prüfer,
|
||||
// der Kern warf sie weg, und im Fenster stand ewig „Rippy liest die
|
||||
// Titel …", ohne dass irgendwo etwas stand.
|
||||
const art = typeof frage === 'object' && frage !== null ? String((frage as Record<string, unknown>).art) : typeof frage
|
||||
kontext.fehler(`Nachricht „${art}" wurde verworfen — sie ist dem Kern unbekannt.`)
|
||||
return
|
||||
}
|
||||
beschaffungLaeuft = true
|
||||
try {
|
||||
const { istNeuer, makemkvNeuesteVersion, makemkvInstallerHolen, versionsangaben } = await import(
|
||||
'./werkzeuge/beschaffen'
|
||||
)
|
||||
beschaffungMelden('Nach der neuesten MakeMKV-Fassung wird gesucht …', null, true)
|
||||
const neueste = await makemkvNeuesteVersion()
|
||||
if (neueste.length === 0) {
|
||||
beschaffungMelden(
|
||||
'Keine der Quellen konnte sagen, welche Fassung aktuell ist (Hersteller-Seite, Forum, Archiv). ' +
|
||||
'Ausweg: MakeMKV von Hand von makemkv.com laden und installieren; Rippy findet es danach von allein.',
|
||||
null,
|
||||
false,
|
||||
true,
|
||||
)
|
||||
beschaffungLaeuft = false
|
||||
switch (frage.art) {
|
||||
case 'ping':
|
||||
kontext.anFenster({ art: 'pong', zeit: frage.zeit })
|
||||
return
|
||||
}
|
||||
const installiert = schluesselStand?.version ?? ''
|
||||
if (installiert.length > 0 && !istNeuer(neueste, installiert)) {
|
||||
// Ehrlich bleiben (31.08.2026 gemessen: 1.18.4 ist installiert UND
|
||||
// die neueste angekündigte — die Beta ist nur abgelaufen): Ein
|
||||
// Download derselben Fassung würde nichts lösen.
|
||||
beschaffungMelden(
|
||||
`MakeMKV ${installiert} ist bereits die neueste angekündigte Fassung. ` +
|
||||
'Sobald der Hersteller eine neuere veröffentlicht, holt dieser Knopf sie — ' +
|
||||
'bis dahin rippt der Beta-Modus weiter.',
|
||||
null,
|
||||
false,
|
||||
)
|
||||
beschaffungLaeuft = false
|
||||
case 'auswerfen':
|
||||
void laufwerke.auswerfen(frage.id)
|
||||
return
|
||||
}
|
||||
// Merken, woran eine gelungene Installation zu erkennen ist: Die
|
||||
// Versions-Ressource der gefundenen EXE ändert sich (oder es gibt
|
||||
// erstmals eine).
|
||||
const alteExe = finden('makemkv', db?.einstellungLesen('werkzeug.makemkv') ?? '')
|
||||
const alteVersion = alteExe.length > 0 ? versionsangaben(alteExe).version : ''
|
||||
|
||||
const ergebnis = await makemkvInstallerHolen(neueste, (text, anteil) =>
|
||||
beschaffungMelden(text, anteil, true),
|
||||
)
|
||||
beschaffungMelden(`Geprüft (Quelle: ${ergebnis.quelle}) — der Installer wird gestartet …`, null, true)
|
||||
|
||||
const startFehler = await new Promise<string | null>((fertig) => {
|
||||
installerAntwort = fertig
|
||||
process.parentPort.postMessage({ art: 'installer-starten', pfad: ergebnis.pfad })
|
||||
// Falls das Haupt nie antwortet, bleibt der Lauf nicht ewig hängen.
|
||||
setTimeout(() => fertig('Keine Antwort vom Haupt-Prozess'), 30_000).unref()
|
||||
})
|
||||
installerAntwort = null
|
||||
if (startFehler !== null && startFehler.length > 0) {
|
||||
beschaffungMelden(`Der Installer wurde nicht gestartet: ${startFehler}`, null, false, true)
|
||||
beschaffungLaeuft = false
|
||||
case 'titel-lesen':
|
||||
// Der Nutzer will selbst wählen — ein laufender Countdown endet.
|
||||
automatik.stopp(frage.id, '')
|
||||
void erkennung.titelLesen(frage.id)
|
||||
return
|
||||
}
|
||||
beschaffungMelden(
|
||||
`Der MakeMKV-Installer ${ergebnis.version} läuft — bitte die Windows-Nachfrage bestätigen und durchklicken. ` +
|
||||
'Rippy merkt die fertige Installation von allein und prüft den Schlüssel neu.',
|
||||
null,
|
||||
true,
|
||||
)
|
||||
// Warten, bis die Installation durch ist: Die Versions-Ressource der
|
||||
// EXE ändert sich. Alle 15 s nachsehen, höchstens 20 Minuten.
|
||||
let runden = 0
|
||||
const zeiger = setInterval(() => {
|
||||
runden += 1
|
||||
const exe = finden('makemkv', db?.einstellungLesen('werkzeug.makemkv') ?? '')
|
||||
const jetztVersion = exe.length > 0 ? versionsangaben(exe).version : ''
|
||||
if (jetztVersion.length > 0 && jetztVersion !== alteVersion) {
|
||||
clearInterval(zeiger)
|
||||
beschaffungLaeuft = false
|
||||
beschaffungMelden(`MakeMKV ${jetztVersion} ist installiert — der Schlüssel wird neu geprüft.`, null, false)
|
||||
void schluesselLauf().then(() => werkzeugeSenden())
|
||||
} else if (runden >= 80) {
|
||||
clearInterval(zeiger)
|
||||
beschaffungLaeuft = false
|
||||
beschaffungMelden(
|
||||
'Die fertige Installation wurde nicht bemerkt — nach dem Durchklicken hilft „Schlüssel neu prüfen".',
|
||||
null,
|
||||
false,
|
||||
)
|
||||
case 'rip-start':
|
||||
automatik.stopp(frage.id, '')
|
||||
ripMitPlatz(frage.id, frage.auftrag ?? [], frage.optionen ?? {})
|
||||
return
|
||||
case 'rip-abbruch':
|
||||
ripVerwaltung.abbrechen(frage.id)
|
||||
return
|
||||
case 'rip-ueberspringen':
|
||||
ripVerwaltung.ueberspringen(frage.id)
|
||||
return
|
||||
case 'rettung-start':
|
||||
automatik.leeren(frage.id)
|
||||
void laufwerke.bereit.then(() => ripVerwaltung.rettungStarten(frage.id))
|
||||
return
|
||||
case 'metadaten-suchen':
|
||||
void erkennung.metadatenSuchen(frage.id, frage.text)
|
||||
return
|
||||
case 'zuordnung-setzen':
|
||||
automatik.stopp(frage.id, '')
|
||||
void erkennung.zuordnungSetzen(frage.id, frage.vorschlag)
|
||||
return
|
||||
case 'einstellung-setzen':
|
||||
if (einstellungen.setzen(frage.schluessel, frage.wert)) {
|
||||
// Neue Aufbewahrungs-Regel, neuer Arbeitsordner: sofort sichtbar.
|
||||
if (frage.schluessel === AUFBEWAHRUNG_SCHLUESSEL) vorgaenge.senden()
|
||||
if (frage.schluessel === 'ablage' || frage.schluessel === 'arbeitsordner') {
|
||||
vorgaenge.senden()
|
||||
speicher.senden()
|
||||
}
|
||||
}
|
||||
}, 15_000)
|
||||
zeiger.unref()
|
||||
} catch (fehler) {
|
||||
beschaffungMelden(fehler instanceof Error ? fehler.message : String(fehler), null, false, true)
|
||||
beschaffungLaeuft = false
|
||||
return
|
||||
case 'einstellungen-laden':
|
||||
einstellungen.senden()
|
||||
return
|
||||
case 'werkzeuge-laden':
|
||||
void werkzeuge.senden()
|
||||
return
|
||||
case 'makemkv-update':
|
||||
void werkzeuge.makemkvUpdateLauf()
|
||||
return
|
||||
case 'schluessel-pruefen':
|
||||
void werkzeuge.schluesselLauf().then(() => werkzeuge.senden())
|
||||
return
|
||||
case 'vorgaenge-laden':
|
||||
vorgaenge.senden()
|
||||
return
|
||||
case 'vorgang-roh-loeschen':
|
||||
vorgaenge.rohLoeschen(frage.vorgangId)
|
||||
return
|
||||
case 'vorgang-neu-komprimieren':
|
||||
vorgaenge.neuKomprimieren(frage.vorgangId, frage.preset ?? '')
|
||||
return
|
||||
case 'kompression-abbrechen':
|
||||
if (!vorgaenge.warteschlange.abbrechen(frage.vorgangId)) {
|
||||
kontext.fehler(`Vorgang ${frage.vorgangId} ist nicht in der Warteschlange.`)
|
||||
}
|
||||
return
|
||||
case 'kompression-pause':
|
||||
vorgaenge.pause(frage.an)
|
||||
return
|
||||
case 'roh-aufraeumen':
|
||||
vorgaenge.rohAufraeumenLauf()
|
||||
return
|
||||
case 'fremden-ordner-loeschen':
|
||||
vorgaenge.fremdenOrdnerLoeschen(frage.ordner)
|
||||
return
|
||||
case 'laufwerk-pruefen':
|
||||
void laufwerke.pruefen(frage.id)
|
||||
return
|
||||
case 'automatik-stopp':
|
||||
automatik.stopp(frage.id, 'Automatik gestoppt — du entscheidest.')
|
||||
return
|
||||
case 'automatik-jetzt':
|
||||
automatik.jetzt(frage.id)
|
||||
return
|
||||
case 'benachrichtigung-testen':
|
||||
void benachrichtigung.testen()
|
||||
return
|
||||
case 'poster-laden':
|
||||
void erkennung.posterLaden()
|
||||
return
|
||||
case 'speicher-laden':
|
||||
speicher.senden()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Die gemessene Auskunft bleibt liegen — die Pipeline braucht sie für die
|
||||
// Preset-Empfehlung, auch wenn gerade kein Fenster offen ist.
|
||||
let werkzeugAuskunft: { presets: string[]; backends: string[] } = { presets: [], backends: [] }
|
||||
|
||||
async function werkzeugeSenden(): Promise<void> {
|
||||
const makemkvcon = finden('makemkv', db?.einstellungLesen('werkzeug.makemkv') ?? '')
|
||||
const handbrake = finden('handbrake', db?.einstellungLesen('werkzeug.handbrake') ?? '')
|
||||
const flac = finden('flac', db?.einstellungLesen('werkzeug.flac') ?? '')
|
||||
const { encoderMessen } = await import('./komprimieren/encoder')
|
||||
const gemessen = await encoderMessen(handbrake)
|
||||
werkzeugAuskunft = { presets: gemessen.presets, backends: gemessen.backends }
|
||||
anFenster({
|
||||
art: 'werkzeuge',
|
||||
auskunft: { makemkvcon, handbrake, flac, presets: gemessen.presets, backends: gemessen.backends },
|
||||
})
|
||||
}
|
||||
|
||||
async function auswerfen(id: string): Promise<void> {
|
||||
await laufwerkeBereit
|
||||
if (ripVerwaltung.laeuft(id)) {
|
||||
anFenster({
|
||||
art: 'auswurf-ergebnis',
|
||||
id,
|
||||
grund: 'abgelehnt',
|
||||
fehler: `Auf Laufwerk ${id} läuft ein Rip — erst abbrechen oder abwarten.`,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (laufwerkApi === null) {
|
||||
anFenster({ art: 'auswurf-ergebnis', id, grund: 'kein-zugriff', fehler: laufwerkFehler })
|
||||
return
|
||||
}
|
||||
const geraete = listOptischeLaufwerke(laufwerkApi)
|
||||
const geraet = geraete.find((g) => kennung(g) === id)
|
||||
if (geraet === undefined) {
|
||||
anFenster({ art: 'auswurf-ergebnis', id, grund: 'kein-zugriff', fehler: `Laufwerk ${id}: gibt es nicht` })
|
||||
return
|
||||
}
|
||||
const { grund, fehler } = await auswerfenMitGrund(laufwerkApi, geraet)
|
||||
anFenster({ art: 'auswurf-ergebnis', id, grund, fehler })
|
||||
// Die Wache merkt den neuen Zustand spätestens in der nächsten Runde;
|
||||
// eine sofortige Runde macht das Fenster ohne 3-Sekunden-Wartezeit frisch.
|
||||
wache?.runde()
|
||||
}
|
||||
|
||||
process.parentPort.on('message', (ereignis) => {
|
||||
const nachricht: unknown = ereignis.data
|
||||
if (istHauptNachricht(nachricht) && nachricht.art === 'installer-ergebnis') {
|
||||
installerAntwort?.(nachricht.fehler ?? '')
|
||||
installerAntwort = null
|
||||
werkzeuge.installerErgebnis(nachricht.fehler)
|
||||
return
|
||||
}
|
||||
if (istHauptNachricht(nachricht) && nachricht.art === 'fenster-port') {
|
||||
@@ -557,54 +320,19 @@ process.parentPort.on('message', (ereignis) => {
|
||||
fensterPort?.close()
|
||||
fensterPort = ereignis.ports[0] ?? null
|
||||
if (fensterPort === null) return
|
||||
fensterPort.on('message', (portEreignis) => {
|
||||
const frage: unknown = portEreignis.data
|
||||
if (!istFensterNachricht(frage)) return
|
||||
if (frage.art === 'ping') {
|
||||
anFenster({ art: 'pong', zeit: frage.zeit })
|
||||
} else if (frage.art === 'auswerfen') {
|
||||
void auswerfen(frage.id)
|
||||
} else if (frage.art === 'rip-start') {
|
||||
void laufwerkeBereit.then(() => ripVerwaltung.starten(frage.id))
|
||||
} else if (frage.art === 'rip-abbruch') {
|
||||
ripVerwaltung.abbrechen(frage.id)
|
||||
} else if (frage.art === 'metadaten-suchen') {
|
||||
void metadatenSuchen(frage.id, frage.text)
|
||||
} else if (frage.art === 'zuordnung-setzen') {
|
||||
void zuordnungSetzen(frage.id, frage.vorschlag)
|
||||
} else if (frage.art === 'einstellung-setzen') {
|
||||
if (!ERLAUBTE_EINSTELLUNGEN.has(frage.schluessel)) {
|
||||
anFenster({ art: 'kern-fehler', text: `Unbekannter Einstellungs-Schlüssel: ${frage.schluessel}` })
|
||||
} else if (db === null) {
|
||||
anFenster({ art: 'kern-fehler', text: 'Einstellung nicht gespeichert — die Datenbank ist nicht da.' })
|
||||
} else {
|
||||
db.einstellungSchreiben(frage.schluessel, frage.wert)
|
||||
einstellungenSenden()
|
||||
}
|
||||
} else if (frage.art === 'einstellungen-laden') {
|
||||
einstellungenSenden()
|
||||
} else if (frage.art === 'werkzeuge-laden') {
|
||||
void werkzeugeSenden()
|
||||
} else if (frage.art === 'bibliothek-laden') {
|
||||
anFenster({ art: 'bibliothek', eintraege: db?.bibliothekListe() ?? [] })
|
||||
} else if (frage.art === 'makemkv-update') {
|
||||
void makemkvUpdateLauf()
|
||||
} else if (frage.art === 'schluessel-pruefen') {
|
||||
void schluesselLauf().then(() => werkzeugeSenden())
|
||||
}
|
||||
})
|
||||
fensterPort.on('message', (portEreignis) => fensterNachricht(portEreignis.data))
|
||||
fensterPort.start()
|
||||
// Damit das Fenster nach dem Verbinden sofort den Stand kennt:
|
||||
anFenster(bereitNachricht())
|
||||
void laufwerkeBereit.then(() => {
|
||||
if (laufwerkFehler !== null) {
|
||||
anFenster({ art: 'kern-fehler', text: `Laufwerke: ${laufwerkFehler}` })
|
||||
}
|
||||
anFenster({ art: 'laufwerke', liste: wache?.aktuellerStand() ?? [], ereignisse: [] })
|
||||
if (schluesselStand !== null) {
|
||||
anFenster({ art: 'schluessel-status', stand: schluesselStand })
|
||||
kontext.anFenster(bereitNachricht())
|
||||
void laufwerke.bereit.then(() => {
|
||||
if (laufwerke.fehler !== null) kontext.fehler(`Laufwerke: ${laufwerke.fehler}`)
|
||||
kontext.anFenster({ art: 'laufwerke', liste: laufwerke.aktuellerStand(), ereignisse: [] })
|
||||
if (werkzeuge.schluesselStand !== null) {
|
||||
kontext.anFenster({ art: 'schluessel-status', stand: werkzeuge.schluesselStand })
|
||||
}
|
||||
})
|
||||
vorgaenge.senden()
|
||||
speicher.senden()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -619,12 +347,25 @@ function bereitNachricht(): KernNachricht {
|
||||
|
||||
process.parentPort.postMessage(bereitNachricht())
|
||||
// Das Haupt braucht die Einstellungen (Update-Adresse) auch ohne Fenster.
|
||||
einstellungenSenden()
|
||||
einstellungen.senden()
|
||||
// Was nach dem letzten Beenden noch offen war (5.4.0).
|
||||
vorgaenge.wiederaufnehmen()
|
||||
// Nicht im Smoke: Der Schlüssel-Lauf spricht Forum + makemkvcon an —
|
||||
// der Gerüst-Beweis soll ohne Netz und ohne MakeMKV grün sein.
|
||||
if (!process.argv.includes('--smoke-kern') && process.env['RIPPY_OHNE_SCHLUESSEL'] !== '1') {
|
||||
void schluesselLauf()
|
||||
if (!smokeKern && process.env['RIPPY_OHNE_SCHLUESSEL'] !== '1') {
|
||||
void werkzeuge.schluesselLauf()
|
||||
void werkzeuge.spurauswahlLauf()
|
||||
// Werkzeuge einmal beim Start messen — die Preset-Empfehlung der
|
||||
// Pipeline braucht Backends und Preset-Liste auch ohne offenes Fenster.
|
||||
void werkzeugeSenden()
|
||||
void werkzeuge.senden()
|
||||
// Täglich neu prüfen (§ 9.1) — ohne den Prozess-Exit aufzuhalten.
|
||||
setInterval(() => void werkzeuge.schluesselLauf(), 24 * 60 * 60 * 1000).unref()
|
||||
}
|
||||
// Roh-Aufräumen (5.4.0): eine Minute nach dem Start, dann täglich — und
|
||||
// nach jeder gelungenen Kompression (siehe Warteschlange). Speicher (5.5.0):
|
||||
// jede Minute.
|
||||
if (!smokeKern) {
|
||||
setTimeout(() => vorgaenge.rohAufraeumenLauf(), 60_000).unref()
|
||||
setInterval(() => vorgaenge.rohAufraeumenLauf(), 24 * 60 * 60 * 1000).unref()
|
||||
speicher.taktStarten()
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// Einrückung — Namen stehen mit genau vier Leerzeichen).
|
||||
// - Hardware nach FAMILIE unterscheiden (nvenc/qsv/vce/vaapi), sonst
|
||||
// läuft AMD unter einem Intel-Namen (Commander-PC: RX 9070 XT).
|
||||
import { spawn } from 'node:child_process'
|
||||
import { werkzeugSpawn } from '../werkzeuge/aufruf'
|
||||
import { textVon } from '../rip/parser'
|
||||
|
||||
const HB_ENCODER_KOPF = /^-e,\s*--encoder\b/
|
||||
@@ -67,7 +67,7 @@ export function parsePresetListe(text: string): string[] {
|
||||
|
||||
function lauf(programm: string, args: string[], timeoutMs: number): Promise<string> {
|
||||
return new Promise((fertig) => {
|
||||
const kind = spawn(programm, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
const kind = werkzeugSpawn(programm, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
const stuecke: Buffer[] = []
|
||||
kind.stdout.on('data', (s: Buffer) => stuecke.push(s))
|
||||
kind.stderr.on('data', (s: Buffer) => stuecke.push(s))
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
// im Fehlerfall die einzige Auskunft.
|
||||
// - Abbruch bei JEDER Zeile prüfen, nicht nur bei Prozent-Änderung —
|
||||
// sonst sieht „Abbrechen" bei einem 4K-Encode minutenlang tot aus.
|
||||
import { spawn } from 'node:child_process'
|
||||
import { werkzeugSpawn } from '../werkzeuge/aufruf'
|
||||
import { existsSync } from 'node:fs'
|
||||
// win32-Pfadregeln ausdrücklich — die CI prüft diese Logik auf Linux.
|
||||
import { win32 as pfad } from 'node:path'
|
||||
@@ -30,8 +30,23 @@ export interface HandbrakeArgsOptionen {
|
||||
preset: string
|
||||
audioSprachen?: readonly string[]
|
||||
untertitelSprachen?: readonly string[]
|
||||
/** Die ERSTE gewählte Untertitelspur beim Abspielen vorwählen (5.4.0):
|
||||
* japanischer Ton, deutsche Untertitel sollen von selbst an sein. */
|
||||
untertitelStandard?: boolean
|
||||
}
|
||||
|
||||
// Sprach-Schalter am mitgelieferten HandBrake 1.11 GEMESSEN (01.09.2026,
|
||||
// Testdatei mit jpn/deu/deu/eng-Ton und deu/eng-Untertiteln, Ausgabe per
|
||||
// ffprobe geprüft — Regel D):
|
||||
// --audio-lang-list deu,eng → je Sprache die ERSTE Spur (2 Spuren)
|
||||
// --audio-lang-list deu,eng --first-audio → dasselbe (je Sprache eine)
|
||||
// --audio-lang-list deu,eng --all-audio → ALLE passenden Spuren (3)
|
||||
// --subtitle-lang-list eng,deu --all-subtitles → Ausgabe-Reihenfolge = LISTEN-
|
||||
// Reihenfolge (eng zuerst), NICHT Quell-Reihenfolge
|
||||
// --subtitle-default=1 → die erste Untertitelspur trägt default=1 — und
|
||||
// zusätzlich forced=1 (so schreibt es HandBrakes Muxer; Jellyfin zeigt
|
||||
// sie damit von selbst)
|
||||
// -N deu allein fügt KEINE Untertitelspur hinzu (nur die Vorwahl-Logik)
|
||||
export function buildHandbrakeArgs(optionen: HandbrakeArgsOptionen): string[] {
|
||||
const args = ['--input', optionen.eingabe, '--output', optionen.ausgabe, '--preset', optionen.preset]
|
||||
const format = FORMATE[extname(optionen.ausgabe).toLowerCase()]
|
||||
@@ -45,6 +60,9 @@ export function buildHandbrakeArgs(optionen: HandbrakeArgsOptionen): string[] {
|
||||
const untertitel = (optionen.untertitelSprachen ?? []).filter((s) => s.length > 0)
|
||||
if (untertitel.length > 0) args.push('--subtitle-lang-list', untertitel.join(','))
|
||||
args.push('--all-subtitles')
|
||||
// Nur mit einer Sprachliste ist klar, WELCHE Spur die erste ist — die
|
||||
// Liste bestimmt die Reihenfolge (gemessen, siehe oben).
|
||||
if (optionen.untertitelStandard === true && untertitel.length > 0) args.push('--subtitle-default=1')
|
||||
return args
|
||||
}
|
||||
|
||||
@@ -54,6 +72,20 @@ export function buildHandbrakeArgs(optionen: HandbrakeArgsOptionen): string[] {
|
||||
// Encoding: … Searching for start time, %.2f → Vorlauf, NICHT Encode
|
||||
const HB_ENCODE = /^Encoding:\s*task\s+(\d+)\s+of\s+(\d+),\s*(\d+(?:\.\d+)?)\s*%/
|
||||
|
||||
// 5.6.0 ETA — Formatstring aus dem Binary gelesen (HandBrakeCLI 1.11.2,
|
||||
// grep -a, 02.09.2026):
|
||||
// Encoding: task %d of %d, %.2f %% (%.2f fps, avg %.2f fps, ETA %02dh%02dm%02ds)
|
||||
// Die Klammer fehlt in den ersten Sekunden (noch keine fps) — dann -1.
|
||||
const HB_ETA = /ETA ([0-9]+)h([0-9]+)m([0-9]+)s/
|
||||
|
||||
/** Restzeit in Sekunden aus einer Encoding-Zeile, -1 ohne Angabe. */
|
||||
export function etaAusZeile(zeile: string): number {
|
||||
if (!HB_ENCODE.test(zeile.trim())) return -1
|
||||
const t = HB_ETA.exec(zeile)
|
||||
if (t === null) return -1
|
||||
return Number(t[1]) * 3600 + Number(t[2]) * 60 + Number(t[3])
|
||||
}
|
||||
|
||||
/** Encode-Fortschritt 0–100 oder -1 („keine Angabe" — ein echtes 0 % muss
|
||||
* von „nichts" unterscheidbar bleiben). Mehrere Durchläufe (task N of M)
|
||||
* werden zusammengerechnet, sonst springt die Anzeige zurück auf 0. */
|
||||
@@ -104,11 +136,12 @@ export function dateiDaneben(erwartet: string, existiert: (p: string) => boolean
|
||||
/** Auswertung des Laufs — pur, zeilenweise, testbar ohne HandBrake. */
|
||||
export class HbAuswertung {
|
||||
prozent = -1
|
||||
restS = -1
|
||||
falschesPreset = ''
|
||||
falscherSchalter = ''
|
||||
letzteZeilen: string[] = []
|
||||
|
||||
verarbeite(zeile: string): { prozent?: number } {
|
||||
verarbeite(zeile: string): { prozent?: number; restS?: number } {
|
||||
if (zeile.trim().length > 0) {
|
||||
this.letzteZeilen.push(zeile)
|
||||
if (this.letzteZeilen.length > HB_ZEILEN_PUFFER) this.letzteZeilen.shift()
|
||||
@@ -116,9 +149,13 @@ export class HbAuswertung {
|
||||
if (this.falschesPreset.length === 0) this.falschesPreset = unbekanntesPreset(zeile)
|
||||
if (this.falscherSchalter.length === 0) this.falscherSchalter = unbekannterSchalter(zeile)
|
||||
const p = progressAusZeile(zeile)
|
||||
if (p >= 0 && p !== this.prozent) {
|
||||
this.prozent = p
|
||||
return { prozent: p }
|
||||
if (p >= 0) {
|
||||
const rest = etaAusZeile(zeile)
|
||||
if (p !== this.prozent || (rest >= 0 && rest !== this.restS)) {
|
||||
this.prozent = p
|
||||
this.restS = rest
|
||||
return rest >= 0 ? { prozent: p, restS: rest } : { prozent: p }
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
@@ -224,9 +261,11 @@ export interface HbLaufOptionen {
|
||||
preset: string
|
||||
audioSprachen?: readonly string[]
|
||||
untertitelSprachen?: readonly string[]
|
||||
untertitelStandard?: boolean
|
||||
/** Zusätzliche Schalter ANS ENDE (z. B. --stop-at duration:5 im Messlauf). */
|
||||
extraArgs?: readonly string[]
|
||||
aufFortschritt?: (prozent: number) => void
|
||||
/** Fortschritt 0–100 und Restzeit in Sekunden (-1 = noch keine Angabe). */
|
||||
aufFortschritt?: (prozent: number, restS: number) => void
|
||||
abbruch?: AbortSignal
|
||||
}
|
||||
|
||||
@@ -234,7 +273,7 @@ export function komprimieren(optionen: HbLaufOptionen): Promise<HbErgebnis> {
|
||||
return new Promise((fertig) => {
|
||||
const args = buildHandbrakeArgs(optionen)
|
||||
args.push(...(optionen.extraArgs ?? []))
|
||||
const kind = spawn(optionen.programm, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
const kind = werkzeugSpawn(optionen.programm, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
|
||||
const auswertung = new HbAuswertung()
|
||||
const leser = new HbZeilenLeser()
|
||||
@@ -248,7 +287,7 @@ export function komprimieren(optionen: HbLaufOptionen): Promise<HbErgebnis> {
|
||||
|
||||
const zeile = (z: string): void => {
|
||||
const ergebnis = auswertung.verarbeite(z)
|
||||
if (ergebnis.prozent !== undefined) optionen.aufFortschritt?.(ergebnis.prozent)
|
||||
if (ergebnis.prozent !== undefined) optionen.aufFortschritt?.(ergebnis.prozent, ergebnis.restS ?? -1)
|
||||
}
|
||||
kind.stdout.on('data', (stueck: Buffer) => leser.hinzu(stueck).forEach(zeile))
|
||||
kind.stderr.on('data', (stueck: Buffer) => leser.hinzu(stueck).forEach(zeile))
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
// HandBrakes Scan einer Datei — welche Ton- und Untertitelspuren stecken
|
||||
// WIRKLICH in der Roh-Datei? (5.4.0)
|
||||
//
|
||||
// Warum das nötig ist: Der Auswahl-Dialog zeigt die Sprachen, die MakeMKV
|
||||
// im Info-Lauf an der DISC gemessen hat. Ob sie alle in der MKV landen,
|
||||
// entscheidet MakeMKVs Auswahlregel (siehe werkzeuge/spurauswahl.ts). Vor
|
||||
// der Kompression wird deshalb die Datei selbst gefragt — fehlt eine
|
||||
// gewünschte Sprache, steht das im Protokoll, statt dass ein leerer
|
||||
// Wunsch still durchrutscht.
|
||||
//
|
||||
// Ausgabeformat am mitgelieferten HandBrake 1.11 gemessen (01.09.2026,
|
||||
// Regel D): `HandBrakeCLI --scan --json -i <datei> -t 1` schreibt ein
|
||||
// Textprotokoll, darin EINMAL „JSON Title Set:" gefolgt von einem
|
||||
// JSON-Objekt, und danach weitere Zeilen („HandBrake has exited."). Das
|
||||
// Objekt endet also NICHT am Textende — es wird über die Klammern
|
||||
// ausgeschnitten. Darin: TitleList[0].AudioList[].LanguageCode (ISO-639-2,
|
||||
// z. B. „jpn"), .SubtitleList[].LanguageCode, .Duration.{Hours,Minutes,Seconds}.
|
||||
import { werkzeugSpawn } from '../werkzeuge/aufruf'
|
||||
import { textVon } from '../rip/parser'
|
||||
|
||||
export interface HbSpuren {
|
||||
audio: Array<{ nr: number; lang: string; beschreibung: string }>
|
||||
untertitel: Array<{ nr: number; lang: string; beschreibung: string }>
|
||||
dauerS: number
|
||||
}
|
||||
|
||||
export function buildScanArgs(datei: string): string[] {
|
||||
return ['--scan', '--json', '--input', datei, '--title', '1']
|
||||
}
|
||||
|
||||
/** Das JSON-Objekt hinter „JSON Title Set:" ausschneiden (pur, testbar). */
|
||||
export function spurenAusScan(ausgabe: string): HbSpuren | null {
|
||||
const marke = ausgabe.indexOf('JSON Title Set:')
|
||||
if (marke < 0) return null
|
||||
const start = ausgabe.indexOf('{', marke)
|
||||
if (start < 0) return null
|
||||
let tiefe = 0
|
||||
let ende = -1
|
||||
for (let i = start; i < ausgabe.length; i++) {
|
||||
const zeichen = ausgabe[i]
|
||||
if (zeichen === '{') tiefe += 1
|
||||
else if (zeichen === '}') {
|
||||
tiefe -= 1
|
||||
if (tiefe === 0) {
|
||||
ende = i
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (ende < 0) return null
|
||||
let daten: unknown
|
||||
try {
|
||||
daten = JSON.parse(ausgabe.slice(start, ende + 1))
|
||||
} catch {
|
||||
// Kein gültiges JSON heißt „nicht auswertbar" — das ist ein null, kein
|
||||
// erfundener leerer Befund (R2).
|
||||
return null
|
||||
}
|
||||
if (typeof daten !== 'object' || daten === null) return null
|
||||
const liste = (daten as { TitleList?: unknown }).TitleList
|
||||
if (!Array.isArray(liste) || liste.length === 0) return null
|
||||
const titel = liste[0] as Record<string, unknown>
|
||||
const spuren = (feld: unknown): Array<{ nr: number; lang: string; beschreibung: string }> => {
|
||||
if (!Array.isArray(feld)) return []
|
||||
return feld.map((s, i) => {
|
||||
const eintrag = (typeof s === 'object' && s !== null ? s : {}) as Record<string, unknown>
|
||||
return {
|
||||
nr: i + 1,
|
||||
lang: String(eintrag.LanguageCode ?? '').toLowerCase(),
|
||||
beschreibung: String(eintrag.Description ?? eintrag.Language ?? ''),
|
||||
}
|
||||
})
|
||||
}
|
||||
const dauer = (titel.Duration ?? {}) as Record<string, unknown>
|
||||
const zahl = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
|
||||
return {
|
||||
audio: spuren(titel.AudioList),
|
||||
untertitel: spuren(titel.SubtitleList),
|
||||
dauerS: zahl(dauer.Hours) * 3600 + zahl(dauer.Minutes) * 60 + zahl(dauer.Seconds),
|
||||
}
|
||||
}
|
||||
|
||||
/** Fragt die Datei — null heißt „konnte nicht nachsehen", nie „keine Spuren". */
|
||||
export function spurenScannen(programm: string, datei: string, timeoutMs = 120_000): Promise<HbSpuren | null> {
|
||||
return new Promise((fertig) => {
|
||||
if (programm.length === 0) {
|
||||
fertig(null)
|
||||
return
|
||||
}
|
||||
const kind = werkzeugSpawn(programm, buildScanArgs(datei), { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
const stuecke: Buffer[] = []
|
||||
kind.stdout.on('data', (s: Buffer) => stuecke.push(s))
|
||||
kind.stderr.on('data', (s: Buffer) => stuecke.push(s))
|
||||
const zeiger = setTimeout(() => kind.kill(), timeoutMs)
|
||||
kind.on('error', () => {
|
||||
clearTimeout(zeiger)
|
||||
fertig(null)
|
||||
})
|
||||
kind.on('close', () => {
|
||||
clearTimeout(zeiger)
|
||||
fertig(spurenAusScan(textVon(Buffer.concat(stuecke))))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** Welche gewünschten Sprachen FEHLEN in der Datei? (pur) */
|
||||
export function fehlendeSprachen(
|
||||
spuren: HbSpuren,
|
||||
gewuenscht: { audio: readonly string[]; untertitel: readonly string[] },
|
||||
): { audio: string[]; untertitel: string[] } {
|
||||
const da = (liste: Array<{ lang: string }>): Set<string> => new Set(liste.map((s) => s.lang))
|
||||
const audioDa = da(spuren.audio)
|
||||
const untertitelDa = da(spuren.untertitel)
|
||||
return {
|
||||
audio: gewuenscht.audio.filter((l) => !audioDa.has(l)),
|
||||
untertitel: gewuenscht.untertitel.filter((l) => !untertitelDa.has(l)),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Der gemeinsame Kontext der Kern-Dienste (5.5.0, Separation of Concerns):
|
||||
// Jeder Dienst bekommt genau das hier — die Datenbank, die Wege nach
|
||||
// draußen und das Protokoll — statt auf Modul-Globale zu greifen. So ist
|
||||
// jeder Dienst für sich lesbar und mit einem Fake-Kontext testbar.
|
||||
import type { DbStatus, KernNachricht } from '../gemeinsam/nachrichten'
|
||||
import type { Protokoll } from '../gemeinsam/protokoll'
|
||||
import type { Datenbank } from './speicher/db'
|
||||
|
||||
export interface KernKontext {
|
||||
/** null, wenn die Datenbank nicht zu öffnen war — der Kern läuft trotzdem (R4). */
|
||||
readonly db: Datenbank | null
|
||||
readonly dbStatus: DbStatus
|
||||
/** Nur ans Fenster (wenn eins verbunden ist). */
|
||||
anFenster(nachricht: KernNachricht): void
|
||||
/** Ans Haupt UND ans Fenster — für alles, was Tray/Taskleiste/Updater brauchen. */
|
||||
anHauptUndFenster(nachricht: KernNachricht): void
|
||||
/** Eine Zeile ohne Warnzeichen fürs Ereignis-Protokoll — und in die Datei. */
|
||||
hinweis(text: string): void
|
||||
/** Eine Zeile MIT Warnzeichen (kern-fehler) — und in die Datei. */
|
||||
fehler(text: string): void
|
||||
readonly protokoll: Protokoll
|
||||
/** Der Gerüst-Beweis (--smoke-kern): ohne Netz, ohne MakeMKV, ohne Takte. */
|
||||
readonly smoke: boolean
|
||||
}
|
||||
|
||||
/** --datenbank=<pfad> kommt vom Haupt (der Kern kennt Electrons app.getPath
|
||||
* nicht — er ist ein reiner Node-Prozess). */
|
||||
export function datenbankPfadAusArgv(argv: readonly string[]): string | null {
|
||||
for (const arg of argv) {
|
||||
if (arg.startsWith('--datenbank=')) {
|
||||
const pfad = arg.slice('--datenbank='.length)
|
||||
if (pfad.length > 0) return pfad
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Einstellung lesen mit Vorgabe — die eine Quelle (§ 6.7). */
|
||||
export function einstellung(kontext: Pick<KernKontext, 'db'>, schluessel: string, vorgabe = ''): string {
|
||||
const wert = kontext.db?.einstellungLesen(schluessel)
|
||||
return wert === null || wert === undefined ? vorgabe : wert
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
// Laufwerks-Gesundheit aus dem Windows-Ereignisprotokoll (5.4.0).
|
||||
//
|
||||
// Der Anlass (01.09.2026): Beim Spartacus-Defekt und beim 5042-Zustand
|
||||
// des BU40N war die entscheidende Frage „Disc oder Hardware?" — und die
|
||||
// Antwort stand im System-Protokoll, von Hand gelesen. Jetzt liest Rippy
|
||||
// sie selbst.
|
||||
//
|
||||
// GEMESSEN (01.09.2026, Regel D, an diesem PC):
|
||||
// wevtutil qe System "/q:<XPath>" /f:xml /c:200 /rd:true
|
||||
// Provider 'cdrom', EventID 7 → „Fehlerhafter Block bei Gerät \Device\CdRom0."
|
||||
// EventID 11 → „Der Treiber hat einen Controllerfehler auf \Device\CdRom0 gefunden."
|
||||
// EventID 51 → Auslagerungs-/E/A-Fehler (bei 7 dabei)
|
||||
// In 30 Tagen: 338 × 7, 8 × 11, 46 × 51 — die 7er häuften sich exakt um
|
||||
// den Spartacus-Rip (20:32), die 11er beim USB-Aussetzer (20:17).
|
||||
// TimeCreated SystemTime ist UTC. Die XML-Ereignisse stehen OHNE
|
||||
// Zeilenumbruch hintereinander; ein Regex je <Event>…</Event> reicht.
|
||||
// Die Argumente gehen ohne Shell an wevtutil — ein „/q:"-Argument mit
|
||||
// Leerzeichen bleibt so EIN Argument (über Git Bash zerlegt es der
|
||||
// Pfad-Konverter, über spawn nicht).
|
||||
import { spawn } from 'node:child_process'
|
||||
import type { LaufwerkBericht } from '../../gemeinsam/nachrichten'
|
||||
import { textVon } from '../rip/parser'
|
||||
|
||||
export interface LaufwerkEreignis {
|
||||
/** ISO-Zeit (UTC). */
|
||||
zeit: string
|
||||
provider: string
|
||||
id: number
|
||||
geraet: string
|
||||
}
|
||||
|
||||
/** Die Ereignis-IDs des cdrom-/disk-Treibers, wie am PC gemessen. */
|
||||
export const EREIGNIS_TEXTE: Record<number, string> = {
|
||||
7: 'fehlerhafter Block',
|
||||
11: 'Controllerfehler',
|
||||
15: 'Gerät nicht bereit',
|
||||
51: 'E/A-Fehler beim Lesen',
|
||||
153: 'E/A-Vorgang wiederholt',
|
||||
}
|
||||
|
||||
export function xpathFuer(stunden: number): string {
|
||||
const ms = Math.max(1, Math.round(stunden)) * 60 * 60 * 1000
|
||||
return `*[System[Provider[@Name='cdrom' or @Name='disk'] and TimeCreated[timediff(@SystemTime) <= ${ms}]]]`
|
||||
}
|
||||
|
||||
export function buildWevtutilArgs(stunden: number, hoechstens = 200): string[] {
|
||||
return ['qe', 'System', `/q:${xpathFuer(stunden)}`, '/f:xml', `/c:${hoechstens}`, '/rd:true']
|
||||
}
|
||||
|
||||
/** Die XML-Ausgabe von wevtutil → Ereignisse (pur, testbar). */
|
||||
export function ereignisseAusXml(xml: string): LaufwerkEreignis[] {
|
||||
const liste: LaufwerkEreignis[] = []
|
||||
for (const treffer of xml.matchAll(/<Event\b[\s\S]*?<\/Event>/g)) {
|
||||
const block = treffer[0]
|
||||
const provider = /<Provider Name='([^']*)'/.exec(block)?.[1] ?? ''
|
||||
const id = Number(/<EventID[^>]*>(\d+)<\/EventID>/.exec(block)?.[1] ?? '')
|
||||
const zeit = /<TimeCreated SystemTime='([^']*)'/.exec(block)?.[1] ?? ''
|
||||
const geraet = /<Data>([^<]*)<\/Data>/.exec(block)?.[1] ?? ''
|
||||
if (!Number.isInteger(id) || provider.length === 0) continue
|
||||
liste.push({ zeit, provider, id, geraet })
|
||||
}
|
||||
return liste
|
||||
}
|
||||
|
||||
/** Urteil aus den Ereignissen (pur). `seit` grenzt auf einen Zeitraum ein
|
||||
* (z. B. den laufenden Rip); ohne `seit` zählt alles, was da ist. */
|
||||
export function beurteilen(ereignisse: readonly LaufwerkEreignis[], stunden: number, seit?: Date): LaufwerkBericht {
|
||||
const relevant = seit === undefined ? [...ereignisse] : ereignisse.filter((e) => new Date(e.zeit).getTime() >= seit.getTime())
|
||||
let fehlerhafteBloecke = 0
|
||||
let controllerfehler = 0
|
||||
let sonstige = 0
|
||||
for (const e of relevant) {
|
||||
if (e.id === 7) fehlerhafteBloecke += 1
|
||||
else if (e.id === 11 || e.id === 15) controllerfehler += 1
|
||||
else sonstige += 1
|
||||
}
|
||||
const letzte = relevant.slice(0, 8).map((e) => {
|
||||
const lokal = e.zeit.length > 0 ? new Date(e.zeit) : null
|
||||
const zeit =
|
||||
lokal !== null && !Number.isNaN(lokal.getTime())
|
||||
? `${lokal.getFullYear()}-${String(lokal.getMonth() + 1).padStart(2, '0')}-${String(lokal.getDate()).padStart(2, '0')} ${String(lokal.getHours()).padStart(2, '0')}:${String(lokal.getMinutes()).padStart(2, '0')}`
|
||||
: '?'
|
||||
return `${zeit} · ${e.provider} ${e.id} · ${EREIGNIS_TEXTE[e.id] ?? 'Ereignis'} (${e.geraet})`
|
||||
})
|
||||
const zeitraum = seit === undefined ? `in den letzten ${stunden} Stunden` : `seit ${seit.toISOString().slice(11, 16)} UTC`
|
||||
|
||||
if (relevant.length === 0) {
|
||||
return {
|
||||
urteil: 'ruhig',
|
||||
text: `Das Windows-Protokoll meldet ${zeitraum} nichts zum Laufwerk — weder Lesefehler noch Hardware-Fehler.`,
|
||||
fehlerhafteBloecke,
|
||||
controllerfehler,
|
||||
sonstige,
|
||||
letzte,
|
||||
stunden,
|
||||
}
|
||||
}
|
||||
if (controllerfehler > 0) {
|
||||
return {
|
||||
urteil: 'hardware',
|
||||
text:
|
||||
`${controllerfehler} Controllerfehler ${zeitraum}` +
|
||||
(fehlerhafteBloecke > 0 ? ` und ${fehlerhafteBloecke} fehlerhafte Blöcke` : '') +
|
||||
'. Ein Controllerfehler kommt vom Laufwerk oder seiner Verbindung, nicht von der Disc: ' +
|
||||
'Bei einem USB-Laufwerk zuerst einen Port direkt am Mainboard probieren, Kabel und Netzteil prüfen.',
|
||||
fehlerhafteBloecke,
|
||||
controllerfehler,
|
||||
sonstige,
|
||||
letzte,
|
||||
stunden,
|
||||
}
|
||||
}
|
||||
if (fehlerhafteBloecke > 0) {
|
||||
return {
|
||||
urteil: 'disc',
|
||||
text:
|
||||
`${fehlerhafteBloecke} fehlerhafte Blöcke ${zeitraum}, keine Controllerfehler. ` +
|
||||
'Das spricht für die DISC (Kratzer, Fertigungsfehler), nicht für das Laufwerk: ' +
|
||||
'Disc reinigen, den betroffenen Titel überspringen oder eine andere Ausgabe versuchen.',
|
||||
fehlerhafteBloecke,
|
||||
controllerfehler,
|
||||
sonstige,
|
||||
letzte,
|
||||
stunden,
|
||||
}
|
||||
}
|
||||
return {
|
||||
urteil: 'unklar',
|
||||
text: `${sonstige} Laufwerks-Ereignisse ${zeitraum}, aber weder fehlerhafte Blöcke noch Controllerfehler — kein klares Bild.`,
|
||||
fehlerhafteBloecke,
|
||||
controllerfehler,
|
||||
sonstige,
|
||||
letzte,
|
||||
stunden,
|
||||
}
|
||||
}
|
||||
|
||||
/** Liest das Protokoll — null heißt „konnte nicht lesen", mit Grund. */
|
||||
export function ereignisseLesen(
|
||||
stunden: number,
|
||||
timeoutMs = 30_000,
|
||||
): Promise<{ ereignisse: LaufwerkEreignis[] } | { fehler: string }> {
|
||||
return new Promise((fertig) => {
|
||||
let kind: ReturnType<typeof spawn>
|
||||
try {
|
||||
kind = spawn('wevtutil', buildWevtutilArgs(stunden), { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
} catch (fehler) {
|
||||
fertig({ fehler: `wevtutil nicht startbar: ${String(fehler)}` })
|
||||
return
|
||||
}
|
||||
const stuecke: Buffer[] = []
|
||||
const fehlerStuecke: Buffer[] = []
|
||||
kind.stdout?.on('data', (s: Buffer) => stuecke.push(s))
|
||||
kind.stderr?.on('data', (s: Buffer) => fehlerStuecke.push(s))
|
||||
const zeiger = setTimeout(() => kind.kill(), timeoutMs)
|
||||
kind.on('error', (fehler) => {
|
||||
clearTimeout(zeiger)
|
||||
fertig({ fehler: `wevtutil nicht startbar: ${String(fehler)}` })
|
||||
})
|
||||
kind.on('close', (code) => {
|
||||
clearTimeout(zeiger)
|
||||
const xml = textVon(Buffer.concat(stuecke))
|
||||
// Ohne Treffer endet wevtutil mit 0 und leerer Ausgabe; ein Fehler
|
||||
// steht auf stderr mit Code ≠ 0.
|
||||
if (code !== 0 && xml.trim().length === 0) {
|
||||
fertig({ fehler: `wevtutil endete mit Code ${code}: ${textVon(Buffer.concat(fehlerStuecke)).trim().slice(0, 200)}` })
|
||||
return
|
||||
}
|
||||
fertig({ ereignisse: ereignisseAusXml(xml) })
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -105,29 +105,149 @@ export function udfVolumeLabel(geraetepfad: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** Der Studio-Titel aus BDMV/META/DL/bdmt_*.xml — bdmt_eng bevorzugt,
|
||||
* Regex statt XML-Parser (Namespaces variieren je Authoring-Werkzeug). */
|
||||
export function titelAusBdmt(wurzel: string): string {
|
||||
/** Was in BDMV/META/DL/bdmt_*.xml steht. Bis zum 01.09.2026 nahm Rippy von
|
||||
* dieser Datei NUR den Titel — das Inhaltsverzeichnis daneben ist der
|
||||
* stärkere Hinweis und lag ungelesen da. An SPARTACUS_GOTA_D2 gemessen:
|
||||
*
|
||||
* <di:name>Spartacus: Gods Of The Arena - Disc 2</di:name>
|
||||
* <di:titleName titleNumber="1">EP 1</di:titleName>
|
||||
* <di:titleName titleNumber="2">EP 2</di:titleName>
|
||||
* <di:titleName titleNumber="51">DUB GER 1</di:titleName> … usw.
|
||||
*
|
||||
* Die Disc sagt damit selbst, dass sie eine SERIE mit zwei Folgen trägt.
|
||||
* Das ist ein Beweis, keine Vermutung — und schlägt jede Titel-Raterei. */
|
||||
export interface BdmtInhalt {
|
||||
/** <di:name> — der Studio-Titel im Klartext; '' wenn keiner dasteht. */
|
||||
titel: string
|
||||
/** <di:titleName> — was die Disc trägt: Folgen, Tonspuren, Trailer. */
|
||||
eintraege: string[]
|
||||
/** Weitere Titel derselben Disc aus den anderen bdmt-Sprachdateien
|
||||
* (bdmt_deu neben bdmt_eng, 5.4.0) — ohne den Haupttitel, ohne Doppel. */
|
||||
alternativen: string[]
|
||||
}
|
||||
|
||||
/** Den Studio-Titel und das Inhaltsverzeichnis aus EINER bdmt-Datei ziehen
|
||||
* (pur, testbar). Regex statt XML-Parser (Namespaces variieren je
|
||||
* Authoring-Werkzeug). */
|
||||
export function bdmtZerlegen(inhalt: string): { titel: string; eintraege: string[] } {
|
||||
const name = /<di:name>([^<]{2,120})<\/di:name>/.exec(inhalt)
|
||||
const eintraege: string[] = []
|
||||
for (const t of inhalt.matchAll(/<di:titleName[^>]*>([^<]{1,120})<\/di:titleName>/g)) {
|
||||
const text = t[1].trim()
|
||||
if (text.length > 0) eintraege.push(text)
|
||||
}
|
||||
return { titel: name === null ? '' : name[1].trim(), eintraege }
|
||||
}
|
||||
|
||||
/** Liest die bdmt-Dateien — bdmt_eng zuerst, die anderen Sprachen liefern
|
||||
* Titel-Varianten (5.4.0). */
|
||||
export function inhaltAusBdmt(wurzel: string): BdmtInhalt {
|
||||
// Nicht lesbar heißt „nichts erfahren" — dasselbe Ergebnis wie „keine
|
||||
// Datei da". Eine Erkennung scheitert daran nie, sie wird nur ärmer.
|
||||
const leer: BdmtInhalt = { titel: '', eintraege: [], alternativen: [] }
|
||||
const metaOrdner = join(wurzel, 'BDMV', 'META', 'DL')
|
||||
if (!existsSync(metaOrdner)) return ''
|
||||
if (!existsSync(metaOrdner)) return leer
|
||||
let kandidaten: string[]
|
||||
try {
|
||||
kandidaten = readdirSync(metaOrdner).sort()
|
||||
} catch {
|
||||
return ''
|
||||
return leer
|
||||
}
|
||||
const bdmt =
|
||||
kandidaten.find((k) => k === 'bdmt_eng.xml') ??
|
||||
kandidaten.find((k) => k.startsWith('bdmt_') && k.endsWith('.xml'))
|
||||
if (bdmt === undefined) return ''
|
||||
let inhalt: string
|
||||
try {
|
||||
inhalt = readFileSync(join(metaOrdner, bdmt)).subarray(0, 65536).toString('utf8')
|
||||
} catch {
|
||||
return ''
|
||||
const bdmts = kandidaten.filter((k) => k.startsWith('bdmt_') && k.endsWith('.xml'))
|
||||
if (bdmts.length === 0) return leer
|
||||
// Englisch zuerst (das ist der Titel, den die Suche bisher kannte), dann
|
||||
// Deutsch, dann der Rest in Dateireihenfolge.
|
||||
const rang = (k: string): number => (k === 'bdmt_eng.xml' ? 0 : k === 'bdmt_deu.xml' ? 1 : 2)
|
||||
bdmts.sort((a, b) => rang(a) - rang(b) || a.localeCompare(b))
|
||||
let titel = ''
|
||||
let eintraege: string[] = []
|
||||
const alternativen: string[] = []
|
||||
for (const bdmt of bdmts) {
|
||||
let inhalt: string
|
||||
try {
|
||||
inhalt = readFileSync(join(metaOrdner, bdmt)).subarray(0, 65536).toString('utf8')
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
const zerlegt = bdmtZerlegen(inhalt)
|
||||
if (titel.length === 0 && zerlegt.titel.length > 0) {
|
||||
titel = zerlegt.titel
|
||||
eintraege = zerlegt.eintraege
|
||||
continue
|
||||
}
|
||||
if (eintraege.length === 0 && zerlegt.eintraege.length > 0) eintraege = zerlegt.eintraege
|
||||
if (
|
||||
zerlegt.titel.length > 0 &&
|
||||
zerlegt.titel.toLowerCase() !== titel.toLowerCase() &&
|
||||
!alternativen.some((a) => a.toLowerCase() === zerlegt.titel.toLowerCase())
|
||||
) {
|
||||
alternativen.push(zerlegt.titel)
|
||||
}
|
||||
}
|
||||
const treffer = /<di:name>([^<]{2,120})<\/di:name>/.exec(inhalt)
|
||||
return treffer === null ? '' : treffer[1].trim()
|
||||
return { titel, eintraege, alternativen }
|
||||
}
|
||||
|
||||
/** Der Studio-Titel aus BDMV/META/DL/bdmt_*.xml — '' wenn keiner dasteht. */
|
||||
export function titelAusBdmt(wurzel: string): string {
|
||||
return inhaltAusBdmt(wurzel).titel
|
||||
}
|
||||
|
||||
/** Sieht dieser Inhaltseintrag aus wie eine Folge? Bewusst ENG: An der
|
||||
* echten Disc stehen neben `EP 1`/`EP 2` auch `DUB GER 1`, `MM ITA` und
|
||||
* `101 LOGO` — Tonspuren, Trailer und Warnhinweise sind keine Folgen. */
|
||||
export function istFolgenName(name: string): boolean {
|
||||
return /^(?:ep|episode|folge)\s*0*\d{1,3}$/i.test(name.trim())
|
||||
}
|
||||
|
||||
/** Wie viele Einträge des Inhaltsverzeichnisses sind Folgen? */
|
||||
export function folgenZahl(eintraege: readonly string[]): number {
|
||||
return eintraege.filter(istFolgenName).length
|
||||
}
|
||||
|
||||
// Verpackungs-Zusätze am ENDE eines Disc-Titels. Was hier NICHT steht,
|
||||
// steht mit Absicht nicht hier:
|
||||
// * `Teil`/`Part` — „Kill Bill Teil 2" ist ein Filmtitel, keine Disc-Nummer.
|
||||
// * eine nackte Zahl — sonst würde aus „Rocky 2" die „Rocky", Disc 2.
|
||||
// Die Ein-Buchstaben-Formen `d`/`s` verlangen einen TRENNER davor: ohne ihn
|
||||
// würde „Ocean's 11" an der Apostroph-Wortgrenze zu „Ocean'", Staffel 11.
|
||||
const TRENNER = String.raw`[\s\-–—_:,.]`
|
||||
const DISC_MUSTER = new RegExp(`${TRENNER}+(?:disc|disk|dvd|scheibe|d)\\s*0*(\\d{1,2})\\s*$`, 'i')
|
||||
const STAFFEL_MUSTER = new RegExp(`${TRENNER}+(?:season|staffel|series|s)\\s*0*(\\d{1,2})\\s*$`, 'i')
|
||||
|
||||
export interface DiscZusatz {
|
||||
/** Der Titel ohne die erkannten Verpackungs-Zusätze. */
|
||||
titel: string
|
||||
/** Nummer der Disc innerhalb der Ausgabe — null, wenn keine dasteht. */
|
||||
discNr: number | null
|
||||
/** Staffel-Nummer — null, wenn keine dasteht. */
|
||||
staffel: number | null
|
||||
}
|
||||
|
||||
/** `Spartacus: Gods Of The Arena - Disc 2` → Titel + Disc 2 (pur).
|
||||
* Abtrennen UND merken: Der Suchtitel wird brauchbar, und die Nummer
|
||||
* braucht die Ablage später sowieso, um Folgen richtig einzusortieren. */
|
||||
export function discZusatzAbtrennen(titel: string): DiscZusatz {
|
||||
let rest = titel.trim()
|
||||
let discNr: number | null = null
|
||||
let staffel: number | null = null
|
||||
// Mehrfach, weil ein Label wie `SHOW_S1_D2` beide Zusätze hintereinander
|
||||
// trägt; die Schleife endet, sobald hinten nichts Bekanntes mehr steht.
|
||||
for (let runde = 0; runde < 4; runde++) {
|
||||
const disc = DISC_MUSTER.exec(rest)
|
||||
if (disc !== null && discNr === null && Number(disc[1]) > 0) {
|
||||
discNr = Number(disc[1])
|
||||
rest = rest.slice(0, disc.index)
|
||||
continue
|
||||
}
|
||||
const staffelTreffer = STAFFEL_MUSTER.exec(rest)
|
||||
if (staffelTreffer !== null && staffel === null && Number(staffelTreffer[1]) > 0) {
|
||||
staffel = Number(staffelTreffer[1])
|
||||
rest = rest.slice(0, staffelTreffer.index)
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
return { titel: rest.replace(new RegExp(`${TRENNER}+$`), '').trim(), discNr, staffel }
|
||||
}
|
||||
|
||||
/** \\.\G: → G:\ — unter Windows hängt Windows die Disc selbst ein. */
|
||||
@@ -138,22 +258,53 @@ export function discWurzel(geraetepfad: string): string {
|
||||
}
|
||||
|
||||
export interface DiscTitel {
|
||||
/** Der Titel, wie die Disc ihn nennt — MIT Zusätzen, ungekürzt. */
|
||||
titel: string
|
||||
/** 'bdmt' (Studio-Klartext) | 'label' (normalisiert) | '' (nichts). */
|
||||
quelle: 'bdmt' | 'label' | ''
|
||||
/** Nummer der Disc in der Ausgabe (aus Titel/Label) — null wenn keine. */
|
||||
discNr: number | null
|
||||
/** Staffel-Nummer aus Titel/Label — null wenn keine. */
|
||||
staffel: number | null
|
||||
/** Folgen im bdmt-Inhaltsverzeichnis (0, wenn es keines gibt). */
|
||||
folgen: number
|
||||
/** Die Disc bezeugt selbst, dass sie Folgen trägt — ab zwei ist es
|
||||
* keine Zufalls-Benennung mehr, sondern eine Serien-Disc. */
|
||||
istSerie: boolean
|
||||
/** Weitere Titel-Varianten (bdmt_deu, 5.4.0) — die Suche fragt jede. */
|
||||
alternativen: string[]
|
||||
}
|
||||
|
||||
/** Der beste verfügbare Disc-Titel: BDMT zuerst, sonst Label. Der
|
||||
* BDMT-Titel wird NICHT normalisiert („2.22" würde zu „2 22"). */
|
||||
/** Mindestens so viele Folgen im Inhaltsverzeichnis, damit „Serie" als
|
||||
* bewiesen gilt — eine einzelne könnte auch eine Zugabe sein. */
|
||||
export const FOLGEN_FUER_SERIE = 2
|
||||
|
||||
function mitZusatz(titel: string, quelle: 'bdmt' | 'label', folgen: number, alternativen: string[] = []): DiscTitel {
|
||||
const zusatz = discZusatzAbtrennen(titel)
|
||||
return {
|
||||
titel,
|
||||
quelle,
|
||||
discNr: zusatz.discNr,
|
||||
staffel: zusatz.staffel,
|
||||
folgen,
|
||||
istSerie: folgen >= FOLGEN_FUER_SERIE,
|
||||
alternativen,
|
||||
}
|
||||
}
|
||||
|
||||
/** Alles, was die Disc über sich selbst sagt: BDMT zuerst, sonst Label.
|
||||
* Der BDMT-Titel wird NICHT normalisiert ('2.22' würde zu '2 22'). */
|
||||
export function discTitel(geraetepfad: string): DiscTitel {
|
||||
const wurzel = discWurzel(geraetepfad)
|
||||
let folgen = 0
|
||||
if (wurzel.length > 0) {
|
||||
const bdmt = titelAusBdmt(wurzel)
|
||||
if (bdmt.length > 0) return { titel: bdmt, quelle: 'bdmt' }
|
||||
const bdmt = inhaltAusBdmt(wurzel)
|
||||
folgen = folgenZahl(bdmt.eintraege)
|
||||
if (bdmt.titel.length > 0) return mitZusatz(bdmt.titel, 'bdmt', folgen, bdmt.alternativen)
|
||||
}
|
||||
const label = isoVolumeLabel(geraetepfad) || udfVolumeLabel(geraetepfad)
|
||||
if (label.length > 0) return { titel: normalizeDiscLabel(label), quelle: 'label' }
|
||||
return { titel: '', quelle: '' }
|
||||
if (label.length > 0) return mitZusatz(normalizeDiscLabel(label), 'label', folgen)
|
||||
return { titel: '', quelle: '', discNr: null, staffel: null, folgen, istSerie: folgen >= FOLGEN_FUER_SERIE, alternativen: [] }
|
||||
}
|
||||
|
||||
/** Billiger, stabiler Disc-Fingerabdruck (Label|Größe) — Basis der
|
||||
@@ -163,25 +314,42 @@ export function discFingerprint(geraetepfad: string, groesseBytes: number): stri
|
||||
return `${label}|${groesseBytes}`
|
||||
}
|
||||
|
||||
/** Höchstens so viele Suchvarianten — jede kostet API-Anfragen. */
|
||||
export const HOECHSTENS_KANDIDATEN = 7
|
||||
|
||||
/** Suchvarianten für die Metadaten-APIs (progressive Degradation,
|
||||
* ARM-Lehre): voller Titel zuerst, dann ohne Doppelpunkt-Teil, ohne
|
||||
* Klammern, dann hintere Worte strippen. */
|
||||
* ARM-Lehre) — von SPEZIFISCH nach vage:
|
||||
*
|
||||
* voller Titel → ohne Disc-/Staffel-Zusatz → ohne Doppelpunkt →
|
||||
* ohne Klammern → hintere Worte strippen → Doppelpunkt-Vorspann
|
||||
*
|
||||
* Die Reihenfolge ist teuer gelernt (01.09.2026, echte Disc): Der
|
||||
* Doppelpunkt-Vorspann stand auf Platz 2 und wirft am meisten weg
|
||||
* ('Spartacus: Gods Of The Arena' → 'Spartacus'). TMDb hat einen Film,
|
||||
* der wörtlich so heißt — die richtige Serie wurde nie gefragt. Er
|
||||
* steht jetzt GANZ hinten; die Sicherheit deckelt ihn zusätzlich
|
||||
* (MINDEST_ABDECKUNG in zuordnung.ts). */
|
||||
export function titelKandidaten(titel: string): string[] {
|
||||
const kandidaten: string[] = []
|
||||
const nachlaufend = new RegExp(`${TRENNER}+$`)
|
||||
const merke = (t: string): void => {
|
||||
const sauber = t.split(/\s+/).filter(Boolean).join(' ').replace(/[ .]+$/, '').replace(/^[ .]+/, '')
|
||||
const sauber = t.split(/\s+/).filter(Boolean).join(' ').replace(nachlaufend, '').replace(/^[ .]+/, '')
|
||||
if (sauber.length > 0 && !kandidaten.includes(sauber)) kandidaten.push(sauber)
|
||||
}
|
||||
merke(titel)
|
||||
if (titel.includes(':')) {
|
||||
merke(titel.split(':', 1)[0])
|
||||
merke(titel.replaceAll(':', ''))
|
||||
}
|
||||
merke(titel.replace(/\([^)]*\)/g, ''))
|
||||
let worte = titel.split(/\s+/).filter(Boolean)
|
||||
while (worte.length > 1 && kandidaten.length < 6) {
|
||||
// Ohne den Verpackungs-Zusatz ist der Titel erst suchbar — und das ist
|
||||
// der Kandidat, der die Serie findet, nicht der gestutzte Vorspann.
|
||||
const ohneZusatz = discZusatzAbtrennen(titel).titel
|
||||
merke(ohneZusatz)
|
||||
// Volume-Labels können keinen Doppelpunkt tragen, TMDb schreibt ihn:
|
||||
// beide Schreibweisen fragen.
|
||||
if (ohneZusatz.includes(':')) merke(ohneZusatz.replaceAll(':', ''))
|
||||
merke(ohneZusatz.replace(/\([^)]*\)/g, ''))
|
||||
let worte = ohneZusatz.split(/\s+/).filter(Boolean)
|
||||
while (worte.length > 1 && kandidaten.length < HOECHSTENS_KANDIDATEN - 1) {
|
||||
worte = worte.slice(0, -1)
|
||||
merke(worte.join(' '))
|
||||
}
|
||||
if (ohneZusatz.includes(':')) merke(ohneZusatz.split(':', 1)[0])
|
||||
return kandidaten
|
||||
}
|
||||
|
||||
@@ -2,14 +2,50 @@
|
||||
// ein v3-Key (32 Hex, api_key-Query) ODER ein v4-Token ('eyJ…',
|
||||
// Bearer-Header) sein — der Befund vom 24.07.: der alte Client konnte nur
|
||||
// eine Form, und die andere sah aus wie „falscher Key". Sprache de-DE.
|
||||
//
|
||||
// 5.4.0 — Kino-Banner v2: Details holen in DERSELBEN Anfrage per
|
||||
// `append_to_response` Logo, Besetzung und Freigabe mit. Die Feldnamen
|
||||
// sind GEMESSEN (01.09.2026 mit dem hinterlegten v4-Token, Regel D):
|
||||
// movie/149?append_to_response=images,credits,release_dates
|
||||
// &include_image_language=de,en,null
|
||||
// → tagline „Neo-Tokyo ist am EXPLODIEREN", runtime 124,
|
||||
// vote_average 7.94, images.logos[{iso_639_1:'de'|'en'|null,
|
||||
// file_path:'/….png'|'.svg', width, height, vote_average}],
|
||||
// credits.cast[{name, character, profile_path, order}],
|
||||
// release_dates.results[{iso_3166_1:'DE', release_dates:[{certification:'16', type:3|5|6}]}]
|
||||
// tv/46296?append_to_response=images,credits,content_ratings
|
||||
// → episode_run_time [53], content_ratings.results[{iso_3166_1:'DE', rating:'18'}],
|
||||
// seasons[{season_number, name, episode_count}], number_of_episodes
|
||||
// configuration.images: logo_sizes w45…w500, profile_sizes w45,w185,h632
|
||||
// Und der Stub-Fund: „Spartacus: Gods of the Arena" gibt es als drei
|
||||
// leere Serien-Einträge (popularity 1, kein Backdrop, 1 Folge) — der
|
||||
// echte Inhalt ist die Extras-Staffel von „Spartacus" (46296).
|
||||
|
||||
export interface FilmTreffer {
|
||||
id: number
|
||||
title?: string
|
||||
name?: string
|
||||
poster_path?: string
|
||||
poster_path?: string | null
|
||||
backdrop_path?: string | null
|
||||
release_date?: string
|
||||
first_air_date?: string
|
||||
overview?: string
|
||||
popularity?: number
|
||||
}
|
||||
|
||||
export interface TmdbLogo {
|
||||
iso_639_1: string | null
|
||||
file_path: string
|
||||
width?: number
|
||||
height?: number
|
||||
vote_average?: number
|
||||
}
|
||||
|
||||
export interface TmdbCast {
|
||||
name: string
|
||||
character?: string
|
||||
profile_path?: string | null
|
||||
order?: number
|
||||
}
|
||||
|
||||
export interface FilmDetails {
|
||||
@@ -19,10 +55,22 @@ export interface FilmDetails {
|
||||
release_date?: string
|
||||
first_air_date?: string
|
||||
overview?: string
|
||||
poster_path?: string
|
||||
backdrop_path?: string
|
||||
poster_path?: string | null
|
||||
backdrop_path?: string | null
|
||||
runtime?: number
|
||||
genres?: Array<{ name: string }>
|
||||
tagline?: string
|
||||
vote_average?: number
|
||||
vote_count?: number
|
||||
popularity?: number
|
||||
episode_run_time?: number[]
|
||||
number_of_episodes?: number
|
||||
number_of_seasons?: number
|
||||
seasons?: Array<{ season_number: number; name?: string; episode_count?: number }>
|
||||
images?: { logos?: TmdbLogo[] }
|
||||
credits?: { cast?: TmdbCast[] }
|
||||
release_dates?: { results?: Array<{ iso_3166_1: string; release_dates?: Array<{ certification?: string; type?: number }> }> }
|
||||
content_ratings?: { results?: Array<{ iso_3166_1: string; rating?: string }> }
|
||||
}
|
||||
|
||||
export function istV4Token(key: string): boolean {
|
||||
@@ -31,6 +79,44 @@ export function istV4Token(key: string): boolean {
|
||||
|
||||
const BASIS = 'https://api.themoviedb.org/3'
|
||||
|
||||
/** Das beste Logo (pur): deutsch vor englisch vor sprachlos, darin das
|
||||
* bestbewertete. '' wenn keins. */
|
||||
export function logoAus(logos: readonly TmdbLogo[] | undefined): string {
|
||||
if (logos === undefined || logos.length === 0) return ''
|
||||
const rang = (l: TmdbLogo): number => (l.iso_639_1 === 'de' ? 0 : l.iso_639_1 === 'en' ? 1 : l.iso_639_1 === null ? 2 : 3)
|
||||
const sortiert = [...logos]
|
||||
.filter((l) => typeof l.file_path === 'string' && l.file_path.length > 0)
|
||||
.sort((a, b) => rang(a) - rang(b) || (b.vote_average ?? 0) - (a.vote_average ?? 0))
|
||||
return sortiert[0]?.file_path ?? ''
|
||||
}
|
||||
|
||||
/** Die deutsche Freigabe (pur): Film aus release_dates (Kino vor
|
||||
* Heimvideo vor TV), Serie aus content_ratings. '' wenn unbekannt. */
|
||||
export function fskAus(details: FilmDetails, typ: 'movie' | 'tv'): string {
|
||||
if (typ === 'tv') {
|
||||
const de = details.content_ratings?.results?.find((r) => r.iso_3166_1 === 'DE')
|
||||
return (de?.rating ?? '').trim()
|
||||
}
|
||||
const de = details.release_dates?.results?.find((r) => r.iso_3166_1 === 'DE')
|
||||
const eintraege = (de?.release_dates ?? []).filter((e) => (e.certification ?? '').trim().length > 0)
|
||||
if (eintraege.length === 0) return ''
|
||||
const rang = (typ2: number | undefined): number => (typ2 === 3 ? 0 : typ2 === 5 ? 1 : typ2 === 4 ? 2 : typ2 === 6 ? 3 : 4)
|
||||
return [...eintraege].sort((a, b) => rang(a.type) - rang(b.type))[0].certification!.trim()
|
||||
}
|
||||
|
||||
/** Die ersten Darsteller mit Bild (pur). */
|
||||
export function besetzungAus(
|
||||
cast: readonly TmdbCast[] | undefined,
|
||||
hoechstens = 6,
|
||||
): Array<{ name: string; rolle: string; bildPfad: string }> {
|
||||
if (cast === undefined) return []
|
||||
return [...cast]
|
||||
.filter((c) => typeof c.name === 'string' && c.name.length > 0)
|
||||
.sort((a, b) => (a.order ?? 999) - (b.order ?? 999))
|
||||
.slice(0, hoechstens)
|
||||
.map((c) => ({ name: c.name, rolle: (c.character ?? '').trim(), bildPfad: c.profile_path ?? '' }))
|
||||
}
|
||||
|
||||
export class TmdbClient {
|
||||
constructor(
|
||||
private readonly apiKey: string,
|
||||
@@ -84,11 +170,35 @@ export class TmdbClient {
|
||||
}
|
||||
|
||||
async movieDetails(id: number): Promise<FilmDetails | null> {
|
||||
return (await this.anfrage(`movie/${id}`, { language: 'de-DE' })) as FilmDetails | null
|
||||
return (await this.anfrage(`movie/${id}`, {
|
||||
language: 'de-DE',
|
||||
append_to_response: 'images,credits,release_dates',
|
||||
include_image_language: 'de,en,null',
|
||||
})) as FilmDetails | null
|
||||
}
|
||||
|
||||
async tvDetails(id: number): Promise<FilmDetails | null> {
|
||||
return (await this.anfrage(`tv/${id}`, { language: 'de-DE' })) as FilmDetails | null
|
||||
return (await this.anfrage(`tv/${id}`, {
|
||||
language: 'de-DE',
|
||||
append_to_response: 'images,credits,content_ratings',
|
||||
include_image_language: 'de,en,null',
|
||||
})) as FilmDetails | null
|
||||
}
|
||||
|
||||
/** Poster beliebter Filme (5.5.0, Hintergrund der Erst-Einrichtung):
|
||||
* /movie/popular liefert results[].poster_path — dieselbe Form wie die
|
||||
* Suche (FilmTreffer). Zwei Seiten à 20 reichen für ein volles Raster. */
|
||||
async beliebtePoster(hoechstens = 40): Promise<string[]> {
|
||||
const pfade: string[] = []
|
||||
for (let seite = 1; seite <= 2 && pfade.length < hoechstens; seite++) {
|
||||
const daten = (await this.anfrage('movie/popular', { language: 'de-DE', page: String(seite) })) as {
|
||||
results?: FilmTreffer[]
|
||||
} | null
|
||||
for (const f of daten?.results ?? []) {
|
||||
if (typeof f.poster_path === 'string' && f.poster_path.length > 0) pfade.push(f.poster_path)
|
||||
}
|
||||
}
|
||||
return pfade.slice(0, hoechstens)
|
||||
}
|
||||
|
||||
/** Staffel-Details mit Episoden-Laufzeiten — für die Serien-Zuordnung. */
|
||||
|
||||
@@ -12,9 +12,24 @@
|
||||
//
|
||||
// Rippy sagt nicht „das ist Akira", sondern „sehr wahrscheinlich Akira
|
||||
// (1988)" — und fragt nur, wenn es das nicht sagen kann (§ 5).
|
||||
import { titelKandidaten } from './discmerkmale'
|
||||
import type { FilmDetails, FilmTreffer, TmdbClient } from './tmdb'
|
||||
//
|
||||
// 5.4.0 — Erkennung v2, drei Ergänzungen, alle an echten Daten bezahlt:
|
||||
// * STUBS: TMDb hat „Spartacus: Gods of the Arena" dreimal als leeren
|
||||
// Serien-Eintrag (kein Backdrop, keine Beschreibung, 1 Folge,
|
||||
// Popularität 1). Ein wörtlicher Treffer darauf sah 5.1.1 als 90 %
|
||||
// sicher an — es war ein Eintrag ohne Inhalt. Leere Einträge zählen
|
||||
// nicht mehr als Treffer.
|
||||
// * LAUFZEIT: Nach dem Info-Lauf wird die gemessene Länge des Hauptinhalts
|
||||
// gegen TMDb gehalten (nachpruefen). Passt sie, wird aus „wahrscheinlich"
|
||||
// „bestätigt"; passt sie gar nicht (95 vs. 158 min), fällt der Treffer
|
||||
// auf einen Vorschlag zurück.
|
||||
// * VARIANTEN: Die Disc trägt oft zwei Titel (bdmt_eng UND bdmt_deu) —
|
||||
// jede Variante wird gefragt, und die Abdeckung wird an IHRER Vorlage
|
||||
// gemessen, nicht an der englischen.
|
||||
import { discZusatzAbtrennen, titelKandidaten } from './discmerkmale'
|
||||
import { besetzungAus, fskAus, logoAus, type FilmDetails, type FilmTreffer, type TmdbClient } from './tmdb'
|
||||
import type { OmdbClient, OmdbTreffer } from './omdb'
|
||||
import type { Darsteller } from '../../gemeinsam/nachrichten'
|
||||
|
||||
export const WENIGE_TREFFER = 3
|
||||
|
||||
@@ -30,6 +45,14 @@ export interface MetaTreffer {
|
||||
laufzeitMinuten: number
|
||||
genres: string[]
|
||||
quelle: 'tmdb' | 'omdb' | ''
|
||||
// ── Kino-Banner v2 (5.4.0) ──
|
||||
logoPfad: string
|
||||
tagline: string
|
||||
bewertung: number
|
||||
fsk: string
|
||||
besetzung: Darsteller[]
|
||||
/** Folgen laut TMDb (Serie) — 0 wenn unbekannt/Film. */
|
||||
folgenGesamt: number
|
||||
}
|
||||
|
||||
export interface Zuordnung {
|
||||
@@ -37,6 +60,8 @@ export interface Zuordnung {
|
||||
jahr: number | null
|
||||
confidence: number
|
||||
meta: MetaTreffer
|
||||
/** Was Rippy nach dem Info-Lauf noch dazugelernt hat (nachpruefen). */
|
||||
hinweise?: string[]
|
||||
}
|
||||
|
||||
function jahrAus(datum: string | undefined): number | null {
|
||||
@@ -45,7 +70,11 @@ function jahrAus(datum: string | undefined): number | null {
|
||||
return Number.isFinite(jahr) && jahr > 0 ? jahr : null
|
||||
}
|
||||
|
||||
function ausDetails(typ: 'movie' | 'tv', id: number, details: FilmDetails): MetaTreffer {
|
||||
export function ausDetails(typ: 'movie' | 'tv', id: number, details: FilmDetails): MetaTreffer {
|
||||
const laufzeit =
|
||||
typ === 'movie'
|
||||
? (details.runtime ?? 0)
|
||||
: ((details.episode_run_time ?? []).find((m) => typeof m === 'number' && m > 0) ?? 0)
|
||||
return {
|
||||
typ,
|
||||
id,
|
||||
@@ -54,9 +83,15 @@ function ausDetails(typ: 'movie' | 'tv', id: number, details: FilmDetails): Meta
|
||||
beschreibung: details.overview ?? '',
|
||||
posterPfad: details.poster_path ?? '',
|
||||
backdropPfad: details.backdrop_path ?? '',
|
||||
laufzeitMinuten: details.runtime ?? 0,
|
||||
laufzeitMinuten: laufzeit,
|
||||
genres: (details.genres ?? []).map((g) => g.name),
|
||||
quelle: 'tmdb',
|
||||
logoPfad: logoAus(details.images?.logos),
|
||||
tagline: (details.tagline ?? '').trim(),
|
||||
bewertung: typeof details.vote_average === 'number' ? Math.round(details.vote_average * 10) / 10 : 0,
|
||||
fsk: fskAus(details, typ),
|
||||
besetzung: besetzungAus(details.credits?.cast),
|
||||
folgenGesamt: typ === 'tv' ? (details.number_of_episodes ?? 0) : 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,38 +108,66 @@ function ausOmdb(treffer: OmdbTreffer): MetaTreffer {
|
||||
laufzeitMinuten: treffer.runtime,
|
||||
genres: treffer.genres,
|
||||
quelle: 'omdb',
|
||||
logoPfad: '',
|
||||
tagline: '',
|
||||
bewertung: 0,
|
||||
fsk: '',
|
||||
besetzung: [],
|
||||
folgenGesamt: 0,
|
||||
}
|
||||
}
|
||||
|
||||
export const LEERER_TREFFER: MetaTreffer = {
|
||||
typ: 'unknown',
|
||||
id: null,
|
||||
titel: '',
|
||||
jahr: null,
|
||||
beschreibung: '',
|
||||
posterPfad: '',
|
||||
backdropPfad: '',
|
||||
laufzeitMinuten: 0,
|
||||
genres: [],
|
||||
quelle: '',
|
||||
logoPfad: '',
|
||||
tagline: '',
|
||||
bewertung: 0,
|
||||
fsk: '',
|
||||
besetzung: [],
|
||||
folgenGesamt: 0,
|
||||
}
|
||||
|
||||
const UNBEKANNT = (titel: string): Zuordnung => ({
|
||||
titel,
|
||||
jahr: null,
|
||||
confidence: 0.3,
|
||||
meta: {
|
||||
typ: 'unknown',
|
||||
id: null,
|
||||
titel,
|
||||
jahr: null,
|
||||
beschreibung: '',
|
||||
posterPfad: '',
|
||||
backdropPfad: '',
|
||||
laufzeitMinuten: 0,
|
||||
genres: [],
|
||||
quelle: '',
|
||||
},
|
||||
meta: { ...LEERER_TREFFER, titel },
|
||||
})
|
||||
|
||||
/** Ein Eintrag ohne Inhalt ist kein Treffer (pur). Gemessen an den drei
|
||||
* „Spartacus: Gods of the Arena"-Stubs: kein Backdrop, keine Beschreibung,
|
||||
* keine Bewertung — TMDb kennt dort nur den Namen. */
|
||||
export function istStub(details: FilmDetails): boolean {
|
||||
const beschreibung = (details.overview ?? '').trim().length > 0
|
||||
const bild = (details.backdrop_path ?? '') !== '' && details.backdrop_path !== null
|
||||
const bewertet = (details.vote_count ?? 0) > 0
|
||||
return !beschreibung && !bild && !bewertet
|
||||
}
|
||||
|
||||
/** Titel → Wortmenge, Satzzeichen fallen weg. Grundlage von Überlappung,
|
||||
* Abdeckung und Titelvergleich — alle drei sollen dasselbe „Wort" meinen. */
|
||||
function worte(t: string): Set<string> {
|
||||
return new Set(
|
||||
t
|
||||
.toLowerCase()
|
||||
.split(/[^\p{L}\p{N}]+/u)
|
||||
.filter((w) => w.length > 0),
|
||||
)
|
||||
}
|
||||
|
||||
/** Wort-Überlappung zweier Titel (Jaccard, 0–1) — das Gate gegen
|
||||
* selbstbewussten Unsinn: Bei der Live-Messung am 30.08.2026 machte OMDb
|
||||
* aus dem Label „Bd Evg D2" den Film „BD Scream 5" (gemeinsam nur „BD"). */
|
||||
export function wortUeberlappung(a: string, b: string): number {
|
||||
const worte = (t: string): Set<string> =>
|
||||
new Set(
|
||||
t
|
||||
.toLowerCase()
|
||||
.split(/[^\p{L}\p{N}]+/u)
|
||||
.filter((w) => w.length > 0),
|
||||
)
|
||||
const wa = worte(a)
|
||||
const wb = worte(b)
|
||||
if (wa.size === 0 || wb.size === 0) return 0
|
||||
@@ -113,71 +176,173 @@ export function wortUeberlappung(a: string, b: string): number {
|
||||
return gemeinsam / (wa.size + wb.size - gemeinsam)
|
||||
}
|
||||
|
||||
/** Gleicher Titel, wenn man Satzzeichen und Groß-/Kleinschreibung
|
||||
* weglässt. Ein Volume-Label KANN keinen Doppelpunkt tragen, TMDb
|
||||
* schreibt ihn: 'Spartacus Gods Of The Arena' gegen 'Spartacus: Gods of
|
||||
* the Arena' war zeichengenau verglichen kein Treffer — der exakt
|
||||
* richtige Fund fiel an einem Satzzeichen durch (01.09.2026 gemessen). */
|
||||
export function gleichBedeutend(a: string, b: string): boolean {
|
||||
// Wortmenge wäre hier falsch: Sie machte 'New York, New York' gleich
|
||||
// 'New York'. Verglichen wird die Wort-FOLGE, nur ohne Satzzeichen.
|
||||
const nackt = (t: string): string =>
|
||||
t
|
||||
.toLowerCase()
|
||||
.split(/[^\p{L}\p{N}]+/u)
|
||||
.filter((w) => w.length > 0)
|
||||
.join(' ')
|
||||
const na = nackt(a)
|
||||
return na.length > 0 && na === nackt(b)
|
||||
}
|
||||
|
||||
/** Wie viel vom Disc-Titel steckt noch in dieser Suchvariante (0–1)? */
|
||||
export function abdeckung(discTitel: string, kandidat: string): number {
|
||||
const ganz = worte(discTitel)
|
||||
if (ganz.size === 0) return 0
|
||||
let drin = 0
|
||||
for (const w of worte(kandidat)) if (ganz.has(w)) drin += 1
|
||||
return drin / ganz.size
|
||||
}
|
||||
|
||||
/** Sicherheit eines Treffers, den das UI als Vorschlag zeigt und nachfragt. */
|
||||
export const VORSCHLAG = 0.6
|
||||
|
||||
/** Darunter ist ein Treffer GERATEN, nicht gefunden — und wird zum
|
||||
* Vorschlag herabgestuft, egal wie wörtlich er aussieht.
|
||||
*
|
||||
* Der Grund, an der echten Disc gemessen (01.09.2026): Aus
|
||||
* 'Spartacus: Gods Of The Arena - Disc 2' blieb nach dem Stutzen die
|
||||
* Frage 'Spartacus' übrig — sechs von sieben Wörtern weggeworfen. TMDb
|
||||
* antwortete mit einem wörtlichen Filmtreffer, und Rippy meldete mit
|
||||
* 95 % Sicherheit den falschen Film. Die alte Kaskade maß nur die ART
|
||||
* des Treffers, nie die QUALITÄT der Frage. */
|
||||
export const MINDEST_ABDECKUNG = 0.5
|
||||
|
||||
export interface DiscHinweis {
|
||||
/** Die Disc bezeugt über ihr EIGENES Inhaltsverzeichnis, dass sie Folgen
|
||||
* trägt (discmerkmale.discTitel().istSerie). Dann sind die Film-Zweige
|
||||
* aus: Was bewiesen ist, muss nicht mehr geraten werden. */
|
||||
istSerie?: boolean
|
||||
/** Weitere Titel derselben Disc (bdmt_deu neben bdmt_eng, 5.4.0). */
|
||||
alternativen?: readonly string[]
|
||||
}
|
||||
|
||||
interface Kandidat {
|
||||
text: string
|
||||
/** Die Vorlage, an der die Abdeckung gemessen wird (Titel ohne Zusatz). */
|
||||
massstab: string
|
||||
}
|
||||
|
||||
/** Suchvarianten aller Titel-Varianten: erst die vollen Titel, dann die
|
||||
* gestutzten — je Variante an ihrer eigenen Vorlage gemessen (pur). */
|
||||
export function kandidatenAus(titel: string, alternativen: readonly string[] = []): Kandidat[] {
|
||||
const quellen = [titel, ...alternativen].map((t) => t.trim()).filter((t) => t.length > 0)
|
||||
const gesehen = new Set<string>()
|
||||
const voll: Kandidat[] = []
|
||||
const rest: Kandidat[] = []
|
||||
for (const quelle of quellen) {
|
||||
const massstab = discZusatzAbtrennen(quelle).titel || quelle
|
||||
const varianten = titelKandidaten(quelle)
|
||||
if (varianten.length === 0) varianten.push(quelle)
|
||||
varianten.forEach((text, i) => {
|
||||
const schluessel = text.toLowerCase()
|
||||
if (gesehen.has(schluessel)) return
|
||||
gesehen.add(schluessel)
|
||||
;(i === 0 ? voll : rest).push({ text, massstab })
|
||||
})
|
||||
}
|
||||
return [...voll, ...rest]
|
||||
}
|
||||
|
||||
/** Die Kaskade — Clients injiziert, damit sie ohne Netz prüfbar ist.
|
||||
*
|
||||
* Reihenfolge JE KANDIDAT: wörtlich Film → wörtlich Serie → (nur beim
|
||||
* vollen Titel) spezifisch Film → spezifisch Serie. Erst dann der nächste,
|
||||
* gekürzte Kandidat. Der Grund ist eine Live-Messung vom 30.08.2026:
|
||||
* Beim Disc-Titel „Spartacus Gods Of The Arena" erschlug der stark
|
||||
* gekürzte Kandidat „Spartacus" mit einem wörtlichen Treffer (Film von
|
||||
* 1960!) die richtige Serie, die auf den VOLLEN Titel eindeutig war. */
|
||||
* Reihenfolge JE KANDIDAT: wörtlich Film → wörtlich Serie → spezifisch
|
||||
* Film → spezifisch Serie. Erst dann der nächste, ärmere Kandidat.
|
||||
*
|
||||
* Drei Sicherungen, alle an echten Discs bezahlt:
|
||||
*
|
||||
* * `hinweis.istSerie` schaltet die Film-Zweige ganz ab.
|
||||
* * Jede Sicherheit wird an der ABDECKUNG gedeckelt (MINDEST_ABDECKUNG):
|
||||
* Wer den halben Titel wegwerfen musste, um zu treffen, hat geraten —
|
||||
* das ist ein Vorschlag, kein Fund. Der Fall vom 01.09.2026:
|
||||
* 'Spartacus: Gods Of The Arena - Disc 2' wurde über die Restfrage
|
||||
* 'Spartacus' zum Film von 1960, mit 95 % Sicherheit gemeldet.
|
||||
* * Die spezifischen Treffer (0,80) verlangen einen VOLLSTÄNDIGEN
|
||||
* Kandidaten — er muss noch jedes Wort des Titels tragen (Zusätze wie
|
||||
* '- Disc 2' zählen nicht mit). Vorher hing das an „Kandidat Nummer
|
||||
* 0"; sobald der Zusatz abgetrennt wurde, war der richtige Kandidat
|
||||
* Nummer 1 und fiel durch dieses Raster.
|
||||
* * (5.4.0) Ein Eintrag ohne Inhalt (istStub) ist kein Treffer — der
|
||||
* nächste wörtliche Treffer bekommt die Chance.
|
||||
*/
|
||||
export async function zuordnen(
|
||||
discTitel: string,
|
||||
tmdb: Pick<TmdbClient, 'searchMovie' | 'searchTv' | 'movieDetails' | 'tvDetails' | 'findByImdb' | 'verfuegbar'>,
|
||||
omdb: Pick<OmdbClient, 'lookup' | 'verfuegbar'>,
|
||||
hinweis: DiscHinweis = {},
|
||||
): Promise<Zuordnung> {
|
||||
const titel = discTitel.trim()
|
||||
if (titel.length === 0) return UNBEKANNT('')
|
||||
|
||||
const kandidaten = titelKandidaten(titel)
|
||||
if (kandidaten.length === 0) kandidaten.push(titel)
|
||||
const nurSerie = hinweis.istSerie === true
|
||||
const kandidaten = kandidatenAus(titel, hinweis.alternativen ?? [])
|
||||
|
||||
let ersteTreffer: FilmTreffer[] = []
|
||||
let ersteFilme: FilmTreffer[] = []
|
||||
let ersteSerien: FilmTreffer[] = []
|
||||
|
||||
for (let i = 0; i < kandidaten.length; i++) {
|
||||
const kandidat = kandidaten[i]
|
||||
const filme = (await tmdb.searchMovie(kandidat)) ?? []
|
||||
if (filme.length > 0 && ersteTreffer.length === 0) ersteTreffer = filme
|
||||
for (const kandidat of kandidaten) {
|
||||
const deckung = abdeckung(kandidat.massstab, kandidat.text)
|
||||
const vollstaendig = deckung >= 1
|
||||
/** Ein Treffer auf eine stark gestutzte Frage bleibt ein Vorschlag. */
|
||||
const deckel = (basis: number): number => (deckung >= MINDEST_ABDECKUNG ? basis : Math.min(basis, VORSCHLAG))
|
||||
|
||||
const filme = nurSerie ? [] : ((await tmdb.searchMovie(kandidat.text)) ?? [])
|
||||
if (filme.length > 0 && ersteFilme.length === 0) ersteFilme = filme
|
||||
|
||||
// Wörtlicher Filmtreffer (0,95)
|
||||
for (const film of filme) {
|
||||
if ((film.title ?? '').toLowerCase() === kandidat.toLowerCase()) {
|
||||
if (gleichBedeutend(film.title ?? '', kandidat.text)) {
|
||||
const details = await tmdb.movieDetails(film.id)
|
||||
if (details !== null) {
|
||||
if (details !== null && !istStub(details)) {
|
||||
const meta = ausDetails('movie', film.id, details)
|
||||
return { titel: meta.titel, jahr: meta.jahr, confidence: 0.95, meta }
|
||||
return { titel: meta.titel, jahr: meta.jahr, confidence: deckel(0.95), meta }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const serien = (await tmdb.searchTv(kandidat)) ?? []
|
||||
const serien = (await tmdb.searchTv(kandidat.text)) ?? []
|
||||
if (serien.length > 0 && ersteSerien.length === 0) ersteSerien = serien
|
||||
|
||||
// Wörtlicher Serientreffer (0,90)
|
||||
for (const serie of serien) {
|
||||
if ((serie.name ?? '').toLowerCase() === kandidat.toLowerCase()) {
|
||||
if (gleichBedeutend(serie.name ?? '', kandidat.text)) {
|
||||
const details = await tmdb.tvDetails(serie.id)
|
||||
if (details !== null) {
|
||||
if (details !== null && !istStub(details)) {
|
||||
const meta = ausDetails('tv', serie.id, details)
|
||||
return { titel: meta.titel, jahr: meta.jahr, confidence: 0.9, meta }
|
||||
return { titel: meta.titel, jahr: meta.jahr, confidence: deckel(0.9), meta }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Spezifische Treffer NUR für den vollen, ungekürzten Titel (0,80):
|
||||
// Wer auf die volle Anfrage eine Handvoll bekommt, hat gefragt wie
|
||||
// jemand, der weiß, was er sucht (der Evangelion-Fund).
|
||||
if (i === 0) {
|
||||
// Spezifische Treffer (0,80): Wer auf eine VOLLSTÄNDIGE Anfrage eine
|
||||
// Handvoll bekommt, hat gefragt wie jemand, der weiß, was er sucht
|
||||
// (der Evangelion-Fund). Stubs zählen dabei nicht mit.
|
||||
if (vollstaendig) {
|
||||
if (filme.length > 0 && filme.length <= WENIGE_TREFFER) {
|
||||
const details = await tmdb.movieDetails(filme[0].id)
|
||||
if (details !== null) {
|
||||
const meta = ausDetails('movie', filme[0].id, details)
|
||||
return { titel: meta.titel, jahr: meta.jahr, confidence: 0.8, meta }
|
||||
for (const film of filme) {
|
||||
const details = await tmdb.movieDetails(film.id)
|
||||
if (details !== null && !istStub(details)) {
|
||||
const meta = ausDetails('movie', film.id, details)
|
||||
return { titel: meta.titel, jahr: meta.jahr, confidence: 0.8, meta }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (serien.length > 0 && serien.length <= WENIGE_TREFFER) {
|
||||
const details = await tmdb.tvDetails(serien[0].id)
|
||||
if (details !== null) {
|
||||
const meta = ausDetails('tv', serien[0].id, details)
|
||||
return { titel: meta.titel, jahr: meta.jahr, confidence: 0.8, meta }
|
||||
for (const serie of serien) {
|
||||
const details = await tmdb.tvDetails(serie.id)
|
||||
if (details !== null && !istStub(details)) {
|
||||
const meta = ausDetails('tv', serie.id, details)
|
||||
return { titel: meta.titel, jahr: meta.jahr, confidence: 0.8, meta }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -187,12 +352,15 @@ export async function zuordnen(
|
||||
// Datensatz wird über TMDb /find (IMDb-ID) nachgeladen.
|
||||
if (omdb.verfuegbar) {
|
||||
for (const kandidat of kandidaten) {
|
||||
const treffer = await omdb.lookup(kandidat)
|
||||
const treffer = await omdb.lookup(kandidat.text)
|
||||
if (treffer !== null) {
|
||||
// Eine Serien-Disc nimmt keinen Film entgegen — sie hat bewiesen,
|
||||
// was sie ist.
|
||||
if (nurSerie && treffer.type !== 'tv') continue
|
||||
// Ähnlichkeits-Gate: OMDbs t=-Suche antwortet auch auf kryptische
|
||||
// Labels mit irgendeinem Film — gemessen „Bd Evg D2" → „BD Scream
|
||||
// 5". Ohne echte Wort-Überlappung ist das kein Treffer.
|
||||
if (wortUeberlappung(kandidat, treffer.title) < 0.5) continue
|
||||
if (wortUeberlappung(kandidat.text, treffer.title) < 0.5) continue
|
||||
const meta = ausOmdb(treffer)
|
||||
if (typeof meta.id === 'string' && meta.id.startsWith('tt') && tmdb.verfuegbar) {
|
||||
const deutsch = await tmdb.findByImdb(meta.id)
|
||||
@@ -208,19 +376,36 @@ export async function zuordnen(
|
||||
if (deutsch['type'] === 'tv') meta.typ = 'tv'
|
||||
}
|
||||
}
|
||||
return { titel: meta.titel, jahr: meta.jahr, confidence: 0.8, meta }
|
||||
// Auch OMDb wird gedeckelt: Ein Treffer auf eine ausgedünnte Frage
|
||||
// ist ein Vorschlag, egal von welchem Dienst er kommt.
|
||||
const sicher = abdeckung(kandidat.massstab, kandidat.text) >= MINDEST_ABDECKUNG ? 0.8 : VORSCHLAG
|
||||
return { titel: meta.titel, jahr: meta.jahr, confidence: sicher, meta }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Vorschlag: bester Rohtreffer (0,60) — das UI fragt nach.
|
||||
if (ersteTreffer.length > 0) {
|
||||
const details = await tmdb.movieDetails(ersteTreffer[0].id)
|
||||
if (details !== null) {
|
||||
const meta = ausDetails('movie', ersteTreffer[0].id, details)
|
||||
return { titel: meta.titel, jahr: meta.jahr, confidence: 0.6, meta }
|
||||
}
|
||||
// 5. Vorschlag: bester Rohtreffer (0,60) — das UI fragt nach. Bei einer
|
||||
// Serien-Disc ist die Filmliste leer, dort zählen nur die Serien.
|
||||
// Einträge MIT Inhalt zuerst; ist alles leer, bleibt der erste Treffer
|
||||
// trotzdem ein Vorschlag — besser als gar keiner (es wird ja gefragt).
|
||||
let notnagel: Zuordnung | null = null
|
||||
for (const film of ersteFilme.slice(0, WENIGE_TREFFER)) {
|
||||
const details = await tmdb.movieDetails(film.id)
|
||||
if (details === null) continue
|
||||
const meta = ausDetails('movie', film.id, details)
|
||||
const vorschlag = { titel: meta.titel, jahr: meta.jahr, confidence: VORSCHLAG, meta }
|
||||
if (!istStub(details)) return vorschlag
|
||||
notnagel ??= vorschlag
|
||||
}
|
||||
for (const serie of ersteSerien.slice(0, WENIGE_TREFFER)) {
|
||||
const details = await tmdb.tvDetails(serie.id)
|
||||
if (details === null) continue
|
||||
const meta = ausDetails('tv', serie.id, details)
|
||||
const vorschlag = { titel: meta.titel, jahr: meta.jahr, confidence: VORSCHLAG, meta }
|
||||
if (!istStub(details)) return vorschlag
|
||||
notnagel ??= vorschlag
|
||||
}
|
||||
if (notnagel !== null) return notnagel
|
||||
|
||||
return UNBEKANNT(titel)
|
||||
}
|
||||
@@ -229,3 +414,71 @@ export async function zuordnen(
|
||||
export function istSicherGenug(zuordnung: Zuordnung, schwelle = 0.9): boolean {
|
||||
return zuordnung.confidence >= schwelle
|
||||
}
|
||||
|
||||
/** Laufzeit passt, wenn die gemessene Länge des Hauptinhalts höchstens so
|
||||
* weit von TMDb abweicht (Kino- gegen Heimvideo-Fassung, Abspann). */
|
||||
export const LAUFZEIT_PASST = 0.06
|
||||
/** Darüber passt sie NICHT — der Treffer wird zum Vorschlag (95 vs. 158 min). */
|
||||
export const LAUFZEIT_FALSCH = 0.25
|
||||
|
||||
/** Was der Info-Lauf über die Disc gemessen hat (rip/titelwahl.ts). */
|
||||
export interface Messung {
|
||||
/** Länge des Hauptinhalts in Sekunden (Film) — 0 wenn keiner erkennbar. */
|
||||
hauptS: number
|
||||
/** Längen der Folgen-Kandidaten in Sekunden (Serie). */
|
||||
folgenS: readonly number[]
|
||||
/** Was die Titel-Struktur sagt: 'film' | 'serie' | 'unklar'. */
|
||||
struktur: 'film' | 'serie' | 'unklar'
|
||||
}
|
||||
|
||||
/**
|
||||
* Nachprüfen NACH dem Info-Lauf (pur, 5.4.0): Erkannt wird beim EINLEGEN,
|
||||
* also vor jeder Messung. Jetzt liegt die stärkste Evidenz vor, die zu
|
||||
* haben ist — die Laufzeit. Aus „wahrscheinlich" wird „bestätigt", oder
|
||||
* Rippy merkt selbst, dass es danebenlag. Eine Wahl des Nutzers
|
||||
* (confidence 1) wird nie herabgestuft, nur kommentiert.
|
||||
*/
|
||||
export function nachpruefen(zuordnung: Zuordnung, messung: Messung): Zuordnung {
|
||||
const hinweise: string[] = []
|
||||
let confidence = zuordnung.confidence
|
||||
const meta = zuordnung.meta
|
||||
const vomNutzer = zuordnung.confidence >= 1
|
||||
const soll = meta.laufzeitMinuten * 60
|
||||
|
||||
if (meta.typ === 'movie' && soll > 0 && messung.hauptS > 0) {
|
||||
const abweichung = Math.abs(messung.hauptS - soll) / soll
|
||||
const ist = Math.round(messung.hauptS / 60)
|
||||
if (abweichung <= LAUFZEIT_PASST) {
|
||||
hinweise.push(`Laufzeit bestätigt: ${ist} min auf der Disc, ${meta.laufzeitMinuten} min bei TMDb.`)
|
||||
if (!vomNutzer) confidence = Math.max(confidence, 0.95)
|
||||
} else if (abweichung >= LAUFZEIT_FALSCH) {
|
||||
hinweise.push(
|
||||
`Laufzeit passt NICHT: ${ist} min auf der Disc, ${meta.laufzeitMinuten} min bei TMDb — bitte über „Ändern …" nachsehen.`,
|
||||
)
|
||||
if (!vomNutzer) confidence = Math.min(confidence, VORSCHLAG)
|
||||
} else {
|
||||
hinweise.push(`Laufzeit ähnlich: ${ist} min auf der Disc, ${meta.laufzeitMinuten} min bei TMDb (andere Fassung?).`)
|
||||
}
|
||||
} else if (meta.typ === 'tv' && soll > 0 && messung.folgenS.length > 0) {
|
||||
const mittel = messung.folgenS.reduce((s, f) => s + f, 0) / messung.folgenS.length
|
||||
const abweichung = Math.abs(mittel - soll) / soll
|
||||
const ist = Math.round(mittel / 60)
|
||||
if (abweichung <= 0.2) {
|
||||
hinweise.push(`Folgenlänge passt: ${ist} min auf der Disc, ${meta.laufzeitMinuten} min bei TMDb.`)
|
||||
if (!vomNutzer) confidence = Math.max(confidence, 0.8)
|
||||
} else if (abweichung >= 0.5) {
|
||||
hinweise.push(`Folgenlänge passt NICHT: ${ist} min auf der Disc, ${meta.laufzeitMinuten} min bei TMDb.`)
|
||||
if (!vomNutzer) confidence = Math.min(confidence, VORSCHLAG)
|
||||
}
|
||||
}
|
||||
|
||||
if (messung.struktur === 'serie' && meta.typ === 'movie') {
|
||||
hinweise.push('Die Titel-Struktur sieht nach einer Serienstaffel aus (mehrere gleich lange Titel), TMDb-Treffer ist ein Film.')
|
||||
if (!vomNutzer) confidence = Math.min(confidence, VORSCHLAG)
|
||||
} else if (messung.struktur === 'film' && meta.typ === 'tv') {
|
||||
hinweise.push('Die Titel-Struktur sieht nach einem Film aus (ein langer Titel), TMDb-Treffer ist eine Serie.')
|
||||
if (!vomNutzer) confidence = Math.min(confidence, VORSCHLAG)
|
||||
}
|
||||
|
||||
return { ...zuordnung, confidence: Math.round(confidence * 100) / 100, hinweise }
|
||||
}
|
||||
|
||||
@@ -11,13 +11,14 @@
|
||||
// sich trotzdem mit 0 beenden (30.08.2026, Spartacus Disc 2).
|
||||
// - Kritische MSG-NUMMERN einsammeln — die letzte Zeile ist fast immer
|
||||
// nur „Failed to open disc", die Ursache steht Zeilen davor.
|
||||
import { spawn } from 'node:child_process'
|
||||
import { werkzeugSpawn } from '../werkzeuge/aufruf'
|
||||
import { mkdirSync, readdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
KRITISCHE_CODES,
|
||||
KRITISCHE_TEXTE,
|
||||
LESEFEHLER_MARKE,
|
||||
offsetAusMeldung,
|
||||
parseMsg,
|
||||
parseStreamInfo,
|
||||
parseTitelInfo,
|
||||
@@ -38,18 +39,41 @@ export function buildRipArgs(geraetepfad: string, zielOrdner: string, titel: str
|
||||
return ['-r', '--noscan', '--progress=-same', 'mkv', quelle(geraetepfad), titel, zielOrdner]
|
||||
}
|
||||
|
||||
/** Ab so vielen Meldungen an DERSELBEN Stelle gilt der Lauf als hängend.
|
||||
* Jeder Leseversuch wirft zwei Zeilen (SCSI-Fehler + Betriebssystemfehler),
|
||||
* zwölf sind also rund sechs Versuche — genug, um einen kurzen Aussetzer
|
||||
* von einem echten Defekt zu unterscheiden. */
|
||||
export const HAENGT_AB = 12
|
||||
|
||||
/** Kennzeichnet einen Titel-Lauf, den ein NEUERER abgelöst hat. Der
|
||||
* Aufrufer schweigt dazu — abgelöst ist kein Fehler, sondern Absicht. */
|
||||
export const ABGELOEST = 'titel-lauf-abgeloest'
|
||||
|
||||
/** Auswertung der Rip-Ausgabe — pur, eine Zeile nach der anderen. */
|
||||
export class RipAuswertung {
|
||||
prozent = -1
|
||||
letzteMeldung = ''
|
||||
lesefehler = false
|
||||
kritischeMeldungen: string[] = []
|
||||
/** Die Stelle, an der es zuletzt klemmte — '' wenn keine. */
|
||||
haengtBei = ''
|
||||
/** Wie oft dieselbe Stelle schon gemeldet wurde. */
|
||||
haengtWiederholungen = 0
|
||||
|
||||
/** Klemmt der Lauf an einer festen Stelle? Dann ist das ein Defekt auf
|
||||
* der Disc und kein Fortschrittsproblem — Weiterversuchen hilft nicht. */
|
||||
haengt(): boolean {
|
||||
return this.haengtBei.length > 0 && this.haengtWiederholungen >= HAENGT_AB
|
||||
}
|
||||
|
||||
/** Verarbeitet eine Zeile; gibt einen neuen Prozentwert zurück oder null. */
|
||||
verarbeite(zeile: string): { prozent?: number; meldung?: { code: number; text: string } } {
|
||||
const p = progressAusPrgv(zeile)
|
||||
if (p >= 0) {
|
||||
if (p === this.prozent) return {}
|
||||
// Es geht wieder voran — was vorher klemmte, klemmt nicht mehr.
|
||||
this.haengtBei = ''
|
||||
this.haengtWiederholungen = 0
|
||||
this.prozent = p
|
||||
return { prozent: p }
|
||||
}
|
||||
@@ -57,6 +81,16 @@ export class RipAuswertung {
|
||||
if (meldung === null) return {}
|
||||
this.letzteMeldung = meldung.text
|
||||
if (meldung.text.includes(LESEFEHLER_MARKE)) this.lesefehler = true
|
||||
// Dieselbe Stelle wieder und wieder heißt: physischer Defekt.
|
||||
const stelle = offsetAusMeldung(meldung.text)
|
||||
if (stelle.length > 0) {
|
||||
if (stelle === this.haengtBei) {
|
||||
this.haengtWiederholungen += 1
|
||||
} else {
|
||||
this.haengtBei = stelle
|
||||
this.haengtWiederholungen = 1
|
||||
}
|
||||
}
|
||||
if (meldung.code in KRITISCHE_CODES) {
|
||||
this.kritischeMeldungen.push(`${KRITISCHE_CODES[meldung.code]} (${meldung.text.trim()})`)
|
||||
} else if (KRITISCHE_TEXTE.some((muster) => meldung.text.includes(muster))) {
|
||||
@@ -103,6 +137,15 @@ export class ZeilenLeser {
|
||||
}
|
||||
|
||||
export interface DiscInfo {
|
||||
/** Was MakeMKV als URSACHE genannt hat, wenn nichts herauskam — die
|
||||
* kritischen MSG-Nummern im Klartext. Leer, wenn alles glatt lief.
|
||||
*
|
||||
* Warum (01.09.2026 am Laufwerk gemessen): Beim Zustand 5042 („Das
|
||||
* Programm konnte keine verwendbaren optischen Laufwerke finden")
|
||||
* antwortet der Info-Lauf nach 0,7 s mit TCOUNT:0. Ohne diese Zeile
|
||||
* stünde im Fenster nur „keine Titel gefunden" — und niemand wüsste,
|
||||
* dass das Laufwerk gemeint ist und nicht die Disc. */
|
||||
ursachen: string[]
|
||||
titel: TitelInfo[]
|
||||
streams: Map<number, TitelStreams>
|
||||
sprachen: { audio: SprachEintrag[]; untertitel: SprachEintrag[] }
|
||||
@@ -114,9 +157,10 @@ export function titelInfoLesen(
|
||||
programm: string,
|
||||
geraetepfad: string,
|
||||
timeoutMs = 300_000,
|
||||
abbruch?: AbortSignal,
|
||||
): Promise<DiscInfo> {
|
||||
return new Promise((fertig, scheitert) => {
|
||||
const kind = spawn(programm, buildInfoArgs(geraetepfad), {
|
||||
const kind = werkzeugSpawn(programm, buildInfoArgs(geraetepfad), {
|
||||
windowsHide: true,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
@@ -127,16 +171,37 @@ export function titelInfoLesen(
|
||||
kind.kill()
|
||||
scheitert(new Error(`makemkvcon info: keine Antwort nach ${Math.round(timeoutMs / 1000)} s`))
|
||||
}, timeoutMs)
|
||||
// Ein abgelöster Lauf lässt das Laufwerk sofort los — zwei makemkvcon
|
||||
// auf derselben Disc machen beide langsamer (Commander-Fund 01.09.2026:
|
||||
// Ein alter Lauf meldete 300 s später seinen Timeout und legte ihn über
|
||||
// die längst gelieferte Titelliste).
|
||||
const aufgeben = (): void => {
|
||||
clearTimeout(zeiger)
|
||||
kind.kill()
|
||||
scheitert(new Error(ABGELOEST))
|
||||
}
|
||||
abbruch?.addEventListener('abort', aufgeben, { once: true })
|
||||
kind.on('error', (fehler) => {
|
||||
clearTimeout(zeiger)
|
||||
abbruch?.removeEventListener('abort', aufgeben)
|
||||
scheitert(fehler)
|
||||
})
|
||||
kind.on('close', () => {
|
||||
clearTimeout(zeiger)
|
||||
abbruch?.removeEventListener('abort', aufgeben)
|
||||
const ausgabe = textVon(Buffer.concat(stuecke))
|
||||
const titel = parseTitelInfo(ausgabe)
|
||||
const streams = parseStreamInfo(ausgabe)
|
||||
fertig({ titel, streams, sprachen: sprachenZusammenfassen(streams) })
|
||||
// Die Ursache steht in den MSG-Nummern, nicht in der letzten Zeile.
|
||||
const ursachen: string[] = []
|
||||
for (const zeile of ausgabe.split('\n')) {
|
||||
const meldung = parseMsg(zeile)
|
||||
if (meldung === null) continue
|
||||
if (meldung.code in KRITISCHE_CODES && !ursachen.includes(KRITISCHE_CODES[meldung.code])) {
|
||||
ursachen.push(KRITISCHE_CODES[meldung.code])
|
||||
}
|
||||
}
|
||||
fertig({ ursachen, titel, streams, sprachen: sprachenZusammenfassen(streams) })
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -154,6 +219,9 @@ export interface RipLaufOptionen {
|
||||
titel: string
|
||||
aufFortschritt?: (prozent: number) => void
|
||||
aufMeldung?: (code: number, text: string) => void
|
||||
/** Der Lauf klemmt an einer festen Stelle (oder klemmt nicht mehr) —
|
||||
* das ist ein Disc-Defekt, kein Fortschrittsproblem. */
|
||||
aufHaengt?: (klemmt: { offset: string; wiederholungen: number } | null) => void
|
||||
abbruch?: AbortSignal
|
||||
}
|
||||
|
||||
@@ -163,7 +231,7 @@ export function rippen(optionen: RipLaufOptionen): Promise<RipErgebnis> {
|
||||
return new Promise((fertig) => {
|
||||
mkdirSync(optionen.zielOrdner, { recursive: true })
|
||||
|
||||
const kind = spawn(
|
||||
const kind = werkzeugSpawn(
|
||||
optionen.programm,
|
||||
buildRipArgs(optionen.geraetepfad, optionen.zielOrdner, optionen.titel),
|
||||
{ windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] },
|
||||
@@ -179,12 +247,22 @@ export function rippen(optionen: RipLaufOptionen): Promise<RipErgebnis> {
|
||||
}
|
||||
optionen.abbruch?.addEventListener('abort', abbrechen, { once: true })
|
||||
|
||||
// Ob es klemmt, wird gemeldet, sobald es sich ÄNDERT — nicht bei jeder
|
||||
// Zeile, sonst rauscht dieselbe Nachricht hundertfach durchs Fenster.
|
||||
let klemmteVorher = false
|
||||
const zeile = (z: string): void => {
|
||||
const ergebnis = auswertung.verarbeite(z)
|
||||
if (ergebnis.prozent !== undefined) optionen.aufFortschritt?.(ergebnis.prozent)
|
||||
if (ergebnis.meldung !== undefined) {
|
||||
optionen.aufMeldung?.(ergebnis.meldung.code, ergebnis.meldung.text)
|
||||
}
|
||||
const klemmt = auswertung.haengt()
|
||||
if (klemmt !== klemmteVorher || (klemmt && auswertung.haengtWiederholungen % 10 === 0)) {
|
||||
klemmteVorher = klemmt
|
||||
optionen.aufHaengt?.(
|
||||
klemmt ? { offset: auswertung.haengtBei, wiederholungen: auswertung.haengtWiederholungen } : null,
|
||||
)
|
||||
}
|
||||
}
|
||||
kind.stdout.on('data', (stueck: Buffer) => leser.hinzu(stueck).forEach(zeile))
|
||||
kind.stderr.on('data', (stueck: Buffer) => leser.hinzu(stueck).forEach(zeile))
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
// MakeMKV will den Laufwerksbuchstaben. Entschieden wird nach der FORM des
|
||||
// Pfades, nicht nach dem Rechner.
|
||||
export function quelle(geraetepfad: string): string {
|
||||
// 5.5.0: Das Rettungs-Abbild ist eine ISO-Datei — makemkvcon liest sie
|
||||
// als `iso:<pfad>` (Manpage makemkvcon: „iso:<FileName>").
|
||||
if (/\.iso$/i.test(geraetepfad)) return `iso:${geraetepfad}`
|
||||
let pfad = (geraetepfad ?? '').trim()
|
||||
if (pfad.length === 0) return ''
|
||||
for (const vorsatz of ['\\\\.\\', '\\\\?\\']) {
|
||||
@@ -170,11 +173,10 @@ export function parseStreamInfo(ausgabe: string): Map<number, TitelStreams> {
|
||||
return ergebnis
|
||||
}
|
||||
|
||||
export interface SprachEintrag {
|
||||
lang: string
|
||||
sprache: string
|
||||
spuren: number
|
||||
}
|
||||
// EINE Definition für Kern und Fenster (5.4.0): Der Auswahl-Dialog zeigt
|
||||
// die Sprachen je Titel — deshalb lebt der Typ in gemeinsam/.
|
||||
import type { SprachEintrag } from '../../gemeinsam/nachrichten'
|
||||
export type { SprachEintrag }
|
||||
|
||||
/** Welche Sprachen hat die DISC insgesamt? Über alle Titel vereinigt,
|
||||
* Reihenfolge stabil (wie auf der Disc). */
|
||||
@@ -200,6 +202,17 @@ export function sprachenZusammenfassen(streamsJeTitel: Map<number, TitelStreams>
|
||||
return { audio: sammeln('audio'), untertitel: sammeln('untertitel') }
|
||||
}
|
||||
|
||||
/** Die Sprachen EINES Titels (5.4.0, für den Auswahl-Dialog) — dieselbe
|
||||
* Zusammenfassung wie oben, nur auf einen Titel beschränkt. Ohne Streams
|
||||
* (Titel unbekannt) leer, nicht erfunden. */
|
||||
export function sprachenFuerTitel(streams: TitelStreams | undefined): {
|
||||
audio: SprachEintrag[]
|
||||
untertitel: SprachEintrag[]
|
||||
} {
|
||||
if (streams === undefined) return { audio: [], untertitel: [] }
|
||||
return sprachenZusammenfassen(new Map([[0, streams]]))
|
||||
}
|
||||
|
||||
// ── Titelwahl-Heuristiken ───────────────────────────────────────────────
|
||||
/** Titel-Nummer für den Hauptfilm. Mit Soll-Laufzeit (Minuten, z. B. aus
|
||||
* TMDb) gewinnt der Titel, der ihr am nächsten kommt (±20 min) — das
|
||||
@@ -249,6 +262,26 @@ export function episodenTitel(dauern: Map<number, number>, episodenMinuten: numb
|
||||
// (30.08.2026, Spartacus Disc 2 — 1 von 2 Titeln kam an, Rückgabewert 0).
|
||||
export const LESEFEHLER_MARKE = 'makemkv.com/errors/read'
|
||||
|
||||
/** Die STELLE, an der ein Lesefehler auftrat. MakeMKV hängt sie ans Ende
|
||||
* der Meldung, in jeder Sprache als letzte Zahl in Anführungszeichen:
|
||||
*
|
||||
* … während des Auslesens von '/BDMV/STREAM/00047.m2ts' bei Offset '56881152'
|
||||
* … while reading '/BDMV/STREAM/00047.m2ts' at offset '56881152'
|
||||
*
|
||||
* Deshalb wird die letzte gequotete Zahl gelesen und nicht das Wort davor
|
||||
* — das wäre von der MakeMKV-Sprache abhängig. '' wenn keine dasteht.
|
||||
*
|
||||
* Wozu (01.09.2026, an der Spartacus-Disc gesehen): Wiederholt sich
|
||||
* DIESELBE Stelle, ist das kein Fortschritt, sondern ein physischer
|
||||
* Defekt — MakeMKV versucht es dann stundenlang, das Laufwerk hämmert,
|
||||
* und Windows protokolliert einen fehlerhaften Block nach dem anderen. */
|
||||
export function offsetAusMeldung(text: string): string {
|
||||
// Mindestens zwei Ziffern: Titel- und Kapitelnummern stehen ebenfalls in
|
||||
// Anführungszeichen, ein Offset ist immer groß.
|
||||
const treffer = /'(\d{2,})'\s*$/.exec(text.trim())
|
||||
return treffer === null ? '' : treffer[1]
|
||||
}
|
||||
|
||||
// Meldungs-Nummern, die die URSACHE eines Fehlschlags benennen — sie sind
|
||||
// sprachunabhängig, anders als die Texte. Jede Nummer wurde am Laufwerk
|
||||
// des Commanders GESEHEN (makemkv_aufruf.py, im Wortlaut dokumentiert).
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
// Das Rettungs-Abbild (5.5.0, Commander: „korrupte Discs … selbst
|
||||
// reparieren, mit 0 füllen") — Rippys eigener Sektor-Leser, wie beim
|
||||
// ISO-Backup (iso.ts), aber mit Wiederholung in immer kleineren Stücken und
|
||||
// Nullen für das, was die Disc nicht mehr hergibt:
|
||||
//
|
||||
// 1 MiB am Stück → scheitert: 64-KiB-Stücke, je zwei Versuche →
|
||||
// scheitert: einzelne 2048-Byte-Sektoren, je zwei Versuche → Nullen.
|
||||
//
|
||||
// Das Ergebnis ist ein Abbild ohne Loch, das MakeMKV als `iso:` lesen kann
|
||||
// (Manpage makemkvcon; MakeMKV-FAQ „decrypted backup"): Ein genullter
|
||||
// Sektor ist für MakeMKV kein Lesefehler mehr, an dem es hängen bleibt —
|
||||
// nur eine Bildstörung von Sekundenbruchteilen an dieser Stelle. Was
|
||||
// wirklich unlesbar ist, holt niemand zurück; aber der Rest der Disc geht
|
||||
// nicht mehr verloren. Läuft NUR, wenn Rippy vorher Lesefehler gesehen
|
||||
// hat (Defekt-Wächter, Lesefehler-Meldung) — eine heile Disc braucht das
|
||||
// nicht (Commander 01.09.2026).
|
||||
//
|
||||
// PUR bis auf die injizierten Lese-/Schreibfunktionen: Die Ampel prüft die
|
||||
// Wiederholungs- und Nullfüll-Logik mit einem nachgebauten Gerät.
|
||||
import { open } from 'node:fs/promises'
|
||||
import { writeFileSync } from 'node:fs'
|
||||
|
||||
export const BLOCK_BYTES = 1024 * 1024
|
||||
export const FEIN_BYTES = 64 * 1024
|
||||
export const SEKTOR_BYTES = 2048
|
||||
|
||||
export interface UnlesbareStelle {
|
||||
offset: number
|
||||
bytes: number
|
||||
}
|
||||
|
||||
export type RettungsErgebnis =
|
||||
| { status: 'success'; bytes: number; unlesbareSektoren: number; stellen: UnlesbareStelle[] }
|
||||
| { status: 'cancelled'; bytes: number; unlesbareSektoren: number; stellen: UnlesbareStelle[] }
|
||||
| { status: 'error'; fehler: string; bytes: number; unlesbareSektoren: number; stellen: UnlesbareStelle[] }
|
||||
|
||||
/** Liest `laenge` Bytes ab `position` in `puffer` (ab 0) und liefert, wie
|
||||
* viele es wurden. Wirft bei einem Lesefehler. */
|
||||
export type Leser = (puffer: Buffer, laenge: number, position: number) => Promise<number>
|
||||
export type Schreiber = (puffer: Buffer, laenge: number) => Promise<void>
|
||||
|
||||
export interface RettungsOptionen {
|
||||
leser: Leser
|
||||
schreiber: Schreiber
|
||||
erwarteteBytes: number
|
||||
aufFortschritt?: (prozent: number, unlesbareSektoren: number, stellen: number) => void
|
||||
abbruch?: AbortSignal
|
||||
/** Versuche je Stück — Vorgabe 2. */
|
||||
versuche?: number
|
||||
}
|
||||
|
||||
async function lesenMitVersuchen(leser: Leser, puffer: Buffer, laenge: number, position: number, versuche: number): Promise<boolean> {
|
||||
for (let i = 0; i < versuche; i++) {
|
||||
try {
|
||||
const n = await leser(puffer, laenge, position)
|
||||
if (n === laenge) return true
|
||||
} catch {
|
||||
// Ein Fehler ist ein Versuch weniger — mehr sagt das Gerät nicht.
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Zusammenhängende unlesbare Bereiche zu Stellen zusammenfassen (pur). */
|
||||
export function stelleHinzu(stellen: UnlesbareStelle[], offset: number, bytes: number): void {
|
||||
const letzte = stellen[stellen.length - 1]
|
||||
if (letzte !== undefined && letzte.offset + letzte.bytes === offset) {
|
||||
letzte.bytes += bytes
|
||||
return
|
||||
}
|
||||
stellen.push({ offset, bytes })
|
||||
}
|
||||
|
||||
export async function rettungsAbbild(o: RettungsOptionen): Promise<RettungsErgebnis> {
|
||||
const versuche = o.versuche ?? 2
|
||||
const puffer = Buffer.alloc(BLOCK_BYTES)
|
||||
const feinPuffer = Buffer.alloc(FEIN_BYTES)
|
||||
const sektorPuffer = Buffer.alloc(SEKTOR_BYTES)
|
||||
const stellen: UnlesbareStelle[] = []
|
||||
let unlesbareSektoren = 0
|
||||
let gelesen = 0
|
||||
let letzterProzent = -1
|
||||
const melden = (): void => {
|
||||
const prozent = o.erwarteteBytes > 0 ? Math.floor((gelesen * 100) / o.erwarteteBytes) : 0
|
||||
if (prozent !== letzterProzent) {
|
||||
letzterProzent = prozent
|
||||
o.aufFortschritt?.(prozent, unlesbareSektoren, stellen.length)
|
||||
}
|
||||
}
|
||||
if (o.erwarteteBytes <= 0) {
|
||||
return { status: 'error', fehler: 'Die Größe der Disc ist unbekannt — ohne sie gibt es kein Abbild.', bytes: 0, unlesbareSektoren, stellen }
|
||||
}
|
||||
try {
|
||||
while (gelesen < o.erwarteteBytes) {
|
||||
if (o.abbruch?.aborted === true) return { status: 'cancelled', bytes: gelesen, unlesbareSektoren, stellen }
|
||||
const n = Math.min(BLOCK_BYTES, o.erwarteteBytes - gelesen)
|
||||
if (!(await lesenMitVersuchen(o.leser, puffer, n, gelesen, 1))) {
|
||||
// Der Block klemmt — feiner lesen, nur das wirklich Kaputte nullen.
|
||||
for (let teil = 0; teil < n; teil += FEIN_BYTES) {
|
||||
const m = Math.min(FEIN_BYTES, n - teil)
|
||||
if (await lesenMitVersuchen(o.leser, feinPuffer, m, gelesen + teil, versuche)) {
|
||||
feinPuffer.copy(puffer, teil, 0, m)
|
||||
continue
|
||||
}
|
||||
for (let s = 0; s < m; s += SEKTOR_BYTES) {
|
||||
const k = Math.min(SEKTOR_BYTES, m - s)
|
||||
if (await lesenMitVersuchen(o.leser, sektorPuffer, k, gelesen + teil + s, versuche)) {
|
||||
sektorPuffer.copy(puffer, teil + s, 0, k)
|
||||
} else {
|
||||
puffer.fill(0, teil + s, teil + s + k)
|
||||
unlesbareSektoren += Math.ceil(k / SEKTOR_BYTES)
|
||||
stelleHinzu(stellen, gelesen + teil + s, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
await o.schreiber(puffer, n)
|
||||
gelesen += n
|
||||
melden()
|
||||
}
|
||||
} catch (fehler) {
|
||||
return { status: 'error', fehler: `Schreiben brach bei ${gelesen} Bytes ab: ${String(fehler)}`, bytes: gelesen, unlesbareSektoren, stellen }
|
||||
}
|
||||
return { status: 'success', bytes: gelesen, unlesbareSektoren, stellen }
|
||||
}
|
||||
|
||||
/** Der Bericht neben dem Abbild — Klartext für den Commander. */
|
||||
export function rettungsBericht(quelle: string, ergebnis: RettungsErgebnis, erwarteteBytes: number): string {
|
||||
const zeilen = [
|
||||
`Rettungs-Abbild von ${quelle}`,
|
||||
`Stand: ${ergebnis.status === 'success' ? 'vollständig' : ergebnis.status === 'cancelled' ? 'abgebrochen' : 'Fehler'}`,
|
||||
`Gelesen: ${ergebnis.bytes} von ${erwarteteBytes} Bytes`,
|
||||
`Unlesbare Sektoren: ${ergebnis.unlesbareSektoren} (${((ergebnis.unlesbareSektoren * SEKTOR_BYTES * 100) / Math.max(1, erwarteteBytes)).toFixed(4)} %) an ${ergebnis.stellen.length} Stellen`,
|
||||
]
|
||||
if (ergebnis.status === 'error') zeilen.push(`Fehler: ${ergebnis.fehler}`)
|
||||
for (const s of ergebnis.stellen.slice(0, 200)) zeilen.push(` ab Byte ${s.offset}: ${s.bytes} Bytes genullt`)
|
||||
if (ergebnis.stellen.length > 200) zeilen.push(` … und ${ergebnis.stellen.length - 200} weitere Stellen`)
|
||||
return zeilen.join('\n') + '\n'
|
||||
}
|
||||
|
||||
/** Vom Gerät in eine Datei — mit Bericht daneben (<ziel>.rettung.txt). */
|
||||
export async function rettungsAbbildDatei(
|
||||
geraetepfad: string,
|
||||
zielDatei: string,
|
||||
erwarteteBytes: number,
|
||||
aufFortschritt?: (prozent: number, unlesbareSektoren: number, stellen: number) => void,
|
||||
abbruch?: AbortSignal,
|
||||
): Promise<RettungsErgebnis> {
|
||||
let quelle
|
||||
try {
|
||||
quelle = await open(geraetepfad, 'r')
|
||||
} catch (fehler) {
|
||||
return { status: 'error', fehler: `Gerät nicht lesbar: ${String(fehler)}`, bytes: 0, unlesbareSektoren: 0, stellen: [] }
|
||||
}
|
||||
let ziel
|
||||
try {
|
||||
ziel = await open(zielDatei, 'w')
|
||||
} catch (fehler) {
|
||||
await quelle.close()
|
||||
return { status: 'error', fehler: `Zieldatei nicht anlegbar: ${String(fehler)}`, bytes: 0, unlesbareSektoren: 0, stellen: [] }
|
||||
}
|
||||
const q = quelle
|
||||
const z = ziel
|
||||
let ergebnis: RettungsErgebnis
|
||||
try {
|
||||
ergebnis = await rettungsAbbild({
|
||||
leser: async (puffer, laenge, position) => (await q.read(puffer, 0, laenge, position)).bytesRead,
|
||||
schreiber: async (puffer, laenge) => {
|
||||
await z.write(puffer, 0, laenge)
|
||||
},
|
||||
erwarteteBytes,
|
||||
aufFortschritt,
|
||||
abbruch,
|
||||
})
|
||||
} finally {
|
||||
await q.close()
|
||||
await z.close()
|
||||
}
|
||||
try {
|
||||
writeFileSync(`${zielDatei}.rettung.txt`, rettungsBericht(geraetepfad, ergebnis, erwarteteBytes))
|
||||
} catch {
|
||||
// Der Bericht ist Beigabe — das Abbild zählt.
|
||||
}
|
||||
return ergebnis
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
// Welche Titel einer Disc will man wirklich? — PUR, ohne Laufwerk, damit
|
||||
// die Ampel das auf Linux prüfen kann.
|
||||
//
|
||||
// Anlass (01.09.2026, Commander): „es fehlt generell noch komplett die
|
||||
// auswahl WAS man rippen möchte. Bei Serien eben genau das die folgen,
|
||||
// bei filmen z. B. nur das hauptfeature oder auch die extras."
|
||||
//
|
||||
// Bis hierher übergab die Pipeline fest `titel: 'all'` — Rippy rippte
|
||||
// IMMER alles, inklusive Trailer, Werbung und Alterskennzeichen.
|
||||
//
|
||||
// Diese Datei RÄT NICHT INS BLAUE. Sie rechnet mit dem, was messbar ist
|
||||
// (Dauer, Größe, Kapitelzahl) und mit dem, was die Disc selbst bezeugt
|
||||
// (Folgenzahl aus dem bdmt-Inhaltsverzeichnis, siehe discmerkmale.ts).
|
||||
// Wo es nicht eindeutig ist, sagt sie das — statt sich zu entscheiden und
|
||||
// zu schweigen.
|
||||
// Die Rolle ist EINE Definition für Kern und Fenster (gemeinsam/).
|
||||
import type { TitelRolle } from '../../gemeinsam/nachrichten'
|
||||
import type { TitelInfo } from './parser'
|
||||
|
||||
export type { TitelRolle }
|
||||
|
||||
/** Kürzer als das ist kein Film und keine Folge, sondern ein Logo, eine
|
||||
* Warnung oder ein Menü-Schnipsel. Dieselbe Grenze benutzt makemkvcon
|
||||
* standardmäßig für seine eigene Mindestlänge. */
|
||||
export const KURZ_S = 120
|
||||
|
||||
/** Folgen einer Staffel sind ähnlich lang — 45 bis 55 Minuten sind eine
|
||||
* Serie, 50 Minuten und 3 Minuten sind es nicht. */
|
||||
export const AEHNLICH = 0.15
|
||||
|
||||
/** Mehrere Titel dicht am längsten heißen fast immer „Seamless Branching"
|
||||
* bzw. Kamerawinkel: dieselbe Fassung in Varianten. Dann ist die Wahl
|
||||
* nicht eindeutig, und Rippy sagt das. */
|
||||
export const FILM_GLEICHAUF = 0.05
|
||||
|
||||
/** Ein Titel, der ungefähr so lang ist wie die anderen ZUSAMMEN, ist ein
|
||||
* Sammeltitel („Alle Folgen am Stück"). Der ist der längste und würde
|
||||
* eine Film-Auswahl sonst gewinnen — und dann rippt Rippy alles doppelt. */
|
||||
export const SAMMEL_TOLERANZ = 0.1
|
||||
|
||||
export interface TitelVorschlag extends TitelInfo {
|
||||
/** Von Rippy vorausgewählt? */
|
||||
gewaehlt: boolean
|
||||
/** Als was Rippy diesen Titel einsortieren würde. */
|
||||
rolle: TitelRolle
|
||||
/** Warum — im Klartext, das Fenster zeigt es an. */
|
||||
grund: string
|
||||
}
|
||||
|
||||
export interface TitelWahl {
|
||||
titel: TitelVorschlag[]
|
||||
/** Einordnung fürs Fenster, z. B. „Serie: 2 Folgen erwartet, 2 gefunden". */
|
||||
einordnung: string
|
||||
/** Rippy ist sich NICHT sicher — das Fenster fragt dann deutlicher nach. */
|
||||
unsicher: boolean
|
||||
}
|
||||
|
||||
/** Was die Disc über sich selbst bezeugt (discmerkmale.discTitel()). */
|
||||
export interface DiscHinweis {
|
||||
istSerie: boolean
|
||||
/** Folgen laut bdmt-Inhaltsverzeichnis; 0 wenn es keines gibt. */
|
||||
folgen: number
|
||||
}
|
||||
|
||||
/** Die Titel-Struktur als Fingerabdruck (5.4.0, KONZEPT § 5: „1 langer +
|
||||
* viele kurze = Film; 6–13 gleich lange = Serienstaffel"). Bewusst
|
||||
* ZURÜCKHALTEND — 'unklar' ist die häufigste und ehrlichste Antwort:
|
||||
* * 'serie': mindestens drei ähnlich lange Titel zwischen 15 und 75 min
|
||||
* * 'film': ein Titel ab 60 min, der den zweitlängsten deutlich überragt
|
||||
* Zwei gleich lange Fassungen (Seamless Branching) bleiben 'unklar', eine
|
||||
* Zwei-Folgen-Disc auch (dafür ist das bdmt-Inhaltsverzeichnis da). */
|
||||
export function strukturHinweis(alle: readonly TitelInfo[]): 'film' | 'serie' | 'unklar' {
|
||||
const lang = alle.filter((t) => t.dauerS >= KURZ_S).sort((a, b) => b.dauerS - a.dauerS)
|
||||
if (lang.length === 0) return 'unklar'
|
||||
const laengster = lang[0].dauerS
|
||||
const aehnlich = lang.filter((t) => Math.abs(t.dauerS - laengster) / laengster <= AEHNLICH)
|
||||
if (aehnlich.length >= 3 && laengster >= 15 * 60 && laengster <= 75 * 60) return 'serie'
|
||||
const zweiter = lang[1]?.dauerS ?? 0
|
||||
if (laengster >= 60 * 60 && zweiter < laengster * 0.6) return 'film'
|
||||
return 'unklar'
|
||||
}
|
||||
|
||||
function dauerText(s: number): string {
|
||||
const m = Math.round(s / 60)
|
||||
return m >= 60 ? `${Math.floor(m / 60)} h ${String(m % 60).padStart(2, '0')} min` : `${m} min`
|
||||
}
|
||||
|
||||
/** Ist dieser Titel ungefähr so lang wie die kürzeren zusammen? */
|
||||
function istSammeltitel(kandidat: TitelInfo, alle: readonly TitelInfo[]): boolean {
|
||||
const kuerzere = alle.filter((t) => t.nr !== kandidat.nr && t.dauerS >= KURZ_S && t.dauerS < kandidat.dauerS)
|
||||
// Mit weniger als zwei Teilen gibt es nichts zu sammeln.
|
||||
if (kuerzere.length < 2) return false
|
||||
const summe = kuerzere.reduce((s, t) => s + t.dauerS, 0)
|
||||
if (summe <= 0) return false
|
||||
return Math.abs(kandidat.dauerS - summe) / kandidat.dauerS <= SAMMEL_TOLERANZ
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Vorauswahl. Sie wählt VOR — entschieden wird im Fenster.
|
||||
*
|
||||
* Drei Fälle:
|
||||
* * Serie (von der Disc bezeugt): die längsten Titel, so viele wie das
|
||||
* Inhaltsverzeichnis Folgen nennt.
|
||||
* * Film: der längste Titel, der kein Sammeltitel ist.
|
||||
* * Unklar: nichts vorausgewählt — lieber fragen als raten.
|
||||
*/
|
||||
export function titelVorauswahl(alle: readonly TitelInfo[], hinweis: DiscHinweis): TitelWahl {
|
||||
if (alle.length === 0) {
|
||||
return { titel: [], einordnung: 'Keine Titel gefunden — die Disc gab nichts her.', unsicher: true }
|
||||
}
|
||||
|
||||
const sammel = new Set(alle.filter((t) => istSammeltitel(t, alle)).map((t) => t.nr))
|
||||
const kandidaten = alle
|
||||
.filter((t) => t.dauerS >= KURZ_S && !sammel.has(t.nr))
|
||||
.sort((a, b) => b.dauerS - a.dauerS)
|
||||
|
||||
const gewaehlt = new Set<number>()
|
||||
let einordnung: string
|
||||
let unsicher = false
|
||||
|
||||
if (kandidaten.length === 0) {
|
||||
einordnung = `Kein Titel über ${Math.round(KURZ_S / 60)} Minuten — hier ist nichts, was nach Hauptinhalt aussieht.`
|
||||
unsicher = true
|
||||
} else if (hinweis.istSerie && hinweis.folgen >= 2) {
|
||||
const folgen = kandidaten.slice(0, hinweis.folgen)
|
||||
for (const t of folgen) gewaehlt.add(t.nr)
|
||||
const laengste = folgen[0].dauerS
|
||||
const passen = folgen.every((t) => Math.abs(t.dauerS - laengste) / laengste <= AEHNLICH)
|
||||
if (folgen.length < hinweis.folgen) {
|
||||
einordnung = `Serie: ${hinweis.folgen} Folgen erwartet, nur ${folgen.length} gefunden — bitte nachsehen.`
|
||||
unsicher = true
|
||||
} else if (!passen) {
|
||||
einordnung = `Serie: ${hinweis.folgen} Folgen erwartet, aber die gefundenen sind unterschiedlich lang — bitte nachsehen.`
|
||||
unsicher = true
|
||||
} else {
|
||||
einordnung = `Serie: ${hinweis.folgen} Folgen erwartet, ${folgen.length} gefunden — passt.`
|
||||
}
|
||||
} else {
|
||||
const laengster = kandidaten[0]
|
||||
const gleichauf = kandidaten.filter(
|
||||
(t) => Math.abs(t.dauerS - laengster.dauerS) / laengster.dauerS <= FILM_GLEICHAUF,
|
||||
)
|
||||
if (gleichauf.length > 1) {
|
||||
// Mehrere Fassungen derselben Länge: die mit den meisten Kapiteln ist
|
||||
// erfahrungsgemäß die Hauptfassung — sicher ist das nicht.
|
||||
const beste = [...gleichauf].sort((a, b) => b.kapitel - a.kapitel || a.nr - b.nr)[0]
|
||||
gewaehlt.add(beste.nr)
|
||||
einordnung = `${gleichauf.length} Titel sind fast gleich lang (${dauerText(laengster.dauerS)}) — Rippy hat den mit den meisten Kapiteln gewählt. Bitte nachsehen.`
|
||||
unsicher = true
|
||||
} else {
|
||||
gewaehlt.add(laengster.nr)
|
||||
einordnung = `Film: ein Hauptinhalt von ${dauerText(laengster.dauerS)} gefunden.`
|
||||
}
|
||||
}
|
||||
|
||||
const alsSerie = hinweis.istSerie && hinweis.folgen >= 2
|
||||
const titel: TitelVorschlag[] = alle.map((t) => ({
|
||||
...t,
|
||||
gewaehlt: gewaehlt.has(t.nr),
|
||||
// Gewählt UND Serie heißt Folge; gewählt und Film heißt Hauptfilm.
|
||||
// Alles andere ist eine Zugabe — wer sie mit anhakt, bekommt sie als
|
||||
// Extra abgelegt, nicht als zweiten Hauptfilm.
|
||||
rolle: gewaehlt.has(t.nr) ? (alsSerie ? 'folge' : 'hauptfilm') : 'extra',
|
||||
grund: gewaehlt.has(t.nr)
|
||||
? alsSerie
|
||||
? 'Folge'
|
||||
: 'Hauptinhalt'
|
||||
: sammel.has(t.nr)
|
||||
? 'Sammeltitel — enthält vermutlich alles am Stück'
|
||||
: t.dauerS < KURZ_S
|
||||
? 'zu kurz — Logo, Warnung oder Menü'
|
||||
: 'Zugabe',
|
||||
}))
|
||||
|
||||
return { titel, einordnung, unsicher }
|
||||
}
|
||||
@@ -1,18 +1,53 @@
|
||||
// Der EINZIGE Ort, der die Datenbank anfasst — § 6.7: EINE Quelle für
|
||||
// Einstellungen. Ein Wächter-Test (test/waechter.test.ts) prüft mechanisch,
|
||||
// dass 'node:sqlite' nirgendwo sonst importiert wird. Der Fund hinter der
|
||||
// Regel: In rc11 schrieb die Oberfläche in die Datenbank, der Betrieb las aus
|
||||
// einer Konfigurationsdatei, die niemand schrieb.
|
||||
// Regel: In rc11 schrieb die Oberfläche in die Datenbank, der Betrieb las
|
||||
// aus einer Konfigurationsdatei, die niemand schrieb.
|
||||
//
|
||||
// node:sqlite statt better-sqlite3: am 30.08.2026 in Electron 44 im
|
||||
// utilityProcess gemessen (KONZEPT-WINDOWS.md § 3.4, beweise/sqlite-*.js) —
|
||||
// keine native Kompilierung, kein node-gyp in der Bau-Kette.
|
||||
//
|
||||
// Schema 4 (5.4.0): die Tabelle `vorgaenge`. Bis dahin wusste Rippy nach
|
||||
// einem Rip NICHT mehr, welcher Roh-Ordner zu welchem Film gehört — und
|
||||
// genau deshalb konnte niemand aufräumen (jede Blu-ray ließ 30+ GB liegen),
|
||||
// niemand „ab Kompression wiederholen" und niemand nach einem Abbruch
|
||||
// nachsehen, was halb fertig herumlag. Der Vorgang ist jetzt ein Datensatz.
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { dirname } from 'node:path'
|
||||
import type { BibliothekEintrag } from '../../gemeinsam/nachrichten'
|
||||
import type { BibliothekEintrag, VorgangPhase } from '../../gemeinsam/nachrichten'
|
||||
|
||||
export const SCHEMA_VERSION = 3
|
||||
export const SCHEMA_VERSION = 4
|
||||
|
||||
/** Ein Vorgang, wie die Datenbank ihn kennt (§ 6.7 / 5.4.0). */
|
||||
export interface Vorgang {
|
||||
/** Der Vorgangsname — zugleich der Name des Roh-Ordners. */
|
||||
id: string
|
||||
laufwerk: string
|
||||
typ: string
|
||||
titel: string
|
||||
jahr: number | null
|
||||
phase: VorgangPhase
|
||||
begonnen: string
|
||||
beendet: string | null
|
||||
rohOrdner: string
|
||||
rohBytes: number
|
||||
rohGeloeschtAm: string | null
|
||||
zielOrdner: string
|
||||
fertigBytes: number
|
||||
/** 0 = die Roh-Dateien SIND das Ergebnis (verschoben, nicht komprimiert). */
|
||||
komprimiert: boolean
|
||||
preset: string
|
||||
fehler: string
|
||||
/** Der Kompressions-Auftrag als JSON — Grundlage für „ab Kompression
|
||||
* wiederholen" (kern/ablauf/auftrag.ts). */
|
||||
auftrag: string
|
||||
lesefehler: boolean
|
||||
/** Die fertigen Dateien als JSON-Liste — das Aufräumen prüft, dass sie
|
||||
* wirklich da sind, BEVOR es den Roh-Ordner löscht. */
|
||||
fertigeDateien: string
|
||||
}
|
||||
|
||||
export class Datenbank {
|
||||
readonly pfad: string
|
||||
@@ -48,6 +83,27 @@ export class Datenbank {
|
||||
datum TEXT NOT NULL,
|
||||
dauer_s INTEGER NOT NULL,
|
||||
poster_pfad TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS vorgaenge (
|
||||
id TEXT PRIMARY KEY,
|
||||
laufwerk TEXT NOT NULL,
|
||||
typ TEXT NOT NULL,
|
||||
titel TEXT NOT NULL,
|
||||
jahr INTEGER,
|
||||
phase TEXT NOT NULL,
|
||||
begonnen TEXT NOT NULL,
|
||||
beendet TEXT,
|
||||
roh_ordner TEXT NOT NULL,
|
||||
roh_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
roh_geloescht_am TEXT,
|
||||
ziel_ordner TEXT NOT NULL DEFAULT '',
|
||||
fertig_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
komprimiert INTEGER NOT NULL DEFAULT 0,
|
||||
preset TEXT NOT NULL DEFAULT '',
|
||||
fehler TEXT NOT NULL DEFAULT '',
|
||||
auftrag TEXT NOT NULL DEFAULT '{}',
|
||||
lesefehler INTEGER NOT NULL DEFAULT 0,
|
||||
fertige_dateien TEXT NOT NULL DEFAULT '[]'
|
||||
);`,
|
||||
)
|
||||
// Schema 2 → 3: Bestands-Datenbanken haben die Bibliothek noch ohne
|
||||
@@ -129,7 +185,91 @@ export class Datenbank {
|
||||
return zeile === undefined ? null : zeile.datum
|
||||
}
|
||||
|
||||
// ── Vorgänge (5.4.0): welcher Roh-Ordner gehört zu welchem Rip ──────────
|
||||
vorgangAnlegen(v: Vorgang): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT OR REPLACE INTO vorgaenge
|
||||
(id, laufwerk, typ, titel, jahr, phase, begonnen, beendet, roh_ordner, roh_bytes,
|
||||
roh_geloescht_am, ziel_ordner, fertig_bytes, komprimiert, preset, fehler, auftrag,
|
||||
lesefehler, fertige_dateien)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
v.id,
|
||||
v.laufwerk,
|
||||
v.typ,
|
||||
v.titel,
|
||||
v.jahr,
|
||||
v.phase,
|
||||
v.begonnen,
|
||||
v.beendet,
|
||||
v.rohOrdner,
|
||||
v.rohBytes,
|
||||
v.rohGeloeschtAm,
|
||||
v.zielOrdner,
|
||||
v.fertigBytes,
|
||||
v.komprimiert ? 1 : 0,
|
||||
v.preset,
|
||||
v.fehler,
|
||||
v.auftrag,
|
||||
v.lesefehler ? 1 : 0,
|
||||
v.fertigeDateien,
|
||||
)
|
||||
}
|
||||
|
||||
/** Ändert nur die genannten Felder — der Rest bleibt, wie er war. */
|
||||
vorgangAktualisieren(id: string, teil: Partial<Omit<Vorgang, 'id'>>): void {
|
||||
const alt = this.vorgangLesen(id)
|
||||
if (alt === null) return
|
||||
this.vorgangAnlegen({ ...alt, ...teil, id })
|
||||
}
|
||||
|
||||
vorgangLesen(id: string): Vorgang | null {
|
||||
const zeile = this.db.prepare(`SELECT * FROM vorgaenge WHERE id = ?`).get(id) as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
return zeile === undefined ? null : vorgangAusZeile(zeile)
|
||||
}
|
||||
|
||||
/** Alle Vorgänge, neueste zuerst. */
|
||||
vorgangListe(): Vorgang[] {
|
||||
const zeilen = this.db
|
||||
.prepare(`SELECT * FROM vorgaenge ORDER BY begonnen DESC`)
|
||||
.all() as Array<Record<string, unknown>>
|
||||
return zeilen.map(vorgangAusZeile)
|
||||
}
|
||||
|
||||
vorgangLoeschen(id: string): void {
|
||||
this.db.prepare(`DELETE FROM vorgaenge WHERE id = ?`).run(id)
|
||||
}
|
||||
|
||||
schliessen(): void {
|
||||
this.db.close()
|
||||
}
|
||||
}
|
||||
|
||||
function vorgangAusZeile(z: Record<string, unknown>): Vorgang {
|
||||
return {
|
||||
id: String(z.id),
|
||||
laufwerk: String(z.laufwerk),
|
||||
typ: String(z.typ),
|
||||
titel: String(z.titel),
|
||||
jahr: z.jahr === null || z.jahr === undefined ? null : Number(z.jahr),
|
||||
phase: String(z.phase) as VorgangPhase,
|
||||
begonnen: String(z.begonnen),
|
||||
beendet: z.beendet === null || z.beendet === undefined ? null : String(z.beendet),
|
||||
rohOrdner: String(z.roh_ordner),
|
||||
rohBytes: Number(z.roh_bytes ?? 0),
|
||||
rohGeloeschtAm:
|
||||
z.roh_geloescht_am === null || z.roh_geloescht_am === undefined ? null : String(z.roh_geloescht_am),
|
||||
zielOrdner: String(z.ziel_ordner ?? ''),
|
||||
fertigBytes: Number(z.fertig_bytes ?? 0),
|
||||
komprimiert: Number(z.komprimiert ?? 0) === 1,
|
||||
preset: String(z.preset ?? ''),
|
||||
fehler: String(z.fehler ?? ''),
|
||||
auftrag: String(z.auftrag ?? '{}'),
|
||||
lesefehler: Number(z.lesefehler ?? 0) === 1,
|
||||
fertigeDateien: String(z.fertige_dateien ?? '[]'),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// EINE Stelle, an der Werkzeuge gestartet werden (5.4.0) — mit einer Naht
|
||||
// für den Klick-Beweis (bau/klick-smoke.mjs): Zeigt der eingestellte
|
||||
// Werkzeug-Pfad auf ein Node-Skript (.cjs/.js), läuft es über die eigene
|
||||
// Node-Laufzeit (ELECTRON_RUN_AS_NODE, Electron-Doku »Environment
|
||||
// Variables«) statt als EXE. So klickt der Beweis durch das ECHTE Programm
|
||||
// — Fenster, Kern, Datenbank, Warteschlange — nur makemkvcon und HandBrake
|
||||
// sind nachgebaut. Für echte EXE-Pfade ändert sich nichts.
|
||||
//
|
||||
// Warum nicht einfach eine .cmd: Node weigert sich seit 18.20/20.12,
|
||||
// .bat/.cmd ohne Shell zu starten (CVE-2024-27980) — und mit Shell wären
|
||||
// Pfade mit Leerzeichen wieder eine Falle.
|
||||
import { spawn } from 'node:child_process'
|
||||
|
||||
export function istSkript(programm: string): boolean {
|
||||
const p = programm.toLowerCase()
|
||||
return p.endsWith('.cjs') || p.endsWith('.mjs') || p.endsWith('.js')
|
||||
}
|
||||
|
||||
/** Programm + Argumente, so wie spawn sie bekommen soll. */
|
||||
export function werkzeugStart(
|
||||
programm: string,
|
||||
args: readonly string[],
|
||||
umgebung: NodeJS.ProcessEnv = process.env,
|
||||
): { programm: string; args: string[]; env: NodeJS.ProcessEnv } {
|
||||
if (!istSkript(programm)) return { programm, args: [...args], env: umgebung }
|
||||
return {
|
||||
programm: process.execPath,
|
||||
args: [programm, ...args],
|
||||
env: { ...umgebung, ELECTRON_RUN_AS_NODE: '1' },
|
||||
}
|
||||
}
|
||||
|
||||
/** spawn mit der Naht — dieselbe Signatur, dieselben Typen. */
|
||||
export const werkzeugSpawn: typeof spawn = ((programm: string, args: readonly string[], optionen: Record<string, unknown>) => {
|
||||
const start = werkzeugStart(programm, args)
|
||||
return spawn(start.programm, start.args, { ...optionen, env: start.env })
|
||||
}) as typeof spawn
|
||||
@@ -0,0 +1,78 @@
|
||||
// MakeMKVs Spur-Auswahlregel (5.4.0) — damit ALLE Sprachen in der
|
||||
// Roh-Datei landen, die der Auswahl-Dialog anbietet.
|
||||
//
|
||||
// Der Befund: Der Info-Lauf zeigt jede Tonspur der Disc — aber ob sie in
|
||||
// die MKV kommt, entscheidet MakeMKVs „default selection rule". Die Doku
|
||||
// (forum.makemkv.com, Thema 4386, am 01.09.2026 nachgelesen — Regel D)
|
||||
// nennt als Regel des Standard-Profils
|
||||
//
|
||||
// -sel:all,+sel:(favlang|nolang),-sel:(havemulti|havecore),=100:all,-10:favlang
|
||||
//
|
||||
// mit: favlang = „matches favorite languages, always matches if no
|
||||
// favorite language is set" · havemulti = „track is mono/stereo and there
|
||||
// is a multi-channel track in same language" · havecore = „this track is
|
||||
// hd track with core audio" · +sel/-sel = wählen/abwählen · =N = Gewicht.
|
||||
//
|
||||
// Solange KEINE Vorzugssprache gesetzt ist, nimmt MakeMKV also alles
|
||||
// (bis auf die Stereo-Doppel und die DTS-Kerne). Sobald jemand in MakeMKVs
|
||||
// Einstellungen „Deutsch" wählt, fielen japanischer und englischer Ton
|
||||
// STILL weg — und der Dialog böte Sprachen an, die nicht in der Datei
|
||||
// sind. Rippy setzt deshalb seine eigene Regel, die sprachunabhängig
|
||||
// alles behält, und zwar NUR, wenn noch keine gesetzt ist (wer eine
|
||||
// eigene hat, behält sie — wie bei app_UpdateEnable in schluessel.ts).
|
||||
// Abgelegt wird wie der Schlüssel in HKCU\Software\MakeMKV (Windows liest
|
||||
// dort, die settings.conf ist der Linux-Weg).
|
||||
import { spawnSync } from 'node:child_process'
|
||||
|
||||
export const REGISTRY_PFAD = 'HKCU\\Software\\MakeMKV'
|
||||
export const REGEL_NAME = 'app_DefaultSelectionString'
|
||||
/** Alles behalten, Doppel und Kerne weg — sprachunabhängig. */
|
||||
export const RIPPY_REGEL = '-sel:all,+sel:all,-sel:(havemulti|havecore),=100:all'
|
||||
|
||||
export interface SpurauswahlStand {
|
||||
/** 'gesetzt' (von Rippy, jetzt) | 'eigen' (der Nutzer hat eine eigene) |
|
||||
* 'rippy' (Rippys Regel steht schon) | 'fehler' */
|
||||
stand: 'gesetzt' | 'eigen' | 'rippy' | 'fehler'
|
||||
regel: string
|
||||
text: string
|
||||
}
|
||||
|
||||
function reg(args: string[]): { ok: boolean; ausgabe: string } {
|
||||
const ergebnis = spawnSync('reg', args, { windowsHide: true, timeout: 15_000, encoding: 'utf8' })
|
||||
return { ok: ergebnis.status === 0, ausgabe: (ergebnis.stdout ?? '') + (ergebnis.stderr ?? '') }
|
||||
}
|
||||
|
||||
/** Den Wert aus `reg query`-Ausgabe lesen (pur, testbar):
|
||||
* „ app_DefaultSelectionString REG_SZ -sel:all,…" */
|
||||
export function regelAusQuery(ausgabe: string): string | null {
|
||||
const treffer = new RegExp(`${REGEL_NAME}\\s+REG_SZ\\s+(.*)$`, 'm').exec(ausgabe)
|
||||
return treffer === null ? null : treffer[1].trim()
|
||||
}
|
||||
|
||||
/** Sicherstellen, dass eine Regel steht, die alle Sprachen behält. */
|
||||
export function spurauswahlSicherstellen(): SpurauswahlStand {
|
||||
if (process.platform !== 'win32') {
|
||||
return { stand: 'fehler', regel: '', text: 'Nur unter Windows (Registry).' }
|
||||
}
|
||||
const vorhanden = reg(['query', REGISTRY_PFAD, '/v', REGEL_NAME])
|
||||
const bisher = vorhanden.ok ? regelAusQuery(vorhanden.ausgabe) : null
|
||||
if (bisher !== null && bisher.length > 0) {
|
||||
if (bisher === RIPPY_REGEL) {
|
||||
return { stand: 'rippy', regel: bisher, text: 'MakeMKV behält alle Sprachen (Rippys Auswahlregel steht).' }
|
||||
}
|
||||
return {
|
||||
stand: 'eigen',
|
||||
regel: bisher,
|
||||
text: `MakeMKV hat eine eigene Auswahlregel („${bisher}") — Rippy lässt sie stehen. Fehlen Sprachen in der Roh-Datei, liegt es daran.`,
|
||||
}
|
||||
}
|
||||
const gesetzt = reg(['add', REGISTRY_PFAD, '/v', REGEL_NAME, '/t', 'REG_SZ', '/d', RIPPY_REGEL, '/f'])
|
||||
if (!gesetzt.ok) {
|
||||
return { stand: 'fehler', regel: '', text: `Auswahlregel nicht setzbar: ${gesetzt.ausgabe.trim().slice(0, 160)}` }
|
||||
}
|
||||
return {
|
||||
stand: 'gesetzt',
|
||||
regel: RIPPY_REGEL,
|
||||
text: 'MakeMKV-Auswahlregel gesetzt: alle Sprachen kommen in die Roh-Datei (Stereo-Doppel und DTS-Kerne nicht).',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
// Tests von Kompression + Ablage (kern/ablauf/ablegen.ts, 5.4.0) — mit
|
||||
// nachgebildetem HandBrake, das die Ausgabedatei wirklich SCHREIBT (die
|
||||
// Serien-Umbenennung braucht sie). Die Regeln: Kompression nur wenn
|
||||
// gewollt UND HandBrake da, sonst VERSCHIEBEN; ein Kompressions-Fehler
|
||||
// lässt den verlustfreien Rip STEHEN und sagt wo er liegt; Sprachen je
|
||||
// Datei erreichen HandBrake; Folgen von Hand schlagen die Laufzeit-Raterei.
|
||||
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { KompressionsAuftrag, RohDatei } from '../src/kern/ablauf/auftrag'
|
||||
import type { Zuordnung } from '../src/kern/metadaten/zuordnung'
|
||||
import { LEERER_TREFFER } from '../src/kern/metadaten/zuordnung'
|
||||
|
||||
const hb = vi.hoisted(() => ({
|
||||
aufrufe: [] as Array<Record<string, unknown>>,
|
||||
ergebnis: 'success' as 'success' | 'error' | 'cancelled',
|
||||
}))
|
||||
|
||||
vi.mock('../src/kern/komprimieren/handbrake', async () => {
|
||||
const fs = await import('node:fs')
|
||||
return {
|
||||
komprimieren: vi.fn(async (o: { ausgabe: string; aufFortschritt?: (p: number) => void }) => {
|
||||
hb.aufrufe.push({ ...o })
|
||||
o.aufFortschritt?.(50)
|
||||
if (hb.ergebnis === 'cancelled') return { status: 'cancelled' }
|
||||
if (hb.ergebnis === 'error') return { status: 'error', fehler: 'HandBrake endete mit Code 3' }
|
||||
fs.writeFileSync(o.ausgabe, 'encoded')
|
||||
return { status: 'success', ausgabe: o.ausgabe }
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
import { komprimierenUndAblegen, type AblageUmgebung } from '../src/kern/ablauf/ablegen'
|
||||
|
||||
let wegwerf: string[] = []
|
||||
afterEach(() => {
|
||||
hb.aufrufe = []
|
||||
hb.ergebnis = 'success'
|
||||
for (const ordner of wegwerf.splice(0)) rmSync(ordner, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function zuordnung(typ: 'movie' | 'tv', titel: string, jahr: number | null): Zuordnung {
|
||||
return {
|
||||
titel,
|
||||
jahr,
|
||||
confidence: 0.95,
|
||||
meta: { ...LEERER_TREFFER, typ, id: 149, titel, jahr, quelle: 'tmdb', laufzeitMinuten: 124 },
|
||||
}
|
||||
}
|
||||
|
||||
function aufbau(dateinamen: string[], rollen: Array<RohDatei['rolle']> = []): {
|
||||
ablage: string
|
||||
roh: string
|
||||
dateien: RohDatei[]
|
||||
} {
|
||||
const ablage = mkdtempSync(join(tmpdir(), 'rippy-ablegen-'))
|
||||
wegwerf.push(ablage)
|
||||
const roh = join(ablage, 'roh', 'bluray-2026-08-30T12-00-00')
|
||||
mkdirSync(roh, { recursive: true })
|
||||
const dateien = dateinamen.map((name, i) => {
|
||||
const pfad = join(roh, name)
|
||||
writeFileSync(pfad, 'roh'.repeat(10))
|
||||
return {
|
||||
pfad,
|
||||
titelNr: i,
|
||||
rolle: rollen[i] ?? 'hauptfilm',
|
||||
dauerS: 3167,
|
||||
groesseBytes: 30,
|
||||
audioSprachen: [],
|
||||
untertitelSprachen: [],
|
||||
}
|
||||
})
|
||||
return { ablage, roh, dateien }
|
||||
}
|
||||
|
||||
function auftrag(teil: Partial<KompressionsAuftrag> & { rohOrdner: string; dateien: RohDatei[] }): KompressionsAuftrag {
|
||||
return {
|
||||
vorgangId: 'bluray-2026-08-30T12-00-00',
|
||||
laufwerk: 'G',
|
||||
discTyp: 'bluray',
|
||||
zuordnung: null,
|
||||
staffel: null,
|
||||
ersteFolge: null,
|
||||
discNr: null,
|
||||
fingerprint: 'LABEL|33',
|
||||
lesefehler: false,
|
||||
hinweise: [],
|
||||
begonnen: '2026-08-30T12:00:00Z',
|
||||
preset: '',
|
||||
zielOrdner: '',
|
||||
...teil,
|
||||
}
|
||||
}
|
||||
|
||||
function umgebung(ablage: string, überschreiben: Partial<AblageUmgebung> = {}): AblageUmgebung {
|
||||
return {
|
||||
handbrake: () => 'C:\\hb\\HandBrakeCLI.exe',
|
||||
ablage: () => ablage,
|
||||
einstellungen: () => ({ audioSprachen: 'deu,eng', untertitelSprachen: 'deu' }),
|
||||
jetzt: () => new Date('2026-08-30T13:00:00Z'),
|
||||
spurenScannen: async () => null,
|
||||
...überschreiben,
|
||||
}
|
||||
}
|
||||
|
||||
const KEIN_ABBRUCH = new AbortController().signal
|
||||
|
||||
describe('komprimierenUndAblegen', () => {
|
||||
it('komprimiert in Filme/<Titel (Jahr)> mit NFO und meldet die Phasen', async () => {
|
||||
const { ablage, roh, dateien } = aufbau(['title_t00.mkv'])
|
||||
const bibliothek: string[] = []
|
||||
const phasen: string[] = []
|
||||
const ergebnis = await komprimierenUndAblegen(
|
||||
auftrag({ rohOrdner: roh, dateien, zuordnung: zuordnung('movie', 'Akira', 1988) }),
|
||||
umgebung(ablage, { bibliothekEintragen: (e) => bibliothek.push(e.titel) }),
|
||||
(s) => phasen.push(s.phase),
|
||||
KEIN_ABBRUCH,
|
||||
)
|
||||
expect(ergebnis.status).toBe('fertig')
|
||||
expect(ergebnis.komprimiert).toBe(true)
|
||||
expect(ergebnis.zielOrdner).toBe(join(ablage, 'Filme', 'Akira (1988)'))
|
||||
expect(existsSync(join(ergebnis.zielOrdner, 'movie.nfo'))).toBe(true)
|
||||
expect(ergebnis.dateien).toEqual([join(ergebnis.zielOrdner, 'Akira (1988).mkv')])
|
||||
expect(phasen).toContain('komprimiert')
|
||||
expect(phasen.at(-1)).toBe('legt-ab')
|
||||
expect(bibliothek).toEqual(['Akira'])
|
||||
expect(ergebnis.text).toContain('komprimiert nach')
|
||||
})
|
||||
|
||||
it('Kompression abgewählt: Roh-Dateien werden VERSCHOBEN, mit NFO — der Roh-Ordner bleibt leer zurück', async () => {
|
||||
const { ablage, roh, dateien } = aufbau(['title_t00.mkv'])
|
||||
const ergebnis = await komprimierenUndAblegen(
|
||||
auftrag({ rohOrdner: roh, dateien, discTyp: 'uhd', zuordnung: zuordnung('movie', 'Akira', 1988) }),
|
||||
umgebung(ablage, { einstellungen: () => ({ transcodePresetUhd: 'keine' }) }),
|
||||
() => {},
|
||||
KEIN_ABBRUCH,
|
||||
)
|
||||
expect(ergebnis.status).toBe('fertig')
|
||||
expect(ergebnis.komprimiert).toBe(false)
|
||||
expect(ergebnis.text).toContain('ohne Kompression')
|
||||
expect(existsSync(join(ergebnis.zielOrdner, 'Akira (1988).mkv'))).toBe(true)
|
||||
expect(existsSync(join(ergebnis.zielOrdner, 'movie.nfo'))).toBe(true)
|
||||
expect(readdirSync(roh)).toEqual([])
|
||||
expect(hb.aufrufe).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('ohne HandBrake: ebenfalls verschieben, nicht liegen lassen', async () => {
|
||||
const { ablage, roh, dateien } = aufbau(['title_t00.mkv'])
|
||||
const ergebnis = await komprimierenUndAblegen(
|
||||
auftrag({ rohOrdner: roh, dateien }),
|
||||
umgebung(ablage, { handbrake: () => '' }),
|
||||
() => {},
|
||||
KEIN_ABBRUCH,
|
||||
)
|
||||
expect(ergebnis.komprimiert).toBe(false)
|
||||
expect(readdirSync(roh)).toEqual([])
|
||||
expect(ergebnis.zielOrdner).toBe(join(ablage, 'Filme', 'bluray-2026-08-30T12-00-00'))
|
||||
})
|
||||
|
||||
it('Kompressions-Fehler: Roh bleibt stehen und wird GENANNT', async () => {
|
||||
hb.ergebnis = 'error'
|
||||
const { ablage, roh, dateien } = aufbau(['title_t00.mkv'])
|
||||
const ergebnis = await komprimierenUndAblegen(auftrag({ rohOrdner: roh, dateien }), umgebung(ablage), () => {}, KEIN_ABBRUCH)
|
||||
expect(ergebnis.status).toBe('fehler')
|
||||
expect(ergebnis.text).toContain('bleibt stehen')
|
||||
expect(ergebnis.text).toContain(roh)
|
||||
expect(existsSync(dateien[0].pfad)).toBe(true)
|
||||
})
|
||||
|
||||
it('Abbruch mittendrin: abgebrochen, Roh bleibt', async () => {
|
||||
hb.ergebnis = 'cancelled'
|
||||
const { ablage, roh, dateien } = aufbau(['title_t00.mkv'])
|
||||
const ergebnis = await komprimierenUndAblegen(auftrag({ rohOrdner: roh, dateien }), umgebung(ablage), () => {}, KEIN_ABBRUCH)
|
||||
expect(ergebnis.status).toBe('abgebrochen')
|
||||
expect(existsSync(dateien[0].pfad)).toBe(true)
|
||||
})
|
||||
|
||||
it('Sprachen je Datei erreichen HandBrake — fremder Ton schaltet die deutschen Untertitel vor', async () => {
|
||||
const { ablage, roh, dateien } = aufbau(['title_t00.mkv', 'title_t01.mkv'])
|
||||
dateien[0].audioSprachen = ['jpn']
|
||||
dateien[0].untertitelSprachen = ['eng', 'deu']
|
||||
const ergebnis = await komprimierenUndAblegen(auftrag({ rohOrdner: roh, dateien }), umgebung(ablage), () => {}, KEIN_ABBRUCH)
|
||||
expect(ergebnis.status).toBe('fertig')
|
||||
expect(hb.aufrufe).toHaveLength(2)
|
||||
// Datei 0: eigene Wahl — deu nach vorn, Vorwahl an.
|
||||
expect(hb.aufrufe[0].audioSprachen).toEqual(['jpn'])
|
||||
expect(hb.aufrufe[0].untertitelSprachen).toEqual(['deu', 'eng'])
|
||||
expect(hb.aufrufe[0].untertitelStandard).toBe(true)
|
||||
// Datei 1: nichts gewählt — die globale Einstellung, keine Vorwahl.
|
||||
expect(hb.aufrufe[1].audioSprachen).toEqual(['deu', 'eng'])
|
||||
expect(hb.aufrufe[1].untertitelSprachen).toEqual(['deu'])
|
||||
expect(hb.aufrufe[1].untertitelStandard).toBe(false)
|
||||
})
|
||||
|
||||
it('der Scan der Roh-Datei meldet fehlende Sprachen als Hinweis', async () => {
|
||||
const { ablage, roh, dateien } = aufbau(['title_t00.mkv'])
|
||||
dateien[0].audioSprachen = ['jpn']
|
||||
const ergebnis = await komprimierenUndAblegen(
|
||||
auftrag({ rohOrdner: roh, dateien }),
|
||||
umgebung(ablage, {
|
||||
spurenScannen: async () => ({ audio: [{ nr: 1, lang: 'deu', beschreibung: 'Deutsch' }], untertitel: [], dauerS: 3167 }),
|
||||
}),
|
||||
() => {},
|
||||
KEIN_ABBRUCH,
|
||||
)
|
||||
expect(ergebnis.hinweise.join(' ')).toContain('Tonspur jpn ist NICHT in der Roh-Datei')
|
||||
})
|
||||
|
||||
it('Serie von Hand: Staffel 1, ab Folge 3 → S01E03 und S01E04, Warnung bei TMDb-Abweichung', async () => {
|
||||
const { ablage, roh, dateien } = aufbau(['title_t00.mkv', 'title_t01.mkv'], ['folge', 'folge'])
|
||||
dateien[1].dauerS = 3249
|
||||
const ergebnis = await komprimierenUndAblegen(
|
||||
auftrag({
|
||||
rohOrdner: roh,
|
||||
dateien,
|
||||
zuordnung: zuordnung('tv', 'Spartacus', 2010),
|
||||
staffel: 1,
|
||||
ersteFolge: 3,
|
||||
}),
|
||||
umgebung(ablage, {
|
||||
// Folge 4 ist bei TMDb 20 min kürzer — das gibt eine Warnung, aber
|
||||
// die Hand des Nutzers gilt.
|
||||
episodenLaufzeiten: async () => [
|
||||
[3, 3200],
|
||||
[4, 2000],
|
||||
],
|
||||
}),
|
||||
() => {},
|
||||
KEIN_ABBRUCH,
|
||||
)
|
||||
expect(ergebnis.status).toBe('fertig')
|
||||
expect(ergebnis.zielOrdner).toBe(join(ablage, 'Serien', 'Spartacus', 'Season 01'))
|
||||
expect(readdirSync(ergebnis.zielOrdner).filter((n) => n.endsWith('.mkv')).sort()).toEqual([
|
||||
'Spartacus S01E03.mkv',
|
||||
'Spartacus S01E04.mkv',
|
||||
])
|
||||
expect(ergebnis.dateien.map((d) => d.split(/[\\/]/).pop())).toEqual(['Spartacus S01E03.mkv', 'Spartacus S01E04.mkv'])
|
||||
expect(ergebnis.hinweise.join(' ')).toContain('Folge 4: Datei 54 min, TMDb 33 min')
|
||||
expect(existsSync(join(ergebnis.zielOrdner, 'tvshow.nfo'))).toBe(true)
|
||||
})
|
||||
|
||||
it('Serie ohne Vorgabe: Laufzeit-Zuordnung wie bisher — nur bei eindeutigem Treffer', async () => {
|
||||
const { ablage, roh, dateien } = aufbau(['title_t00.mkv', 'title_t01.mkv'], ['folge', 'folge'])
|
||||
dateien[0].dauerS = 3167
|
||||
dateien[1].dauerS = 3249
|
||||
const ergebnis = await komprimierenUndAblegen(
|
||||
auftrag({ rohOrdner: roh, dateien, zuordnung: zuordnung('tv', 'Spartacus', 2010), staffel: 2 }),
|
||||
umgebung(ablage, {
|
||||
episodenLaufzeiten: async () => [
|
||||
[1, 3180],
|
||||
[2, 3240],
|
||||
],
|
||||
}),
|
||||
() => {},
|
||||
KEIN_ABBRUCH,
|
||||
)
|
||||
expect(readdirSync(ergebnis.zielOrdner).filter((n) => n.endsWith('.mkv')).sort()).toEqual([
|
||||
'Spartacus S02E01.mkv',
|
||||
'Spartacus S02E02.mkv',
|
||||
])
|
||||
})
|
||||
|
||||
it('Extras liegen im Unterordner extras\\ neben dem Hauptfilm', async () => {
|
||||
const { ablage, roh, dateien } = aufbau(['title_t00.mkv', 'title_t05.mkv'], ['hauptfilm', 'extra'])
|
||||
const ergebnis = await komprimierenUndAblegen(
|
||||
auftrag({ rohOrdner: roh, dateien, zuordnung: zuordnung('movie', 'Akira', 1988) }),
|
||||
umgebung(ablage),
|
||||
() => {},
|
||||
KEIN_ABBRUCH,
|
||||
)
|
||||
expect(existsSync(join(ergebnis.zielOrdner, 'Akira (1988).mkv'))).toBe(true)
|
||||
expect(existsSync(join(ergebnis.zielOrdner, 'extras', 'Akira (1988) - Extra 01.mkv'))).toBe(true)
|
||||
})
|
||||
|
||||
it('Wiederholen: der vorgegebene Zielordner wird benutzt, kein zweiter angelegt', async () => {
|
||||
const { ablage, roh, dateien } = aufbau(['title_t00.mkv'])
|
||||
const ziel = join(ablage, 'Filme', 'Akira (1988)')
|
||||
mkdirSync(ziel, { recursive: true })
|
||||
const ergebnis = await komprimierenUndAblegen(
|
||||
auftrag({ rohOrdner: roh, dateien, zuordnung: zuordnung('movie', 'Akira', 1988), zielOrdner: ziel, preset: 'Fast 1080p30' }),
|
||||
umgebung(ablage),
|
||||
() => {},
|
||||
KEIN_ABBRUCH,
|
||||
)
|
||||
expect(ergebnis.zielOrdner).toBe(ziel)
|
||||
expect(ergebnis.preset).toBe('Fast 1080p30')
|
||||
expect(readdirSync(join(ablage, 'Filme'))).toEqual(['Akira (1988)'])
|
||||
})
|
||||
|
||||
it('der Fortschritt ist über beide Dateien gewichtet', async () => {
|
||||
const { ablage, roh, dateien } = aufbau(['title_t00.mkv', 'title_t01.mkv'])
|
||||
dateien[0].groesseBytes = 3
|
||||
dateien[1].groesseBytes = 1
|
||||
const prozente: number[] = []
|
||||
await komprimierenUndAblegen(auftrag({ rohOrdner: roh, dateien }), umgebung(ablage), (s) => prozente.push(s.prozent), KEIN_ABBRUCH)
|
||||
// 50 % der ersten (3/4) = 37, 50 % der zweiten = 87.
|
||||
expect(prozente).toContain(37)
|
||||
expect(prozente).toContain(87)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Wiederholen ersetzt die alte Fassung', () => {
|
||||
it('Serie: die alten S01E03/E04 fallen erst nach dem Encode, dann greift die Umbenennung', async () => {
|
||||
const { ablage, roh, dateien } = aufbau(['title_t00.mkv', 'title_t01.mkv'], ['folge', 'folge'])
|
||||
const ziel = join(ablage, 'Serien', 'Spartacus', 'Season 01')
|
||||
mkdirSync(ziel, { recursive: true })
|
||||
const alt3 = join(ziel, 'Spartacus S01E03.mkv')
|
||||
const alt4 = join(ziel, 'Spartacus S01E04.mkv')
|
||||
writeFileSync(alt3, 'alt')
|
||||
writeFileSync(alt4, 'alt')
|
||||
const ergebnis = await komprimierenUndAblegen(
|
||||
auftrag({
|
||||
rohOrdner: roh,
|
||||
dateien,
|
||||
zuordnung: zuordnung('tv', 'Spartacus', 2010),
|
||||
staffel: 1,
|
||||
ersteFolge: 3,
|
||||
zielOrdner: ziel,
|
||||
ersetzt: [alt3, alt4],
|
||||
}),
|
||||
umgebung(ablage),
|
||||
() => {},
|
||||
KEIN_ABBRUCH,
|
||||
)
|
||||
expect(ergebnis.status).toBe('fertig')
|
||||
expect(readdirSync(ziel).filter((n) => n.endsWith('.mkv')).sort()).toEqual(['Spartacus S01E03.mkv', 'Spartacus S01E04.mkv'])
|
||||
expect(readFileSync(alt3, 'utf8')).toBe('encoded')
|
||||
expect(ergebnis.hinweise.join(' ')).toContain('Alte Fassung ersetzt')
|
||||
expect(ergebnis.hinweise.join(' ')).not.toContain('existiert schon')
|
||||
})
|
||||
|
||||
it('scheitert der zweite Lauf, bleibt die alte Fassung stehen', async () => {
|
||||
hb.ergebnis = 'error'
|
||||
const { ablage, roh, dateien } = aufbau(['title_t00.mkv'])
|
||||
const ziel = join(ablage, 'Filme', 'Akira (1988)')
|
||||
mkdirSync(ziel, { recursive: true })
|
||||
const alt = join(ziel, 'title_t00.mkv')
|
||||
writeFileSync(alt, 'alt')
|
||||
const ergebnis = await komprimierenUndAblegen(
|
||||
auftrag({ rohOrdner: roh, dateien, zuordnung: zuordnung('movie', 'Akira', 1988), zielOrdner: ziel, ersetzt: [alt] }),
|
||||
umgebung(ablage),
|
||||
() => {},
|
||||
KEIN_ABBRUCH,
|
||||
)
|
||||
expect(ergebnis.status).toBe('fehler')
|
||||
expect(readFileSync(alt, 'utf8')).toBe('alt')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
// Die Werkzeug-Naht (kern/werkzeuge/aufruf.ts, 5.4.0): echte EXE-Pfade
|
||||
// gehen unverändert durch; Node-Skripte (die Nachbauten des Klick-Beweises)
|
||||
// laufen über die eigene Laufzeit mit ELECTRON_RUN_AS_NODE.
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { istSkript, werkzeugStart } from '../src/kern/werkzeuge/aufruf'
|
||||
|
||||
describe('werkzeugStart', () => {
|
||||
it('eine EXE bleibt eine EXE — Argumente und Umgebung unverändert', () => {
|
||||
const start = werkzeugStart('C:\\Programme\\MakeMKV\\makemkvcon.exe', ['-r', 'info', 'dev:G:'], { PATH: 'x' })
|
||||
expect(start.programm).toBe('C:\\Programme\\MakeMKV\\makemkvcon.exe')
|
||||
expect(start.args).toEqual(['-r', 'info', 'dev:G:'])
|
||||
expect(start.env).toEqual({ PATH: 'x' })
|
||||
})
|
||||
|
||||
it('ein Skript läuft über die eigene Laufzeit, das Skript wird erstes Argument', () => {
|
||||
const start = werkzeugStart('F:\\rippy\\bau\\klick\\fake-makemkvcon.cjs', ['-r', 'info'], { PATH: 'x' })
|
||||
expect(start.programm).toBe(process.execPath)
|
||||
expect(start.args).toEqual(['F:\\rippy\\bau\\klick\\fake-makemkvcon.cjs', '-r', 'info'])
|
||||
expect(start.env['ELECTRON_RUN_AS_NODE']).toBe('1')
|
||||
expect(start.env['PATH']).toBe('x')
|
||||
})
|
||||
|
||||
it('istSkript kennt .cjs/.mjs/.js — und sonst nichts', () => {
|
||||
expect(istSkript('a.cjs')).toBe(true)
|
||||
expect(istSkript('A.MJS')).toBe(true)
|
||||
expect(istSkript('a.js')).toBe(true)
|
||||
expect(istSkript('HandBrakeCLI.exe')).toBe(false)
|
||||
expect(istSkript('a.json')).toBe(false)
|
||||
expect(istSkript('')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,139 @@
|
||||
// Tests des Kompressions-Auftrags (kern/ablauf/auftrag.ts, 5.4.0) — pur:
|
||||
// Sprachwahl je Datei, Folgen von Hand, gewichteter Fortschritt, JSON.
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
auftragAusJson,
|
||||
auftragZuJson,
|
||||
folgenNummern,
|
||||
folgenPlausibel,
|
||||
gewichteterFortschritt,
|
||||
sprachwahl,
|
||||
type KompressionsAuftrag,
|
||||
} from '../src/kern/ablauf/auftrag'
|
||||
|
||||
describe('sprachwahl — die Regel des Commanders', () => {
|
||||
const global = { audio: ['deu', 'eng'], untertitel: ['deu'] }
|
||||
|
||||
it('nichts gewählt: die globale Einstellung, keine Vorwahl', () => {
|
||||
expect(sprachwahl({ audioSprachen: [], untertitelSprachen: [] }, global)).toEqual({
|
||||
audio: ['deu', 'eng'],
|
||||
untertitel: ['deu'],
|
||||
untertitelStandard: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('japanischer Ton + deutsche Untertitel: Untertitel nach vorn und vorgewählt', () => {
|
||||
expect(sprachwahl({ audioSprachen: ['jpn'], untertitelSprachen: ['eng', 'deu'] }, global)).toEqual({
|
||||
audio: ['jpn'],
|
||||
untertitel: ['deu', 'eng'],
|
||||
untertitelStandard: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('deutscher Ton zuerst: keine Vorwahl, auch wenn Untertitel gewählt sind', () => {
|
||||
expect(sprachwahl({ audioSprachen: ['deu', 'jpn'], untertitelSprachen: ['deu'] }, global).untertitelStandard).toBe(false)
|
||||
})
|
||||
|
||||
it('fremder Ton, aber kein deutscher Untertitel gewählt: nichts vorzuwählen', () => {
|
||||
expect(sprachwahl({ audioSprachen: ['jpn'], untertitelSprachen: ['eng'] }, global)).toEqual({
|
||||
audio: ['jpn'],
|
||||
untertitel: ['eng'],
|
||||
untertitelStandard: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('ohne globale Wunschsprache gibt es keine Vorwahl', () => {
|
||||
expect(sprachwahl({ audioSprachen: ['jpn'], untertitelSprachen: ['deu'] }, { audio: [], untertitel: [] }).untertitelStandard).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('folgenNummern und folgenPlausibel', () => {
|
||||
it('fortlaufend ab der ersten Folge', () => {
|
||||
expect(folgenNummern(2, 3)).toEqual([3, 4])
|
||||
expect(folgenNummern(0, 3)).toEqual([])
|
||||
expect(folgenNummern(2, 0)).toEqual([1, 2])
|
||||
})
|
||||
|
||||
it('warnt nur bei deutlicher Abweichung von TMDb', () => {
|
||||
const laufzeiten: Array<[number, number]> = [
|
||||
[3, 3200],
|
||||
[4, 2000],
|
||||
]
|
||||
const w = folgenPlausibel([3167, 3249], [3, 4], laufzeiten)
|
||||
expect(w).toHaveLength(1)
|
||||
expect(w[0]).toContain('Folge 4')
|
||||
expect(folgenPlausibel([3167], [9], laufzeiten)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('gewichteterFortschritt — nie zurück auf null', () => {
|
||||
it('gewichtet nach Größe', () => {
|
||||
expect(gewichteterFortschritt([3, 1], 0, 50)).toBe(37)
|
||||
expect(gewichteterFortschritt([3, 1], 0, 100)).toBe(75)
|
||||
expect(gewichteterFortschritt([3, 1], 1, 50)).toBe(87)
|
||||
expect(gewichteterFortschritt([3, 1], 1, 100)).toBe(100)
|
||||
})
|
||||
it('ohne Gewichte zählen alle gleich; -1 heißt „noch keine Angabe" vom laufenden Teil', () => {
|
||||
expect(gewichteterFortschritt([0, 0], 1, 50)).toBe(75)
|
||||
expect(gewichteterFortschritt([1, 1], 1, -1)).toBe(50)
|
||||
expect(gewichteterFortschritt([], 0, 50)).toBe(-1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Auftrag als JSON — und wieder zurück', () => {
|
||||
const a: KompressionsAuftrag = {
|
||||
vorgangId: 'bluray-2026-08-30T12-00-00',
|
||||
laufwerk: 'G',
|
||||
discTyp: 'bluray',
|
||||
rohOrdner: 'E:\\Rippy\\roh\\bluray-2026-08-30T12-00-00',
|
||||
dateien: [
|
||||
{ pfad: 'E:\\Rippy\\roh\\x\\title_t00.mkv', titelNr: 0, rolle: 'folge', dauerS: 3167, groesseBytes: 16e9, audioSprachen: ['jpn'], untertitelSprachen: ['deu'] },
|
||||
],
|
||||
zuordnung: null,
|
||||
staffel: 1,
|
||||
ersteFolge: 3,
|
||||
discNr: 2,
|
||||
fingerprint: 'SPARTACUS_GOTA_D2|33759100928',
|
||||
lesefehler: false,
|
||||
hinweise: ['Übersprungen: Titel 5.'],
|
||||
begonnen: '2026-08-30T12:00:00.000Z',
|
||||
preset: '',
|
||||
zielOrdner: '',
|
||||
ersetzt: [],
|
||||
}
|
||||
it('kommt unverändert zurück', () => {
|
||||
expect(auftragAusJson(auftragZuJson(a))).toEqual(a)
|
||||
})
|
||||
it('Unsinn ist null, kein Absturz', () => {
|
||||
expect(auftragAusJson('{')).toBeNull()
|
||||
expect(auftragAusJson('{}')).toBeNull()
|
||||
expect(auftragAusJson('{"vorgangId":"x","rohOrdner":"y","dateien":[{}]}')).toBeNull()
|
||||
})
|
||||
it('fehlende Felder bekommen Vorgaben', () => {
|
||||
const b = auftragAusJson('{"vorgangId":"x","rohOrdner":"y","dateien":[{"pfad":"p"}]}')!
|
||||
expect(b.dateien[0].rolle).toBe('hauptfilm')
|
||||
expect(b.dateien[0].audioSprachen).toEqual([])
|
||||
expect(b.staffel).toBeNull()
|
||||
expect(b.preset).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sprachwahl — Untertitel-Vorwahl je Extra (5.6.0, immer optional)', () => {
|
||||
const global = { audio: ['deu', 'eng'], untertitel: ['deu'] }
|
||||
it('eine gesetzte Vorwahl schlägt die Regel: nach vorn und vorgewählt', () => {
|
||||
expect(sprachwahl({ audioSprachen: ['deu'], untertitelSprachen: ['eng', 'deu'], untertitelVorwahl: 'deu' }, global)).toEqual({
|
||||
audio: ['deu'],
|
||||
untertitel: ['deu', 'eng'],
|
||||
untertitelStandard: true,
|
||||
})
|
||||
})
|
||||
it('null heißt ausdrücklich keine Vorwahl — auch bei fremdem Ton', () => {
|
||||
expect(sprachwahl({ audioSprachen: ['jpn'], untertitelSprachen: ['deu'], untertitelVorwahl: null }, global).untertitelStandard).toBe(false)
|
||||
})
|
||||
it('fehlend heißt Regel wie bisher', () => {
|
||||
expect(sprachwahl({ audioSprachen: ['jpn'], untertitelSprachen: ['deu'] }, global).untertitelStandard).toBe(true)
|
||||
})
|
||||
it('eine Vorwahl, die nicht in der Liste steht, wird ergänzt', () => {
|
||||
expect(sprachwahl({ audioSprachen: ['jpn'], untertitelSprachen: ['eng'], untertitelVorwahl: 'deu' }, global).untertitel).toEqual(['deu', 'eng'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,210 @@
|
||||
// Tests der Automatik (kern/dienste/automatik.ts, 5.5.0): Rippys Vorschlag
|
||||
// als Auftrag, Staffel/Folge aus Disc und Serien-Merker, und der Ablauf
|
||||
// mit Fake-Uhr — sicher → Titel lesen → Countdown → Rip; unsicher → nur
|
||||
// sagen, warum; Stopp und Jetzt.
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AutomatikStand, GeraeteInfo, KernNachricht, RipAuftragOptionen, RipAuftragTitel, TitelStand } from '../src/gemeinsam/nachrichten'
|
||||
import type { Protokoll } from '../src/gemeinsam/protokoll'
|
||||
import { AUTOMATIK_SICHER, AutomatikDienst, auftragAusVorschlag, optionenAus, type AutomatikAbhaengigkeiten } from '../src/kern/dienste/automatik'
|
||||
import type { KernKontext } from '../src/kern/kontext'
|
||||
import type { Zuordnung } from '../src/kern/metadaten/zuordnung'
|
||||
|
||||
function titel(nr: number, gewaehlt: boolean, rolle: 'hauptfilm' | 'extra' | 'folge' = 'hauptfilm'): TitelStand['titel'][number] {
|
||||
return { nr, dauerS: 3000 + nr, groesseBytes: 1e9 * (nr + 1), kapitel: 12, gewaehlt, rolle, grund: gewaehlt ? 'Hauptfilm' : 'zu kurz', sprachen: { audio: [], untertitel: [] } }
|
||||
}
|
||||
|
||||
function stand(teile: TitelStand['titel'], unsicher = false, fehler = ''): TitelStand {
|
||||
return { id: 'G', laeuft: false, titel: teile, einordnung: unsicher ? 'zwei fast gleich lange Titel — bitte nachsehen' : 'Film: ein Hauptfilm', unsicher, fehler }
|
||||
}
|
||||
|
||||
function geraet(typ: GeraeteInfo['typ'] = 'bluray'): GeraeteInfo {
|
||||
return { id: 'G', name: 'G:', typ, pfad: 'G:\\', status: 'ready', modell: 'Fake', seriennummer: '', groesseBytes: 4e10, grund: '' }
|
||||
}
|
||||
|
||||
function zuordnung(confidence: number): Zuordnung {
|
||||
return { titel: 'Akira', jahr: 1988, confidence, meta: {} as never }
|
||||
}
|
||||
|
||||
describe('auftragAusVorschlag', () => {
|
||||
it('nimmt nur die vorausgewählten Titel, mit Rolle, Dauer und Größe — Sprachen leer (Einstellung gilt)', () => {
|
||||
const auftrag = auftragAusVorschlag(stand([titel(0, true), titel(1, false), titel(2, true, 'extra')]))
|
||||
expect(auftrag).toEqual<RipAuftragTitel[]>([
|
||||
{ nr: 0, rolle: 'hauptfilm', dauerS: 3000, groesseBytes: 1e9 },
|
||||
{ nr: 2, rolle: 'extra', dauerS: 3002, groesseBytes: 3e9 },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('optionenAus', () => {
|
||||
it('ohne alles: nichts vorgeben', () => {
|
||||
expect(optionenAus(null, null)).toEqual<RipAuftragOptionen>({ staffel: null, ersteFolge: null })
|
||||
})
|
||||
it('die Staffel der Disc schlägt den Merker', () => {
|
||||
expect(optionenAus({ staffel: 2, discNr: 2 }, { staffel: 1, naechsteFolge: 5 })).toEqual({ staffel: 2, ersteFolge: 5 })
|
||||
})
|
||||
it('Disc 1 fängt nie beim Merker an — sonst begänne die neue Ausgabe bei Folge 5', () => {
|
||||
expect(optionenAus({ staffel: null, discNr: 1 }, { staffel: 1, naechsteFolge: 5 })).toEqual({ staffel: 1, ersteFolge: null })
|
||||
})
|
||||
it('Disc-Nummer unbekannt oder > 1: ab der gemerkten Folge weiter', () => {
|
||||
expect(optionenAus({ staffel: null, discNr: null }, { staffel: 1, naechsteFolge: 3 })).toEqual({ staffel: 1, ersteFolge: 3 })
|
||||
expect(optionenAus({ staffel: null, discNr: 3 }, { staffel: 1, naechsteFolge: 9 })).toEqual({ staffel: 1, ersteFolge: 9 })
|
||||
})
|
||||
})
|
||||
|
||||
function aufbau(sekunden = 3) {
|
||||
const nachrichten: AutomatikStand[] = []
|
||||
const hinweise: string[] = []
|
||||
const kontext = {
|
||||
db: null,
|
||||
dbStatus: { ok: false, fehler: 'Test' },
|
||||
anFenster: (n: KernNachricht) => {
|
||||
if (n.art === 'automatik') nachrichten.push(n.stand)
|
||||
},
|
||||
anHauptUndFenster: () => {},
|
||||
hinweis: (t: string) => hinweise.push(t),
|
||||
fehler: () => {},
|
||||
protokoll: { schreiben: () => {} } as unknown as Protokoll,
|
||||
smoke: true,
|
||||
} as unknown as KernKontext
|
||||
const gestartet: Array<{ id: string; auftrag: RipAuftragTitel[]; optionen: RipAuftragOptionen }> = []
|
||||
const benachrichtigt: string[] = []
|
||||
let tick: (() => void) | null = null
|
||||
let gestoppt = 0
|
||||
let an = true
|
||||
let ripLaeuft = false
|
||||
let titelStand: TitelStand = stand([titel(0, true), titel(1, false)])
|
||||
let angaben: { staffel: number | null; discNr: number | null } | null = null
|
||||
let merker: { staffel: number; naechsteFolge: number } | null = null
|
||||
const abh: AutomatikAbhaengigkeiten = {
|
||||
an: () => an,
|
||||
sekunden: () => sekunden,
|
||||
ripLaeuft: () => ripLaeuft,
|
||||
titelLesen: async () => titelStand,
|
||||
ripStarten: (id, auftrag, optionen) => gestartet.push({ id, auftrag, optionen }),
|
||||
discAngaben: () => angaben,
|
||||
serienMerker: () => merker,
|
||||
benachrichtigen: (t, x) => benachrichtigt.push(`${t}: ${x}`),
|
||||
takt: (fn) => {
|
||||
tick = fn
|
||||
return 1 as unknown as NodeJS.Timeout
|
||||
},
|
||||
taktStoppen: () => {
|
||||
gestoppt += 1
|
||||
tick = null
|
||||
},
|
||||
}
|
||||
const dienst = new AutomatikDienst(kontext, abh)
|
||||
return {
|
||||
dienst,
|
||||
nachrichten,
|
||||
hinweise,
|
||||
gestartet,
|
||||
benachrichtigt,
|
||||
tick: () => {
|
||||
if (tick === null) throw new Error('kein Takt')
|
||||
tick()
|
||||
},
|
||||
gestopptZahl: () => gestoppt,
|
||||
setAn: (w: boolean) => (an = w),
|
||||
setRipLaeuft: (w: boolean) => (ripLaeuft = w),
|
||||
setTitel: (s: TitelStand) => (titelStand = s),
|
||||
setAngaben: (a: typeof angaben) => (angaben = a),
|
||||
setMerker: (m: typeof merker) => (merker = m),
|
||||
}
|
||||
}
|
||||
|
||||
describe('AutomatikDienst', () => {
|
||||
it('sicher erkannt, nicht gerippt: liest Titel, zählt herunter, startet mit Rippys Vorschlag', async () => {
|
||||
const a = aufbau(3)
|
||||
await a.dienst.pruefen(geraet(), zuordnung(0.95), null)
|
||||
expect(a.nachrichten[0].text).toContain('liest die Titel')
|
||||
expect(a.nachrichten[1]).toMatchObject({ aktiv: true, sekunden: 3 })
|
||||
a.tick()
|
||||
a.tick()
|
||||
expect(a.nachrichten[a.nachrichten.length - 1]).toMatchObject({ aktiv: true, sekunden: 1 })
|
||||
expect(a.gestartet).toHaveLength(0)
|
||||
a.tick()
|
||||
expect(a.gestartet).toHaveLength(1)
|
||||
expect(a.gestartet[0].auftrag).toEqual([{ nr: 0, rolle: 'hauptfilm', dauerS: 3000, groesseBytes: 1e9 }])
|
||||
expect(a.nachrichten[a.nachrichten.length - 1]).toMatchObject({ aktiv: false, text: 'Automatik: Rip gestartet (1 Titel).' })
|
||||
expect(a.benachrichtigt[0]).toContain('Automatik hat auf Laufwerk G gestartet')
|
||||
expect(a.gestopptZahl()).toBe(1)
|
||||
})
|
||||
it('unsichere Erkennung: nichts passiert von selbst — nur der Grund', async () => {
|
||||
const a = aufbau()
|
||||
await a.dienst.pruefen(geraet(), zuordnung(AUTOMATIK_SICHER - 0.01), null)
|
||||
expect(a.nachrichten).toHaveLength(1)
|
||||
expect(a.nachrichten[0]).toMatchObject({ aktiv: false })
|
||||
expect(a.nachrichten[0].text).toContain('nicht sicher genug')
|
||||
expect(a.gestartet).toHaveLength(0)
|
||||
})
|
||||
it('schon gerippt: wartet und nennt das Datum', async () => {
|
||||
const a = aufbau()
|
||||
await a.dienst.pruefen(geraet(), zuordnung(0.99), '2026-08-30T10:00:00Z')
|
||||
expect(a.nachrichten[0].text).toContain('schon gerippt (2026-08-30)')
|
||||
expect(a.gestartet).toHaveLength(0)
|
||||
})
|
||||
it('aus, CD, unbekannte Disc oder laufender Rip: gar nichts', async () => {
|
||||
const a = aufbau()
|
||||
a.setAn(false)
|
||||
await a.dienst.pruefen(geraet(), zuordnung(0.99), null)
|
||||
a.setAn(true)
|
||||
await a.dienst.pruefen(geraet('cd'), zuordnung(0.99), null)
|
||||
await a.dienst.pruefen(geraet('unknown'), zuordnung(0.99), null)
|
||||
a.setRipLaeuft(true)
|
||||
await a.dienst.pruefen(geraet(), zuordnung(0.99), null)
|
||||
expect(a.nachrichten).toHaveLength(0)
|
||||
})
|
||||
it('unsicherer Titel-Lauf (zwei fast gleich lange Titel): kein Countdown, die Einordnung wird gesagt', async () => {
|
||||
const a = aufbau()
|
||||
a.setTitel(stand([titel(0, true), titel(1, false)], true))
|
||||
await a.dienst.pruefen(geraet(), zuordnung(0.99), null)
|
||||
expect(a.nachrichten[a.nachrichten.length - 1].text).toContain('bitte nachsehen')
|
||||
expect(a.gestartet).toHaveLength(0)
|
||||
})
|
||||
it('Stopp beendet den Countdown mit Grund; Jetzt überspringt ihn', async () => {
|
||||
const a = aufbau(30)
|
||||
await a.dienst.pruefen(geraet(), zuordnung(0.99), null)
|
||||
a.dienst.stopp('G', 'Automatik gestoppt — du entscheidest.')
|
||||
expect(a.gestopptZahl()).toBe(1)
|
||||
expect(a.nachrichten[a.nachrichten.length - 1]).toMatchObject({ aktiv: false, text: 'Automatik gestoppt — du entscheidest.' })
|
||||
expect(() => a.tick()).toThrow('kein Takt')
|
||||
// Jetzt braucht einen aktiven Countdown und den gemerkten Titel-Stand.
|
||||
a.dienst.jetzt('G')
|
||||
expect(a.gestartet).toHaveLength(0)
|
||||
await a.dienst.pruefen(geraet(), zuordnung(0.99), null)
|
||||
a.dienst.titelMerken('G', stand([titel(0, true), titel(1, false)]))
|
||||
a.dienst.jetzt('G')
|
||||
expect(a.gestartet).toHaveLength(1)
|
||||
})
|
||||
it('Serie: Staffel/Folge aus Disc und Merker, Merker nur mit gemerkter Zuordnung', async () => {
|
||||
const a = aufbau(1)
|
||||
a.setTitel(stand([titel(0, true, 'folge'), titel(1, true, 'folge')]))
|
||||
a.setAngaben({ staffel: null, discNr: 2 })
|
||||
a.setMerker({ staffel: 1, naechsteFolge: 3 })
|
||||
a.dienst.zuordnungMerken('G', zuordnung(0.99))
|
||||
await a.dienst.pruefen(geraet(), zuordnung(0.99), null)
|
||||
a.tick()
|
||||
expect(a.gestartet[0].optionen).toEqual({ staffel: 1, ersteFolge: 3 })
|
||||
})
|
||||
it('leeren: Countdown weg und ein leerer Stand ans Fenster — der Kasten verschwindet (5.6.0)', async () => {
|
||||
const a = aufbau(5)
|
||||
await a.dienst.pruefen(geraet(), zuordnung(0.99), null)
|
||||
a.dienst.leeren('G')
|
||||
expect(a.gestopptZahl()).toBe(1)
|
||||
expect(a.nachrichten[a.nachrichten.length - 1]).toMatchObject({ aktiv: false, sekunden: 0, text: '' })
|
||||
// Ohne bekannten Stand wird nichts gesendet — kein leerer Kasten aus dem Nichts.
|
||||
const b = aufbau(5)
|
||||
b.dienst.leeren('G')
|
||||
expect(b.nachrichten).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('Disc raus: Countdown weg, Stand weg', async () => {
|
||||
const a = aufbau(5)
|
||||
await a.dienst.pruefen(geraet(), zuordnung(0.99), null)
|
||||
expect(a.dienst.stand('G')).not.toBeNull()
|
||||
a.dienst.discEntnommen('G')
|
||||
expect(a.dienst.stand('G')).toBeNull()
|
||||
expect(a.gestopptZahl()).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,112 @@
|
||||
// Tests der Benachrichtigung (kern/dienste/benachrichtigung.ts, 5.5.0):
|
||||
// Was an Discord und Telegram geht (Form laut deren Doku), welche Kanäle
|
||||
// eingerichtet sind, und dass ein Fehlschlag gesagt wird, aber nichts
|
||||
// kippt.
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { KernNachricht } from '../src/gemeinsam/nachrichten'
|
||||
import type { Protokoll } from '../src/gemeinsam/protokoll'
|
||||
import { BenachrichtigungsDienst, discordAnfrage, kanaeleAus, kanaeleEingerichtet, telegramAnfrage } from '../src/kern/dienste/benachrichtigung'
|
||||
import type { EinstellungsDienst } from '../src/kern/dienste/einstellungen'
|
||||
import type { KernKontext } from '../src/kern/kontext'
|
||||
|
||||
function fakeKontext() {
|
||||
const fehler: string[] = []
|
||||
const hinweise: string[] = []
|
||||
const zeilen: string[] = []
|
||||
const nachrichten: KernNachricht[] = []
|
||||
const kontext = {
|
||||
db: null,
|
||||
dbStatus: { ok: false, fehler: 'Test' },
|
||||
anFenster: (n: KernNachricht) => nachrichten.push(n),
|
||||
anHauptUndFenster: (n: KernNachricht) => nachrichten.push(n),
|
||||
hinweis: (t: string) => hinweise.push(t),
|
||||
fehler: (t: string) => fehler.push(t),
|
||||
protokoll: { schreiben: (t: string) => zeilen.push(t) } as unknown as Protokoll,
|
||||
smoke: true,
|
||||
} as unknown as KernKontext
|
||||
return { kontext, fehler, hinweise, zeilen, nachrichten }
|
||||
}
|
||||
|
||||
function fakeEinstellungen(werte: Record<string, string>): EinstellungsDienst {
|
||||
return { lesen: (s: string, vorgabe = '') => werte[s] ?? vorgabe } as unknown as EinstellungsDienst
|
||||
}
|
||||
|
||||
describe('discordAnfrage / telegramAnfrage', () => {
|
||||
it('Discord: POST an den Webhook mit { content, username } — Titel fett, dann Text', () => {
|
||||
const a = discordAnfrage('https://discord.com/api/webhooks/1/abc', 'Rip fertig', 'Akira (1988)')
|
||||
expect(a.url).toBe('https://discord.com/api/webhooks/1/abc')
|
||||
const body = JSON.parse(a.body) as { content: string; username: string }
|
||||
expect(body.content).toBe('**Rip fertig**\nAkira (1988)')
|
||||
expect(body.username).toBe('Rippy')
|
||||
})
|
||||
it('Discord: content bleibt unter 2000 Zeichen', () => {
|
||||
const a = discordAnfrage('https://x', 'T', 'x'.repeat(5000))
|
||||
expect((JSON.parse(a.body) as { content: string }).content.length).toBeLessThanOrEqual(2000)
|
||||
})
|
||||
it('Telegram: bot<TOKEN>/sendMessage mit { chat_id, text }', () => {
|
||||
const a = telegramAnfrage('123:ABC', '42', 'Rip fertig', 'Akira')
|
||||
expect(a.url).toBe('https://api.telegram.org/bot123:ABC/sendMessage')
|
||||
expect(JSON.parse(a.body)).toEqual({ chat_id: '42', text: 'Rip fertig\nAkira' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('kanaeleAus / kanaeleEingerichtet', () => {
|
||||
it('trimmt und erkennt: Discord allein reicht, Telegram nur mit Token UND Chat-ID', () => {
|
||||
expect(kanaeleEingerichtet(kanaeleAus((s) => (s === 'discordWebhook' ? ' https://d ' : '')))).toBe(true)
|
||||
expect(kanaeleEingerichtet(kanaeleAus((s) => (s === 'telegramToken' ? 't' : '')))).toBe(false)
|
||||
expect(kanaeleEingerichtet(kanaeleAus((s) => (s === 'telegramToken' ? 't' : s === 'telegramChatId' ? '1' : '')))).toBe(true)
|
||||
expect(kanaeleEingerichtet(kanaeleAus(() => ''))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('BenachrichtigungsDienst', () => {
|
||||
it('schickt über jeden eingerichteten Kanal und schreibt ins Protokoll', async () => {
|
||||
const { kontext, fehler, zeilen } = fakeKontext()
|
||||
const aufrufe: Array<{ url: string; body: string }> = []
|
||||
const holen = (async (url: string | URL | Request, init?: RequestInit) => {
|
||||
aufrufe.push({ url: String(url), body: String(init?.body) })
|
||||
return { status: 204 }
|
||||
}) as unknown as typeof fetch
|
||||
const d = new BenachrichtigungsDienst(kontext, fakeEinstellungen({ discordWebhook: 'https://d', telegramToken: 't', telegramChatId: '9' }), holen)
|
||||
expect(await d.senden('Titel', 'Text')).toEqual([])
|
||||
expect(aufrufe.map((a) => a.url)).toEqual(['https://d', 'https://api.telegram.org/bott/sendMessage'])
|
||||
expect(fehler).toEqual([])
|
||||
expect(zeilen[0]).toContain('Benachrichtigt (Discord, Telegram): Titel')
|
||||
})
|
||||
it('ein HTTP-Fehler wird gesagt (kern-fehler), aber nicht geworfen', async () => {
|
||||
const { kontext, fehler } = fakeKontext()
|
||||
const holen = (async () => ({ status: 404 })) as unknown as typeof fetch
|
||||
const d = new BenachrichtigungsDienst(kontext, fakeEinstellungen({ discordWebhook: 'https://d' }), holen)
|
||||
expect(await d.senden('T', 'x')).toEqual(['Discord: HTTP 404'])
|
||||
expect(fehler).toEqual(['Benachrichtigung Discord: HTTP 404'])
|
||||
})
|
||||
it('ein Netzfehler ebenso', async () => {
|
||||
const { kontext } = fakeKontext()
|
||||
const holen = (async () => {
|
||||
throw new Error('fetch failed')
|
||||
}) as unknown as typeof fetch
|
||||
const d = new BenachrichtigungsDienst(kontext, fakeEinstellungen({ discordWebhook: 'https://d' }), holen)
|
||||
expect(await d.senden('T', 'x')).toEqual(['Discord: fetch failed'])
|
||||
})
|
||||
it('ausgeschaltet: nichts geht raus — außer erzwungen (Testnachricht)', async () => {
|
||||
const { kontext } = fakeKontext()
|
||||
let aufrufe = 0
|
||||
const holen = (async () => {
|
||||
aufrufe += 1
|
||||
return { status: 200 }
|
||||
}) as unknown as typeof fetch
|
||||
const d = new BenachrichtigungsDienst(kontext, fakeEinstellungen({ benachrichtigungen: 'aus', discordWebhook: 'https://d' }), holen)
|
||||
expect(await d.senden('T', 'x')).toEqual([])
|
||||
expect(aufrufe).toBe(0)
|
||||
expect(await d.senden('T', 'x', true)).toEqual([])
|
||||
expect(aufrufe).toBe(1)
|
||||
})
|
||||
it('testen ohne Kanal: sagt, was fehlt; mit Kanal: „Testnachricht verschickt."', async () => {
|
||||
const ohne = fakeKontext()
|
||||
await new BenachrichtigungsDienst(ohne.kontext, fakeEinstellungen({}), (async () => ({ status: 200 })) as unknown as typeof fetch).testen()
|
||||
expect(ohne.fehler[0]).toContain('kein Kanal eingerichtet')
|
||||
const mit = fakeKontext()
|
||||
await new BenachrichtigungsDienst(mit.kontext, fakeEinstellungen({ discordWebhook: 'https://d' }), (async () => ({ status: 200 })) as unknown as typeof fetch).testen()
|
||||
expect(mit.hinweise).toEqual(['Testnachricht verschickt.'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
// Tests der Dateinamen in der Ablage (kern/ablage/dateinamen.ts, 5.6.0).
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { rollenNummern, zielDateiname } from '../src/kern/ablage/dateinamen'
|
||||
|
||||
describe('zielDateiname', () => {
|
||||
const film = { titel: 'Akira', jahr: 1988, alsSerie: false }
|
||||
it('ein Hauptfilm heißt wie der Film', () => {
|
||||
expect(zielDateiname({ ...film, rolle: 'hauptfilm', nummer: 1, anzahl: 1, original: 'title_t00.mkv' })).toBe('Akira (1988).mkv')
|
||||
})
|
||||
it('zwei Hauptfilme werden Fassungen', () => {
|
||||
expect(zielDateiname({ ...film, rolle: 'hauptfilm', nummer: 1, anzahl: 2, original: 'title_t00.mkv' })).toBe('Akira (1988) - Fassung 1.mkv')
|
||||
expect(zielDateiname({ ...film, rolle: 'hauptfilm', nummer: 2, anzahl: 2, original: 'title_t01.mkv' })).toBe('Akira (1988) - Fassung 2.mkv')
|
||||
})
|
||||
it('Extras werden nummeriert', () => {
|
||||
expect(zielDateiname({ ...film, rolle: 'extra', nummer: 3, anzahl: 5, original: 'title_t07.mkv' })).toBe('Akira (1988) - Extra 03.mkv')
|
||||
})
|
||||
it('die Endung bleibt, was MakeMKV schrieb', () => {
|
||||
expect(zielDateiname({ ...film, rolle: 'hauptfilm', nummer: 1, anzahl: 1, original: 'x.mp4' })).toBe('Akira (1988).mp4')
|
||||
})
|
||||
it('ohne Jahr ohne Klammer', () => {
|
||||
expect(zielDateiname({ ...film, jahr: null, rolle: 'hauptfilm', nummer: 1, anzahl: 1, original: 'a.mkv' })).toBe('Akira.mkv')
|
||||
})
|
||||
it('Serie: Folgen behalten den Namen (die Episoden-Zuordnung benennt später), Extras heißen nach der Serie', () => {
|
||||
const serie = { titel: 'Spartacus', jahr: 2010, alsSerie: true }
|
||||
expect(zielDateiname({ ...serie, rolle: 'folge', nummer: 1, anzahl: 4, original: 'title_t02.mkv' })).toBe('title_t02.mkv')
|
||||
expect(zielDateiname({ ...serie, rolle: 'extra', nummer: 1, anzahl: 1, original: 'title_t09.mkv' })).toBe('Spartacus - Extra 01.mkv')
|
||||
})
|
||||
it('ohne erkannten Titel bleibt der MakeMKV-Name', () => {
|
||||
expect(zielDateiname({ titel: '', jahr: null, alsSerie: false, rolle: 'hauptfilm', nummer: 1, anzahl: 1, original: 'title_t00.mkv' })).toBe('title_t00.mkv')
|
||||
})
|
||||
})
|
||||
|
||||
describe('rollenNummern', () => {
|
||||
it('zählt je Rolle in Reihenfolge und kennt die Gesamtzahl', () => {
|
||||
expect(rollenNummern(['hauptfilm', 'extra', 'extra', 'hauptfilm'])).toEqual([
|
||||
[1, 2],
|
||||
[1, 2],
|
||||
[2, 2],
|
||||
[2, 2],
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -71,3 +71,59 @@ describe('Datenbank', () => {
|
||||
expect(() => new Datenbank(kaputt)).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
// ── Vorgänge (5.4.0): welcher Roh-Ordner gehört zu welchem Rip ──────────
|
||||
describe('Datenbank — Vorgänge', () => {
|
||||
const vorgang = {
|
||||
id: 'bluray-2026-09-01T20-00-00',
|
||||
laufwerk: 'G',
|
||||
typ: 'bluray',
|
||||
titel: 'Spartacus',
|
||||
jahr: 2010,
|
||||
phase: 'rippt' as const,
|
||||
begonnen: '2026-09-01T20:00:00.000Z',
|
||||
beendet: null,
|
||||
rohOrdner: 'E:\Rippy\roh\bluray-2026-09-01T20-00-00',
|
||||
rohBytes: 0,
|
||||
rohGeloeschtAm: null,
|
||||
zielOrdner: '',
|
||||
fertigBytes: 0,
|
||||
komprimiert: false,
|
||||
preset: '',
|
||||
fehler: '',
|
||||
auftrag: '{}',
|
||||
lesefehler: false,
|
||||
fertigeDateien: '[]',
|
||||
}
|
||||
|
||||
it('anlegen, lesen, aktualisieren (nur die genannten Felder), listen, löschen', () => {
|
||||
const db = new Datenbank(':memory:')
|
||||
aufraeumen.push(() => db.schliessen())
|
||||
db.vorgangAnlegen(vorgang)
|
||||
expect(db.vorgangLesen(vorgang.id)).toEqual(vorgang)
|
||||
db.vorgangAktualisieren(vorgang.id, { phase: 'fertig', komprimiert: true, fertigBytes: 4800, beendet: '2026-09-01T21:00:00.000Z' })
|
||||
const neu = db.vorgangLesen(vorgang.id)!
|
||||
expect(neu.phase).toBe('fertig')
|
||||
expect(neu.komprimiert).toBe(true)
|
||||
expect(neu.fertigBytes).toBe(4800)
|
||||
expect(neu.titel).toBe('Spartacus')
|
||||
expect(neu.rohGeloeschtAm).toBeNull()
|
||||
db.vorgangAnlegen({ ...vorgang, id: 'dvd-2026-09-02T10-00-00', begonnen: '2026-09-02T10:00:00.000Z' })
|
||||
expect(db.vorgangListe().map((v) => v.id)).toEqual(['dvd-2026-09-02T10-00-00', vorgang.id])
|
||||
db.vorgangLoeschen(vorgang.id)
|
||||
expect(db.vorgangLesen(vorgang.id)).toBeNull()
|
||||
expect(db.vorgangAktualisieren('gibt-es-nicht', { phase: 'fertig' })).toBeUndefined()
|
||||
})
|
||||
|
||||
it('Schema 3 → 4: eine Bestands-Datenbank bekommt die Tabelle ohne Datenverlust', () => {
|
||||
const pfad = frischerPfad()
|
||||
const alt = new Datenbank(pfad)
|
||||
alt.einstellungSchreiben('ablage', 'E:\Rippy')
|
||||
alt.schliessen()
|
||||
const neu = new Datenbank(pfad)
|
||||
aufraeumen.push(() => neu.schliessen())
|
||||
expect(neu.einstellungLesen('ablage')).toBe('E:\Rippy')
|
||||
expect(neu.vorgangListe()).toEqual([])
|
||||
expect(neu.schemaVersion()).toBe(SCHEMA_VERSION)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// Tests der Laufwerks-Gesundheit (kern/laufwerk/ereignisse.ts, 5.4.0) —
|
||||
// an der ECHTEN wevtutil-Ausgabe dieses PCs (01.09.2026): cdrom 7
|
||||
// (fehlerhafter Block, der Spartacus-Defekt), cdrom 11 (Controllerfehler,
|
||||
// der USB-Aussetzer).
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { beurteilen, buildWevtutilArgs, ereignisseAusXml, xpathFuer } from '../src/kern/laufwerk/ereignisse'
|
||||
|
||||
/** Drei Ereignisse, so wie wevtutil /f:xml sie schreibt — ohne Zeilenumbrüche. */
|
||||
const XML =
|
||||
"<Event xmlns='http://schemas.microsoft.com/win/2004/08/events/event'><System><Provider Name='cdrom'/><EventID Qualifiers='49156'>7</EventID><Version>0</Version><Level>2</Level><TimeCreated SystemTime='2026-09-01T18:32:42.3868580Z'/><EventRecordID>135751</EventRecordID><Channel>System</Channel><Computer>TobisNicerPC</Computer></System><EventData><Data>\\Device\\CdRom0</Data><Binary>0300</Binary></EventData></Event>" +
|
||||
"<Event xmlns='http://schemas.microsoft.com/win/2004/08/events/event'><System><Provider Name='cdrom'/><EventID Qualifiers='49156'>7</EventID><TimeCreated SystemTime='2026-09-01T18:32:39.6629681Z'/><Channel>System</Channel></System><EventData><Data>\\Device\\CdRom0</Data></EventData></Event>" +
|
||||
"<Event xmlns='http://schemas.microsoft.com/win/2004/08/events/event'><System><Provider Name='cdrom'/><EventID Qualifiers='49156'>11</EventID><TimeCreated SystemTime='2026-09-01T18:17:39.6632548Z'/><Channel>System</Channel></System><EventData><Data>\\Device\\CdRom0</Data></EventData></Event>"
|
||||
|
||||
describe('ereignisseAusXml', () => {
|
||||
it('liest Provider, ID, Zeit und Gerät je Ereignis', () => {
|
||||
const e = ereignisseAusXml(XML)
|
||||
expect(e).toHaveLength(3)
|
||||
expect(e[0]).toEqual({ zeit: '2026-09-01T18:32:42.3868580Z', provider: 'cdrom', id: 7, geraet: '\\Device\\CdRom0' })
|
||||
expect(e[2].id).toBe(11)
|
||||
})
|
||||
it('leere Ausgabe = keine Ereignisse', () => {
|
||||
expect(ereignisseAusXml('')).toEqual([])
|
||||
expect(ereignisseAusXml('\r\n')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('beurteilen — Disc oder Hardware?', () => {
|
||||
const alle = ereignisseAusXml(XML)
|
||||
|
||||
it('ein Controllerfehler dabei: Hardware — Port am Mainboard probieren', () => {
|
||||
const b = beurteilen(alle, 24)
|
||||
expect(b.urteil).toBe('hardware')
|
||||
expect(b.controllerfehler).toBe(1)
|
||||
expect(b.fehlerhafteBloecke).toBe(2)
|
||||
expect(b.text).toContain('Mainboard')
|
||||
expect(b.letzte).toHaveLength(3)
|
||||
expect(b.letzte[0]).toContain('fehlerhafter Block')
|
||||
})
|
||||
|
||||
it('nur fehlerhafte Blöcke: die Disc', () => {
|
||||
const b = beurteilen(alle.filter((e) => e.id === 7), 24)
|
||||
expect(b.urteil).toBe('disc')
|
||||
expect(b.text).toContain('DISC')
|
||||
})
|
||||
|
||||
it('nichts: ruhig', () => {
|
||||
const b = beurteilen([], 24)
|
||||
expect(b.urteil).toBe('ruhig')
|
||||
expect(b.text).toContain('nichts')
|
||||
})
|
||||
|
||||
it('seit einem Zeitpunkt: nur die jüngeren zählen', () => {
|
||||
const b = beurteilen(alle, 24, new Date('2026-09-01T18:30:00Z'))
|
||||
expect(b.controllerfehler).toBe(0)
|
||||
expect(b.urteil).toBe('disc')
|
||||
})
|
||||
})
|
||||
|
||||
describe('die wevtutil-Argumente (Regel D: so gemessen)', () => {
|
||||
it('XPath auf cdrom/disk mit Zeitfenster, XML, neueste zuerst', () => {
|
||||
expect(xpathFuer(24)).toBe("*[System[Provider[@Name='cdrom' or @Name='disk'] and TimeCreated[timediff(@SystemTime) <= 86400000]]]")
|
||||
const args = buildWevtutilArgs(24, 50)
|
||||
expect(args[0]).toBe('qe')
|
||||
expect(args[1]).toBe('System')
|
||||
expect(args[2]).toBe(`/q:${xpathFuer(24)}`)
|
||||
expect(args).toContain('/f:xml')
|
||||
expect(args).toContain('/c:50')
|
||||
expect(args).toContain('/rd:true')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
// Tests der Fensterlage (haupt/fensterlage.ts, 5.5.0): 80 % des
|
||||
// Arbeitsbereichs, mittig, Mindestmaß; gemerkte Lage nur, wenn sie noch auf
|
||||
// einem Bildschirm liegt; Lesen/Schreiben der Datei.
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterAll, describe, expect, it } from 'vitest'
|
||||
import { anfangsLage, lageLesen, lageSchreiben, liegtSichtbar, startLage } from '../src/haupt/fensterlage'
|
||||
|
||||
const ordner = mkdtempSync(join(tmpdir(), 'rippy-lage-'))
|
||||
afterAll(() => rmSync(ordner, { recursive: true, force: true }))
|
||||
|
||||
describe('startLage', () => {
|
||||
it('Full HD ohne Taskleiste: 80 % und mittig', () => {
|
||||
expect(startLage({ x: 0, y: 0, width: 1920, height: 1040 })).toEqual({ x: 192, y: 104, width: 1536, height: 832, maximiert: false })
|
||||
})
|
||||
it('kleiner Bildschirm: Mindestmaß 1100 × 720, aber nie größer als der Bildschirm', () => {
|
||||
expect(startLage({ x: 0, y: 0, width: 1280, height: 720 })).toEqual({ x: 90, y: 0, width: 1100, height: 720, maximiert: false })
|
||||
expect(startLage({ x: 0, y: 0, width: 1024, height: 600 })).toEqual({ x: 0, y: 0, width: 1024, height: 600, maximiert: false })
|
||||
})
|
||||
it('Zweitmonitor rechts: der Versatz des Arbeitsbereichs zählt mit', () => {
|
||||
const l = startLage({ x: 1920, y: 0, width: 2560, height: 1400 })
|
||||
expect(l.x).toBe(1920 + 256)
|
||||
expect(l.width).toBe(2048)
|
||||
})
|
||||
})
|
||||
|
||||
describe('liegtSichtbar / anfangsLage', () => {
|
||||
const bildschirme = [
|
||||
{ x: 0, y: 0, width: 1920, height: 1080 },
|
||||
{ x: 1920, y: 0, width: 2560, height: 1440 },
|
||||
]
|
||||
it('auf einem Bildschirm: ja; im Nirgendwo eines abgestöpselten Monitors: nein', () => {
|
||||
expect(liegtSichtbar({ x: 100, y: 100, width: 1200, height: 800, maximiert: false }, bildschirme)).toBe(true)
|
||||
expect(liegtSichtbar({ x: 2500, y: 200, width: 1200, height: 800, maximiert: false }, bildschirme)).toBe(true)
|
||||
expect(liegtSichtbar({ x: -3000, y: 200, width: 1200, height: 800, maximiert: false }, bildschirme)).toBe(false)
|
||||
expect(liegtSichtbar({ x: 100, y: 100, width: 300, height: 200, maximiert: false }, bildschirme)).toBe(false)
|
||||
})
|
||||
it('anfangsLage: gemerkt, wenn sichtbar — sonst Start', () => {
|
||||
const arbeit = { x: 0, y: 0, width: 1920, height: 1040 }
|
||||
const gemerkt = { x: 2000, y: 50, width: 1400, height: 900, maximiert: true }
|
||||
expect(anfangsLage(gemerkt, arbeit, bildschirme)).toBe(gemerkt)
|
||||
expect(anfangsLage({ ...gemerkt, x: 9000 }, arbeit, bildschirme)).toEqual(startLage(arbeit))
|
||||
expect(anfangsLage(null, arbeit, bildschirme)).toEqual(startLage(arbeit))
|
||||
})
|
||||
})
|
||||
|
||||
describe('lageLesen / lageSchreiben', () => {
|
||||
it('Runde durch die Datei', () => {
|
||||
const pfad = join(ordner, 'fenster-lage.json')
|
||||
lageSchreiben(pfad, { x: 10, y: 20, width: 1300, height: 850, maximiert: true })
|
||||
expect(lageLesen(pfad)).toEqual({ x: 10, y: 20, width: 1300, height: 850, maximiert: true })
|
||||
})
|
||||
it('fehlende oder kaputte Datei heißt Startlage (null)', () => {
|
||||
expect(lageLesen(join(ordner, 'gibt-es-nicht.json'))).toBeNull()
|
||||
const kaputt = join(ordner, 'kaputt.json')
|
||||
writeFileSync(kaputt, '{"x": "zehn"}')
|
||||
expect(lageLesen(kaputt)).toBeNull()
|
||||
writeFileSync(kaputt, 'kein json')
|
||||
expect(lageLesen(kaputt)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
// Tests zu mehreren Filmen je Disc und Ordnern mit Filmnamen
|
||||
// (kern/ablauf/filme.ts, 5.6.0).
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { MetaVorschlag } from '../src/gemeinsam/nachrichten'
|
||||
import type { RohDatei } from '../src/kern/ablauf/auftrag'
|
||||
import { filmGruppen, rohOrdnerName, zuordnungAusVorschlag } from '../src/kern/ablauf/filme'
|
||||
import { LEERER_TREFFER, type Zuordnung } from '../src/kern/metadaten/zuordnung'
|
||||
|
||||
function zuordnung(titel: string, jahr: number | null, typ: 'movie' | 'tv' = 'movie', id = 1): Zuordnung {
|
||||
return { titel, jahr, confidence: 0.95, meta: { ...LEERER_TREFFER, typ, id, titel, jahr, quelle: 'tmdb' } }
|
||||
}
|
||||
|
||||
function datei(pfad: string, film?: MetaVorschlag): RohDatei {
|
||||
return { pfad, titelNr: 0, rolle: 'hauptfilm', dauerS: 6000, groesseBytes: 1e9, audioSprachen: [], untertitelSprachen: [], ...(film !== undefined ? { film } : {}) }
|
||||
}
|
||||
|
||||
const akira: MetaVorschlag = { typ: 'movie', tmdbId: 149, titel: 'Akira', jahr: 1988, posterPfad: '/a.jpg' }
|
||||
const ghost: MetaVorschlag = { typ: 'movie', tmdbId: 9323, titel: 'Ghost in the Shell', jahr: 1995, posterPfad: '' }
|
||||
|
||||
describe('rohOrdnerName', () => {
|
||||
it('Film: „Titel (Jahr)"', () => {
|
||||
expect(rohOrdnerName(zuordnung('Akira', 1988), null, 'bluray-2026', () => false)).toBe('Akira (1988)')
|
||||
})
|
||||
it('Serie: Titel, Staffel und Disc', () => {
|
||||
expect(rohOrdnerName(zuordnung('Spartacus', 2010, 'tv'), { staffel: 1, discNr: 2 }, 'bluray-2026', () => false)).toBe('Spartacus - Staffel 1 Disc 2')
|
||||
expect(rohOrdnerName(zuordnung('Spartacus', 2010, 'tv'), { staffel: null, discNr: null }, 'bluray-2026', () => false)).toBe('Spartacus')
|
||||
})
|
||||
it('bei Wiederholung mit Zusatz (2), (3) …', () => {
|
||||
const da = new Set(['Akira (1988)', 'Akira (1988) (2)'])
|
||||
expect(rohOrdnerName(zuordnung('Akira', 1988), null, 'bluray-2026', (n) => da.has(n))).toBe('Akira (1988) (3)')
|
||||
})
|
||||
it('ohne Zuordnung oder ohne brauchbaren Namen bleibt der Vorgangsname', () => {
|
||||
expect(rohOrdnerName(null, null, 'bluray-2026', () => false)).toBe('bluray-2026')
|
||||
expect(rohOrdnerName(zuordnung(' ', null), null, 'bluray-2026', () => false)).toBe('bluray-2026')
|
||||
expect(rohOrdnerName(zuordnung('???', null), null, 'bluray-2026', () => false)).toBe('bluray-2026')
|
||||
})
|
||||
it('Sonderzeichen werden entschärft', () => {
|
||||
expect(rohOrdnerName(zuordnung('Alien: Covenant', 2017), null, 'x', () => false)).not.toContain(':')
|
||||
})
|
||||
})
|
||||
|
||||
describe('filmGruppen', () => {
|
||||
it('ohne Film-Zuordnung je Titel bleibt alles beim Hauptvorgang', () => {
|
||||
const g = filmGruppen([datei('a'), datei('b')], 149)
|
||||
expect(g.primaer).toHaveLength(2)
|
||||
expect(g.primaerFilm).toBeNull()
|
||||
expect(g.weitere).toHaveLength(0)
|
||||
})
|
||||
it('ein Titel, der zur Disc-Zuordnung passt, zählt als Hauptvorgang; ein anderer Film wird eigene Gruppe', () => {
|
||||
const g = filmGruppen([datei('a', akira), datei('b', ghost), datei('c', ghost)], 149)
|
||||
expect(g.primaer.map((d) => d.pfad)).toEqual(['a'])
|
||||
expect(g.weitere).toHaveLength(1)
|
||||
expect(g.weitere[0].film.titel).toBe('Ghost in the Shell')
|
||||
expect(g.weitere[0].dateien.map((d) => d.pfad)).toEqual(['b', 'c'])
|
||||
})
|
||||
it('hat kein Titel die Disc-Zuordnung behalten, wird die erste Gruppe zum Hauptvorgang', () => {
|
||||
const g = filmGruppen([datei('a', ghost), datei('b', akira)], 42)
|
||||
expect(g.primaerFilm?.titel).toBe('Ghost in the Shell')
|
||||
expect(g.primaer.map((d) => d.pfad)).toEqual(['a'])
|
||||
expect(g.weitere.map((w) => w.film.titel)).toEqual(['Akira'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('zuordnungAusVorschlag', () => {
|
||||
it('ist sicher (vom Nutzer) und trägt Titel, Jahr, Poster', () => {
|
||||
const z = zuordnungAusVorschlag(akira)
|
||||
expect(z.confidence).toBe(1)
|
||||
expect(z.titel).toBe('Akira')
|
||||
expect(z.meta.id).toBe(149)
|
||||
expect(z.meta.posterPfad).toBe('/a.jpg')
|
||||
expect(z.meta.typ).toBe('movie')
|
||||
})
|
||||
})
|
||||