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:
HoneyandClaude Opus 4.8 committed 2026-07-30 20:32:08 +02:00
1 parent 9c8b4775ea
commit ccf704754a
4 files changed
+48 -13

No files matched your search

+19 -10
View File
@@ -272,14 +272,29 @@ export async function romajiConvert(texts) {
function _itunesBase() {
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) {
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`);
return JSON.parse(await r.text()).results || [];
} catch { return []; }
if (!r.ok) { _itunesLastError = r.status; return []; }
_itunesLastError = null;
const t = await r.text();
return t ? (JSON.parse(t).results || []) : [];
} catch { _itunesLastError = 'network'; return []; }
}
export async function itunesLookupAlbum(collectionId) {
@@ -302,10 +317,7 @@ export async function itunesLookupArtistAlbums(artistId, limit=200) {
}
export async function itunesTopCharts() {
const urls = [
'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',
];
const urls = [_chartsUrl('de', 'albums'), _chartsUrl('us', 'albums')];
for(const url of urls) {
try {
const r = await fetch(url, { signal: AbortSignal.timeout(6000) });
@@ -325,10 +337,7 @@ export async function itunesTopCharts() {
}
export async function itunesTopSongs() {
const urls = [
'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',
];
const urls = [_chartsUrl('de', 'songs'), _chartsUrl('us', 'songs')];
for(const url of urls) {
try {
const r = await fetch(url, { signal: AbortSignal.timeout(6000) });