fix(v5): Laufzeit-Pfade plattformneutral — pipeline/struktur auf node:path
Ampel / ampel (push) Successful in 1m21s
Ampel / ampel (push) Successful in 1m21s
Der NFO-Ablage-Test brach in der Linux-CI: pipeline/struktur bauten mit path.win32 auf POSIX-Testordnern (mkdir legte 'x\Filme' als EINEN Namen an, readdir fand nichts). Regel jetzt dokumentiert: Module ueber LAUFZEIT-Wurzeln (Ablage) nutzen plattform-path — zur echten Laufzeit immer Windows, in der CI physisch korrekt auf POSIX; Module mit FESTER Windows-Semantik (katalog, dateiDaneben) bleiben bei path.win32. Testerwartungen entsprechend ueber join() statt Literale. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
3dc296f071
commit
e840b7f327
@@ -5,9 +5,12 @@
|
||||
// Zuordnung umbenannt — lieber gar nicht als falsch (ARMs offene Wunde
|
||||
// #395 war genau das Ratespiel).
|
||||
import { existsSync, readdirSync, renameSync } from 'node:fs'
|
||||
import { win32 as pfad } from 'node:path'
|
||||
|
||||
const { join } = pfad
|
||||
// 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'
|
||||
|
||||
// Zeichen, die in Ordnernamen auf SMB/NTFS/ext4 Ärger machen.
|
||||
// eslint-Hinweis unnötig: bewusst inklusive Steuerzeichen.
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
// dieser Etappe GRUNDSÄTZLICH BEHALTEN — Löschen bekommt erst mit der
|
||||
// Einstellung im UI (W-5) eine Stimme.
|
||||
import { copyFileSync, mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { win32 as pfad } from 'node:path'
|
||||
// Plattform-Pfade wie in struktur.ts: Laufzeit-Wurzeln, CI testet auf Linux.
|
||||
import { basename, join } from 'node:path'
|
||||
import type { GeraeteInfo, RipStatus } from '../../gemeinsam/nachrichten'
|
||||
import { baueNfo } from '../ablage/nfo'
|
||||
import { bibliothekRefresh } from '../ablage/medienserver'
|
||||
@@ -17,8 +18,6 @@ import { komprimieren } from '../komprimieren/handbrake'
|
||||
import { komprimierenFuer, presetFuer, sprachliste, type Einstellungen } from '../komprimieren/presets'
|
||||
import { rippen } from '../rip/makemkv'
|
||||
|
||||
const { basename, join } = pfad
|
||||
|
||||
interface LaufenderVorgang {
|
||||
abbruch: AbortController
|
||||
status: RipStatus
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
// Was die Disc über sich selbst verrät (KONZEPT § 5, Schritt 3) — Regeln
|
||||
// aus prescan.py, dort an echten Discs gemessen:
|
||||
//
|
||||
// Quelle 1 (beste): BDMV/META/DL/bdmt_*.xml — der Studio-Titel im
|
||||
// Klartext. Unter Windows hängt Windows die Disc selbst ein (G:\).
|
||||
// Quelle 2: Volume-Label (ISO-9660 für DVDs, UDF für Blu-rays ohne
|
||||
// ISO-Bridge) — Labels wie BD_EVG_D2 taugen erst normalisiert.
|
||||
//
|
||||
// KEIN makemkvcon hier: Der Zweig kostete 20–120 s je eingelegter Disc
|
||||
// und sein Ergebnis las niemand (der 30.08.-Fund „das erkennen dauert
|
||||
// sehr sehr lange").
|
||||
import { closeSync, existsSync, openSync, readdirSync, readFileSync, readSync } from 'node:fs'
|
||||
import { win32 as pfad } from 'node:path'
|
||||
|
||||
/** 'PULP_FICTION_DE' → 'Pulp Fiction De' (pur). */
|
||||
export function normalizeDiscLabel(label: string): string {
|
||||
if (label.length === 0) return ''
|
||||
const bereinigt = label.replaceAll('_', ' ').replaceAll('.', ' ').split(/\s+/).filter(Boolean).join(' ')
|
||||
if (bereinigt === bereinigt.toUpperCase()) {
|
||||
return bereinigt
|
||||
.toLowerCase()
|
||||
.split(' ')
|
||||
.map((w) => (w.length > 0 ? w[0].toUpperCase() + w.slice(1) : w))
|
||||
.join(' ')
|
||||
}
|
||||
return bereinigt
|
||||
}
|
||||
|
||||
/** UDF d-string (ECMA-167 1/7.2.12): letztes Byte = Länge, erstes =
|
||||
* Kompressions-ID (8 = Latin-1, 16 = UTF-16BE). Pur, testbar. */
|
||||
export function parseUdfDstring(daten: Buffer): string {
|
||||
if (daten.length === 0) return ''
|
||||
const laenge = daten[daten.length - 1]
|
||||
if (laenge < 2 || laenge > daten.length - 1) return ''
|
||||
const compId = daten[0]
|
||||
const text = daten.subarray(1, laenge)
|
||||
if (compId === 8) return text.toString('latin1').trim()
|
||||
if (compId === 16) {
|
||||
// UTF-16BE: Bytes tauschen, dann als LE lesen (Node kennt nur utf16le).
|
||||
const getauscht = Buffer.alloc(text.length - (text.length % 2))
|
||||
for (let i = 0; i + 1 < text.length; i += 2) {
|
||||
getauscht[i] = text[i + 1]
|
||||
getauscht[i + 1] = text[i]
|
||||
}
|
||||
return getauscht.toString('utf16le').trim()
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const SEKTOR = 2048
|
||||
|
||||
function lesenBei(fd: number, position: number, laenge: number): Buffer {
|
||||
const puffer = Buffer.alloc(laenge)
|
||||
const gelesen = readSync(fd, puffer, 0, laenge, position)
|
||||
return puffer.subarray(0, gelesen)
|
||||
}
|
||||
|
||||
/** ISO-9660-Volume-Label: Sektor 16, Bytes 40–71 (DVDs). '' wenn keins. */
|
||||
export function isoVolumeLabel(geraetepfad: string): string {
|
||||
let fd: number
|
||||
try {
|
||||
fd = openSync(geraetepfad, 'r')
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
try {
|
||||
const pvd = lesenBei(fd, 16 * SEKTOR, SEKTOR)
|
||||
if (pvd.length < 72 || !pvd.subarray(1, 6).equals(Buffer.from('CD001', 'ascii'))) return ''
|
||||
return pvd.subarray(40, 72).toString('latin1').trim()
|
||||
} catch {
|
||||
return ''
|
||||
} finally {
|
||||
closeSync(fd)
|
||||
}
|
||||
}
|
||||
|
||||
/** UDF-Volume-Label (Blu-rays ohne ISO-Bridge): AVDP bei Sektor 256 →
|
||||
* Main VDS → PVD (Tag 1), Volume Identifier ab Offset 24 (ECMA-167). */
|
||||
export function udfVolumeLabel(geraetepfad: string): string {
|
||||
let fd: number
|
||||
try {
|
||||
fd = openSync(geraetepfad, 'r')
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
try {
|
||||
const avdp = lesenBei(fd, 256 * SEKTOR, SEKTOR)
|
||||
if (avdp.length < 24 || avdp.readUInt16LE(0) !== 2) return ''
|
||||
const vdsOrt = avdp.readUInt32LE(20)
|
||||
for (let i = 0; i < 32; i++) {
|
||||
const block = lesenBei(fd, (vdsOrt + i) * SEKTOR, SEKTOR)
|
||||
if (block.length < 56) return ''
|
||||
const tagId = block.readUInt16LE(0)
|
||||
if (tagId === 1) return parseUdfDstring(block.subarray(24, 56))
|
||||
if (tagId === 8) return '' // Terminating Descriptor
|
||||
}
|
||||
return ''
|
||||
} catch {
|
||||
return ''
|
||||
} finally {
|
||||
closeSync(fd)
|
||||
}
|
||||
}
|
||||
|
||||
/** Der Studio-Titel aus BDMV/META/DL/bdmt_*.xml — bdmt_eng bevorzugt,
|
||||
* Regex statt XML-Parser (Namespaces variieren je Authoring-Werkzeug). */
|
||||
export function titelAusBdmt(wurzel: string): string {
|
||||
const metaOrdner = pfad.join(wurzel, 'BDMV', 'META', 'DL')
|
||||
if (!existsSync(metaOrdner)) return ''
|
||||
let kandidaten: string[]
|
||||
try {
|
||||
kandidaten = readdirSync(metaOrdner).sort()
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
const bdmt =
|
||||
kandidaten.find((k) => k === 'bdmt_eng.xml') ??
|
||||
kandidaten.find((k) => k.startsWith('bdmt_') && k.endsWith('.xml'))
|
||||
if (bdmt === undefined) return ''
|
||||
let inhalt: string
|
||||
try {
|
||||
inhalt = readFileSync(pfad.join(metaOrdner, bdmt)).subarray(0, 65536).toString('utf8')
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
const treffer = /<di:name>([^<]{2,120})<\/di:name>/.exec(inhalt)
|
||||
return treffer === null ? '' : treffer[1].trim()
|
||||
}
|
||||
|
||||
/** \\.\G: → G:\ — unter Windows hängt Windows die Disc selbst ein. */
|
||||
export function discWurzel(geraetepfad: string): string {
|
||||
const buchstabe = geraetepfad.replace(/:$/, '').split('\\').pop()!.replace(/:$/, '')
|
||||
const wurzel = buchstabe + ':\\'
|
||||
return existsSync(wurzel) ? wurzel : ''
|
||||
}
|
||||
|
||||
export interface DiscTitel {
|
||||
titel: string
|
||||
/** 'bdmt' (Studio-Klartext) | 'label' (normalisiert) | '' (nichts). */
|
||||
quelle: 'bdmt' | 'label' | ''
|
||||
}
|
||||
|
||||
/** Der beste verfügbare Disc-Titel: BDMT zuerst, sonst Label. Der
|
||||
* BDMT-Titel wird NICHT normalisiert („2.22" würde zu „2 22"). */
|
||||
export function discTitel(geraetepfad: string): DiscTitel {
|
||||
const wurzel = discWurzel(geraetepfad)
|
||||
if (wurzel.length > 0) {
|
||||
const bdmt = titelAusBdmt(wurzel)
|
||||
if (bdmt.length > 0) return { titel: bdmt, quelle: 'bdmt' }
|
||||
}
|
||||
const label = isoVolumeLabel(geraetepfad) || udfVolumeLabel(geraetepfad)
|
||||
if (label.length > 0) return { titel: normalizeDiscLabel(label), quelle: 'label' }
|
||||
return { titel: '', quelle: '' }
|
||||
}
|
||||
|
||||
/** Billiger, stabiler Disc-Fingerabdruck (Label|Größe) — Basis der
|
||||
* Duplikat-Erkennung in der Bibliothek. */
|
||||
export function discFingerprint(geraetepfad: string, groesseBytes: number): string {
|
||||
const label = isoVolumeLabel(geraetepfad) || udfVolumeLabel(geraetepfad) || 'unbekannt'
|
||||
return `${label}|${groesseBytes}`
|
||||
}
|
||||
|
||||
/** Suchvarianten für die Metadaten-APIs (progressive Degradation,
|
||||
* ARM-Lehre): voller Titel zuerst, dann ohne Doppelpunkt-Teil, ohne
|
||||
* Klammern, dann hintere Worte strippen. */
|
||||
export function titelKandidaten(titel: string): string[] {
|
||||
const kandidaten: string[] = []
|
||||
const merke = (t: string): void => {
|
||||
const sauber = t.split(/\s+/).filter(Boolean).join(' ').replace(/[ .]+$/, '').replace(/^[ .]+/, '')
|
||||
if (sauber.length > 0 && !kandidaten.includes(sauber)) kandidaten.push(sauber)
|
||||
}
|
||||
merke(titel)
|
||||
if (titel.includes(':')) {
|
||||
merke(titel.split(':', 1)[0])
|
||||
merke(titel.replaceAll(':', ''))
|
||||
}
|
||||
merke(titel.replace(/\([^)]*\)/g, ''))
|
||||
let worte = titel.split(/\s+/).filter(Boolean)
|
||||
while (worte.length > 1 && kandidaten.length < 6) {
|
||||
worte = worte.slice(0, -1)
|
||||
merke(worte.join(' '))
|
||||
}
|
||||
return kandidaten
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// OMDb-Client — eigene Datenbasis, findet oft, was TMDb nicht exakt
|
||||
// trifft. Nur Englisch; der deutsche Datensatz wird danach über
|
||||
// TMDb /find (IMDb-ID) nachgeladen (zuordnung.ts). Ohne Key überspringt
|
||||
// er sich selbst — kein Fehler, keine Behauptung.
|
||||
|
||||
export interface OmdbTreffer {
|
||||
type: 'movie' | 'tv'
|
||||
id: string
|
||||
title: string
|
||||
year: number | null
|
||||
overview: string
|
||||
poster_path: string
|
||||
runtime: number
|
||||
genres: string[]
|
||||
source: 'omdb'
|
||||
}
|
||||
|
||||
export class OmdbClient {
|
||||
constructor(
|
||||
private readonly apiKey: string,
|
||||
private readonly holen: typeof fetch = fetch,
|
||||
) {}
|
||||
|
||||
get verfuegbar(): boolean {
|
||||
return this.apiKey.length > 0
|
||||
}
|
||||
|
||||
async lookup(titel: string): Promise<OmdbTreffer | null> {
|
||||
if (!this.verfuegbar) return null
|
||||
try {
|
||||
const url = new URL('https://www.omdbapi.com/')
|
||||
url.searchParams.set('apikey', this.apiKey)
|
||||
url.searchParams.set('t', titel)
|
||||
url.searchParams.set('r', 'json')
|
||||
const antwort = await this.holen(url.toString(), { signal: AbortSignal.timeout(15_000) })
|
||||
if (antwort.status >= 300) return null
|
||||
const daten = (await antwort.json()) as Record<string, string>
|
||||
if (daten['Response'] !== 'True') return null
|
||||
const jahr = /(\d{4})/.exec(daten['Year'] ?? '')
|
||||
const laufzeit = /(\d+)/.exec(daten['Runtime'] ?? '')
|
||||
return {
|
||||
type: daten['Type'] === 'series' ? 'tv' : 'movie',
|
||||
id: daten['imdbID'] ?? '',
|
||||
title: daten['Title'] ?? titel,
|
||||
year: jahr === null ? null : Number(jahr[1]),
|
||||
overview: daten['Plot'] ?? '',
|
||||
poster_path: daten['Poster'] !== 'N/A' ? (daten['Poster'] ?? '') : '',
|
||||
runtime: laufzeit === null ? 0 : Number(laufzeit[1]),
|
||||
genres: (daten['Genre'] ?? '')
|
||||
.split(',')
|
||||
.map((g) => g.trim())
|
||||
.filter((g) => g.length > 0),
|
||||
source: 'omdb',
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
// TMDb-Client (KONZEPT § 4.2) — Regeln aus clients/tmdb.py: Der Key kann
|
||||
// ein v3-Key (32 Hex, api_key-Query) ODER ein v4-Token ('eyJ…',
|
||||
// Bearer-Header) sein — der Befund vom 24.07.: der alte Client konnte nur
|
||||
// eine Form, und die andere sah aus wie „falscher Key". Sprache de-DE.
|
||||
|
||||
export interface FilmTreffer {
|
||||
id: number
|
||||
title?: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
export interface FilmDetails {
|
||||
id: number
|
||||
title?: string
|
||||
name?: string
|
||||
release_date?: string
|
||||
first_air_date?: string
|
||||
overview?: string
|
||||
poster_path?: string
|
||||
backdrop_path?: string
|
||||
runtime?: number
|
||||
genres?: Array<{ name: string }>
|
||||
}
|
||||
|
||||
export function istV4Token(key: string): boolean {
|
||||
return key.startsWith('eyJ')
|
||||
}
|
||||
|
||||
const BASIS = 'https://api.themoviedb.org/3'
|
||||
|
||||
export class TmdbClient {
|
||||
constructor(
|
||||
private readonly apiKey: string,
|
||||
private readonly holen: typeof fetch = fetch,
|
||||
) {}
|
||||
|
||||
get verfuegbar(): boolean {
|
||||
return this.apiKey.length > 0
|
||||
}
|
||||
|
||||
private async anfrage(weg: string, params: Record<string, string>): Promise<unknown> {
|
||||
if (!this.verfuegbar) return null
|
||||
const url = new URL(`${BASIS}/${weg}`)
|
||||
for (const [name, wert] of Object.entries(params)) url.searchParams.set(name, wert)
|
||||
const kopf: Record<string, string> = { Accept: 'application/json' }
|
||||
if (istV4Token(this.apiKey)) {
|
||||
kopf['Authorization'] = `Bearer ${this.apiKey}`
|
||||
} else {
|
||||
url.searchParams.set('api_key', this.apiKey)
|
||||
}
|
||||
try {
|
||||
const antwort = await this.holen(url.toString(), {
|
||||
headers: kopf,
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
})
|
||||
if (antwort.status >= 300) return null
|
||||
return await antwort.json()
|
||||
} catch {
|
||||
// R2: Ein verpasster Abruf ist keine Nachricht über die Welt — der
|
||||
// Aufrufer bekommt null („weiß nicht"), nie eine leere Behauptung.
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async searchMovie(titel: string): Promise<FilmTreffer[] | null> {
|
||||
const daten = (await this.anfrage('search/movie', {
|
||||
query: titel,
|
||||
language: 'de-DE',
|
||||
include_adult: 'false',
|
||||
})) as { results?: FilmTreffer[] } | null
|
||||
return daten?.results ?? null
|
||||
}
|
||||
|
||||
async searchTv(titel: string): Promise<FilmTreffer[] | null> {
|
||||
const daten = (await this.anfrage('search/tv', {
|
||||
query: titel,
|
||||
language: 'de-DE',
|
||||
include_adult: 'false',
|
||||
})) as { results?: FilmTreffer[] } | null
|
||||
return daten?.results ?? null
|
||||
}
|
||||
|
||||
async movieDetails(id: number): Promise<FilmDetails | null> {
|
||||
return (await this.anfrage(`movie/${id}`, { language: 'de-DE' })) as FilmDetails | null
|
||||
}
|
||||
|
||||
async tvDetails(id: number): Promise<FilmDetails | null> {
|
||||
return (await this.anfrage(`tv/${id}`, { language: 'de-DE' })) as FilmDetails | null
|
||||
}
|
||||
|
||||
/** Staffel-Details mit Episoden-Laufzeiten — für die Serien-Zuordnung. */
|
||||
async tvStaffel(id: number, staffel: number): Promise<{ episodes?: Array<{ episode_number: number; runtime?: number }> } | null> {
|
||||
return (await this.anfrage(`tv/${id}/season/${staffel}`, { language: 'de-DE' })) as {
|
||||
episodes?: Array<{ episode_number: number; runtime?: number }>
|
||||
} | null
|
||||
}
|
||||
|
||||
/** IMDb-ID → deutscher Datensatz (für OMDb-Treffer, die nur Englisch
|
||||
* können — Commander-Wunsch 24.07.). */
|
||||
async findByImdb(imdbId: string): Promise<Record<string, unknown> | null> {
|
||||
const daten = (await this.anfrage(`find/${imdbId}`, {
|
||||
external_source: 'imdb_id',
|
||||
language: 'de-DE',
|
||||
})) as { movie_results?: FilmDetails[]; tv_results?: FilmDetails[] } | null
|
||||
const film = daten?.movie_results?.[0]
|
||||
if (film !== undefined) {
|
||||
return {
|
||||
title: film.title,
|
||||
overview: film.overview,
|
||||
poster_path: film.poster_path,
|
||||
year: film.release_date !== undefined && film.release_date.length >= 4 ? Number(film.release_date.slice(0, 4)) : null,
|
||||
type: 'movie',
|
||||
}
|
||||
}
|
||||
const serie = daten?.tv_results?.[0]
|
||||
if (serie !== undefined) {
|
||||
return {
|
||||
title: serie.name,
|
||||
overview: serie.overview,
|
||||
poster_path: serie.poster_path,
|
||||
year: serie.first_air_date !== undefined && serie.first_air_date.length >= 4 ? Number(serie.first_air_date.slice(0, 4)) : null,
|
||||
type: 'tv',
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
// 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 {
|
||||
@@ -28,19 +29,21 @@ describe('sichererName', () => {
|
||||
|
||||
describe('zielordner', () => {
|
||||
it('sprechender Name; bei Kollision Job-Kennung statt Überschreiben', () => {
|
||||
expect(zielordner('E:\\Filme', 'Akira', 1988, 'job-1234abcd', () => false)).toBe(
|
||||
'E:\\Filme\\Akira (1988)',
|
||||
// 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('E:\\Filme', 'Akira', 1988, 'job-1234abcd', () => true)).toBe(
|
||||
'E:\\Filme\\Akira (1988) [job-1234]',
|
||||
expect(zielordner('Filme', 'Akira', 1988, 'job-1234abcd', () => true)).toBe(
|
||||
join('Filme', 'Akira (1988) [job-1234]'),
|
||||
)
|
||||
expect(zielordner('E:\\Filme', '', null, 'job-1', () => false)).toBe('E:\\Filme\\job-1')
|
||||
expect(zielordner('Filme', '', null, 'job-1', () => false)).toBe(join('Filme', 'job-1'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('serienOrdner', () => {
|
||||
it('Jellyfin-Schema Serie/Season NN', () => {
|
||||
expect(serienOrdner('E:\\Serien', 'Dr. Who?', 2)).toBe('E:\\Serien\\Dr. Who\\Season 02')
|
||||
expect(serienOrdner('Serien', 'Dr. Who?', 2)).toBe(join('Serien', 'Dr. Who', 'Season 02'))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -90,14 +93,14 @@ describe('matcheEpisoden', () => {
|
||||
describe('episodenUmbenennen', () => {
|
||||
it('benennt in Disc-Reihenfolge um, überschreibt nie', () => {
|
||||
const umbenannt: Array<[string, string]> = []
|
||||
const meldungen = episodenUmbenennen('E:\\S', 'Akte X', 1, [3, 4], {
|
||||
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([
|
||||
['E:\\S\\title_t00.mkv', 'E:\\S\\Akte X S01E03.mkv'],
|
||||
['E:\\S\\title_t01.mkv', 'E:\\S\\Akte X S01E04.mkv'],
|
||||
[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)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user