diff --git a/rippy-windows/src/fenster/App.tsx b/rippy-windows/src/fenster/App.tsx
index ea5db19..6bfce0c 100644
--- a/rippy-windows/src/fenster/App.tsx
+++ b/rippy-windows/src/fenster/App.tsx
@@ -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)}` : ''}
)}
+ {geraet.status === 'ready' && info !== undefined && info.titel.length > 0 && (
+ = 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}` : ''}
+
+ )}
{geraet.status === 'empty' && keine Disc eingelegt
}
{geraet.status === 'unknown' && {geraet.grund}
}
{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() {
) : (
{verbindung.laufwerke.map((geraet) => (
-
+
))}
)}
diff --git a/rippy-windows/src/fenster/kernverbindung.ts b/rippy-windows/src/fenster/kernverbindung.ts
index 1e7af7c..72de71a 100644
--- a/rippy-windows/src/fenster/kernverbindung.ts
+++ b/rippy-windows/src/fenster/kernverbindung.ts
@@ -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
+ /** Was Rippy über die eingelegte Disc weiß (§ 5 Schritt 3). */
+ discInfos: Record
/** 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()
@@ -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)
}
diff --git a/rippy-windows/src/gemeinsam/nachrichten.ts b/rippy-windows/src/gemeinsam/nachrichten.ts
index c70d9f7..c44b7e5 100644
--- a/rippy-windows/src/gemeinsam/nachrichten.ts
+++ b/rippy-windows/src/gemeinsam/nachrichten.ts
@@ -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
+ /** 0–1; 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).id === 'string' &&
+ typeof (wert.info as Record).titel === 'string' &&
+ typeof (wert.info as Record).confidence === 'number'
+ )
default:
return false
}
diff --git a/rippy-windows/src/kern/ablage/poster.ts b/rippy-windows/src/kern/ablage/poster.ts
new file mode 100644
index 0000000..fc3d868
--- /dev/null
+++ b/rippy-windows/src/kern/ablage/poster.ts
@@ -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 {
+ 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)}`
+ }
+}
diff --git a/rippy-windows/src/kern/ablauf/pipeline.ts b/rippy-windows/src/kern/ablauf/pipeline.ts
index 854d2ea..81ec940 100644
--- a/rippy-windows/src/kern/ablauf/pipeline.ts
+++ b/rippy-windows/src/kern/ablauf/pipeline.ts
@@ -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 „ (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'] ?? '')
diff --git a/rippy-windows/src/kern/index.ts b/rippy-windows/src/kern/index.ts
index 3c0aea0..1cb5cb8 100644
--- a/rippy-windows/src/kern/index.ts
+++ b/rippy-windows/src/kern/index.ts
@@ -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()
+
+async function discErmitteln(id: string, geraetepfad: string): Promise {
+ 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 = (async () => {
try {
if (process.platform !== 'win32') {
@@ -89,6 +122,17 @@ const laufwerkeBereit: Promise = (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 {
diff --git a/rippy-windows/src/kern/metadaten/zuordnung.ts b/rippy-windows/src/kern/metadaten/zuordnung.ts
new file mode 100644
index 0000000..08977df
--- /dev/null
+++ b/rippy-windows/src/kern/metadaten/zuordnung.ts
@@ -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, 0–1) — 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 =>
+ 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,
+ omdb: Pick,
+): Promise {
+ 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
+}
diff --git a/rippy-windows/test/messung.metadaten.test.ts b/rippy-windows/test/messung.metadaten.test.ts
new file mode 100644
index 0000000..265dea6
--- /dev/null
+++ b/rippy-windows/test/messung.metadaten.test.ts
@@ -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)
+ })
+})
diff --git a/rippy-windows/test/metadaten.test.ts b/rippy-windows/test/metadaten.test.ts
new file mode 100644
index 0000000..9b25d78
--- /dev/null
+++ b/rippy-windows/test/metadaten.test.ts
@@ -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'), 'Deutscher Titel')
+ writeFileSync(join(meta, 'bdmt_eng.xml'), 'English Title')
+ 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
+}
+
+function fakeTmdb(filme: Record, serien: Record = {}) {
+ 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('')
+ })
+})
diff --git a/rippy-windows/test/waechter.test.ts b/rippy-windows/test/waechter.test.ts
index c739c7d..2342b24 100644
--- a/rippy-windows/test/waechter.test.ts
+++ b/rippy-windows/test/waechter.test.ts
@@ -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