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:
1 parent
6f419d24ff
commit
487dbf12b4
1 file changed
+82
-68
+82
-68
@@ -388,42 +388,66 @@ function enqueue(task) {
|
||||
}
|
||||
|
||||
// ─── SPOTIFY ──────────────────────────────────────────────────────────────────
|
||||
let _spTok = null, _spTokExp = 0;
|
||||
|
||||
// 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;
|
||||
}
|
||||
// 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).
|
||||
|
||||
function parseSpotifyUrl(url) {
|
||||
// Handles locale prefixes: open.spotify.com/intl-de/track/... or open.spotify.com/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]+)/);
|
||||
// Handles locale prefixes: /intl-de/track/... and plain /track/...
|
||||
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/...');
|
||||
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 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
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' });
|
||||
try {
|
||||
const { type, id } = parseSpotifyUrl(url);
|
||||
const tok = await getSpotifyToken();
|
||||
const spH = { Authorization: `Bearer ${tok}` };
|
||||
const spFetch = (u) => fetch(u, { headers: spH, signal: AbortSignal.timeout(10000) });
|
||||
const entity = await fetchSpotifyEmbed(type, id);
|
||||
const artist = _spArtistName(entity.artists);
|
||||
const artUrl = _spArtwork(entity);
|
||||
|
||||
if (type === 'track') {
|
||||
const r = await spFetch(`https://api.spotify.com/v1/tracks/${id}`);
|
||||
if (!r.ok) throw new Error(`Spotify API ${r.status}`);
|
||||
const d = await r.json();
|
||||
return res.json({ type: 'track', name: d.name, artist: d.artists?.[0]?.name || '', artworkUrl: d.album?.images?.[0]?.url || '',
|
||||
tracks: [{ trackName: d.name, artistName: d.artists?.[0]?.name || '', collectionName: d.album?.name || '',
|
||||
artworkUrl100: d.album?.images?.[0]?.url || '', releaseDate: d.album?.release_date || '',
|
||||
trackNumber: d.track_number, trackExplicitness: d.explicit ? 'explicit' : 'notExplicit', previewUrl: d.preview_url || null }] });
|
||||
return res.json({
|
||||
type: 'track',
|
||||
name: entity.name,
|
||||
artist,
|
||||
artworkUrl: artUrl,
|
||||
tracks: [_spTrackItem(entity, entity.album?.name, artUrl, artist)],
|
||||
});
|
||||
}
|
||||
|
||||
if (type === 'album') {
|
||||
const r = await spFetch(`https://api.spotify.com/v1/albums/${id}`);
|
||||
if (!r.ok) throw new Error(`Spotify API ${r.status}`);
|
||||
const d = await r.json();
|
||||
const art = d.images?.[0]?.url || '';
|
||||
return res.json({ type: 'album', name: d.name, artist: d.artists?.[0]?.name || '', artworkUrl: art,
|
||||
tracks: (d.tracks?.items || []).map(t => ({ trackName: t.name, artistName: t.artists?.[0]?.name || d.artists?.[0]?.name || '',
|
||||
collectionName: d.name, artworkUrl100: art, releaseDate: d.release_date || '',
|
||||
trackNumber: t.track_number, trackExplicitness: t.explicit ? 'explicit' : 'notExplicit', previewUrl: t.preview_url || null })) });
|
||||
const trackList = entity.trackList || entity.tracks?.items || [];
|
||||
return res.json({
|
||||
type: 'album',
|
||||
name: entity.name,
|
||||
artist,
|
||||
artworkUrl: artUrl,
|
||||
tracks: trackList.map(t => _spTrackItem(t, entity.name, artUrl, artist)),
|
||||
});
|
||||
}
|
||||
|
||||
if (type === 'playlist') {
|
||||
const r = await spFetch(`https://api.spotify.com/v1/playlists/${id}?fields=name,description,images,owner,tracks.total`);
|
||||
if (!r.ok) throw new Error(`Spotify API ${r.status}`);
|
||||
const pl = await r.json();
|
||||
const tracks = [];
|
||||
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))`;
|
||||
while (nextUrl) {
|
||||
const tr = await spFetch(nextUrl);
|
||||
if (!tr.ok) break;
|
||||
const td = await tr.json();
|
||||
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 });
|
||||
const trackList = entity.trackList || [];
|
||||
const owner = entity.ownerV2?.data?.name || entity.owner?.displayName || entity.owner?.display_name || '';
|
||||
return res.json({
|
||||
type: 'playlist',
|
||||
name: entity.name,
|
||||
artist: owner,
|
||||
artworkUrl: artUrl,
|
||||
description: entity.description || '',
|
||||
tracks: trackList.map(t => _spTrackItem(t, null, null, null)),
|
||||
});
|
||||
}
|
||||
|
||||
res.status(400).json({ error: `Unbekannter Typ: ${type}` });
|
||||
|
||||
Reference in new issue
Block a user