// Erkennung v2 + Kino-Banner v2 (5.4.0) — alles an GEMESSENEN Antworten // (01.09.2026, TMDb mit dem hinterlegten v4-Token): // movie/149 (Akira): tagline, runtime 124, vote_average 7.94, // images.logos mit iso_639_1 'en'/'de', credits.cast, release_dates DE // mit certification '16' (type 3 Kino, 5 Heimvideo, 6 TV). // tv/46296 (Spartacus): episode_run_time [53], content_ratings DE '18'. // Die drei „Spartacus: Gods of the Arena"-Stubs: overview '', backdrop // null, 1 Folge, Popularität 1. 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 { bdmtZerlegen, inhaltAusBdmt } from '../src/kern/metadaten/discmerkmale' import { besetzungAus, fskAus, logoAus, type FilmDetails } from '../src/kern/metadaten/tmdb' import { LEERER_TREFFER, VORSCHLAG, ausDetails, istStub, kandidatenAus, nachpruefen, zuordnen, type Zuordnung, } from '../src/kern/metadaten/zuordnung' import { strukturHinweis } from '../src/kern/rip/titelwahl' let wegwerf: string[] = [] afterEach(() => { for (const o of wegwerf.splice(0)) rmSync(o, { recursive: true, force: true }) }) /** Die gemessene Akira-Antwort, auf die genutzten Felder gekürzt. */ const AKIRA: FilmDetails = { id: 149, title: 'Akira', release_date: '1988-06-10', overview: 'Tokio 1988. In einem Geheimlabor der Armee …', poster_path: '/fDRZmPUPUHTxULsuSjPIqPHKjuE.jpg', backdrop_path: '/fK40VGYIm7hmKrLJ26fgPQU0qRG.jpg', runtime: 124, genres: [{ name: 'Animation' }, { name: 'Science Fiction' }, { name: 'Action' }], tagline: 'Neo-Tokyo ist am EXPLODIEREN', vote_average: 7.94, vote_count: 4944, images: { logos: [ { iso_639_1: 'en', file_path: '/AbcUfIbGRwrlPqdmTSi2WzArjQh.png', width: 664, height: 387, vote_average: 1.75 }, { iso_639_1: 'en', file_path: '/cHIsyx3xAW314SijTvOyPozWwDV.png', width: 472, height: 290, vote_average: 1.75 }, { iso_639_1: 'de', file_path: '/lHBQKVTxGUUqCMVo7rzTkYICHjm.png', width: 867, height: 404, vote_average: 0 }, { iso_639_1: null, file_path: '/sprachlos.png', width: 100, height: 50, vote_average: 5 }, ], }, credits: { cast: [ { name: 'Nozomu Sasaki', character: 'Tetsuo Shima (voice)', profile_path: '/7usoPlvc9Xa1rDA2vxmVi0OvLDb.jpg', order: 1 }, { name: 'Mitsuo Iwata', character: 'Shotaro Kaneda (voice)', profile_path: '/xCAsuzc5OBQZWhG5WSZIyhBZ7xj.jpg', order: 0 }, { name: 'Mami Koyama', character: 'Kei (voice)', profile_path: null, order: 2 }, ], }, release_dates: { results: [ { iso_3166_1: 'US', release_dates: [{ certification: 'R', type: 3 }] }, { iso_3166_1: 'DE', release_dates: [ { certification: '16', type: 5 }, { certification: '16', type: 3 }, { certification: '', type: 6 }, ], }, ], }, } const SPARTACUS_TV: FilmDetails = { id: 46296, name: 'Spartacus', first_air_date: '2010-01-22', overview: 'Der Thraker Spartacus …', backdrop_path: '/x.jpg', vote_average: 8, vote_count: 1000, episode_run_time: [53], number_of_episodes: 39, content_ratings: { results: [{ iso_3166_1: 'US', rating: 'TV-MA' }, { iso_3166_1: 'DE', rating: '18' }] }, } /** Einer der drei gemessenen Stubs. */ const STUB: FilmDetails = { id: 331850, name: 'Spartacus: Gods of the Arena', first_air_date: '', overview: '', backdrop_path: null, vote_average: 8, vote_count: 0, number_of_episodes: 1, episode_run_time: [], } describe('Kino-Banner v2 — die Felder aus der gemessenen Antwort', () => { it('logoAus: deutsch vor englisch, darin das bestbewertete', () => { expect(logoAus(AKIRA.images?.logos)).toBe('/lHBQKVTxGUUqCMVo7rzTkYICHjm.png') expect(logoAus(AKIRA.images?.logos?.filter((l) => l.iso_639_1 !== 'de'))).toBe('/AbcUfIbGRwrlPqdmTSi2WzArjQh.png') expect(logoAus([])).toBe('') expect(logoAus(undefined)).toBe('') }) it('fskAus: Film aus den deutschen Freigaben (Kino vor Heimvideo), Serie aus content_ratings', () => { expect(fskAus(AKIRA, 'movie')).toBe('16') expect(fskAus(SPARTACUS_TV, 'tv')).toBe('18') expect(fskAus({ id: 1 }, 'movie')).toBe('') expect(fskAus(STUB, 'tv')).toBe('') }) it('besetzungAus: nach order sortiert, höchstens sechs, ohne Bild bleibt der Name', () => { const b = besetzungAus(AKIRA.credits?.cast) expect(b.map((d) => d.name)).toEqual(['Mitsuo Iwata', 'Nozomu Sasaki', 'Mami Koyama']) expect(b[0].rolle).toBe('Shotaro Kaneda (voice)') expect(b[2].bildPfad).toBe('') expect(besetzungAus(undefined)).toEqual([]) }) it('ausDetails trägt alles in den Treffer — Film und Serie', () => { const film = ausDetails('movie', 149, AKIRA) expect(film.tagline).toBe('Neo-Tokyo ist am EXPLODIEREN') expect(film.bewertung).toBe(7.9) expect(film.fsk).toBe('16') expect(film.logoPfad).toContain('.png') expect(film.besetzung).toHaveLength(3) expect(film.laufzeitMinuten).toBe(124) expect(film.genres).toEqual(['Animation', 'Science Fiction', 'Action']) const serie = ausDetails('tv', 46296, SPARTACUS_TV) expect(serie.laufzeitMinuten).toBe(53) expect(serie.folgenGesamt).toBe(39) expect(serie.fsk).toBe('18') }) it('istStub erkennt die leeren Einträge — und nichts anderes', () => { expect(istStub(STUB)).toBe(true) expect(istStub(AKIRA)).toBe(false) expect(istStub(SPARTACUS_TV)).toBe(false) // Nur die Beschreibung fehlt (deutsche Übersetzung nicht da) — kein Stub. expect(istStub({ ...SPARTACUS_TV, overview: '' })).toBe(false) }) }) describe('zuordnen — leere Einträge zählen nicht (der Stub-Fund)', () => { const tmdb = { verfuegbar: true, searchMovie: async () => [], searchTv: async (t: string) => t === 'Spartacus: Gods of the Arena' ? [ { id: 331850, name: 'Spartacus: Gods of the Arena' }, { id: 331915, name: 'Spartacus Gods of the Arena' }, ] : t === 'Spartacus' ? [{ id: 46296, name: 'Spartacus' }] : [], movieDetails: async () => null, tvDetails: async (id: number) => (id === 46296 ? SPARTACUS_TV : { ...STUB, id }), findByImdb: async () => null, } const keinOmdb = { verfuegbar: false, lookup: async () => null } it('der wörtliche Stub-Treffer gewinnt nicht mehr — die echte Serie wird ein Vorschlag', async () => { const z = await zuordnen('Spartacus: Gods of the Arena', tmdb, keinOmdb, { istSerie: true }) // Über den Doppelpunkt-Vorspann 'Spartacus' (Abdeckung 1/5 → gedeckelt). expect(z.titel).toBe('Spartacus') expect(z.confidence).toBe(VORSCHLAG) expect(z.meta.laufzeitMinuten).toBe(53) }) it('gibt es NUR Stubs, bleibt der erste ein Vorschlag — es wird ja gefragt', async () => { const nurStubs = { ...tmdb, searchTv: async (t: string) => (t.startsWith('Spartacus: Gods') ? [{ id: 331850, name: 'Spartacus: Gods of the Arena' }] : []) } const z = await zuordnen('Spartacus: Gods of the Arena', nurStubs, keinOmdb, { istSerie: true }) expect(z.confidence).toBe(VORSCHLAG) expect(z.titel).toBe('Spartacus: Gods of the Arena') }) }) describe('kandidatenAus — jede Titel-Variante an ihrer eigenen Vorlage gemessen', () => { it('volle Titel zuerst, dann die gestutzten, ohne Doppel', () => { const k = kandidatenAus('Spartacus: Gods Of The Arena - Disc 2', ['Spartacus: Götter der Arena - Disc 2']) expect(k[0].text).toBe('Spartacus: Gods Of The Arena - Disc 2') expect(k[1].text).toBe('Spartacus: Götter der Arena - Disc 2') expect(k[0].massstab).toBe('Spartacus: Gods Of The Arena') expect(k[1].massstab).toBe('Spartacus: Götter der Arena') expect(new Set(k.map((x) => x.text.toLowerCase())).size).toBe(k.length) }) it('ohne Alternativen wie bisher', () => { expect(kandidatenAus('Akira').map((x) => x.text)).toEqual(['Akira']) }) }) describe('nachpruefen — die Laufzeit nach dem Info-Lauf', () => { const akira: Zuordnung = { titel: 'Akira', jahr: 1988, confidence: 0.8, meta: ausDetails('movie', 149, AKIRA) } it('passt die Laufzeit, wird aus 0,80 ein bestätigtes 0,95', () => { const z = nachpruefen(akira, { hauptS: 124 * 60 + 30, folgenS: [], struktur: 'film' }) expect(z.confidence).toBe(0.95) expect(z.hinweise?.[0]).toContain('Laufzeit bestätigt') }) it('passt sie gar nicht (95 vs 158 min), fällt der Treffer auf den Vorschlag zurück', () => { const falsch = { ...akira, meta: { ...akira.meta, laufzeitMinuten: 158 } } const z = nachpruefen(falsch, { hauptS: 95 * 60, folgenS: [], struktur: 'film' }) expect(z.confidence).toBe(VORSCHLAG) expect(z.hinweise?.[0]).toContain('passt NICHT') }) it('die Wahl des Nutzers (1,0) wird nie herabgestuft, nur kommentiert', () => { const eigene = { ...akira, confidence: 1 } const z = nachpruefen(eigene, { hauptS: 95 * 60, folgenS: [], struktur: 'film' }) expect(z.confidence).toBe(1) expect(z.hinweise).toHaveLength(1) }) it('Serie: Folgenlänge gegen episode_run_time', () => { const serie: Zuordnung = { titel: 'Spartacus', jahr: 2010, confidence: 0.6, meta: ausDetails('tv', 46296, SPARTACUS_TV) } const z = nachpruefen(serie, { hauptS: 0, folgenS: [3167, 3249], struktur: 'unklar' }) expect(z.confidence).toBe(0.8) expect(z.hinweise?.[0]).toContain('Folgenlänge passt') }) it('Struktur sagt Serie, Treffer sagt Film: Vorschlag mit Hinweis', () => { const z = nachpruefen({ ...akira, meta: { ...akira.meta, laufzeitMinuten: 0 } }, { hauptS: 0, folgenS: [], struktur: 'serie' }) expect(z.confidence).toBe(VORSCHLAG) expect(z.hinweise?.[0]).toContain('Serienstaffel') }) it('ohne Laufzeit bei TMDb bleibt alles, wie es war', () => { const ohne = { ...akira, meta: { ...LEERER_TREFFER, typ: 'movie' as const } } const z = nachpruefen(ohne, { hauptS: 7000, folgenS: [], struktur: 'unklar' }) expect(z.confidence).toBe(0.8) expect(z.hinweise).toEqual([]) }) }) describe('strukturHinweis — der Fingerabdruck der Titel-Struktur', () => { const t = (nr: number, min: number) => ({ nr, dauerS: Math.round(min * 60), groesseBytes: 1e9, kapitel: 8 }) it('ein langer + viele kurze = Film', () => { expect(strukturHinweis([t(0, 124), t(1, 3), t(2, 1.5), t(3, 20)])).toBe('film') }) it('sechs gleich lange = Serie', () => { expect(strukturHinweis([t(0, 44), t(1, 43), t(2, 45), t(3, 44), t(4, 42), t(5, 44), t(6, 2)])).toBe('serie') }) it('zwei gleich lange Fassungen bleiben unklar (Seamless Branching)', () => { expect(strukturHinweis([t(0, 120), t(1, 118)])).toBe('unklar') }) it('zwei Folgen bleiben unklar — dafür ist das bdmt-Inhaltsverzeichnis da', () => { expect(strukturHinweis([t(0, 52), t(1, 54), t(2, 1)])).toBe('unklar') }) it('nichts Langes: unklar', () => { expect(strukturHinweis([t(0, 1), t(1, 0.5)])).toBe('unklar') expect(strukturHinweis([])).toBe('unklar') }) }) describe('bdmt_deu als zweite Titel-Variante', () => { it('bdmtZerlegen liest Titel und Inhaltsverzeichnis (pur)', () => { const z = bdmtZerlegen('TitelEP 1') expect(z).toEqual({ titel: 'Titel', eintraege: ['EP 1'] }) }) it('englisch bleibt der Haupttitel, deutsch wird Alternative — Doppel fallen weg', () => { const wurzel = mkdtempSync(join(tmpdir(), 'rippy-bdmt2-')) wegwerf.push(wurzel) const meta = join(wurzel, 'BDMV', 'META', 'DL') mkdirSync(meta, { recursive: true }) writeFileSync(join(meta, 'bdmt_deu.xml'), 'Spartacus: Götter der Arena') writeFileSync(join(meta, 'bdmt_eng.xml'), 'Spartacus: Gods of the ArenaEP 1EP 2') writeFileSync(join(meta, 'bdmt_fra.xml'), 'Spartacus: Gods of the Arena') const inhalt = inhaltAusBdmt(wurzel) expect(inhalt.titel).toBe('Spartacus: Gods of the Arena') expect(inhalt.eintraege).toEqual(['EP 1', 'EP 2']) expect(inhalt.alternativen).toEqual(['Spartacus: Götter der Arena']) }) it('nur bdmt_deu: dann ist der deutsche Titel der Haupttitel', () => { const wurzel = mkdtempSync(join(tmpdir(), 'rippy-bdmt3-')) wegwerf.push(wurzel) const meta = join(wurzel, 'BDMV', 'META', 'DL') mkdirSync(meta, { recursive: true }) writeFileSync(join(meta, 'bdmt_deu.xml'), 'Nur Deutsch') expect(inhaltAusBdmt(wurzel)).toEqual({ titel: 'Nur Deutsch', eintraege: [], alternativen: [] }) }) })