// MusicBrainz fallback for single-track metadata — used only when Deezer finds no artist-verified // match (see trackmeta.js). Returns the SAME shape as deezer.lookupTrackMeta so the caller is // agnostic. Same matching discipline as Deezer: the artist must correspond, or we return {} and let // the download use its clean fallback (album_artist = song artist, album = "Singles", source cover). // // MusicBrainz rules: a descriptive User-Agent is REQUIRED and requests are rate-limited to ~1/sec. // We do a single recording search per lookup and throttle to stay within that budget. Cover art // comes from the Cover Art Archive (coverartarchive.org) by release MBID; a 404 there is tolerated // downstream (the download falls back to the source thumbnail). const MB_BASE = 'https://musicbrainz.org/ws/2'; const USER_AGENT = 'NovaSonic/2.0 ( https://github.com/novasonic )'; // ── normalization + artist matching (self-contained; mirrors deezer.js intent) ── const stripLeadSym = s => (s || '').replace(/^[^\p{L}\p{N}]+/u, '').trim(); const _normTitle = s => (s || '').toLowerCase().replace(/[\[((].*?[\]))]/g, '') .replace(/\s*[-–]\s*(feat|ft|featuring)\.?.*$/i, '').replace(/[^\p{L}\p{N}]+/gu, ' ').trim(); const _normArtist = s => stripLeadSym((s || '').split(/[;,&]/)[0].toLowerCase()).replace(/[^\p{L}\p{N}]+/gu, ''); function _artistOk(candA, wantA) { if (!wantA || !candA) return false; if (candA === wantA) return true; const short = Math.min(candA.length, wantA.length); return short >= 3 && (candA.includes(wantA) || wantA.includes(candA)); } // Pure: choose the best artist-verified recording for (artist, title). Prefers a recording that // carries a release (so we get an album). Exported for unit tests. function mbPickMatch(recordings, artist, title) { if (!Array.isArray(recordings) || !recordings.length) return null; const normT = _normTitle(title); const wantA = _normArtist(artist); const candArtist = rec => _normArtist(rec['artist-credit']?.[0]?.name || rec['artist-credit']?.[0]?.artist?.name); const matches = recordings.filter(r => _normTitle(r.title) === normT && _artistOk(candArtist(r), wantA)); if (!matches.length) return null; // Prefer one with a release (album); otherwise the first artist+title match. return matches.find(r => Array.isArray(r.releases) && r.releases.length) || matches[0]; } const _yearOf = d => { const m = /^(\d{4})/.exec(d || ''); return m ? Number(m[1]) : undefined; }; // Pick the most "album-like" release: an official Album release-group, else one with a date, else first. function _pickRelease(rec) { const rels = Array.isArray(rec.releases) ? rec.releases : []; if (!rels.length) return null; const isAlbum = r => (r['release-group']?.['primary-type'] || '').toLowerCase() === 'album'; return rels.find(r => isAlbum(r) && r.date) || rels.find(isAlbum) || rels.find(r => r.date) || rels[0]; } // Pure: map a chosen recording to the lookupTrackMeta shape. Exported for unit tests. function mapMbRecording(rec) { if (!rec) return {}; const rel = _pickRelease(rec); const artistName = rec['artist-credit']?.[0]?.name || rec['artist-credit']?.[0]?.artist?.name || undefined; return { album: rel?.title || undefined, albumArtist: artistName, // recording search rarely carries the release artist-credit trackName: rec.title || undefined, year: _yearOf(rel?.date), genre: undefined, // omitted (extra call); "leave the rest alone" on fallback artworkUrl: rel?.id ? `https://coverartarchive.org/release/${rel.id}/front-500` : '', durationMs: Number.isFinite(rec.length) ? rec.length : undefined, metaArtistName: artistName, _source: 'musicbrainz', }; } // ── rate limiting (~1 request/sec, global to the process) ── let _chain = Promise.resolve(); let _last = 0; function _throttle() { _chain = _chain.then(async () => { const wait = Math.max(0, 1000 - (Date.now() - _last)); if (wait) await new Promise(r => setTimeout(r, wait)); _last = Date.now(); }); return _chain; } async function lookupTrackMeta(artist, title) { try { const a = stripLeadSym((artist || '').split(/[;,&]/)[0].trim()); const t = (title || '').replace(/\s*[-–]\s*\S[^-]*$/, '').trim() || title; if (!a || !t) return {}; const q = encodeURIComponent(`recording:"${t}" AND artist:"${a}"`); await _throttle(); const r = await fetch(`${MB_BASE}/recording?query=${q}&fmt=json&limit=10&inc=releases`, { headers: { 'User-Agent': USER_AGENT, 'Accept': 'application/json' }, signal: AbortSignal.timeout(10000), }); if (!r.ok) return {}; const recordings = (await r.json()).recordings || []; return mapMbRecording(mbPickMatch(recordings, artist, title)); } catch { return {}; } } module.exports = { lookupTrackMeta, mbPickMatch, mapMbRecording };