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

+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,
});
}));