feat(web): switch the Apple/Erkunden search to Deezer

Apple's iTunes /search returns 403 from server IPs, killing the Erkunden search. Deezer
is free, needs no key, and is actually better: it returns explicit versions, artist
images, track durations and 30s previews.

- Backend: /api/deezer/* proxy (CORS-safe); album download accepts a `tracks` array in
  the request body (Deezer albums send their tracks directly, pre-mapped).
- api.js: itunesSearch / itunesLookupAlbum / itunesLookupArtistSongs|Albums now hit
  Deezer and map results to the existing ItunesItem shape, so the UI + download flow are
  unchanged. Function names kept.
- downloads.js: album downloads fetch the Deezer album's tracks and send them in the body.

iTunes /lookup (by ID) and the charts RSS proxy stay. Known follow-ups: artist-type
download still calls iTunes /search server-side; the metadata-editor iTunes autofill;
and the Android client — all still on iTunes for now.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Opus 4.8 committed 2026-07-30 20:54:10 +02:00
1 parent ccf704754a
commit 68a9cdd7d1
4 files changed
+103 -22

No files matched your search

+74 -18
View File
@@ -280,40 +280,96 @@ function _chartsUrl(country, kind) {
: `https://rss.applemarketingtools.com/api/v2/${country}/music/most-played/50/${kind}.json`;
}
// Last iTunes /search outcome: null = ok, else the HTTP status (e.g. 403 when Apple blocks the
// Search API) or 'network'. Read by the search view to tell "no results" apart from "API down".
// ─── 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
// flow stay unchanged.
function _deezerBase() {
return S.cfg.backendUrl ? `${S.cfg.backendUrl}/api/deezer` : 'https://api.deezer.com';
}
function _dzTrack(t) {
return {
wrapperType: 'track', kind: 'song',
trackId: t.id, trackName: t.title,
artistId: t.artist?.id, artistName: t.artist?.name || '',
collectionId: t.album?.id, collectionName: t.album?.title || '',
artworkUrl100: t.album?.cover_big || t.album?.cover_medium || t.artist?.picture_medium || '',
previewUrl: t.preview || '',
trackTimeMillis: t.duration ? t.duration * 1000 : undefined,
trackNumber: t.track_position || undefined,
discNumber: t.disk_number || undefined,
trackExplicitness: t.explicit_lyrics ? 'explicit' : 'notExplicit',
};
}
function _dzAlbum(a) {
return {
wrapperType: 'collection', collectionType: 'Album',
collectionId: a.id, collectionName: a.title,
artistId: a.artist?.id, artistName: a.artist?.name || '',
artworkUrl100: a.cover_big || a.cover_medium || '',
trackCount: a.nb_tracks || undefined,
releaseDate: a.release_date || undefined,
};
}
function _dzArtist(a) {
return {
wrapperType: 'artist',
artistId: a.id, artistName: a.name,
artworkUrl100: a.picture_big || a.picture_medium || '',
};
}
// Last search outcome: null = ok, else HTTP status or 'network'. Read by the search view to tell
// "no results" apart from "search backend down".
let _itunesLastError = null;
export function itunesLastError() { return _itunesLastError; }
// Keeps the old name so all callers stay unchanged. entity: musicTrack | album | musicArtist.
export async function itunesSearch(query, entity='musicTrack', limit=25) {
try {
// US store only — one consistent naming source across search + download (matches the Android
// client, which is already US). A single fetch also halves the iTunes request rate.
const r = await fetch(`${_itunesBase()}/search?term=${encodeURIComponent(query)}&media=music&entity=${entity}&limit=${limit}&country=us&explicit=Yes`);
const path = entity === 'album' ? 'search/album' : entity === 'musicArtist' ? 'search/artist' : 'search';
const r = await fetch(`${_deezerBase()}/${path}?q=${encodeURIComponent(query)}&limit=${limit}`);
if (!r.ok) { _itunesLastError = r.status; return []; }
_itunesLastError = null;
const t = await r.text();
return t ? (JSON.parse(t).results || []) : [];
const txt = await r.text();
const data = (txt ? JSON.parse(txt) : {}).data || [];
if (entity === 'album') return data.map(_dzAlbum);
if (entity === 'musicArtist') return data.map(_dzArtist);
return data.map(_dzTrack);
} catch { _itunesLastError = 'network'; return []; }
}
// Album tracks (for the album detail view + album download). Deezer's /album/{id} embeds tracks +
// album-level release/genre/cover, so one request gives everything.
export async function itunesLookupAlbum(collectionId) {
const r = await fetch(`${_itunesBase()}/lookup?id=${collectionId}&entity=song&explicit=Yes`);
const d = await r.json();
return (d.results || []).filter(x => x.wrapperType === 'track');
try {
const r = await fetch(`${_deezerBase()}/album/${collectionId}`);
const a = await r.json();
const cover = a.cover_big || a.cover_medium || '';
return (a.tracks?.data || []).map(t => ({
..._dzTrack({ ...t, album: { id: a.id, title: a.title, cover_big: a.cover_big, cover_medium: a.cover_medium } }),
collectionArtistName: a.artist?.name || '',
artworkUrl100: cover,
releaseDate: a.release_date || undefined,
primaryGenreName: a.genres?.data?.[0]?.name,
trackCount: a.nb_tracks || undefined,
}));
} catch { return []; }
}
// Returns actual explicit tracks for an artist via lookup (search endpoint only returns cleaned versions)
export async function itunesLookupArtistSongs(artistId, limit=15) {
const r = await fetch(`${_itunesBase()}/lookup?id=${artistId}&entity=song&limit=${limit}&country=us&explicit=Yes`);
const d = await r.json();
return (d.results || []).filter(x => x.wrapperType === 'track');
try {
const r = await fetch(`${_deezerBase()}/artist/${artistId}/top?limit=${limit}`);
return ((await r.json()).data || []).map(_dzTrack);
} catch { return []; }
}
export async function itunesLookupArtistAlbums(artistId, limit=200) {
const r = await fetch(`${_itunesBase()}/lookup?id=${artistId}&entity=album&limit=${limit}&country=us`);
const d = await r.json();
return (d.results || []).filter(x => x.wrapperType === 'collection' && x.collectionType === 'Album');
export async function itunesLookupArtistAlbums(artistId, limit=100) {
try {
const r = await fetch(`${_deezerBase()}/artist/${artistId}/albums?limit=${limit}`);
return ((await r.json()).data || []).map(_dzAlbum).filter(a => a.collectionType === 'Album');
} catch { return []; }
}
export async function itunesTopCharts() {