From 904e4b2ef6bd1c0edd526e0450ad3974a09852a6 Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Sat, 12 Sep 2026 21:31:38 +0200 Subject: [PATCH] =?UTF-8?q?fix(v5):=20Serien=20=C3=BCber=20mehrere=20Discs?= =?UTF-8?q?=20=E2=80=94=20nur=20die=20eigenen=20Folgen=20benennen,=20nie?= =?UTF-8?q?=20=C3=BCberschreiben;=20HandBrake=20schreibt=20auf=20Zwischenn?= =?UTF-8?q?amen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 „.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 --- rippy-windows/src/kern/ablage/dateinamen.ts | 20 ++++++ rippy-windows/src/kern/ablage/struktur.ts | 21 ++++-- rippy-windows/src/kern/ablauf/ablegen.ts | 40 ++++++++---- .../src/kern/komprimieren/handbrake.ts | 65 ++++++++++++++++++- rippy-windows/test/ablage.test.ts | 25 +++++++ rippy-windows/test/ablegen.test.ts | 48 ++++++++++++++ rippy-windows/test/dateinamen.test.ts | 14 ++++ rippy-windows/test/handbrake.test.ts | 19 ++++++ 8 files changed, 231 insertions(+), 21 deletions(-) diff --git a/rippy-windows/src/kern/ablage/dateinamen.ts b/rippy-windows/src/kern/ablage/dateinamen.ts index c114ce6..04ee477 100644 --- a/rippy-windows/src/kern/ablage/dateinamen.ts +++ b/rippy-windows/src/kern/ablage/dateinamen.ts @@ -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}` +} diff --git a/rippy-windows/src/kern/ablage/struktur.ts b/rippy-windows/src/kern/ablage/struktur.ts index c1eb815..92794f4 100644 --- a/rippy-windows/src/kern/ablage/struktur.ts +++ b/rippy-windows/src/kern/ablage/struktur.ts @@ -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`] } diff --git a/rippy-windows/src/kern/ablauf/ablegen.ts b/rippy-windows/src/kern/ablauf/ablegen.ts index cc6aee3..f566122 100644 --- a/rippy-windows/src/kern/ablauf/ablegen.ts +++ b/rippy-windows/src/kern/ablauf/ablegen.ts @@ -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( diff --git a/rippy-windows/src/kern/komprimieren/handbrake.ts b/rippy-windows/src/kern/komprimieren/handbrake.ts index e3a6468..270b0d4 100644 --- a/rippy-windows/src/kern/komprimieren/handbrake.ts +++ b/rippy-windows/src/kern/komprimieren/handbrake.ts @@ -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 0–100 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 { 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 { 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 { 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 }) }) }) } diff --git a/rippy-windows/test/ablage.test.ts b/rippy-windows/test/ablage.test.ts index 65dec94..9bb811e 100644 --- a/rippy-windows/test/ablage.test.ts +++ b/rippy-windows/test/ablage.test.ts @@ -172,3 +172,28 @@ describe('Encoder- und Preset-Listen', () => { expect(parsePresetListe(liste)).toEqual(['Very Fast 2160p60 4K AV1', 'H.265 VCN 2160p 4K']) }) }) + +// 5.7.0 (Durchsicht F3): Nur die Dateien DIESER Disc werden benannt — der +// Season-Ordner darf voll sein. +describe('episodenUmbenennen mit nurDiese (5.7.0)', () => { + it('Bestand im Ordner stört nicht', () => { + const umbenannt: Array<[string, string]> = [] + const meldungen = episodenUmbenennen( + 'S', + 'Akte X', + 1, + [3, 4], + { + listen: () => ['Akte X S01E01.mkv', 'Akte X S01E02.mkv', 'title_t00.mkv', 'title_t01.mkv'], + existiert: () => false, + umbenennen: (von, nach) => umbenannt.push([von, nach]), + }, + ['title_t00.mkv', 'title_t01.mkv'], + ) + expect(umbenannt).toEqual([ + [join('S', 'title_t00.mkv'), join('S', 'Akte X S01E03.mkv')], + [join('S', 'title_t01.mkv'), join('S', 'Akte X S01E04.mkv')], + ]) + expect(meldungen).toHaveLength(2) + }) +}) diff --git a/rippy-windows/test/ablegen.test.ts b/rippy-windows/test/ablegen.test.ts index 10df288..2651e03 100644 --- a/rippy-windows/test/ablegen.test.ts +++ b/rippy-windows/test/ablegen.test.ts @@ -347,3 +347,51 @@ describe('Wiederholen ersetzt die alte Fassung', () => { expect(readFileSync(alt, 'utf8')).toBe('alt') }) }) + +// ── 5.7.0: Funde der Durchsicht vom 12.09.2026 ───────────────────────── +describe('komprimierenUndAblegen — Durchsicht 5.7.0', () => { + // F3: Disc 2 derselben Staffel landet im selben Season-Ordner wie Disc 1. + // Vorher zählte die Umbenennung ALLE MKVs des Ordners („4 Dateien, 2 + // Zuordnungen") und tat nichts — der Serien-Merker lief ins Leere. + it('F3: zweite Disc in denselben Season-Ordner — nur ihre Folgen werden benannt', async () => { + const { ablage, roh, dateien } = aufbau(['title_t00.mkv', 'title_t01.mkv'], ['folge', 'folge']) + const season = join(ablage, 'Serien', 'Spartacus', 'Season 01') + mkdirSync(season, { recursive: true }) + writeFileSync(join(season, 'Spartacus S01E01.mkv'), 'disc1') + writeFileSync(join(season, 'Spartacus S01E02.mkv'), 'disc1') + const ergebnis = await komprimierenUndAblegen( + auftrag({ rohOrdner: roh, dateien, zuordnung: zuordnung('tv', 'Spartacus', 2010), staffel: 1, ersteFolge: 3 }), + umgebung(ablage), + () => {}, + KEIN_ABBRUCH, + ) + expect(ergebnis.status).toBe('fertig') + expect(readdirSync(season).filter((n) => n.endsWith('.mkv')).sort()).toEqual([ + 'Spartacus S01E01.mkv', + 'Spartacus S01E02.mkv', + 'Spartacus S01E03.mkv', + 'Spartacus S01E04.mkv', + ]) + expect(readFileSync(join(season, 'Spartacus S01E01.mkv'), 'utf8')).toBe('disc1') + expect(ergebnis.folgenVergeben).toEqual([3, 4]) + }) + + // F3: Liegt im Season-Ordner noch ein title_t00.mkv der ersten Disc + // (deren Umbenennung ausfiel), darf die zweite es NICHT überschreiben. + it('F3: gleichnamige Datei im Ziel wird nie überschrieben — „(2)" statt Verlust', async () => { + const { ablage, roh, dateien } = aufbau(['title_t00.mkv'], ['folge']) + const season = join(ablage, 'Serien', 'Spartacus', 'Season 01') + mkdirSync(season, { recursive: true }) + writeFileSync(join(season, 'title_t00.mkv'), 'disc1-unbenannt') + const ergebnis = await komprimierenUndAblegen( + auftrag({ rohOrdner: roh, dateien, zuordnung: zuordnung('tv', 'Spartacus', 2010), staffel: 1 }), + umgebung(ablage), + () => {}, + KEIN_ABBRUCH, + ) + expect(ergebnis.status).toBe('fertig') + expect(readFileSync(join(season, 'title_t00.mkv'), 'utf8')).toBe('disc1-unbenannt') + expect(existsSync(join(season, 'title_t00 (2).mkv'))).toBe(true) + expect(ergebnis.dateien.map((d) => d.split(/[\\/]/).pop())).toEqual(['title_t00 (2).mkv']) + }) +}) diff --git a/rippy-windows/test/dateinamen.test.ts b/rippy-windows/test/dateinamen.test.ts index de5954a..313ddfa 100644 --- a/rippy-windows/test/dateinamen.test.ts +++ b/rippy-windows/test/dateinamen.test.ts @@ -40,3 +40,17 @@ describe('rollenNummern', () => { ]) }) }) + +// 5.7.0 (Durchsicht F3): nie überschreiben — ein freier Name, wo einer +// nötig ist. +describe('freierName', () => { + it('frei bleibt frei, belegt bekommt (2), (3) …', async () => { + const { freierName } = await import('../src/kern/ablage/dateinamen') + const belegt = new Set(['E:\\S\\x.mkv', 'E:\\S\\x (2).mkv']) + expect(freierName('E:\\S\\y.mkv', (p) => belegt.has(p))).toBe('E:\\S\\y.mkv') + expect(freierName('E:\\S\\x.mkv', (p) => belegt.has(p))).toBe('E:\\S\\x (3).mkv') + expect(freierName('/a/b/x.mkv', () => true).startsWith('/a/b/x (')).toBe(true) + // Ein Punkt im Ordnernamen ist keine Endung. + expect(freierName('E:\\Akira (1988)\\ohne-endung', (p) => p === 'E:\\Akira (1988)\\ohne-endung')).toBe('E:\\Akira (1988)\\ohne-endung (2)') + }) +}) diff --git a/rippy-windows/test/handbrake.test.ts b/rippy-windows/test/handbrake.test.ts index 0de3bab..09de07b 100644 --- a/rippy-windows/test/handbrake.test.ts +++ b/rippy-windows/test/handbrake.test.ts @@ -220,3 +220,22 @@ describe('etaAusZeile — Restzeit aus der Encoding-Zeile (5.6.0, Format aus dem expect(a.verarbeite('Encoding: task 1 of 1, 11.00 %')).toEqual({ prozent: 11 }) }) }) + +// 5.7.0 (Durchsicht F9): HandBrake schreibt auf einen Zwischennamen; eine +// halbe Datei trägt nie den echten Namen. +describe('Zwischenname (5.7.0)', () => { + it('zwischenname und endname gehören zusammen', async () => { + const { zwischenname, endname } = await import('../src/kern/komprimieren/handbrake') + expect(zwischenname('E:\\Filme\\Akira (1988)\\Akira (1988).mkv')).toBe('E:\\Filme\\Akira (1988)\\Akira (1988).teil.mkv') + expect(endname('E:\\Filme\\Akira (1988)\\Akira (1988).teil.mkv')).toBe('E:\\Filme\\Akira (1988)\\Akira (1988).mkv') + expect(endname('E:\\x\\title_t00.teil.mp4')).toBe('E:\\x\\title_t00.mp4') + expect(zwischenname('ohne-endung')).toBe('ohne-endung.teil') + }) + it('zwischenReste räumt die Zwischen-Datei und ihre Datei daneben', async () => { + const { zwischenReste } = await import('../src/kern/komprimieren/handbrake') + const da = new Set(['E:\\x\\a.teil.mkv', 'E:\\x\\a.teil.mp4']) + const weg: string[] = [] + expect(zwischenReste('E:\\x\\a.teil.mkv', (p) => weg.push(p), (p) => da.has(p))).toEqual(['E:\\x\\a.teil.mkv', 'E:\\x\\a.teil.mp4']) + expect(zwischenReste('E:\\x\\b.teil.mkv', (p) => weg.push(p), (p) => da.has(p))).toEqual([]) + }) +})