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
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user