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>
176 lines
8.3 KiB
JavaScript
176 lines
8.3 KiB
JavaScript
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,
|
||
};
|