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>
200 lines
6.5 KiB
TypeScript
200 lines
6.5 KiB
TypeScript
// Tests der Ablage — sichere Namen, Zielstruktur, Episoden-Zuordnung, NFO.
|
|
// Testfälle sinngemäß aus test_medien.py (Docker-Zweig).
|
|
import { join } from 'node:path'
|
|
import { describe, expect, it } from 'vitest'
|
|
import { baueNfo } from '../src/kern/ablage/nfo'
|
|
import {
|
|
episodenUmbenennen,
|
|
matcheEpisoden,
|
|
serienOrdner,
|
|
sichererName,
|
|
zielordner,
|
|
} from '../src/kern/ablage/struktur'
|
|
import { parseEncoderListe, leiteBackendsAb, parsePresetListe } from '../src/kern/komprimieren/encoder'
|
|
|
|
describe('sichererName', () => {
|
|
it('entfernt Verbotenes, behält Leerzeichen, hängt das Jahr an', () => {
|
|
expect(sichererName('Der Herr der Ringe: Die Gefährten', 2001)).toBe(
|
|
'Der Herr der Ringe Die Gefährten (2001)',
|
|
)
|
|
})
|
|
it('kein doppeltes Jahr (der OMDb-Fund)', () => {
|
|
expect(sichererName('X (2009)', 2009)).toBe('X (2009)')
|
|
})
|
|
it('leerer Titel bleibt leer, Punkte am Ende fallen', () => {
|
|
expect(sichererName('...')).toBe('')
|
|
expect(sichererName('Film.', null)).toBe('Film')
|
|
})
|
|
})
|
|
|
|
describe('zielordner', () => {
|
|
it('sprechender Name; bei Kollision Job-Kennung statt Überschreiben', () => {
|
|
// Erwartungen über join() — die Funktionen arbeiten mit den
|
|
// Pfadregeln der LAUFZEIT-Plattform (Linux-CI testet mit POSIX).
|
|
expect(zielordner('Filme', 'Akira', 1988, 'job-1234abcd', () => false)).toBe(
|
|
join('Filme', 'Akira (1988)'),
|
|
)
|
|
expect(zielordner('Filme', 'Akira', 1988, 'job-1234abcd', () => true)).toBe(
|
|
join('Filme', 'Akira (1988) [job-1234]'),
|
|
)
|
|
expect(zielordner('Filme', '', null, 'job-1', () => false)).toBe(join('Filme', 'job-1'))
|
|
})
|
|
})
|
|
|
|
describe('serienOrdner', () => {
|
|
it('Jellyfin-Schema Serie/Season NN', () => {
|
|
expect(serienOrdner('Serien', 'Dr. Who?', 2)).toBe(join('Serien', 'Dr. Who', 'Season 02'))
|
|
})
|
|
})
|
|
|
|
describe('matcheEpisoden', () => {
|
|
it('Stufe 1: komplette Staffel in Reihenfolge — auch bei uniformen Zeiten', () => {
|
|
expect(
|
|
matcheEpisoden(
|
|
[1420, 1425, 1418],
|
|
[
|
|
[1, 1420],
|
|
[2, 1420],
|
|
[3, 1420],
|
|
],
|
|
),
|
|
).toEqual([1, 2, 3])
|
|
})
|
|
it('Stufe 2: distinkte Laufzeiten, ordnungserhaltend', () => {
|
|
expect(
|
|
matcheEpisoden(
|
|
[1200, 2400],
|
|
[
|
|
[1, 1210],
|
|
[2, 2390],
|
|
[3, 3000],
|
|
],
|
|
),
|
|
).toEqual([1, 2])
|
|
})
|
|
it('mehrdeutig heißt null — lieber gar nicht als falsch', () => {
|
|
expect(
|
|
matcheEpisoden(
|
|
[1420, 1421],
|
|
[
|
|
[1, 1420],
|
|
[2, 1420],
|
|
[3, 1420],
|
|
],
|
|
),
|
|
).toBeNull()
|
|
})
|
|
it('keine Daten heißt null', () => {
|
|
expect(matcheEpisoden([], [[1, 100]])).toBeNull()
|
|
expect(matcheEpisoden([100], [])).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('episodenUmbenennen', () => {
|
|
it('benennt in Disc-Reihenfolge um, überschreibt nie', () => {
|
|
const umbenannt: Array<[string, string]> = []
|
|
const meldungen = episodenUmbenennen('S', 'Akte X', 1, [3, 4], {
|
|
listen: () => ['title_t01.mkv', 'title_t00.mkv'],
|
|
existiert: () => false,
|
|
umbenennen: (von, nach) => umbenannt.push([von, nach]),
|
|
})
|
|
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)
|
|
})
|
|
it('Zahl passt nicht → gar nichts anfassen', () => {
|
|
const meldungen = episodenUmbenennen('E:\\S', 'X', 1, [1], {
|
|
listen: () => ['a.mkv', 'b.mkv'],
|
|
existiert: () => false,
|
|
umbenennen: () => {
|
|
throw new Error('darf nicht')
|
|
},
|
|
})
|
|
expect(meldungen[0]).toContain('übersprungen')
|
|
})
|
|
})
|
|
|
|
describe('baueNfo', () => {
|
|
it('movie.nfo mit XML-Escaping', () => {
|
|
const nfo = baueNfo(
|
|
{ beschreibung: 'Roboter & Menschen', genres: ['Sci-Fi'], laufzeitMinuten: 124 },
|
|
'Akira <Director\'s Cut>',
|
|
1988,
|
|
)
|
|
expect(nfo).toContain('<movie>')
|
|
expect(nfo).toContain('<title>Akira <Director\'s Cut></title>')
|
|
expect(nfo).toContain('<plot>Roboter & Menschen</plot>')
|
|
expect(nfo).toContain('<year>1988</year>')
|
|
expect(nfo).toContain('<runtime>124</runtime>')
|
|
expect(nfo).toContain('Source: Rippy')
|
|
})
|
|
it('Serie bekommt tvshow als Wurzel', () => {
|
|
expect(baueNfo({ typ: 'serie' }, 'Akte X')).toContain('<tvshow>')
|
|
})
|
|
})
|
|
|
|
// caps.py-Fixtures: das --help- und --preset-list-Format, am echten
|
|
// HandBrake gemessen (25./26.07.2026).
|
|
describe('Encoder- und Preset-Listen', () => {
|
|
it('parseEncoderListe liest den -e-Block bis zur nächsten Option', () => {
|
|
const hilfe = [
|
|
' -e, --encoder <string> Select video encoder:',
|
|
' svt_av1',
|
|
' x264',
|
|
' x265',
|
|
' nvenc_h264',
|
|
' --encoder-preset <string>',
|
|
' ignored',
|
|
].join('\n')
|
|
expect(parseEncoderListe(hilfe)).toEqual(['svt_av1', 'x264', 'x265', 'nvenc_h264'])
|
|
})
|
|
it('leiteBackendsAb: Familien statt Sammelbegriff, AV1 sichtbar', () => {
|
|
expect(leiteBackendsAb(['x264', 'x265', 'svt_av1', 'vce_h265', 'vce_av1'])).toEqual([
|
|
'cpu-x264',
|
|
'cpu-x265',
|
|
'cpu-av1',
|
|
'vce',
|
|
'vce-av1',
|
|
])
|
|
expect(leiteBackendsAb([])).toEqual([])
|
|
})
|
|
it('parsePresetListe: genau vier Leerzeichen sind ein Name', () => {
|
|
const liste = [
|
|
'General/',
|
|
' Very Fast 2160p60 4K AV1',
|
|
' AV1 video (up to 2160p60) and AAC stereo audio.',
|
|
'Hardware/',
|
|
' H.265 VCN 2160p 4K',
|
|
'[10:26:05] qsv: not available on this system',
|
|
].join('\n')
|
|
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)
|
|
})
|
|
})
|