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>
126 lines
4.9 KiB
TypeScript
126 lines
4.9 KiB
TypeScript
// Zielstruktur für Jellyfin/Emby/Kodi/Plex (KONZEPT § 4.2) — Regeln aus
|
|
// medien.py: Ordner heißt „<Titel> (Jahr)" (daran erkennen die Server den
|
|
// Film; jellyfin.org/docs …/movies, support.plex.tv „Naming"), Serien
|
|
// liegen als <Serie>/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: <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, 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<readonly [nummer: number, laufzeitS: number]>,
|
|
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
|
|
}
|