diff --git a/rippy-windows/src/kern/ablage/struktur.ts b/rippy-windows/src/kern/ablage/struktur.ts index deae812..6fea322 100644 --- a/rippy-windows/src/kern/ablage/struktur.ts +++ b/rippy-windows/src/kern/ablage/struktur.ts @@ -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. diff --git a/rippy-windows/src/kern/ablauf/pipeline.ts b/rippy-windows/src/kern/ablauf/pipeline.ts index 495b1f6..854d2ea 100644 --- a/rippy-windows/src/kern/ablauf/pipeline.ts +++ b/rippy-windows/src/kern/ablauf/pipeline.ts @@ -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 diff --git a/rippy-windows/src/kern/metadaten/discmerkmale.ts b/rippy-windows/src/kern/metadaten/discmerkmale.ts new file mode 100644 index 0000000..795b25b --- /dev/null +++ b/rippy-windows/src/kern/metadaten/discmerkmale.ts @@ -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 = /([^<]{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 +} diff --git a/rippy-windows/src/kern/metadaten/omdb.ts b/rippy-windows/src/kern/metadaten/omdb.ts new file mode 100644 index 0000000..a4b0efd --- /dev/null +++ b/rippy-windows/src/kern/metadaten/omdb.ts @@ -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 { + 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 + 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 + } + } +} diff --git a/rippy-windows/src/kern/metadaten/tmdb.ts b/rippy-windows/src/kern/metadaten/tmdb.ts new file mode 100644 index 0000000..f3e18d1 --- /dev/null +++ b/rippy-windows/src/kern/metadaten/tmdb.ts @@ -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): Promise { + 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 = { 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 { + 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 { + 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 { + return (await this.anfrage(`movie/${id}`, { language: 'de-DE' })) as FilmDetails | null + } + + async tvDetails(id: number): Promise { + 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 | 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 + } +} diff --git a/rippy-windows/test/ablage.test.ts b/rippy-windows/test/ablage.test.ts index 6a799f3..65dec94 100644 --- a/rippy-windows/test/ablage.test.ts +++ b/rippy-windows/test/ablage.test.ts @@ -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) })