Skip to content

Instantly share code, notes, and snippets.

@Always-Self-Hosted
Last active October 30, 2025 20:20
Show Gist options
  • Select an option

  • Save Always-Self-Hosted/1658e9d52fb07125bd4475c7facf9baf to your computer and use it in GitHub Desktop.

Select an option

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
#!/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())
@Always-Self-Hosted
Copy link
Author

Always-Self-Hosted commented Sep 30, 2025

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!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment