fix(web): proxy Apple charts (CORS) + clear message when Apple search is blocked
Confirmed via curl from a clean IP: Apple's iTunes /search returns 403 (blocked server-side, likely datacenter-IP anti-scraping) while /lookup?id= still returns 200. So the Erkunden Apple search fails through no fault of ours. - Charts: the "most-played" RSS (rss.applemarketingtools.com) was fetched directly from the browser and hit CORS; proxy it via /api/itunes-charts/:kind. - Error visibility: itunesSearch records the last outcome (HTTP status / network); the global-search view now shows "Apple-Suche gerade nicht verfügbar" with the reason (403 = Apple blocks the API, not you) instead of a blank "no results". - iTunes proxy now forwards the upstream status code instead of always 200. Deezer (free, no key, ~50 req/5s) is the planned search fallback; MusicBrainz is too rate-limited (1/s) for interactive search. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
9c8b4775ea
commit
ccf704754a
4 files changed
+47
-12
No files matched your search
@@ -319,6 +319,20 @@ app.get('/api/itunes/:endpoint', (req, res) => {
|
|||||||
const target = `https://itunes.apple.com/${req.params.endpoint}${qs ? '?' + qs : ''}`;
|
const target = `https://itunes.apple.com/${req.params.endpoint}${qs ? '?' + qs : ''}`;
|
||||||
https.get(target, { headers: { 'User-Agent': 'NovaSonic/1.0' } }, upstream => {
|
https.get(target, { headers: { 'User-Agent': 'NovaSonic/1.0' } }, upstream => {
|
||||||
res.setHeader('Content-Type', 'application/json');
|
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) => {
|
||||||
|
const kind = req.params.kind === 'songs' ? 'songs' : 'albums';
|
||||||
|
const country = String(req.query.country || 'de').replace(/[^a-z]/gi, '').slice(0, 2).toLowerCase() || 'de';
|
||||||
|
const target = `https://rss.applemarketingtools.com/api/v2/${country}/music/most-played/50/${kind}.json`;
|
||||||
|
https.get(target, { headers: { 'User-Agent': 'NovaSonic/1.0' } }, upstream => {
|
||||||
|
res.setHeader('Content-Type', 'application/json');
|
||||||
|
res.status(upstream.statusCode || 200);
|
||||||
upstream.pipe(res);
|
upstream.pipe(res);
|
||||||
}).on('error', e => res.status(502).json({ error: e.message }));
|
}).on('error', e => res.status(502).json({ error: e.message }));
|
||||||
});
|
});
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
const CACHE = 'ns-v47';
|
const CACHE = 'ns-v48';
|
||||||
const PRECACHE = ['/', 'index.html', 'manifest.json', 'icon.svg'];
|
const PRECACHE = ['/', 'index.html', 'manifest.json', 'icon.svg'];
|
||||||
const SKIP_PATHS = ['/rest/stream', '/rest/download', '/config.js'];
|
const SKIP_PATHS = ['/rest/stream', '/rest/download', '/config.js'];
|
||||||
// Auth token params stripped from cache keys so artwork survives across sessions/token refreshes.
|
// Auth token params stripped from cache keys so artwork survives across sessions/token refreshes.
|
||||||
|
|||||||
+19
-10
@@ -272,14 +272,29 @@ export async function romajiConvert(texts) {
|
|||||||
function _itunesBase() {
|
function _itunesBase() {
|
||||||
return S.cfg.backendUrl ? `${S.cfg.backendUrl}/api/itunes` : 'https://itunes.apple.com';
|
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`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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".
|
||||||
|
let _itunesLastError = null;
|
||||||
|
export function itunesLastError() { return _itunesLastError; }
|
||||||
|
|
||||||
export async function itunesSearch(query, entity='musicTrack', limit=25) {
|
export async function itunesSearch(query, entity='musicTrack', limit=25) {
|
||||||
try {
|
try {
|
||||||
// US store only — one consistent naming source across search + download (matches the Android
|
// 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.
|
// 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 r = await fetch(`${_itunesBase()}/search?term=${encodeURIComponent(query)}&media=music&entity=${entity}&limit=${limit}&country=us&explicit=Yes`);
|
||||||
return JSON.parse(await r.text()).results || [];
|
if (!r.ok) { _itunesLastError = r.status; return []; }
|
||||||
} catch { return []; }
|
_itunesLastError = null;
|
||||||
|
const t = await r.text();
|
||||||
|
return t ? (JSON.parse(t).results || []) : [];
|
||||||
|
} catch { _itunesLastError = 'network'; return []; }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function itunesLookupAlbum(collectionId) {
|
export async function itunesLookupAlbum(collectionId) {
|
||||||
@@ -302,10 +317,7 @@ export async function itunesLookupArtistAlbums(artistId, limit=200) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function itunesTopCharts() {
|
export async function itunesTopCharts() {
|
||||||
const urls = [
|
const urls = [_chartsUrl('de', 'albums'), _chartsUrl('us', 'albums')];
|
||||||
'https://rss.applemarketingtools.com/api/v2/de/music/most-played/50/albums.json',
|
|
||||||
'https://rss.applemarketingtools.com/api/v2/us/music/most-played/50/albums.json',
|
|
||||||
];
|
|
||||||
for(const url of urls) {
|
for(const url of urls) {
|
||||||
try {
|
try {
|
||||||
const r = await fetch(url, { signal: AbortSignal.timeout(6000) });
|
const r = await fetch(url, { signal: AbortSignal.timeout(6000) });
|
||||||
@@ -325,10 +337,7 @@ export async function itunesTopCharts() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function itunesTopSongs() {
|
export async function itunesTopSongs() {
|
||||||
const urls = [
|
const urls = [_chartsUrl('de', 'songs'), _chartsUrl('us', 'songs')];
|
||||||
'https://rss.applemarketingtools.com/api/v2/de/music/most-played/50/songs.json',
|
|
||||||
'https://rss.applemarketingtools.com/api/v2/us/music/most-played/50/songs.json',
|
|
||||||
];
|
|
||||||
for(const url of urls) {
|
for(const url of urls) {
|
||||||
try {
|
try {
|
||||||
const r = await fetch(url, { signal: AbortSignal.timeout(6000) });
|
const r = await fetch(url, { signal: AbortSignal.timeout(6000) });
|
||||||
|
|||||||
+13
-1
@@ -1,6 +1,6 @@
|
|||||||
import { S } from '../state.js';
|
import { S } from '../state.js';
|
||||||
import { esc, toast } from '../utils.js';
|
import { esc, toast } from '../utils.js';
|
||||||
import { api, itunesSearch, itunesLookupAlbum, fetchRomajiIndex, dispArtistById } from '../api.js';
|
import { api, itunesSearch, itunesLastError, itunesLookupAlbum, fetchRomajiIndex, dispArtistById } from '../api.js';
|
||||||
import { makeCard, makeAlbumCard, makeGlobalTrackItem, makeSongItem, mkSection } from '../ui/components.js';
|
import { makeCard, makeAlbumCard, makeGlobalTrackItem, makeSongItem, mkSection } from '../ui/components.js';
|
||||||
import { playPreview } from '../player/player.js';
|
import { playPreview } from '../player/player.js';
|
||||||
import { openDlModal } from '../ui/ui.js';
|
import { openDlModal } from '../ui/ui.js';
|
||||||
@@ -175,8 +175,20 @@ export async function doGlobalSearch(q) {
|
|||||||
s.appendChild(list); el.appendChild(s);
|
s.appendChild(list); el.appendChild(s);
|
||||||
}
|
}
|
||||||
if(!artists.length && !albums.length && !trackResults.length) {
|
if(!artists.length && !albums.length && !trackResults.length) {
|
||||||
|
const err = itunesLastError();
|
||||||
|
if (err) {
|
||||||
|
// Distinguish "genuinely no results" from "Apple search is down" so the user knows where the
|
||||||
|
// problem is. Apple currently blocks the Search API (/search) with 403 from many server IPs.
|
||||||
|
const detail = err === 403
|
||||||
|
? 'Apple blockt gerade die Such-API (Fehler 403). Das liegt an Apple, nicht an dir — Downloads per Direktlink funktionieren weiter.'
|
||||||
|
: err === 'network'
|
||||||
|
? 'Keine Verbindung zur Apple-Such-API (Netzwerk/Backend). Läuft dein Backend?'
|
||||||
|
: `Apple-Suche nicht verfügbar (Fehler ${err}).`;
|
||||||
|
el.innerHTML = `<div class="empty-state"><div class="icon">⚠️</div><p>Apple-Suche gerade nicht verfügbar</p><p style="font-size:12px;color:var(--text3);max-width:420px;margin:8px auto 0">${detail}</p></div>`;
|
||||||
|
} else {
|
||||||
el.innerHTML = '<div class="empty-state"><div class="icon">🔍</div><p>Keine Ergebnisse gefunden</p></div>';
|
el.innerHTML = '<div class="empty-state"><div class="icon">🔍</div><p>Keine Ergebnisse gefunden</p></div>';
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch(e) { el.innerHTML = `<p style="color:var(--danger)">Suche fehlgeschlagen: ${e.message}</p>`; }
|
} catch(e) { el.innerHTML = `<p style="color:var(--danger)">Suche fehlgeschlagen: ${e.message}</p>`; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user