fix(v5): Laufzeit-Pfade plattformneutral — pipeline/struktur auf node:path
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:
Hitonabi
2026-08-30 18:29:46 +02:00
co-authored by Claude Fable 5
parent 3dc296f071
commit e840b7f327
6 changed files with 390 additions and 15 deletions
+6 -3
View File
@@ -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.
+2 -3
View File
@@ -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 20120 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 4071 (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
}
+59
View File
@@ -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
}
}
}
+127
View File
@@ -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
}
}
+12 -9
View File
@@ -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)
})