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

+17 -41
View File
@@ -267,23 +267,11 @@ export async function romajiConvert(texts) {
} catch { return texts; }
}
// ─── ITUNES ───
// Route through backend proxy to avoid CORS; fall back to direct if no backend configured.
function _itunesBase() {
return S.cfg.backendUrl ? `${S.cfg.backendUrl}/api/itunes` : 'https://itunes.apple.com';
}
// Apple charts RSS — proxied through the backend (browsers can't fetch rss.applemarketingtools.com;
// no CORS header). kind = albums | songs.
function _chartsUrl(country, kind) {
return S.cfg.backendUrl
? `${S.cfg.backendUrl}/api/itunes-charts/${kind}?country=${country}`
: `https://rss.applemarketingtools.com/api/v2/${country}/music/most-played/50/${kind}.json`;
}
// ─── DEEZER (search source) ───
// Apple's iTunes /search returns 403 from server IPs, so Deezer is the search backend. Free, no key,
// returns explicit versions + artist images + track durations. Proxied through the backend to avoid
// any CORS surprises. All results are mapped to the iTunes "ItunesItem" shape so the UI + download
// ─── DEEZER (search + charts source) ───
// Apple's iTunes /search returns 403 from server IPs, so Deezer is the metadata backend for
// everything: search, album/artist lookups AND the "Neu & Beliebt" charts. Free, no key, returns
// explicit versions + artist images + track durations. Proxied through the backend to avoid any
// CORS surprises. All results are mapped to the legacy "ItunesItem" shape so the UI + download
// flow stay unchanged.
function _deezerBase() {
return S.cfg.backendUrl ? `${S.cfg.backendUrl}/api/deezer` : 'https://api.deezer.com';
@@ -380,39 +368,27 @@ export async function deezerArtistImage(artistId) {
} catch { return null; }
}
// "Neu & Beliebt" — Deezer's global chart (Apple's charts RSS needed a proxy and the search
// fallback is 403 from server IPs). Mapped to { name, artistName, artworkUrl, artworkUrl100 }.
export async function itunesTopCharts() {
const urls = [_chartsUrl('de', 'albums'), _chartsUrl('us', 'albums')];
for(const url of urls) {
try {
const r = await fetch(url, { signal: AbortSignal.timeout(6000) });
if(!r.ok) continue;
const d = await r.json();
if(d.feed?.results?.length) return d.feed.results;
} catch {}
}
try {
const r = await fetch(`${_itunesBase()}/search?term=new+music&media=music&entity=album&limit=50&country=de`);
const r = await fetch(`${_deezerBase()}/chart/0/albums?limit=50`);
const d = await r.json();
return (d.results||[]).map(a => ({
name: a.collectionName, artistName: a.artistName,
artworkUrl: a.artworkUrl100?.replace('100x100','300x300'), artworkUrl100: a.artworkUrl100,
return (d.data || []).map(a => ({
name: a.title, artistName: a.artist?.name || '',
artworkUrl: a.cover_big || a.cover_medium || '', artworkUrl100: a.cover_medium || '',
}));
} catch { return []; }
}
export async function itunesTopSongs() {
const urls = [_chartsUrl('de', 'songs'), _chartsUrl('us', 'songs')];
for(const url of urls) {
try {
const r = await fetch(url, { signal: AbortSignal.timeout(6000) });
if(!r.ok) continue;
const d = await r.json();
if(d.feed?.results?.length) return d.feed.results;
} catch {}
}
try {
const r = await fetch(`${_itunesBase()}/search?term=top+hits&media=music&entity=musicTrack&limit=50&country=de`);
const r = await fetch(`${_deezerBase()}/chart/0/tracks?limit=50`);
const d = await r.json();
return (d.results||[]).map(t => ({ name: t.trackName, artistName: t.artistName, artworkUrl: t.artworkUrl100 }));
return (d.data || []).map(t => ({
name: t.title, artistName: t.artist?.name || '',
artworkUrl: t.album?.cover_medium || t.album?.cover_big || '',
previewUrl: t.preview || '',
}));
} catch { return []; }
}
+2 -2
View File
@@ -46,7 +46,7 @@ import {
toggleMobileSearch, closeMobileSearch, switchSetupTab,
toggleNotifPanel, deleteNotif, clearNotifs, initNotifBell, _notifNavigate,
_pickTheme,
toggleInfoEdit, onInfoArtFile, autofillMusicBrainz, autofillItunes, saveMetadata, deleteSong,
toggleInfoEdit, onInfoArtFile, autofillMusicBrainz, autofillDeezer, saveMetadata, deleteSong,
addRomajiOverrideUI, removeRomajiOverrideUI,
openRomajiOverridesModal, closeRomajiOverridesModal,
} from './ui/ui.js';
@@ -334,7 +334,7 @@ Object.assign(window, {
loadSoundCloudImporter, resolveSoundCloudLink, clearSoundCloudPreview, scDlAll, scDlSingle, scDlTrack,
executeSpotifyDownload, resolveYouTubeLink, executeYouTubeDownload,
openPlModal, closePlModal, addSongsToPlaylist, createAndAddPlaylist, createPlaylistPrompt,
showSongInfo, closeInfoModal, toggleInfoEdit, onInfoArtFile, autofillMusicBrainz, autofillItunes, saveMetadata, deleteSong,
showSongInfo, closeInfoModal, toggleInfoEdit, onInfoArtFile, autofillMusicBrainz, autofillDeezer, saveMetadata, deleteSong,
openDlModal, closeDlModal, selectDlType, executeDownload,
openEditPlModal, closeEditPlModal, saveEditPlaylist,
editPlUploadCover, editPlCoverSelected, editPlRemoveCover, deletePlaylist, _confirmOk, _confirmCancel,
+6 -6
View File
@@ -207,18 +207,18 @@ export async function autofillMusicBrainz() {
}
}
export async function autofillItunes() {
export async function autofillDeezer() {
if (!S.cfg.backendUrl) { toast('⚠ Kein Backend konfiguriert'); return; }
const s = S.infoSong;
if (!s) return;
const artist = document.getElementById('ie-artist').value || s.artist || '';
const title = document.getElementById('ie-title').value || s.title || '';
const status = document.getElementById('info-edit-status');
status.textContent = '🔍 Apple Music…';
status.textContent = '🔍 Deezer…';
try {
const r = await fetch(`${S.cfg.backendUrl}/api/itunes/lookup?artist=${encodeURIComponent(artist)}&title=${encodeURIComponent(title)}`);
const r = await fetch(`${S.cfg.backendUrl}/api/meta/lookup?artist=${encodeURIComponent(artist)}&title=${encodeURIComponent(title)}`);
const d = await r.json();
if (!d.found) { status.textContent = '❌ Kein Treffer auf iTunes'; return; }
if (!d.found) { status.textContent = '❌ Kein Treffer auf Deezer'; return; }
if (d.title) document.getElementById('ie-title').value = d.title;
if (d.artist) document.getElementById('ie-artist').value = _fmtArtists(d.artist, '; ');
if (d.albumArtist) document.getElementById('ie-albumartist').value = _fmtArtists(d.albumArtist, ', ');
@@ -232,9 +232,9 @@ export async function autofillItunes() {
S._infoArtData = { url: d.coverUrl };
document.getElementById('info-edit-art-preview').innerHTML =
`<img src="${d.coverUrl}" style="width:100%;height:100%;object-fit:cover" onerror="this.parentNode.innerHTML='<div class=\\'no-art\\' style=\\'width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:28px\\'>🎵</div>'">`;
status.textContent = '✅ Metadaten + Cover von Apple Music';
status.textContent = '✅ Metadaten + Cover von Deezer';
} else {
status.textContent = '✅ Metadaten von Apple Music (kein Cover)';
status.textContent = '✅ Metadaten von Deezer (kein Cover)';
}
} catch (e) {
status.textContent = '❌ Fehler: ' + e.message;
+1 -1
View File
@@ -210,7 +210,7 @@ export async function loadArtistDetail(artist) {
}
}
if(iTracks.length) {
const heading = topSongs.length ? 'Weltweit populär (iTunes)' : 'Populäre Titel';
const heading = topSongs.length ? 'Weltweit populär (Deezer)' : 'Populäre Titel';
const sec = document.createElement('div');
sec.innerHTML = `<div class="section-title" style="margin-top:${topSongs.length?'8px':'0'};margin-bottom:10px">${heading}</div>`;
const list = document.createElement('div'); list.className = 'song-list'; list.style.marginBottom = '20px';
+8 -8
View File
@@ -247,7 +247,7 @@ function _setEnrichStatus(done, current, total) {
if (!el) return;
if (done) {
el.className = 'done';
el.innerHTML = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="20 6 9 17 4 12"/></svg> iTunes-Daten geladen';
el.innerHTML = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="20 6 9 17 4 12"/></svg> Deezer-Daten geladen';
el.style.display = 'flex';
el.style.opacity = '1';
setTimeout(() => {
@@ -256,7 +256,7 @@ function _setEnrichStatus(done, current, total) {
}, 2000);
} else {
el.className = '';
el.innerHTML = `<div class="enrich-spinner"></div> Lade iTunes-Daten… (${current}/${total})`;
el.innerHTML = `<div class="enrich-spinner"></div> Lade Deezer-Daten… (${current}/${total})`;
el.style.display = 'flex';
el.style.opacity = '1';
}
@@ -274,7 +274,7 @@ async function _loadSpotifyTrackArtwork(tracks, listEl) {
_setArt(el, url);
});
// Enrich each track with iTunes data — same call as global search
// Enrich each track with Deezer data — same call as global search
const CONC = 4;
let enriched = 0;
_setEnrichStatus(false, 0, tracks.length);
@@ -287,15 +287,15 @@ async function _loadSpotifyTrackArtwork(tracks, listEl) {
const results = await itunesSearch(`${t.trackName} ${firstArtist}`, 'musicTrack', 1);
const match = results[0];
if (!match) return;
// Always safe to enrich: artwork, preview, explicit, iTunes artist name
// Always safe to enrich: artwork, preview, explicit, canonical artist name
if (match.artworkUrl100) t.artworkUrl100 = match.artworkUrl100;
if (match.trackExplicitness) t.trackExplicitness = match.trackExplicitness;
if (match.previewUrl) t.previewUrl = match.previewUrl;
// Store iTunes canonical name separately — keeps original Spotify/Japanese in artistName
// Store the Deezer canonical name separately — keeps original Spotify/Japanese in artistName
// so the UI shows the original and YTM search uses the original channel name.
if (match.artistName) t.itunesArtistName = match.artistName;
// Only enrich album/duration if we're confident the iTunes match is the correct song.
// For Japanese titles we can't word-match, so we skip — the backend's lookupItunesTrackMeta
if (match.artistName) t.metaArtistName = match.artistName;
// Only enrich album/duration if we're confident the Deezer match is the correct song.
// For Japanese titles we can't word-match, so we skip — the backend's lookupTrackMeta
// handles it with better matching. For ASCII titles, require at least one shared word.
const JP_RE = /[ぁ-んァ-ン一-龯]/;
const isJapanese = JP_RE.test(t.trackName);