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

+14 -2
View File
@@ -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.
+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 };
+22
View File
@@ -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 };
+4 -3
View File
@@ -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 {
+15 -4
View File
@@ -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', () => {
+48
View File
@@ -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), {});
});