Files
HoneyandClaude Opus 4.8 6d084a7b7a 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>
2026-08-12 01:51:29 +02:00

98 lines
4.8 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 };