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
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user