diff --git a/backend/lib/deezer.js b/backend/lib/deezer.js index d377147..cd10161 100644 --- a/backend/lib/deezer.js +++ b/backend/lib/deezer.js @@ -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. diff --git a/backend/lib/musicbrainz.js b/backend/lib/musicbrainz.js new file mode 100644 index 0000000..6456131 --- /dev/null +++ b/backend/lib/musicbrainz.js @@ -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 }; diff --git a/backend/lib/trackmeta.js b/backend/lib/trackmeta.js new file mode 100644 index 0000000..e96bc07 --- /dev/null +++ b/backend/lib/trackmeta.js @@ -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 }; diff --git a/backend/novasonic-server.js b/backend/novasonic-server.js index 08ccf32..f114407 100644 --- a/backend/novasonic-server.js +++ b/backend/novasonic-server.js @@ -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 { diff --git a/backend/test/deezer.test.js b/backend/test/deezer.test.js index 09456ee..f593d2f 100644 --- a/backend/test/deezer.test.js +++ b/backend/test/deezer.test.js @@ -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', () => { diff --git a/backend/test/musicbrainz.test.js b/backend/test/musicbrainz.test.js new file mode 100644 index 0000000..2d94c84 --- /dev/null +++ b/backend/test/musicbrainz.test.js @@ -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), {}); +});