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
@@ -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()
|
||||
Reference in new issue
Block a user