fix: replace SpotipyFree with curl_cffi, remove dead Node.js TOTP code
SpotipyFree crashes with KeyError: 'ownerV2' — Spotify changed their internal API response format and the library is out of date. curl_cffi presents a real Chrome TLS fingerprint so open.spotify.com/api/ server-time responds (plain Node.js TLS is filtered by Spotify's WAF). Node.js TOTP fallback always failed on this for the same reason — removed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
750089fc8e
commit
5265aa1f33
2 files changed
+105
-342
No files matched your search
@@ -67,254 +67,6 @@ function parseSpotifyUrl(url) {
|
||||
return { type: m[1], id: m[2] };
|
||||
}
|
||||
|
||||
// undici (native fetch) cannot abort a pending TCP connect even with AbortController.
|
||||
// Use Node's https module with socket.setTimeout() — fires at OS level during connect.
|
||||
function _fetchTimeout(url, opts = {}, ms = 10000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const u = new URL(url);
|
||||
const label = u.hostname + u.pathname.slice(0, 40);
|
||||
const done = (fn) => { if (!done.called) { done.called = true; fn(); } };
|
||||
const req = https.request({
|
||||
hostname: u.hostname, port: 443,
|
||||
path: u.pathname + u.search,
|
||||
method: opts.method || 'GET',
|
||||
// Force HTTP/1.1 — api.spotify.com negotiates h2 via ALPN but
|
||||
// https.request uses HTTP/1.1 framing, causing a silent hang.
|
||||
ALPNProtocols: ['http/1.1'],
|
||||
headers: {
|
||||
'User-Agent': 'NovaSonic/1.0',
|
||||
'Accept': 'application/json',
|
||||
...opts.headers,
|
||||
},
|
||||
}, res => {
|
||||
console.log(`[sp-http] ${res.statusCode} ${label}`);
|
||||
const chunks = [];
|
||||
res.on('data', c => chunks.push(c));
|
||||
res.on('end', () => {
|
||||
const body = Buffer.concat(chunks).toString('utf8');
|
||||
done(() => resolve({
|
||||
ok: res.statusCode >= 200 && res.statusCode < 300,
|
||||
status: res.statusCode,
|
||||
headers: { get: h => res.headers[h.toLowerCase()] ?? null },
|
||||
json: () => { try { return Promise.resolve(JSON.parse(body)); } catch(e) { return Promise.reject(e); } },
|
||||
text: () => Promise.resolve(body),
|
||||
}));
|
||||
});
|
||||
res.on('error', e => done(() => reject(e)));
|
||||
});
|
||||
// Two-layer timeout: req-level (fires after ms of total inactivity) + socket-level
|
||||
req.setTimeout(ms, () => req.destroy(new Error(`Spotify timeout ${ms}ms: ${label}`)));
|
||||
req.on('socket', s => s.setTimeout(ms, () => req.destroy(new Error(`Spotify socket timeout ${ms}ms: ${label}`))));
|
||||
req.on('error', e => done(() => reject(e)));
|
||||
if (opts.body) req.write(opts.body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
let _spTokenCache = null;
|
||||
let _spTokenExpiry = 0;
|
||||
let _spSecretCache = null;
|
||||
let _spSecretExp = 0;
|
||||
|
||||
// Dynamically extract Spotify's TOTP secret from their web-player JS bundle.
|
||||
async function _fetchSpotifySecret() {
|
||||
if (_spSecretCache && Date.now() < _spSecretExp) return _spSecretCache;
|
||||
|
||||
const htmlRes = await _fetchTimeout('https://open.spotify.com/', {
|
||||
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,*/*;q=0.8',
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
'Accept-Encoding': 'identity',
|
||||
},
|
||||
}, 20000);
|
||||
if (!htmlRes.ok) throw new Error(`open.spotify.com ${htmlRes.status}`);
|
||||
const html = await htmlRes.text();
|
||||
|
||||
// Collect JS bundle URLs referenced by the main page
|
||||
const seen = new Set();
|
||||
const jsUrls = [];
|
||||
const srcRe = /src="((?:https?:\/\/[^"]*|\/[^"]*?)\.js(?:\?[^"]*)?)"/g;
|
||||
let m;
|
||||
while ((m = srcRe.exec(html)) !== null) {
|
||||
const url = m[1].startsWith('http') ? m[1] : 'https://open.spotify.com' + m[1];
|
||||
if (!seen.has(url)) { seen.add(url); jsUrls.push(url); }
|
||||
}
|
||||
|
||||
// Search each bundle for {secret:"...",version:N}
|
||||
for (const url of jsUrls.slice(0, 15)) {
|
||||
try {
|
||||
const r = await _fetchTimeout(url, {
|
||||
headers: { 'User-Agent': 'Mozilla/5.0', 'Accept': '*/*', 'Accept-Encoding': 'identity' },
|
||||
}, 8000);
|
||||
if (!r.ok) continue;
|
||||
const js = await r.text();
|
||||
const sm = js.match(/\{secret:\s*["']([^"']+)["']\s*,\s*version:\s*(\d+)\s*\}/);
|
||||
if (sm) {
|
||||
const secret = [...sm[1]].map(c => c.charCodeAt(0));
|
||||
const version = parseInt(sm[2], 10);
|
||||
_spSecretCache = { secret, version };
|
||||
_spSecretExp = Date.now() + 60 * 60 * 1000; // cache 1h
|
||||
console.log(`[spotify] TOTP secret v${version} found`);
|
||||
return _spSecretCache;
|
||||
}
|
||||
} catch { /* try next bundle */ }
|
||||
}
|
||||
throw new Error('Spotify TOTP-Secret nicht gefunden');
|
||||
}
|
||||
|
||||
function _generateTotp(secretArray, serverTimestamp) {
|
||||
const crypto = require('crypto');
|
||||
// XOR transform each byte with ((index % 33) + 9)
|
||||
const transformed = secretArray.map((b, i) => b ^ ((i % 33) + 9));
|
||||
const secretBytes = Buffer.from(Buffer.from(transformed.join(''), 'utf8').toString('hex'), 'hex');
|
||||
const counter = Math.floor(serverTimestamp / 30);
|
||||
const counterBuf = Buffer.alloc(8);
|
||||
counterBuf.writeBigUInt64BE(BigInt(counter));
|
||||
const digest = crypto.createHmac('sha1', secretBytes).update(counterBuf).digest();
|
||||
const offset = digest[digest.length - 1] & 0xf;
|
||||
const code = (
|
||||
((digest[offset] & 0x7f) << 24) |
|
||||
((digest[offset + 1] & 0xff) << 16) |
|
||||
((digest[offset + 2] & 0xff) << 8) |
|
||||
(digest[offset + 3] & 0xff)
|
||||
) % 1_000_000;
|
||||
return code.toString().padStart(6, '0');
|
||||
}
|
||||
|
||||
async function fetchSpotifyToken() {
|
||||
if (_spTokenCache && Date.now() < _spTokenExpiry) return _spTokenCache;
|
||||
|
||||
// Approach 1: TOTP-based anonymous token — no developer credentials needed,
|
||||
// uses open.spotify.com/api/token with a computed 6-digit code.
|
||||
try {
|
||||
const { secret, version } = await _fetchSpotifySecret();
|
||||
|
||||
const stRes = await _fetchTimeout('https://open.spotify.com/api/server-time', {
|
||||
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',
|
||||
'Referer': 'https://open.spotify.com/',
|
||||
},
|
||||
});
|
||||
if (!stRes.ok) throw new Error(`server-time ${stRes.status}`);
|
||||
const { serverTime } = await stRes.json();
|
||||
|
||||
const totp = _generateTotp(secret, serverTime);
|
||||
const tRes = await _fetchTimeout(
|
||||
`https://open.spotify.com/api/token?reason=init&productType=web-player&totp=${totp}&totpVer=${version}&totpServer=${totp}`,
|
||||
{
|
||||
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',
|
||||
'Referer': 'https://open.spotify.com/',
|
||||
},
|
||||
}
|
||||
);
|
||||
if (!tRes.ok) throw new Error(`api/token ${tRes.status}`);
|
||||
const t = await tRes.json();
|
||||
if (!t.accessToken) throw new Error('no accessToken in response');
|
||||
|
||||
_spTokenCache = t.accessToken;
|
||||
_spTokenExpiry = (t.accessTokenExpirationTimestampMs || (Date.now() + 55 * 60 * 1000)) - 30000;
|
||||
console.log('[spotify] token ok (TOTP)');
|
||||
return _spTokenCache;
|
||||
} catch (e) {
|
||||
console.log('[spotify] TOTP failed:', e.message);
|
||||
_spSecretCache = null; // force secret re-extraction next time
|
||||
}
|
||||
|
||||
throw new Error('Spotify-Token nicht abrufbar (TOTP gescheitert)');
|
||||
}
|
||||
|
||||
async function _spApi(path, token) {
|
||||
const url = path.startsWith('http') ? path : `https://api.spotify.com/v1${path}`;
|
||||
const r = await _fetchTimeout(url, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Origin': 'https://open.spotify.com',
|
||||
'Referer': 'https://open.spotify.com/',
|
||||
'app-platform': 'WebPlayer',
|
||||
'spotify-app-version': '1.2.46.25.g7f189073',
|
||||
'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': 'en-US,en;q=0.9',
|
||||
},
|
||||
});
|
||||
if (r.status === 429) {
|
||||
const retryAfter = parseInt(r.headers.get('Retry-After') || '10', 10);
|
||||
_spTokenCache = null;
|
||||
_spTokenExpiry = 0;
|
||||
console.log(`[spotify] 429 Retry-After=${retryAfter}s — failing fast`);
|
||||
throw new Error(`RATE_LIMITED:${retryAfter}`);
|
||||
}
|
||||
if (!r.ok) throw new Error(`Spotify API Fehler ${r.status}: ${path}`);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
async function _spAllPlaylistTracks(id, token) {
|
||||
const tracks = [];
|
||||
let skippedLocal = 0;
|
||||
let path = `/playlists/${id}/tracks?limit=100`;
|
||||
let pages = 0;
|
||||
while (path && pages++ < 100) {
|
||||
const page = await _spApi(path, token);
|
||||
for (const item of page.items || []) {
|
||||
const t = item.track;
|
||||
if (!t || !t.id) continue;
|
||||
if (t.is_local) { skippedLocal++; continue; }
|
||||
tracks.push(t);
|
||||
}
|
||||
path = page.next || null;
|
||||
if (path) await new Promise(r => setTimeout(r, 300)); // pace multi-page requests
|
||||
}
|
||||
return { tracks, skippedLocal };
|
||||
}
|
||||
|
||||
async function _spAllAlbumTracks(id, token) {
|
||||
const tracks = [];
|
||||
let path = `/albums/${id}/tracks?limit=50`;
|
||||
let pages = 0;
|
||||
while (path && pages++ < 100) {
|
||||
const page = await _spApi(path, token);
|
||||
tracks.push(...(page.items || []).filter(t => t?.id));
|
||||
path = page.next || null;
|
||||
}
|
||||
return tracks;
|
||||
}
|
||||
|
||||
function _spArtwork(entity) {
|
||||
// Real API: entity.images[] or entity.album.images[]
|
||||
const imgs = entity?.images || entity?.album?.images || [];
|
||||
if (imgs.length) return imgs.sort((a, b) => (b.width || 0) - (a.width || 0))[0]?.url || '';
|
||||
return '';
|
||||
}
|
||||
|
||||
function _spAllArtists(artistsRaw) {
|
||||
if (!artistsRaw) return '';
|
||||
const arr = Array.isArray(artistsRaw) ? artistsRaw : (artistsRaw.items || []);
|
||||
return arr.map(a => a?.profile?.name || a?.name || '').filter(Boolean).join(', ');
|
||||
}
|
||||
|
||||
function _spTrackItem(t, defaultAlbum, defaultArt, defaultAlbumArtist) {
|
||||
const trackName = t?.name || '';
|
||||
const artist = _spAllArtists(t?.artists) || defaultAlbumArtist || '';
|
||||
const art = _spArtwork(t) || defaultArt || '';
|
||||
const albumObj = t?.album || {};
|
||||
const album = albumObj?.name || defaultAlbum || '';
|
||||
const date = albumObj?.release_date || '';
|
||||
return {
|
||||
trackName,
|
||||
artistName: artist,
|
||||
collectionName: album,
|
||||
artworkUrl100: art,
|
||||
releaseDate: date,
|
||||
trackNumber: t?.track_number,
|
||||
trackExplicitness: t?.explicit ? 'explicit' : 'notExplicit',
|
||||
previewUrl: t?.preview_url || null,
|
||||
durationMs: t?.duration_ms || null,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── ROUTES ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -373,46 +125,7 @@ async function _resolveSpotifyViaSpotdl(url) {
|
||||
}
|
||||
|
||||
async function _resolveSpotify(type, id, originalUrl) {
|
||||
// Primary: SpotipyFree (SpotAPI + tls_client Chrome fingerprint, Spotify internal endpoints)
|
||||
try {
|
||||
console.log('[spotify] trying spotdl resolver');
|
||||
return await _resolveSpotifyViaSpotdl(originalUrl);
|
||||
} catch (e) {
|
||||
if (e.message.startsWith('RATE_LIMITED:')) throw e; // propagate, don't retry via TOTP
|
||||
console.log('[spotify] spotdl failed:', e.message, '— falling back to TOTP');
|
||||
}
|
||||
|
||||
// Fallback: TOTP anonymous token
|
||||
const token = await fetchSpotifyToken();
|
||||
|
||||
if (type === 'track') {
|
||||
const t = await _spApi(`/tracks/${id}`, token);
|
||||
const artist = _spAllArtists(t.artists);
|
||||
const artUrl = _spArtwork(t);
|
||||
return { type: 'track', name: t.name || '', artist, artworkUrl: artUrl,
|
||||
tracks: [_spTrackItem(t, t.album?.name, artUrl, artist)] };
|
||||
}
|
||||
|
||||
if (type === 'album') {
|
||||
const album = await _spApi(`/albums/${id}`, token);
|
||||
const artist = _spAllArtists(album.artists);
|
||||
const artUrl = _spArtwork(album);
|
||||
const trackItems = await _spAllAlbumTracks(id, token);
|
||||
return { type: 'album', name: album.name || '', artist, artworkUrl: artUrl,
|
||||
tracks: trackItems.map(t => _spTrackItem(t, album.name, artUrl, artist)) };
|
||||
}
|
||||
|
||||
if (type === 'playlist') {
|
||||
const pl = await _spApi(`/playlists/${id}?fields=name,description,owner,images`, token);
|
||||
const owner = pl.owner?.display_name || '';
|
||||
const artUrl = _spArtwork(pl);
|
||||
const { tracks: trackItems, skippedLocal } = await _spAllPlaylistTracks(id, token);
|
||||
return { type: 'playlist', name: pl.name || '', artist: owner, artworkUrl: artUrl,
|
||||
description: pl.description || '', skippedLocal,
|
||||
tracks: trackItems.map(t => _spTrackItem(t, null, null, null)) };
|
||||
}
|
||||
|
||||
throw new Error(`Unbekannter Typ: ${type}`);
|
||||
}
|
||||
|
||||
app.post('/api/spotify/resolve', async (req, res) => {
|
||||
|
||||
+105
-55
@@ -1,84 +1,135 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Resolves Spotify URLs via SpotipyFree (SpotAPI + tls_client Chrome fingerprint,
|
||||
hits Spotify's internal spclient endpoints — not the rate-limited public REST API).
|
||||
Resolves Spotify URLs via curl_cffi Chrome TLS impersonation + TOTP anonymous token.
|
||||
curl_cffi presents a real Chrome TLS fingerprint, which gets past Spotify's bot filters
|
||||
on open.spotify.com/api/server-time (plain Node.js/Python TLS does not).
|
||||
"""
|
||||
import sys, json, re, time
|
||||
import sys, json, re, hmac, hashlib, struct, time
|
||||
|
||||
def eprint(*a): print(*a, file=sys.stderr)
|
||||
|
||||
# ─── APPROACH 1: SpotipyFree ─────────────────────────────────────────────────
|
||||
def _totp(secret_str, server_ts):
|
||||
secret_bytes = [ord(c) for c in secret_str]
|
||||
transformed = [b ^ ((i % 33) + 9) for i, b in enumerate(secret_bytes)]
|
||||
key = bytes.fromhex(''.join(str(b) for b in transformed).encode('utf-8').hex())
|
||||
counter_b = struct.pack('>Q', server_ts // 30)
|
||||
digest = hmac.new(key, counter_b, hashlib.sha1).digest()
|
||||
offset = digest[-1] & 0xf
|
||||
code = ((digest[offset] & 0x7f) << 24 | (digest[offset+1] & 0xff) << 16 |
|
||||
(digest[offset+2] & 0xff) << 8 | (digest[offset+3] & 0xff)) % 1_000_000
|
||||
return str(code).zfill(6)
|
||||
|
||||
def _fmt_spotipy_track(t, default_album='', default_art='', default_artist=''):
|
||||
def _get_token(session):
|
||||
html = session.get('https://open.spotify.com/', headers={
|
||||
'Accept': 'text/html,*/*;q=0.8', 'Accept-Language': 'en-US,en;q=0.9',
|
||||
'Accept-Encoding': 'identity',
|
||||
}, timeout=20).text
|
||||
js_urls, seen = [], set()
|
||||
for m in re.finditer(r'src="((?:https?://[^"]*|/[^"]*?)\.js(?:\?[^"]*)?)"', html):
|
||||
u = m.group(1)
|
||||
if not u.startswith('http'): u = 'https://open.spotify.com' + u
|
||||
if u not in seen: seen.add(u); js_urls.append(u)
|
||||
secret = version = None
|
||||
for u in js_urls[:20]:
|
||||
if secret: break
|
||||
try:
|
||||
js = session.get(u, headers={'Accept':'*/*','Accept-Encoding':'identity'}, timeout=8).text
|
||||
m = re.search(r'\{secret:\s*["\']([^"\']+)["\']\s*,\s*version:\s*(\d+)\s*\}', js)
|
||||
if m: secret, version = m.group(1), int(m.group(2))
|
||||
except: pass
|
||||
if not secret: raise Exception('TOTP secret not found')
|
||||
eprint(f'[TOTP] secret v{version} found')
|
||||
st = session.get('https://open.spotify.com/api/server-time', headers={
|
||||
'Accept': 'application/json', 'Referer': 'https://open.spotify.com/'}, timeout=10).json()
|
||||
totp = _totp(secret, st['serverTime'])
|
||||
tok = session.get(
|
||||
f'https://open.spotify.com/api/token?reason=init&productType=web-player&totp={totp}&totpVer={version}&totpServer={totp}',
|
||||
headers={'Accept': 'application/json', 'Referer': 'https://open.spotify.com/'}, timeout=10).json()
|
||||
if 'accessToken' not in tok: raise Exception(f'No accessToken: {json.dumps(tok)[:100]}')
|
||||
eprint('[TOTP] token ok')
|
||||
return tok['accessToken']
|
||||
|
||||
def _auth_h(token):
|
||||
return {'Authorization': f'Bearer {token}', 'app-platform': 'WebPlayer',
|
||||
'spotify-app-version': '1.2.46.25.g7f189073', 'Origin': 'https://open.spotify.com',
|
||||
'Referer': 'https://open.spotify.com/', 'Accept': 'application/json',
|
||||
'Accept-Language': 'en-US,en;q=0.9'}
|
||||
|
||||
def _rest(session, path, token):
|
||||
url = path if path.startswith('http') else f'https://api.spotify.com/v1{path}'
|
||||
r = session.get(url, headers=_auth_h(token), timeout=15)
|
||||
if r.status_code == 429:
|
||||
retry = int(r.headers.get('Retry-After', '30'))
|
||||
eprint(f'[REST] 429 Retry-After={retry}s')
|
||||
print(json.dumps({'error': 'RATE_LIMITED', 'retryAfter': retry}))
|
||||
sys.exit(1)
|
||||
if not r.ok: raise Exception(f'REST {r.status_code}: {path}')
|
||||
return r.json()
|
||||
|
||||
def _fmt_track(t, default_album='', default_art='', default_artist=''):
|
||||
if not t or not t.get('name'): return None
|
||||
artists = ', '.join(a.get('name','') for a in (t.get('artists') or [])) or default_artist
|
||||
artists = ', '.join(a.get('name', '') for a in (t.get('artists') or [])) or default_artist
|
||||
alb = t.get('album') or {}
|
||||
imgs = alb.get('images') or []
|
||||
art = sorted(imgs, key=lambda x: -(x.get('width') or 0))[0].get('url','') if imgs else default_art
|
||||
art = sorted(imgs, key=lambda x: -(x.get('width') or 0))[0].get('url', '') if imgs else default_art
|
||||
return {
|
||||
'trackName': t.get('name',''),
|
||||
'trackName': t.get('name', ''),
|
||||
'artistName': artists,
|
||||
'collectionName': alb.get('name','') or default_album,
|
||||
'collectionName': alb.get('name', '') or default_album,
|
||||
'artworkUrl100': art,
|
||||
'releaseDate': alb.get('release_date',''),
|
||||
'releaseDate': alb.get('release_date', ''),
|
||||
'trackNumber': t.get('track_number'),
|
||||
'trackExplicitness': 'explicit' if t.get('explicit') else 'notExplicit',
|
||||
'durationMs': t.get('duration_ms'),
|
||||
}
|
||||
|
||||
def resolve_via_spotipyfree(sp_type, sp_id):
|
||||
from SpotipyFree import Spotify as Sp
|
||||
sp = Sp()
|
||||
eprint('[SpotipyFree] session created')
|
||||
def _best_art(imgs):
|
||||
return sorted(imgs, key=lambda x: -(x.get('width') or 0))[0].get('url', '') if imgs else ''
|
||||
|
||||
def resolve(sp_type, sp_id):
|
||||
from curl_cffi import requests as cf
|
||||
session = cf.Session(impersonate='chrome124')
|
||||
token = _get_token(session)
|
||||
|
||||
if sp_type == 'track':
|
||||
t = sp.track(sp_id)
|
||||
t = _rest(session, f'/tracks/{sp_id}', token)
|
||||
artist = ', '.join(a['name'] for a in (t.get('artists') or []))
|
||||
imgs = (t.get('album') or {}).get('images') or []
|
||||
art = sorted(imgs, key=lambda x: -(x.get('width') or 0))[0].get('url','') if imgs else ''
|
||||
tr = _fmt_spotipy_track(t)
|
||||
return {'type':'track','name':t.get('name',''),'artist':artist,'artworkUrl':art,'tracks':[tr]}
|
||||
art = _best_art((t.get('album') or {}).get('images') or [])
|
||||
return {'type': 'track', 'name': t.get('name', ''), 'artist': artist, 'artworkUrl': art,
|
||||
'tracks': [_fmt_track(t)]}
|
||||
|
||||
if sp_type == 'album':
|
||||
alb = sp.album(sp_id)
|
||||
alb = _rest(session, f'/albums/{sp_id}', token)
|
||||
artist = ', '.join(a['name'] for a in (alb.get('artists') or []))
|
||||
imgs = alb.get('images') or []
|
||||
art = sorted(imgs, key=lambda x: -(x.get('width') or 0))[0].get('url','') if imgs else ''
|
||||
tracks, offset = [], 0
|
||||
while True:
|
||||
page = sp.album_tracks(sp_id, limit=50, offset=offset)
|
||||
for t in (page.get('items') or []):
|
||||
tr = _fmt_spotipy_track(t, alb.get('name',''), art, artist)
|
||||
if tr: tracks.append(tr)
|
||||
if not page.get('next'): break
|
||||
offset += len(page.get('items') or [])
|
||||
time.sleep(0.3)
|
||||
eprint(f'[SpotipyFree] album ok — {len(tracks)} tracks')
|
||||
return {'type':'album','name':alb.get('name',''),'artist':artist,'artworkUrl':art,'tracks':tracks}
|
||||
art = _best_art(alb.get('images') or [])
|
||||
tracks, path = [], f'/albums/{sp_id}/tracks?limit=50'
|
||||
while path:
|
||||
pg = _rest(session, path, token)
|
||||
tracks += [t for t in [_fmt_track(i, alb.get('name', ''), art, artist)
|
||||
for i in (pg.get('items') or []) if i and i.get('id')] if t]
|
||||
path = pg.get('next')
|
||||
if path: time.sleep(0.3)
|
||||
eprint(f'[curl_cffi] album ok — {len(tracks)} tracks')
|
||||
return {'type': 'album', 'name': alb.get('name', ''), 'artist': artist,
|
||||
'artworkUrl': art, 'tracks': tracks}
|
||||
|
||||
if sp_type == 'playlist':
|
||||
pl = sp.playlist(sp_id, fields='name,owner,images')
|
||||
imgs = pl.get('images') or []
|
||||
art = sorted(imgs, key=lambda x: -(x.get('width') or 0))[0].get('url','') if imgs else ''
|
||||
owner = (pl.get('owner') or {}).get('display_name','')
|
||||
tracks, skipped, offset = [], 0, 0
|
||||
while True:
|
||||
page = sp.playlist_items(sp_id, limit=100, offset=offset,
|
||||
additional_types=['track'])
|
||||
for item in (page.get('items') or []):
|
||||
pl = _rest(session, f'/playlists/{sp_id}?fields=name,description,owner,images', token)
|
||||
owner = (pl.get('owner') or {}).get('display_name', '')
|
||||
art = _best_art(pl.get('images') or [])
|
||||
tracks, skipped, path = [], 0, f'/playlists/{sp_id}/tracks?limit=100'
|
||||
while path:
|
||||
pg = _rest(session, path, token)
|
||||
for item in (pg.get('items') or []):
|
||||
t = item.get('track')
|
||||
if not t or not t.get('id'): continue
|
||||
if t.get('is_local'): skipped += 1; continue
|
||||
tr = _fmt_spotipy_track(t)
|
||||
tr = _fmt_track(t)
|
||||
if tr: tracks.append(tr)
|
||||
if not page.get('next'): break
|
||||
offset += len(page.get('items') or [])
|
||||
time.sleep(0.3)
|
||||
eprint(f'[SpotipyFree] playlist ok — {len(tracks)} tracks')
|
||||
return {'type':'playlist','name':pl.get('name',''),'artist':owner,
|
||||
'artworkUrl':art,'skippedLocal':skipped,'tracks':tracks}
|
||||
|
||||
# ─── MAIN ────────────────────────────────────────────────────────────────────
|
||||
path = pg.get('next')
|
||||
if path: time.sleep(0.3)
|
||||
eprint(f'[curl_cffi] playlist ok — {len(tracks)} tracks')
|
||||
return {'type': 'playlist', 'name': pl.get('name', ''), 'artist': owner,
|
||||
'artworkUrl': art, 'skippedLocal': skipped, 'tracks': tracks}
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2: eprint('No URL'); sys.exit(1)
|
||||
@@ -87,13 +138,12 @@ def main():
|
||||
if not m: eprint('Invalid URL'); sys.exit(1)
|
||||
sp_type, sp_id = m.group(1), m.group(2)
|
||||
eprint(f'[spotify] resolving {sp_type} {sp_id}')
|
||||
|
||||
try:
|
||||
result = resolve_via_spotipyfree(sp_type, sp_id)
|
||||
result = resolve(sp_type, sp_id)
|
||||
print(json.dumps(result, ensure_ascii=False))
|
||||
except SystemExit: raise
|
||||
except Exception as e:
|
||||
eprint(f'[SpotipyFree] failed: {type(e).__name__}: {e}')
|
||||
eprint(f'[curl_cffi] failed: {type(e).__name__}: {e}')
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
Reference in new issue
Block a user