From 5f386e5406dfe25b3cf063d8fb043d3da25478ff Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Sun, 30 Aug 2026 18:39:09 +0200 Subject: [PATCH] =?UTF-8?q?feat(v5):=20Etappe=20W-4=20=E2=80=94=20Metadate?= =?UTF-8?q?n-Automatik=20mit=20Sicherheitsgrad?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ' (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 --- rippy-windows/src/fenster/App.tsx | 34 ++- rippy-windows/src/fenster/kernverbindung.ts | 36 ++- rippy-windows/src/gemeinsam/nachrichten.ts | 19 ++ rippy-windows/src/kern/ablage/poster.ts | 30 +++ rippy-windows/src/kern/ablauf/pipeline.ts | 31 ++- rippy-windows/src/kern/index.ts | 45 ++++ rippy-windows/src/kern/metadaten/zuordnung.ts | 225 +++++++++++++++++ rippy-windows/test/messung.metadaten.test.ts | 46 ++++ rippy-windows/test/metadaten.test.ts | 233 ++++++++++++++++++ rippy-windows/test/waechter.test.ts | 18 ++ 10 files changed, 709 insertions(+), 8 deletions(-) create mode 100644 rippy-windows/src/kern/ablage/poster.ts create mode 100644 rippy-windows/src/kern/metadaten/zuordnung.ts create mode 100644 rippy-windows/test/messung.metadaten.test.ts create mode 100644 rippy-windows/test/metadaten.test.ts 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