Files
rippy/rippy-windows/src/kern/metadaten/discmerkmale.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

322 lines
13 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.
// Was die Disc über sich selbst verrät (KONZEPT § 5, Schritt 3) — Regeln
// aus prescan.py, dort an echten Discs gemessen:
//
// Quelle 1 (beste): BDMV/META/DL/bdmt_*.xml — der Studio-Titel im
// Klartext. Unter Windows hängt Windows die Disc selbst ein (G:\).
// Quelle 2: Volume-Label (ISO-9660 für DVDs, UDF für Blu-rays ohne
// ISO-Bridge) — Labels wie BD_EVG_D2 taugen erst normalisiert.
//
// KEIN makemkvcon hier: Der Zweig kostete 20120 s je eingelegter Disc
// und sein Ergebnis las niemand (der 30.08.-Fund „das erkennen dauert
// sehr sehr lange").
import { closeSync, existsSync, openSync, readdirSync, readFileSync, readSync } from 'node:fs'
// Plattform-Pfade: titelAusBdmt läuft auf einer LAUFZEIT-Wurzel (G:\ im
// Betrieb, ein tmp-Ordner im Linux-CI-Test) — dieselbe Regel wie in
// struktur.ts, am 30.08.2026 zum dritten Mal gelernt.
import { join } from 'node:path'
/** 'PULP_FICTION_DE' → 'Pulp Fiction De' (pur). */
export function normalizeDiscLabel(label: string): string {
if (label.length === 0) return ''
const bereinigt = label.replaceAll('_', ' ').replaceAll('.', ' ').split(/\s+/).filter(Boolean).join(' ')
if (bereinigt === bereinigt.toUpperCase()) {
return bereinigt
.toLowerCase()
.split(' ')
.map((w) => (w.length > 0 ? w[0].toUpperCase() + w.slice(1) : w))
.join(' ')
}
return bereinigt
}
/** UDF d-string (ECMA-167 1/7.2.12): letztes Byte = Länge, erstes =
* Kompressions-ID (8 = Latin-1, 16 = UTF-16BE). Pur, testbar. */
export function parseUdfDstring(daten: Buffer): string {
if (daten.length === 0) return ''
const laenge = daten[daten.length - 1]
if (laenge < 2 || laenge > daten.length - 1) return ''
const compId = daten[0]
const text = daten.subarray(1, laenge)
if (compId === 8) return text.toString('latin1').trim()
if (compId === 16) {
// UTF-16BE: Bytes tauschen, dann als LE lesen (Node kennt nur utf16le).
const getauscht = Buffer.alloc(text.length - (text.length % 2))
for (let i = 0; i + 1 < text.length; i += 2) {
getauscht[i] = text[i + 1]
getauscht[i + 1] = text[i]
}
return getauscht.toString('utf16le').trim()
}
return ''
}
const SEKTOR = 2048
function lesenBei(fd: number, position: number, laenge: number): Buffer {
const puffer = Buffer.alloc(laenge)
const gelesen = readSync(fd, puffer, 0, laenge, position)
return puffer.subarray(0, gelesen)
}
/** ISO-9660-Volume-Label: Sektor 16, Bytes 4071 (DVDs). '' wenn keins. */
export function isoVolumeLabel(geraetepfad: string): string {
let fd: number
try {
fd = openSync(geraetepfad, 'r')
} catch {
return ''
}
try {
const pvd = lesenBei(fd, 16 * SEKTOR, SEKTOR)
if (pvd.length < 72 || !pvd.subarray(1, 6).equals(Buffer.from('CD001', 'ascii'))) return ''
return pvd.subarray(40, 72).toString('latin1').trim()
} catch {
return ''
} finally {
closeSync(fd)
}
}
/** UDF-Volume-Label (Blu-rays ohne ISO-Bridge): AVDP bei Sektor 256 →
* Main VDS → PVD (Tag 1), Volume Identifier ab Offset 24 (ECMA-167). */
export function udfVolumeLabel(geraetepfad: string): string {
let fd: number
try {
fd = openSync(geraetepfad, 'r')
} catch {
return ''
}
try {
const avdp = lesenBei(fd, 256 * SEKTOR, SEKTOR)
if (avdp.length < 24 || avdp.readUInt16LE(0) !== 2) return ''
const vdsOrt = avdp.readUInt32LE(20)
for (let i = 0; i < 32; i++) {
const block = lesenBei(fd, (vdsOrt + i) * SEKTOR, SEKTOR)
if (block.length < 56) return ''
const tagId = block.readUInt16LE(0)
if (tagId === 1) return parseUdfDstring(block.subarray(24, 56))
if (tagId === 8) return '' // Terminating Descriptor
}
return ''
} catch {
return ''
} finally {
closeSync(fd)
}
}
/** 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 leer
let kandidaten: string[]
try {
kandidaten = readdirSync(metaOrdner).sort()
} catch {
return leer
}
const bdmt =
kandidaten.find((k) => k === 'bdmt_eng.xml') ??
kandidaten.find((k) => k.startsWith('bdmt_') && k.endsWith('.xml'))
if (bdmt === undefined) return leer
let inhalt: string
try {
inhalt = readFileSync(join(metaOrdner, bdmt)).subarray(0, 65536).toString('utf8')
} catch {
return leer
}
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. */
export function discWurzel(geraetepfad: string): string {
const buchstabe = geraetepfad.replace(/:$/, '').split('\\').pop()!.replace(/:$/, '')
const wurzel = buchstabe + ':\\'
return existsSync(wurzel) ? wurzel : ''
}
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
}
/** 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 = 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 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
* Duplikat-Erkennung in der Bibliothek. */
export function discFingerprint(geraetepfad: string, groesseBytes: number): string {
const label = isoVolumeLabel(geraetepfad) || udfVolumeLabel(geraetepfad) || 'unbekannt'
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) — 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(nachlaufend, '').replace(/^[ .]+/, '')
if (sauber.length > 0 && !kandidaten.includes(sauber)) kandidaten.push(sauber)
}
merke(titel)
// 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
}