// 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). // // 5.4.0 — Erkennung v2, drei Ergänzungen, alle an echten Daten bezahlt: // * STUBS: TMDb hat „Spartacus: Gods of the Arena" dreimal als leeren // Serien-Eintrag (kein Backdrop, keine Beschreibung, 1 Folge, // Popularität 1). Ein wörtlicher Treffer darauf sah 5.1.1 als 90 % // sicher an — es war ein Eintrag ohne Inhalt. Leere Einträge zählen // nicht mehr als Treffer. // * LAUFZEIT: Nach dem Info-Lauf wird die gemessene Länge des Hauptinhalts // gegen TMDb gehalten (nachpruefen). Passt sie, wird aus „wahrscheinlich" // „bestätigt"; passt sie gar nicht (95 vs. 158 min), fällt der Treffer // auf einen Vorschlag zurück. // * VARIANTEN: Die Disc trägt oft zwei Titel (bdmt_eng UND bdmt_deu) — // jede Variante wird gefragt, und die Abdeckung wird an IHRER Vorlage // gemessen, nicht an der englischen. import { discZusatzAbtrennen, titelKandidaten } from './discmerkmale' import { besetzungAus, fskAus, logoAus, type FilmDetails, type FilmTreffer, type TmdbClient } from './tmdb' import type { OmdbClient, OmdbTreffer } from './omdb' import type { Darsteller } from '../../gemeinsam/nachrichten' 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' | '' // ── Kino-Banner v2 (5.4.0) ── logoPfad: string tagline: string bewertung: number fsk: string besetzung: Darsteller[] /** Folgen laut TMDb (Serie) — 0 wenn unbekannt/Film. */ folgenGesamt: number } export interface Zuordnung { titel: string jahr: number | null confidence: number meta: MetaTreffer /** Was Rippy nach dem Info-Lauf noch dazugelernt hat (nachpruefen). */ hinweise?: string[] } 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 } export function ausDetails(typ: 'movie' | 'tv', id: number, details: FilmDetails): MetaTreffer { const laufzeit = typ === 'movie' ? (details.runtime ?? 0) : ((details.episode_run_time ?? []).find((m) => typeof m === 'number' && m > 0) ?? 0) 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: laufzeit, genres: (details.genres ?? []).map((g) => g.name), quelle: 'tmdb', logoPfad: logoAus(details.images?.logos), tagline: (details.tagline ?? '').trim(), bewertung: typeof details.vote_average === 'number' ? Math.round(details.vote_average * 10) / 10 : 0, fsk: fskAus(details, typ), besetzung: besetzungAus(details.credits?.cast), folgenGesamt: typ === 'tv' ? (details.number_of_episodes ?? 0) : 0, } } 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', logoPfad: '', tagline: '', bewertung: 0, fsk: '', besetzung: [], folgenGesamt: 0, } } export const LEERER_TREFFER: MetaTreffer = { typ: 'unknown', id: null, titel: '', jahr: null, beschreibung: '', posterPfad: '', backdropPfad: '', laufzeitMinuten: 0, genres: [], quelle: '', logoPfad: '', tagline: '', bewertung: 0, fsk: '', besetzung: [], folgenGesamt: 0, } const UNBEKANNT = (titel: string): Zuordnung => ({ titel, jahr: null, confidence: 0.3, meta: { ...LEERER_TREFFER, titel }, }) /** Ein Eintrag ohne Inhalt ist kein Treffer (pur). Gemessen an den drei * „Spartacus: Gods of the Arena"-Stubs: kein Backdrop, keine Beschreibung, * keine Bewertung — TMDb kennt dort nur den Namen. */ export function istStub(details: FilmDetails): boolean { const beschreibung = (details.overview ?? '').trim().length > 0 const bild = (details.backdrop_path ?? '') !== '' && details.backdrop_path !== null const bewertet = (details.vote_count ?? 0) > 0 return !beschreibung && !bild && !bewertet } /** 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 /** Weitere Titel derselben Disc (bdmt_deu neben bdmt_eng, 5.4.0). */ alternativen?: readonly string[] } interface Kandidat { text: string /** Die Vorlage, an der die Abdeckung gemessen wird (Titel ohne Zusatz). */ massstab: string } /** Suchvarianten aller Titel-Varianten: erst die vollen Titel, dann die * gestutzten — je Variante an ihrer eigenen Vorlage gemessen (pur). */ export function kandidatenAus(titel: string, alternativen: readonly string[] = []): Kandidat[] { const quellen = [titel, ...alternativen].map((t) => t.trim()).filter((t) => t.length > 0) const gesehen = new Set() const voll: Kandidat[] = [] const rest: Kandidat[] = [] for (const quelle of quellen) { const massstab = discZusatzAbtrennen(quelle).titel || quelle const varianten = titelKandidaten(quelle) if (varianten.length === 0) varianten.push(quelle) varianten.forEach((text, i) => { const schluessel = text.toLowerCase() if (gesehen.has(schluessel)) return gesehen.add(schluessel) ;(i === 0 ? voll : rest).push({ text, massstab }) }) } return [...voll, ...rest] } /** 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. * * (5.4.0) Ein Eintrag ohne Inhalt (istStub) ist kein Treffer — der * nächste wörtliche Treffer bekommt die Chance. */ 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 const kandidaten = kandidatenAus(titel, hinweis.alternativen ?? []) let ersteFilme: FilmTreffer[] = [] let ersteSerien: FilmTreffer[] = [] for (const kandidat of kandidaten) { const deckung = abdeckung(kandidat.massstab, kandidat.text) 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.text)) ?? []) if (filme.length > 0 && ersteFilme.length === 0) ersteFilme = filme // Wörtlicher Filmtreffer (0,95) for (const film of filme) { if (gleichBedeutend(film.title ?? '', kandidat.text)) { const details = await tmdb.movieDetails(film.id) if (details !== null && !istStub(details)) { 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.text)) ?? [] if (serien.length > 0 && ersteSerien.length === 0) ersteSerien = serien // Wörtlicher Serientreffer (0,90) for (const serie of serien) { if (gleichBedeutend(serie.name ?? '', kandidat.text)) { const details = await tmdb.tvDetails(serie.id) if (details !== null && !istStub(details)) { 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). Stubs zählen dabei nicht mit. if (vollstaendig) { if (filme.length > 0 && filme.length <= WENIGE_TREFFER) { for (const film of filme) { const details = await tmdb.movieDetails(film.id) if (details !== null && !istStub(details)) { const meta = ausDetails('movie', film.id, details) return { titel: meta.titel, jahr: meta.jahr, confidence: 0.8, meta } } } } if (serien.length > 0 && serien.length <= WENIGE_TREFFER) { for (const serie of serien) { const details = await tmdb.tvDetails(serie.id) if (details !== null && !istStub(details)) { const meta = ausDetails('tv', serie.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.text) 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.text, 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(kandidat.massstab, kandidat.text) >= 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. // Einträge MIT Inhalt zuerst; ist alles leer, bleibt der erste Treffer // trotzdem ein Vorschlag — besser als gar keiner (es wird ja gefragt). let notnagel: Zuordnung | null = null for (const film of ersteFilme.slice(0, WENIGE_TREFFER)) { const details = await tmdb.movieDetails(film.id) if (details === null) continue const meta = ausDetails('movie', film.id, details) const vorschlag = { titel: meta.titel, jahr: meta.jahr, confidence: VORSCHLAG, meta } if (!istStub(details)) return vorschlag notnagel ??= vorschlag } for (const serie of ersteSerien.slice(0, WENIGE_TREFFER)) { const details = await tmdb.tvDetails(serie.id) if (details === null) continue const meta = ausDetails('tv', serie.id, details) const vorschlag = { titel: meta.titel, jahr: meta.jahr, confidence: VORSCHLAG, meta } if (!istStub(details)) return vorschlag notnagel ??= vorschlag } if (notnagel !== null) return notnagel 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 } /** Laufzeit passt, wenn die gemessene Länge des Hauptinhalts höchstens so * weit von TMDb abweicht (Kino- gegen Heimvideo-Fassung, Abspann). */ export const LAUFZEIT_PASST = 0.06 /** Darüber passt sie NICHT — der Treffer wird zum Vorschlag (95 vs. 158 min). */ export const LAUFZEIT_FALSCH = 0.25 /** Was der Info-Lauf über die Disc gemessen hat (rip/titelwahl.ts). */ export interface Messung { /** Länge des Hauptinhalts in Sekunden (Film) — 0 wenn keiner erkennbar. */ hauptS: number /** Längen der Folgen-Kandidaten in Sekunden (Serie). */ folgenS: readonly number[] /** Was die Titel-Struktur sagt: 'film' | 'serie' | 'unklar'. */ struktur: 'film' | 'serie' | 'unklar' } /** * Nachprüfen NACH dem Info-Lauf (pur, 5.4.0): Erkannt wird beim EINLEGEN, * also vor jeder Messung. Jetzt liegt die stärkste Evidenz vor, die zu * haben ist — die Laufzeit. Aus „wahrscheinlich" wird „bestätigt", oder * Rippy merkt selbst, dass es danebenlag. Eine Wahl des Nutzers * (confidence 1) wird nie herabgestuft, nur kommentiert. */ export function nachpruefen(zuordnung: Zuordnung, messung: Messung): Zuordnung { const hinweise: string[] = [] let confidence = zuordnung.confidence const meta = zuordnung.meta const vomNutzer = zuordnung.confidence >= 1 const soll = meta.laufzeitMinuten * 60 if (meta.typ === 'movie' && soll > 0 && messung.hauptS > 0) { const abweichung = Math.abs(messung.hauptS - soll) / soll const ist = Math.round(messung.hauptS / 60) if (abweichung <= LAUFZEIT_PASST) { hinweise.push(`Laufzeit bestätigt: ${ist} min auf der Disc, ${meta.laufzeitMinuten} min bei TMDb.`) if (!vomNutzer) confidence = Math.max(confidence, 0.95) } else if (abweichung >= LAUFZEIT_FALSCH) { hinweise.push( `Laufzeit passt NICHT: ${ist} min auf der Disc, ${meta.laufzeitMinuten} min bei TMDb — bitte über „Ändern …" nachsehen.`, ) if (!vomNutzer) confidence = Math.min(confidence, VORSCHLAG) } else { hinweise.push(`Laufzeit ähnlich: ${ist} min auf der Disc, ${meta.laufzeitMinuten} min bei TMDb (andere Fassung?).`) } } else if (meta.typ === 'tv' && soll > 0 && messung.folgenS.length > 0) { const mittel = messung.folgenS.reduce((s, f) => s + f, 0) / messung.folgenS.length const abweichung = Math.abs(mittel - soll) / soll const ist = Math.round(mittel / 60) if (abweichung <= 0.2) { hinweise.push(`Folgenlänge passt: ${ist} min auf der Disc, ${meta.laufzeitMinuten} min bei TMDb.`) if (!vomNutzer) confidence = Math.max(confidence, 0.8) } else if (abweichung >= 0.5) { hinweise.push(`Folgenlänge passt NICHT: ${ist} min auf der Disc, ${meta.laufzeitMinuten} min bei TMDb.`) if (!vomNutzer) confidence = Math.min(confidence, VORSCHLAG) } } if (messung.struktur === 'serie' && meta.typ === 'movie') { hinweise.push('Die Titel-Struktur sieht nach einer Serienstaffel aus (mehrere gleich lange Titel), TMDb-Treffer ist ein Film.') if (!vomNutzer) confidence = Math.min(confidence, VORSCHLAG) } else if (messung.struktur === 'film' && meta.typ === 'tv') { hinweise.push('Die Titel-Struktur sieht nach einem Film aus (ein langer Titel), TMDb-Treffer ist eine Serie.') if (!vomNutzer) confidence = Math.min(confidence, VORSCHLAG) } return { ...zuordnung, confidence: Math.round(confidence * 100) / 100, hinweise } }