feat: move the whole metadata/search stack from iTunes to Deezer (1.3)

Apple's iTunes /search returns 403 from server/datacenter IPs, which had broken global
search, charts, album/artist/single downloads and the metadata-editor autofill in
production. Everything now sources metadata from Deezer (free, no key, more requests,
returns explicit versions + real artist images + durations). Web + Android share one
source, so an artist resolves to one consistent name everywhere. Deezer results are
mapped to the legacy "ItunesItem" shape, so the UI + download flow are unchanged.

Backend:
- New backend/lib/deezer.js: lookupTrackMeta, fetchAlbumTracks, searchAlbumTracks,
  fetchArtistAlbums (+ pure pickTrackMatch/mapAlbumTrack for tests).
- metadata.js drops the iTunes lookups; novasonic-server download paths (track, album,
  artist, playlist enrich, spotify artwork) + autofill now use Deezer.
- Artist download uses Deezer discography (real albums only), not the iTunes album search.
- Editor autofill endpoint /api/itunes/lookup -> /api/meta/lookup; removed the dead
  iTunes /search proxy and the Apple charts RSS proxy.
- backend/test/deezer.test.js: unit tests for numbering, mixed-artist album-artist
  stability, explicit flags, the Japanese-title match guard (npm test; RUN_LIVE=1 for live).

Web:
- api.js search/lookups already on Deezer; charts now use Deezer /chart. Artist cards
  show the real Deezer photo. Visible "iTunes"/"Apple Music" labels -> "Deezer"
  (search badge, charts badge, preview bar, editor button, artist heading). SW ns-v51.

Android:
- New DeezerModels.kt with Dz* models + mappers to ItunesItem/ItunesRssEntry.
- ItunesClient is Deezer-backed (search = tracks+albums+artists in parallel; album/artist
  lookups; /chart charts), same method signatures. Visible strings -> Deezer.

Docs: README, CLAUDE.md and CHANGELOG (1.3) updated; Platform Parity Matrix reflects the
shared Deezer source + Android non-local artist page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Opus 4.8 committed 2026-07-31 12:48:33 +02:00
1 parent a65613b5a1
commit 1e69831dd8
19 files changed
+628 -350

No files matched your search

