// Tests der Metadaten-Schicht: Disc-Merkmale (pur/Dateisystem) und die // Confidence-Kaskade mit nachgebildeten Clients — jede Stufe hat ihren // gemessenen Grund (prescan.py). import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { normalizeDiscLabel, parseUdfDstring, titelAusBdmt, titelKandidaten, } from '../src/kern/metadaten/discmerkmale' import { istSicherGenug, wortUeberlappung, zuordnen, WENIGE_TREFFER } from '../src/kern/metadaten/zuordnung' import { posterUrl } from '../src/kern/ablage/poster' let wegwerf: string[] = [] afterEach(() => { for (const o of wegwerf.splice(0)) rmSync(o, { recursive: true, force: true }) }) describe('normalizeDiscLabel', () => { it('GROSS_MIT_UNTERSTRICHEN → Titel-Schreibung', () => { expect(normalizeDiscLabel('PULP_FICTION_DE')).toBe('Pulp Fiction De') expect(normalizeDiscLabel('BD_EVG_D2')).toBe('Bd Evg D2') }) it('Gemischtes bleibt, wie es ist', () => { expect(normalizeDiscLabel('Akira 4K')).toBe('Akira 4K') expect(normalizeDiscLabel('')).toBe('') }) }) describe('parseUdfDstring', () => { it('liest Latin-1 (Kompressions-ID 8)', () => { const roh = Buffer.concat([Buffer.from([8]), Buffer.from('SPARTACUS', 'latin1'), Buffer.alloc(21)]) roh[roh.length - 1] = 10 // genutzte Länge: ID + 9 Zeichen expect(parseUdfDstring(roh)).toBe('SPARTACUS') }) it('liest UTF-16BE (Kompressions-ID 16)', () => { const text = Buffer.from([0x00, 0x41, 0x00, 0x42]) const roh = Buffer.concat([Buffer.from([16]), text, Buffer.alloc(27)]) roh[roh.length - 1] = 1 + text.length expect(parseUdfDstring(roh)).toBe('AB') }) it('Unsinnige Längen sind leer, kein Absturz', () => { expect(parseUdfDstring(Buffer.alloc(0))).toBe('') expect(parseUdfDstring(Buffer.from([8, 65, 99]))).toBe('') }) }) describe('titelAusBdmt', () => { it('liest den Studio-Titel, bdmt_eng bevorzugt', () => { const wurzel = mkdtempSync(join(tmpdir(), 'rippy-bdmt-')) wegwerf.push(wurzel) const meta = join(wurzel, 'BDMV', 'META', 'DL') mkdirSync(meta, { recursive: true }) writeFileSync(join(meta, 'bdmt_deu.xml'), 'Deutscher Titel') writeFileSync(join(meta, 'bdmt_eng.xml'), 'English Title') expect(titelAusBdmt(wurzel)).toBe('English Title') }) it('ohne BDMV-Metadaten leer', () => { const wurzel = mkdtempSync(join(tmpdir(), 'rippy-bdmt-')) wegwerf.push(wurzel) expect(titelAusBdmt(wurzel)).toBe('') }) }) describe('titelKandidaten — progressive Degradation (ARM-Lehre)', () => { it('voller Titel zuerst, dann Varianten, dann gestutzt', () => { const kandidaten = titelKandidaten('Evangelion: 2.22 You Can (Not) Advance.') expect(kandidaten[0]).toBe('Evangelion: 2.22 You Can (Not) Advance') expect(kandidaten).toContain('Evangelion') expect(kandidaten.length).toBeLessThanOrEqual(8) }) }) // ── Die Kaskade mit Fake-Clients ──────────────────────────────────────── interface FilmEintrag { id: number title: string details?: Record } function fakeTmdb(filme: Record, serien: Record = {}) { return { verfuegbar: true, searchMovie: async (t: string) => filme[t] ?? [], searchTv: async (t: string) => (serien[t] ?? []).map((s) => ({ id: s.id, name: s.title })), movieDetails: async (id: number) => { for (const liste of Object.values(filme)) { const treffer = liste.find((f) => f.id === id) if (treffer !== undefined) { return { id, title: treffer.title, release_date: '1988-07-16', overview: 'Ü', ...treffer.details } } } return null }, tvDetails: async (id: number) => { for (const liste of Object.values(serien)) { const treffer = liste.find((f) => f.id === id) if (treffer !== undefined) return { id, name: treffer.title, first_air_date: '1993-09-10' } } return null }, findByImdb: async () => null, } } const keinOmdb = { verfuegbar: false, lookup: async () => null } describe('zuordnen — die Confidence-Kaskade', () => { it('wörtlicher Filmtreffer: 0,95', async () => { const z = await zuordnen('Akira', fakeTmdb({ Akira: [{ id: 1, title: 'Akira' }] }), keinOmdb) expect(z.confidence).toBe(0.95) expect(z.titel).toBe('Akira') expect(z.jahr).toBe(1988) expect(istSicherGenug(z)).toBe(true) }) it('wörtlicher Serientreffer: 0,90', async () => { const z = await zuordnen('Akte X', fakeTmdb({}, { 'Akte X': [{ id: 7, title: 'Akte X' }] }), keinOmdb) expect(z.confidence).toBe(0.9) expect(z.meta.typ).toBe('tv') }) it('der Evangelion-Fund: voller Titel + wenige Treffer = 0,80', async () => { // 'Evangelion 2.22' → 1 Treffer, dessen Titel NICHT wörtlich gleich // ist — vor dem Fix wurde genau der richtige Film verworfen. const z = await zuordnen( 'Evangelion 2.22', fakeTmdb({ 'Evangelion 2.22': [{ id: 9, title: 'Evangelion: 2.0 You Can (Not) Advance' }] }), keinOmdb, ) expect(z.confidence).toBe(0.8) expect(z.titel).toBe('Evangelion: 2.0 You Can (Not) Advance') }) it('viele Treffer auf den vollen Titel sind KEIN spezifischer Fund', async () => { const viele = Array.from({ length: WENIGE_TREFFER + 1 }, (_, i) => ({ id: 100 + i, title: `Irgendwas ${i}`, })) const z = await zuordnen('Evangelion', fakeTmdb({ Evangelion: viele }), keinOmdb) // Kaskade fällt bis zum Vorschlag durch (0,6) — das UI fragt nach. expect(z.confidence).toBe(0.6) expect(istSicherGenug(z)).toBe(false) }) it('OMDb greift, wenn TMDb nichts Spezifisches hat: 0,80', async () => { const omdb = { verfuegbar: true, lookup: async (t: string) => t === 'Seltener Film' ? { type: 'movie' as const, id: 'tt0123', title: 'Seltener Film', year: 1999, overview: 'en', poster_path: '', runtime: 100, genres: [], source: 'omdb' as const, } : null, } const z = await zuordnen('Seltener Film', fakeTmdb({}), omdb) expect(z.confidence).toBe(0.8) expect(z.meta.quelle).toBe('omdb') }) it('der Spartacus-Fund: spezifische Serie auf den vollen Titel schlägt den wörtlichen Treffer eines STARK gekürzten Kandidaten', async () => { // Live gemessen (30.08.2026): 'Spartacus Gods Of The Arena' wurde zum // Film „Spartacus" (1960), weil der gekürzte Kandidat wörtlich traf, // BEVOR die volle Anfrage als spezifische Serie gewertet wurde. const z = await zuordnen( 'Spartacus Gods Of The Arena', fakeTmdb( { Spartacus: [{ id: 5, title: 'Spartacus' }] }, { 'Spartacus Gods Of The Arena': [{ id: 6, title: 'Spartacus: Gods of the Arena' }] }, ), keinOmdb, ) expect(z.confidence).toBe(0.8) expect(z.meta.typ).toBe('tv') expect(z.titel).toBe('Spartacus: Gods of the Arena') }) it('OMDb-Unsinn fällt am Ähnlichkeits-Gate (der Bd-Evg-D2-Fund)', async () => { const omdb = { verfuegbar: true, lookup: async () => ({ type: 'movie' as const, id: 'tt9', title: 'BD Scream 5', year: 2013, overview: '', poster_path: '', runtime: 90, genres: [], source: 'omdb' as const, }), } const z = await zuordnen('Bd Evg D2', fakeTmdb({}), omdb) expect(z.confidence).toBe(0.3) expect(z.meta.typ).toBe('unknown') }) it('wortUeberlappung rechnet Jaccard über Wörter', () => { expect(wortUeberlappung('Pulp Fiction', 'Pulp Fiction')).toBe(1) expect(wortUeberlappung('Bd Evg D2', 'BD Scream 5')).toBeLessThan(0.5) expect(wortUeberlappung('', 'x')).toBe(0) }) it('gar nichts gefunden: 0,30 mit dem Disc-Titel', async () => { const z = await zuordnen('Logical Volume Id', fakeTmdb({}), keinOmdb) expect(z.confidence).toBe(0.3) expect(z.titel).toBe('Logical Volume Id') }) it('leerer Titel bleibt unbekannt, ohne API-Anfragen', async () => { const z = await zuordnen('', fakeTmdb({}), keinOmdb) expect(z.confidence).toBe(0.3) }) }) describe('posterUrl', () => { it('relative TMDb-Pfade bekommen das w500-Präfix', () => { expect(posterUrl('/abc.jpg')).toBe('https://image.tmdb.org/t/p/w500/abc.jpg') expect(posterUrl('https://x/y.jpg')).toBe('https://x/y.jpg') expect(posterUrl('')).toBe('') }) })