// MusicBrainz erkennt eine CD an der LAGE ihrer Spuren (KONZEPT § 6.3, // Vorlage musicbrainz_cd.py — Rechnung laut // musicbrainz.org/doc/Disc_ID_Calculation, am 29.08.2026 gegen die echte // AC/DC-„Back in Black"-Antwort bewiesen). // // DIE Falle: Die Versätze zählen MIT den 150 Frames Vorlauf (MSF), das // Lesen zählt OHNE (LBA). Wer das verwechselt, bekommt eine Kennung, die // MusicBrainz nicht kennt — ohne Fehlermeldung, die Antwort ist dann // einfach „unbekannte Disc". import { createHash } from 'node:crypto' import { VORLAUF_FRAMES, type Spur } from '../rip/audiocd' const DIENST = 'https://musicbrainz.org/ws/2/discid/' // MusicBrainz verlangt eine sprechende Kennung mit Kontakt — ohne sie // wird gedrosselt oder geblockt (Rate-Limiting-Richtlinie). const KOPF = { 'User-Agent': 'Rippy/5.0 (https://github.com/Hitonabi/rippy)' } /** Immer 99 Spur-Plätze im Hash-Text, auch bei 12 Spuren. */ export const PLAETZE = 99 /** Die MusicBrainz-Kennung (pur). Versätze EINSCHLIESSLICH Vorlauf. */ export function discId(spurVersaetze: readonly number[], leadOut: number, erste = 1, letzte?: number): string { if (spurVersaetze.length === 0) return '' const letzteSpur = letzte ?? erste + spurVersaetze.length - 1 const hex2 = (n: number): string => n.toString(16).toUpperCase().padStart(2, '0') const hex8 = (n: number): string => n.toString(16).toUpperCase().padStart(8, '0') const teile = [hex2(erste), hex2(letzteSpur), hex8(leadOut)] for (let platz = 0; platz < PLAETZE; platz++) { teile.push(hex8(platz < spurVersaetze.length ? spurVersaetze[platz] : 0)) } const roh = createHash('sha1').update(teile.join(''), 'ascii').digest('base64') // MusicBrainz' eigenes Alphabet: + / = werden zu . _ - return roh.replaceAll('+', '.').replaceAll('/', '_').replaceAll('=', '-') } /** Kennung direkt aus tocZerlegen() — rechnet den Vorlauf wieder HINZU. */ export function kennungAusSpuren(spuren: readonly Spur[]): string { if (spuren.length === 0) return '' const geordnet = [...spuren].sort((a, b) => a.nr - b.nr) const versaetze = geordnet.map((s) => s.start + VORLAUF_FRAMES) const letzte = geordnet[geordnet.length - 1] const leadOut = letzte.start + letzte.sektoren + VORLAUF_FRAMES return discId(versaetze, leadOut, geordnet[0].nr, letzte.nr) } export interface MusicBrainzAntwort { releases?: Array<{ title?: string date?: string 'artist-credit'?: Array<{ name?: string; joinphrase?: string }> media?: Array<{ tracks?: Array<{ position?: number | string title?: string 'artist-credit'?: Array<{ name?: string; joinphrase?: string }> }> }> }> } /** Nachschlagen — null bei unbekannter Disc, keinem Netz oder Fremdem: * alles dasselbe Ergebnis (keine Tags), ein Rip scheitert daran nie. */ export async function abfragen( kennung: string, holen: typeof fetch = fetch, ): Promise { if (kennung.length === 0) return null try { const antwort = await holen( `${DIENST}${encodeURIComponent(kennung).replaceAll('%2D', '-')}?fmt=json&inc=recordings+artist-credits`, { headers: KOPF, signal: AbortSignal.timeout(15_000) }, ) if (antwort.status >= 300) return null return (await antwort.json()) as MusicBrainzAntwort } catch { return null } } function kuenstler(eintrag: Array<{ name?: string; joinphrase?: string }> | undefined): string { // „Artist Credit" ist eine Liste mit Bindewörtern — „Simon" + „ & " + // „Garfunkel". Nur den ersten Namen zu nehmen verlöre die Hälfte. return (eintrag ?? []) .map((s) => (s.name ?? '') + (s.joinphrase ?? '')) .join('') .trim() } /** Antwort → {spurNr: Tags}. Die ERSTE Veröffentlichung mit dieser Disc — * bei mehreren ist jede gleich richtig, eine Auswahl wäre geraten. */ export function tagsJeSpur(antwort: MusicBrainzAntwort | null): Map> { const tags = new Map>() const release = antwort?.releases?.[0] if (release === undefined) return tags const album = release.title ?? '' const jahr = (release.date ?? '').slice(0, 4) const albumKuenstler = kuenstler(release['artist-credit']) for (const medium of release.media ?? []) { for (const spur of medium.tracks ?? []) { const nr = Number(spur.position ?? 0) if (!Number.isInteger(nr) || nr <= 0) continue tags.set(nr, { TITLE: spur.title ?? '', ARTIST: kuenstler(spur['artist-credit']) || albumKuenstler, ALBUM: album, ALBUMARTIST: albumKuenstler, DATE: jahr, TRACKNUMBER: String(nr), }) } } return tags } /** „Künstler - Album" für den Zielordner — '' wenn nichts bekannt. */ export function albumOrdner(antwort: MusicBrainzAntwort | null, saubern: (t: string) => string): string { const tags = tagsJeSpur(antwort) if (tags.size === 0) return '' const erste = tags.get(Math.min(...tags.keys()))! const wer = erste['ALBUMARTIST'] || erste['ARTIST'] || '' const album = erste['ALBUM'] || '' const roh = wer.length > 0 && album.length > 0 ? `${wer} - ${album}` : album || wer return saubern(roh) }