fix(v5): Laufzeit-Pfade plattformneutral — pipeline/struktur auf node:path
Ampel / ampel (push) Successful in 1m21s
Ampel / ampel (push) Successful in 1m21s
Der NFO-Ablage-Test brach in der Linux-CI: pipeline/struktur bauten mit path.win32 auf POSIX-Testordnern (mkdir legte 'x\Filme' als EINEN Namen an, readdir fand nichts). Regel jetzt dokumentiert: Module ueber LAUFZEIT-Wurzeln (Ablage) nutzen plattform-path — zur echten Laufzeit immer Windows, in der CI physisch korrekt auf POSIX; Module mit FESTER Windows-Semantik (katalog, dateiDaneben) bleiben bei path.win32. Testerwartungen entsprechend ueber join() statt Literale. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
3dc296f071
commit
e840b7f327
@@ -0,0 +1,184 @@
|
||||
// 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'
|
||||
import { win32 as pfad } 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)
|
||||
}
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
const metaOrdner = pfad.join(wurzel, 'BDMV', 'META', 'DL')
|
||||
if (!existsSync(metaOrdner)) return ''
|
||||
let kandidaten: string[]
|
||||
try {
|
||||
kandidaten = readdirSync(metaOrdner).sort()
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
const bdmt =
|
||||
kandidaten.find((k) => k === 'bdmt_eng.xml') ??
|
||||
kandidaten.find((k) => k.startsWith('bdmt_') && k.endsWith('.xml'))
|
||||
if (bdmt === undefined) return ''
|
||||
let inhalt: string
|
||||
try {
|
||||
inhalt = readFileSync(pfad.join(metaOrdner, bdmt)).subarray(0, 65536).toString('utf8')
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
const treffer = /<di:name>([^<]{2,120})<\/di:name>/.exec(inhalt)
|
||||
return treffer === null ? '' : treffer[1].trim()
|
||||
}
|
||||
|
||||
/** \\.\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 {
|
||||
titel: string
|
||||
/** 'bdmt' (Studio-Klartext) | 'label' (normalisiert) | '' (nichts). */
|
||||
quelle: 'bdmt' | 'label' | ''
|
||||
}
|
||||
|
||||
/** Der beste verfügbare Disc-Titel: 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)
|
||||
if (wurzel.length > 0) {
|
||||
const bdmt = titelAusBdmt(wurzel)
|
||||
if (bdmt.length > 0) return { titel: bdmt, quelle: 'bdmt' }
|
||||
}
|
||||
const label = isoVolumeLabel(geraetepfad) || udfVolumeLabel(geraetepfad)
|
||||
if (label.length > 0) return { titel: normalizeDiscLabel(label), quelle: 'label' }
|
||||
return { titel: '', quelle: '' }
|
||||
}
|
||||
|
||||
/** 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}`
|
||||
}
|
||||
|
||||
/** Suchvarianten für die Metadaten-APIs (progressive Degradation,
|
||||
* ARM-Lehre): voller Titel zuerst, dann ohne Doppelpunkt-Teil, ohne
|
||||
* Klammern, dann hintere Worte strippen. */
|
||||
export function titelKandidaten(titel: string): string[] {
|
||||
const kandidaten: string[] = []
|
||||
const merke = (t: string): void => {
|
||||
const sauber = t.split(/\s+/).filter(Boolean).join(' ').replace(/[ .]+$/, '').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) {
|
||||
worte = worte.slice(0, -1)
|
||||
merke(worte.join(' '))
|
||||
}
|
||||
return kandidaten
|
||||
}
|
||||
Reference in New Issue
Block a user