├── .gitignore ├── requirements.txt ├── Makefile ├── README.md └── src └── spotify_dl.py /.gitignore: -------------------------------------------------------------------------------- 1 | **/pyenv 2 | **/pyenv/** -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests==2.32.3 2 | mutagen==1.47.0 #eyed3==0.9.7 3 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | spotify_dl: 2 | echo "\n*Building with PyInstaller*\n" 3 | python -m venv env 4 | source ./env/Scripts/activate 5 | pip install -r requirements.txt 6 | ./env/Scripts/deactivate 7 | pip install pyinstaller 8 | pyinstaller src/spotify_dl.py --onefile --paths ./env/Lib/site-packages 9 | echo "\nDone" 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # spotify-downloader 2 | 3 | Spotify song downloader (downloads songs from publicly available sources) 4 | 5 | The script `src/spotify_dl.py` can be run in interactive mode or CLI mode. 6 | 7 | 8 | ## Build the binary 9 | 10 | To generate a .exe for this tool, with Python installed, run the following commands: 11 | ```shell 12 | git clone https://github.com/MattJaccino/spotify-downloader.git 13 | cd spotify-downloader/ 14 | python -m venv ./venv 15 | cd venv/ 16 | source ./Scripts/activate 17 | pip install -r requirements.txt 18 | pip install pyinstaller 19 | pyinstaller src/spotify_dl.py --onefile --paths ./venv/Lib/site-packages 20 | ``` 21 | 22 | The directory `dist/` will contain the executable. 23 | 24 | 25 | ## Interactive mode 26 | 27 | When run without any arguments, interactive mode is used. The user is prompted for URLs of songs or playlists. If a playlist is given, the user has the option to download individual songs from that playlist or all of them as well as the ability to see the songs in the playlist prior to making a decision. The default download directory is the user's `Downloads/` directory, e.g. `C:\Users\[USER]\Downloads\`. The user is prompted if they want to change the directory prior to downloading the songs. Template variables can be used in the path. 28 | 29 | ## CLI mode 30 | 31 | ```shell 32 | usage: spotify_dl.py [-h] [-u URLS [URLS ...]] [-f FILENAME] [-d {lucida,spotifydown}] [-t {mp3-320,mp3-256,mp3-128,ogg-320,ogg-256,ogg-128,original}] [-o OUTPUT] [-c] [-p {skip,overwrite,append_number}] [-k CONFIG_FILE] [--retry-failed-downloads RETRY_FAILED_DOWNLOADS] [--cfg-file CFG_FILE] [--debug] [-s] 33 | 34 | optional arguments: 35 | -h, --help show this help message and exit 36 | -u URLS [URLS ...], --urls URLS [URLS ...] 37 | URL(s) of Sptofy songs or playlists to download. If a playlist is given, append "|[TRACK NUMBERS]" to URL to specify which tracks to download. Example: 38 | 'https://open.spotify.com/playlist/mYpl4YLi5T|1,4,15-' to download the first, fourth, and fifteenth to the end. If not specified, all tracks are downloaded. 39 | -f FILENAME, --filename FILENAME, --filename-template FILENAME 40 | Specify custom filename template using variables '{title}', '{artist}', and '{track_num}'. Defaults to '{title} - {artist}'. 41 | -d {lucida,spotifydown}, --downloader {lucida,spotifydown} 42 | Specify download server to use. Defaults to 'lucida'. 43 | -t {mp3-320,mp3-256,mp3-128,ogg-320,ogg-256,ogg-128,original}, --file-type {mp3-320,mp3-256,mp3-128,ogg-320,ogg-256,ogg-128,original} 44 | Specify audio file format to download. Must be one of mp3-320, mp3-256, mp3-128, ogg-320, ogg-256, ogg-128, original. Defaults to 'mp3-320'. 45 | -o OUTPUT, --output OUTPUT 46 | Path to directory where tracks should be downloaded to. Defaults to 'C:\Users\mattj\Downloads' 47 | -c, --create-dir Create the output directory if it does not exist. 48 | -p {skip,overwrite,append_number}, --duplicate-download-handling {skip,overwrite,append_number} 49 | How to handle if a track already exists at the download location. Defaults to 'skip'. 50 | -k CONFIG_FILE, --config-file CONFIG_FILE 51 | Path to JSON containing download instructions. 52 | --retry-failed-downloads RETRY_FAILED_DOWNLOADS 53 | Number of times to retry failed downloads. Defaults to 0. 54 | --cfg-file CFG_FILE Path to .cfg file used for user default settings if not using `$HOME/.spotify_dl.cfg`. 55 | --debug Debug mode. 56 | -s, --skip-duplicate-downloads 57 | [To be deprecated] Don't download a song if the file already exists in the output directory. Defaults to True. 58 | ``` 59 | 60 | ### Cfg file 61 | 62 | Not to be confused with the "config" file below (bad name, I know. I'll change it at some point), The user can define a file named `.spotify_dl.cfg` in their home directory (in Windows, it's `C:\Users\[Your user]\`) to define settings that will always be used by this tool. The `Settings` section **must** be defined. 63 | 64 | Example: 65 | 66 | ``` 67 | [Settings] 68 | default_download_location="C:\Users\me\Desktop\folder" 69 | default_filename_template="{artist} - {title}" 70 | ``` 71 | 72 | See the [sample .spotify_dl.cfg](./dist/.spotify_dl.cfg). 73 | 74 | The following values are supported: 75 | * `default_download_location`: Path to directory/folder to download tracks to. 76 | * `default_downloader`: Downloader to use. Either `lucida` or `spotifydown`. 77 | * `default_file_type`: Audio file type to download. See CLI output above for options. _(Only applicable when using Lucida)_. 78 | * `default_filename_template`: Filename format template to use when naming downloads. 79 | * `default_retry_downloads_attempts`: Number of attempts to retry downloading tracks that failed to download. 80 | * `duplicate_download_handling`: How to handle downloads for files that already exist. Options are `skip`, `overwrite`, or `append_number` 81 | 82 | 83 | ### Config file 84 | 85 | Example of JSON file used to contain download instructions: 86 | 87 | ```json 88 | [ 89 | { 90 | "url": "https://open.spotify.com/playlist/mYpl4YLi5T" 91 | }, 92 | { 93 | "url": "https://open.spotify.com/playlist/mYpl4YLi5T2", 94 | "output_dir": "/path/to/dir/", 95 | "create_dir": true, 96 | "filename_template": "{id} - {artist} - {title}" 97 | }, 98 | { 99 | "url": "https://open.spotify.com/playlist/mYpl4YLi5T3", 100 | "skip_duplicate_downloads": true 101 | } 102 | ] 103 | ``` 104 | 105 | The following arguments can be specified in entries within the config JSON: 106 | * `url` (Required) 107 | * `output_dir` 108 | * `downloader` 109 | * `create_dir` 110 | * `skip_duplicate_downloads` 111 | * `duplicate_download_handling` 112 | * `filename_template` 113 | * `file_type` 114 | There types and use match those of the CLI arguments. 115 | 116 | _The arguments `--retry-failed-downloads` and `--cfg-file` are not set in the config JSON. It should be run when using the respective args when executing the tool, e.g., `spotify_dl --config-file path/to/config.json --retry-failed-downloads 3`_ 117 | -------------------------------------------------------------------------------- /src/spotify_dl.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import re 4 | import signal 5 | import sys 6 | import traceback 7 | from argparse import ArgumentParser 8 | from base64 import b64decode 9 | from configparser import ConfigParser 10 | from datetime import datetime 11 | from pathlib import Path 12 | from time import sleep 13 | 14 | # Want to figure out how to do this without a third party module 15 | import requests 16 | from mutagen import File as AudioFile 17 | 18 | 19 | # Cheeky Ctrl+C handler 20 | signal.signal(signal.SIGINT, lambda sig, frame : print('\n\nInterrupt received. Exiting.\n') or sys.exit(0)) 21 | 22 | ### Cfg file constants 23 | CFG_SECTION_HEADER = "Settings" 24 | CFG_DEFAULT_FILENAME_TEMPLATE_OPTION = "default_filename_template" 25 | CFG_DEFAULT_DOWNLOADER_OPTION = "default_downloader" 26 | CFG_DEFAULT_FILE_TYPE_OPTION = "default_file_type" 27 | CFG_DEFAULT_DOWNLOAD_LOCATION_OPTION = "default_download_location" 28 | CFG_DEFAULT_NUM_RETRY_ATTEMPTS_OPTION = "default_retry_downloads_attempts" 29 | CFG_DEFAULT_DUPLICATE_DOWNLOAD_HANDLING = "duplicate_download_handling" 30 | 31 | OUTPUT_DIR_DEFAULT = str(Path.home()/"Downloads") 32 | 33 | ### DOWNLOADER CONSTANTS ### 34 | 35 | # ## Spotifydown constants 36 | # DOWNLOADER_SPOTIFYDOWN = "spotifydown" 37 | # DOWNLOADER_SPOTIFYDOWN_URL = "https://spotifydown.com" 38 | # DOWNLOADER_SPOTIFYDOWN_API_URL = "https://api.spotifydown.com" 39 | # # Clean browser heads for API 40 | # DOWNLOADER_SPOTIFYDOWN_HEADERS = { 41 | # 'Host': 'api.spotifydown.com', 42 | # 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0', 43 | # 'Accept': '*/*', 44 | # 'Accept-Language': 'en-US,en;q=0.5', 45 | # 'Accept-Encoding': 'gzip', 46 | # 'Referer': 'https://spotifydown.com/', 47 | # 'Origin': 'https://spotifydown.com', 48 | # 'DNT': '1', 49 | # 'Connection': 'keep-alive', 50 | # 'Sec-Fetch-Dest': 'empty', 51 | # 'Sec-Fetch-Mode': 'cors', 52 | # 'Sec-Fetch-Site': 'same-site', 53 | # 'Sec-GPC': '1', 54 | # 'TE': 'trailers' 55 | # } 56 | 57 | # ## Lucida constants 58 | # DOWNLOADER_LUCIDA = "lucida" 59 | # DOWNLOADER_LUCIDA_URL = "https://lucida.su" 60 | # # Can't support formats that aren't available in mutagen 61 | # DOWNLOADER_LUCIDA_FILE_FORMATS = [ 62 | # 'original', 63 | # 'mp3-320', 'mp3-256', 'mp3-128', 64 | # 'ogg-vorbis-320', 'ogg-vorbis-256', 'ogg-vorbis-128', 65 | # 'ogg-opus-320', 'ogg-opus-256', 'ogg-opus-128', 'ogg-opus-96', 'ogg-opus-64', 66 | # #'opus-320', 'opus-256', 'opus-128', 'opus-96', 'opus-64', 67 | # 'flac-16', 68 | # 'm4a-aac-320', 'm4a-aac-256', 'm4a-aac-192', 'm4a-aac-128', 69 | # #'bitcrush' 70 | # ] 71 | # DOWNLOADER_LUCIDA_FILE_FORMAT_DEFAULT = "mp3-320" 72 | # DOWNLOADER_LUCIDA_HEADERS = { 73 | # 'Host': 'lucida.su', 74 | # 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0', 75 | # 'Accept': '*/*', 76 | # 'Accept-Language': 'en-US,en;q=0.5', 77 | # 'Accept-Encoding': 'gzip', 78 | # 'Referer': 'https://spotifydown.com/', 79 | # 'DNT': '1', 80 | # 'Connection': 'keep-alive', 81 | # 'Sec-Fetch-Dest': 'document', 82 | # 'Sec-Fetch-Mode': 'navigate', 83 | # 'Sec-Fetch-Site': 'same-origin', 84 | # 'Sec-GPC': '1' 85 | # } 86 | 87 | ## SpotifyMate constants 88 | DOWNLOADER_SPOTIFYMATE = "spotifymate" 89 | DOWNLOADER_SPOTIFYMATE_URL = "https://spotifymate.com" 90 | DOWNLOADER_SPOTIFYMATE_HEADERS = { 91 | "Host": "spotifymate.com", 92 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:134.0) Gecko/20100101 Firefox/134.0", 93 | "Accept": "*/*", 94 | "Accept-Language": "en-US,en;q=0.5", 95 | "Accept-Encoding": "gzip", 96 | "Referer": "https://spotifymate.com/en", 97 | "Origin": "https://spotifymate.com", 98 | "DNT": "1", 99 | "Sec-Fetch-Dest": "empty", 100 | "Sec-Fetch-Mode": "cors", 101 | "Sec-Fetch-Site": "same-origin", 102 | "Connection": "keep-alive", 103 | "Alt-Used": "spotifymate.com", 104 | "Sec-GPC": "1", 105 | "Priority": "u=0" 106 | } 107 | 108 | DOWNLOADER_OPTIONS = [DOWNLOADER_SPOTIFYMATE] # [DOWNLOADER_LUCIDA, DOWNLOADER_SPOTIFYDOWN, DOWNLOADER_SPOTIFYMATE] 109 | DOWNLOADER_DEFAULT = DOWNLOADER_SPOTIFYMATE # DOWNLOADER_LUCIDA 110 | 111 | MULTI_TRACK_INPUT_URL_TRACK_NUMS_RE = re.compile(r'^https?:\/\/open\.spotify\.com\/(album|playlist)\/[\w]+(?:\?[\w=%-]*|)\|(?P.*)$') 112 | 113 | FILENAME_TEMPLATE_VARS = ["title", "artist", "album", "track_num"] 114 | FILENAME_TEMPLATE_DEFAULT = r"{title} - {artist}" 115 | 116 | # In interactive mode, user is prompted upon first duplicate encountered 117 | # Otherwise, set via CLI arg 118 | DUPLICATE_DOWNLOAD_CHOICES = ["skip", "overwrite", "append_number"] 119 | DUPLICATE_DOWNLOAD_CHOICE_DEFAULT = "skip" 120 | duplicate_downloads_action = DUPLICATE_DOWNLOAD_CHOICE_DEFAULT 121 | duplicate_downloads_prompted = False 122 | 123 | 124 | ##### Spotify stuff ##### 125 | 126 | def _get_spotify_token() -> str: 127 | token_resp = requests.post( 128 | url="https://accounts.spotify.com/api/token?grant_type=client_credentials&client_id=" 129 | f"{os.getenv('SPOTIFY_CLIENT_ID', input('Enter Spotify client ID: '))}" 130 | f"&client_secret={os.getenv('SPOTIFY_CLIENT_SECRET', input("Enter Spotify client secret: "))}", #"https://open.spotify.com/get_access_token", 131 | headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:130.0) Gecko/20100101 Firefox/130.0", "Content-Type": "application/x-www-form-urlencoded"} 132 | ) 133 | return token_resp.json()['access_token'] 134 | 135 | 136 | class SpotifySong: 137 | def __init__( 138 | self, 139 | title: str, 140 | artist: str, 141 | album: str, 142 | id: str, 143 | track_number: str = "0", 144 | cover_art_url: str = "", 145 | release_date: str = "" 146 | ): 147 | self.title = title 148 | self.artist = artist 149 | self.album = album 150 | self.track_number = str(track_number) 151 | self.id = id 152 | self.cover_art_url = cover_art_url 153 | self.release_date = release_date 154 | self.url = f"https://open.spotify.com/track/{self.id}" 155 | 156 | def __eq__(self, other): 157 | return self.url == other.url 158 | 159 | def __hash__(self): 160 | return hash(self.url) 161 | 162 | 163 | class SpotifyAlbum: 164 | def __init__( 165 | self, 166 | title: str, 167 | artist: str, 168 | tracks: list, 169 | id: str, 170 | cover_art_url: str, 171 | release_date: str 172 | ): 173 | self.title = title 174 | self.artist = artist 175 | self.tracks = tracks 176 | self.id = id 177 | self.cover_art_url = cover_art_url 178 | self.release_date = release_date 179 | self.url = f"https://open.spotify.com/album/{self.id}" 180 | 181 | def __eq__(self, other): 182 | return self.url == other.url 183 | 184 | 185 | class SpotifyPlaylist: 186 | def __init__( 187 | self, 188 | name: str, 189 | owner: str, 190 | tracks: list, 191 | id: str, 192 | cover_art_url: str 193 | ): 194 | self.name = name 195 | self.owner = owner 196 | self.tracks = tracks 197 | self.id = id 198 | self.cover_art_url = cover_art_url 199 | self.url = f"https://open.spotify.com/playlist/{self.id}" 200 | 201 | def __eq__(self, other): 202 | return self.url == other.url 203 | 204 | 205 | def get_spotify_track(track_id: str, token: str) -> SpotifySong: 206 | # GET to playlist URL can get first 30 songs only 207 | # soup.find_all('meta', content=re.compile("https://open.spotify.com/track/\w+")) 208 | 209 | track_resp = requests.get( 210 | f'https://api.spotify.com/v1/tracks/{track_id}', 211 | headers={'Authorization': f"Bearer {token}"} 212 | ) 213 | 214 | track = track_resp.json() 215 | 216 | return SpotifySong( 217 | title=track['name'], 218 | artist=', '.join(artist['name'] for artist in track['artists']), 219 | album=track['album']['name'], 220 | id=track['id'], 221 | track_number=track['track_number'], 222 | cover_art_url=track['album']['images'][0]['url'] if len(track['album'].get('images', [])) else None, 223 | release_date=track['album']['release_date'] 224 | ) 225 | 226 | 227 | def get_spotify_album(album_id: str, token: str) -> SpotifyAlbum: 228 | # GET to playlist URL can get first 30 songs only 229 | # soup.find_all('meta', content=re.compile("https://open.spotify.com/track/\w+")) 230 | 231 | album_resp = requests.get( 232 | f'https://api.spotify.com/v1/albums/{album_id}', 233 | headers={'Authorization': f"Bearer {token}"} 234 | ) 235 | 236 | album = album_resp.json() 237 | 238 | # Same for all tracks 239 | cover_art_url = album['images'][0]['url'] if len(album.get('images', [])) else None 240 | release_date = album['release_date'] 241 | 242 | tracks_list = [ 243 | SpotifySong( 244 | title=track['name'], 245 | artist=', '.join(artist['name'] for artist in track['artists']), 246 | album=album['name'], 247 | id=track['id'], 248 | track_number=track['track_number'], 249 | cover_art_url=cover_art_url, 250 | release_date=release_date 251 | ) 252 | for track in album['tracks']['items'] 253 | if track 254 | ] 255 | 256 | return SpotifyAlbum( 257 | title=album['name'], 258 | artist=', '.join(artist['name'] for artist in album['artists']), 259 | tracks=tracks_list, 260 | id=album['id'], 261 | cover_art_url=cover_art_url, 262 | release_date=release_date 263 | ) 264 | 265 | 266 | def get_spotify_playlist(playlist_id: str, token: str) -> SpotifyPlaylist: 267 | # GET to playlist URL can get first 30 songs only 268 | # soup.find_all('meta', content=re.compile("https://open.spotify.com/track/\w+")) 269 | 270 | playlist_resp = requests.get( 271 | f'https://api.spotify.com/v1/playlists/{playlist_id}', 272 | headers={'Authorization': f"Bearer {token}"} 273 | ) 274 | 275 | playlist = playlist_resp.json() 276 | playlist_tracks = playlist.get('tracks', []) 277 | 278 | tracks_list = [ 279 | SpotifySong( 280 | title=track_data['track']['name'], 281 | artist=', '.join(artist['name'] for artist in track_data['track']['artists']), 282 | album=track_data['track']['album']['name'], 283 | id=track_data['track']['id'], 284 | track_number=track_data['track']['track_number'], 285 | cover_art_url=track_data['track']['album']['images'][0]['url'] if len(track_data['track']['album'].get('images', [])) else None, 286 | release_date=track_data['track']['album']['release_date'] 287 | ) 288 | for track_data in playlist_tracks['items'] 289 | if track_data['track'] 290 | ] 291 | 292 | while next_chunk_url := playlist_tracks.get('next'): 293 | playlist_resp = requests.get( 294 | next_chunk_url, 295 | headers={'Authorization': f"Bearer {token}"} 296 | ) 297 | 298 | playlist_tracks = playlist_resp.json() 299 | 300 | tracks_list.extend( 301 | SpotifySong( 302 | title=track_data['track']['name'], 303 | artist=', '.join(artist['name'] for artist in track_data['track']['artists']), 304 | album=track_data['track']['album']['name'], 305 | id=track_data['track']['id'], 306 | track_number=track_data['track']['track_number'], 307 | cover_art_url=track_data['track']['album']['images'][0]['url'] if len(track_data['track']['album'].get('images', [])) else None, 308 | release_date=track_data['track']['album']['release_date'] 309 | ) 310 | for track_data in playlist_tracks['items'] 311 | if track_data['track'] 312 | ) 313 | 314 | return SpotifyPlaylist( 315 | name=playlist['name'], 316 | owner=playlist['owner']['display_name'], 317 | tracks=tracks_list, 318 | id=playlist_id, 319 | cover_art_url=playlist['images'][0]['url'] if len(playlist.get('images', [])) else None 320 | ) 321 | 322 | ################### 323 | 324 | def _validate_filename_template(given_str: str, required: bool = True): 325 | if not any(var in given_str for var in FILENAME_TEMPLATE_VARS) \ 326 | and required: 327 | raise ValueError( 328 | "Filename should contain at least one of the following to " 329 | "prevent files from being overwritten: " 330 | f"{', '.join('{' + var + '}' for var in FILENAME_TEMPLATE_VARS)}" 331 | ) 332 | for detected_var in re.findall(r"{(\w+)}", given_str): 333 | if detected_var not in FILENAME_TEMPLATE_VARS: 334 | raise ValueError( 335 | "Variable in filename template of not one of " 336 | f"{', '.join(map(repr, FILENAME_TEMPLATE_VARS))}. Found: '{detected_var}'" 337 | ) 338 | 339 | 340 | def validate_config_file(config_file: Path) -> list: 341 | allowed_args = { 342 | 'url': (str, None), 343 | 'output_dir': (str, None), 344 | 'create_dir': (bool, None), 345 | 'skip_duplicate_downloads': (bool, None), 346 | 'duplicate_download_handling': (str, DUPLICATE_DOWNLOAD_CHOICES), 347 | 'filename_template': (str, None), 348 | # 'file_type': (str, DOWNLOADER_LUCIDA_FILE_FORMATS) 349 | } 350 | 351 | with open(config_file) as config_fp: 352 | loaded_config = json.load(config_fp) 353 | 354 | if not isinstance(loaded_config, list): 355 | raise RuntimeError("Config file must be a list") 356 | 357 | for e_idx, entry in enumerate(loaded_config, start=1): 358 | for key in entry: 359 | if key not in allowed_args: 360 | raise ValueError( 361 | f"Key '{key}' in entry {e_idx} is not valid. " 362 | f"Allowed keys are: {', '.join(allowed_args.keys())}" 363 | ) 364 | elif not isinstance(entry[key], allowed_args[key][0]): 365 | raise ValueError( 366 | f"Key '{key}' in entry {e_idx} is the wrong type. " 367 | f"Argument for '{key}' must be of type '{allowed_args[key]}'" 368 | ) 369 | 370 | # specific validation 371 | elif (allowed_values := allowed_args[key][1]) \ 372 | and entry[key] not in allowed_values: 373 | raise ValueError( 374 | f"Value for key '{key}' in entry {e_idx} is " 375 | f"not one of {' ,'.join(allowed_values)}" 376 | ) 377 | 378 | return loaded_config 379 | 380 | 381 | def parse_cfg(cfg_path: Path) -> ConfigParser: 382 | parser = ConfigParser() 383 | 384 | # just in case 385 | if not isinstance(cfg_path, Path): 386 | cfg_path = Path(str(cfg_path)) 387 | 388 | if not cfg_path.is_file(): 389 | return parser 390 | 391 | try: 392 | parser.read(cfg_path) 393 | except Exception as exc: 394 | print( 395 | f"[!] Cfg file at {cfg_path.absolute()} received a parsing error: {exc}" 396 | ) 397 | sys.exit(1) 398 | 399 | # validate 400 | 401 | if CFG_SECTION_HEADER not in parser: 402 | print( 403 | f"[!] Cfg file at {cfg_path.absolute()} does not contain section '{CFG_SECTION_HEADER}'." 404 | ) 405 | sys.exit(1) 406 | 407 | if default_filename_template := parser.get(CFG_SECTION_HEADER, CFG_DEFAULT_FILENAME_TEMPLATE_OPTION, fallback=None): 408 | try: 409 | _validate_filename_template(default_filename_template) 410 | except Exception as exc: 411 | print( 412 | f"[!] Cfg file at {cfg_path.absolute()} has an invalid value for " 413 | f"'{CFG_DEFAULT_FILENAME_TEMPLATE_OPTION}': {exc}." 414 | ) 415 | sys.exit(1) 416 | 417 | if (default_downloader := parser.get(CFG_SECTION_HEADER, CFG_DEFAULT_DOWNLOADER_OPTION, fallback=None)) \ 418 | and (default_downloader_lower := default_downloader.lower()) not in DOWNLOADER_OPTIONS: 419 | print( 420 | f"[!] Cfg file at {cfg_path.absolute()} has an invalid value for " 421 | f"'{CFG_DEFAULT_DOWNLOADER_OPTION}': '{default_downloader_lower}' " 422 | f"is not one of {', '.join(DOWNLOADER_OPTIONS)}." 423 | ) 424 | sys.exit(1) 425 | 426 | # if (default_file_type := parser.get(CFG_SECTION_HEADER, CFG_DEFAULT_FILE_TYPE_OPTION, fallback=None)) \ 427 | # and (file_type_lower := default_file_type.lower()) not in DOWNLOADER_LUCIDA_FILE_FORMATS: 428 | # print( 429 | # f"[!] Cfg file at {cfg_path.absolute()} has an invalid value for " 430 | # f"'{CFG_DEFAULT_FILE_TYPE_OPTION}': '{file_type_lower}' " 431 | # f"is not one of {', '.join(DOWNLOADER_LUCIDA_FILE_FORMATS)}" 432 | # ) 433 | # sys.exit(1) 434 | 435 | # if (default_download_location := parser.get(CFG_SECTION_HEADER, CFG_DEFAULT_DOWNLOAD_LOCATION_OPTION, fallback=None)) \ 436 | # and not Path(default_download_location).is_dir(): 437 | # print( 438 | # f"[!] Cfg file at {cfg_path.absolute()} has an invalid value for " 439 | # f"'{CFG_DEFAULT_DOWNLOAD_LOCATION_OPTION}': '{default_download_location}' " 440 | # f"was not found." 441 | # ) 442 | # sys.exit(1) 443 | 444 | if (default_num_retries := parser.get(CFG_SECTION_HEADER, CFG_DEFAULT_NUM_RETRY_ATTEMPTS_OPTION, fallback=None)) \ 445 | and not str(default_num_retries).isnumeric(): 446 | print( 447 | f"[!] Cfg file at {cfg_path.absolute()} has an invalid value for " 448 | f"'{CFG_DEFAULT_NUM_RETRY_ATTEMPTS_OPTION}': '{default_num_retries}' " 449 | f"is not an integer." 450 | ) 451 | sys.exit(1) 452 | 453 | if (dup_dl_handling := parser.get(CFG_SECTION_HEADER, CFG_DEFAULT_DUPLICATE_DOWNLOAD_HANDLING, fallback=None)) \ 454 | and not dup_dl_handling in (allowed_vals := DUPLICATE_DOWNLOAD_CHOICES): 455 | print( 456 | f"[!] Cfg file at {cfg_path.absolute()} has an invalid value for " 457 | f"'{CFG_DEFAULT_DUPLICATE_DOWNLOAD_HANDLING}': '{dup_dl_handling}' " 458 | f"is not one of {', '.join(allowed_vals)}." 459 | ) 460 | sys.exit(1) 461 | 462 | print(f"[-] Using user's .cfg file at {cfg_path.absolute()}.\n") 463 | 464 | return parser 465 | 466 | 467 | def get_filename_template_from_user(spotify_dl_cfg: ConfigParser) -> str: 468 | default_filename_template = spotify_dl_cfg.get(CFG_SECTION_HEADER, CFG_DEFAULT_FILENAME_TEMPLATE_OPTION, fallback=FILENAME_TEMPLATE_DEFAULT) 469 | 470 | print( 471 | "\nIf you would like to use a different naming pattern for the file, enter it now.\n" 472 | f"Variables allowed: {', '.join(FILENAME_TEMPLATE_VARS)}. " 473 | r"Must be contained in curly braces {}" 474 | f"\n\nDefault: \"{default_filename_template}\"\n\n" 475 | ) 476 | 477 | # loop until we get something we can use 478 | while 1: 479 | filename_resp = input( 480 | "Filename or press [ENTER] to use default: " 481 | ) 482 | 483 | if not filename_resp: 484 | return default_filename_template 485 | 486 | try: 487 | _validate_filename_template(filename_resp) 488 | except ValueError as exc: 489 | print(f"'{filename_resp}' is invalid - {exc}") 490 | else: 491 | return filename_resp 492 | 493 | 494 | def get_downloader_from_user(spotify_dl_cfg: ConfigParser) -> str: 495 | default_downloader = spotify_dl_cfg.get(CFG_SECTION_HEADER, CFG_DEFAULT_DOWNLOADER_OPTION, fallback=DOWNLOADER_DEFAULT) 496 | 497 | print( 498 | "\nIf you would like to use a different download source, enter it now.\n" 499 | f"Server options: {', '.join(DOWNLOADER_OPTIONS)}." 500 | f"\n\nDefault: \"{default_downloader}\"\n\n" 501 | ) 502 | 503 | # loop until we get something we can use 504 | while 1: 505 | downloader_resp = input( 506 | f"Select {' or '.join(map(repr, DOWNLOADER_OPTIONS))} or press [ENTER] to use default: " 507 | ) 508 | 509 | if not downloader_resp: 510 | return default_downloader 511 | 512 | if (downloader_resp_lower := downloader_resp.lower()) not in DOWNLOADER_OPTIONS: 513 | print(f"'{downloader_resp_lower}' is not one of {', '.join(DOWNLOADER_OPTIONS)}") 514 | else: 515 | return downloader_resp_lower 516 | 517 | 518 | # def get_file_type_from_user(spotify_dl_cfg: ConfigParser) -> str: 519 | # default_file_type = spotify_dl_cfg.get(CFG_SECTION_HEADER, CFG_DEFAULT_FILE_TYPE_OPTION, fallback=DOWNLOADER_LUCIDA_FILE_FORMAT_DEFAULT) 520 | 521 | # print( 522 | # "\nIf you would like to download using a different audio format, enter it now.\n" 523 | # f"Formats allowed: {', '.join(DOWNLOADER_LUCIDA_FILE_FORMATS)}.\n\n" 524 | # f"Default: \"{default_file_type}\"\n\n" 525 | # ) 526 | 527 | # # loop until we get something we can use 528 | # while 1: 529 | # file_type_resp = input( 530 | # "File format or press [ENTER] to use default: " 531 | # ) 532 | 533 | # if not file_type_resp: 534 | # return default_file_type 535 | 536 | # if (file_type_lower := file_type_resp.lower()) not in DOWNLOADER_LUCIDA_FILE_FORMATS: 537 | # print(f"'{file_type_lower}' is not one of {', '.join(DOWNLOADER_LUCIDA_FILE_FORMATS)}") 538 | # else: 539 | # return file_type_lower 540 | 541 | 542 | def assemble_str_from_template( 543 | track: SpotifySong, 544 | template: str, 545 | required: bool = True 546 | ) -> str: 547 | if not template: 548 | template = FILENAME_TEMPLATE_DEFAULT 549 | else: 550 | _validate_filename_template(template, required) 551 | 552 | template = template \ 553 | .replace(r"{track_num}", track.track_number) \ 554 | .replace(r"{title}", track.title) \ 555 | .replace(r"{album}", track.album) \ 556 | .replace(r"{artist}", track.artist) 557 | 558 | return template 559 | 560 | 561 | def set_output_dir( 562 | interactive: bool, 563 | spotify_dl_cfg: ConfigParser, 564 | output_dir: str, 565 | track: SpotifySong = None, 566 | create_dir: bool = False, 567 | prompt_for_new_location: bool = True, 568 | check_dir_exists: bool = True 569 | ) -> Path: 570 | default_output_dir = spotify_dl_cfg.get(CFG_SECTION_HEADER, CFG_DEFAULT_DOWNLOAD_LOCATION_OPTION, fallback=output_dir) 571 | 572 | # specified output dir takes precedence if it's not the default 573 | if output_dir != OUTPUT_DIR_DEFAULT: 574 | default_output_dir = output_dir 575 | 576 | if interactive: 577 | if track: 578 | output_dir = Path(assemble_str_from_template(track, str(default_output_dir), required=False)) 579 | else: 580 | output_dir = Path(default_output_dir) 581 | 582 | if prompt_for_new_location: 583 | print(f"Downloads will go to {output_dir}. If you would like to change, enter the location or press [ENTER]") 584 | 585 | if other_dir := input("(New download location?) "): 586 | output_dir = Path(other_dir) 587 | 588 | if check_dir_exists: 589 | while not output_dir.is_dir(): 590 | mkdir_inp = input(f"The directory '{output_dir.absolute()}' does not exist. Would you like to create it? [y/n]: ") 591 | if mkdir_inp.lower() == 'y': 592 | output_dir.mkdir(parents=True) 593 | else: 594 | output_dir = Path(input("\nNew download location: ")) 595 | 596 | else: 597 | if track: 598 | output_dir = Path(assemble_str_from_template(track, str(output_dir), required=False)) 599 | else: 600 | output_dir = Path(default_output_dir) 601 | 602 | if check_dir_exists and not output_dir.is_dir(): 603 | if create_dir: 604 | output_dir.mkdir(parents=True) 605 | else: 606 | raise ValueError( 607 | f"Specified directory '{output_dir}' is not a valid directory." 608 | ) 609 | 610 | return output_dir 611 | 612 | 613 | # def _download_track_spotifydown(track: SpotifySong) -> requests.Response: 614 | # session = requests.Session() 615 | 616 | # session.send( 617 | # requests.Request( 618 | # method="GET", 619 | # url=DOWNLOADER_SPOTIFYDOWN_URL, 620 | # headers={ 621 | # "Host": "spotifydown.com", 622 | # "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:134.0) Gecko/20100101 Firefox/134.0", 623 | # "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", 624 | # "Accept-Language": "en-US,en;q=0.5", 625 | # "Accept-Encoding": "gzip", 626 | # "DNT": "1", 627 | # "Alt-Used": "spotifydown.com", 628 | # "Connection": "keep-alive", 629 | # "Referer": "https://spotifydown.com/", 630 | # "Upgrade-Insecure-Requests": "1", 631 | # "Sec-Fetch-Dest": "document", 632 | # "Sec-Fetch-Mode": "navigate", 633 | # "Sec-Fetch-Site": "same-origin", 634 | # "Sec-Fetch-User": "?1", 635 | # "Sec-GPC": "1", 636 | # "Priority": "u=0, i", 637 | # "TE": "trailers" 638 | # } 639 | # ).prepare() 640 | # ) 641 | 642 | # try: 643 | # resp = requests.get(f"{DOWNLOADER_SPOTIFYDOWN_API_URL}/download/{track.id}", headers=DOWNLOADER_SPOTIFYDOWN_HEADERS) 644 | # except Exception as exc: 645 | # raise RuntimeError("ERROR: ", exc) 646 | 647 | # resp_json = resp.json() 648 | 649 | # if not resp_json['success']: 650 | # raise RuntimeError(f"Error: {resp.status_code} - {resp_json}") 651 | 652 | # # Clean browser heads for API 653 | # hdrs = { 654 | # #'Host': 'cdn[#].tik.live', # <-- set this below 655 | # 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0', 656 | # 'Accept': '*/*', 657 | # 'Accept-Language': 'en-US,en;q=0.5', 658 | # 'Accept-Encoding': 'gzip', 659 | # 'Referer': 'https://spotifydown.com/', 660 | # 'Origin': 'https://spotifydown.com', 661 | # 'DNT': '1', 662 | # 'Connection': 'keep-alive', 663 | # 'Sec-Fetch-Dest': 'empty', 664 | # 'Sec-Fetch-Mode': 'cors', 665 | # 'Sec-Fetch-Site': 'cross-site', 666 | # 'Sec-GPC': '1' 667 | # } 668 | 669 | # if 'link' not in resp_json or 'metadata' not in resp_json: 670 | # raise RuntimeError( 671 | # f"Bad metadata response for track '{track.artist} - {track.title}': {resp_json}" 672 | # ) 673 | 674 | # hdrs['Host'] = resp_json['link'].split('/')[2] 675 | # return requests.get(resp_json['link'], headers=hdrs) 676 | 677 | 678 | # def _download_track_lucida(track_url: str, file_format=DOWNLOADER_LUCIDA_FILE_FORMAT_DEFAULT) -> requests.Response: 679 | # # Per lemonbar, send max 10 reqs / min, and we already sleep for 1 below. 680 | # file_format = file_format.lower() 681 | 682 | # if file_format not in DOWNLOADER_LUCIDA_FILE_FORMATS: 683 | # raise ValueError(f"File format '{file_format}' is not one of {', '.join(DOWNLOADER_LUCIDA_FILE_FORMATS)}") 684 | 685 | # # Grab token from response from Lucida server 686 | # metadata_req = requests.get( 687 | # url=f"https://lucida.su/?url={track_url}&country=auto", 688 | # headers={ 689 | # 'Host': "lucida.su", 690 | # 'User-Agent': "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:131.0) Gecko/20100101 Firefox/131.0", 691 | # 'Accept': "text/html",#,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/png,image/svg+xml,*/*;q=0.8", 692 | # 'Accept-Language': "en-US,en;q=0.5", 693 | # 'Accept-Encoding': "gzip",#, deflate, br, zstd", 694 | # 'Referer': "https://lucida.su/", 695 | # 'DNT': "1", 696 | # 'Connection': "keep-alive", 697 | # #'Cookie': "cf_clearance=SyaHxXD5M7mLGW9vlS.ljeBiNBa3LMeyZK9AtBrtkT8-1729096731-1.2.1.1-uPEurBb9v0qWI3ifgmnVR7WCRHGqZbDpXpPkdTIEO_qilE9ymDegpxxWYEs.waLo4WKsVany3LOufnDbSi9XyMhLhFWJm4.b3i12MBufpazy0I_AWnBsxBmrxy3EkZjwknlH3xNrR6WZX_JOGCZQJSjO_ooadSuz1VOMWzDZiDdJG52EXvEAgyftXPgBaHb7FS578mMRP9FASKesfre7amhF9wIvq1Upxgk9lIH_O2DMRbNjk1q4d32wawHiFc_DMXUx76_Cw05ZRCjijMujIeaxMcQ1YzhuEjN4yRGc4X7PrJkd2NBxt1T5Jy6_U1yEixuQGoGzVNf8KW5bTyeNUJ0omMczVqr80_uyW.bmw_E", 698 | # 'Upgrade-Insecure-Requests': "1", 699 | # 'Sec-Fetch-Dest': "document", 700 | # 'Sec-Fetch-Mode': "navigate", 701 | # 'Sec-Fetch-Site': "same-origin", 702 | # 'Sec-Fetch-User': "?1", 703 | # 'Sec-GPC': "1", 704 | # 'Priority': "u=0, i" 705 | # }, 706 | # allow_redirects=True, 707 | # ) 708 | 709 | # metadata_req_content_str = metadata_req.content.decode('utf-8', errors='ignore') 710 | 711 | # token = re.search(r"token:\s?\"([\w-]+)\",\s?tokenExpiry:\s?(\d+)", metadata_req_content_str) 712 | 713 | # if not token: 714 | # with open('err.html', 'wb') as f: 715 | # f.write(metadata_req.content) 716 | # raise RuntimeError("Unable to locate token for Lucida", metadata_req.status_code, metadata_req_content_str) 717 | 718 | # dl_target_req = requests.post( 719 | # url="https://lucida.su/api/load?url=%2Fapi%2Ffetch%2Fstream%2Fv2", 720 | # json={ 721 | # "url": track_url, 722 | # "metadata": True, 723 | # "private": True, 724 | # "handoff": True, 725 | # "account": {"type": "country", "id": "auto"}, 726 | # "upload": {"enabled": False, "service": "pixeldrain"}, 727 | # # downscale args: mp3-320, mp3-256, mp3-128, ogg-320, ogg-256, ogg-128, ... 728 | # "downscale": file_format, 729 | # "token": { 730 | # # double b64-decoded str 731 | # "primary": b64decode(b64decode(token.group(1).encode())).decode(), 732 | # "expiry": token.group(2) 733 | # } 734 | # }, 735 | # headers={ 736 | # "Referer": metadata_req.url, 737 | # "Origin": "https://lucida.su" 738 | # } 739 | # ) 740 | 741 | # if not dl_target_req.ok: 742 | # raise RuntimeError(f"Bad dl target response: [{dl_target_req.status_code}] {dl_target_req.content.decode()}") 743 | 744 | # dl_target_req_json = dl_target_req.json() 745 | 746 | # if not dl_target_req_json.get('success'): 747 | # raise RuntimeError(f"Bad dl target JSON response: [{dl_target_req.status_code}] {dl_target_req.content.decode()}") 748 | 749 | # resp = requests.get( 750 | # f"https://lucida.su/api/load?url=%2Fapi%2Ffetch%2Frequest%2F{dl_target_req_json['handoff']}&force={dl_target_req_json['name']}", 751 | # allow_redirects=False 752 | # ) 753 | 754 | # if not resp.ok: 755 | # raise RuntimeError(f"Bad response from API load: [{resp.status_code}] {resp.content.decode()}") 756 | 757 | # sleep(1) 758 | 759 | # try: 760 | # resp = requests.get(resp.url) 761 | # while resp.json()['status'] != "completed": #in ["ripping", "metadata", "downscale"]: 762 | # sleep(1) 763 | # resp = requests.get(resp.url) 764 | # except Exception as exc: 765 | # raise RuntimeError(f"Unexpected error during download: {exc} -- [{resp.status_code}] {resp.content.decode()}") 766 | 767 | # return requests.get( 768 | # f"https://{dl_target_req_json['name']}.lucida.su/api/fetch/request/{dl_target_req_json['handoff']}/download" 769 | # ) 770 | 771 | def _download_track_spotifymate(track_url: str) -> requests.Response: 772 | session = requests.Session() 773 | page_resp = session.send(requests.Request('GET',"https://spotifymate.com/en").prepare()) 774 | 775 | token_re=re.compile(r"Paste URL from Spotify.*\s*") 776 | 777 | token=token_re.search(page_resp.content.decode()) 778 | 779 | if not token: 780 | raise RuntimeError("Token not found in response for Spotifymate title page.") 781 | 782 | track_search_req = requests.Request( 783 | method='POST', 784 | url="https://spotifymate.com/action", 785 | files={ 786 | 'url':(None, track_url), 787 | token.group(1): (None, token.group(2)) 788 | }, 789 | headers=DOWNLOADER_SPOTIFYMATE_HEADERS, 790 | cookies=page_resp.cookies 791 | ) 792 | 793 | r2 = session.send(track_search_req.prepare()) 794 | 795 | dl_re = re.compile(r"href=\"([\w\?:/.=]+)\".*Download Mp3") 796 | 797 | dl_url = dl_re.search(r2.content.decode()) 798 | 799 | if not dl_url: 800 | raise RuntimeError("Download URL not found in response.") 801 | 802 | 803 | return session.send( 804 | requests.Request( 805 | method='GET', 806 | url=dl_url.group(1), 807 | headers=DOWNLOADER_SPOTIFYMATE_HEADERS 808 | ).prepare() 809 | ) 810 | 811 | def get_tracks_to_download( 812 | interactive: bool, 813 | filename_template: str, 814 | spotify_token: str, 815 | cli_arg_urls: list = None 816 | ) -> list: 817 | tracks_to_dl = [] 818 | 819 | if interactive: 820 | print("Enter URL for Spotify track to download, a playlist to download from, or press [ENTER] with an empty line when done.") 821 | 822 | while url := input("> "): 823 | track_obj_title_tuple_list = process_input_url(url, filename_template, interactive, spotify_token) 824 | 825 | if not track_obj_title_tuple_list: 826 | continue 827 | 828 | tracks_to_dl.extend(track_obj_title_tuple_list) 829 | 830 | else: 831 | for url in cli_arg_urls: 832 | track_obj_title_tuple_list = process_input_url(url, filename_template, interactive, spotify_token) 833 | 834 | if not track_obj_title_tuple_list: 835 | continue 836 | 837 | tracks_to_dl.extend(track_obj_title_tuple_list) 838 | 839 | return tracks_to_dl 840 | 841 | 842 | def track_num_inp_to_ind(given_inp: str, list_len: int) -> list: 843 | indexes_or_slices = [] 844 | # Remove whitespace 845 | no_ws = re.sub(r'\s', '', given_inp) 846 | 847 | for item in no_ws.split(','): 848 | 849 | if item.isnumeric(): # ensure the user inputs a valid number in the playlist range 850 | if not 1 <= int(item) <= list_len: 851 | print(f"Track number {item} does not exist. Valid numbers are 1 - {list_len}") 852 | continue 853 | # Subtract one for indexing 854 | indexes_or_slices.append(str(int(item) - 1)) 855 | 856 | elif '-' in item: 857 | start, end = item.split('-') 858 | if not start: 859 | # '-3' --> :3 since that gets the first three tracks, 0, 1, and 2 860 | indexes_or_slices.append(f":{end}") 861 | elif not end: 862 | indexes_or_slices.append(f"{int(start) - 1}:") 863 | else: 864 | indexes_or_slices.append(f"{int(start) - 1}:{end}") 865 | 866 | elif item == '*': 867 | indexes_or_slices.append(':') 868 | 869 | else: 870 | print(f' [!] Invalid input: {item}') 871 | 872 | if not indexes_or_slices: 873 | print(f" [!] No valid input received: '{given_inp}'. Try again.") 874 | 875 | return indexes_or_slices 876 | 877 | 878 | def get_track_nums_input(tracks: list, entity_type: str) -> list: 879 | track_numbers_inp = None 880 | 881 | while not track_numbers_inp: 882 | track_numbers_inp = input('\n' 883 | f" Enter 'show' to list the {entity_type} tracks, the track numbers to download, or '*' to download all:\n" 884 | " Example: '1, 4, 15-' to download the first, fourth, and fifteenth to the end\n" 885 | " > " 886 | ) 887 | 888 | if 'show' in track_numbers_inp.lower(): 889 | print( 890 | '\n ', 891 | '\n '.join(f"{ind + 1:>4}| {track.title} - {track.artist}" for ind, track in enumerate(tracks)), 892 | '\n', 893 | sep='' 894 | ) 895 | track_numbers_inp = None 896 | 897 | return track_numbers_inp 898 | 899 | 900 | def process_input_url(url: str, filename_template: str, interactive: bool, spotify_token: str) -> list: 901 | track_obj_title_tuples = [] 902 | 903 | if "/track/" in url: 904 | track_obj = get_spotify_track(track_id=url.split('/')[-1].split('?')[0], token=spotify_token) 905 | 906 | if not track_obj: 907 | print(f"\t[!] Song not found{f' at {url}' if not interactive else ''}.") 908 | return [] 909 | 910 | out_file_title = assemble_str_from_template( 911 | track=track_obj, 912 | template=filename_template 913 | ) 914 | 915 | print(f"\t{out_file_title}") 916 | 917 | track_obj_title_tuples.append((track_obj, out_file_title)) 918 | 919 | elif "/playlist/" in url or "/album/" in url: 920 | entity_id = url.split('/')[-1].split('?')[0].split('|')[0] 921 | 922 | if "/playlist/" in url: 923 | entity_type = "playlist" 924 | multi_track_obj = get_spotify_playlist(playlist_id=entity_id, token=spotify_token) 925 | else: 926 | entity_type = "album" 927 | multi_track_obj = get_spotify_album(album_id=entity_id, token=spotify_token) 928 | 929 | if not multi_track_obj: 930 | print( 931 | f"\t[!] {entity_type.capitalize()} not found{f' at {url}' if not interactive else ''}" 932 | f"{' or it is set to Private' if entity_type == 'playlist' else ''}." 933 | ) 934 | return [] 935 | 936 | if isinstance(multi_track_obj, SpotifyAlbum): 937 | print(f"\t{multi_track_obj.title} - {multi_track_obj.artist} ({len(multi_track_obj.tracks)} tracks)") 938 | else: 939 | print(f"\t{multi_track_obj.name} - {multi_track_obj.owner} ({len(multi_track_obj.tracks)} tracks)") 940 | 941 | album_or_playlist_tracks = multi_track_obj.tracks 942 | 943 | if interactive: 944 | track_numbers_inp = get_track_nums_input(album_or_playlist_tracks, entity_type) 945 | 946 | while not (indexes_or_slices := track_num_inp_to_ind(track_numbers_inp, list_len=len(album_or_playlist_tracks))): 947 | track_numbers_inp = get_track_nums_input(album_or_playlist_tracks, entity_type) 948 | 949 | else: 950 | if specified_track_nums := MULTI_TRACK_INPUT_URL_TRACK_NUMS_RE.match(url): 951 | track_numbers_inp = specified_track_nums.group('track_nums') 952 | else: 953 | # Default to downloading whole playlist/album 954 | track_numbers_inp = '*' 955 | 956 | indexes_or_slices = track_num_inp_to_ind(track_numbers_inp, list_len=len(album_or_playlist_tracks)) 957 | 958 | if not indexes_or_slices: 959 | raise ValueError( 960 | f"Invalid track number indentifer(s) given: '{specified_track_nums}'" 961 | ) 962 | 963 | # Process input given for which tracks to download 964 | tracks_to_dl = [] 965 | for index_or_slice in indexes_or_slices: 966 | 967 | if index_or_slice.isnumeric(): 968 | tracks_to_dl.append(album_or_playlist_tracks[int(index_or_slice)]) 969 | else: 970 | tracks_to_dl.extend( 971 | eval(f"album_or_playlist_tracks[{index_or_slice}]") 972 | ) 973 | 974 | for track in sorted(tracks_to_dl, key=album_or_playlist_tracks.index): 975 | 976 | # pad with 0s based on length of str of number of tracks in album/playlist 977 | track_num = str(album_or_playlist_tracks.index(track) + 1).zfill(len(str(len(album_or_playlist_tracks)))) 978 | 979 | track.track_number = track_num 980 | 981 | out_file_title = assemble_str_from_template( 982 | track=track, 983 | template=filename_template 984 | ) 985 | 986 | print(f"\t{album_or_playlist_tracks.index(track) + 1:>4}| {out_file_title}") 987 | 988 | track_obj_title_tuples.append((track, out_file_title)) 989 | 990 | else: 991 | print(f"\t[!] Invalid URL{f' -- {url}' if not interactive else ''}.") 992 | return [] 993 | 994 | return track_obj_title_tuples 995 | 996 | 997 | def download_track( 998 | track: SpotifySong, 999 | spotify_dl_cfg: ConfigParser, 1000 | out_file_title: str, 1001 | output_dir: str, 1002 | create_dir: bool, 1003 | downloader: str = DOWNLOADER_DEFAULT, 1004 | # file_type: str = DOWNLOADER_LUCIDA_FILE_FORMAT_DEFAULT, 1005 | interactive: bool = False, 1006 | duplicate_download_handling: str = DUPLICATE_DOWNLOAD_CHOICE_DEFAULT, 1007 | skip_duplicates: bool = False 1008 | ): 1009 | # if downloader == DOWNLOADER_LUCIDA: 1010 | # # This might come back to bite me in the ass. need to infer file type 1011 | # file_ext = file_type.split('-')[0] if file_type != 'original' else "ogg" 1012 | # else: 1013 | file_ext = "mp3" 1014 | 1015 | track_filename = re.sub(r'[<>:"/\|\\?*]', '_', f"{out_file_title}.{file_ext}") 1016 | 1017 | global duplicate_downloads_action 1018 | global duplicate_downloads_prompted 1019 | 1020 | dest_dir = set_output_dir( 1021 | interactive=interactive, 1022 | track=track, 1023 | spotify_dl_cfg=spotify_dl_cfg, 1024 | output_dir=output_dir, 1025 | create_dir=create_dir, 1026 | prompt_for_new_location=False, 1027 | check_dir_exists=True 1028 | ) 1029 | 1030 | if (dest_dir/track_filename).exists(): 1031 | # Use user's defined handling if there is one 1032 | if dup_dl_handling := spotify_dl_cfg.get(CFG_SECTION_HEADER, CFG_DEFAULT_DUPLICATE_DOWNLOAD_HANDLING, fallback=None): 1033 | 1034 | print("Using action defined in .cfg...") 1035 | 1036 | duplicate_downloads_action = dup_dl_handling 1037 | 1038 | # don't prompt the user 1039 | duplicate_downloads_prompted = True 1040 | 1041 | if (duplicate_download_handling == "skip") \ 1042 | or skip_duplicates \ 1043 | or (duplicate_downloads_action == "skip"): 1044 | print(f"Skipping download for '{out_file_title}'...") 1045 | return 1046 | 1047 | if interactive and not duplicate_downloads_prompted: 1048 | dup_song_inp = input( 1049 | f"The song '{out_file_title}' was already downloaded to {dest_dir.absolute()}.\n" 1050 | " Would you like to download it again? [y/N]: " 1051 | ) 1052 | 1053 | if skip_this_dl := (not dup_song_inp or dup_song_inp.lower().startswith('n')): 1054 | print("\nSkipping download.\n") 1055 | # Prompt user if we haven't yet before skipping this one 1056 | 1057 | if not duplicate_downloads_prompted: 1058 | dup_all_inp = input( 1059 | " Would you like to re-download songs that have already been downloaded? [y/N]: " 1060 | ) 1061 | 1062 | if not dup_all_inp or dup_all_inp.lower().startswith('n'): 1063 | duplicate_downloads_action = "skip" 1064 | print("\nSkipping duplicate downloads.\n") 1065 | elif dup_all_inp.lower().startswith('y'): 1066 | dup_append_inp = input( 1067 | " Would you like to append a number to filenames for songs that have already been downloaded? [y/N]: " 1068 | ) 1069 | if not dup_append_inp or dup_append_inp.lower().startswith('n'): 1070 | duplicate_downloads_action = "overwrite" 1071 | print("\nRe-downloading all tracks.\n") 1072 | else: 1073 | duplicate_downloads_action = "append_number" 1074 | print("\nRe-downloading all tracks but not overwriting.\n") 1075 | 1076 | duplicate_downloads_prompted = True 1077 | 1078 | if skip_this_dl: 1079 | return 1080 | 1081 | if duplicate_downloads_action == "append_number": 1082 | num = 0 1083 | while (dest_dir/track_filename).exists(): 1084 | num += 1 1085 | track_filename = re.sub(r'[<>:"/\|\\?*]', '_', f"{out_file_title} ({num}).{file_ext}") 1086 | 1087 | print(f"Downloading: '{out_file_title}'...") 1088 | 1089 | if downloader == DOWNLOADER_SPOTIFYMATE: 1090 | audio_dl_resp = _download_track_spotifymate(track_url=track.url) 1091 | 1092 | # elif downloader == DOWNLOADER_LUCIDA: 1093 | # audio_dl_resp = _download_track_lucida(track_url=track.url, file_format=file_type) 1094 | 1095 | # elif downloader == DOWNLOADER_SPOTIFYDOWN: 1096 | # audio_dl_resp = _download_track_spotifydown(track=track) 1097 | 1098 | else: 1099 | raise ValueError(f"Unrecognized downloader: '{downloader}'") 1100 | 1101 | # Lucida returns HTML when it has a problem 1102 | if not audio_dl_resp.ok \ 1103 | or audio_dl_resp.content.startswith(b""): 1104 | raise RuntimeError( 1105 | f"Bad download response for track '{out_file_title}': [{audio_dl_resp.status_code}] {audio_dl_resp.content}" 1106 | ) 1107 | 1108 | with open(dest_dir/track_filename, 'wb') as track_audio_fp: 1109 | track_audio_fp.write(audio_dl_resp.content) 1110 | 1111 | audio_file = AudioFile(dest_dir/track_filename) 1112 | 1113 | if not audio_file: 1114 | print("\tUnable to parse metadata of track file! Fields like 'title' may be empty.") 1115 | 1116 | else: 1117 | if audio_file.tags: 1118 | # Remove comments 1119 | for key in ['comment', 'COMM', 'COMM::\x00\x00\x00']: 1120 | try: 1121 | del audio_file.tags[key] 1122 | except KeyError: 1123 | pass 1124 | 1125 | else: 1126 | # No idea if this will work, hopefully it won't have to 1127 | audio_file.tags['title'] = [track.title] 1128 | audio_file.tags['artist'] = track.artist.split(', ') 1129 | audio_file.tags['album'] = [track.album] 1130 | 1131 | # For cover art 1132 | if track.cover_art_url: 1133 | cover_resp = requests.get(track.cover_art_url) 1134 | audio_file.tags['metadata_block_picture'] = cover_resp.content 1135 | 1136 | if track.release_date: 1137 | audio_file.tags['release_date'] = [track.release_date] 1138 | 1139 | if track.track_number: 1140 | audio_file.tags['tracknumber'] = [track.track_number] 1141 | 1142 | audio_file.save() 1143 | 1144 | # prevent API throttling 1145 | sleep(0.5) 1146 | 1147 | print("\tDone.") 1148 | 1149 | 1150 | def download_all_tracks( 1151 | tracks_to_dl: list, 1152 | interactive: bool, 1153 | duplicate_download_handling: str, 1154 | skip_duplicate_downloads: bool, 1155 | spotify_dl_cfg: ConfigParser, 1156 | output_dir: str = OUTPUT_DIR_DEFAULT, 1157 | create_dir: bool = False, 1158 | downloader: str = DOWNLOADER_DEFAULT, 1159 | # file_type: str = DOWNLOADER_LUCIDA_FILE_FORMAT_DEFAULT, 1160 | debug_mode: bool = False 1161 | ) -> list: 1162 | downloader = spotify_dl_cfg.get(CFG_SECTION_HEADER, CFG_DEFAULT_DOWNLOADER_OPTION, fallback=downloader) 1163 | 1164 | output_dir = set_output_dir( 1165 | interactive=interactive, 1166 | track=None, 1167 | spotify_dl_cfg=spotify_dl_cfg, 1168 | output_dir=output_dir, 1169 | create_dir=create_dir, 1170 | prompt_for_new_location=True, 1171 | check_dir_exists=False 1172 | ) 1173 | 1174 | print(f"\nDownloading to '{Path(output_dir).absolute()}' using {downloader.capitalize()}.\n") 1175 | 1176 | print('-' * 32) 1177 | 1178 | tracks = list(dict.fromkeys(tracks_to_dl)) 1179 | broken_tracks = [] 1180 | 1181 | for idx, (track_obj, out_file_title) in enumerate(tracks, start=1): 1182 | 1183 | print(f"[{idx:>3}/{len(tracks):>3}]", end=' ') 1184 | 1185 | try: 1186 | download_track( 1187 | track=track_obj, 1188 | spotify_dl_cfg=spotify_dl_cfg, 1189 | out_file_title=out_file_title, 1190 | output_dir=output_dir, 1191 | create_dir=create_dir, 1192 | downloader=downloader, 1193 | # file_type=file_type, 1194 | interactive=interactive, 1195 | duplicate_download_handling=duplicate_download_handling, 1196 | skip_duplicates=skip_duplicate_downloads 1197 | ) 1198 | 1199 | except Exception as exc: 1200 | print("\tDownload failed!") 1201 | 1202 | print("!!!",exc) 1203 | 1204 | broken_tracks.append((track_obj, out_file_title, output_dir, create_dir, idx)) 1205 | 1206 | if debug_mode: 1207 | with open('.spotify_dl_err.txt', 'a') as debug_fp: 1208 | debug_fp.write( 1209 | f"{datetime.now()} | {exc}" 1210 | f":: {traceback.format_exc()}\n\n" 1211 | ) 1212 | 1213 | print("\nAll done.\n") 1214 | if broken_tracks: 1215 | print( 1216 | f"[!] {len(broken_tracks)} track{'s' if len(broken_tracks) != 1 else ''} failed " 1217 | "to download. Before exiting, there will be a chance to retry downloading these.\n" 1218 | ) 1219 | 1220 | return broken_tracks 1221 | 1222 | 1223 | def spotify_downloader( 1224 | interactive: bool, 1225 | spotify_token: str, 1226 | spotify_dl_cfg: ConfigParser, 1227 | downloader: str = DOWNLOADER_DEFAULT, 1228 | urls: list = None, 1229 | output_dir: str = OUTPUT_DIR_DEFAULT, 1230 | create_dir: bool = None, 1231 | duplicate_download_handling: str = DUPLICATE_DOWNLOAD_CHOICE_DEFAULT, 1232 | skip_duplicate_downloads: bool = None, 1233 | debug_mode: bool = None, 1234 | filename_template: str = FILENAME_TEMPLATE_DEFAULT, 1235 | # file_type: str = DOWNLOADER_LUCIDA_FILE_FORMAT_DEFAULT 1236 | ): 1237 | loop_prompt = True 1238 | 1239 | broken_tracks = [] 1240 | while loop_prompt and (tracks_to_dl := get_tracks_to_download( 1241 | interactive=interactive, 1242 | spotify_token=spotify_token, 1243 | filename_template=filename_template,cli_arg_urls=urls)): 1244 | 1245 | print(f"\nTracks to download: {len(tracks_to_dl)}\n") 1246 | 1247 | broken_tracks.extend( 1248 | download_all_tracks( 1249 | tracks_to_dl=tracks_to_dl, 1250 | interactive=interactive, 1251 | spotify_dl_cfg=spotify_dl_cfg, 1252 | downloader=downloader, 1253 | output_dir=output_dir, 1254 | create_dir=create_dir, 1255 | duplicate_download_handling=duplicate_download_handling, 1256 | skip_duplicate_downloads=skip_duplicate_downloads, 1257 | # file_type=file_type, 1258 | debug_mode=debug_mode 1259 | ) 1260 | ) 1261 | if not interactive: 1262 | loop_prompt = False 1263 | 1264 | return broken_tracks 1265 | 1266 | 1267 | def parse_args(): 1268 | parser = ArgumentParser() 1269 | 1270 | parser.add_argument( 1271 | '-u', 1272 | '--urls', 1273 | nargs='+', 1274 | help="URL(s) of Sptofy songs or playlists to download. " 1275 | "If a playlist is given, append \"|[TRACK NUMBERS]\" to URL to specify which tracks to download. " 1276 | "Example: 'https://open.spotify.com/playlist/mYpl4YLi5T|1,4,15-' to download the first, fourth, " 1277 | "and fifteenth to the end. If not specified, all tracks are downloaded." 1278 | ) 1279 | parser.add_argument( 1280 | '-f', 1281 | '--filename', 1282 | '--filename-template', 1283 | type=str, 1284 | default=FILENAME_TEMPLATE_DEFAULT, 1285 | help=r"Specify custom filename template using variables '{title}', '{artist}', and '{track_num}'. " 1286 | f"Defaults to '{FILENAME_TEMPLATE_DEFAULT}'." 1287 | ) 1288 | parser.add_argument( 1289 | '-d', 1290 | '--downloader', 1291 | type=str, 1292 | choices=DOWNLOADER_OPTIONS, 1293 | default=DOWNLOADER_DEFAULT, 1294 | help=f"Specify download server to use. Defaults to '{DOWNLOADER_DEFAULT}'." 1295 | ) 1296 | # parser.add_argument( 1297 | # '-t', 1298 | # '--file-type', 1299 | # type=str, 1300 | # choices=DOWNLOADER_LUCIDA_FILE_FORMATS, 1301 | # default=DOWNLOADER_LUCIDA_FILE_FORMAT_DEFAULT, 1302 | # help=f"Specify audio file format to download. Must be one of {', '.join(DOWNLOADER_LUCIDA_FILE_FORMATS)}. " 1303 | # f"Defaults to '{DOWNLOADER_LUCIDA_FILE_FORMAT_DEFAULT}'." 1304 | # ) 1305 | parser.add_argument( 1306 | '-o', 1307 | '--output', 1308 | type=str, 1309 | default=OUTPUT_DIR_DEFAULT, 1310 | help=f"Path to directory where tracks should be downloaded to. Defaults to '{OUTPUT_DIR_DEFAULT}'" 1311 | ) 1312 | parser.add_argument( 1313 | '-c', 1314 | '--create-dir', 1315 | action='store_true', 1316 | help="Create the output directory if it does not exist." 1317 | ) 1318 | parser.add_argument( 1319 | '-p', 1320 | '--duplicate-download-handling', 1321 | choices=DUPLICATE_DOWNLOAD_CHOICES, 1322 | default=DUPLICATE_DOWNLOAD_CHOICE_DEFAULT, 1323 | help="How to handle if a track already exists at the download location. " 1324 | f"Defaults to '{DUPLICATE_DOWNLOAD_CHOICE_DEFAULT}'." 1325 | ) 1326 | parser.add_argument( 1327 | '-k', 1328 | '--config-file', 1329 | type=Path, 1330 | help="Path to JSON containing download instructions." 1331 | ) 1332 | parser.add_argument( 1333 | '--retry-failed-downloads', 1334 | type=int, 1335 | default=0, 1336 | help="Number of times to retry failed downloads. Defaults to 0." 1337 | ) 1338 | parser.add_argument( 1339 | '--cfg-file', 1340 | type=Path, 1341 | help="Path to .cfg file used for user default settings if not using `$HOME/.spotify_dl.cfg`." 1342 | ) 1343 | parser.add_argument( 1344 | '--debug', 1345 | action='store_true', 1346 | default=False, 1347 | help="Debug mode." 1348 | ) 1349 | # for backwards compatibility 1350 | parser.add_argument( 1351 | '-s', 1352 | '--skip-duplicate-downloads', 1353 | action='store_true', 1354 | default=True, 1355 | help="[To be deprecated] Don't download a song if the file already exists in the output directory. Defaults to True." 1356 | ) 1357 | 1358 | return parser.parse_args() 1359 | 1360 | 1361 | def main(): 1362 | print('', '=' * 48, '|| Spotify Song Downloader v0.2.1 ||', '=' * 48, sep='\n', end='\n\n') 1363 | 1364 | token = _get_spotify_token() 1365 | 1366 | spotify_dl_cfg = parse_cfg(Path.home()/".spotify_dl.cfg") 1367 | 1368 | # No given args 1369 | if len(sys.argv) == 1: 1370 | # Interactive mode 1371 | interactive = True 1372 | 1373 | downloader = get_downloader_from_user(spotify_dl_cfg) 1374 | 1375 | filename_template = get_filename_template_from_user(spotify_dl_cfg) 1376 | 1377 | # if downloader == DOWNLOADER_LUCIDA: 1378 | # out_file_type = get_file_type_from_user(spotify_dl_cfg) 1379 | # else: 1380 | out_file_type = "mp3" 1381 | 1382 | broken_tracks = spotify_downloader( 1383 | interactive=interactive, 1384 | spotify_token=token, 1385 | spotify_dl_cfg=spotify_dl_cfg, 1386 | downloader=downloader, 1387 | output_dir=OUTPUT_DIR_DEFAULT, 1388 | urls=None, 1389 | create_dir=None, 1390 | debug_mode=None, 1391 | filename_template=filename_template, 1392 | file_type=out_file_type 1393 | ) 1394 | 1395 | else: 1396 | # CLI mode 1397 | interactive = False 1398 | 1399 | args = parse_args() 1400 | 1401 | if args.cfg_file: 1402 | spotify_dl_cfg = parse_cfg(args.cfg_file) 1403 | 1404 | out_file_type = args.file_type 1405 | downloader = args.downloader 1406 | 1407 | if not (config_file := args.config_file): 1408 | 1409 | if not (urls := args.urls): 1410 | raise ValueError( 1411 | "The '-u'/'--urls' argument must be " 1412 | "supplied if not using a config file" 1413 | ) 1414 | 1415 | broken_tracks = spotify_downloader( 1416 | interactive=interactive, 1417 | spotify_token=token, 1418 | spotify_dl_cfg=spotify_dl_cfg, 1419 | downloader=downloader, 1420 | output_dir=args.output, 1421 | urls=urls, 1422 | create_dir=args.create_dir, 1423 | duplicate_download_handling=args.duplicate_download_handling, 1424 | skip_duplicate_downloads=args.skip_duplicate_downloads, 1425 | debug_mode=args.debug, 1426 | filename_template=args.filename 1427 | ) 1428 | 1429 | else: 1430 | loaded_config = validate_config_file(config_file) 1431 | 1432 | broken_tracks = [] 1433 | 1434 | for entry in loaded_config: 1435 | broken_tracks.extend( 1436 | spotify_downloader( 1437 | interactive=interactive, 1438 | spotify_token=token, 1439 | spotify_dl_cfg=spotify_dl_cfg, 1440 | output_dir=entry['output_dir'] if 'output_dir' in entry else OUTPUT_DIR_DEFAULT, 1441 | downloader=downloader or entry.get('downloader', DOWNLOADER_DEFAULT), 1442 | urls=[entry['url']], 1443 | create_dir=entry.get('create_dir'), 1444 | duplicate_download_handling=entry.get('duplicate_download_handling', DUPLICATE_DOWNLOAD_CHOICE_DEFAULT), 1445 | skip_duplicate_downloads=entry.get('skip_duplicate_downloads', False), 1446 | debug_mode=args.debug, 1447 | filename_template=entry.get('filename_template'), 1448 | # file_type=entry.get('file_type', DOWNLOADER_LUCIDA_FILE_FORMAT_DEFAULT) 1449 | ) 1450 | ) 1451 | 1452 | if broken_tracks: 1453 | nl = '\n' 1454 | print( 1455 | "\n[!] The following tracks could not be downloaded:\n" 1456 | f" * {f'{nl} * '.join(out_file_title for _, out_file_title, *_ in broken_tracks)}\n" 1457 | ) 1458 | 1459 | num_retries_cfg = int(spotify_dl_cfg.get(CFG_SECTION_HEADER, CFG_DEFAULT_NUM_RETRY_ATTEMPTS_OPTION, fallback=0)) 1460 | 1461 | if not interactive: 1462 | num_retries = args.retry_failed_downloads or num_retries_cfg 1463 | else: 1464 | resp = input("Would you like to retry downloading these tracks? [y/N]\n") 1465 | if resp.lower() == 'y': 1466 | while 1: 1467 | try: 1468 | num_retries = num_retries_cfg or int(input("How many attempts?\n")) 1469 | break 1470 | except Exception: 1471 | print("Invalid response. Please enter a number.\n") 1472 | else: 1473 | print("Not attempting to download.") 1474 | num_retries = 0 1475 | 1476 | if num_retries: 1477 | print("Re-attempting to download tracks") 1478 | for i in range(num_retries): 1479 | 1480 | if not broken_tracks: 1481 | break 1482 | 1483 | print(f"\nAttempt {i + 1} of {num_retries}") 1484 | for track, out_file_title, output_dir, create_dir, idx in broken_tracks.copy(): 1485 | 1486 | try: 1487 | download_track( 1488 | track=track, 1489 | spotify_dl_cfg=spotify_dl_cfg, 1490 | out_file_title=out_file_title, 1491 | output_dir=output_dir, 1492 | create_dir=create_dir, 1493 | downloader=downloader, 1494 | file_type=out_file_type 1495 | ) 1496 | 1497 | except Exception as exc: 1498 | print("\tDownload failed!") 1499 | 1500 | else: 1501 | broken_tracks.remove((track, out_file_title, output_dir, create_dir, idx)) 1502 | sleep(1) 1503 | 1504 | if interactive: 1505 | input("\nPress [ENTER] to exit.\n") 1506 | 1507 | # Give a chance to see the messages if running via executable 1508 | sleep(1) 1509 | print("\nExiting...\n") 1510 | sleep(3) 1511 | 1512 | 1513 | if __name__ == '__main__': 1514 | main() 1515 | --------------------------------------------------------------------------------