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

+19 -2
View File
@@ -324,6 +324,20 @@ app.get('/api/itunes/:endpoint', (req, 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, …
app.get('/api/deezer/*', (req, res) => {
const sub = String(req.params[0] || '').replace(/[^a-zA-Z0-9/_-]/g, '');
const qs = new URLSearchParams(req.query).toString();
const target = `https://api.deezer.com/${sub}${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 }));
});
// 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) => {
@@ -626,8 +640,11 @@ app.post('/api/download', async (req, res) => {
}
} else if (type === 'album') {
let tracks = [];
if (collectionId) {
// 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.
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);
}
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'ns-v48';
const CACHE = 'ns-v49';
const PRECACHE = ['/', 'index.html', 'manifest.json', 'icon.svg'];
const SKIP_PATHS = ['/rest/stream', '/rest/download', '/config.js'];
// Auth token params stripped from cache keys so artwork survives across sessions/token refreshes.
+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() {
+9 -1
View File
@@ -1,6 +1,6 @@
import { S } from '../state.js';
import { esc, toast } from '../utils.js';
import { api, fetchRomajiIndex, romajiConvert, getRomajiOverrides, invalidateRomajiIndex } from '../api.js';
import { api, fetchRomajiIndex, romajiConvert, getRomajiOverrides, invalidateRomajiIndex, itunesLookupAlbum } from '../api.js';
import { _addNotif } from './notify.js';
// ─── DOWNLOAD MODAL ───
@@ -47,6 +47,14 @@ export async function executeDownload() {
...(ov[_title] && { titleSort: ov[_title] }),
...(ov[_album] && { albumSort: ov[_album] }),
};
// Album download: fetch the album's tracks from Deezer (id in collectionId) and send them in the
// request — the backend downloads that list directly (Apple's /search is 403, so no iTunes lookup).
if (S.dlType === 'album' && payload.collectionId) {
try {
const albumTracks = await itunesLookupAlbum(payload.collectionId);
if (albumTracks.length) payload.tracks = albumTracks;
} catch {}
}
addJob(jobId, payload.title, payload.artist, payload.type, payload.album);
try {
const res = await fetch(`${S.cfg.backendUrl}/api/download`, {