fix(v5): Disc-Erkennung — die Disc selbst befragen statt raten
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:
Hitonabi
2026-09-01 17:31:56 +02:00
co-authored by Claude Opus 5
parent 673f7ff3e7
commit 38a3a87f70
10 changed files with 631 additions and 66 deletions
+19 -2
View File
@@ -26,7 +26,7 @@ import {
listOptischeLaufwerke,
} from './laufwerk/disc'
import { DiscWache } from './laufwerk/wache'
import { discFingerprint, discTitel } from './metadaten/discmerkmale'
import { discFingerprint, discTitel, type DiscTitel } from './metadaten/discmerkmale'
import { OmdbClient } from './metadaten/omdb'
import { TmdbClient } from './metadaten/tmdb'
import { zuordnen, type Zuordnung } from './metadaten/zuordnung'
@@ -94,15 +94,21 @@ let wache: DiscWache | null = null
// ── Metadaten (W-4): Was liegt da drin? ─────────────────────────────────
const discZuordnungen = new Map<string, Zuordnung>()
const discFingerprints = new Map<string, string>()
// Was die Disc über sich selbst sagt (Disc-Nummer, Staffel, Folgenzahl) —
// das bleibt wahr, auch wenn der Commander den Titel selbst neu wählt.
const discMerkmale = new Map<string, DiscTitel>()
async function discErmitteln(id: string, geraetepfad: string, groesseBytes: number): Promise<void> {
try {
const gefunden = discTitel(geraetepfad)
const fingerprint = discFingerprint(geraetepfad, groesseBytes)
discFingerprints.set(id, fingerprint)
discMerkmale.set(id, gefunden)
const tmdb = new TmdbClient(db?.einstellungLesen('tmdbApiKey') ?? '')
const omdb = new OmdbClient(db?.einstellungLesen('omdbApiKey') ?? '')
const zuordnung = await zuordnen(gefunden.titel, tmdb, omdb)
// Der Hinweis kommt von der Disc selbst (bdmt-Inhaltsverzeichnis) —
// trägt sie Folgen, kann kein Filmtreffer mehr gewinnen.
const zuordnung = await zuordnen(gefunden.titel, tmdb, omdb, { istSerie: gefunden.istSerie })
discZuordnungen.set(id, zuordnung)
anHauptUndFenster({
art: 'disc-info',
@@ -118,6 +124,9 @@ async function discErmitteln(id: string, geraetepfad: string, groesseBytes: numb
// § 6.6: Rippy erkennt beim Einlegen, wenn eine Disc schon einmal
// durchgelaufen ist — und fragt nach, statt stumm zu doppeln.
schonGerippt: db?.bibliothekSchonGerippt(fingerprint) ?? null,
discNr: gefunden.discNr,
staffel: gefunden.staffel,
folgen: gefunden.folgen,
},
})
} catch (fehler) {
@@ -203,6 +212,10 @@ async function zuordnungSetzen(id: string, vorschlag: MetaVorschlag): Promise<vo
backdropPfad: zuordnung.meta.backdropPfad,
beschreibung: zuordnung.meta.beschreibung,
schonGerippt: db?.bibliothekSchonGerippt(discFingerprints.get(id) ?? '') ?? null,
// Eigene Titelwahl ändert nichts daran, welche Disc der Ausgabe hier liegt.
discNr: discMerkmale.get(id)?.discNr ?? null,
staffel: discMerkmale.get(id)?.staffel ?? null,
folgen: discMerkmale.get(id)?.folgen ?? 0,
},
})
} catch (fehler) {
@@ -227,6 +240,7 @@ const laufwerkeBereit: Promise<void> = (async () => {
} else if (ereignis.art === 'disc-entnommen') {
discZuordnungen.delete(ereignis.geraet.id)
discFingerprints.delete(ereignis.geraet.id)
discMerkmale.delete(ereignis.geraet.id)
anHauptUndFenster({
art: 'disc-info',
info: {
@@ -239,6 +253,9 @@ const laufwerkeBereit: Promise<void> = (async () => {
backdropPfad: '',
beschreibung: '',
schonGerippt: null,
discNr: null,
staffel: null,
folgen: 0,
},
})
}
+159 -25
View File
@@ -105,29 +105,118 @@ export function udfVolumeLabel(geraetepfad: string): string {
}
}
/** Der Studio-Titel aus BDMV/META/DL/bdmt_*.xml — bdmt_eng bevorzugt,
* Regex statt XML-Parser (Namespaces variieren je Authoring-Werkzeug). */
export function titelAusBdmt(wurzel: string): string {
/** Was in BDMV/META/DL/bdmt_*.xml steht. Bis zum 01.09.2026 nahm Rippy von
* dieser Datei NUR den Titel — das Inhaltsverzeichnis daneben ist der
* stärkere Hinweis und lag ungelesen da. An SPARTACUS_GOTA_D2 gemessen:
*
* <di:name>Spartacus: Gods Of The Arena - Disc 2</di:name>
* <di:titleName titleNumber="1">EP 1</di:titleName>
* <di:titleName titleNumber="2">EP 2</di:titleName>
* <di:titleName titleNumber="51">DUB GER 1</di:titleName> … usw.
*
* Die Disc sagt damit selbst, dass sie eine SERIE mit zwei Folgen trägt.
* Das ist ein Beweis, keine Vermutung — und schlägt jede Titel-Raterei. */
export interface BdmtInhalt {
/** <di:name> — der Studio-Titel im Klartext; '' wenn keiner dasteht. */
titel: string
/** <di:titleName> — was die Disc trägt: Folgen, Tonspuren, Trailer. */
eintraege: string[]
}
/** Liest die bdmt-Datei EINMAL — bdmt_eng bevorzugt. Regex statt
* XML-Parser (Namespaces variieren je Authoring-Werkzeug). */
export function inhaltAusBdmt(wurzel: string): BdmtInhalt {
// Nicht lesbar heißt „nichts erfahren" — dasselbe Ergebnis wie „keine
// Datei da". Eine Erkennung scheitert daran nie, sie wird nur ärmer.
const leer: BdmtInhalt = { titel: '', eintraege: [] }
const metaOrdner = join(wurzel, 'BDMV', 'META', 'DL')
if (!existsSync(metaOrdner)) return ''
if (!existsSync(metaOrdner)) return leer
let kandidaten: string[]
try {
kandidaten = readdirSync(metaOrdner).sort()
} catch {
return ''
return leer
}
const bdmt =
kandidaten.find((k) => k === 'bdmt_eng.xml') ??
kandidaten.find((k) => k.startsWith('bdmt_') && k.endsWith('.xml'))
if (bdmt === undefined) return ''
if (bdmt === undefined) return leer
let inhalt: string
try {
inhalt = readFileSync(join(metaOrdner, bdmt)).subarray(0, 65536).toString('utf8')
} catch {
return ''
return leer
}
const treffer = /<di:name>([^<]{2,120})<\/di:name>/.exec(inhalt)
return treffer === null ? '' : treffer[1].trim()
const name = /<di:name>([^<]{2,120})<\/di:name>/.exec(inhalt)
const eintraege: string[] = []
for (const t of inhalt.matchAll(/<di:titleName[^>]*>([^<]{1,120})<\/di:titleName>/g)) {
const text = t[1].trim()
if (text.length > 0) eintraege.push(text)
}
return { titel: name === null ? '' : name[1].trim(), eintraege }
}
/** Der Studio-Titel aus BDMV/META/DL/bdmt_*.xml — '' wenn keiner dasteht. */
export function titelAusBdmt(wurzel: string): string {
return inhaltAusBdmt(wurzel).titel
}
/** Sieht dieser Inhaltseintrag aus wie eine Folge? Bewusst ENG: An der
* echten Disc stehen neben `EP 1`/`EP 2` auch `DUB GER 1`, `MM ITA` und
* `101 LOGO` — Tonspuren, Trailer und Warnhinweise sind keine Folgen. */
export function istFolgenName(name: string): boolean {
return /^(?:ep|episode|folge)\s*0*\d{1,3}$/i.test(name.trim())
}
/** Wie viele Einträge des Inhaltsverzeichnisses sind Folgen? */
export function folgenZahl(eintraege: readonly string[]): number {
return eintraege.filter(istFolgenName).length
}
// Verpackungs-Zusätze am ENDE eines Disc-Titels. Was hier NICHT steht,
// steht mit Absicht nicht hier:
// * `Teil`/`Part` — „Kill Bill Teil 2" ist ein Filmtitel, keine Disc-Nummer.
// * eine nackte Zahl — sonst würde aus „Rocky 2" die „Rocky", Disc 2.
// Die Ein-Buchstaben-Formen `d`/`s` verlangen einen TRENNER davor: ohne ihn
// würde „Ocean's 11" an der Apostroph-Wortgrenze zu „Ocean'", Staffel 11.
const TRENNER = String.raw`[\s\-–—_:,.]`
const DISC_MUSTER = new RegExp(`${TRENNER}+(?:disc|disk|dvd|scheibe|d)\\s*0*(\\d{1,2})\\s*$`, 'i')
const STAFFEL_MUSTER = new RegExp(`${TRENNER}+(?:season|staffel|series|s)\\s*0*(\\d{1,2})\\s*$`, 'i')
export interface DiscZusatz {
/** Der Titel ohne die erkannten Verpackungs-Zusätze. */
titel: string
/** Nummer der Disc innerhalb der Ausgabe — null, wenn keine dasteht. */
discNr: number | null
/** Staffel-Nummer — null, wenn keine dasteht. */
staffel: number | null
}
/** `Spartacus: Gods Of The Arena - Disc 2` → Titel + Disc 2 (pur).
* Abtrennen UND merken: Der Suchtitel wird brauchbar, und die Nummer
* braucht die Ablage später sowieso, um Folgen richtig einzusortieren. */
export function discZusatzAbtrennen(titel: string): DiscZusatz {
let rest = titel.trim()
let discNr: number | null = null
let staffel: number | null = null
// Mehrfach, weil ein Label wie `SHOW_S1_D2` beide Zusätze hintereinander
// trägt; die Schleife endet, sobald hinten nichts Bekanntes mehr steht.
for (let runde = 0; runde < 4; runde++) {
const disc = DISC_MUSTER.exec(rest)
if (disc !== null && discNr === null && Number(disc[1]) > 0) {
discNr = Number(disc[1])
rest = rest.slice(0, disc.index)
continue
}
const staffelTreffer = STAFFEL_MUSTER.exec(rest)
if (staffelTreffer !== null && staffel === null && Number(staffelTreffer[1]) > 0) {
staffel = Number(staffelTreffer[1])
rest = rest.slice(0, staffelTreffer.index)
continue
}
break
}
return { titel: rest.replace(new RegExp(`${TRENNER}+$`), '').trim(), discNr, staffel }
}
/** \\.\G: → G:\ — unter Windows hängt Windows die Disc selbst ein. */
@@ -138,22 +227,50 @@ export function discWurzel(geraetepfad: string): string {
}
export interface DiscTitel {
/** Der Titel, wie die Disc ihn nennt — MIT Zusätzen, ungekürzt. */
titel: string
/** 'bdmt' (Studio-Klartext) | 'label' (normalisiert) | '' (nichts). */
quelle: 'bdmt' | 'label' | ''
/** Nummer der Disc in der Ausgabe (aus Titel/Label) — null wenn keine. */
discNr: number | null
/** Staffel-Nummer aus Titel/Label — null wenn keine. */
staffel: number | null
/** Folgen im bdmt-Inhaltsverzeichnis (0, wenn es keines gibt). */
folgen: number
/** Die Disc bezeugt selbst, dass sie Folgen trägt — ab zwei ist es
* keine Zufalls-Benennung mehr, sondern eine Serien-Disc. */
istSerie: boolean
}
/** Der beste verfügbare Disc-Titel: BDMT zuerst, sonst Label. Der
* BDMT-Titel wird NICHT normalisiert („2.22" würde zu „2 22"). */
/** Mindestens so viele Folgen im Inhaltsverzeichnis, damit „Serie" als
* bewiesen gilt — eine einzelne könnte auch eine Zugabe sein. */
export const FOLGEN_FUER_SERIE = 2
function mitZusatz(titel: string, quelle: 'bdmt' | 'label', folgen: number): DiscTitel {
const zusatz = discZusatzAbtrennen(titel)
return {
titel,
quelle,
discNr: zusatz.discNr,
staffel: zusatz.staffel,
folgen,
istSerie: folgen >= FOLGEN_FUER_SERIE,
}
}
/** Alles, was die Disc über sich selbst sagt: BDMT zuerst, sonst Label.
* Der BDMT-Titel wird NICHT normalisiert ('2.22' würde zu '2 22'). */
export function discTitel(geraetepfad: string): DiscTitel {
const wurzel = discWurzel(geraetepfad)
let folgen = 0
if (wurzel.length > 0) {
const bdmt = titelAusBdmt(wurzel)
if (bdmt.length > 0) return { titel: bdmt, quelle: 'bdmt' }
const bdmt = inhaltAusBdmt(wurzel)
folgen = folgenZahl(bdmt.eintraege)
if (bdmt.titel.length > 0) return mitZusatz(bdmt.titel, 'bdmt', folgen)
}
const label = isoVolumeLabel(geraetepfad) || udfVolumeLabel(geraetepfad)
if (label.length > 0) return { titel: normalizeDiscLabel(label), quelle: 'label' }
return { titel: '', quelle: '' }
if (label.length > 0) return mitZusatz(normalizeDiscLabel(label), 'label', folgen)
return { titel: '', quelle: '', discNr: null, staffel: null, folgen, istSerie: folgen >= FOLGEN_FUER_SERIE }
}
/** Billiger, stabiler Disc-Fingerabdruck (Label|Größe) — Basis der
@@ -163,25 +280,42 @@ export function discFingerprint(geraetepfad: string, groesseBytes: number): stri
return `${label}|${groesseBytes}`
}
/** Höchstens so viele Suchvarianten — jede kostet API-Anfragen. */
export const HOECHSTENS_KANDIDATEN = 7
/** Suchvarianten für die Metadaten-APIs (progressive Degradation,
* ARM-Lehre): voller Titel zuerst, dann ohne Doppelpunkt-Teil, ohne
* Klammern, dann hintere Worte strippen. */
* ARM-Lehre) von SPEZIFISCH nach vage:
*
* voller Titel → ohne Disc-/Staffel-Zusatz → ohne Doppelpunkt →
* ohne Klammern → hintere Worte strippen → Doppelpunkt-Vorspann
*
* Die Reihenfolge ist teuer gelernt (01.09.2026, echte Disc): Der
* Doppelpunkt-Vorspann stand auf Platz 2 und wirft am meisten weg
* ('Spartacus: Gods Of The Arena' → 'Spartacus'). TMDb hat einen Film,
* der wörtlich so heißt — die richtige Serie wurde nie gefragt. Er
* steht jetzt GANZ hinten; die Sicherheit deckelt ihn zusätzlich
* (MINDEST_ABDECKUNG in zuordnung.ts). */
export function titelKandidaten(titel: string): string[] {
const kandidaten: string[] = []
const nachlaufend = new RegExp(`${TRENNER}+$`)
const merke = (t: string): void => {
const sauber = t.split(/\s+/).filter(Boolean).join(' ').replace(/[ .]+$/, '').replace(/^[ .]+/, '')
const sauber = t.split(/\s+/).filter(Boolean).join(' ').replace(nachlaufend, '').replace(/^[ .]+/, '')
if (sauber.length > 0 && !kandidaten.includes(sauber)) kandidaten.push(sauber)
}
merke(titel)
if (titel.includes(':')) {
merke(titel.split(':', 1)[0])
merke(titel.replaceAll(':', ''))
}
merke(titel.replace(/\([^)]*\)/g, ''))
let worte = titel.split(/\s+/).filter(Boolean)
while (worte.length > 1 && kandidaten.length < 6) {
// Ohne den Verpackungs-Zusatz ist der Titel erst suchbar — und das ist
// der Kandidat, der die Serie findet, nicht der gestutzte Vorspann.
const ohneZusatz = discZusatzAbtrennen(titel).titel
merke(ohneZusatz)
// Volume-Labels können keinen Doppelpunkt tragen, TMDb schreibt ihn:
// beide Schreibweisen fragen.
if (ohneZusatz.includes(':')) merke(ohneZusatz.replaceAll(':', ''))
merke(ohneZusatz.replace(/\([^)]*\)/g, ''))
let worte = ohneZusatz.split(/\s+/).filter(Boolean)
while (worte.length > 1 && kandidaten.length < HOECHSTENS_KANDIDATEN - 1) {
worte = worte.slice(0, -1)
merke(worte.join(' '))
}
if (ohneZusatz.includes(':')) merke(ohneZusatz.split(':', 1)[0])
return kandidaten
}
+122 -33
View File
@@ -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, 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 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 (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 → (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 }
}
}