Ampel / ampel (push) Successful in 1m38s
Der Commander legte Disc 2 von „Spartacus: Gods of the Arena" ein, Rippy meldete den FILM „Spartacus" von 1960. An der echten Disc gemessen (Laufwerk G:, Label SPARTACUS_GOTA_D2, UDF, 33.759.100.928 Bytes): Der stark gestutzte Kandidat „Spartacus" stand auf Platz 2 der Suchvarianten und holte dort einen wörtlichen Filmtreffer mit 95 Prozent — die richtige Serie stand auf Platz 6 und wurde nie gefragt. Vier Ursachen, alle behoben: 1. Die Sicherheit maß die ART des Treffers, nie die QUALITÄT der Frage. Neu: abdeckung() + MINDEST_ABDECKUNG — wer mehr als die Hälfte des Titels wegwerfen musste, um zu treffen, bekommt höchstens einen Vorschlag (0,60), und das UI fragt nach, statt sich sicher zu irren. 2. Verpackungs-Zusätze galten als Wortmüll. Neu: discZusatzAbtrennen() trennt "- Disc 2" / "_D2" / "S1" ab UND merkt sie — die Nummer braucht die Ablage später sowieso. Vier Fallen sind abgesichert: "Rocky 2", "Kill Bill Teil 2", "Ocean s 11" (Apostroph-Wortgrenze) und nackte Zahlen am Ende. 3. Der Titelvergleich war zeichengenau. Ein Volume-Label KANN keinen Doppelpunkt tragen, TMDb schreibt ihn — der exakt richtige Treffer fiel an einem Satzzeichen durch. Neu: gleichBedeutend(). 4. Rippy öffnete das Inhaltsverzeichnis der Disc und las nur die erste Zeile daraus. Neu: inhaltAusBdmt() liest auch <di:titleName>. "EP 1" und "EP 2" beweisen die Serie, "DUB GER 1" und "104 GER FSK" nicht. Ist die Serie bewiesen, sind die Film-Zweige der Kaskade AUS. Gemessen an der eingelegten Disc (neue messung.disc-erkennung.test.ts): Titel "Spartacus: Gods Of The Arena - Disc 2" (Quelle: bdmt) Disc 2 | Staffel — | Folgen 2 | Serie bewiesen: true Varianten: "...Arena - Disc 2" -> "...Arena" -> ... -> "Spartacus" 225 Tests grün (vorher 209), Typprüfung sauber. Disc-Nummer, Staffel und Folgenzahl stehen jetzt auch im Fenster (Kachel neben der Erkennung). Version 5.1.1 — zugleich der erste echte Selbst-Update-Beweis (§ 6.8). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
321 lines
12 KiB
TypeScript
321 lines
12 KiB
TypeScript
// 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<string> {
|
||
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<TmdbClient, 'searchMovie' | 'searchTv' | 'movieDetails' | 'tvDetails' | 'findByImdb' | 'verfuegbar'>,
|
||
omdb: Pick<OmdbClient, 'lookup' | 'verfuegbar'>,
|
||
hinweis: DiscHinweis = {},
|
||
): Promise<Zuordnung> {
|
||
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
|
||
}
|