fix(download): stop wrong album/cover from same-titled tracks; add MusicBrainz fallback

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>
This commit is contained in:
HoneyandClaude Opus 4.8 committed 2026-08-12 01:51:29 +02:00
1 parent 16f76a4236
commit 6d084a7b7a
6 files changed
+200 -9

No files matched your search

+97
View File
@@ -0,0 +1,97 @@
// 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 };