Files
Aurora-SpotiRip/spotify_downloader.py
R3ap3r 3178b8f4f7 Aurora SpotiRip desktop and web app
- Spotify playlist, library, album and artist browsing with in-app playback
- Song, playlist and album downloads via spotdl with YouTube and Bandcamp fallbacks
- Electron desktop app (castLabs Widevine) with a permanent sidebar layout
- Linux and Windows packaging scripts
2026-09-25 23:20:57 -05:00

193 lines
7.0 KiB
Python

import argparse
import json
import os
import re
import shlex
import shutil
import sys
import zipfile
from pathlib import Path
from spotdl import Spotdl
from spotdl.download.downloader import AUDIO_PROVIDERS
from spotdl.utils.config import DEFAULT_CONFIG
FILENAME_TEMPLATE = "{artists} - {title}.{output-ext}"
BATCH_SIZE = 4
FALLBACK_PROVIDERS = ["youtube", "bandcamp"]
EVENT_PREFIX = "@@SPOTIRIP "
_client = None
_json_events = False
def yt_dlp_args():
runtime = os.environ.get("SPOTIRIP_JS_RUNTIME")
if runtime:
return "--js-runtimes " + shlex.quote(f"node:{runtime}")
for name in ("deno", "node"):
path = shutil.which(name)
if path:
return "--js-runtimes " + shlex.quote(f"{name}:{path}")
return None
def get_client(output_dir="downloads"):
global _client
if _client is None:
client_id = os.environ.get("SPOTIFY_CLIENT_ID")
client_secret = os.environ.get("SPOTIFY_CLIENT_SECRET")
_client = Spotdl(
client_id=client_id or DEFAULT_CONFIG["client_id"],
client_secret=client_secret or DEFAULT_CONFIG["client_secret"],
use_official_api=bool(client_id and client_secret),
downloader_settings={
"output": str(Path(output_dir) / FILENAME_TEMPLATE),
"yt_dlp_args": yt_dlp_args(),
**({"ffmpeg": os.environ["SPOTIRIP_FFMPEG"]} if os.environ.get("SPOTIRIP_FFMPEG") else {}),
},
)
return _client
def emit(event, **data):
if _json_events:
print(EVENT_PREFIX + json.dumps({"event": event, **data}), flush=True)
def safe_filename(name):
cleaned = re.sub(r'[\\/:*?"<>|\x00-\x1f]+', " ", name).strip(" .")
return re.sub(r"\s+", " ", cleaned)[:150] or "Spotify download"
def _retry_with_fallback(client, songs):
downloader = client.downloader
settings = downloader.settings
primary = downloader.audio_providers
recovered = []
try:
for name in FALLBACK_PROVIDERS:
if not songs:
break
downloader.audio_providers = [
AUDIO_PROVIDERS[name](
output_format=settings["format"],
cookie_file=settings["cookie_file"],
search_query=settings["search_query"],
filter_results=settings["filter_results"],
yt_dlp_args=settings["yt_dlp_args"],
)
]
for song in songs:
song.download_url = None
print(f"Retrying {song.name} by {'/'.join(song.artists)} with {name}")
results = client.download_songs(songs)
recovered += [(song, path) for song, path in results if path]
songs = [song for song, path in results if not path]
finally:
downloader.audio_providers = primary
return recovered
def _download(urls, output_dir="downloads"):
client = get_client(output_dir)
songs = client.search(urls if isinstance(urls, list) else [urls])
list_name = songs[0].list_name if songs and songs[0].list_name else None
emit("found", total=len(songs), listName=list_name)
results = []
for start in range(0, len(songs), BATCH_SIZE):
batch = client.download_songs(songs[start : start + BATCH_SIZE])
failed = [song for song, path in batch if not path]
if failed:
retried = {id(song): path for song, path in _retry_with_fallback(client, failed)}
batch = [(song, path or retried.get(id(song))) for song, path in batch]
for song, path in batch:
results.append((song, path))
if path:
print(f"Downloaded {song.name} by {'/'.join(song.artists)} -> {path}")
else:
print(f"Failed to download {song.name} by {'/'.join(song.artists)}")
emit(
"progress",
done=len(results),
total=len(songs),
title=song.name,
artist="/".join(song.artists),
ok=bool(path),
)
return results, list_name
def zip_results(results, zip_path):
with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_STORED) as archive:
for _, path in results:
if path and Path(path).exists():
archive.write(path, arcname=Path(path).name)
return zip_path
def download_spotify_song(song_url, output_dir="downloads"):
try:
results, _ = _download(song_url, output_dir)
return results
except Exception as e:
print(f"Error downloading song: {e}")
def download_spotify_playlist(playlist_url, output_dir="downloads", zip_name=None):
try:
results, list_name = _download(playlist_url, output_dir)
if zip_name is not None:
name = safe_filename(zip_name or list_name or "Spotify playlist")
zip_results(results, Path(output_dir) / f"{name}.zip")
return results
except Exception as e:
print(f"Error downloading playlist: {e}")
def main():
global _json_events
parser = argparse.ArgumentParser(description="Download Spotify tracks, albums and playlists with spotdl.")
parser.add_argument("urls", nargs="*", help="Spotify track, album or playlist URL(s)")
parser.add_argument("--urls-stdin", action="store_true", help="Read additional URLs from standard input, one per line")
parser.add_argument("--output-dir", default="downloads", help="Folder to save MP3s into")
parser.add_argument("--zip", action="store_true", help="Also bundle the MP3s into a zip named after the playlist")
parser.add_argument("--zip-name", default="", help="Name for the zip (defaults to the playlist/album name)")
parser.add_argument("--json", action="store_true", help="Print machine-readable progress events")
args = parser.parse_args()
_json_events = args.json
if args.urls_stdin:
args.urls += [line.strip() for line in sys.stdin if line.strip()]
if not args.urls:
parser.error("no URLs given")
Path(args.output_dir).mkdir(parents=True, exist_ok=True)
if not args.json:
for url in args.urls:
if "/playlist/" in url or "/album/" in url:
download_spotify_playlist(url, args.output_dir, args.zip_name if args.zip else None)
else:
download_spotify_song(url, args.output_dir)
return
try:
results, list_name = _download(args.urls, args.output_dir)
saved = [path for _, path in results if path]
if not saved:
emit("error", message="None of the tracks could be downloaded.")
sys.exit(1)
if args.zip:
name = safe_filename(args.zip_name or list_name or "Spotify playlist")
file = zip_results(results, Path(args.output_dir) / f"{name}.zip")
else:
file = saved[0]
emit("complete", file=str(Path(file).resolve()), downloaded=len(saved), failed=len(results) - len(saved))
except Exception as e:
emit("error", message=str(e))
sys.exit(1)
if __name__ == "__main__":
main()