Files
rippy/rippy-windows/src/kern/metadaten/zuordnung.ts
T
HitonabiandClaude Opus 5 38a3a87f70
Ampel / ampel (push) Successful in 1m38s
fix(v5): Disc-Erkennung — die Disc selbst befragen statt raten
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>
2026-09-01 17:31:56 +02:00

321 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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, 01) — 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 (01)? */
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
}