feat: use spotdl (TzurSoffer fork) as primary Spotify resolver
Replaces rate-limited api.spotify.com calls with spotdl's SpotAPI approach, which uses Spotify's internal browser endpoints — no developer credentials needed, no api.spotify.com rate limits. TOTP anonymous token kept as automatic fallback. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
8cc6653898
commit
3c85deab40
3 files changed
+129
-3
No files matched your search
@@ -376,7 +376,33 @@ app.get('/api/health', (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
async function _resolveSpotify(type, id) {
|
async function _resolveSpotifyViaSpotdl(url) {
|
||||||
|
const { execFile } = require('child_process');
|
||||||
|
const scriptPath = path.join(__dirname, 'spotify_resolve.py');
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
execFile('python3', [scriptPath, url], { timeout: 90000, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {
|
||||||
|
if (err) return reject(new Error(`spotdl: ${stderr?.trim() || err.message}`));
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(stdout.trim());
|
||||||
|
if (data.error) return reject(new Error(data.error));
|
||||||
|
resolve(data);
|
||||||
|
} catch {
|
||||||
|
reject(new Error(`spotdl: ungültige Ausgabe — ${stdout.slice(0, 200)}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _resolveSpotify(type, id, originalUrl) {
|
||||||
|
// Primary: spotdl (TzurSoffer fork) — uses SpotAPI, no rate limits
|
||||||
|
try {
|
||||||
|
console.log('[spotify] trying spotdl resolver');
|
||||||
|
return await _resolveSpotifyViaSpotdl(originalUrl);
|
||||||
|
} catch (e) {
|
||||||
|
console.log('[spotify] spotdl failed:', e.message, '— falling back to TOTP');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: TOTP anonymous token
|
||||||
const token = await fetchSpotifyToken();
|
const token = await fetchSpotifyToken();
|
||||||
|
|
||||||
if (type === 'track') {
|
if (type === 'track') {
|
||||||
@@ -419,7 +445,7 @@ app.post('/api/spotify/resolve', async (req, res) => {
|
|||||||
const timeout = new Promise((_, reject) =>
|
const timeout = new Promise((_, reject) =>
|
||||||
setTimeout(() => reject(new Error('Zeitüberschreitung — Spotify API antwortet nicht (>120s)')), 120000)
|
setTimeout(() => reject(new Error('Zeitüberschreitung — Spotify API antwortet nicht (>120s)')), 120000)
|
||||||
);
|
);
|
||||||
const data = await Promise.race([_resolveSpotify(type, id), timeout]);
|
const data = await Promise.race([_resolveSpotify(type, id, url), timeout]);
|
||||||
res.json(data);
|
res.json(data);
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
console.error('[spotify/resolve]', e.message);
|
console.error('[spotify/resolve]', e.message);
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Resolves a Spotify URL to track metadata using spotdl (TzurSoffer fork).
|
||||||
|
Usage: python3 spotify_resolve.py <spotify_url>
|
||||||
|
Outputs JSON to stdout.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print(json.dumps({"error": "No URL provided"}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
url = sys.argv[1]
|
||||||
|
tmp = tempfile.mktemp(suffix=".spotdl")
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["spotdl", "save", url, "--save-file", tmp, "--log-level", "ERROR"],
|
||||||
|
capture_output=True, text=True, timeout=60
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
err = result.stderr.strip() or result.stdout.strip() or "spotdl failed"
|
||||||
|
print(json.dumps({"error": err}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if not os.path.exists(tmp):
|
||||||
|
print(json.dumps({"error": "spotdl produced no output file"}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
with open(tmp, "r", encoding="utf-8") as f:
|
||||||
|
songs = json.load(f)
|
||||||
|
|
||||||
|
if not songs:
|
||||||
|
print(json.dumps({"error": "No tracks found"}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Transform spotdl Song format to NovaSonic format
|
||||||
|
def fmt_track(s):
|
||||||
|
artists = s.get("artists") or []
|
||||||
|
artist_str = ", ".join(artists) if artists else s.get("artist", "")
|
||||||
|
date = s.get("date") or (str(s["year"]) if s.get("year") else "")
|
||||||
|
return {
|
||||||
|
"trackName": s.get("name", ""),
|
||||||
|
"artistName": artist_str,
|
||||||
|
"collectionName": s.get("album_name", ""),
|
||||||
|
"artworkUrl100": s.get("cover_url", ""),
|
||||||
|
"releaseDate": date,
|
||||||
|
"trackNumber": s.get("track_number"),
|
||||||
|
"trackExplicitness": "explicit" if s.get("explicit") else "notExplicit",
|
||||||
|
"durationMs": int(s["duration"] * 1000) if s.get("duration") else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
tracks = [fmt_track(s) for s in songs]
|
||||||
|
first = songs[0]
|
||||||
|
artists0 = first.get("artists") or []
|
||||||
|
artist0 = ", ".join(artists0) if artists0 else first.get("artist", "")
|
||||||
|
|
||||||
|
# Determine type from URL
|
||||||
|
if "/track/" in url:
|
||||||
|
out = {
|
||||||
|
"type": "track",
|
||||||
|
"name": first.get("name", ""),
|
||||||
|
"artist": artist0,
|
||||||
|
"artworkUrl": first.get("cover_url", ""),
|
||||||
|
"tracks": tracks,
|
||||||
|
}
|
||||||
|
elif "/album/" in url:
|
||||||
|
out = {
|
||||||
|
"type": "album",
|
||||||
|
"name": first.get("album_name", ""),
|
||||||
|
"artist": first.get("album_artist", artist0),
|
||||||
|
"artworkUrl": first.get("cover_url", ""),
|
||||||
|
"tracks": tracks,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
out = {
|
||||||
|
"type": "playlist",
|
||||||
|
"name": first.get("list_name", ""),
|
||||||
|
"artist": first.get("album_artist", ""),
|
||||||
|
"artworkUrl": first.get("cover_url", ""),
|
||||||
|
"skippedLocal": 0,
|
||||||
|
"tracks": tracks,
|
||||||
|
}
|
||||||
|
|
||||||
|
print(json.dumps(out, ensure_ascii=False))
|
||||||
|
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.unlink(tmp)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+2
-1
@@ -34,7 +34,8 @@ services:
|
|||||||
- SPOTIFY_CLIENT_SECRET=${SPOTIFY_CLIENT_SECRET:-}
|
- SPOTIFY_CLIENT_SECRET=${SPOTIFY_CLIENT_SECRET:-}
|
||||||
command: >
|
command: >
|
||||||
sh -c "
|
sh -c "
|
||||||
apk add --no-cache yt-dlp ffmpeg python3 &&
|
apk add --no-cache yt-dlp ffmpeg python3 py3-pip py3-requests git gcc musl-dev python3-dev libffi-dev &&
|
||||||
|
pip install --break-system-packages 'git+https://github.com/TzurSoffer/spotify-downloader' &&
|
||||||
mkdir -p /deps && cd /deps && npm install express &&
|
mkdir -p /deps && cd /deps && npm install express &&
|
||||||
cd /app && NODE_PATH=/deps/node_modules node novasonic-server.js
|
cd /app && NODE_PATH=/deps/node_modules node novasonic-server.js
|
||||||
"
|
"
|
||||||
Reference in new issue
Block a user