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:
1 parent
a65613b5a1
commit
1e69831dd8
19 files changed
+628
-350
No files matched your search
+17
-41
@@ -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
@@ -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
@@ -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
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user