From 3c85deab402e063cd702bbf43759b523aea907e6 Mon Sep 17 00:00:00 2001 From: Honey Date: Mon, 11 May 2026 14:49:19 +0200 Subject: [PATCH] feat: use spotdl (TzurSoffer fork) as primary Spotify resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/novasonic-server.js | 30 ++++++++++- backend/spotify_resolve.py | 99 +++++++++++++++++++++++++++++++++++++ docker-compose.yml | 3 +- 3 files changed, 129 insertions(+), 3 deletions(-) create mode 100644 backend/spotify_resolve.py diff --git a/backend/novasonic-server.js b/backend/novasonic-server.js index 567ab5b..36d41f4 100644 --- a/backend/novasonic-server.js +++ b/backend/novasonic-server.js @@ -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(); if (type === 'track') { @@ -419,7 +445,7 @@ app.post('/api/spotify/resolve', async (req, res) => { const timeout = new Promise((_, reject) => 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); } catch(e) { console.error('[spotify/resolve]', e.message); diff --git a/backend/spotify_resolve.py b/backend/spotify_resolve.py new file mode 100644 index 0000000..fab495c --- /dev/null +++ b/backend/spotify_resolve.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +""" +Resolves a Spotify URL to track metadata using spotdl (TzurSoffer fork). +Usage: python3 spotify_resolve.py +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() diff --git a/docker-compose.yml b/docker-compose.yml index 1009cfa..b79a7ad 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -34,7 +34,8 @@ services: - SPOTIFY_CLIENT_SECRET=${SPOTIFY_CLIENT_SECRET:-} command: > 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 && cd /app && NODE_PATH=/deps/node_modules node novasonic-server.js "