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