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>
290 lines
12 KiB
TypeScript
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: [] })
|
|
})
|
|
})
|