feat(v5): Etappe W-7 — Audio-CD, ISO-Backup, MakeMKV-Schluesselkette
Ampel / ampel (push) Successful in 1m20s
Ampel / ampel (push) Successful in 1m20s
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>
This commit is contained in:
co-authored by
Claude Fable 5
parent
c78ff22ab7
commit
2d9cef22b3
@@ -0,0 +1,123 @@
|
||||
// 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)
|
||||
}
|
||||
Reference in New Issue
Block a user