Files
rippy/rippy-windows/test/metadaten-v2.test.ts
T
HitonabiandClaude Fable 5.1 3f7f962dff feat(v5): Kern 5.4.0 — Vorgaenge, Kompressions-Warteschlange, Roh-Aufraeumen, Sprachen je Titel, Erkennung v2
WAS: Der Rip endet, sobald die Roh-Dateien liegen; die Kompression laeuft
danach in einer Warteschlange (eine zur Zeit), das Laufwerk ist frei fuer
die naechste Disc. Jeder Rip ist jetzt ein Datensatz (Tabelle vorgaenge,
Schema 4): Roh-Ordner, Zielordner, Phase, Auftrag als JSON. Darauf bauen:
Roh-Aufraeumen nach Regel (Vorgabe 7 Tage, nur bei gelungener Kompression
und nur wenn jede fertige Datei da und nicht leer ist), Wiederholen ab
Kompression, Aufraeumen halber Dateien nach Abbruch/Fehler, Wiederaufnahme
nach Neustart. Unkomprimierte Rips (4K, kein HandBrake) werden in den
Zielordner VERSCHOBEN statt unter roh\ liegen zu bleiben.
Sprachen je Titel reisen vom Info-Lauf (SINFO) bis in HandBrake; fremder
Ton schaltet den deutschen Untertitel vor (--subtitle-default=1). Vor der
Kompression prueft ein HandBrake-Scan die Roh-Datei. MakeMKVs
Auswahlregel wird gesetzt, wenn keine steht, damit alle Sprachen in die
Roh-Datei kommen. Staffel und erste Folge von Hand schlagen die
Laufzeit-Raterei. Durchgehender Fortschritt, nach Groesse gewichtet.
Erkennung v2: leere TMDb-Eintraege (Stubs) zaehlen nicht, Laufzeit wird
nach dem Info-Lauf gegen TMDb gehalten, Titel-Struktur als Fingerabdruck,
bdmt_deu als zweite Titel-Variante. TMDb liefert Logo, Tagline,
Bewertung, FSK und Besetzung in derselben Anfrage. Laufwerks-Gesundheit
aus dem Windows-Ereignisprotokoll (wevtutil): Disc oder Hardware.

WARUM: Uebergabe-Punkte 1-5, 8-12 des SAVEPOINT vom 01.09.2026 — vom
Commander gutgeheissen (7 Tage / Untertitel vorgewaehlt / entkoppelte
Kompression waren seine Wahl).

GEMESSEN (Regel D): HandBrake 1.11 Sprach-Schalter an einer ffmpeg-
Testdatei (jpn/deu/deu/eng + deu/eng), HandBrake --scan --json Format,
TMDb movie/149 + tv/46296 mit append_to_response, wevtutil /f:xml an
diesem PC (338x cdrom 7, 8x cdrom 11 in 30 Tagen), makemkvcon --minlength,
MakeMKV-Auswahlregel laut forum.makemkv.com t=4386.

348 Tests gruen (vorher 261), Typpruefung und Bau gruen. Oberflaeche
folgt im naechsten Schritt.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-01 22:02:03 +02:00

290 lines
12 KiB
TypeScript

// 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('<x><di:name>Titel</di:name><di:titleName titleNumber="1">EP 1</di:titleName></x>')
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'), '<x><di:name>Spartacus: Götter der Arena</di:name></x>')
writeFileSync(join(meta, 'bdmt_eng.xml'), '<x><di:name>Spartacus: Gods of the Arena</di:name><di:titleName titleNumber="1">EP 1</di:titleName><di:titleName titleNumber="2">EP 2</di:titleName></x>')
writeFileSync(join(meta, 'bdmt_fra.xml'), '<x><di:name>Spartacus: Gods of the Arena</di:name></x>')
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'), '<x><di:name>Nur Deutsch</di:name></x>')
expect(inhaltAusBdmt(wurzel)).toEqual({ titel: 'Nur Deutsch', eintraege: [], alternativen: [] })
})
})