feat(v5): Oberflaeche 5.4.0 — Sprachen je Titel, Staffel/Folge, Vorschau, Kino-Banner v2, Roh-Dateien, Warteschlange

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 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-01 22:13:22 +02:00
co-authored by Claude Fable 5.1
parent 3f7f962dff
commit e3ce8220f1
7 changed files with 1046 additions and 171 deletions
+48
View File
@@ -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",
+1
View File
@@ -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",
File diff suppressed because it is too large Load Diff
+106 -52
View File
@@ -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<string, LaufwerkBericht>
/** 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<Horcher>()
let gestartet = false
let port: MessagePort | null = null
function melden(): void {
for (const h of horcher) {
h({
...stand,
laufwerke: [...stand.laufwerke],
rips: { ...stand.rips },
discInfos: { ...stand.discInfos },
vorschlaege: { ...stand.vorschlaege },
einstellungen: { ...stand.einstellungen },
bibliothek: [...stand.bibliothek],
meldungen: [...stand.meldungen],
})
function kopie(): Verbindung {
return {
...stand,
laufwerke: [...stand.laufwerke],
rips: { ...stand.rips },
discInfos: { ...stand.discInfos },
vorschlaege: { ...stand.vorschlaege },
einstellungen: { ...stand.einstellungen },
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`)
}
+74
View File
@@ -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']
}
+46 -17
View File
@@ -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<KernNachricht, { art: 'rip-status' }>['status'] | null = null
function anzeigeAktualisieren(): void {
const rip = letzterRip !== null && letzterRip.phase === 'rippt' ? letzterRip : null
if (fenster !== null && !fenster.isDestroyed()) {
if (rip !== null && rip.prozent >= 0) {
fenster.setProgressBar(rip.prozent / 100)
} else if (letzterRip !== null && letzterRip.phase === 'fehler') {
fenster.setProgressBar(1, { mode: 'error' })
} else if (kompressionStand !== null && kompressionStand.prozent >= 0) {
fenster.setProgressBar(kompressionStand.prozent / 100)
} else {
fenster.setProgressBar(-1)
}
}
if (rip !== null) {
trayHinweis(`rippt Laufwerk ${rip.id}${rip.prozent >= 0 ? ` (${rip.prozent} %)` : ''}`)
} else if (kompressionStand !== null) {
trayHinweis(`komprimiert „${kompressionStand.titel}"${kompressionStand.prozent >= 0 ? ` (${kompressionStand.prozent} %)` : ''}`)
} else {
trayHinweis('')
}
}
// § 7: Toasts, Taskleisten-Fortschritt und Tray-Zustand aus dem
// Rip-Status — die Information, die man will, ohne das Fenster zu öffnen.
function ripStatusVerarbeiten(status: Extract<KernNachricht, { art: 'rip-status' }>['status']): void {
@@ -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)
}
+5 -17
View File
@@ -11,23 +11,11 @@ import { existsSync, readdirSync, renameSync } from 'node:fs'
// müssen dort physisch funktionieren. Feste Windows-Muster (Katalog,
// %ProgramFiles%) bleiben dagegen bei path.win32.
import { join } from 'node:path'
// EINE Namensregel für Kern und Fenster (5.4.0: die Zielpfad-Vorschau im
// Auswahl-Dialog rechnet mit derselben Funktion).
import { sichererName, staffelOrdnerName } from '../../gemeinsam/ablagepfad'
// Zeichen, die in Ordnernamen auf SMB/NTFS/ext4 Ärger machen.
// eslint-Hinweis unnötig: bewusst inklusive Steuerzeichen.
// eslint-disable-next-line no-control-regex
const VERBOTEN = /[<>:"/\\|?*\x00-\x1f]/g
/** Titel → dateisystem-tauglicher Ordnername (pur). */
export function sichererName(titel: string, jahr?: number | string | null): string {
let name = (titel ?? '').replace(VERBOTEN, '').trim().replace(/\.+$/, '')
name = name.replace(/\s+/g, ' ').slice(0, 150).trim()
if (name.length === 0) return ''
// Kein doppeltes Jahr: OMDb-Titel tragen es teils schon im Namen.
if (jahr !== undefined && jahr !== null && String(jahr).length > 0 && !name.endsWith(`(${jahr})`)) {
name = `${name} (${jahr})`
}
return name
}
export { sichererName }
/** Zielordner: sprechender Name; existiert er schon (Disc doppelt
* gerippt), wird die Job-Kennung angehängt statt zu überschreiben. */
@@ -48,7 +36,7 @@ export function zielordner(
/** Jellyfin-Konvention: <basis>/<Serie>/Season NN (pur). */
export function serienOrdner(basis: string, serie: string, staffel: number): string {
const name = sichererName(serie) || 'Unbekannte Serie'
return join(basis, name, `Season ${String(Math.trunc(staffel)).padStart(2, '0')}`)
return join(basis, name, staffelOrdnerName(staffel))
}
/** Ordnet Dateien Episoden zu — über Laufzeiten, ordnungserhaltend.