Files
rippy/rippy-windows/src/kern/metadaten/musicbrainz.ts
T
HitonabiandClaude Fable 5 2d9cef22b3
Ampel / ampel (push) Successful in 1m20s
feat(v5): Etappe W-7 — Audio-CD, ISO-Backup, MakeMKV-Schluesselkette
rip/audiocd.ts: die Rechnungen PUR (MSF→LBA mit den 150 Vorlauf-Frames,
TOC-Zerlegung mit Lead-Out-Pflicht und Daten-Track-Bit, 27-Sektoren-
Bloecke unter der 64-KB-Treibergrenze, RAW_READ_INFO mit DiskOffset in
2048ern OBWOHL der Sektor 2352 hat, WAV-Kopf, AC/DC-sichere Namen).
metadaten/musicbrainz.ts: DiscID nach musicbrainz.org-Rechnung — gegen
die ECHTE AC/DC-Kennung getestet (3KVSIWn_…), Vorlauf wird beim Melden
wieder HINZUgerechnet; Tags je Spur mit Bindewort-Kuenstlern.
laufwerk/disc.ts: audioToc + audioSpurLesen ueber die LaufwerkApi.

rip/iso.ts (§ 6.4): 1:1-Abbild fuer unbekannte Discs — async (ein
33-GB-Abbild darf den Kern nicht blockieren), sektor-ausgerichtet,
unvollstaendig heisst FEHLER mit Byte-Zahlen, nie Schoenfaerberei.

werkzeuge/schluessel.ts (§ 9.1): Dauerlizenz schlaegt Forum-Beta-Key
(t=1053, T-Muster aus makemkv_key.py), Ablage in HKCU ueber reg.exe
(kein zweiter koffi-Ort), GEFRAGT statt gerechnet (makemkvcon info
disc:9999, MSG 5020/5021/5051), abgelehnter Key wird ZURUECKGENOMMEN
(schlimmer als keiner: 0 Titel, kein Laufwerk), app_UpdateEnable nur-
wenn-fehlt (der 4K-Schluesselkanal). Kern prueft beim Start und
taeglich; Dashboard-Kachel zeigt Stand + letzten Pruefzeitpunkt;
Dauerlizenz-Feld in den Einstellungen.

Pipeline verzweigt nach Disc-Typ: cd → Sektoren→WAV→FLAC mit
MusicBrainz-Tags (Ordner 'Kuenstler - Album', ohne Treffer ehrlich
'Track NN'), unknown → ISO. Bibliothek-Eintraege fuer beide.

181 Tests gruen, Smoke gruen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 18:56:07 +02:00

124 lines
5.1 KiB
TypeScript

// 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<MusicBrainzAntwort | null> {
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<number, Record<string, string>> {
const tags = new Map<number, Record<string, string>>()
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)
}