// Zielstruktur für Jellyfin/Emby/Kodi/Plex (KONZEPT § 4.2) — Regeln aus // medien.py: Ordner heißt „ (Jahr)" (daran erkennen die Server den // Film; jellyfin.org/docs …/movies, support.plex.tv „Naming"), Serien // liegen als /Season NN. Episoden werden NUR bei EINDEUTIGER // Zuordnung umbenannt — lieber gar nicht als falsch (ARMs offene Wunde // #395 war genau das Ratespiel). import { existsSync, readdirSync, renameSync } from 'node:fs' // Plattform-Pfadregeln MIT ABSICHT: Diese Funktionen bauen auf // LAUFZEIT-Wurzeln auf (die Ablage-Einstellung) — zur echten Laufzeit ist // das immer Windows, in der Linux-CI testen sie mit POSIX-Ordnern und // 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' export { sichererName } /** Zielordner: sprechender Name; existiert er schon (Disc doppelt * gerippt), wird die Job-Kennung angehängt statt zu überschreiben. */ export function zielordner( basis: string, titel: string, jahr: number | string | null, jobId: string, existiert: (p: string) => boolean = existsSync, ): string { const name = sichererName(titel, jahr) if (name.length === 0) return join(basis, jobId) const ziel = join(basis, name) if (existiert(ziel)) return join(basis, `${name} [${jobId.slice(0, 8)}]`) return ziel } /** 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, staffelOrdnerName(staffel)) } /** Ordnet Dateien Episoden zu — über Laufzeiten, ordnungserhaltend. * Rückgabe: Episoden-Nummern je Datei, oder null wenn NICHT eindeutig. * * Stufe 1: komplette Staffel (Dateizahl == Episodenzahl) → 1:1 in * Reihenfolge. Stufe 2: Einzel-Zuordnung über distinkte Laufzeiten; * passt eine Datei auf mehrere Episoden ähnlich gut → null. */ export function matcheEpisoden( dateiSekunden: readonly number[], episoden: ReadonlyArray, toleranzProzent = 8.0, toleranzMinS = 180, ): number[] | null { if (dateiSekunden.length === 0 || episoden.length === 0) return null const toleranz = (dauer: number): number => Math.max(toleranzMinS, (dauer * toleranzProzent) / 100) if (dateiSekunden.length === episoden.length) { const passt = dateiSekunden.every((dauer, i) => { const epDauer = episoden[i][1] return epDauer > 0 && Math.abs(dauer - epDauer) <= toleranz(dauer) }) if (passt) return episoden.map(([nr]) => nr) } const zuordnung: number[] = [] let start = 0 for (const dauer of dateiSekunden) { const kandidaten = episoden .slice(start) .filter(([, epDauer]) => epDauer > 0 && Math.abs(dauer - epDauer) <= toleranz(dauer)) if (kandidaten.length === 0) return null let gewaehlt = kandidaten[0] if (kandidaten.length > 1) { const sortiert = [...kandidaten].sort((a, b) => Math.abs(dauer - a[1]) - Math.abs(dauer - b[1])) // Nur eindeutig, wenn der beste KLAR besser passt (doppelt so nah). if (!(Math.abs(dauer - sortiert[0][1]) * 2 < Math.abs(dauer - sortiert[1][1]))) return null gewaehlt = sortiert[0] } zuordnung.push(gewaehlt[0]) start = episoden.findIndex(([nr]) => nr === gewaehlt[0]) + 1 } return zuordnung } /** Benennt MKVs nach Jellyfin-Schema um: „Serie S01E02.mkv". Reihenfolge = * sortierte Dateinamen (MakeMKV nummeriert in Disc-Reihenfolge). */ export function episodenUmbenennen( ordner: string, serie: string, staffel: number, zuordnung: readonly number[], fs: { listen: (o: string) => string[] existiert: (p: string) => boolean umbenennen: (von: string, nach: string) => void } = { listen: (o) => readdirSync(o), existiert: existsSync, umbenennen: renameSync, }, ): string[] { const meldungen: string[] = [] const dateien = fs .listen(ordner) .filter((f) => f.endsWith('.mkv')) .sort() if (dateien.length !== zuordnung.length) { return [`Umbenennen übersprungen: ${dateien.length} Dateien, ${zuordnung.length} Zuordnungen`] } const serienName = sichererName(serie) || 'Serie' for (let i = 0; i < dateien.length; i++) { const alt = dateien[i] const neu = `${serienName} S${String(Math.trunc(staffel)).padStart(2, '0')}E${String(Math.trunc(zuordnung[i])).padStart(2, '0')}.mkv` if (alt === neu) continue const ziel = join(ordner, neu) if (fs.existiert(ziel)) { meldungen.push(`Umbenennen übersprungen (${neu} existiert schon)`) continue } fs.umbenennen(join(ordner, alt), ziel) meldungen.push(`${alt} → ${neu}`) } return meldungen }