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:
1 parent
16f76a4236
commit
6d084a7b7a
6 files changed
+200
-9
No files matched your search
+14
-2
@@ -69,8 +69,20 @@ function pickTrackMatch(results, artist, title) {
|
||||
const strict = results.find(t => normalizeTitle(t.title) === normT && firstWord(t.artist?.name) === firstWord(normA));
|
||||
const JP_RE = /[-ヿ㐀-䶿一-鿿]/;
|
||||
const isJp = JP_RE.test(normT) || JP_RE.test(normA);
|
||||
const titleOnly = results.find(t => normalizeTitle(t.title) === normT);
|
||||
return strict || (!isJp ? titleOnly || null : null);
|
||||
// Loose artist match: same title AND the artist names correspond (equal, or one contains the
|
||||
// other after stripping all non-alphanumerics — covers "Okawari" vs "DJ Okawari"). This REPLACES
|
||||
// the old blind title-only fallback, which grabbed a same-titled song by a completely different
|
||||
// artist (e.g. "Memories" by Rab Noakes for "☆NoAki☆") and applied its wrong album/cover/year.
|
||||
const _n = s => stripLeadSym((s || '').toLowerCase()).replace(/[^\p{L}\p{N}]+/gu, '');
|
||||
const wantA = _n(normA);
|
||||
const artistOk = candA => {
|
||||
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));
|
||||
};
|
||||
const loose = results.find(t => normalizeTitle(t.title) === normT && artistOk(_n(t.artist?.name)));
|
||||
return strict || (isJp ? null : loose) || null;
|
||||
}
|
||||
|
||||
// Single-track metadata — the Deezer equivalent of the old iTunes lookupTrackMeta.
|
||||
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,22 @@
|
||||
// Single-track metadata resolver — the ONE place callers use to enrich a downloaded single.
|
||||
// Order: Deezer (artist-verified) → MusicBrainz (artist-verified) → nothing.
|
||||
// Returning {} is intentional and safe: the download then uses its clean fallback
|
||||
// (album_artist = song artist, album = "Singles", cover = source thumbnail, year/genre untouched).
|
||||
//
|
||||
// Both sources apply the SAME discipline — the artist must correspond — so a same-titled song by a
|
||||
// different artist can never contaminate album/cover/year again.
|
||||
|
||||
const dz = require('./deezer');
|
||||
const mb = require('./musicbrainz');
|
||||
|
||||
async function lookupTrackMeta(artist, title) {
|
||||
const d = await dz.lookupTrackMeta(artist, title).catch(() => ({}));
|
||||
if (d && d.album) return d; // Deezer matched (artist-verified)
|
||||
|
||||
const m = await mb.lookupTrackMeta(artist, title).catch(() => ({}));
|
||||
if (m && m.album) return m; // MusicBrainz matched (artist-verified)
|
||||
|
||||
return d || {}; // nothing convincing → clean fallback
|
||||
}
|
||||
|
||||
module.exports = { lookupTrackMeta };
|
||||
@@ -19,6 +19,7 @@ const CONFIG = require('./lib/config');
|
||||
const { jobs, updateJob, log } = require('./lib/jobs');
|
||||
const { sanitize, findExistingTrack } = require('./lib/metadata');
|
||||
const dz = require('./lib/deezer');
|
||||
const tm = require('./lib/trackmeta'); // Deezer → MusicBrainz → clean fallback (single tracks)
|
||||
const { downloadTrack, downloadAlbumTracks } = require('./lib/downloader');
|
||||
const { buildRomajiIndex, clearRomajiCache, isTokenizerReady, fetchNdSongs, romajiForStorage } = require('./lib/romaji');
|
||||
|
||||
@@ -289,7 +290,7 @@ app.get('/api/meta/lookup', async (req, res) => {
|
||||
const { artist, title } = req.query;
|
||||
if (!artist || !title) return res.status(400).json({ error: 'artist and title required' });
|
||||
try {
|
||||
const meta = await dz.lookupTrackMeta(artist, title);
|
||||
const meta = await tm.lookupTrackMeta(artist, title);
|
||||
if (!meta.album && !meta.genre && !meta.year) return res.json({ found: false });
|
||||
res.json({
|
||||
found: true,
|
||||
@@ -524,7 +525,7 @@ app.post('/api/youtube/resolve', async (req, res) => {
|
||||
const title = e.title || '';
|
||||
const sourceUrl = e.webpage_url || url;
|
||||
let meta = {};
|
||||
try { meta = await dz.lookupTrackMeta(rawArtist.split(',')[0].trim(), title); } catch {}
|
||||
try { meta = await tm.lookupTrackMeta(rawArtist.split(',')[0].trim(), title); } catch {}
|
||||
|
||||
const artist = meta.artist || rawArtist;
|
||||
const artworkUrl = meta.artworkUrl || e.thumbnail || '';
|
||||
@@ -583,7 +584,7 @@ app.post('/api/download', async (req, res) => {
|
||||
const searchArtist = artist.split(',')[0].trim();
|
||||
if (!meta.album) {
|
||||
log(jobId, '📋 Suche Album-Metadaten von Deezer…');
|
||||
const extra = await dz.lookupTrackMeta(searchArtist, title);
|
||||
const extra = await tm.lookupTrackMeta(searchArtist, title);
|
||||
if (extra.album) log(jobId, `📋 Album: "${extra.album}" (${extra.year || '?'})`);
|
||||
meta = { ...meta, ...extra, artworkUrl: spArt || extra.artworkUrl || '', genre: meta.genre || extra.genre || undefined };
|
||||
} else {
|
||||
|
||||
@@ -65,10 +65,21 @@ test('pickTrackMatch: strict match on artist + title', () => {
|
||||
assert.equal(m, results[1]);
|
||||
});
|
||||
|
||||
test('pickTrackMatch: title-only fallback for non-Japanese when artist differs', () => {
|
||||
const results = [{ title: 'Levitating', artist: { name: 'Some Cover Act' } }];
|
||||
const m = dz.pickTrackMatch(results, 'Dua Lipa', 'Levitating');
|
||||
assert.equal(m, results[0]); // ASCII title → title-only fallback allowed
|
||||
test('pickTrackMatch: NO match when the artist is completely different (no wrong album)', () => {
|
||||
// Regression: "☆NoAki☆ – Memories" used to grab a same-titled song by an unrelated artist and
|
||||
// apply its album/cover/year. A wholly different artist must now return null → clean fallback.
|
||||
const results = [
|
||||
{ title: 'Memories', artist: { name: 'Rab Noakes' } },
|
||||
{ title: 'Memories', artist: { name: 'Maroon 5' } },
|
||||
];
|
||||
assert.equal(dz.pickTrackMatch(results, '☆NoAki☆', 'Memories'), null);
|
||||
assert.equal(dz.pickTrackMatch(results, 'Some Cover Act', 'Levitating'), null);
|
||||
});
|
||||
|
||||
test('pickTrackMatch: loose artist match — one name contained in the other', () => {
|
||||
const results = [{ title: 'Flower Dance', artist: { name: 'DJ Okawari' } }];
|
||||
const m = dz.pickTrackMatch(results, 'Okawari', 'Flower Dance');
|
||||
assert.equal(m, results[0]); // "okawari" ⊂ "djokawari" → accepted
|
||||
});
|
||||
|
||||
test('pickTrackMatch: NO title-only fallback for Japanese titles', () => {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const mb = require('../lib/musicbrainz');
|
||||
|
||||
const rec = (title, artist, releases = []) => ({
|
||||
id: 'rec-' + title, title, length: 200000,
|
||||
'artist-credit': [{ name: artist, artist: { id: 'a', name: artist } }],
|
||||
releases,
|
||||
});
|
||||
const albumRel = { id: 'rel-1', title: 'Real Album', date: '2021-05-01', 'release-group': { 'primary-type': 'Album' } };
|
||||
const singleRel = { id: 'rel-2', title: 'Some Single', date: '2020-01-01', 'release-group': { 'primary-type': 'Single' } };
|
||||
|
||||
test('mbPickMatch: artist-verified match prefers a recording with a release', () => {
|
||||
const recs = [rec('Memories', 'NoAki'), rec('Memories', 'NoAki', [albumRel])];
|
||||
assert.equal(mb.mbPickMatch(recs, 'NoAki', 'Memories'), recs[1]);
|
||||
});
|
||||
|
||||
test('mbPickMatch: completely different artist → null (no wrong album)', () => {
|
||||
const recs = [rec('Memories', 'Rab Noakes', [albumRel]), rec('Memories', 'Maroon 5', [albumRel])];
|
||||
assert.equal(mb.mbPickMatch(recs, '☆NoAki☆', 'Memories'), null);
|
||||
});
|
||||
|
||||
test('mbPickMatch: loose artist match (one contained in the other)', () => {
|
||||
const recs = [rec('Flower Dance', 'DJ Okawari', [albumRel])];
|
||||
assert.equal(mb.mbPickMatch(recs, 'Okawari', 'Flower Dance'), recs[0]);
|
||||
});
|
||||
|
||||
test('mapMbRecording: maps album/year/cover/duration and prefers the Album release', () => {
|
||||
const m = mb.mapMbRecording(rec('Memories', 'NoAki', [singleRel, albumRel]));
|
||||
assert.equal(m.album, 'Real Album');
|
||||
assert.equal(m.albumArtist, 'NoAki');
|
||||
assert.equal(m.year, 2021);
|
||||
assert.equal(m.trackName, 'Memories');
|
||||
assert.equal(m.durationMs, 200000);
|
||||
assert.equal(m.artworkUrl, 'https://coverartarchive.org/release/rel-1/front-500');
|
||||
assert.equal(m._source, 'musicbrainz');
|
||||
});
|
||||
|
||||
test('mapMbRecording: no release → no album, no artwork (clean fallback territory)', () => {
|
||||
const m = mb.mapMbRecording(rec('Orphan Song', 'NoAki', []));
|
||||
assert.equal(m.album, undefined);
|
||||
assert.equal(m.artworkUrl, '');
|
||||
assert.equal(m.metaArtistName, 'NoAki');
|
||||
});
|
||||
|
||||
test('mapMbRecording: null in → {} out', () => {
|
||||
assert.deepEqual(mb.mapMbRecording(null), {});
|
||||
});
|
||||
Reference in new issue
Block a user