From e3ce8220f151b1ce5ee8af1443de02fe7beaae4a Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Tue, 1 Sep 2026 22:13:22 +0200 Subject: [PATCH] =?UTF-8?q?feat(v5):=20Oberflaeche=205.4.0=20=E2=80=94=20S?= =?UTF-8?q?prachen=20je=20Titel,=20Staffel/Folge,=20Vorschau,=20Kino-Banne?= =?UTF-8?q?r=20v2,=20Roh-Dateien,=20Warteschlange?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WAS: Der Auswahl-Dialog zeigt je Titel die gemessenen Ton- und Untertitelspuren als Chips (vorbelegt aus der Einstellung, sonst alles, was der Titel hat), Felder fuer Staffel und erste Folge, und zwei Zeilen Zielpfad-Vorschau (gemeinsam/ablagepfad.ts — dieselbe Namensregel wie der Kern). Die Dialoge leben jetzt im App-Zustand statt in der Kachel: ein leerer Takt der Wache raeumte sonst Kachel UND Dialog ab. Die Laufwerks-Kachel ist das Kino-Banner v2: Logo statt Text, Tagline, Zeile Jahr/FSK/Laufzeit/Genre/Bewertung, Darsteller-Streifen, die Hinweise der Nachpruefung, ein Knopf „Laufwerk pruefen" mit Urteil aus dem Windows-Protokoll, und nach dem Rip der Stand der Kompression. Neu in der Uebersicht: die Kompressions-Karte (laeuft/wartet, Abbruch). Neu in den Einstellungen: „Roh-Dateien" mit Regel, Belegung, jedem Vorgang (Neu komprimieren mit Preset, Roh loeschen) und Ordnern ohne Vorgang; der Auswurf-Schalter in „Ablage"; der Datenbank-Pfad unter „Programm". Das Haupt zeigt die Kompression in Taskleiste und Tray und laesst Updates auch auf sie warten. Playwright kommt als Bau-Werkzeug dazu (Commander-Ja nach Regel B; MIT, wird nicht ausgeliefert). Co-Authored-By: Claude Fable 5.1 --- rippy-windows/package-lock.json | 48 ++ rippy-windows/package.json | 1 + rippy-windows/src/fenster/App.tsx | 851 ++++++++++++++++++-- rippy-windows/src/fenster/kernverbindung.ts | 158 ++-- rippy-windows/src/gemeinsam/ablagepfad.ts | 74 ++ rippy-windows/src/haupt/index.ts | 63 +- rippy-windows/src/kern/ablage/struktur.ts | 22 +- 7 files changed, 1046 insertions(+), 171 deletions(-) create mode 100644 rippy-windows/src/gemeinsam/ablagepfad.ts diff --git a/rippy-windows/package-lock.json b/rippy-windows/package-lock.json index 85c5f0c..acd7506 100644 --- a/rippy-windows/package-lock.json +++ b/rippy-windows/package-lock.json @@ -24,6 +24,7 @@ "electron": "44.0.0", "electron-builder": "^26.15.3", "electron-vite": "^5.0.0", + "playwright": "^1.62.1", "react": "^19.2.8", "react-dom": "^19.2.8", "tailwindcss": "^4.3.3", @@ -5612,6 +5613,53 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/plist": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", diff --git a/rippy-windows/package.json b/rippy-windows/package.json index 11220df..2dcf95b 100644 --- a/rippy-windows/package.json +++ b/rippy-windows/package.json @@ -32,6 +32,7 @@ "electron": "44.0.0", "electron-builder": "^26.15.3", "electron-vite": "^5.0.0", + "playwright": "^1.62.1", "react": "^19.2.8", "react-dom": "^19.2.8", "tailwindcss": "^4.3.3", diff --git a/rippy-windows/src/fenster/App.tsx b/rippy-windows/src/fenster/App.tsx index 08f8742..f1e3f8a 100644 --- a/rippy-windows/src/fenster/App.tsx +++ b/rippy-windows/src/fenster/App.tsx @@ -13,28 +13,83 @@ import type { DiscTitelVorschlag, GeraeteInfo, HauptStatus, + KompressionStand, + LaufwerkBericht, RipStatus, + SprachEintrag, TitelRolle, TitelStand, + VorgaengeStand, + VorgangEintrag, } from '../gemeinsam/nachrichten' +import { zielVorschau } from '../gemeinsam/ablagepfad' import { DEFAULT_HB_PRESET, presetEmpfehlung } from '../gemeinsam/preset-empfehlung' import { aufVerbindung, auswerfen, einstellungSetzen, + fremdenOrdnerLoeschen, + kompressionAbbrechen, + laufwerkPruefen, makemkvUpdate, metadatenSuchen, ripAbbrechen, ripStarten, ripUeberspringen, + rohAufraeumen, schluesselPruefen, titelLesen, verbindungStarten, + vorgaengeLaden, + vorgangNeuKomprimieren, + vorgangRohLoeschen, zuordnungSetzen, type Verbindung, } from './kernverbindung' import type { MetaVorschlag } from '../gemeinsam/nachrichten' +/** TMDb-Logo (freigestellter Schriftzug) — Größen aus /configuration + * gemessen: w300 reicht für die Kachel. SVG-Logos zeigt Chromium direkt. */ +function logoUrl(pfad: string): string { + if (pfad.length === 0) return '' + if (pfad.startsWith('http')) return pfad + return `https://image.tmdb.org/t/p/w300${pfad}` +} + +function profilUrl(pfad: string): string { + if (pfad.length === 0) return '' + if (pfad.startsWith('http')) return pfad + return `https://image.tmdb.org/t/p/w185${pfad}` +} + +function datumKurz(iso: string | null): string { + if (iso === null || iso.length < 16) return '—' + return iso.slice(0, 16).replace('T', ' ') +} + +/** „1988 · FSK 16 · 124 Min · Animation · ★ 7,9" — nur, was da ist. */ +function metaZeile(info: DiscInfoStand): string { + const teile: string[] = [] + if (info.jahr !== null) teile.push(String(info.jahr)) + if (info.fsk.length > 0) teile.push(`FSK ${info.fsk}`) + if (info.laufzeitMinuten > 0) teile.push(`${info.laufzeitMinuten} Min`) + if (info.genres.length > 0) teile.push(info.genres.slice(0, 3).join(' · ')) + if (info.bewertung > 0) teile.push(`★ ${info.bewertung.toFixed(1).replace('.', ',')}`) + return teile.join(' · ') +} + +/** Vorbelegung der Sprachen je Titel: die Einstellung, soweit der Titel + * sie hat — sonst alles, was er hat (japanischer Ton statt gar keiner). */ +function sprachenVorbelegt(vorhanden: readonly SprachEintrag[], einstellung: string): string[] { + const da = vorhanden.map((s) => s.lang) + const wunsch = einstellung + .split(',') + .map((s) => s.trim().toLowerCase()) + .filter((s) => s.length > 0) + const treffer = wunsch.filter((w) => da.includes(w)) + return treffer.length > 0 ? treffer : da +} + function posterUrlKlein(pfad: string): string { if (pfad.length === 0) return '' if (pfad.startsWith('http')) return pfad @@ -404,13 +459,62 @@ function dauerText(s: number): string { : `${m}:${String(Math.round(s % 60)).padStart(2, '0')} min` } +/** Sprach-Chips eines Titels — anklickbar, außerhalb des Auswahl-Labels + * (ein Knopf IM Label würde die Checkbox mit umschalten). */ +function SprachChips({ + name, + vorhanden, + gewaehlt, + aufWechsel, +}: { + name: string + vorhanden: readonly SprachEintrag[] + gewaehlt: readonly string[] + aufWechsel: (lang: string) => void +}) { + if (vorhanden.length === 0) return null + return ( + + {name} + {vorhanden.map((s) => { + const an = gewaehlt.includes(s.lang) + return ( + + ) + })} + + ) +} + function TitelWahlDialog({ id, + typ, stand, + info, + einstellungen, + regel, schliessen, }: { id: string + typ: GeraeteInfo['typ'] stand: TitelStand | undefined + info: DiscInfoStand | undefined + einstellungen: Record + /** Klartext der Aufbewahrungs-Regel — damit klar ist, was mit dem + * Roh-Material passiert. */ + regel: string schliessen: () => void }) { const hintergrund = useHintergrundKlick(schliessen) @@ -418,9 +522,28 @@ function TitelWahlDialog({ // Eigene Rollen-Wahl je Titel; was hier nicht drinsteht, behaelt Rippys // Vorschlag ('hauptfilm' beim Film, 'folge' bei der Serie, sonst 'extra'). const [rollen, setRollen] = useState>({}) + // 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>({}) + // Staffel und erste Folge von Hand (5.4.0) — die Staffel kommt von der + // Disc, die erste Folge weiß nur der Nutzer („auf Disc 2 sind 3–4"). + const [staffel, setStaffel] = useState( + info !== undefined && info.staffel !== null ? String(info.staffel) : '', + ) + const [ersteFolge, setErsteFolge] = useState('') // Rippys Vorauswahl gilt, bis der Nutzer sie anfasst. const aktiv = gewaehlt ?? new Set((stand?.titel ?? []).filter((t) => t.gewaehlt).map((t) => t.nr)) const rolleVon = (t: DiscTitelVorschlag): TitelRolle => rollen[t.nr] ?? t.rolle + const sprachenVon = (t: DiscTitelVorschlag): { audio: string[]; untertitel: string[] } => + sprachen[t.nr] ?? { + audio: sprachenVorbelegt(t.sprachen.audio, einstellungen['audioSprachen'] ?? ''), + untertitel: sprachenVorbelegt(t.sprachen.untertitel, einstellungen['untertitelSprachen'] ?? ''), + } + const sprachWechsel = (t: DiscTitelVorschlag, art: 'audio' | 'untertitel', lang: string): void => { + const jetzt = sprachenVon(t) + const liste = jetzt[art].includes(lang) ? jetzt[art].filter((l) => l !== lang) : [...jetzt[art], lang] + setSprachen({ ...sprachen, [t.nr]: { ...jetzt, [art]: liste } }) + } const umschalten = (nr: number): void => { const neu = new Set(aktiv) if (neu.has(nr)) neu.delete(nr) @@ -428,7 +551,28 @@ function TitelWahlDialog({ setGewaehlt(neu) } const alle = stand?.titel ?? [] - const gesamtBytes = alle.filter((t) => aktiv.has(t.nr)).reduce((s, t) => s + t.groesseBytes, 0) + 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 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 vorschau = zielVorschau({ + ablage: einstellungen['ablage'] ?? 'Videos\\Rippy', + alsSerie, + titel: info?.titel ?? '', + jahr: info?.jahr ?? null, + staffel: staffelZahl, + ersteFolge: folgeZahl, + hauptTitel, + extras, + vorgangsName: `${typ}-`, + }) return (
e.stopPropagation()} >

Was soll gerippt werden?

@@ -492,54 +636,123 @@ function TitelWahlDialog({
- {alle.map((t) => ( - - ))} + {alle.map((t) => { + const an = aktiv.has(t.nr) + const wahl = sprachenVon(t) + return ( +
+ + {/* Sprachen je Titel (5.4.0): Was der Info-Lauf an DIESEM + Titel gemessen hat — anhaken, was in die Datei soll. + Der Commander-Fall: japanischer Ton, deutsche Untertitel. */} + {an && (t.sprachen.audio.length > 0 || t.sprachen.untertitel.length > 0) && ( +
+ sprachWechsel(t, 'audio', lang)} + /> + sprachWechsel(t, 'untertitel', lang)} + /> + {wahl.audio.length === 0 && t.sprachen.audio.length > 0 && ( + kein Ton gewählt — HandBrake nimmt die erste Spur + )} +
+ )} +
+ ) + })} {alle.length === 0 && (

Die Disc gab keine Titel her.

)}
+ {alsSerie && ( +
+ + +

+ Leer heißt: Rippy ordnet die Folgen über die TMDb-Laufzeiten zu, und nur, wenn es eindeutig ist. + Weißt du es besser („auf Disc 2 sind die Folgen 3 und 4"), trag es hier ein — dann zählt Rippy + fortlaufend in Disc-Reihenfolge. + {info !== undefined && info.discNr !== null && ` Die Disc nennt sich selbst Disc ${info.discNr}.`} +

+
+ )} + + {/* Vorschau (5.4.0): Rolle, Serie, Staffel und Extras entscheiden + einiges — zwei Zeilen Zielpfad ersparen Überraschungen. */} +
+

LANDET IN

+

{vorschau[0]}

+

{vorschau[1]}

+
+

- {aktiv.size} von {alle.length} gewählt · zusammen {gb(gesamtBytes)} + {aktiv.size} von {alle.length} gewählt · zusammen {gb(gesamtBytes)} verlustfrei + {regel.length > 0 ? ` · ${regel}` : ''}

- {alle.some((t) => aktiv.has(t.nr) && rolleVon(t) === 'folge') - ? 'Folgen landen unter Serien/… /Season NN. Die Episoden-Nummern setzt Rippy nur, wenn die Laufzeiten eindeutig passen — sonst behalten die Dateien ihre Namen.' + {alsSerie + ? 'Folgen landen unter Serien/… /Season NN. Ohne Vorgabe setzt Rippy die Episoden-Nummern nur, wenn die Laufzeiten eindeutig passen — sonst behalten die Dateien ihre Namen.' : 'Der Hauptfilm landet in seinem eigenen Ordner, Extras daneben im Unterordner „extras" — so lesen Jellyfin, Emby und Kodi sie als Zugaben.'} + {' '}Das Laufwerk ist frei, sobald die Roh-Dateien liegen — die Kompression läuft danach im Hintergrund.

)} @@ -555,9 +768,18 @@ function TitelWahlDialog({ onClick={() => { ripStarten( id, - alle - .filter((t) => aktiv.has(t.nr)) - .map((t) => ({ nr: t.nr, rolle: rolleVon(t), dauerS: t.dauerS })), + gewaehlteTitel.map((t) => { + const wahl = sprachenVon(t) + return { + nr: t.nr, + rolle: rolleVon(t), + dauerS: t.dauerS, + groesseBytes: t.groesseBytes, + audioSprachen: wahl.audio, + untertitelSprachen: wahl.untertitel, + } + }), + { staffel: alsSerie ? staffelZahl : null, ersteFolge: alsSerie ? folgeZahl : null }, ) schliessen() }} @@ -576,29 +798,51 @@ function LaufwerkKachel({ geraet, rip, info, - vorschlaege, ripStartMs, protokoll, verbindung, - titelStand, + bericht, + vorgang, + kompression, + wartePlatz, + aufRippen, + aufAendern, }: { geraet: GeraeteInfo rip: RipStatus | undefined info: DiscInfoStand | undefined - vorschlaege: MetaVorschlag[] ripStartMs: number | undefined protokoll: string[] verbindung: Verbindung - titelStand: TitelStand | undefined + /** Was das Windows-Protokoll zu diesem Laufwerk sagt (5.4.0). */ + bericht: LaufwerkBericht | undefined + /** Der Vorgang des letzten Rips — für den Stand der Kompression (5.4.0). */ + vorgang: VorgangEintrag | undefined + kompression: KompressionStand | null + wartePlatz: number + /** Die Dialoge leben im App-Zustand (5.4.0): Ein leerer Takt der Wache + * räumte sonst Kachel UND Dialog ab. */ + aufRippen: () => void + aufAendern: () => void }) { - const [aendernOffen, setAendernOffen] = useState(false) - const [titelOffen, setTitelOffen] = useState(false) const [protokollOffen, setProtokollOffen] = useState(false) + const [berichtOffen, setBerichtOffen] = useState(false) const ripLaeuft = rip !== undefined && LAUFENDE_PHASEN.includes(rip.phase) const discDa = geraet.status === 'ready' const backdrop = discDa && info !== undefined ? backdropUrl(info.backdropPfad) : '' const poster = discDa && info !== undefined ? posterUrlGross(info.posterPfad) : '' const erkannt = discDa && info !== undefined && info.titel.length > 0 + const logo = erkannt ? logoUrl(info.logoPfad) : '' + const berichtTon = + bericht === undefined + ? '' + : bericht.urteil === 'ruhig' + ? 'text-gruen border-gruen/40 bg-gruen/10' + : bericht.urteil === 'disc' + ? 'text-warn border-warn/45 bg-warn/10' + : bericht.urteil === 'hardware' + ? 'text-rot border-rot/45 bg-rot/10' + : 'text-dunst border-white/12 bg-samt/60' return (
@@ -639,11 +883,7 @@ function LaufwerkKachel({ ) : (
)}
+ {/* Kino-Banner v2 (5.4.0): Logo statt Text, wo TMDb eines hat — + der größte optische Sprung. Titel und Jahr bleiben lesbar. */} {erkannt ? ( -

- {info.titel} - {info.jahr !== null && ({info.jahr})} -

+ logo.length > 0 ? ( +
+ {info.titel} + + {info.titel} + {info.jahr !== null && ` (${info.jahr})`} + +
+ ) : ( +

+ {info.titel} + {info.jahr !== null && ({info.jahr})} +

+ ) ) : (

{TYP_NAMEN[geraet.typ]}

)} + {erkannt && info.tagline.length > 0 && ( +

{info.tagline}

+ )} + {erkannt && metaZeile(info).length > 0 && ( +

{metaZeile(info)}

+ )} {erkannt && info.beschreibung.length > 0 && (

{info.beschreibung}

)} @@ -725,9 +987,35 @@ function LaufwerkKachel({
+ {/* Darsteller-Streifen (5.4.0) */} + {erkannt && info.besetzung.length > 0 && ( +
+ {info.besetzung.map((d) => ( +
+ {d.bildPfad.length > 0 ? ( + + ) : ( + + {d.name + .split(' ') + .map((w) => w[0] ?? '') + .join('') + .slice(0, 2) + .toUpperCase()} + + )} + + {d.name} + {d.rolle.length > 0 && {d.rolle}} + +
+ ))} +
+ )} +
+ {erkannt && ( )}
+ {/* Was Rippy NACH dem Info-Lauf dazugelernt hat (Erkennung v2). */} + {erkannt && info.hinweise.length > 0 && ( +
+ {info.hinweise.map((h) => ( +

+ {h.includes('NICHT') ? '⚠ ' : h.includes('bestätigt') ? '✓ ' : '· '} + {h} +

+ ))} +
+ )} {geraet.grund.length > 0 &&

{geraet.grund}

}
)} + {/* Laufwerks-Gesundheit (5.4.0, Punkt 8): Disc oder Hardware? Kommt + von selbst nach einem gescheiterten Rip — oder auf Knopfdruck. */} + {bericht !== undefined && (berichtOffen || bericht.urteil !== 'ruhig') && ( +
+
+

+ + {bericht.urteil === 'ruhig' + ? 'LAUFWERK RUHIG' + : bericht.urteil === 'disc' + ? 'VERDACHT: DIE DISC' + : bericht.urteil === 'hardware' + ? 'VERDACHT: DIE HARDWARE' + : 'PROTOKOLL'} + {' '} + — {bericht.text} +

+ +
+ {bericht.letzte.length > 0 && ( +
+ {bericht.letzte.map((z, i) => ( +

+ {z} +

+ ))} +
+ )} +
+ )} + {rip !== undefined && (
@@ -820,6 +1167,39 @@ function LaufwerkKachel({ {datei}

))} + + {/* Nach dem Rip (5.4.0): Wo steht die Kompression? Das + Laufwerk ist schon frei — die Arbeit läuft im Hintergrund. */} + {rip.phase === 'fertig' && vorgang !== undefined && ( +
+ {kompression !== null ? ( + <> +

+ KOMPRESSION LÄUFT{kompression.prozent >= 0 ? ` — ${kompression.prozent} %` : ''} +

+
+ +
+

+ > {kompression.text} +

+ + ) : wartePlatz > 0 ? ( +

+ Kompression wartet — Platz {wartePlatz} in der Warteschlange. +

+ ) : vorgang.phase === 'fertig' ? ( +

+ ✓ {vorgang.komprimiert ? 'Komprimiert und abgelegt' : 'Verlustfrei abgelegt'}: {vorgang.zielOrdner} + {vorgang.fertigBytes > 0 && ` (${gb(vorgang.fertigBytes)})`} +

+ ) : vorgang.phase === 'fehler' || vorgang.phase === 'abgebrochen' ? ( +

⚠ {vorgang.fehler.length > 0 ? vorgang.fehler : `Kompression ${vorgang.phase}`}

+ ) : ( +

Kompression: {vorgang.phase}

+ )} +
+ )}
{ripLaeuft && rip.prozent >= 0 && (
@@ -848,23 +1228,267 @@ function LaufwerkKachel({
)}
+ + ) +} - {titelOffen && ( - setTitelOffen(false)} /> +// Die Kompressions-Warteschlange in der Übersicht (5.4.0): Rip und +// Kompression sind entkoppelt — hier steht, was gerade rechnet und was +// noch wartet, mit Abbruch je Auftrag. +function KompressionsKarte({ stand }: { stand: VorgaengeStand['kompression'] }) { + if (stand.laeuft === null && stand.wartend.length === 0) return null + const l = stand.laeuft + return ( +
+
+ + KOMPRESSION + · + + {l !== null ? 'läuft' : 'wartet'} + {stand.wartend.length > 0 && ` · ${stand.wartend.length} in der Warteschlange`} + +
+ {l !== null && ( +
+
+
+

{l.titel}

+
+ +
+

+ > {l.text} +

+
+ {l.prozent >= 0 && ( +
+ {l.prozent} + % +
+ )} +
+ +
)} - - {aendernOffen && ( - 0 ? info.titel : ''} - vorschlaege={vorschlaege} - schliessen={() => setAendernOffen(false)} - /> + {stand.wartend.length > 0 && ( +
+ {stand.wartend.map((w, i) => ( +
+ + Platz {i + 1} · {w.titel} + + +
+ ))} +
)}
) } +// Roh-Dateien (5.4.0): Belegung, Regel, jeder Vorgang mit „Neu +// komprimieren" und „Roh löschen", dazu Ordner, die kein Vorgang kennt. +function RohDateienKarte({ verbindung, presets }: { verbindung: Verbindung; presets: string[] }) { + const werte = verbindung.einstellungen + const v = verbindung.vorgaenge + const [presetWahl, setPresetWahl] = useState>({}) + const [alleZeigen, setAlleZeigen] = useState(false) + const regelWert = werte['rohAufbewahrung'] ?? '7' + const phasenText: Record = { + rippt: 'rippt', + wartet: 'wartet auf Kompression', + komprimiert: 'komprimiert', + 'legt-ab': 'legt ab', + fertig: 'fertig', + fehler: 'Fehler', + abgebrochen: 'abgebrochen', + } + const phasenTon = (p: VorgangEintrag['phase']): 'gruen' | 'gelb' | 'blau' | 'grau' => + p === 'fertig' ? 'gruen' : p === 'fehler' || p === 'abgebrochen' ? 'gelb' : p === 'wartet' ? 'grau' : 'blau' + const liste = v?.vorgaenge ?? [] + const sichtbar = alleZeigen ? liste : liste.slice(0, 12) + + return ( + +
+ +
+ + +
+
+
+ {v === null ? ( +

Belegung wird gemessen …

+ ) : ( + <> +

+ Belegung jetzt:{' '} + {gb(v.rohGesamtBytes)} in{' '} + {liste.filter((e) => !e.rohGeloescht).length + v.fremde.length} Roh-Ordnern unter {werte['ablage'] ?? ''}\roh +

+

{v.regel}

+

+ Unkomprimierte Rips (z. B. 4K verlustfrei) liegen im Zielordner, nicht hier — sie sind nie betroffen. +

+ + )} +
+ + {liste.length === 0 && v !== null && ( +

Noch kein Vorgang — der erste Rip legt hier einen Eintrag an.

+ )} +
+ {sichtbar.map((e) => { + const ruhig = e.phase === 'fertig' || e.phase === 'fehler' || e.phase === 'abgebrochen' + return ( +
+
+ + {e.titel} + {e.jahr !== null && ({e.jahr})} + + + + {(TYP_NAMEN[e.typ as GeraeteInfo['typ']] ?? e.typ).toUpperCase()} · {datumKurz(e.begonnen)} + {e.titelZahl > 0 && ` · ${e.titelZahl} ${e.titelZahl === 1 ? 'Titel' : 'Titel'}`} + {e.preset.length > 0 && ` · ${e.preset}`} + +
+

+ Roh:{' '} + {e.rohGeloescht ? 'gelöscht' : gb(e.rohBytes)} + {e.faelligAm.length > 0 && ` · fällig ${datumKurz(e.faelligAm)}`} + {' · '} + Fertig:{' '} + {e.phase === 'fertig' ? ( + + {e.fertigBytes > 0 ? `${gb(e.fertigBytes)} · ` : ''} + {e.zielOrdner} + {!e.komprimiert && ' (verlustfrei, ohne Kompression)'} + + ) : ( + '—' + )} +

+ {e.fehler.length > 0 &&

⚠ {e.fehler}

} +
+ {e.neuKomprimierbar && ( + <> + + + + )} + {!e.rohGeloescht && ruhig && ( + + )} + {(e.phase === 'wartet' || e.phase === 'komprimiert' || e.phase === 'legt-ab') && ( + + )} +
+
+ ) + })} +
+ {liste.length > sichtbar.length && ( + + )} + + {v !== null && v.fremde.length > 0 && ( +
+

ORDNER OHNE VORGANG

+

+ Aus der Zeit vor 5.4.0 oder von Hand hineingelegt — Rippy kennt sie nicht und löscht sie deshalb NIE von selbst. +

+
+ {v.fremde.map((f) => ( +
+ + {f.ordner} · {gb(f.bytes)} + + +
+ ))} +
+
+ )} +
+ ) +} + // Die MakeMKV-Karte (§ 9.1) — amber Glas mit dem Update-Weg aus § 9. function SchluesselKarte({ verbindung }: { verbindung: Verbindung }) { const s = verbindung.schluessel @@ -1112,6 +1736,7 @@ function Einstellungen({ verbindung, haupt }: { verbindung: Verbindung; haupt: H const SEKTIONEN: Array<[string, string]> = [ ['ablage', 'Ablage'], ['kompression', 'Kompression'], + ['roh', 'Roh-Dateien'], ['metadaten', 'Metadaten'], ['makemkv', 'MakeMKV'], ['medienserver', 'Medienserver'], @@ -1174,8 +1799,14 @@ function Einstellungen({ verbindung, haupt }: { verbindung: Verbindung; haupt: H

├─ Filme\Akira (1988)\ — Film, Poster und Infodatei beieinander

├─ Musik\ — Audio-CDs als FLAC, benannt nach MusicBrainz

├─ ISO\ — unerkannte Discs als 1:1-Abbild

-

└─ roh\ — Arbeitsstand während des Rips, räumt sich selbst auf

+

├─ Serien\Spartacus\Season 01\ — Folgen, wie Jellyfin sie erwartet

+

└─ roh\ — verlustfreies Roh-Material, aufgeräumt nach der Regel unter „Roh-Dateien"

+ setzen('autoAuswurf', an ? 'true' : 'false')} + label="Disc nach dem Rip auswerfen — die nächste kann rein, während die Kompression läuft" + /> + + -

Updates warten, solange ein Rip läuft — nie mitten im Vorgang.

+

+ Datenbank: {verbindung.kern !== null && verbindung.kern.datenbank.ok ? verbindung.kern.datenbank.pfad : '—'} +

+

Updates warten, solange ein Rip oder eine Kompression läuft — nie mitten im Vorgang.

) : (
- {verbindung.laufwerke.map((geraet) => ( - - ))} + {verbindung.laufwerke.map((geraet) => { + const ripVorgangId = verbindung.rips[geraet.id]?.vorgangId ?? '' + const laeuft = verbindung.vorgaenge?.kompression.laeuft ?? null + const wartend = verbindung.vorgaenge?.kompression.wartend ?? [] + return ( + v.id === ripVorgangId)} + kompression={laeuft !== null && laeuft.vorgangId === ripVorgangId ? laeuft : null} + wartePlatz={wartend.findIndex((w) => w.vorgangId === ripVorgangId) + 1} + aufRippen={() => { + // Erst die Titel lesen (20-120 s), dann waehlen lassen. + setDialog({ art: 'titel', id: geraet.id }) + titelLesen(geraet.id) + }} + aufAendern={() => setDialog({ art: 'aendern', id: geraet.id })} + /> + ) + })}
)} + {verbindung.vorgaenge !== null && ( +
+ +
+ )} +
+ {dialog !== null && dialog.art === 'titel' && ( + l.id === dialog.id)?.typ ?? 'bluray'} + stand={verbindung.discTitel[dialog.id]} + info={verbindung.discInfos[dialog.id]} + einstellungen={verbindung.einstellungen} + regel={verbindung.vorgaenge?.regel ?? ''} + schliessen={() => setDialog(null)} + /> + )} + {dialog !== null && dialog.art === 'aendern' && ( + setDialog(null)} + /> + )} + {verbindung.meldungen.length > 0 && (
EREIGNISSE
diff --git a/rippy-windows/src/fenster/kernverbindung.ts b/rippy-windows/src/fenster/kernverbindung.ts index 5a25e18..d3be20c 100644 --- a/rippy-windows/src/fenster/kernverbindung.ts +++ b/rippy-windows/src/fenster/kernverbindung.ts @@ -11,12 +11,16 @@ import { type BeschaffungStand, type BibliothekEintrag, type DiscInfoStand, + type FensterNachricht, type GeraeteInfo, type KernNachricht, + type LaufwerkBericht, type MetaVorschlag, - type TitelStand, + type RipAuftragOptionen, type RipAuftragTitel, type RipStatus, + type TitelStand, + type VorgaengeStand, type WerkzeugAuskunft, } from '../gemeinsam/nachrichten' @@ -54,13 +58,18 @@ export interface Verbindung { } | null /** Stand der MakeMKV-Beschaffung (§ 9) — null, solange nie eine lief. */ beschaffung: BeschaffungStand | null + /** Vorgänge, Roh-Belegung und Kompressions-Warteschlange (5.4.0) — null, + * solange der Kern noch nichts geschickt hat. */ + vorgaenge: VorgaengeStand | null + /** Was das Windows-Protokoll je Laufwerk sagt (5.4.0) — bleibt stehen. */ + gesundheit: Record /** Kurze Ereigniszeilen (neueste zuerst): Disc rein/raus, Auswurf, Fehler. */ meldungen: string[] } type Horcher = (stand: Verbindung) => void -const MAX_MELDUNGEN = 20 +const MAX_MELDUNGEN = 30 const stand: Verbindung = { verbunden: false, @@ -78,27 +87,32 @@ const stand: Verbindung = { bibliothek: [], schluessel: null, beschaffung: null, + vorgaenge: null, + gesundheit: {}, meldungen: [], } const horcher = new Set() let gestartet = false let port: MessagePort | null = null -function melden(): void { - for (const h of horcher) { - h({ - ...stand, - laufwerke: [...stand.laufwerke], - rips: { ...stand.rips }, - discInfos: { ...stand.discInfos }, - vorschlaege: { ...stand.vorschlaege }, - einstellungen: { ...stand.einstellungen }, - bibliothek: [...stand.bibliothek], - meldungen: [...stand.meldungen], - }) +function kopie(): Verbindung { + return { + ...stand, + laufwerke: [...stand.laufwerke], + rips: { ...stand.rips }, + discInfos: { ...stand.discInfos }, + vorschlaege: { ...stand.vorschlaege }, + einstellungen: { ...stand.einstellungen }, + bibliothek: [...stand.bibliothek], + gesundheit: { ...stand.gesundheit }, + meldungen: [...stand.meldungen], } } +function melden(): void { + for (const h of horcher) h(kopie()) +} + function meldung(text: string): void { stand.meldungen = [text, ...stand.meldungen].slice(0, MAX_MELDUNGEN) } @@ -116,6 +130,9 @@ function kernNachricht(nachricht: KernNachricht): void { case 'kern-fehler': meldung(`⚠ ${nachricht.text}`) break + case 'kern-hinweis': + meldung(nachricht.text) + break case 'laufwerke': stand.laufwerke = nachricht.liste for (const ereignis of nachricht.ereignisse) { @@ -137,11 +154,20 @@ function kernNachricht(nachricht: KernNachricht): void { break } case 'disc-info': { + const vorher = stand.discInfos[nachricht.info.id] stand.discInfos = { ...stand.discInfos, [nachricht.info.id]: nachricht.info } if (nachricht.info.titel.length > 0) { - meldung( - `Laufwerk ${nachricht.info.id}: erkannt als „${nachricht.info.titel}${nachricht.info.jahr !== null ? ` (${nachricht.info.jahr})` : ''}“ · ${Math.round(nachricht.info.confidence * 100)} %`, - ) + // Dieselbe Erkennung nicht zweimal melden — die Nachprüfung nach + // dem Info-Lauf schickt den Stand erneut, mit Hinweisen. + const neuErkannt = vorher === undefined || vorher.titel !== nachricht.info.titel + if (neuErkannt) { + meldung( + `Laufwerk ${nachricht.info.id}: erkannt als „${nachricht.info.titel}${nachricht.info.jahr !== null ? ` (${nachricht.info.jahr})` : ''}“ · ${Math.round(nachricht.info.confidence * 100)} %`, + ) + } + for (const h of nachricht.info.hinweise) { + if (vorher === undefined || !vorher.hinweise.includes(h)) meldung(`Laufwerk ${nachricht.info.id}: ${h}`) + } } break } @@ -169,6 +195,18 @@ function kernNachricht(nachricht: KernNachricht): void { stand.beschaffung = nachricht.stand if (nachricht.stand.fehler) meldung(`⚠ MakeMKV-Update: ${nachricht.stand.text}`) break + case 'vorgaenge': + stand.vorgaenge = nachricht.stand + break + case 'kompression-ergebnis': + meldung(`${nachricht.ok ? '' : '⚠ '}Kompression „${nachricht.titel}": ${nachricht.text}`) + break + case 'laufwerk-gesundheit': + stand.gesundheit = { ...stand.gesundheit, [nachricht.id]: nachricht.bericht } + if (nachricht.bericht.urteil === 'hardware' || nachricht.bericht.urteil === 'disc') { + meldung(`Laufwerk ${nachricht.id}: ${nachricht.bericht.text}`) + } + break case 'rip-status': { stand.rips = { ...stand.rips, [nachricht.status.id]: nachricht.status } const laeuft = ['liest-info', 'rippt', 'komprimiert', 'legt-ab'].includes(nachricht.status.phase) @@ -201,6 +239,9 @@ function kernNachricht(nachricht: KernNachricht): void { } break } + case 'installer-starten': + // Nur fürs Haupt gedacht — im Fenster nichts zu tun. + break } melden() } @@ -213,10 +254,11 @@ function portAnnehmen(neuerPort: MessagePort): void { if (istKernNachricht(nachricht)) kernNachricht(nachricht) } neuerPort.start() - neuerPort.postMessage({ art: 'ping', zeit: Date.now() }) - neuerPort.postMessage({ art: 'einstellungen-laden' }) - neuerPort.postMessage({ art: 'werkzeuge-laden' }) - neuerPort.postMessage({ art: 'bibliothek-laden' }) + senden({ art: 'ping', zeit: Date.now() }) + senden({ art: 'einstellungen-laden' }) + senden({ art: 'werkzeuge-laden' }) + senden({ art: 'bibliothek-laden' }) + senden({ art: 'vorgaenge-laden' }) melden() } @@ -241,20 +283,16 @@ export function verbindungStarten(): void { export function aufVerbindung(h: Horcher): () => void { horcher.add(h) - h({ - ...stand, - laufwerke: [...stand.laufwerke], - rips: { ...stand.rips }, - discInfos: { ...stand.discInfos }, - vorschlaege: { ...stand.vorschlaege }, - einstellungen: { ...stand.einstellungen }, - bibliothek: [...stand.bibliothek], - meldungen: [...stand.meldungen], - }) + h(kopie()) return () => horcher.delete(h) } -function anKern(nachricht: { art: string; id: string; auftrag?: RipAuftragTitel[] }, wasWar: string): void { +function senden(nachricht: FensterNachricht): void { + port?.postMessage(nachricht) +} + +/** Schickt eine Nachricht — und sagt es, wenn kein Kern da ist (R4). */ +function anKern(nachricht: FensterNachricht, wasWar: string): void { if (port === null) { meldung(`⚠ ${wasWar}: keine Verbindung zum Kern`) melden() @@ -273,8 +311,8 @@ export function titelLesen(id: string): void { } /** `auftrag` leer heißt: alles rippen (wie vor 5.2). */ -export function ripStarten(id: string, auftrag: RipAuftragTitel[] = []): void { - anKern({ art: 'rip-start', id, auftrag }, `Rip ${id}`) +export function ripStarten(id: string, auftrag: RipAuftragTitel[] = [], optionen: RipAuftragOptionen = {}): void { + anKern({ art: 'rip-start', id, auftrag, optionen }, `Rip ${id}`) } /** Den laufenden Titel aufgeben und mit dem nächsten weitermachen. */ @@ -287,34 +325,50 @@ export function ripAbbrechen(id: string): void { } export function metadatenSuchen(id: string, text: string): void { - if (port === null) return - port.postMessage({ art: 'metadaten-suchen', id, text }) + senden({ art: 'metadaten-suchen', id, text }) } export function zuordnungSetzen(id: string, vorschlag: MetaVorschlag): void { - if (port === null) return - port.postMessage({ art: 'zuordnung-setzen', id, vorschlag }) + senden({ art: 'zuordnung-setzen', id, vorschlag }) } export function makemkvUpdate(): void { - if (port === null) { - meldung('⚠ MakeMKV-Update: keine Verbindung zum Kern') - melden() - return - } - port.postMessage({ art: 'makemkv-update' }) + anKern({ art: 'makemkv-update' }, 'MakeMKV-Update') } export function schluesselPruefen(): void { - if (port === null) return - port.postMessage({ art: 'schluessel-pruefen' }) + senden({ art: 'schluessel-pruefen' }) } export function einstellungSetzen(schluessel: string, wert: string): void { - if (port === null) { - meldung(`⚠ Einstellung ${schluessel}: keine Verbindung zum Kern`) - melden() - return - } - port.postMessage({ art: 'einstellung-setzen', schluessel, wert }) + anKern({ art: 'einstellung-setzen', schluessel, wert }, `Einstellung ${schluessel}`) +} + +// ── 5.4.0: Vorgänge, Roh-Dateien, Warteschlange, Laufwerks-Gesundheit ── +export function vorgaengeLaden(): void { + senden({ art: 'vorgaenge-laden' }) +} + +export function vorgangRohLoeschen(vorgangId: string): void { + anKern({ art: 'vorgang-roh-loeschen', vorgangId }, 'Roh-Dateien löschen') +} + +export function vorgangNeuKomprimieren(vorgangId: string, preset: string): void { + anKern({ art: 'vorgang-neu-komprimieren', vorgangId, preset }, 'Neu komprimieren') +} + +export function kompressionAbbrechen(vorgangId: string): void { + anKern({ art: 'kompression-abbrechen', vorgangId }, 'Kompression abbrechen') +} + +export function rohAufraeumen(): void { + anKern({ art: 'roh-aufraeumen' }, 'Roh-Dateien aufräumen') +} + +export function fremdenOrdnerLoeschen(ordner: string): void { + anKern({ art: 'fremden-ordner-loeschen', ordner }, 'Ordner löschen') +} + +export function laufwerkPruefen(id: string): void { + anKern({ art: 'laufwerk-pruefen', id }, `Laufwerk ${id} prüfen`) } diff --git a/rippy-windows/src/gemeinsam/ablagepfad.ts b/rippy-windows/src/gemeinsam/ablagepfad.ts new file mode 100644 index 0000000..c41d4e2 --- /dev/null +++ b/rippy-windows/src/gemeinsam/ablagepfad.ts @@ -0,0 +1,74 @@ +// Ablage-Namen und die Zielpfad-Vorschau — PUR, ohne Dateisystem, damit +// Kern UND Fenster dieselbe Regel benutzen (5.4.0: „Vorschau, welche Datei +// landet wo" im Auswahl-Dialog). Die Kern-Seite (kern/ablage/struktur.ts) +// baut auf diesen Namen ihre echten Ordner. + +// Zeichen, die in Ordnernamen auf SMB/NTFS/ext4 Ärger machen. +// eslint-Hinweis unnötig: bewusst inklusive Steuerzeichen. +// eslint-disable-next-line no-control-regex +const VERBOTEN = /[<>:"/\\|?*\x00-\x1f]/g + +/** Titel → dateisystem-tauglicher Ordnername (pur). */ +export function sichererName(titel: string, jahr?: number | string | null): string { + let name = (titel ?? '').replace(VERBOTEN, '').trim().replace(/\.+$/, '') + name = name.replace(/\s+/g, ' ').slice(0, 150).trim() + if (name.length === 0) return '' + // Kein doppeltes Jahr: OMDb-Titel tragen es teils schon im Namen. + if (jahr !== undefined && jahr !== null && String(jahr).length > 0 && !name.endsWith(`(${jahr})`)) { + name = `${name} (${jahr})` + } + return name +} + +export function staffelOrdnerName(staffel: number): string { + return `Season ${String(Math.trunc(staffel)).padStart(2, '0')}` +} + +export function folgenDateiname(serie: string, staffel: number, folge: number): string { + const s = String(Math.trunc(staffel)).padStart(2, '0') + const e = String(Math.trunc(folge)).padStart(2, '0') + return `${sichererName(serie) || 'Serie'} S${s}E${e}.mkv` +} + +export interface VorschauEingabe { + ablage: string + alsSerie: boolean + titel: string + jahr: number | null + staffel: number | null + ersteFolge: number | null + /** Zahl der gewählten Folgen (Serie) bzw. Hauptfilme (Film). */ + hauptTitel: number + extras: number + /** Der Vorgangsname — gilt, wenn kein Titel erkannt ist. */ + vorgangsName: string +} + +/** Zwei Zeilen: Zielordner und was darin landet (pur). Windows-Trenner, + * weil die Vorschau eine Windows-Ablage beschreibt. */ +export function zielVorschau(e: VorschauEingabe): [string, string] { + const wurzel = e.ablage.replace(/[\\/]+$/, '') + const titel = e.titel.trim().length > 0 ? e.titel.trim() : e.vorgangsName + if (e.alsSerie) { + const staffel = e.staffel ?? 1 + const ordner = `${wurzel}\\Serien\\${sichererName(titel) || 'Unbekannte Serie'}\\${staffelOrdnerName(staffel)}\\` + const n = Math.max(1, e.hauptTitel) + let inhalt: string + if (e.ersteFolge !== null && e.ersteFolge > 0) { + const erste = folgenDateiname(titel, staffel, e.ersteFolge) + inhalt = + n === 1 + ? erste + : `${erste} … ${folgenDateiname(titel, staffel, e.ersteFolge + n - 1)} (${n} Folgen, fortlaufend)` + } else { + inhalt = `${n} ${n === 1 ? 'Folge' : 'Folgen'} — Nummern aus den TMDb-Laufzeiten, sonst bleiben die MakeMKV-Namen` + } + if (e.extras > 0) inhalt += ` · extras\\ (${e.extras})` + return [ordner, inhalt] + } + const ordner = `${wurzel}\\Filme\\${sichererName(titel, e.jahr) || e.vorgangsName}\\` + const teile: string[] = [] + if (e.hauptTitel > 0) teile.push(e.hauptTitel === 1 ? 'der Hauptfilm' : `${e.hauptTitel} Hauptfilme`) + if (e.extras > 0) teile.push(`extras\\ (${e.extras} ${e.extras === 1 ? 'Zugabe' : 'Zugaben'})`) + return [ordner, teile.length > 0 ? teile.join(' · ') : 'nichts gewählt'] +} diff --git a/rippy-windows/src/haupt/index.ts b/rippy-windows/src/haupt/index.ts index 759ea37..792ea1d 100644 --- a/rippy-windows/src/haupt/index.ts +++ b/rippy-windows/src/haupt/index.ts @@ -94,6 +94,12 @@ function start(): void { } } else if (nachricht.art === 'installer-starten') { void installerStarten(nachricht.pfad) + } else if (nachricht.art === 'vorgaenge') { + // 5.4.0: Die Kompression läuft OHNE Laufwerk weiter — Updates + // warten auch auf sie, und die Taskleiste zeigt ihren Fortschritt. + kompressionVerarbeiten(nachricht.stand.kompression.laeuft) + } else if (nachricht.art === 'kompression-ergebnis' && !smokeModus) { + toast(nachricht.ok ? 'Rippy ist fertig' : 'Rippy: Kompression fehlgeschlagen', `${nachricht.titel}: ${nachricht.text}`) } else if (nachricht.art === 'disc-info' && nachricht.info.titel.length > 0 && !smokeModus) { // Windows-Toast beim Erkennen (§ 7) — Klick holt das Fenster hoch. toast( @@ -109,6 +115,43 @@ function start(): void { smokeModus, ) + // Die laufende Kompression (5.4.0) — ein eigener Schlüssel in den + // aktiven Vorgängen, weil sie an keinem Laufwerk mehr hängt. + const KOMPRESSION = 'kompression' + let kompressionStand: { titel: string; prozent: number } | null = null + + function kompressionVerarbeiten(laeuft: { titel: string; prozent: number } | null): void { + kompressionStand = laeuft === null ? null : { titel: laeuft.titel, prozent: laeuft.prozent } + if (laeuft === null) aktiveVorgaenge.delete(KOMPRESSION) + else aktiveVorgaenge.add(KOMPRESSION) + anzeigeAktualisieren() + } + + /** Taskleiste und Tray aus dem, was gerade läuft: ein Rip zuerst, sonst + * die Kompression — sonst nichts. */ + let letzterRip: Extract['status'] | null = null + function anzeigeAktualisieren(): void { + const rip = letzterRip !== null && letzterRip.phase === 'rippt' ? letzterRip : null + if (fenster !== null && !fenster.isDestroyed()) { + if (rip !== null && rip.prozent >= 0) { + fenster.setProgressBar(rip.prozent / 100) + } else if (letzterRip !== null && letzterRip.phase === 'fehler') { + fenster.setProgressBar(1, { mode: 'error' }) + } else if (kompressionStand !== null && kompressionStand.prozent >= 0) { + fenster.setProgressBar(kompressionStand.prozent / 100) + } else { + fenster.setProgressBar(-1) + } + } + if (rip !== null) { + trayHinweis(`rippt Laufwerk ${rip.id}${rip.prozent >= 0 ? ` (${rip.prozent} %)` : ''}`) + } else if (kompressionStand !== null) { + trayHinweis(`komprimiert „${kompressionStand.titel}"${kompressionStand.prozent >= 0 ? ` (${kompressionStand.prozent} %)` : ''}`) + } else { + trayHinweis('') + } + } + // § 7: Toasts, Taskleisten-Fortschritt und Tray-Zustand aus dem // Rip-Status — die Information, die man will, ohne das Fenster zu öffnen. function ripStatusVerarbeiten(status: Extract['status']): void { @@ -117,25 +160,11 @@ function start(): void { } else { aktiveVorgaenge.delete(status.id) } - if (fenster !== null && !fenster.isDestroyed()) { - if ((status.phase === 'rippt' || status.phase === 'komprimiert') && status.prozent >= 0) { - fenster.setProgressBar(status.prozent / 100) - } else if (status.phase === 'fehler') { - fenster.setProgressBar(1, { mode: 'error' }) - } else { - fenster.setProgressBar(-1) - } - } - if (status.phase === 'rippt') { - trayHinweis(`rippt Laufwerk ${status.id}${status.prozent >= 0 ? ` (${status.prozent} %)` : ''}`) - } else if (status.phase === 'komprimiert') { - trayHinweis(`komprimiert Laufwerk ${status.id}${status.prozent >= 0 ? ` (${status.prozent} %)` : ''}`) - } else { - trayHinweis('') - } + letzterRip = status + anzeigeAktualisieren() if (smokeModus) return if (status.phase === 'fertig') { - toast('Rippy ist fertig', status.text) + toast('Rippy: Rip fertig', status.text) } else if (status.phase === 'fehler') { toast('Rippy: Fehler', status.text) } diff --git a/rippy-windows/src/kern/ablage/struktur.ts b/rippy-windows/src/kern/ablage/struktur.ts index 6fea322..c1eb815 100644 --- a/rippy-windows/src/kern/ablage/struktur.ts +++ b/rippy-windows/src/kern/ablage/struktur.ts @@ -11,23 +11,11 @@ import { existsSync, readdirSync, renameSync } from 'node:fs' // müssen dort physisch funktionieren. Feste Windows-Muster (Katalog, // %ProgramFiles%) bleiben dagegen bei path.win32. import { join } from 'node:path' +// EINE Namensregel für Kern und Fenster (5.4.0: die Zielpfad-Vorschau im +// Auswahl-Dialog rechnet mit derselben Funktion). +import { sichererName, staffelOrdnerName } from '../../gemeinsam/ablagepfad' -// Zeichen, die in Ordnernamen auf SMB/NTFS/ext4 Ärger machen. -// eslint-Hinweis unnötig: bewusst inklusive Steuerzeichen. -// eslint-disable-next-line no-control-regex -const VERBOTEN = /[<>:"/\\|?*\x00-\x1f]/g - -/** Titel → dateisystem-tauglicher Ordnername (pur). */ -export function sichererName(titel: string, jahr?: number | string | null): string { - let name = (titel ?? '').replace(VERBOTEN, '').trim().replace(/\.+$/, '') - name = name.replace(/\s+/g, ' ').slice(0, 150).trim() - if (name.length === 0) return '' - // Kein doppeltes Jahr: OMDb-Titel tragen es teils schon im Namen. - if (jahr !== undefined && jahr !== null && String(jahr).length > 0 && !name.endsWith(`(${jahr})`)) { - name = `${name} (${jahr})` - } - return name -} +export { sichererName } /** Zielordner: sprechender Name; existiert er schon (Disc doppelt * gerippt), wird die Job-Kennung angehängt statt zu überschreiben. */ @@ -48,7 +36,7 @@ export function zielordner( /** Jellyfin-Konvention: //Season NN (pur). */ export function serienOrdner(basis: string, serie: string, staffel: number): string { const name = sichererName(serie) || 'Unbekannte Serie' - return join(basis, name, `Season ${String(Math.trunc(staffel)).padStart(2, '0')}`) + return join(basis, name, staffelOrdnerName(staffel)) } /** Ordnet Dateien Episoden zu — über Laufzeiten, ordnungserhaltend.