diff --git a/backend/novasonic-server.js b/backend/novasonic-server.js index 96d92bf..2d97ad3 100644 --- a/backend/novasonic-server.js +++ b/backend/novasonic-server.js @@ -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); } diff --git a/public/sw.js b/public/sw.js index 32ddc13..d418d83 100644 --- a/public/sw.js +++ b/public/sw.js @@ -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. diff --git a/src/api.js b/src/api.js index 4edcbff..1efb610 100644 --- a/src/api.js +++ b/src/api.js @@ -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() { diff --git a/src/ui/downloads.js b/src/ui/downloads.js index cd9aac5..2ba6110 100644 --- a/src/ui/downloads.js +++ b/src/ui/downloads.js @@ -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`, {