feat(backend): switch Spotify resolve to embed-page scraping

Removes the Spotify Web API token approach (blocked on non-Premium
accounts and from datacenter IPs). Now fetches open.spotify.com/embed
pages and parses __NEXT_DATA__ JSON — works without any auth from any IP.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Sonnet 4.6 committed 2026-04-27 01:47:06 +02:00
1 parent 6f419d24ff
commit 487dbf12b4
1 file changed
+82 -68
+82 -68
View File
@@ -388,42 +388,66 @@ function enqueue(task) {
} }
// ─── SPOTIFY ────────────────────────────────────────────────────────────────── // ─── SPOTIFY ──────────────────────────────────────────────────────────────────
let _spTok = null, _spTokExp = 0; // Scrapes Spotify's public embed pages — no token, no account, no Premium needed.
// Embed pages serve __NEXT_DATA__ JSON to anyone (used for website embeds worldwide).
// Uses Spotify's public web-player token — no account or Premium required
async function getSpotifyToken() {
if (_spTok && Date.now() < _spTokExp - 60000) return _spTok;
const r = await fetch(
'https://open.spotify.com/get_access_token?reason=transport&productType=web_player',
{
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
Accept: 'application/json',
'Accept-Language': 'de-DE,de;q=0.9,en;q=0.8',
Cookie: 'sp_t=1',
Referer: 'https://open.spotify.com/',
'sec-fetch-dest': 'empty',
'sec-fetch-mode': 'cors',
'sec-fetch-site': 'same-origin',
},
signal: AbortSignal.timeout(8000),
}
);
if (!r.ok) throw new Error(`Spotify token fehlgeschlagen (${r.status}) — Server-IP möglicherweise von Spotify geblockt`);
const d = await r.json();
if (!d.accessToken) throw new Error('Kein Spotify-Token erhalten');
_spTok = d.accessToken;
_spTokExp = d.accessTokenExpirationTimestampMs || (Date.now() + 3600000);
return _spTok;
}
function parseSpotifyUrl(url) { function parseSpotifyUrl(url) {
// Handles locale prefixes: open.spotify.com/intl-de/track/... or open.spotify.com/track/... // Handles locale prefixes: /intl-de/track/... and plain /track/...
const m = url.match(/open\.spotify\.com\/(?:[a-z]{2,5}-[a-z]{2,5}\/)?(?:intl-[a-z-]+\/)?(track|album|playlist)\/([A-Za-z0-9]+)/); const m = url.match(/open\.spotify\.com\/(?:[a-z]{2,8}-[a-z]{2,8}\/)?(?:intl-[a-z-]+\/)?(track|album|playlist)\/([A-Za-z0-9]+)/);
if (!m) throw new Error('Ungültige Spotify-URL — erwartet: open.spotify.com/track|album|playlist/...'); if (!m) throw new Error('Ungültige Spotify-URL — erwartet: open.spotify.com/track|album|playlist/...');
return { type: m[1], id: m[2] }; return { type: m[1], id: m[2] };
} }
async function fetchSpotifyEmbed(type, id) {
const r = await fetch(`https://open.spotify.com/embed/${type}/${id}`, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
Accept: 'text/html,application/xhtml+xml',
'Accept-Language': 'en-US,en;q=0.9',
},
signal: AbortSignal.timeout(12000),
});
if (!r.ok) throw new Error(`Spotify-Embed nicht erreichbar (${r.status})`);
const html = await r.text();
const m = html.match(/<script id="__NEXT_DATA__" type="application\/json">(.+?)<\/script>/s);
if (!m) throw new Error('Spotify-Seite nicht parsebar — Struktur geändert?');
const nd = JSON.parse(m[1]);
const entity = nd?.props?.pageProps?.state?.data?.entity
|| nd?.props?.pageProps?.data?.entity
|| nd?.props?.pageProps?.entity;
if (!entity) throw new Error('Keine Metadaten in Spotify-Embed gefunden');
return entity;
}
function _spArtwork(entity) {
const sources = entity?.album?.coverArt?.sources || entity?.coverArt?.sources || [];
if (sources.length) return sources.sort((a, b) => (b.width || 0) - (a.width || 0))[0].url;
return entity?.album?.images?.[0]?.url || entity?.images?.[0]?.url || '';
}
function _spArtistName(artistsArr) {
const a = artistsArr?.[0];
return a?.profile?.name || a?.name || '';
}
function _spTrackItem(t, defaultAlbum, defaultArt, defaultAlbumArtist) {
const track = t?.track || t;
const artist = _spArtistName(track?.artists) || defaultAlbumArtist || '';
const art = _spArtwork(track) || defaultArt || '';
const album = track?.album?.name || defaultAlbum || '';
const date = track?.album?.date?.isoString || track?.album?.release_date || '';
return {
trackName: track?.name || '',
artistName: artist,
collectionName: album,
artworkUrl100: art,
releaseDate: date,
trackNumber: track?.trackNumber || track?.track_number,
trackExplicitness: track?.contentRating?.label === 'EXPLICIT' || track?.explicit ? 'explicit' : 'notExplicit',
previewUrl: track?.previewUrl || track?.preview_url || null,
};
}
// ─── ROUTES ────────────────────────────────────────────────────────────────── // ─── ROUTES ──────────────────────────────────────────────────────────────────
app.get('/api/health', (req, res) => { app.get('/api/health', (req, res) => {
@@ -435,52 +459,42 @@ app.post('/api/spotify/resolve', async (req, res) => {
if (!url) return res.status(400).json({ error: 'url required' }); if (!url) return res.status(400).json({ error: 'url required' });
try { try {
const { type, id } = parseSpotifyUrl(url); const { type, id } = parseSpotifyUrl(url);
const tok = await getSpotifyToken(); const entity = await fetchSpotifyEmbed(type, id);
const spH = { Authorization: `Bearer ${tok}` }; const artist = _spArtistName(entity.artists);
const spFetch = (u) => fetch(u, { headers: spH, signal: AbortSignal.timeout(10000) }); const artUrl = _spArtwork(entity);
if (type === 'track') { if (type === 'track') {
const r = await spFetch(`https://api.spotify.com/v1/tracks/${id}`); return res.json({
if (!r.ok) throw new Error(`Spotify API ${r.status}`); type: 'track',
const d = await r.json(); name: entity.name,
return res.json({ type: 'track', name: d.name, artist: d.artists?.[0]?.name || '', artworkUrl: d.album?.images?.[0]?.url || '', artist,
tracks: [{ trackName: d.name, artistName: d.artists?.[0]?.name || '', collectionName: d.album?.name || '', artworkUrl: artUrl,
artworkUrl100: d.album?.images?.[0]?.url || '', releaseDate: d.album?.release_date || '', tracks: [_spTrackItem(entity, entity.album?.name, artUrl, artist)],
trackNumber: d.track_number, trackExplicitness: d.explicit ? 'explicit' : 'notExplicit', previewUrl: d.preview_url || null }] }); });
} }
if (type === 'album') { if (type === 'album') {
const r = await spFetch(`https://api.spotify.com/v1/albums/${id}`); const trackList = entity.trackList || entity.tracks?.items || [];
if (!r.ok) throw new Error(`Spotify API ${r.status}`); return res.json({
const d = await r.json(); type: 'album',
const art = d.images?.[0]?.url || ''; name: entity.name,
return res.json({ type: 'album', name: d.name, artist: d.artists?.[0]?.name || '', artworkUrl: art, artist,
tracks: (d.tracks?.items || []).map(t => ({ trackName: t.name, artistName: t.artists?.[0]?.name || d.artists?.[0]?.name || '', artworkUrl: artUrl,
collectionName: d.name, artworkUrl100: art, releaseDate: d.release_date || '', tracks: trackList.map(t => _spTrackItem(t, entity.name, artUrl, artist)),
trackNumber: t.track_number, trackExplicitness: t.explicit ? 'explicit' : 'notExplicit', previewUrl: t.preview_url || null })) }); });
} }
if (type === 'playlist') { if (type === 'playlist') {
const r = await spFetch(`https://api.spotify.com/v1/playlists/${id}?fields=name,description,images,owner,tracks.total`); const trackList = entity.trackList || [];
if (!r.ok) throw new Error(`Spotify API ${r.status}`); const owner = entity.ownerV2?.data?.name || entity.owner?.displayName || entity.owner?.display_name || '';
const pl = await r.json(); return res.json({
const tracks = []; type: 'playlist',
let nextUrl = `https://api.spotify.com/v1/playlists/${id}/tracks?limit=100&fields=next,items(is_local,track(name,artists,album,track_number,explicit,preview_url))`; name: entity.name,
while (nextUrl) { artist: owner,
const tr = await spFetch(nextUrl); artworkUrl: artUrl,
if (!tr.ok) break; description: entity.description || '',
const td = await tr.json(); tracks: trackList.map(t => _spTrackItem(t, null, null, null)),
for (const item of (td.items || [])) { });
if (!item?.track || item.is_local) continue;
tracks.push({ trackName: item.track.name, artistName: item.track.artists?.[0]?.name || '',
collectionName: item.track.album?.name || '', artworkUrl100: item.track.album?.images?.[0]?.url || '',
releaseDate: item.track.album?.release_date || '', trackNumber: item.track.track_number,
trackExplicitness: item.track.explicit ? 'explicit' : 'notExplicit', previewUrl: item.track.preview_url || null });
}
nextUrl = td.next || null;
}
return res.json({ type: 'playlist', name: pl.name, artist: pl.owner?.display_name || '',
artworkUrl: pl.images?.[0]?.url || '', description: pl.description || '', tracks });
} }
res.status(400).json({ error: `Unbekannter Typ: ${type}` }); res.status(400).json({ error: `Unbekannter Typ: ${type}` });