+163
View File
@@ -0,0 +1,163 @@
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);
const titleOnly = results.find(t => normalizeTitle(t.title) === normT);
return strict || (!isJp ? titleOnly || null : 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,
};
+4 -126
View File
@@ -17,7 +17,7 @@ function navidromeArtist(name) {
.replace(/,\s*/g, ';');
}
// Download hi-res iTunes artwork (1000x1000) to cover.jpg, return path or null
// Download hi-res cover artwork (1000x1000) to cover.jpg, return path or null
async function downloadArtwork(url, dir) {
if (!url) return null;
const dest = path.join(dir, 'cover.jpg');
@@ -112,133 +112,11 @@ async function fetchLyrics(artist, title, album) {
} catch { return null; }
}
// Normalize a track title for comparison: unify variant suffixes so that
// "don't rush - slowed" and "don't rush (slowed)" are treated as equal.
function normalizeTitle(s) {
return (s || '')
.toLowerCase()
.trim()
// convert "title - variant" → "title (variant)" so both forms compare equal
.replace(/\s+-\s+([^-]+)$/, ' ($1)')
// strip all remaining parens/brackets content for the base comparison
.replace(/[\[((][^\]))]*[\]))]/g, '')
// strip punctuation so "Mr. Lawrence" == "Mr Lawrence" and comma variants match
.replace(/[,.'!?]/g, '')
.replace(/\s+/g, ' ')
.trim();
}
// iTunes JP store returns localized genre names — map them to English equivalents
const JP_GENRE_MAP = {
'アニメ': 'Anime', 'イージーリスニング': 'Easy Listening', 'ポップ': 'Pop',
'J-Pop': 'J-Pop', 'ロック': 'Rock', 'クラシック': 'Classical', 'ジャズ': 'Jazz',
'エレクトロニック': 'Electronic', 'R&B/ソウル': 'R&B/Soul', 'ヒップホップ/ラップ': 'Hip-Hop/Rap',
'オルタナティブ': 'Alternative', 'サウンドトラック': 'Soundtrack', 'ボーカル': 'Vocal',
'インストゥルメンタル': 'Instrumental', 'ワールド': 'World', 'フォーク': 'Folk',
'カントリー': 'Country', 'レゲエ': 'Reggae', 'ソウル': 'Soul', 'ブルース': 'Blues',
'メタル': 'Metal', 'パンク': 'Punk', 'ダンス': 'Dance', 'コメディ': 'Comedy',
'スピーチ': 'Speech', 'キッズ/ファミリー': 'Kids/Family',
};
function toEnglishGenre(g) { return JP_GENRE_MAP[g] || g; }
// iTunes track lookup — fills in album, year, trackNum, totalTracks, genre when missing
async function lookupItunesTrackMeta(artist, title) {
try {
// Search with base title (strip variant suffix) for better iTunes hit rate —
// e.g. "don't rush - slowed" → search "don't rush" so iTunes finds the single
const baseTitle = title.replace(/\s*[-–]\s*\S[^-]*$/, '').trim() || title;
const searchTitle = baseTitle !== title ? baseTitle : title;
// Strip leading aesthetic punctuation many artists use (".diedlonely", "†", "✝", "_x")
// so the iTunes search actually surfaces them instead of a same-titled track by someone else.
const stripLeadSym = s => (s || '').replace(/^[^\p{L}\p{N}]+/u, '').trim();
const searchArtist = stripLeadSym(artist.split(/[;,&]/)[0].trim());
const fetchItunes = (country) => new Promise((resolve, reject) => {
const url = `https://itunes.apple.com/search?term=${encodeURIComponent(searchArtist + ' ' + searchTitle)}&media=music&entity=musicTrack&limit=10&country=${country}&explicit=Yes`;
https.get(url, res => {
let d = ''; res.on('data', c => d += c);
res.on('end', () => { try { resolve(JSON.parse(d).results || []); } catch { reject(new Error('parse')); } });
}).on('error', reject);
});
const normT = normalizeTitle(title);
const normA = (searchArtist || '').toLowerCase().trim();
// Split by space OR comma/semicolon/slash so "VXLLAIN, iGRES" → "vxllain" not "vxllain,"
const firstWord = s => stripLeadSym((s || '').toLowerCase()).split(/[\s,;/]/)[0];
const strictMatch = results => results.find(t =>
normalizeTitle(t.trackName) === normT && firstWord(t.artistName) === firstWord(normA)
);
const titleOnlyMatch = results => results.find(t => normalizeTitle(t.trackName) === normT);
// US store only — one consistent naming source (English/romaji) across search + download, so the
// same artist always resolves the same way instead of "sometimes Japanese, sometimes romaji". A
// single request instead of JP+US also halves the download's iTunes calls (helps the rate limit).
const usResults = await fetchItunes('us');
const JP_RE = /[぀-ヿ㐀-䶿一-鿿]/;
const isJpSearch = JP_RE.test(normT) || JP_RE.test(normA);
// titleOnlyMatch (no artist check) is too permissive when the title is Japanese — common anime/
// OST titles exist under different artists — so only use it as a fallback for non-Japanese titles.
const match = strictMatch(usResults) || (!isJpSearch && titleOnlyMatch(usResults));
if (!match) return {};
const naming = match;
const rawGenre = match.primaryGenreName;
const genre = rawGenre ? toEnglishGenre(rawGenre) : undefined;
return {
album: naming.collectionName || '',
albumArtist: naming.collectionArtistName || undefined,
trackName: naming.trackName || undefined,
year: match.releaseDate ? new Date(match.releaseDate).getFullYear() : undefined,
trackNum: match.trackNumber || undefined,
totalTracks: match.trackCount || undefined,
discNum: match.discNumber || undefined,
discTotal: match.discCount || undefined,
genre: genre,
artworkUrl: (naming.artworkUrl100 || match.artworkUrl100 || '').replace('100x100bb', '1000x1000bb'),
durationMs: match.trackTimeMillis || undefined,
itunesArtistName: naming.artistName || undefined,
};
} catch { return {}; }
}
// Fetch individual tracks for an iTunes collectionId
async function fetchItunesTracks(collectionId) {
return new Promise((resolve, reject) => {
const url = `https://itunes.apple.com/lookup?id=${collectionId}&entity=song&explicit=Yes`;
https.get(url, res => {
let data = '';
res.on('data', d => data += d);
res.on('end', () => {
try {
const parsed = JSON.parse(data);
resolve((parsed.results || []).filter(r => r.wrapperType === 'track'));
} catch { reject(new Error('iTunes API parse error')); }
});
}).on('error', reject);
});
}
// Search iTunes for album tracks by artist + album name (fallback when no collectionId)
async function searchItunesAlbumTracks(artist, album) {
return new Promise(resolve => {
const q = encodeURIComponent(`${artist} ${album}`);
https.get(`https://itunes.apple.com/search?term=${q}&media=music&entity=album&limit=5&country=us&explicit=Yes`, res => {
let d = '';
res.on('data', c => d += c);
res.on('end', () => {
try {
const results = JSON.parse(d).results || [];
const match = results.find(r =>
r.collectionName?.toLowerCase().includes(album.toLowerCase()) ||
r.artistName?.toLowerCase().includes(artist.toLowerCase())
);
if (match) fetchItunesTracks(match.collectionId).then(resolve).catch(() => resolve([]));
else resolve([]);
} catch { resolve([]); }
});
}).on('error', () => resolve([]));
});
}
// Track/album/artist metadata now comes from Deezer (backend/lib/deezer.js) — Apple's
// iTunes /search returns 403 from server IPs. See that module for lookupTrackMeta,
// fetchAlbumTracks, searchAlbumTracks and fetchArtistAlbums.
module.exports = {
sanitize, navidromeArtist,
downloadArtwork, findExistingTrack,
lookupItunesTrackMeta, fetchItunesTracks, searchItunesAlbumTracks,
};
+70 -102
View File
@@ -17,7 +17,8 @@ const crypto = require('crypto');
const CONFIG = require('./lib/config');
const { jobs, updateJob, log } = require('./lib/jobs');
const { sanitize, lookupItunesTrackMeta, fetchItunesTracks, searchItunesAlbumTracks, findExistingTrack } = require('./lib/metadata');
const { sanitize, findExistingTrack } = require('./lib/metadata');
const dz = require('./lib/deezer');
const { downloadTrack, downloadAlbumTracks } = require('./lib/downloader');
const { buildRomajiIndex, clearRomajiCache, isTokenizerReady, fetchNdSongs, romajiForStorage } = require('./lib/romaji');
@@ -274,7 +275,7 @@ app.post('/api/spotify/track-artwork', async (req, res) => {
const batch = tracks.slice(i, i + CONC);
const batchRes = await Promise.all(batch.map(async t => {
const searchArtist = (t.artistName || '').split(/\s*[,/•]\s*/)[0].trim();
const meta = await lookupItunesTrackMeta(searchArtist, t.trackName || '');
const meta = await dz.lookupTrackMeta(searchArtist, t.trackName || '');
return { idx: t.idx, artworkUrl: meta.artworkUrl || t.existingArt || '' };
}));
results.push(...batchRes);
@@ -282,51 +283,36 @@ app.post('/api/spotify/track-artwork', async (req, res) => {
res.json({ results });
});
// ─── ITUNES METADATA LOOKUP (must be before the proxy route) ─────────────────
app.get('/api/itunes/lookup', async (req, res, next) => {
// ─── METADATA-EDITOR AUTOFILL ────────────────────────────────────────────────
// Fills the editor from Deezer given artist + title (replaces the old iTunes autofill).
app.get('/api/meta/lookup', async (req, res) => {
const { artist, title } = req.query;
// ID-based lookups (?id=...) are not for track meta — let them fall through to the proxy.
if (!artist || !title) return next();
if (!artist || !title) return res.status(400).json({ error: 'artist and title required' });
try {
const meta = await lookupItunesTrackMeta(artist, title);
const meta = await dz.lookupTrackMeta(artist, title);
if (!meta.album && !meta.genre && !meta.year) return res.json({ found: false });
res.json({
found: true,
title: meta.trackName || undefined,
artist: meta.itunesArtistName || undefined,
albumArtist: meta.albumArtist || undefined,
album: meta.album || undefined,
year: meta.year || undefined,
trackNum: meta.trackNum || undefined,
totalTracks: meta.totalTracks || undefined,
discNum: meta.discNum || undefined,
genre: meta.genre || undefined,
coverUrl: meta.artworkUrl || undefined,
title: meta.trackName || undefined,
artist: meta.metaArtistName || undefined,
albumArtist: meta.albumArtist || undefined,
album: meta.album || undefined,
year: meta.year || undefined,
trackNum: meta.trackNum || undefined,
totalTracks: meta.totalTracks || undefined,
discNum: meta.discNum || undefined,
genre: meta.genre || undefined,
coverUrl: meta.artworkUrl || undefined,
});
} catch (e) {
console.error('[itunes/lookup]', e.message);
console.error('[meta/lookup]', e.message);
res.status(500).json({ error: e.message });
}
});
// ─── ITUNES PROXY ─────────────────────────────────────────────────────────────
// Browsers can't call itunes.apple.com directly (no CORS headers). Proxy it here.
// Note: /api/itunes/lookup is handled above — proxy only covers /search and id-based /lookup.
app.get('/api/itunes/:endpoint', (req, res) => {
const allowed = new Set(['search', 'lookup']);
if (!allowed.has(req.params.endpoint)) return res.status(404).json({ error: 'not found' });
const qs = new URLSearchParams(req.query).toString();
const target = `https://itunes.apple.com/${req.params.endpoint}${qs ? '?' + qs : ''}`;
https.get(target, { headers: { 'User-Agent': 'NovaSonic/1.0' } }, upstream => {
res.setHeader('Content-Type', 'application/json');
res.status(upstream.statusCode || 200);
upstream.pipe(res);
}).on('error', e => res.status(502).json({ error: e.message }));
});
// Deezer API proxy — the search source (Apple's /search returns 403 from server IPs). Deezer is
// free, needs no key, and returns explicit versions + artist images + track durations. Covers
// /search, /search/album, /search/artist, /album/{id}, /album/{id}/tracks, /artist/{id}/albums, …
// Deezer API proxy — the metadata source for search, charts, album/artist lookups (Apple's /search
// returns 403 from server IPs). Free, no key, returns explicit versions + artist images + durations.
// Covers /search, /search/album, /search/artist, /album/{id}, /artist/{id}/albums, /chart/…, etc.
// Regex route (not '/api/deezer/*') — a bare "*" is an invalid path pattern in Express 5 and
// crashes the server at startup, taking every endpoint down with it.
app.get(/^\/api\/deezer\/(.+)/, (req, res) => {
@@ -340,19 +326,6 @@ app.get(/^\/api\/deezer\/(.+)/, (req, res) => {
}).on('error', e => res.status(502).json({ error: e.message }));
});
// Apple "most-played" charts RSS — the browser can't fetch rss.applemarketingtools.com directly
// (no CORS header), so proxy it. kind = albums | songs; country defaults to de.
app.get('/api/itunes-charts/:kind', (req, res) => {
const kind = req.params.kind === 'songs' ? 'songs' : 'albums';
const country = String(req.query.country || 'de').replace(/[^a-z]/gi, '').slice(0, 2).toLowerCase() || 'de';
const target = `https://rss.applemarketingtools.com/api/v2/${country}/music/most-played/50/${kind}.json`;
https.get(target, { headers: { 'User-Agent': 'NovaSonic/1.0' } }, upstream => {
res.setHeader('Content-Type', 'application/json');
res.status(upstream.statusCode || 200);
upstream.pipe(res);
}).on('error', e => res.status(502).json({ error: e.message }));
});
app.get('/api/events/:artist', (req, res) => {
const key = CONFIG.ticketmasterKey;
if (!key) { console.warn('[events] TICKETMASTER_API_KEY not set'); return res.json([]); }
@@ -545,27 +518,27 @@ app.post('/api/youtube/resolve', async (req, res) => {
artworkUrl: entries[0]?.thumbnail || '', tracks });
}
// Single video — enrich with iTunes
// Single video — enrich with Deezer
const e = entries[0];
const rawArtist = _cleanYtArtist(e.uploader || e.channel || e.creator || '');
const title = e.title || '';
const sourceUrl = e.webpage_url || url;
let itunes = {};
try { itunes = await lookupItunesTrackMeta(rawArtist.split(',')[0].trim(), title); } catch {}
let meta = {};
try { meta = await dz.lookupTrackMeta(rawArtist.split(',')[0].trim(), title); } catch {}
const artist = itunes.artist || rawArtist;
const artworkUrl = itunes.artworkUrl || e.thumbnail || '';
const artist = meta.artist || rawArtist;
const artworkUrl = meta.artworkUrl || e.thumbnail || '';
return res.json({
type: 'track', name: title, artist, artworkUrl,
tracks: [{
trackName: title,
artistName: artist,
artworkUrl: artworkUrl,
collectionName: itunes.album || '',
releaseDate: itunes.year ? String(itunes.year) : '',
trackNumber: itunes.trackNum || undefined,
durationMs: itunes.durationMs || (e.duration ? Math.round(e.duration * 1000) : undefined),
genre: itunes.genre || '',
collectionName: meta.album || '',
releaseDate: meta.year ? String(meta.year) : '',
trackNumber: meta.trackNum || undefined,
durationMs: meta.durationMs || (e.duration ? Math.round(e.duration * 1000) : undefined),
genre: meta.genre || '',
sourceUrl,
}],
});
@@ -609,19 +582,19 @@ app.post('/api/download', async (req, res) => {
};
const searchArtist = artist.split(',')[0].trim();
if (!meta.album) {
log(jobId, '📋 Suche Album-Metadaten von iTunes…');
const extra = await lookupItunesTrackMeta(searchArtist, title);
log(jobId, '📋 Suche Album-Metadaten von Deezer…');
const extra = await dz.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 {
log(jobId, `📋 Album: "${meta.album}"`);
// Always fetch iTunes when any field is missing — durationMs is required for the yt-dlp
// Always fetch Deezer when any field is missing — durationMs is required for the yt-dlp
// duration filter; without it any unrelated video that matches the title text gets downloaded.
if (!meta.genre || !meta.totalTracks || !meta.durationMs || !meta.trackNum || !meta.year) {
// Strip release-type suffixes before iTunes lookup so "recognize - Single" → "recognize"
// Strip release-type suffixes before the Deezer lookup so "recognize - Single" → "recognize"
const RELEASE_SUFFIX_RE = /\s*[-–]\s*(?:(?:official\s+)?(?:music\s+)?(?:single|ep|lp|album|ost|soundtrack|remaster(?:ed)?|deluxe(?:\s+edition)?|bonus\s+tracks?|b-?sides?|version|edit)\b.*|(?:20|19)\d{2})\s*$/i;
const itunesTitle = title.replace(RELEASE_SUFFIX_RE, '').trim() || title;
const genreMeta = await lookupItunesTrackMeta(searchArtist, itunesTitle).catch(() => ({}));
const lookupTitle = title.replace(RELEASE_SUFFIX_RE, '').trim() || title;
const genreMeta = await dz.lookupTrackMeta(searchArtist, lookupTitle).catch(() => ({}));
if (!meta.genre && genreMeta.genre) { meta.genre = genreMeta.genre; log(jobId, `📋 Genre: "${genreMeta.genre}"`); }
if (!meta.year && genreMeta.year) { meta.year = genreMeta.year; log(jobId, `📋 Jahr: ${genreMeta.year}`); }
if (!meta.totalTracks && genreMeta.totalTracks) { meta.totalTracks = genreMeta.totalTracks; }
@@ -631,7 +604,7 @@ app.post('/api/download', async (req, res) => {
if (!meta.trackNum && genreMeta.trackNum) { meta.trackNum = genreMeta.trackNum; }
}
}
const finalArtist = meta.itunesArtistName || artist;
const finalArtist = meta.metaArtistName || artist;
const result = await downloadTrack({ jobId, artist: finalArtist, rawArtist: artist, title, ...meta, sourceUrl: sourceUrl || sp.sourceUrl || undefined });
if (result?.skipped) {
updateJob(jobId, { status: 'done', progress: 100, message: 'Bereits vorhanden – übersprungen', skipped: true });
@@ -643,26 +616,26 @@ app.post('/api/download', async (req, res) => {
} else if (type === 'album') {
// Prefer a track list supplied in the request body (Deezer albums send their tracks directly,
// pre-mapped to the iTunes track shape). Fall back to the iTunes lookup for legacy callers.
// pre-mapped to the ItunesItem shape). Fall back to a Deezer lookup for legacy callers.
let tracks = Array.isArray(req.body.tracks) ? req.body.tracks : [];
if (tracks.length) log(jobId, `📋 ${tracks.length} Tracks aus Anfrage (Deezer)`);
if (!tracks.length && collectionId) {
log(jobId, '📋 Lade Track-Liste von iTunes…');
tracks = await fetchItunesTracks(collectionId);
log(jobId, '📋 Lade Track-Liste von Deezer…');
tracks = await dz.fetchAlbumTracks(collectionId);
}
if (!tracks.length) {
log(jobId, '🔍 Suche Album auf iTunes…');
tracks = await searchItunesAlbumTracks(artist, album || title);
log(jobId, '🔍 Suche Album auf Deezer…');
tracks = await dz.searchAlbumTracks(artist, album || title);
}
if (!tracks.length) {
throw new Error(`Keine einzelnen Tracks für "${album || title}" gefunden. Bitte über iTunes Global-Suche herunterladen.`);
throw new Error(`Keine einzelnen Tracks für "${album || title}" gefunden. Bitte über die Global-Suche herunterladen.`);
}
// Album artist: prefer iTunes' album-level collectionArtistName (identical across every track)
// Album artist: prefer the album-level collectionArtistName (identical across every track)
// over the request's `artist`, which can be a single track's performer — e.g. the lead single
// "TAKEDOWN" is by TWICE, so `artist` arrives as "TWICE" and would tag the whole soundtrack's
// album_artist as TWICE. Keeps album_artist consistent so Navidrome doesn't split the album.
const albumArtistName = tracks.map(t => t.collectionArtistName).find(Boolean) || artist;
if (albumArtistName !== artist) log(jobId, `👥 Album-Künstler: "${albumArtistName}" (iTunes)`);
if (albumArtistName !== artist) log(jobId, `👥 Album-Künstler: "${albumArtistName}" (Deezer)`);
const { done: dlDone, skipped: dlSkipped } = await downloadAlbumTracks({ jobId, artist: albumArtistName, albumTitle: album || title, tracks, artworkUrl });
const allSkipped = dlSkipped === tracks.length;
const someSkipped = dlSkipped > 0 && !allSkipped;
@@ -675,19 +648,14 @@ app.post('/api/download', async (req, res) => {
} else if (type === 'artist') {
log(jobId, `🎤 Künstler-Download: ${artist}`);
const artistData = await new Promise((resolve, reject) => {
https.get(`https://itunes.apple.com/search?term=${encodeURIComponent(artist)}&media=music&entity=album&limit=10&country=de`, res => {
let d = ''; res.on('data', c => d += c);
res.on('end', () => { try { resolve(JSON.parse(d).results || []); } catch { reject(new Error('parse')); } });
}).on('error', reject);
});
const artistAlbums = artistData.filter(a => a.artistName?.toLowerCase() === artist.toLowerCase()).slice(0, 10);
// Deezer discography: search the artist, take their real albums (not singles/EPs).
const artistAlbums = await dz.fetchArtistAlbums(artist, 10).catch(() => []);
log(jobId, `📀 ${artistAlbums.length} Alben gefunden`);
let done = 0;
for (const alb of artistAlbums) {
const tracks = await fetchItunesTracks(alb.collectionId).catch(() => []);
const tracks = await dz.fetchAlbumTracks(alb.collectionId).catch(() => []);
if (tracks.length) {
// Same as the album path: album_artist from iTunes' album-level artist, so a collaboration
// Same as the album path: album_artist from the album-level artist, so a collaboration
// or soundtrack in the artist's discography isn't tagged with the searched artist.
const albArtist = tracks.map(t => t.collectionArtistName).find(Boolean) || alb.artistName || artist;
await downloadAlbumTracks({ jobId, artist: albArtist, albumTitle: alb.collectionName, tracks, artworkUrl: alb.artworkUrl100 });
@@ -708,46 +676,46 @@ app.post('/api/download', async (req, res) => {
const batchResults = await Promise.allSettled(batch.map(async track => {
const trackTitle = track.trackName || '';
const trackArtist = track.artistName || artist || '';
// Strip release-type suffixes before iTunes lookup ("Song - Single" → "Song")
// Strip release-type suffixes before the Deezer lookup ("Song - Single" → "Song")
const RELEASE_SUFFIX_RE = /\s*[-–]\s*(?:(?:official\s+)?(?:music\s+)?(?:single|ep|lp|album|ost|soundtrack|remaster(?:ed)?|deluxe(?:\s+edition)?|bonus\s+tracks?|b-?sides?|version|edit)\b.*|(?:20|19)\d{2})\s*$/i;
const itunesTitle = trackTitle.replace(RELEASE_SUFFIX_RE, '').trim() || trackTitle;
// If the frontend already enriched this track with iTunes data (collectionName +
const lookupTitle = trackTitle.replace(RELEASE_SUFFIX_RE, '').trim() || trackTitle;
// If the frontend already enriched this track with Deezer data (collectionName +
// trackNumber + durationMs all present), skip the re-lookup to avoid a different
// iTunes result overriding the already-correct enriched metadata.
// Deezer result overriding the already-correct enriched metadata.
const alreadyEnriched = !!(track.collectionName && track.trackNumber && track.durationMs);
const searchArtist = trackArtist.split(',')[0].trim();
let itunes = {};
let meta = {};
if (alreadyEnriched) {
log(jobId, `📋 Frontend-Metadaten: "${trackArtist} – ${trackTitle}" (${track.collectionName})`);
// Spotify enrichment copies album/trackNum/duration but not genre/totalTracks/disc — fetch those
const genreMeta = await lookupItunesTrackMeta(searchArtist, itunesTitle).catch(() => ({}));
itunes = {
const genreMeta = await dz.lookupTrackMeta(searchArtist, lookupTitle).catch(() => ({}));
meta = {
genre: genreMeta.genre,
totalTracks: genreMeta.totalTracks,
discNum: genreMeta.discNum,
discTotal: genreMeta.discTotal,
};
} else {
log(jobId, `📋 iTunes: "${trackArtist} – ${trackTitle}"`);
itunes = await lookupItunesTrackMeta(searchArtist, itunesTitle);
log(jobId, `📋 Deezer: "${trackArtist} – ${trackTitle}"`);
meta = await dz.lookupTrackMeta(searchArtist, lookupTitle);
}
// Prefer iTunes-resolved name (from server lookup or frontend enrichment) for sort tags / folders.
// Prefer the resolved name (from server lookup or frontend enrichment) for sort tags / folders.
// trackArtist stays as rawArtist for main tags and YTM search.
const resolvedArtist = itunes.itunesArtistName || track.itunesArtistName || trackArtist;
const resolvedArtist = meta.metaArtistName || track.metaArtistName || trackArtist;
return downloadTrack({
jobId,
artist: resolvedArtist, // iTunes English name → used for sort tags + folder names
artist: resolvedArtist, // resolved (Deezer) name → used for sort tags + folder names
rawArtist: trackArtist, // original Spotify/Japanese name → used for main tag + YTM search
albumArtist: resolvedArtist,
title: trackTitle,
album: itunes.album || track.collectionName || track.album || '',
year: itunes.year || (track.releaseDate ? new Date(track.releaseDate).getFullYear() : undefined) || track.year,
trackNum: itunes.trackNum || track.trackNumber || undefined,
totalTracks: itunes.totalTracks || undefined,
genre: itunes.genre || track.primaryGenreName || track.genre || undefined,
artworkUrl: itunes.artworkUrl || track.artworkUrl100 || track.artworkUrl || artworkUrl || '',
album: meta.album || track.collectionName || track.album || '',
year: meta.year || (track.releaseDate ? new Date(track.releaseDate).getFullYear() : undefined) || track.year,
trackNum: meta.trackNum || track.trackNumber || undefined,
totalTracks: meta.totalTracks || undefined,
genre: meta.genre || track.primaryGenreName || track.genre || undefined,
artworkUrl: meta.artworkUrl || track.artworkUrl100 || track.artworkUrl || artworkUrl || '',
explicit: track.trackExplicitness === 'explicit',
durationMs: itunes.durationMs || track.durationMs || undefined,
durationMs: meta.durationMs || track.durationMs || undefined,
sourceUrl: track.sourceUrl || undefined,
});
}));
+3
View File
@@ -1,6 +1,9 @@
{
"name": "novasonic-backend",
"private": true,
"scripts": {
"test": "node --test"
},
"dependencies": {
"express": "^4.18.0",
"kuromoji": "^0.1.2"
+109
View File
@@ -0,0 +1,109 @@
'use strict';
// Unit tests for the Deezer metadata mapping/matching (backend/lib/deezer.js).
// Pure logic only — no network. Run: `node --test` from backend/ (or `npm test`).
// A live smoke test against api.deezer.com is included but only runs with RUN_LIVE=1.
const test = require('node:test');
const assert = require('node:assert/strict');
const dz = require('../lib/deezer');
// ── Fixtures: the exact Deezer shapes the mappers consume ─────────────────────
const ALBUM = {
id: 302127,
title: 'Discovery',
cover_xl: 'https://img/cover_xl.jpg',
cover_big: 'https://img/cover_big.jpg',
release_date: '2001-03-07',
nb_tracks: 14,
record_type: 'album',
genres: { data: [{ id: 113, name: 'Dance' }] },
artist: { id: 27, name: 'Daft Punk' },
};
const TRACK = {
id: 3135556,
title: 'Harder, Better, Faster, Stronger',
duration: 224,
explicit_lyrics: false,
preview: 'https://img/preview.mp3',
artist: { id: 27, name: 'Daft Punk' },
};
test('mapAlbumTrack: numbers by album order and inherits album-level fields', () => {
const item = dz.mapAlbumTrack(TRACK, ALBUM, 3); // 4th track
assert.equal(item.wrapperType, 'track');
assert.equal(item.kind, undefined); // shape has no kind here; downloadAlbumTracks doesn't read it
assert.equal(item.trackNumber, 4); // index + 1 (Deezer album tracks carry no track_position)
assert.equal(item.trackName, 'Harder, Better, Faster, Stronger');
assert.equal(item.collectionName, 'Discovery');
assert.equal(item.collectionArtistName, 'Daft Punk');
assert.equal(item.primaryGenreName, 'Dance');
assert.equal(item.releaseDate, '2001-03-07');
assert.equal(item.trackCount, 14);
assert.equal(item.trackTimeMillis, 224000); // seconds → ms
assert.equal(item.trackExplicitness, 'notExplicit');
assert.equal(item.artworkUrl100, 'https://img/cover_xl.jpg');
});
test('mapAlbumTrack: per-track artist wins over the album artist (mixed-artist albums)', () => {
const feat = { ...TRACK, artist: { id: 99, name: 'MeloMance' } };
const item = dz.mapAlbumTrack(feat, ALBUM, 0);
assert.equal(item.artistName, 'MeloMance'); // drives the per-track YTM search
assert.equal(item.collectionArtistName, 'Daft Punk'); // album_artist stays stable → no album split
});
test('mapAlbumTrack: explicit flag maps through', () => {
const item = dz.mapAlbumTrack({ ...TRACK, explicit_lyrics: true }, ALBUM, 0);
assert.equal(item.trackExplicitness, 'explicit');
});
test('pickTrackMatch: strict match on artist + title', () => {
const results = [
{ title: 'Some Other Song', artist: { name: 'Daft Punk' } },
{ title: 'Harder, Better, Faster, Stronger', artist: { name: 'Daft Punk' } },
];
const m = dz.pickTrackMatch(results, 'Daft Punk', 'Harder, Better, Faster, Stronger');
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 title-only fallback for Japanese titles', () => {
const results = [{ title: '残酷な天使のテーゼ', artist: { name: 'Someone Else' } }];
const m = dz.pickTrackMatch(results, '高橋洋子', '残酷な天使のテーゼ');
assert.equal(m, null); // JP title + wrong artist must not match a same-titled cover
});
test('pickTrackMatch: Japanese title DOES match when the artist matches', () => {
const results = [{ title: '残酷な天使のテーゼ', artist: { name: '高橋洋子' } }];
const m = dz.pickTrackMatch(results, '高橋洋子', '残酷な天使のテーゼ');
assert.equal(m, results[0]);
});
test('pickTrackMatch: leading aesthetic punctuation is ignored on the artist', () => {
const results = [{ title: 'nostalgia', artist: { name: '.diedlonely' } }];
const m = dz.pickTrackMatch(results, '.diedlonely', 'nostalgia');
assert.equal(m, results[0]);
});
test('pickTrackMatch: empty / no match returns null', () => {
assert.equal(dz.pickTrackMatch([], 'x', 'y'), null);
assert.equal(dz.pickTrackMatch([{ title: 'A', artist: { name: 'B' } }], 'C', 'D'), null);
});
test('normalizeTitle: unifies variant suffixes and punctuation', () => {
assert.equal(dz.normalizeTitle("don't rush - slowed"), dz.normalizeTitle("don't rush (slowed)"));
assert.equal(dz.normalizeTitle('Mr. Lawrence'), dz.normalizeTitle('Mr Lawrence'));
});
// ── Live smoke test (opt-in): confirms the real Deezer shape still maps ────────
test('LIVE: lookupTrackMeta returns enriched fields', { skip: process.env.RUN_LIVE !== '1' }, async () => {
const meta = await dz.lookupTrackMeta('Daft Punk', 'Harder, Better, Faster, Stronger');
assert.ok(meta.album, 'album present');
assert.ok(meta.genre, 'genre present');
assert.ok(meta.year, 'year present');
assert.ok(meta.durationMs > 0, 'duration present');
});