// 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 20–120 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 40–71 (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: * * Spartacus: Gods Of The Arena - Disc 2 * EP 1 * EP 2 * DUB GER 1 … 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 { /** — der Studio-Titel im Klartext; '' wenn keiner dasteht. */ titel: string /** — 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 = /([^<]{2,120})<\/di:name>/.exec(inhalt) const eintraege: string[] = [] for (const t of inhalt.matchAll(/]*>([^<]{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 }