├── requirements.txt ├── .gitignore ├── getData.js ├── README.md └── suno-downloader.py /requirements.txt: -------------------------------------------------------------------------------- 1 | tqdm>=4.65.0 2 | requests>=2.31.0 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.code-workspace 3 | songs/ 4 | songs.csv 5 | song-row.html 6 | -------------------------------------------------------------------------------- /getData.js: -------------------------------------------------------------------------------- 1 | // Login to https://suno.com/me 2 | copy( 3 | [ 4 | "song_name,song_url,song_prompt", 5 | ...[ 6 | ...$('[role="grid"]')[ 7 | Object.keys($('[role="grid"]')).filter((x) => 8 | x.startsWith("__reactProps") 9 | )[0] 10 | ].children[0].props.values[0][1].collection, 11 | ] 12 | .filter((x) => x.value.audio_url) 13 | .map((x) => { 14 | const title = x.value.title.trim() || x.value.id; 15 | // Use a hash of the song's ID for consistency 16 | const hash = x.value.id.slice(0, 5); // Use the first 5 characters of the ID 17 | // Get UUID from the song's ID 18 | const uuid = x.value.id; 19 | // Format filename: lowercase, replace spaces with dashes, add id and hash 20 | const formattedTitle = `${title 21 | .toLowerCase() 22 | .replace(/\s+/g, "-")}-id-${hash}`; 23 | 24 | // Find the description from the DOM using the song ID 25 | const songElement = document.querySelector( 26 | `[data-clip-id="${x.value.id}"]` 27 | ); 28 | let description = ""; 29 | if (songElement) { 30 | const descriptionSpan = Array.from( 31 | songElement.querySelectorAll("span[title]") 32 | ).find((span) => span.textContent.trim().length > 50); 33 | description = descriptionSpan 34 | ? descriptionSpan.getAttribute("title").trim() 35 | : "No description available"; 36 | } 37 | 38 | // Include original UUID filename in the description 39 | const fullDescription = `Original filename: ${uuid}.mp3\n\nPrompt:\n${description}`; 40 | 41 | // Always wrap description in quotes for consistency, and ensure no trailing newline 42 | return `${formattedTitle}.mp3,${ 43 | x.value.audio_url 44 | },"${fullDescription.replace(/\n$/, "")}"`; 45 | }), 46 | ] 47 | .join("\n") 48 | .trim() // Trim any extra whitespace/newlines from the final output 49 | ); 50 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Suno Music Downloader 2 | 3 | A set of tools to download your music from Suno.ai with organized filenames and prompts. 4 | 5 | ## Setup 6 | 7 | 1. Clone this repository 8 | 2. Install Python requirements: 9 | ```bash 10 | pip3 install -r requirements.txt 11 | ``` 12 | 13 | ## Usage 14 | 15 | ### 1. Get Song Data 16 | 17 | 1. Login to https://suno.com/me 18 | 2. Open browser developer tools (F12) 19 | 3. Copy and paste the contents of `getData.js` into the console 20 | 4. Copy the output and save it to `songs.csv` 21 | 22 | The script will generate a CSV with: 23 | - Formatted filenames (with random ID) 24 | - Download URLs 25 | - Original prompts and UUIDs 26 | 27 | ### 2. Download Songs 28 | 29 | Run the Python downloader: 30 | ```bash 31 | python3 suno-downloader.py 32 | ``` 33 | 34 | ### Features 35 | 36 | - **Fast Parallel Downloads**: Downloads 4 files simultaneously 37 | - **Smart File Handling**: 38 | - Skips existing files automatically 39 | - Creates organized filenames with IDs 40 | - Preserves original UUIDs in text files 41 | - **Progress Tracking**: 42 | - Real-time progress bars for each download 43 | - Download speed and size information 44 | - Completion summary with success/failure counts 45 | - **Error Handling**: 46 | - Automatic retry on failed downloads (up to 3 attempts) 47 | - Detailed error reporting 48 | - Graceful handling of network issues 49 | 50 | ## Output Structure 51 | 52 | ``` 53 | songs/ 54 | ├── song-name-id-xxxxx.mp3 # Organized filename with random ID 55 | └── song-name-id-xxxxx.txt # Matching text file with prompt 56 | ``` 57 | 58 | Text files contain: 59 | ``` 60 | Original filename: uuid.mp3 61 | 62 | Prompt: 63 | Your original generation prompt 64 | ``` 65 | 66 | ## Files 67 | 68 | - `getData.js` - Browser script to extract song data 69 | - `suno-downloader.py` - Python script with parallel download capability 70 | - `requirements.txt` - Python package dependencies 71 | - `songs.csv` - Generated list of songs to download 72 | 73 | ## Technical Details 74 | 75 | - Uses Python's ThreadPoolExecutor for parallel downloads 76 | - Configurable number of simultaneous downloads (default: 4) 77 | - Progress bars powered by tqdm 78 | - Robust error handling with automatic retries 79 | -------------------------------------------------------------------------------- /suno-downloader.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import csv 4 | import os 5 | import time 6 | from concurrent.futures import ThreadPoolExecutor 7 | from tqdm import tqdm 8 | import requests 9 | from pathlib import Path 10 | import sys 11 | 12 | MAX_RETRIES = 3 13 | MAX_WORKERS = 4 14 | 15 | 16 | def download_file(url, filename, total_size=None): 17 | """Download a file with progress bar and retry logic.""" 18 | for attempt in range(MAX_RETRIES): 19 | try: 20 | response = requests.get(url, stream=True) 21 | response.raise_for_status() 22 | 23 | total = total_size or int(response.headers.get("content-length", 0)) 24 | 25 | with open(filename, "wb") as f, tqdm( 26 | desc=Path(filename).name, 27 | total=total, 28 | unit="iB", 29 | unit_scale=True, 30 | unit_divisor=1024, 31 | ) as pbar: 32 | for data in response.iter_content(chunk_size=1024): 33 | size = f.write(data) 34 | pbar.update(size) 35 | return True 36 | 37 | except Exception as e: 38 | if attempt < MAX_RETRIES - 1: 39 | print(f"\nRetrying {filename} (Attempt {attempt + 2}/{MAX_RETRIES})") 40 | time.sleep(2) 41 | else: 42 | print(f"\nFailed to download {filename}: {str(e)}") 43 | return False 44 | 45 | 46 | def process_song(row): 47 | """Process a single song (for parallel processing).""" 48 | try: 49 | # Skip empty or malformed rows 50 | if not row or len(row) != 3: 51 | print(f"Skipping invalid row: {row}") 52 | return False 53 | 54 | filename, url, description = row 55 | 56 | # Skip if any required field is empty 57 | if not all([filename.strip(), url.strip(), description.strip()]): 58 | print(f"Skipping row with empty fields: {filename}") 59 | return False 60 | 61 | # Create full paths 62 | mp3_path = os.path.join("songs", filename) 63 | txt_path = os.path.join("songs", filename.replace(".mp3", ".txt")) 64 | 65 | # Skip if files already exist 66 | if os.path.exists(mp3_path) and os.path.exists(txt_path): 67 | print(f"Skipping existing file: {filename}") 68 | return True 69 | 70 | # Save description to text file 71 | with open(txt_path, "w", encoding="utf-8") as txt_file: 72 | txt_file.write(description.strip()) 73 | 74 | # Download MP3 75 | return download_file(url, mp3_path) 76 | except Exception as e: 77 | print( 78 | f"Error processing song {filename if 'filename' in locals() else 'unknown'}: {str(e)}" 79 | ) 80 | return False 81 | 82 | 83 | def main(): 84 | """Main execution function.""" 85 | # Create songs directory if it doesn't exist 86 | os.makedirs("songs", exist_ok=True) 87 | 88 | # Read the CSV file 89 | try: 90 | with open("songs.csv", "r", encoding="utf-8") as file: 91 | # Use csv.reader with proper quoting and filtering 92 | reader = csv.reader(file, quoting=csv.QUOTE_ALL, skipinitialspace=True) 93 | next(reader) # Skip header 94 | # Filter out empty rows and validate row length 95 | songs = [row for row in reader if row and len(row) == 3] 96 | except FileNotFoundError: 97 | print("Error: songs.csv not found") 98 | sys.exit(1) 99 | except Exception as e: 100 | print(f"Error reading CSV: {str(e)}") 101 | sys.exit(1) 102 | 103 | if not songs: 104 | print("No valid songs found in CSV") 105 | sys.exit(1) 106 | 107 | print(f"Found {len(songs)} valid songs to process") 108 | 109 | # Process songs in parallel 110 | with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor: 111 | results = list(executor.map(process_song, songs)) 112 | 113 | # Summary 114 | successful = sum(1 for r in results if r is True) 115 | print(f"\nDownload complete!") 116 | print(f"Successfully downloaded: {successful}/{len(songs)} songs") 117 | if successful != len(songs): 118 | print(f"Failed downloads: {len(songs) - successful}") 119 | 120 | 121 | if __name__ == "__main__": 122 | main() 123 | --------------------------------------------------------------------------------