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:
1 parent
ccf704754a
commit
68a9cdd7d1
4 files changed
+103
-22
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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`, {
|
||||
|
||||
Reference in new issue
Block a user