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
+190 -3
View File
@@ -6,12 +6,23 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
discZusatzAbtrennen,
folgenZahl,
inhaltAusBdmt,
istFolgenName,
normalizeDiscLabel,
parseUdfDstring,
titelAusBdmt,
titelKandidaten,
} from '../src/kern/metadaten/discmerkmale'
import { istSicherGenug, wortUeberlappung, zuordnen, WENIGE_TREFFER } from '../src/kern/metadaten/zuordnung'
import {
abdeckung,
gleichBedeutend,
istSicherGenug,
wortUeberlappung,
zuordnen,
WENIGE_TREFFER,
} from '../src/kern/metadaten/zuordnung'
import { posterUrl } from '../src/kern/ablage/poster'
let wegwerf: string[] = []
@@ -169,10 +180,13 @@ describe('zuordnen — die Confidence-Kaskade', () => {
expect(z.meta.quelle).toBe('omdb')
})
it('der Spartacus-Fund: spezifische Serie auf den vollen Titel schlägt den wörtlichen Treffer eines STARK gekürzten Kandidaten', async () => {
it('der Spartacus-Fund: die Serie schlägt den wörtlichen Treffer eines STARK gekürzten Kandidaten', async () => {
// Live gemessen (30.08.2026): 'Spartacus Gods Of The Arena' wurde zum
// Film „Spartacus" (1960), weil der gekürzte Kandidat wörtlich traf,
// BEVOR die volle Anfrage als spezifische Serie gewertet wurde.
// Seit dem 01.09.2026 ist es sogar ein WÖRTLICHER Serientreffer (0,90
// statt 0,80): Der Vergleich stolpert nicht mehr über den Doppelpunkt,
// den TMDb schreibt und ein Volume-Label nicht tragen kann.
const z = await zuordnen(
'Spartacus Gods Of The Arena',
fakeTmdb(
@@ -181,7 +195,7 @@ describe('zuordnen — die Confidence-Kaskade', () => {
),
keinOmdb,
)
expect(z.confidence).toBe(0.8)
expect(z.confidence).toBe(0.9)
expect(z.meta.typ).toBe('tv')
expect(z.titel).toBe('Spartacus: Gods of the Arena')
})
@@ -224,6 +238,179 @@ describe('zuordnen — die Confidence-Kaskade', () => {
})
})
// ── Der Spartacus-Fund des Commanders (01.09.2026) ──────────────────────
//
// Gemessen an der Disc, die im Laufwerk lag: Volume-Label
// SPARTACUS_GOTA_D2 (UDF, 33 759 100 928 Bytes), und in
// G:\BDMV\META\DL\bdmt_eng.xml stand alles, was Rippy brauchte — es las
// nur die erste Zeile davon. Rippy meldete „Spartacus" (Film, 1960).
/** Das echte bdmt_eng.xml der Disc, gekürzt auf das Wesentliche. */
const ECHTES_BDMT =
'<?xml version="1.0" encoding="UTF-8"?><disclib xmlns="urn:BDA:bdmv;disclib">' +
'<di:discinfo xmlns:di="urn:BDA:bdmv;discinfo"><di:title>' +
'<di:name>Spartacus: Gods Of The Arena - Disc 2</di:name>' +
'<di:numSets>1</di:numSets><di:setNumber>1</di:setNumber></di:title>' +
'<di:description><di:tableOfContents>' +
'<di:titleName titleNumber="1">EP 1</di:titleName>' +
'<di:titleName titleNumber="2">EP 2</di:titleName>' +
'<di:titleName titleNumber="20">MM ITA</di:titleName>' +
'<di:titleName titleNumber="50">ILSM</di:titleName>' +
'<di:titleName titleNumber="51">DUB GER 1</di:titleName>' +
'<di:titleName titleNumber="52">DUB GER 2</di:titleName>' +
'<di:titleName titleNumber="101">101 LOGO</di:titleName>' +
'<di:titleName titleNumber="104">104 GER FSK</di:titleName>' +
'<di:titleName titleNumber="105">105 WARNINGS</di:titleName>' +
'</di:tableOfContents></di:description></di:discinfo></disclib>'
describe('inhaltAusBdmt — die Disc sagt selbst, was sie ist', () => {
it('liest Titel UND Inhaltsverzeichnis der echten Disc', () => {
const wurzel = mkdtempSync(join(tmpdir(), 'rippy-toc-'))
wegwerf.push(wurzel)
const meta = join(wurzel, 'BDMV', 'META', 'DL')
mkdirSync(meta, { recursive: true })
writeFileSync(join(meta, 'bdmt_eng.xml'), ECHTES_BDMT)
const inhalt = inhaltAusBdmt(wurzel)
expect(inhalt.titel).toBe('Spartacus: Gods Of The Arena - Disc 2')
expect(inhalt.eintraege).toContain('EP 1')
expect(inhalt.eintraege).toContain('DUB GER 1')
// Zwei Folgen — das deckt sich mit der Disc-Struktur: genau zwei große
// Ströme (15,76 GB und 14,89 GB), alles andere unter 200 MB.
expect(folgenZahl(inhalt.eintraege)).toBe(2)
})
it('Tonspuren, Trailer und Warnhinweise sind KEINE Folgen', () => {
expect(istFolgenName('EP 1')).toBe(true)
expect(istFolgenName('Episode 12')).toBe(true)
expect(istFolgenName('Folge 3')).toBe(true)
expect(istFolgenName('DUB GER 1')).toBe(false)
expect(istFolgenName('101 LOGO')).toBe(false)
expect(istFolgenName('104 GER FSK')).toBe(false)
expect(istFolgenName('MM ITA')).toBe(false)
})
it('ohne bdmt-Datei bleibt alles leer, ohne Absturz', () => {
const wurzel = mkdtempSync(join(tmpdir(), 'rippy-toc-'))
wegwerf.push(wurzel)
expect(inhaltAusBdmt(wurzel)).toEqual({ titel: '', eintraege: [] })
})
})
describe('discZusatzAbtrennen — abtrennen UND merken', () => {
it('trennt den Disc-Zusatz vom echten Titel', () => {
expect(discZusatzAbtrennen('Spartacus: Gods Of The Arena - Disc 2')).toEqual({
titel: 'Spartacus: Gods Of The Arena',
discNr: 2,
staffel: null,
})
})
it('auch aus dem Volume-Label (SPARTACUS_GOTA_D2)', () => {
expect(discZusatzAbtrennen(normalizeDiscLabel('SPARTACUS_GOTA_D2'))).toEqual({
titel: 'Spartacus Gota',
discNr: 2,
staffel: null,
})
})
it('Staffel und Disc hintereinander', () => {
expect(discZusatzAbtrennen('Akte X S1 D2')).toEqual({ titel: 'Akte X', discNr: 2, staffel: 1 })
})
it('lässt echte Titel in Ruhe — die vier Fallen', () => {
// Nackte Zahl am Ende: sonst würde aus 'Rocky 2' die 'Rocky', Disc 2.
expect(discZusatzAbtrennen('Rocky 2').titel).toBe('Rocky 2')
// 'Teil'/'Part' sind Titel-Wörter, keine Verpackung.
expect(discZusatzAbtrennen('Kill Bill Teil 2').titel).toBe('Kill Bill Teil 2')
// Die Apostroph-Wortgrenze: ohne den Trenner-Zwang würde hieraus
// "Ocean'" mit Staffel 11.
expect(discZusatzAbtrennen("Ocean's 11").titel).toBe("Ocean's 11")
expect(discZusatzAbtrennen("Ocean's 11").staffel).toBe(null)
expect(discZusatzAbtrennen('Pulp Fiction')).toEqual({ titel: 'Pulp Fiction', discNr: null, staffel: null })
})
})
describe('titelKandidaten — der Doppelpunkt-Vorspann steht GANZ hinten', () => {
it('die Reihenfolge der echten Disc', () => {
const k = titelKandidaten('Spartacus: Gods Of The Arena - Disc 2')
// Platz 1 war bis zum 01.09.2026 'Spartacus' — und erschlug damit alles.
expect(k[0]).toBe('Spartacus: Gods Of The Arena - Disc 2')
expect(k[1]).toBe('Spartacus: Gods Of The Arena')
expect(k).toContain('Spartacus Gods Of The Arena')
expect(k[k.length - 1]).toBe('Spartacus')
expect(k.indexOf('Spartacus')).toBeGreaterThan(k.indexOf('Spartacus: Gods Of The Arena'))
// Kein Müll-Zwischenschritt mehr, der auf einem Trennzeichen endet.
expect(k.some((t) => /[\s\-–—_:,.]$/.test(t))).toBe(false)
})
})
describe('gleichBedeutend und abdeckung', () => {
it('der Doppelpunkt kostet keinen Treffer mehr', () => {
expect(gleichBedeutend('Spartacus: Gods of the Arena', 'Spartacus Gods Of The Arena')).toBe(true)
})
it('aber Wortdopplungen bleiben unterscheidbar', () => {
expect(gleichBedeutend('New York, New York', 'New York')).toBe(false)
expect(gleichBedeutend('', 'x')).toBe(false)
})
it('abdeckung misst, wie viel von der Frage übrig ist', () => {
expect(abdeckung('Spartacus: Gods Of The Arena', 'Spartacus')).toBeCloseTo(0.2)
expect(abdeckung('Spartacus: Gods Of The Arena', 'Spartacus: Gods Of The Arena')).toBe(1)
expect(abdeckung('', 'x')).toBe(0)
})
})
describe('zuordnen — der Fund des Commanders, an der echten Disc', () => {
const echteWelt = () =>
fakeTmdb(
// TMDb hat einen Film, der WÖRTLICH 'Spartacus' heißt — das ist die Falle.
{ Spartacus: [{ id: 5, title: 'Spartacus' }] },
{ 'Spartacus: Gods Of The Arena': [{ id: 6, title: 'Spartacus: Gods of the Arena' }] },
)
it('der echte Disc-Titel führt zur Serie, nicht zum Film von 1960', async () => {
const z = await zuordnen('Spartacus: Gods Of The Arena - Disc 2', echteWelt(), keinOmdb)
expect(z.meta.typ).toBe('tv')
expect(z.titel).toBe('Spartacus: Gods of the Arena')
expect(z.confidence).toBe(0.9)
})
it('mit dem Serien-Hinweis der Disc kann ein Film gar nicht mehr gewinnen', async () => {
const z = await zuordnen('Spartacus: Gods Of The Arena - Disc 2', echteWelt(), keinOmdb, { istSerie: true })
expect(z.meta.typ).toBe('tv')
expect(z.titel).toBe('Spartacus: Gods of the Arena')
})
it('DER REGRESSIONSTEST: kennt TMDb nur den Film, wird daraus ein VORSCHLAG', async () => {
// Genau der Fehler, den der Commander sah: Die Restfrage 'Spartacus'
// trifft wörtlich — vorher 95 % Sicherheit auf den falschen Film.
// Jetzt deckelt die Abdeckung (1 von 5 Wörtern übrig) auf 0,60: Rippy
// zeigt es als Vorschlag und fragt nach, statt sich sicher zu irren.
const z = await zuordnen(
'Spartacus: Gods Of The Arena - Disc 2',
fakeTmdb({ Spartacus: [{ id: 5, title: 'Spartacus' }] }),
keinOmdb,
)
expect(z.titel).toBe('Spartacus')
expect(z.confidence).toBe(0.6)
expect(istSicherGenug(z)).toBe(false)
})
it('eine Serien-Disc nimmt auch aus dem Label keinen Filmtreffer an', async () => {
// Ohne bdmt bliebe nur 'Spartacus Gota D2' — die Folgen im
// Inhaltsverzeichnis bezeugen trotzdem, dass hier kein Film liegt.
const z = await zuordnen(
normalizeDiscLabel('SPARTACUS_GOTA_D2'),
fakeTmdb({ Spartacus: [{ id: 5, title: 'Spartacus' }] }),
keinOmdb,
{ istSerie: true },
)
expect(z.meta.typ).not.toBe('movie')
expect(z.confidence).toBe(0.3)
})
})
describe('posterUrl', () => {
it('relative TMDb-Pfade bekommen das w500-Präfix', () => {
expect(posterUrl('/abc.jpg')).toBe('https://image.tmdb.org/t/p/w500/abc.jpg')