fix(v5): Serien über mehrere Discs — nur die eigenen Folgen benennen, nie überschreiben; HandBrake schreibt auf Zwischennamen

WAS:
- episodenUmbenennen bekommt die Dateien DIESER Disc (nurDiese) statt
  den ganzen Season-Ordner zu zählen. Vorher hieß es bei Disc 2 „über-
  sprungen: 4 Dateien, 2 Zuordnungen", sobald Disc 1 dort schon S01E01/
  S01E02 abgelegt hatte — Serien-Merker und „ab Folge" liefen ins Leere.
- freierName: eine vorhandene Datei gleichen Namens im Ziel wird nie
  ersetzt („title_t00 (2).mkv") — außer beim Wiederholen, wo die alte
  Fassung ausdrücklich weicht (auftrag.ersetzt).
- komprimieren() lässt HandBrake auf „<name>.teil.mkv" schreiben und
  benennt erst die fertige Datei um; nach Abbruch oder Fehler werden die
  Reste (auch eine Datei daneben) entfernt. Vorher blieb eine halbe Datei
  unter dem endgültigen Namen liegen — ohne Arbeitsordner direkt in der
  Ablage, wo Jellyfin sie als kaputten Film zeigte.
WARUM: Durchsicht 12.09.2026, Funde F3 und F9 — mit Tests (Season-Ordner
mit Bestand, Kollision, Zwischenname, Reste).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-12 21:31:38 +02:00
co-authored by Claude Opus 5
parent 8c4456d005
commit 904e4b2ef6
8 changed files with 231 additions and 21 deletions
@@ -45,3 +45,23 @@ export function rollenNummern(rollen: readonly TitelRolle[]): Array<[number, num
return [n, gesamt.get(r) ?? 1]
})
}
/** Ein Name, der im Ordner noch frei ist (5.7.0, Durchsicht F3): `x.mkv`,
* sonst `x (2).mkv`, `x (3).mkv` … — nie eine vorhandene Datei
* überschreiben. Der Fall: Disc 2 einer Staffel legt `title_t00.mkv` in
* denselben Season-Ordner, in dem Disc 1 ihr `title_t00.mkv` noch trägt
* (Umbenennung ausgefallen) — HandBrake hätte sie still ersetzt. Pur:
* `existiert` kommt von außen; getrennt wird am letzten Punkt des Namens,
* Pfadtrenner beider Welten bleiben unangetastet. */
export function freierName(pfad: string, existiert: (p: string) => boolean): string {
if (!existiert(pfad)) return pfad
const trenner = Math.max(pfad.lastIndexOf('/'), pfad.lastIndexOf('\\'))
const punkt = pfad.lastIndexOf('.')
const stamm = punkt > trenner + 1 ? pfad.slice(0, punkt) : pfad
const endung = punkt > trenner + 1 ? pfad.slice(punkt) : ''
for (let n = 2; n < 1000; n++) {
const kandidat = `${stamm} (${n})${endung}`
if (!existiert(kandidat)) return kandidat
}
return `${stamm} (${Date.now()})${endung}`
}
+16 -5
View File
@@ -84,7 +84,14 @@ export function matcheEpisoden(
}
/** Benennt MKVs nach Jellyfin-Schema um: „Serie S01E02.mkv". Reihenfolge =
* sortierte Dateinamen (MakeMKV nummeriert in Disc-Reihenfolge). */
* sortierte Dateinamen (MakeMKV nummeriert in Disc-Reihenfolge).
*
* 5.7.0 (Durchsicht F3): `nurDiese` nennt die Dateien DIESER Disc (nur
* Namen, in Ablage-Reihenfolge). Vorher zählte die Funktion ALLE MKVs im
* Season-Ordner — und sobald Disc 1 dort schon S01E01/S01E02 abgelegt
* hatte, hieß es bei Disc 2 „übersprungen: 4 Dateien, 2 Zuordnungen".
* Der Serien-Merker lief damit ab der zweiten Disc ins Leere. Ohne
* `nurDiese` gilt das alte Verhalten (alle MKVs des Ordners). */
export function episodenUmbenennen(
ordner: string,
serie: string,
@@ -99,12 +106,16 @@ export function episodenUmbenennen(
existiert: existsSync,
umbenennen: renameSync,
},
nurDiese?: readonly string[],
): string[] {
const meldungen: string[] = []
const dateien = fs
.listen(ordner)
.filter((f) => f.endsWith('.mkv'))
.sort()
const dateien =
nurDiese !== undefined
? [...nurDiese]
: fs
.listen(ordner)
.filter((f) => f.endsWith('.mkv'))
.sort()
if (dateien.length !== zuordnung.length) {
return [`Umbenennen übersprungen: ${dateien.length} Dateien, ${zuordnung.length} Zuordnungen`]
}
+27 -13
View File
@@ -34,7 +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 { freierName, rollenNummern, zielDateiname } from '../ablage/dateinamen'
import { baueNfo } from '../ablage/nfo'
import { bibliothekRefresh } from '../ablage/medienserver'
import { posterSpeichern } from '../ablage/poster'
@@ -189,24 +189,35 @@ export async function komprimierenUndAblegen(
// Laufzeiten der FOLGEN in Ablage-Reihenfolge — Grundlage der
// Episoden-Zuordnung weiter unten.
const folgenSekunden: number[] = []
/** Die Folgen DIESER Disc (Zielnamen, Ablage-Reihenfolge) — nur die werden
* umbenannt, nicht der ganze Season-Ordner (5.7.0, F3). */
const folgenDateien: string[] = []
// 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))
// Beim Wiederholen darf die neue Fassung den alten Namen tragen — die alte
// Datei fällt erst, wenn die neue fertig ist (unten). Alles andere, was
// schon dasteht, wird NIE überschrieben (F3): dann „x (2).mkv".
const ersetzt = new Set(auftrag.ersetzt ?? [])
const freiOderErsetzt = (pfad: string): string => (ersetzt.has(pfad) ? pfad : freierName(pfad, existsSync))
for (let i = 0; i < dateien.length; i++) {
const datei = dateien[i]
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),
}),
const zielDatei = freiOderErsetzt(
join(
zielFuer(datei.rolle),
zielDateiname({
titel: zuordnung?.titel ?? '',
jahr: zuordnung?.jahr ?? null,
alsSerie,
rolle: datei.rolle,
nummer,
anzahl,
original: basename(datei.pfad),
}),
),
)
if (datei.rolle === 'folge') folgenDateien.push(basename(zielDatei))
if (abbruch.aborted) return abgebrochen(fertigOrdner, fertigeDateien, preset, komprimiert, hinweise)
if (!komprimiert) {
melden({
@@ -266,6 +277,9 @@ export async function komprimierenUndAblegen(
audioSprachen: wahl.audio,
untertitelSprachen: wahl.untertitel,
untertitelStandard: wahl.untertitelStandard,
// Beim Wiederholen darf die neue Fassung die alte gleichen Namens
// ersetzen (ohne Arbeitsordner schreibt HandBrake direkt ins Ziel).
ueberschreiben: zwischen.length === 0 && ersetzt.has(zielDatei),
abbruch,
aufFortschritt: (prozent, restS) => {
melden({
@@ -346,7 +360,7 @@ export async function komprimierenUndAblegen(
episoden = laufzeiten.length > 0 ? matcheEpisoden(folgenSekunden, laufzeiten) : null
}
if (episoden !== null) {
hinweise.push(...episodenUmbenennen(fertigOrdner, serienTitel, staffel, episoden))
hinweise.push(...episodenUmbenennen(fertigOrdner, serienTitel, staffel, episoden, undefined, folgenDateien))
folgenVergeben = episoden
} else {
hinweise.push(
@@ -16,7 +16,7 @@
// - Abbruch bei JEDER Zeile prüfen, nicht nur bei Prozent-Änderung —
// sonst sieht „Abbrechen" bei einem 4K-Encode minutenlang tot aus.
import { werkzeugSpawn } from '../werkzeuge/aufruf'
import { existsSync } from 'node:fs'
import { existsSync, renameSync, unlinkSync } from 'node:fs'
// win32-Pfadregeln ausdrücklich — die CI prüft diese Logik auf Linux.
import { win32 as pfad } from 'node:path'
import { textVon } from '../rip/parser'
@@ -264,14 +264,55 @@ export interface HbLaufOptionen {
untertitelStandard?: boolean
/** Zusätzliche Schalter ANS ENDE (z. B. --stop-at duration:5 im Messlauf). */
extraArgs?: readonly string[]
/** Die fertige Datei darf eine vorhandene gleichen Namens ersetzen —
* nur beim Wiederholen (auftrag.ersetzt); sonst ist das ein Fehler. */
ueberschreiben?: boolean
/** Fortschritt 0100 und Restzeit in Sekunden (-1 = noch keine Angabe). */
aufFortschritt?: (prozent: number, restS: number) => void
abbruch?: AbortSignal
}
/** Der Zwischenname, unter dem HandBrake schreibt (5.7.0, Durchsicht F9):
* `Akira (1988).mkv` → `Akira (1988).teil.mkv`. Die Endung bleibt, damit
* --format und die Datei-daneben-Prüfung weiter an der Endung hängen;
* erst eine FERTIGE Datei bekommt den echten Namen. Vorher blieb nach
* Abbruch oder Fehler eine halbe Datei unter dem endgültigen Namen liegen
* — ohne Arbeitsordner direkt in der Ablage, wo Jellyfin sie als kaputten
* Film zeigte. */
export function zwischenname(ausgabe: string): string {
const endung = extname(ausgabe)
return endung.length > 0 ? ausgabe.slice(0, -endung.length) + '.teil' + endung : ausgabe + '.teil'
}
/** `x.teil.mkv` → `x.mkv` (auch für die Datei daneben: `x.teil.mp4` → `x.mp4`). */
export function endname(zwischen: string): string {
return zwischen.replace(/\.teil(\.[^.\\/]+)$/, '$1')
}
/** Reste eines abgebrochenen oder gescheiterten Laufs entfernen — die
* Zwischen-Datei und, falls HandBrake einen anderen Container wählte,
* ihre Datei daneben. Liefert, was weg ist. */
export function zwischenReste(zwischen: string, entfernen: (p: string) => void = unlinkSync, existiert: (p: string) => boolean = existsSync): string[] {
const weg: string[] = []
for (const kandidat of [zwischen, dateiDaneben(zwischen, existiert)]) {
if (kandidat.length === 0 || !existiert(kandidat)) continue
try {
entfernen(kandidat)
weg.push(kandidat)
} catch {
// Eine Datei, die sich nicht löschen lässt (noch offen?), bleibt —
// sie heißt „.teil" und ist damit als halb erkennbar.
}
}
return weg
}
export function komprimieren(optionen: HbLaufOptionen): Promise<HbErgebnis> {
return new Promise((fertig) => {
const args = buildHandbrakeArgs(optionen)
// HandBrake schreibt auf den Zwischennamen; der echte Name kommt erst
// mit dem Erfolg (F9).
const zwischen = zwischenname(optionen.ausgabe)
const args = buildHandbrakeArgs({ ...optionen, ausgabe: zwischen })
args.push(...(optionen.extraArgs ?? []))
const kind = werkzeugSpawn(optionen.programm, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
@@ -294,6 +335,7 @@ export function komprimieren(optionen: HbLaufOptionen): Promise<HbErgebnis> {
kind.on('error', (fehler) => {
optionen.abbruch?.removeEventListener('abort', abbrechen)
zwischenReste(zwischen)
fertig({ status: 'error', fehler: String(fehler) })
})
@@ -301,10 +343,27 @@ export function komprimieren(optionen: HbLaufOptionen): Promise<HbErgebnis> {
optionen.abbruch?.removeEventListener('abort', abbrechen)
leser.schluss().forEach(zeile)
if (abgebrochen) {
zwischenReste(zwischen)
fertig({ status: 'cancelled' })
return
}
fertig(auswertung.urteil(code, optionen.ausgabe))
const urteil = auswertung.urteil(code, zwischen)
if (urteil.status !== 'success') {
zwischenReste(zwischen)
fertig(urteil)
return
}
// Fertig: Zwischenname → echter Name (eine vorhandene Datei gleichen
// Namens wäre ein Fehler in der Ablage-Logik — nie still überschreiben).
const ziel = endname(urteil.ausgabe)
try {
if (optionen.ueberschreiben !== true && existsSync(ziel)) throw new Error(`${basename(ziel)} existiert schon`)
renameSync(urteil.ausgabe, ziel)
} catch (fehler) {
fertig({ status: 'error', fehler: `Die fertige Datei ließ sich nicht umbenennen (${String(fehler)}) — sie liegt unter ${urteil.ausgabe}` })
return
}
fertig({ ...urteil, ausgabe: ziel })
})
})
}