// 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 { discZusatzAbtrennen, 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 /** TMDb-Hintergrundbild (Querformat) — fürs Kachel-Panorama; '' wenn keins. */ backdropPfad: 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 ?? '', backdropPfad: details.backdrop_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, // OMDb kennt keine Querformat-Bilder — die Kachel fällt aufs Poster zurück. backdropPfad: '', 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: '', backdropPfad: '', laufzeitMinuten: 0, genres: [], quelle: '', }, }) /** Titel → Wortmenge, Satzzeichen fallen weg. Grundlage von Überlappung, * Abdeckung und Titelvergleich — alle drei sollen dasselbe „Wort" meinen. */ function worte(t: string): Set { return new Set( t .toLowerCase() .split(/[^\p{L}\p{N}]+/u) .filter((w) => w.length > 0), ) } /** 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 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) } /** Gleicher Titel, wenn man Satzzeichen und Groß-/Kleinschreibung * weglässt. Ein Volume-Label KANN keinen Doppelpunkt tragen, TMDb * schreibt ihn: 'Spartacus Gods Of The Arena' gegen 'Spartacus: Gods of * the Arena' war zeichengenau verglichen kein Treffer — der exakt * richtige Fund fiel an einem Satzzeichen durch (01.09.2026 gemessen). */ export function gleichBedeutend(a: string, b: string): boolean { // Wortmenge wäre hier falsch: Sie machte 'New York, New York' gleich // 'New York'. Verglichen wird die Wort-FOLGE, nur ohne Satzzeichen. const nackt = (t: string): string => t .toLowerCase() .split(/[^\p{L}\p{N}]+/u) .filter((w) => w.length > 0) .join(' ') const na = nackt(a) return na.length > 0 && na === nackt(b) } /** Wie viel vom Disc-Titel steckt noch in dieser Suchvariante (0–1)? */ export function abdeckung(discTitel: string, kandidat: string): number { const ganz = worte(discTitel) if (ganz.size === 0) return 0 let drin = 0 for (const w of worte(kandidat)) if (ganz.has(w)) drin += 1 return drin / ganz.size } /** Sicherheit eines Treffers, den das UI als Vorschlag zeigt und nachfragt. */ export const VORSCHLAG = 0.6 /** Darunter ist ein Treffer GERATEN, nicht gefunden — und wird zum * Vorschlag herabgestuft, egal wie wörtlich er aussieht. * * Der Grund, an der echten Disc gemessen (01.09.2026): Aus * 'Spartacus: Gods Of The Arena - Disc 2' blieb nach dem Stutzen die * Frage 'Spartacus' übrig — sechs von sieben Wörtern weggeworfen. TMDb * antwortete mit einem wörtlichen Filmtreffer, und Rippy meldete mit * 95 % Sicherheit den falschen Film. Die alte Kaskade maß nur die ART * des Treffers, nie die QUALITÄT der Frage. */ export const MINDEST_ABDECKUNG = 0.5 export interface DiscHinweis { /** Die Disc bezeugt über ihr EIGENES Inhaltsverzeichnis, dass sie Folgen * trägt (discmerkmale.discTitel().istSerie). Dann sind die Film-Zweige * aus: Was bewiesen ist, muss nicht mehr geraten werden. */ istSerie?: boolean } /** Die Kaskade — Clients injiziert, damit sie ohne Netz prüfbar ist. * * Reihenfolge JE KANDIDAT: wörtlich Film → wörtlich Serie → spezifisch * Film → spezifisch Serie. Erst dann der nächste, ärmere Kandidat. * * Drei Sicherungen, alle an echten Discs bezahlt: * * * `hinweis.istSerie` schaltet die Film-Zweige ganz ab. * * Jede Sicherheit wird an der ABDECKUNG gedeckelt (MINDEST_ABDECKUNG): * Wer den halben Titel wegwerfen musste, um zu treffen, hat geraten — * das ist ein Vorschlag, kein Fund. Der Fall vom 01.09.2026: * 'Spartacus: Gods Of The Arena - Disc 2' wurde über die Restfrage * 'Spartacus' zum Film von 1960, mit 95 % Sicherheit gemeldet. * * Die spezifischen Treffer (0,80) verlangen einen VOLLSTÄNDIGEN * Kandidaten — er muss noch jedes Wort des Titels tragen (Zusätze wie * '- Disc 2' zählen nicht mit). Vorher hing das an „Kandidat Nummer * 0"; sobald der Zusatz abgetrennt wurde, war der richtige Kandidat * Nummer 1 und fiel durch dieses Raster. */ export async function zuordnen( discTitel: string, tmdb: Pick, omdb: Pick, hinweis: DiscHinweis = {}, ): Promise { const titel = discTitel.trim() if (titel.length === 0) return UNBEKANNT('') const nurSerie = hinweis.istSerie === true // Maßstab für die Abdeckung ist der Titel OHNE Verpackungs-Zusatz: // '- Disc 2' ist Rauschen und darf einer guten Frage nicht angelastet werden. const massstab = discZusatzAbtrennen(titel).titel || titel const kandidaten = titelKandidaten(titel) if (kandidaten.length === 0) kandidaten.push(titel) let ersteFilme: FilmTreffer[] = [] let ersteSerien: FilmTreffer[] = [] for (const kandidat of kandidaten) { const deckung = abdeckung(massstab, kandidat) const vollstaendig = deckung >= 1 /** Ein Treffer auf eine stark gestutzte Frage bleibt ein Vorschlag. */ const deckel = (basis: number): number => (deckung >= MINDEST_ABDECKUNG ? basis : Math.min(basis, VORSCHLAG)) const filme = nurSerie ? [] : ((await tmdb.searchMovie(kandidat)) ?? []) if (filme.length > 0 && ersteFilme.length === 0) ersteFilme = filme // Wörtlicher Filmtreffer (0,95) for (const film of filme) { if (gleichBedeutend(film.title ?? '', kandidat)) { 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: deckel(0.95), meta } } } } const serien = (await tmdb.searchTv(kandidat)) ?? [] if (serien.length > 0 && ersteSerien.length === 0) ersteSerien = serien // Wörtlicher Serientreffer (0,90) for (const serie of serien) { if (gleichBedeutend(serie.name ?? '', kandidat)) { 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: deckel(0.9), meta } } } } // Spezifische Treffer (0,80): Wer auf eine VOLLSTÄNDIGE Anfrage eine // Handvoll bekommt, hat gefragt wie jemand, der weiß, was er sucht // (der Evangelion-Fund). if (vollstaendig) { 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) { // Eine Serien-Disc nimmt keinen Film entgegen — sie hat bewiesen, // was sie ist. if (nurSerie && treffer.type !== 'tv') continue // Ä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' } } // Auch OMDb wird gedeckelt: Ein Treffer auf eine ausgedünnte Frage // ist ein Vorschlag, egal von welchem Dienst er kommt. const sicher = abdeckung(massstab, kandidat) >= MINDEST_ABDECKUNG ? 0.8 : VORSCHLAG return { titel: meta.titel, jahr: meta.jahr, confidence: sicher, meta } } } } // 5. Vorschlag: bester Rohtreffer (0,60) — das UI fragt nach. Bei einer // Serien-Disc ist die Filmliste leer, dort zählen nur die Serien. if (ersteFilme.length > 0) { const details = await tmdb.movieDetails(ersteFilme[0].id) if (details !== null) { const meta = ausDetails('movie', ersteFilme[0].id, details) return { titel: meta.titel, jahr: meta.jahr, confidence: VORSCHLAG, meta } } } if (ersteSerien.length > 0) { const details = await tmdb.tvDetails(ersteSerien[0].id) if (details !== null) { const meta = ausDetails('tv', ersteSerien[0].id, details) return { titel: meta.titel, jahr: meta.jahr, confidence: VORSCHLAG, 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 }