Files
NovaSonic/backend/lib/deezer.js
T
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

176 lines
8.3 KiB
JavaScript
Raw 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.
const https = require('https');
// ─── DEEZER METADATA SOURCE ───────────────────────────────────────────────────
// Apple's iTunes /search returns 403 from server/datacenter IPs, so all server-side
// metadata (single-track enrichment, album track lists, artist discography, the
// metadata-editor autofill) comes from Deezer. Free, no key, ~50 req/5s, returns
// explicit flags + artist images + durations. Results are mapped to the same
// "ItunesItem" shape the download pipeline (downloadAlbumTracks / downloadTrack)
// already consumes, so nothing downstream had to change.
const DZ = 'https://api.deezer.com';
function dzGet(subpath) {
return new Promise((resolve, reject) => {
https.get(`${DZ}/${subpath}`, { headers: { 'User-Agent': 'NovaSonic/1.0' } }, res => {
let d = '';
res.on('data', c => d += c);
res.on('end', () => { try { resolve(JSON.parse(d) || {}); } catch { reject(new Error('Deezer parse error')); } });
}).on('error', reject);
});
}
// ─── matching helpers (local copies to avoid a circular require with metadata.js) ─
function normalizeTitle(s) {
return (s || '')
.toLowerCase().trim()
.replace(/\s+-\s+([^-]+)$/, ' ($1)')
.replace(/[\[((][^\]))]*[\]))]/g, '')
.replace(/[,.'!?]/g, '')
.replace(/\s+/g, ' ')
.trim();
}
const stripLeadSym = s => (s || '').replace(/^[^\p{L}\p{N}]+/u, '').trim();
const firstWord = s => stripLeadSym((s || '').toLowerCase()).split(/[\s,;/]/)[0];
const yearOf = d => { const y = d ? new Date(d).getFullYear() : NaN; return Number.isFinite(y) ? y : undefined; };
// Deezer covers come in fixed sizes; cover_xl is 1000x1000 (matches the iTunes hi-res artwork).
const coverXl = a => a?.cover_xl || a?.cover_big || a?.cover_medium || a?.cover || '';
// Map one album track (from /album/{id} .tracks.data — these carry no track_position,
// so the album's own ordering supplies the number) to the iTunes track shape.
function mapAlbumTrack(t, al, index) {
return {
wrapperType: 'track',
trackId: t.id,
trackName: t.title,
artistName: t.artist?.name || al.artist?.name || '', // per-track artist (mixed albums)
collectionName: al.title,
collectionArtistName: al.artist?.name || '', // album-level artist (stable)
collectionType: al.record_type === 'compilation' ? 'Compilation' : 'Album',
artworkUrl100: coverXl(al),
releaseDate: al.release_date || undefined,
primaryGenreName: al.genres?.data?.[0]?.name || undefined,
trackNumber: index + 1,
trackCount: al.nb_tracks || undefined,
trackTimeMillis: t.duration ? t.duration * 1000 : undefined,
trackExplicitness: t.explicit_lyrics ? 'explicit' : 'notExplicit',
previewUrl: t.preview || '',
};
}
// Pick the best track from a Deezer /search result list for (artist, title). Pure + exported
// so the matching rules are unit-testable. Strict = same title AND same first artist word;
// falls back to a title-only match, but never for Japanese titles (same title, different artist
// is common for anime/OST). Returns the matched Deezer track or null.
function pickTrackMatch(results, artist, title) {
if (!Array.isArray(results) || !results.length) return null;
const normT = normalizeTitle(title);
const normA = stripLeadSym((artist || '').split(/[;,&]/)[0].trim()).toLowerCase().trim();
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);
// 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.
// Fills album/year/trackNum/genre/duration/artwork when the caller only has artist+title.
// Returns {} when nothing convincing matches (callers treat that as "no enrichment").
async function lookupTrackMeta(artist, title) {
try {
const baseTitle = title.replace(/\s*[-–]\s*\S[^-]*$/, '').trim() || title;
const searchTitle = baseTitle !== title ? baseTitle : title;
const searchArtist = stripLeadSym((artist || '').split(/[;,&]/)[0].trim());
const q = encodeURIComponent(`${searchArtist} ${searchTitle}`);
const results = (await dzGet(`search?q=${q}&limit=10`)).data || [];
if (!results.length) return {};
const match = pickTrackMatch(results, artist, title);
if (!match) return {};
// The search result lacks genre / track number / release date — the album endpoint carries them.
const al = match.album?.id ? await dzGet(`album/${match.album.id}`).catch(() => ({})) : {};
// track_position + disk_number live on the full /track record, not on the search hit.
const tr = match.id ? await dzGet(`track/${match.id}`).catch(() => ({})) : {};
return {
album: match.album?.title || al.title || '',
albumArtist: al.artist?.name || undefined,
trackName: match.title || undefined,
year: yearOf(tr.release_date || al.release_date),
trackNum: tr.track_position || undefined,
totalTracks: al.nb_tracks || undefined,
discNum: tr.disk_number || undefined,
discTotal: undefined,
genre: al.genres?.data?.[0]?.name || undefined,
artworkUrl: coverXl(al) || coverXl(match.album) || '',
durationMs: (tr.duration || match.duration) ? (tr.duration || match.duration) * 1000 : undefined,
metaArtistName: match.artist?.name || undefined,
};
} catch { return {}; }
}
// All tracks of a Deezer album id, in the legacy ItunesItem track shape.
async function fetchAlbumTracks(albumId) {
try {
const al = await dzGet(`album/${albumId}`);
return (al.tracks?.data || []).map((t, i) => mapAlbumTrack(t, al, i));
} catch { return []; }
}
// Album fallback when no album id is known: search Deezer for the album, then its tracks.
async function searchAlbumTracks(artist, album) {
try {
const q = encodeURIComponent(`${artist} ${album}`);
const results = (await dzGet(`search/album?q=${q}&limit=5`)).data || [];
const lc = s => (s || '').toLowerCase();
const match = results.find(r =>
lc(r.title).includes(lc(album)) || lc(r.artist?.name).includes(lc(artist))
) || results[0];
return match ? await fetchAlbumTracks(match.id) : [];
} catch { return []; }
}
// Artist discography for the artist-download: search the artist, take real albums
// (record_type "album", not singles/EPs), return {collectionId, collectionName, artworkUrl100}.
async function fetchArtistAlbums(artistName, limit = 10) {
try {
const artists = (await dzGet(`search/artist?q=${encodeURIComponent(artistName)}&limit=1`)).data || [];
if (!artists.length) return [];
const albums = (await dzGet(`artist/${artists[0].id}/albums?limit=100`)).data || [];
return albums
.filter(a => a.record_type === 'album')
.slice(0, limit)
.map(a => ({
collectionId: a.id,
collectionName: a.title,
artistName: artists[0].name,
artworkUrl100: coverXl(a),
}));
} catch { return []; }
}
module.exports = {
dzGet,
lookupTrackMeta,
fetchAlbumTracks,
searchAlbumTracks,
fetchArtistAlbums,
// exported for unit tests
mapAlbumTrack,
pickTrackMatch,
normalizeTitle,
};