fix(v5): Disc-Erkennung — die Disc selbst befragen statt raten
Ampel / ampel (push) Successful in 1m38s
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
673f7ff3e7
commit
38a3a87f70
@@ -12,7 +12,7 @@
|
||||
//
|
||||
// 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 { discZusatzAbtrennen, titelKandidaten } from './discmerkmale'
|
||||
import type { FilmDetails, FilmTreffer, TmdbClient } from './tmdb'
|
||||
import type { OmdbClient, OmdbTreffer } from './omdb'
|
||||
|
||||
@@ -94,17 +94,21 @@ const UNBEKANNT = (titel: string): Zuordnung => ({
|
||||
},
|
||||
})
|
||||
|
||||
/** 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 worte = (t: string): Set<string> =>
|
||||
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
|
||||
@@ -113,59 +117,130 @@ export function wortUeberlappung(a: string, b: string): number {
|
||||
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 → (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. */
|
||||
* 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 ersteTreffer: FilmTreffer[] = []
|
||||
let ersteFilme: FilmTreffer[] = []
|
||||
let ersteSerien: 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
|
||||
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 ((film.title ?? '').toLowerCase() === kandidat.toLowerCase()) {
|
||||
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: 0.95, meta }
|
||||
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 ((serie.name ?? '').toLowerCase() === kandidat.toLowerCase()) {
|
||||
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: 0.9, meta }
|
||||
return { titel: meta.titel, jahr: meta.jahr, confidence: deckel(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) {
|
||||
// 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) {
|
||||
@@ -189,6 +264,9 @@ export async function zuordnen(
|
||||
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.
|
||||
@@ -208,17 +286,28 @@ export async function zuordnen(
|
||||
if (deutsch['type'] === 'tv') meta.typ = 'tv'
|
||||
}
|
||||
}
|
||||
return { titel: meta.titel, jahr: meta.jahr, confidence: 0.8, meta }
|
||||
// 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.
|
||||
if (ersteTreffer.length > 0) {
|
||||
const details = await tmdb.movieDetails(ersteTreffer[0].id)
|
||||
// 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', ersteTreffer[0].id, details)
|
||||
return { titel: meta.titel, jahr: meta.jahr, confidence: 0.6, meta }
|
||||
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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user