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
+31 -3
View File
@@ -2,7 +2,7 @@
// Disc-Wache. Die große Oberfläche (Dashboard, Bibliothek, Einstellungen)
// ist Etappe W-5 — hier wird das Gerüst SICHTBAR bewiesen.
import { useEffect, useState } from 'react'
import type { GeraeteInfo, HauptStatus, RipStatus } from '../gemeinsam/nachrichten'
import type { DiscInfoStand, GeraeteInfo, HauptStatus, RipStatus } from '../gemeinsam/nachrichten'
import {
aufVerbindung,
auswerfen,
@@ -60,7 +60,22 @@ function Kachel({ titel, zustand, zeilen }: KachelProps) {
const LAUFENDE_PHASEN = ['liest-info', 'rippt', 'komprimiert', 'legt-ab']
function LaufwerkKachel({ geraet, rip }: { geraet: GeraeteInfo; rip: RipStatus | undefined }) {
function sicherheitsWort(confidence: number): string {
if (confidence >= 0.9) return 'sehr wahrscheinlich'
if (confidence >= 0.8) return 'wahrscheinlich'
if (confidence >= 0.6) return 'Vorschlag'
return 'unsicher'
}
function LaufwerkKachel({
geraet,
rip,
info,
}: {
geraet: GeraeteInfo
rip: RipStatus | undefined
info: DiscInfoStand | undefined
}) {
const ripLaeuft = rip !== undefined && LAUFENDE_PHASEN.includes(rip.phase)
const zustand = geraet.status === 'ready' ? 'ok' : geraet.status === 'empty' ? 'wartet' : 'fehler'
const punktFarbe = ripLaeuft
@@ -113,6 +128,13 @@ function LaufwerkKachel({ geraet, rip }: { geraet: GeraeteInfo; rip: RipStatus |
{geraet.groesseBytes > 0 ? ` · ${gb(geraet.groesseBytes)}` : ''}
</p>
)}
{geraet.status === 'ready' && info !== undefined && info.titel.length > 0 && (
<p className={info.confidence >= 0.8 ? 'text-emerald-300' : 'text-amber-300'}>
{sicherheitsWort(info.confidence)}: {info.titel}
{info.jahr !== null ? ` (${info.jahr})` : ''} · {Math.round(info.confidence * 100)} %
{info.quelle.length > 0 ? ` · ${info.quelle}` : ''}
</p>
)}
{geraet.status === 'empty' && <p>keine Disc eingelegt</p>}
{geraet.status === 'unknown' && <p className="text-red-300">{geraet.grund}</p>}
{geraet.status !== 'unknown' && geraet.grund.length > 0 && (
@@ -162,6 +184,7 @@ export default function App() {
pongLatenzMs: null,
laufwerke: [],
rips: {},
discInfos: {},
meldungen: [],
})
@@ -201,7 +224,12 @@ export default function App() {
) : (
<div className="grid gap-4 sm:grid-cols-2">
{verbindung.laufwerke.map((geraet) => (
<LaufwerkKachel key={geraet.id} geraet={geraet} rip={verbindung.rips[geraet.id]} />
<LaufwerkKachel
key={geraet.id}
geraet={geraet}
rip={verbindung.rips[geraet.id]}
info={verbindung.discInfos[geraet.id]}
/>
))}
</div>
)}
+33 -3
View File
@@ -6,7 +6,13 @@
// jedem neuen Abonnenten sofort wiedergegeben — er wird nie auf »leer«
// zurückgesetzt, nur weil gerade keine Nachricht kam. Insbesondere die
// Laufwerksliste: Sie ändert sich NUR durch eine neue Liste vom Kern.
import { istKernNachricht, type GeraeteInfo, type KernNachricht, type RipStatus } from '../gemeinsam/nachrichten'
import {
istKernNachricht,
type DiscInfoStand,
type GeraeteInfo,
type KernNachricht,
type RipStatus,
} from '../gemeinsam/nachrichten'
export interface Verbindung {
verbunden: boolean
@@ -16,6 +22,8 @@ export interface Verbindung {
/** Letzter Rip-Stand je Laufwerks-Kennung. Bleibt nach dem Ende stehen,
* bis ein neuer Rip startet (R2: kein Verschwinden ohne neue Auskunft). */
rips: Record<string, RipStatus>
/** Was Rippy über die eingelegte Disc weiß (§ 5 Schritt 3). */
discInfos: Record<string, DiscInfoStand>
/** Kurze Ereigniszeilen (neueste zuerst): Disc rein/raus, Auswurf, Fehler. */
meldungen: string[]
}
@@ -30,6 +38,7 @@ const stand: Verbindung = {
pongLatenzMs: null,
laufwerke: [],
rips: {},
discInfos: {},
meldungen: [],
}
const horcher = new Set<Horcher>()
@@ -38,7 +47,13 @@ let port: MessagePort | null = null
function melden(): void {
for (const h of horcher) {
h({ ...stand, laufwerke: [...stand.laufwerke], rips: { ...stand.rips }, meldungen: [...stand.meldungen] })
h({
...stand,
laufwerke: [...stand.laufwerke],
rips: { ...stand.rips },
discInfos: { ...stand.discInfos },
meldungen: [...stand.meldungen],
})
}
}
@@ -79,6 +94,15 @@ function kernNachricht(nachricht: KernNachricht): void {
meldung(nachricht.grund === 'ok' ? text : `${text}`)
break
}
case 'disc-info': {
stand.discInfos = { ...stand.discInfos, [nachricht.info.id]: nachricht.info }
if (nachricht.info.titel.length > 0) {
meldung(
`Laufwerk ${nachricht.info.id}: erkannt als „${nachricht.info.titel}${nachricht.info.jahr !== null ? ` (${nachricht.info.jahr})` : ''}“ · ${Math.round(nachricht.info.confidence * 100)} %`,
)
}
break
}
case 'rip-status': {
stand.rips = { ...stand.rips, [nachricht.status.id]: nachricht.status }
if (nachricht.status.phase === 'fertig') {
@@ -130,7 +154,13 @@ export function verbindungStarten(): void {
export function aufVerbindung(h: Horcher): () => void {
horcher.add(h)
h({ ...stand, laufwerke: [...stand.laufwerke], rips: { ...stand.rips }, meldungen: [...stand.meldungen] })
h({
...stand,
laufwerke: [...stand.laufwerke],
rips: { ...stand.rips },
discInfos: { ...stand.discInfos },
meldungen: [...stand.meldungen],
})
return () => horcher.delete(h)
}
@@ -59,6 +59,17 @@ export interface RipStatus {
lesefehler: boolean
}
/** Was Rippy über die eingelegte Disc herausgefunden hat (§ 5 Schritt 3). */
export interface DiscInfoStand {
id: string
titel: string
jahr: number | null
/** 01; die Schwelle für „sicher genug" ist einstellbar. */
confidence: number
quelle: string
posterPfad: string
}
/** Nachrichten, die der Kern verschickt. */
export type KernNachricht =
| { art: 'kern-bereit'; pid: number; nodeVersion: string; datenbank: DbStatus }
@@ -67,6 +78,7 @@ export type KernNachricht =
| { art: 'laufwerke'; liste: GeraeteInfo[]; ereignisse: WacheEreignis[] }
| { art: 'auswurf-ergebnis'; id: string; grund: AuswurfGrund; fehler: string | null }
| { art: 'rip-status'; status: RipStatus }
| { art: 'disc-info'; info: DiscInfoStand }
/** Nachrichten, die das Fenster über den MessagePort an den Kern schickt. */
export type FensterNachricht =
@@ -113,6 +125,13 @@ export function istKernNachricht(wert: unknown): wert is KernNachricht {
)
case 'rip-status':
return istRipStatus(wert.status)
case 'disc-info':
return (
istObjekt(wert.info) &&
typeof (wert.info as Record<string, unknown>).id === 'string' &&
typeof (wert.info as Record<string, unknown>).titel === 'string' &&
typeof (wert.info as Record<string, unknown>).confidence === 'number'
)
default:
return false
}
+30
View File
@@ -0,0 +1,30 @@
// Poster/Fanart neben den Film legen (KONZEPT § 6.10) — Regeln aus
// medien.py: relative TMDb-Pfade bekommen das w500-Präfix; ein fehlendes
// Poster darf einen gelungenen Rip NIE kippen (best effort, aber die
// Meldung sagt was passiert ist).
import { writeFileSync } from 'node:fs'
export function posterUrl(posterPfad: string): string {
if (posterPfad.length === 0) return ''
if (posterPfad.startsWith('http')) return posterPfad
return `https://image.tmdb.org/t/p/w500${posterPfad}`
}
export async function posterSpeichern(
posterPfad: string,
zielDatei: string,
holen: typeof fetch = fetch,
): Promise<string> {
const url = posterUrl(posterPfad)
if (url.length === 0) return ''
try {
const antwort = await holen(url, { signal: AbortSignal.timeout(15_000) })
if (antwort.status !== 200) return `Poster: HTTP ${antwort.status}`
const daten = Buffer.from(await antwort.arrayBuffer())
if (daten.length === 0) return 'Poster: leere Antwort'
writeFileSync(zielDatei, daten)
return 'poster.jpg gespeichert'
} catch (fehler) {
return `Poster fehlgeschlagen: ${String(fehler)}`
}
}
+29 -2
View File
@@ -13,9 +13,11 @@ import { basename, join } from 'node:path'
import type { GeraeteInfo, RipStatus } from '../../gemeinsam/nachrichten'
import { baueNfo } from '../ablage/nfo'
import { bibliothekRefresh } from '../ablage/medienserver'
import { posterSpeichern } from '../ablage/poster'
import { zielordner } from '../ablage/struktur'
import { komprimieren } from '../komprimieren/handbrake'
import { komprimierenFuer, presetFuer, sprachliste, type Einstellungen } from '../komprimieren/presets'
import type { Zuordnung } from '../metadaten/zuordnung'
import { rippen } from '../rip/makemkv'
interface LaufenderVorgang {
@@ -34,6 +36,8 @@ export interface PipelineUmgebung {
ablage: () => string
einstellungen: () => Einstellungen
melden: (status: RipStatus) => void
/** Die letzte Metadaten-Zuordnung dieses Laufwerks (W-4) — oder null. */
zuordnung?: (id: string) => Zuordnung | null
/** Zeitstempel für Ordnernamen — injizierbar für Tests. */
jetzt?: () => Date
}
@@ -163,7 +167,13 @@ export class Pipeline {
}
const preset = presetFuer(geraet.typ, einstellungen)
const fertigOrdner = zielordner(join(this.umgebung.ablage(), 'Filme'), vorgangsName, null, vorgangsName)
// Mit Metadaten (W-4) heißt der Ordner „<Titel> (Jahr)" — daran
// erkennen Jellyfin & Co. den Film; ohne bleibt der Vorgangsname.
const zuordnung = this.umgebung.zuordnung?.(id) ?? null
const fertigOrdner =
zuordnung !== null && zuordnung.titel.length > 0
? zielordner(join(this.umgebung.ablage(), 'Filme'), zuordnung.titel, zuordnung.jahr, vorgangsName)
: zielordner(join(this.umgebung.ablage(), 'Filme'), vorgangsName, null, vorgangsName)
mkdirSync(fertigOrdner, { recursive: true })
const fertigeDateien: string[] = []
@@ -227,13 +237,30 @@ export class Pipeline {
this.melden({ ...alt, phase: 'legt-ab', prozent: 100, text: 'Lege ab …', dateien: fertigeDateien })
}
try {
writeFileSync(join(fertigOrdner, 'movie.nfo'), baueNfo({ quelle: 'Rippy v5' }, vorgangsName, null))
const nfoName = zuordnung?.meta.typ === 'tv' ? 'tvshow.nfo' : 'movie.nfo'
writeFileSync(
join(fertigOrdner, nfoName),
baueNfo(
{
typ: zuordnung?.meta.typ === 'tv' ? 'serie' : 'film',
beschreibung: zuordnung?.meta.beschreibung,
genres: zuordnung?.meta.genres,
laufzeitMinuten: zuordnung?.meta.laufzeitMinuten,
quelle: zuordnung !== null && zuordnung.meta.quelle.length > 0 ? `Rippy v5 (${zuordnung.meta.quelle})` : 'Rippy v5',
},
zuordnung !== null && zuordnung.titel.length > 0 ? zuordnung.titel : vorgangsName,
zuordnung?.jahr ?? null,
),
)
} catch (fehler) {
// Ein fehlendes NFO darf einen gelungenen Rip nicht kippen — aber es
// wird GESAGT (R4).
const stand = this.laufend.get(id)?.status
if (stand !== undefined) this.melden({ ...stand, text: `NFO fehlgeschlagen: ${String(fehler)}` })
}
if (zuordnung !== null && zuordnung.meta.posterPfad.length > 0) {
await posterSpeichern(zuordnung.meta.posterPfad, join(fertigOrdner, 'poster.jpg'))
}
const server = String(einstellungen['mediaServer'] ?? '')
const serverUrl = String(einstellungen['mediaServerUrl'] ?? '')
+45
View File
@@ -18,6 +18,10 @@ import { Pipeline } from './ablauf/pipeline'
import type { LaufwerkApi } from './laufwerk/codes'
import { auswerfenMitGrund, geraeteInfo, kennung, listOptischeLaufwerke } from './laufwerk/disc'
import { DiscWache } from './laufwerk/wache'
import { discTitel } from './metadaten/discmerkmale'
import { OmdbClient } from './metadaten/omdb'
import { TmdbClient } from './metadaten/tmdb'
import { zuordnen, type Zuordnung } from './metadaten/zuordnung'
import { Datenbank } from './speicher/db'
import { finden } from './werkzeuge/katalog'
@@ -78,6 +82,35 @@ let laufwerkApi: LaufwerkApi | null = null
let laufwerkFehler: string | null = null
let wache: DiscWache | null = null
// ── Metadaten (W-4): Was liegt da drin? ─────────────────────────────────
const discZuordnungen = new Map<string, Zuordnung>()
async function discErmitteln(id: string, geraetepfad: string): Promise<void> {
try {
const gefunden = discTitel(geraetepfad)
const tmdb = new TmdbClient(db?.einstellungLesen('tmdbApiKey') ?? '')
const omdb = new OmdbClient(db?.einstellungLesen('omdbApiKey') ?? '')
const zuordnung = await zuordnen(gefunden.titel, tmdb, omdb)
discZuordnungen.set(id, zuordnung)
anHauptUndFenster({
art: 'disc-info',
info: {
id,
titel: zuordnung.titel,
jahr: zuordnung.jahr,
confidence: zuordnung.confidence,
quelle: zuordnung.meta.quelle,
posterPfad: zuordnung.meta.posterPfad,
},
})
} catch (fehler) {
// R4: Eine gescheiterte Erkennung ist eine laute Zeile — gerippt
// werden kann trotzdem (die Zuordnung ist Komfort, kein Torwächter).
console.error(`[kern] Disc-Erkennung ${id}: ${String(fehler)}`)
anFenster({ art: 'kern-fehler', text: `Disc-Erkennung ${id}: ${String(fehler)}` })
}
}
const laufwerkeBereit: Promise<void> = (async () => {
try {
if (process.platform !== 'win32') {
@@ -89,6 +122,17 @@ const laufwerkeBereit: Promise<void> = (async () => {
() => listOptischeLaufwerke(laufwerkApi!).map((g) => geraeteInfo(laufwerkApi!, g)),
(meldung) => {
anHauptUndFenster({ art: 'laufwerke', liste: meldung.laufwerke, ereignisse: meldung.ereignisse })
for (const ereignis of meldung.ereignisse) {
if (ereignis.art === 'disc-eingelegt') {
void discErmitteln(ereignis.geraet.id, ereignis.geraet.pfad)
} else if (ereignis.art === 'disc-entnommen') {
discZuordnungen.delete(ereignis.geraet.id)
anHauptUndFenster({
art: 'disc-info',
info: { id: ereignis.geraet.id, titel: '', jahr: null, confidence: 0, quelle: '', posterPfad: '' },
})
}
}
},
(fehler) => {
// R4: Eine gescheiterte Runde ist eine laute Zeile — der letzte
@@ -148,6 +192,7 @@ const ripVerwaltung = new Pipeline({
ablage: ablageOrdner,
einstellungen: einstellungenLaden,
melden: (status) => anHauptUndFenster({ art: 'rip-status', status }),
zuordnung: (id) => discZuordnungen.get(id) ?? null,
})
async function auswerfen(id: string): Promise<void> {
@@ -0,0 +1,225 @@
// Treffer + Sicherheitsgrad (KONZEPT § 5, Schritt 3) — die Kaskade aus
// prescan.py mit ihren gemessenen Begründungen:
//
// 0,95 wörtlicher TMDb-Filmtreffer auf eine Suchvariante
// 0,90 wörtlicher TMDb-Serientreffer
// 0,80 SPEZIFISCHER Treffer: voller Disc-Titel, höchstens 3 Treffer
// (der Evangelion-Fund: 'Evangelion 2.22' → 1 Treffer = der
// richtige Film; 'Evangelion' → 20 beliebige. Die Spezifität der
// Anfrage sagt mehr als jede Titel-Ähnlichkeit.) — auch OMDb
// 0,60 Vorschlag (bester Rohtreffer) — das UI fragt nach
// 0,30 unbekannt
//
// Rippy sagt nicht „das ist Akira", sondern „sehr wahrscheinlich Akira
// (1988)" — und fragt nur, wenn es das nicht sagen kann (§ 5).
import { titelKandidaten } from './discmerkmale'
import type { FilmDetails, FilmTreffer, TmdbClient } from './tmdb'
import type { OmdbClient, OmdbTreffer } from './omdb'
export const WENIGE_TREFFER = 3
export interface MetaTreffer {
typ: 'movie' | 'tv' | 'unknown'
id: number | string | null
titel: string
jahr: number | null
beschreibung: string
posterPfad: string
laufzeitMinuten: number
genres: string[]
quelle: 'tmdb' | 'omdb' | ''
}
export interface Zuordnung {
titel: string
jahr: number | null
confidence: number
meta: MetaTreffer
}
function jahrAus(datum: string | undefined): number | null {
if (datum === undefined || datum.length < 4) return null
const jahr = Number(datum.slice(0, 4))
return Number.isFinite(jahr) && jahr > 0 ? jahr : null
}
function ausDetails(typ: 'movie' | 'tv', id: number, details: FilmDetails): MetaTreffer {
return {
typ,
id,
titel: (typ === 'movie' ? details.title : details.name) ?? '',
jahr: jahrAus(typ === 'movie' ? details.release_date : details.first_air_date),
beschreibung: details.overview ?? '',
posterPfad: details.poster_path ?? '',
laufzeitMinuten: details.runtime ?? 0,
genres: (details.genres ?? []).map((g) => g.name),
quelle: 'tmdb',
}
}
function ausOmdb(treffer: OmdbTreffer): MetaTreffer {
return {
typ: treffer.type,
id: treffer.id,
titel: treffer.title,
jahr: treffer.year,
beschreibung: treffer.overview,
posterPfad: treffer.poster_path,
laufzeitMinuten: treffer.runtime,
genres: treffer.genres,
quelle: 'omdb',
}
}
const UNBEKANNT = (titel: string): Zuordnung => ({
titel,
jahr: null,
confidence: 0.3,
meta: {
typ: 'unknown',
id: null,
titel,
jahr: null,
beschreibung: '',
posterPfad: '',
laufzeitMinuten: 0,
genres: [],
quelle: '',
},
})
/** Wort-Überlappung zweier Titel (Jaccard, 01) — das Gate gegen
* selbstbewussten Unsinn: Bei der Live-Messung am 30.08.2026 machte OMDb
* aus dem Label „Bd Evg D2" den Film „BD Scream 5" (gemeinsam nur „BD"). */
export function wortUeberlappung(a: string, b: string): number {
const worte = (t: string): Set<string> =>
new Set(
t
.toLowerCase()
.split(/[^\p{L}\p{N}]+/u)
.filter((w) => w.length > 0),
)
const wa = worte(a)
const wb = worte(b)
if (wa.size === 0 || wb.size === 0) return 0
let gemeinsam = 0
for (const w of wa) if (wb.has(w)) gemeinsam += 1
return gemeinsam / (wa.size + wb.size - gemeinsam)
}
/** Die Kaskade — Clients injiziert, damit sie ohne Netz prüfbar ist.
*
* Reihenfolge JE KANDIDAT: wörtlich Film → wörtlich Serie → (nur beim
* vollen Titel) spezifisch Film → spezifisch Serie. Erst dann der nächste,
* gekürzte Kandidat. Der Grund ist eine Live-Messung vom 30.08.2026:
* Beim Disc-Titel „Spartacus Gods Of The Arena" erschlug der stark
* gekürzte Kandidat „Spartacus" mit einem wörtlichen Treffer (Film von
* 1960!) die richtige Serie, die auf den VOLLEN Titel eindeutig war. */
export async function zuordnen(
discTitel: string,
tmdb: Pick<TmdbClient, 'searchMovie' | 'searchTv' | 'movieDetails' | 'tvDetails' | 'findByImdb' | 'verfuegbar'>,
omdb: Pick<OmdbClient, 'lookup' | 'verfuegbar'>,
): Promise<Zuordnung> {
const titel = discTitel.trim()
if (titel.length === 0) return UNBEKANNT('')
const kandidaten = titelKandidaten(titel)
if (kandidaten.length === 0) kandidaten.push(titel)
let ersteTreffer: FilmTreffer[] = []
for (let i = 0; i < kandidaten.length; i++) {
const kandidat = kandidaten[i]
const filme = (await tmdb.searchMovie(kandidat)) ?? []
if (filme.length > 0 && ersteTreffer.length === 0) ersteTreffer = filme
// Wörtlicher Filmtreffer (0,95)
for (const film of filme) {
if ((film.title ?? '').toLowerCase() === kandidat.toLowerCase()) {
const details = await tmdb.movieDetails(film.id)
if (details !== null) {
const meta = ausDetails('movie', film.id, details)
return { titel: meta.titel, jahr: meta.jahr, confidence: 0.95, meta }
}
}
}
const serien = (await tmdb.searchTv(kandidat)) ?? []
// Wörtlicher Serientreffer (0,90)
for (const serie of serien) {
if ((serie.name ?? '').toLowerCase() === kandidat.toLowerCase()) {
const details = await tmdb.tvDetails(serie.id)
if (details !== null) {
const meta = ausDetails('tv', serie.id, details)
return { titel: meta.titel, jahr: meta.jahr, confidence: 0.9, meta }
}
}
}
// Spezifische Treffer NUR für den vollen, ungekürzten Titel (0,80):
// Wer auf die volle Anfrage eine Handvoll bekommt, hat gefragt wie
// jemand, der weiß, was er sucht (der Evangelion-Fund).
if (i === 0) {
if (filme.length > 0 && filme.length <= WENIGE_TREFFER) {
const details = await tmdb.movieDetails(filme[0].id)
if (details !== null) {
const meta = ausDetails('movie', filme[0].id, details)
return { titel: meta.titel, jahr: meta.jahr, confidence: 0.8, meta }
}
}
if (serien.length > 0 && serien.length <= WENIGE_TREFFER) {
const details = await tmdb.tvDetails(serien[0].id)
if (details !== null) {
const meta = ausDetails('tv', serien[0].id, details)
return { titel: meta.titel, jahr: meta.jahr, confidence: 0.8, meta }
}
}
}
}
// 4. OMDb (0,80) — findet oft, was TMDb nicht exakt trifft; deutscher
// Datensatz wird über TMDb /find (IMDb-ID) nachgeladen.
if (omdb.verfuegbar) {
for (const kandidat of kandidaten) {
const treffer = await omdb.lookup(kandidat)
if (treffer !== null) {
// Ähnlichkeits-Gate: OMDbs t=-Suche antwortet auch auf kryptische
// Labels mit irgendeinem Film — gemessen „Bd Evg D2" → „BD Scream
// 5". Ohne echte Wort-Überlappung ist das kein Treffer.
if (wortUeberlappung(kandidat, treffer.title) < 0.5) continue
const meta = ausOmdb(treffer)
if (typeof meta.id === 'string' && meta.id.startsWith('tt') && tmdb.verfuegbar) {
const deutsch = await tmdb.findByImdb(meta.id)
if (deutsch !== null) {
if (typeof deutsch['title'] === 'string' && deutsch['title'].length > 0) meta.titel = deutsch['title']
if (typeof deutsch['overview'] === 'string' && deutsch['overview'].length > 0) {
meta.beschreibung = deutsch['overview']
}
if (typeof deutsch['poster_path'] === 'string' && deutsch['poster_path'].length > 0) {
meta.posterPfad = deutsch['poster_path']
}
if (typeof deutsch['year'] === 'number') meta.jahr = deutsch['year']
if (deutsch['type'] === 'tv') meta.typ = 'tv'
}
}
return { titel: meta.titel, jahr: meta.jahr, confidence: 0.8, meta }
}
}
}
// 5. Vorschlag: bester Rohtreffer (0,60) — das UI fragt nach.
if (ersteTreffer.length > 0) {
const details = await tmdb.movieDetails(ersteTreffer[0].id)
if (details !== null) {
const meta = ausDetails('movie', ersteTreffer[0].id, details)
return { titel: meta.titel, jahr: meta.jahr, confidence: 0.6, meta }
}
}
return UNBEKANNT(titel)
}
/** § 5 Schritt 4: „Sicher genug?" — die Schwelle ist einstellbar. */
export function istSicherGenug(zuordnung: Zuordnung, schwelle = 0.9): boolean {
return zuordnung.confidence >= schwelle
}
@@ -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