Root cause: deezer pickTrackMatch fell back to a blind title-only match, grabbing a
same-titled song by a completely different artist (e.g. 'Memories' by Rab Noakes for
'☆NoAki☆') and applying its album/cover/year. The download's clean fallback existed
but was never reached because the lookup almost always returned *something*.
Stufe A — deezer.js: the title-only fallback now REQUIRES an artist correspondence
(equal, or one name contained in the other after normalization). No match → {} →
the existing clean fallback finally runs (album_artist = song artist, album =
'Singles', cover = source thumbnail, year/genre untouched). Test that encoded the old
blind behaviour replaced; NoAki + loose-match regression tests added.
Stufe B — musicbrainz.js: a rate-limited (~1/s, User-Agent) MusicBrainz recording
lookup with the SAME artist discipline + Cover Art Archive cover. trackmeta.js is the
single resolver (Deezer → MusicBrainz → clean fallback); the three single-track call
sites use it (album batch stays Deezer-only to avoid MB rate-limit slowdowns).
Unit-tested (17/17) + live-verified: NoAki→clean fallback, real track→Deezer, MB finds
real albums when Deezer misses. Backend-only; no frontend/Android change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
98 lines
4.8 KiB
JavaScript
98 lines
4.8 KiB
JavaScript
// 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 };
|