feat(v5): Etappe W-4 — Metadaten-Automatik mit Sicherheitsgrad
Ampel / ampel (push) Failing after 1m20s

metadaten/: discmerkmale.ts (BDMT-Studio-Titel, ISO-9660- und
UDF-Volume-Label direkt vom Medium — OHNE koffi, ueber fs auf \.\G: —,
Label-Normalisierung, Fingerabdruck, progressive Titel-Kandidaten),
tmdb.ts (v3-Key UND v4-Token, de-DE, /find fuer deutsche Texte zu
OMDb-Treffern), omdb.ts, zuordnung.ts (die Confidence-Kaskade
0,95/0,90/0,80/0,60/0,30 aus prescan.py). ablage/poster.ts. Kern meldet
'disc-info' beim Einlegen; die Pipeline benennt den fertigen Ordner
'<Titel> (Jahr)', schreibt movie.nfo/tvshow.nfo mit echten Metadaten
und laedt das Poster. Fenster zeigt 'sehr wahrscheinlich: … · 95 %'.

ZWEI Kaskaden-Schwaechen LIVE GEFUNDEN und gehaertet (gegen die echten
APIs gemessen, Keys aus den Docker-Settings der VM, nicht persistiert):
1. 'Spartacus Gods Of The Arena' wurde zum FILM 'Spartacus' (1960) —
   der stark gekuerzte Kandidat traf woertlich, bevor die volle Anfrage
   als spezifische SERIE galt. Kaskade jetzt JE KANDIDAT
   woertlich→spezifisch (spezifisch nur fuer die volle Anfrage, auch
   fuer Serien). Nachgemessen: jetzt Serie, 90 %.
2. OMDbs t=-Suche machte aus dem Label 'Bd Evg D2' selbstbewusst
   'BD Scream 5' — jetzt Wort-Jaccard-Gate >= 0,5. Nachgemessen: der
   Fall ist jetzt ein ehrlicher 60-%-Vorschlag, den das UI nachfragt.

Messwerte: Akira→95% · Evangelion 2.22→80% (der dokumentierte Fund) ·
Spartacus GotA→Serie 90% · Bd Evg D2→Vorschlag 60%.

Dazu ein neuer Quelltext-Hygiene-Waechter (keine rohen Steuerbytes —
zweimal beim Schreiben passiert, einmal haette ein Test dadurch anders
gemessen als gelesen). 165 Tests gruen, Smoke gruen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-08-30 18:39:09 +02:00
co-authored by Claude Fable 5
parent e840b7f327
commit 5f386e5406
10 changed files with 709 additions and 8 deletions
@@ -0,0 +1,46 @@
// MESSUNG gegen die echten Metadaten-APIs (TMDb/OMDb) — läuft nur auf
// Verlangen und mit Schlüsseln aus der Umgebung (nie committet):
//
// RIPPY_MESSUNG_META=1 RIPPY_TMDB_KEY=… RIPPY_OMDB_KEY=… \
// npx vitest run test/messung.metadaten.test.ts --reporter=verbose
import { describe, expect, it } from 'vitest'
const messen = process.env.RIPPY_MESSUNG_META === '1'
const tmdbKey = process.env.RIPPY_TMDB_KEY ?? ''
const omdbKey = process.env.RIPPY_OMDB_KEY ?? ''
// Echte Disc-Titel-Fälle aus dem Projektalltag: der saubere Studio-Titel,
// der spezifische Fall (Evangelion-Fund), ein kryptisches Label und eine
// Serien-Disc.
const FAELLE = [
'Akira',
'Evangelion 2.22',
'Spartacus Gods Of The Arena',
'Bd Evg D2',
]
describe.skipIf(!messen)('Zuordnung gegen echte APIs', () => {
it('ordnet die Projekt-Fälle zu', { timeout: 120_000 }, async () => {
expect(tmdbKey.length, 'RIPPY_TMDB_KEY fehlt').toBeGreaterThan(0)
const { TmdbClient } = await import('../src/kern/metadaten/tmdb')
const { OmdbClient } = await import('../src/kern/metadaten/omdb')
const { zuordnen } = await import('../src/kern/metadaten/zuordnung')
const tmdb = new TmdbClient(tmdbKey)
const omdb = new OmdbClient(omdbKey)
for (const fall of FAELLE) {
const z = await zuordnen(fall, tmdb, omdb)
console.log(
`MESSUNG „${fall}" → „${z.titel}"${z.jahr !== null ? ` (${z.jahr})` : ''} · ` +
`${Math.round(z.confidence * 100)} % · ${z.meta.quelle || 'keine Quelle'} · ${z.meta.typ}`,
)
expect(z.confidence).toBeGreaterThan(0)
}
})
})
describe.skipIf(messen)('Metadaten-Messung übersprungen', () => {
it('läuft nur mit RIPPY_MESSUNG_META=1', () => {
expect(true).toBe(true)
})
})
+233
View File
@@ -0,0 +1,233 @@
// 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'), '<x><di:name>Deutscher Titel</di:name></x>')
writeFileSync(join(meta, 'bdmt_eng.xml'), '<x><di:name>English Title</di:name></x>')
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<string, unknown>
}
function fakeTmdb(filme: Record<string, FilmEintrag[]>, serien: Record<string, FilmEintrag[]> = {}) {
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('')
})
})
+18
View File
@@ -79,6 +79,24 @@ describe('§ 4.1 — kein HTTP-Server, kein Port, kein localhost', () => {
})
})
describe('Quelltext-Hygiene — keine rohen Steuerbytes', () => {
it('keine Datei enthält Steuerzeichen außer Tab/LF/CR', () => {
// Zweimal passiert (30.08.2026): Escape-Sequenzen wie \x00 landeten
// als ECHTE Bytes in der Datei — grep meldet „Binary file", Diffs
// leiden, und ein Test sah dadurch anders aus, als sein Quelltext las.
const testDateien = readdirSync(join(WURZEL, 'test'))
.filter((name) => /\.(ts|tsx)$/.test(name))
.map((name) => ({
pfad: `test/${name}`,
inhalt: readFileSync(join(WURZEL, 'test', name), 'utf8'),
}))
const verstoesse = [...dateien, ...testDateien]
.filter((d) => /[\x00-\x08\x0b\x0c\x0e-\x1f]/.test(d.inhalt))
.map((d) => d.pfad)
expect(verstoesse).toEqual([])
})
})
describe('§ 6.7 — EINE Quelle für Einstellungen', () => {
it('node:sqlite wird nur in kern/speicher/db.ts angefasst', () => {
// Der rc11-Fund: Oberfläche schrieb in die Datenbank, der Betrieb las