Last active
October 30, 2025 20:20
-
-
Save Always-Self-Hosted/1658e9d52fb07125bd4475c7facf9baf to your computer and use it in GitHub Desktop.
I wrote this script because beginbot is publishing absolute fire on suno right now and looking around all the current scripts/cli tools only work if you give them song ids or at best a playlist id, But i just wanted an easy way to give a username and get all the songs downloaded
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| # -*- coding: utf-8 -*- | |
| # Copyright (c) 2025 Ash <always-self-hosted@protonmail.com> | |
| # This file is released under MIT license | |
| """This script accepts a suno username and will download all public songs found.""" | |
| from argparse import ArgumentParser, Namespace | |
| from datetime import datetime | |
| from enum import Enum | |
| from json import loads | |
| from logging import Formatter, StreamHandler, getLogger | |
| from os import W_OK, access | |
| from pathlib import Path | |
| from re import sub | |
| from shutil import get_terminal_size | |
| from subprocess import run | |
| from sys import exit | |
| from time import sleep | |
| from traceback import extract_tb | |
| from unicodedata import normalize | |
| from urllib import request | |
| from urllib.request import HTTPError, Request | |
| SCRIPT_NAME = Path(__file__).stem | |
| SCRIPT_VERSION = "0.5.3" | |
| SCRIPT_ASCII = f""" | |
| ▗▖ ▗▄▖ ▗▖ | |
| ▐▌ ▝▜▌ ▐▌ | |
| ▗▟██▖▐▌ ▐▌▐▙██▖ ▟█▙ ▟█▟▌ ▟█▙ █ █▐▙██▖ ▐▌ ▟█▙ ▟██▖ ▟█▟▌ | |
| ▐▙▄▖▘▐▌ ▐▌▐▛ ▐▌▐▛ ▜▌ ▐▛ ▜▌▐▛ ▜▌▜ █ ▛▐▛ ▐▌ ▐▌ ▐▛ ▜▌ ▘▄▟▌▐▛ ▜▌ | |
| ▀▀█▖▐▌ ▐▌▐▌ ▐▌▐▌ ▐▌ ▐▌ ▐▌▐▌ ▐▌▐▙█▟▌▐▌ ▐▌ ▐▌ ▐▌ ▐▌▗█▀▜▌▐▌ ▐▌ | |
| ▐▄▄▟▌▐▙▄█▌▐▌ ▐▌▝█▄█▘ ▝█▄█▌▝█▄█▘▝█ █▘▐▌ ▐▌ ▐▙▄ ▝█▄█▘▐▙▄█▌▝█▄█▌ | |
| ▀▀▀ ▀▀▝▘▝▘ ▝▘ ▝▀▘ ▝▀▝▘ ▝▀▘ ▀ ▀ ▝▘ ▝▘ ▀▀ ▝▀▘ ▀▀▝▘ ▝▀▝▘ | |
| ⣇⡀ ⡀⢀ ⡀⢀ ⢀⣀ ⢀⡀ ⡀⣀ ⣀⡀ ⢀⣀ ⣀⣀ ⢀⡀ | |
| ⠧⠜ ⣑⡺ ⠣⠼ ⠭⠕ ⠣⠭ ⠏ ⠇⠸ ⠣⠼ ⠇⠇⠇ ⠣⠭ v{SCRIPT_VERSION} | |
| """ | |
| LOG_LEVELS = {"debug": 10, "info": 20, "warning": 30, "error": 40} | |
| SUNO_API_URL = "https://studio-api.prod.suno.com/api/profiles/{username}" | |
| SUNO_PARAMS = "?page={page}&playlists_sort_by=created_at&clips_sort_by=created_at" | |
| CLIENT = request.build_opener() | |
| # This is a good faith attempt at being a good citizen of the open internet. | |
| # If this UA gets blocked, we should reach out to suno and plead for them to reconsider. | |
| CLIENT.addheaders = [("User-agent", f"{SCRIPT_NAME}/{SCRIPT_VERSION}")] | |
| class Colours(Enum): | |
| """ANSI escape codes for terminal text colouring.""" | |
| RESET = "\x1b[0m" | |
| RED = "\x1b[31m" | |
| GREEN = "\x1b[32m" | |
| YELLOW = "\x1b[33m" | |
| BLUE = "\x1b[34m" | |
| class Processed(Enum): | |
| """Possible outcomes from processing Suno songs.""" | |
| NO_AUDIO = 0 | |
| EXISTS = 1 | |
| FAILED = 2 | |
| DOWNLOADED = 3 | |
| class Logger: | |
| """This logger is designed to utilize carriage return to avoid spamming the terminal | |
| for the happy path messages. It also ensures it's not overwriting any messages when | |
| the log_level is set to debug or the script is interrupted by an exit.""" | |
| last_msg_len = 0 | |
| cr_used = False | |
| extras = {"start": "", "spacing": "", "end": ""} | |
| def __init__(self, log_level: str) -> None: | |
| self.logger = getLogger(SCRIPT_NAME) | |
| self.logger.setLevel(LOG_LEVELS[log_level]) | |
| stream_handler = StreamHandler() | |
| stream_handler.setLevel(LOG_LEVELS[log_level]) | |
| stream_handler.setFormatter(Formatter("{start}{message}{spacing}{end}", style="{")) | |
| stream_handler.terminator = "" | |
| self.logger.addHandler(stream_handler) | |
| def log_message( | |
| self, log_level: str, message: str, colour: Colours = Colours.RESET, cr: bool = False, final_msg: bool = False | |
| ) -> None: | |
| """When cr=True is passed to this function, it reuses the last log line in the terminal for | |
| this new message. When final_msg=True is given, it will ensure it prints a final new line.""" | |
| if not LOG_LEVELS[log_level] >= self.logger.level: | |
| return | |
| # We want to keep EVERY message if we are in debug mode, even if we previously passed cr=True. | |
| use_raw_msg = self.logger.level == LOG_LEVELS["debug"] | |
| line_length = len(message) | |
| if not use_raw_msg: | |
| # We need to ensure we are truncating to the terminal width to not break cr usage. | |
| term_width, _ = get_terminal_size() | |
| if cr and not final_msg and line_length > term_width: | |
| message = message[:term_width] | |
| line_length = term_width | |
| message = f"{colour.value}{message}{Colours.RESET.value}" | |
| # When CR was previously used, ensure if the previous message was longer we clear those chars. | |
| spacing = " " * max(0, self.last_msg_len - line_length) | |
| self.last_msg_len = line_length | |
| self.extras["start"] = "\n" if use_raw_msg and self.cr_used else "" | |
| self.extras["end"] = "\r" if cr and not final_msg else "\n" | |
| self.extras["spacing"] = spacing if self.cr_used and not use_raw_msg else "" | |
| self.cr_used = self.extras["end"] == "\r" | |
| self.logger.log(LOG_LEVELS[log_level], message, extra=self.extras) | |
| def send_request(logger: Logger, url: str, retries: int = 1, backoff: int = 2) -> bytes: | |
| """urllib wrapper with built in retries and backoff mechanism.""" | |
| req = Request(url) | |
| logger.log_message("debug", f"Sending request: {req.get_method()} {req.get_full_url()}") | |
| while True: | |
| try: | |
| with CLIENT.open(req, timeout=10) as resp: | |
| return resp.read() | |
| except HTTPError as err: | |
| logger.log_message("debug", f"Failed with {err.code}: {err.msg}") | |
| if retries > 0 and backoff > 0: | |
| if err.code == 429: | |
| logger.log_message("warning", "Currently being rate limited by suno...", Colours.YELLOW, cr=True) | |
| logger.log_message("debug", f"Retrying after {backoff} seconds") | |
| sleep(backoff) | |
| # Special case rate limiting so we don't completely fail when we just needed to wait and keep trying. | |
| if not err.code == 429: | |
| retries -= 1 | |
| backoff *= 2 | |
| continue | |
| raise err | |
| def sanitize_username(username: str) -> str: | |
| """Sanitize username for correct api use.""" | |
| # If the user copy pasted the username from the profile url it contains an @. | |
| return username.removeprefix("@") | |
| def sanitize_pathname(name: str) -> str: | |
| """Sanitize string for safe path use.""" | |
| # Try to cover most os cases for characters and length. | |
| return sub(r'[<>:"/\\|?*\0]', "", name)[:255] | |
| def sanitize_metadata(value: str) -> str: | |
| """Sanitize annoying characters for ffmpeg metadata.""" | |
| value = normalize("NFKC", value) | |
| value = value.replace("\n", " ") | |
| value = sub(r'["“”‘’]', "'", value) | |
| value = sub(r"[;=\\]", "_", value) | |
| value = sub(r"[\r\t\0]", "", value) | |
| return value | |
| def check_ffmpeg() -> bool: | |
| """Check if ffmpeg is installed and accessible.""" | |
| try: | |
| run(["ffmpeg", "-version"], capture_output=True, text=True, check=True) | |
| return True | |
| except Exception: | |
| return False | |
| def download_file(logger: Logger, url: str, filepath: Path) -> bool: | |
| """Download a file from URL to filepath with retry.""" | |
| try: | |
| data = send_request(logger, url) | |
| filepath.write_bytes(data) | |
| return True | |
| except Exception as err: | |
| logger.log_message("warning", f"Failed to download file: {err}", Colours.YELLOW) | |
| filepath.unlink(missing_ok=True) | |
| return False | |
| def embed_metadata(logger: Logger, mp3_path: Path, thumbnail_path: Path, metadata: dict) -> bool: | |
| """Embed metadata and thumbnail into MP3 using ffmpeg.""" | |
| # We want to create a new file so if ffmpeg fails we still have the MP3 just without metadata. | |
| final_mp3 = mp3_path.with_name(f"final-{mp3_path.name}") | |
| # We need to make sure the args are in the correct order for this to work. This is very fiddly! | |
| ffmpeg_cmd = ["ffmpeg", "-y", "-i", str(mp3_path)] | |
| if thumbnail_path.exists(): | |
| ffmpeg_cmd.extend(["-i", str(thumbnail_path), "-map", "0:a", "-map", "1:v", "-disposition:v", "attached_pic"]) | |
| else: | |
| ffmpeg_cmd.extend(["-map", "0:a"]) | |
| ffmpeg_cmd.extend(["-c", "copy"]) | |
| for keyword, item in metadata.items(): | |
| # ffmpeg hates empty values! | |
| if item is not None and str(item).strip(): | |
| ffmpeg_cmd.extend(["-metadata", f"{keyword}={sanitize_metadata(item)}"]) | |
| ffmpeg_cmd.append(str(final_mp3)) | |
| logger.log_message("debug", f"Running ffmpeg command: {' '.join(ffmpeg_cmd)}") | |
| try: | |
| run(ffmpeg_cmd, capture_output=True, text=True, check=True) | |
| final_mp3.rename(mp3_path) | |
| return True | |
| except Exception as err: | |
| logger.log_message("warning", f"ffmpeg failed embedding metadata: {str(err)}", Colours.YELLOW) | |
| final_mp3.unlink(missing_ok=True) | |
| return False | |
| def get_suno_songs(logger: Logger, username: str) -> list: | |
| """Get all unique songs for a given username.""" | |
| current_page = 1 | |
| seen_ids = set() | |
| found_songs = [] | |
| api_url = SUNO_API_URL.format_map({"username": username}) | |
| logger.log_message("info", f"Looking for songs from {username}", Colours.GREEN) | |
| while True: | |
| api_params = SUNO_PARAMS.format_map({"page": current_page}) | |
| current_songs = len(found_songs) | |
| try: | |
| data = send_request(logger, api_url + api_params) | |
| except HTTPError as err: | |
| logger.log_message("warning", f"Error fetching songs: {err}", Colours.YELLOW) | |
| break | |
| # Suno seems to use ISO-8859-1 encoding. | |
| data = loads(data.decode("latin-1", "replace").encode("utf-8")) | |
| if not isinstance(data, dict) or "clips" not in data: | |
| logger.log_message("warning", "Invalid API response getting songs", Colours.YELLOW) | |
| break | |
| for song in data["clips"]: | |
| song_id = song.get("id") | |
| if song_id and song_id not in seen_ids: | |
| seen_ids.add(song_id) | |
| found_songs.append(song) | |
| if not len(found_songs) > current_songs: | |
| break | |
| logger.log_message("info", f"Found {len(found_songs)} unique songs so far...", Colours.GREEN, cr=True) | |
| current_page += 1 | |
| if len(found_songs) > 0: | |
| logger.log_message("info", f"Found {len(found_songs)} unique songs", Colours.GREEN) | |
| # Inplace sorting of play_count as an arbitrary choice for --songs | |
| found_songs.sort(key=lambda song: song.get("play_count", 0), reverse=True) | |
| return found_songs | |
| def process_suno_song( | |
| logger: Logger, song: dict, downloads_dir: Path, metadata: bool, force: bool, dry_run: bool | |
| ) -> Processed: | |
| """Download and optionally embed metadata into a Suno song. Handles skipping, redownloads, and file cleanup.""" | |
| song_id = song.get("id") # id is already known to exist by get_suno_songs(). | |
| song_title = song.get("title", "Unknown") | |
| unsanitized_name = f"{song_title} ({song_id})" | |
| friendly_name = sanitize_pathname(unsanitized_name) | |
| audio_url = song.get("audio_url") | |
| if not audio_url: | |
| logger.log_message("warning", f"No audio URL found for {friendly_name}", Colours.YELLOW) | |
| return Processed.NO_AUDIO | |
| mp3_filename = downloads_dir / f"{friendly_name}.mp3" | |
| if mp3_filename.exists() and not force: | |
| logger.log_message("info", f"Skipping existing file: {friendly_name}", Colours.GREEN, cr=True) | |
| return Processed.EXISTS | |
| if dry_run: | |
| return Processed.DOWNLOADED | |
| logger.log_message("info", f"Downloading MP3: {friendly_name}", Colours.GREEN, cr=True) | |
| temp_mp3 = downloads_dir / f"temp-{song_id}.mp3" | |
| if not download_file(logger, audio_url, temp_mp3): | |
| temp_mp3.unlink(missing_ok=True) | |
| return Processed.FAILED | |
| if metadata: | |
| song_metadata = {} | |
| song_metadata["song_id"] = song_id | |
| song_metadata["title"] = song_title | |
| temp_thumbnail = downloads_dir / f"temp-{song_id}.jpg" | |
| thumbnail_url = song.get("image_large_url") or song.get("image_url") | |
| if thumbnail_url: | |
| logger.log_message("info", f"Downloading thumbnail for: {friendly_name}", Colours.GREEN, cr=True) | |
| download_file(logger, thumbnail_url, temp_thumbnail) | |
| else: | |
| logger.log_message("debug", f"No thumbnail found for: {friendly_name}") | |
| if song.get("handle"): | |
| song_metadata["artist"] = song["handle"] | |
| if song.get("created_at"): | |
| timestamp = datetime.fromisoformat(song["created_at"]) | |
| # Just supporting ID3 for now. | |
| song_metadata["date"] = timestamp.strftime("%Y-%m-%d") | |
| if song.get("metadata", {}).get("tags"): | |
| song_metadata["genre"] = song["metadata"]["tags"] | |
| if song.get("metadata", {}).get("prompt"): | |
| song_metadata["suno_prompt"] = song["metadata"]["prompt"] | |
| if song.get("metadata", {}).get("gpt_description_prompt"): | |
| song_metadata["gpt_prompt"] = song["metadata"]["gpt_description_prompt"] | |
| # This obviously becomes inaccurate almost immediately but its a nice to have? | |
| # Users can always update it with --force if they care about it being accurate. | |
| if song.get("play_count"): | |
| song_metadata["play_count"] = str(song["play_count"]) | |
| logger.log_message("info", f"Embedding metadata for: {friendly_name}", Colours.GREEN, cr=True) | |
| embed_metadata(logger, temp_mp3, temp_thumbnail, song_metadata) | |
| temp_thumbnail.unlink(missing_ok=True) | |
| temp_mp3.rename(mp3_filename) | |
| temp_mp3.unlink(missing_ok=True) | |
| return Processed.DOWNLOADED | |
| def get_cli_args() -> Namespace: | |
| """Parse and return command-line arguments.""" | |
| parser = ArgumentParser( | |
| prog=SCRIPT_NAME, description="Downloads (with metadata) songs published by a Suno user", add_help=True | |
| ) | |
| parser.add_argument("username", type=str, help="Suno username to fetch songs from") | |
| parser.add_argument("-d", "--directory", type=Path, default=Path.cwd(), help="Directory to use for songs") | |
| parser.add_argument("-s", "--songs", type=int, default=None, help="Download number of songs by listen count") | |
| parser.add_argument("-l", "--log-level", choices=LOG_LEVELS, default="info", help="Level of log messages to print") | |
| parser.add_argument("--metadata", action="store_true", help="Embed metadata into downloaded songs") | |
| parser.add_argument("--force", action="store_true", help="Redownload and overwrite songs that already exist") | |
| parser.add_argument("--dry-run", action="store_true", help="Print actions to console instead of taking them") | |
| return parser.parse_args() | |
| def validate_cli_args(logger: Logger, args: Namespace) -> bool: | |
| """Validate args and return bool indicating if we should proceed.""" | |
| if args.songs and not args.songs > 0: | |
| logger.log_message("error", "--songs argument must be a positive integer", Colours.RED) | |
| return False | |
| if args.metadata and not check_ffmpeg(): | |
| logger.log_message( | |
| "error", | |
| "ffmpeg is not installed or not found in PATH which is required to support --metadata", | |
| Colours.RED, | |
| ) | |
| return False | |
| provided_dir = Path(args.directory).resolve() | |
| if not provided_dir.is_dir() or not access(provided_dir, W_OK): | |
| logger.log_message("error", f"Failed to use directory {provided_dir}", Colours.RED) | |
| return False | |
| return True | |
| def main() -> int: | |
| """Main entry point for the script. Handles CLI parsing, song fetching, downloading, and cleanup.""" | |
| args = get_cli_args() | |
| logger = Logger(args.log_level) | |
| logger.log_message("info", SCRIPT_ASCII, Colours.BLUE) | |
| if not validate_cli_args(logger, args): | |
| return 1 | |
| username = sanitize_username(args.username) | |
| downloads_dir = Path(args.directory).resolve() / sanitize_pathname(username) | |
| processed_songs = {Processed.NO_AUDIO: 0, Processed.EXISTS: 0, Processed.FAILED: 0, Processed.DOWNLOADED: 0} | |
| try: | |
| found_songs = get_suno_songs(logger, username) | |
| if not found_songs: | |
| logger.log_message("error", f"No songs found for {username}", Colours.RED) | |
| return 1 | |
| downloads_dir.mkdir(exist_ok=True) | |
| # We have validated args.songs is either a positive integer or None | |
| for song in found_songs[: args.songs]: | |
| processed = process_suno_song(logger, song, downloads_dir, args.metadata, args.force, args.dry_run) | |
| processed_songs[processed] += 1 | |
| for status, count in processed_songs.items(): | |
| if not count: | |
| continue | |
| message_prefix = "Would have " if args.dry_run else "" | |
| if status == Processed.NO_AUDIO: | |
| message = f"Skipped {count} songs with no audio url" | |
| colour = Colours.BLUE | |
| elif status == Processed.EXISTS: | |
| message = f"Skipped {count} songs that are already downloaded" | |
| colour = Colours.BLUE | |
| elif status == Processed.FAILED: | |
| message = f"Failed to download {count} songs due to errors" | |
| colour = Colours.RED | |
| elif status == Processed.DOWNLOADED: | |
| message = f"Downloaded {count} songs to {downloads_dir}" | |
| colour = Colours.GREEN | |
| logger.log_message("info", message_prefix + message, colour, cr=True, final_msg=True) | |
| return 0 | |
| except KeyboardInterrupt: | |
| logger.log_message("info", "CTRL + C Detected! Interrupted by user...", Colours.BLUE) | |
| downloaded_songs = processed_songs[Processed.DOWNLOADED] | |
| if downloaded_songs > 0 and not args.dry_run: | |
| logger.log_message("info", f"Stopping script after downloading {downloaded_songs} songs", Colours.BLUE) | |
| return 0 | |
| except Exception as err: | |
| logger.log_message("error", "Exception raised. Printing traceback...", Colours.RED) | |
| # If we are in debug, just raise for the full traceback. | |
| if logger.logger.level == LOG_LEVELS["debug"]: | |
| raise err | |
| # Otherwise just print a mini traceback as its probably good enough. | |
| err_name = err.__class__.__name__ | |
| trace = extract_tb(err.__traceback__) | |
| _, _, func_name, _ = trace[-1] # Last called function. | |
| _, lineno, _, _ = trace[-2] if len(trace) > 1 else trace[-1] # Actual call site. | |
| logger.log_message("error", f"Error in function {func_name} at line {lineno}", Colours.RED) | |
| logger.log_message("error", f"{err_name}: {err}", Colours.RED) | |
| return 1 | |
| finally: | |
| for temp_file in downloads_dir.glob("temp-*"): | |
| logger.log_message("debug", f"Cleaning up temp file {temp_file.name}") | |
| temp_file.unlink(missing_ok=True) | |
| # Cleanup if we were just testing and we created a directory for no reason. | |
| if args.dry_run and downloads_dir.exists() and not any(downloads_dir.iterdir()): | |
| downloads_dir.rmdir() | |
| if __name__ == "__main__": | |
| exit(main()) |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Ok wayyy too many revisions later I think this is pretty robust now. I am a grug brain developer so it is written in that style and should be pretty easy to follow for most people who are at least vaguely familiar with python or other scripting languages.
If you notice any issues or want further features comment them below 😃
also 🎵 I wanna be teej 🎵
teej or begin if you ever see this, you are my heroes!