├── README.md └── zspotify.py /README.md: -------------------------------------------------------------------------------- 1 | # zspotify 2 | Spotify song downloader without injecting into the windows client 3 | ![image](https://user-images.githubusercontent.com/12180913/137086248-371a3d81-75b3-4d75-a90c-966549c45745.png) 4 | ``` 5 | sudo apt install ffmpeg (For windows download the binarys and place it in %PATH%) 6 | pip music_tag 7 | pip pydub 8 | pip install git+https://github.com/kokarare1212/librespot-python 9 | ``` 10 | 11 | 12 | - Use "-p" or "--playlist" to download a saved playlist from our account 13 | - Supply the URL or ID of a Track/Album/Playlist as an argument to download it 14 | - Don't supply any arguments and it will give you a search input field to find and download a specific Track/Album/Playlist via the query. 15 | -------------------------------------------------------------------------------- /zspotify.py: -------------------------------------------------------------------------------- 1 | import os 2 | import os.path 3 | import platform 4 | 5 | import unicodedata 6 | import re 7 | 8 | import subprocess 9 | import time 10 | import sys 11 | 12 | import requests 13 | 14 | import json 15 | 16 | import music_tag 17 | 18 | from librespot.audio.decoders import AudioQuality, VorbisOnlyAudioQuality 19 | from librespot.core import Session 20 | from librespot.metadata import TrackId 21 | 22 | from pydub import AudioSegment 23 | 24 | quality: AudioQuality = AudioQuality.HIGH 25 | #quality: AudioQuality = AudioQuality.VERY_HIGH #Uncomment this line if you have a premium account 26 | session: Session = None 27 | 28 | import hashlib 29 | 30 | 31 | rootPath = "ZSpotify Music/" 32 | skipExistingFiles = True 33 | 34 | 35 | #miscellaneous functions for general use 36 | def clear(): 37 | if platform.system() == "Windows": 38 | os.system("cls") 39 | else: 40 | os.system("clear") 41 | 42 | def wait(seconds: int = 3): 43 | for i in range(seconds)[::-1]: 44 | print("\rWait for %d second(s)..." % (i + 1), end="") 45 | time.sleep(1) 46 | 47 | def sanitizeData(value): 48 | return value.replace("\\", "").replace("/", "").replace(":", "").replace("*", "").replace("?", "").replace("'", "").replace("<", "").replace(">", "").replace('"',"") 49 | 50 | def splash(): 51 | print("=================================\n" 52 | "| Spotify Downloader |\n" 53 | "| |\n" 54 | "| by Footsiefat/Deathmonger |\n" 55 | "=================================\n\n\n") 56 | 57 | 58 | 59 | #two mains functions for logging in and doing client stuff 60 | def login(): 61 | global session 62 | 63 | if os.path.isfile("credentials.json"): 64 | try: 65 | session = Session.Builder().stored_file().create() 66 | return 67 | except RuntimeError: 68 | pass 69 | while True: 70 | user_name = input("UserName: ") 71 | password = input("Password: ") 72 | try: 73 | session = Session.Builder().user_pass(user_name, password).create() 74 | return 75 | except RuntimeError: 76 | pass 77 | 78 | def client(): 79 | global quality, session 80 | splash() 81 | if len(sys.argv) > 1: 82 | if sys.argv[1] != "-p" and sys.argv[1] != "--playlist": 83 | track_uri_search = re.search( 84 | r"^spotify:track:(?P[0-9a-zA-Z]{22})$", sys.argv[1]) 85 | track_url_search = re.search( 86 | r"^(https?://)?open\.spotify\.com/track/(?P[0-9a-zA-Z]{22})(\?si=.+?)?$", 87 | sys.argv[1], 88 | ) 89 | 90 | album_uri_search = re.search( 91 | r"^spotify:album:(?P[0-9a-zA-Z]{22})$", sys.argv[1]) 92 | album_url_search = re.search( 93 | r"^(https?://)?open\.spotify\.com/album/(?P[0-9a-zA-Z]{22})(\?si=.+?)?$", 94 | sys.argv[1], 95 | ) 96 | 97 | playlist_uri_search = re.search( 98 | r"^spotify:playlist:(?P[0-9a-zA-Z]{22})$", sys.argv[1]) 99 | playlist_url_search = re.search( 100 | r"^(https?://)?open\.spotify\.com/playlist/(?P[0-9a-zA-Z]{22})(\?si=.+?)?$", 101 | sys.argv[1], 102 | ) 103 | 104 | if track_uri_search is not None or track_url_search is not None: 105 | track_id_str = (track_uri_search 106 | if track_uri_search is not None else 107 | track_url_search).group("TrackID") 108 | 109 | downloadTrack(track_id_str) 110 | elif album_uri_search is not None or album_url_search is not None: 111 | album_id_str = (album_uri_search 112 | if album_uri_search is not None else 113 | album_url_search).group("AlbumID") 114 | 115 | downloadAlbum(album_id_str) 116 | elif playlist_uri_search is not None or playlist_url_search is not None: 117 | playlist_id_str = (playlist_uri_search 118 | if playlist_uri_search is not None else 119 | playlist_url_search).group("PlaylistID") 120 | 121 | token = session.tokens().get("user-read-email") 122 | playlistSongs = get_playlist_songs(token, playlist_id_str) 123 | name, creator = get_playlist_info(token, playlist_id_str) 124 | for song in playlistSongs: 125 | downloadTrack(song['track']['id'], name + "/") 126 | print("\n") 127 | else: 128 | downloadFromOurPlaylists() 129 | else: 130 | searchText = input("Enter search: ") 131 | search(searchText) 132 | wait() 133 | 134 | 135 | 136 | #related functions that do stuff with the spotify API 137 | def search(searchTerm): 138 | token = session.tokens().get("user-read-email") 139 | 140 | 141 | resp = requests.get( 142 | "https://api.spotify.com/v1/search", 143 | { 144 | "limit": "10", 145 | "offset": "0", 146 | "q": searchTerm, 147 | "type": "track,album,playlist" 148 | }, 149 | headers={"Authorization": "Bearer %s" % token}, 150 | ) 151 | 152 | i = 1 153 | tracks = resp.json()["tracks"]["items"] 154 | if len(tracks) > 0: 155 | print("### TRACKS ###") 156 | for track in tracks: 157 | print("%d, %s | %s" % ( 158 | i, 159 | track["name"], 160 | ",".join([artist["name"] for artist in track["artists"]]), 161 | )) 162 | i += 1 163 | totalTracks = i - 1 164 | print("\n") 165 | else: 166 | totalTracks = 0 167 | 168 | 169 | albums = resp.json()["albums"]["items"] 170 | if len(albums) > 0: 171 | print("### ALBUMS ###") 172 | for album in albums: 173 | print("%d, %s | %s" % ( 174 | i, 175 | album["name"], 176 | ",".join([artist["name"] for artist in album["artists"]]), 177 | )) 178 | i += 1 179 | totalAlbums = i - totalTracks - 1 180 | print("\n") 181 | else: 182 | totalAlbums = 0 183 | 184 | 185 | playlists = resp.json()["playlists"]["items"] 186 | if len(playlists) > 0: 187 | print("### PLAYLISTS ###") 188 | for playlist in playlists: 189 | print("%d, %s | %s" % ( 190 | i, 191 | playlist["name"], 192 | playlist['owner']['display_name'], 193 | )) 194 | i += 1 195 | totalPlaylists = i - totalTracks - totalAlbums - 1 196 | print("\n") 197 | else: 198 | totalPlaylists = 0 199 | 200 | if len(tracks) + len(albums) + len(playlists) == 0: 201 | print("NO RESULTS FOUND - EXITING...") 202 | else: 203 | position = int(input("SELECT ITEM BY ID: ")) 204 | 205 | if position <= totalTracks: 206 | trackId = tracks[position - 1]["id"] 207 | downloadTrack(trackId) 208 | elif position <= totalAlbums + totalTracks: 209 | downloadAlbum(albums[position - totalTracks - 1]["id"]) 210 | else: 211 | playlistChoice = playlists[position - totalTracks - totalAlbums - 1] 212 | playlistSongs = get_playlist_songs(token, playlistChoice['id']) 213 | for song in playlistSongs: 214 | if song['track']['id'] != None: 215 | downloadTrack(song['track']['id'], sanitizeData(playlistChoice['name'].strip()) + "/") 216 | print("\n") 217 | 218 | def getSongInfo(songId): 219 | token = session.tokens().get("user-read-email") 220 | 221 | info = json.loads(requests.get("https://api.spotify.com/v1/tracks?ids=" + songId + '&market=from_token', headers={"Authorization": "Bearer %s" % token}).text) 222 | 223 | artists = [] 224 | for x in info['tracks'][0]['artists']: 225 | artists.append(sanitizeData(x['name'])) 226 | albumName = sanitizeData(info['tracks'][0]['album']["name"]) 227 | name = sanitizeData(info['tracks'][0]['name']) 228 | imageUrl = info['tracks'][0]['album']['images'][0]['url'] 229 | releaseYear = info['tracks'][0]['album']['release_date'].split("-")[0] 230 | disc_number = info['tracks'][0]['disc_number'] 231 | track_number = info['tracks'][0]['track_number'] 232 | scrapedSongId = info['tracks'][0]['id'] 233 | isPlayAble = info['tracks'][0]['is_playable'] 234 | 235 | return artists, albumName, name, imageUrl, releaseYear, disc_number, track_number, scrapedSongId, isPlayAble 236 | 237 | 238 | 239 | #Functions directly related to modifying the downloaded audio and its metadata 240 | def convertToMp3(filename): 241 | print("### CONVERTING TO MP3 ###") 242 | raw_audio = AudioSegment.from_file(filename, format="ogg", 243 | frame_rate=44100, channels=2, sample_width=2) 244 | raw_audio.export(filename, format="mp3") 245 | 246 | def setAudioTags(filename, artists, name, albumName, releaseYear, disc_number, track_number): 247 | print("### SETTING MUSIC TAGS ###") 248 | f = music_tag.load_file(filename) 249 | f['artist'] = convArtistFormat(artists) 250 | f['tracktitle'] = name 251 | f['album'] = albumName 252 | f['year'] = releaseYear 253 | f['discnumber'] = disc_number 254 | f['tracknumber'] = track_number 255 | f.save() 256 | 257 | def setMusicThumbnail(filename, imageUrl): 258 | print("### SETTING THUMBNAIL ###") 259 | r = requests.get(imageUrl).content 260 | f = music_tag.load_file(filename) 261 | f['artwork'] = r 262 | f.save() 263 | 264 | def convArtistFormat(artists): 265 | formatted = "" 266 | for x in artists: 267 | formatted += x + ", " 268 | return formatted[:-2] 269 | 270 | 271 | 272 | #Extra functions directly related to spotify playlists 273 | def get_all_playlists(access_token): 274 | playlists = [] 275 | limit = 50 276 | offset = 0 277 | 278 | while True: 279 | headers = {'Authorization': f'Bearer {access_token}'} 280 | params = {'limit': limit, 'offset': offset} 281 | resp = requests.get("https://api.spotify.com/v1/me/playlists", headers=headers, params=params).json() 282 | offset += limit 283 | playlists.extend(resp['items']) 284 | 285 | if len(resp['items']) < limit: 286 | break 287 | 288 | return playlists 289 | 290 | def get_playlist_songs(access_token, playlist_id): 291 | songs = [] 292 | offset = 0 293 | limit = 100 294 | 295 | while True: 296 | headers = {'Authorization': f'Bearer {access_token}'} 297 | params = {'limit': limit, 'offset': offset} 298 | resp = requests.get(f'https://api.spotify.com/v1/playlists/{playlist_id}/tracks', headers=headers, params=params).json() 299 | offset += limit 300 | songs.extend(resp['items']) 301 | 302 | if len(resp['items']) < limit: 303 | break 304 | 305 | return songs 306 | 307 | def get_playlist_info(access_token, playlist_id): 308 | headers = {'Authorization': f'Bearer {access_token}'} 309 | resp = requests.get(f'https://api.spotify.com/v1/playlists/{playlist_id}?fields=name,owner(display_name)&market=from_token', headers=headers).json() 310 | return resp['name'].strip(), resp['owner']['display_name'].strip() 311 | 312 | 313 | #Extra functions directly related to spotify albums 314 | def get_album_tracks(access_token, album_id): 315 | songs = [] 316 | offset = 0 317 | limit = 50 318 | 319 | while True: 320 | headers = {'Authorization': f'Bearer {access_token}'} 321 | params = {'limit': limit, 'offset': offset} 322 | resp = requests.get(f'https://api.spotify.com/v1/albums/{album_id}/tracks', headers=headers, params=params).json() 323 | offset += limit 324 | songs.extend(resp['items']) 325 | 326 | if len(resp['items']) < limit: 327 | break 328 | 329 | return songs 330 | 331 | def get_album_name(access_token, album_id): 332 | headers = {'Authorization': f'Bearer {access_token}'} 333 | resp = requests.get(f'https://api.spotify.com/v1/albums/{album_id}', headers=headers).json() 334 | return resp['artists'][0]['name'], sanitizeData(resp['name']) 335 | 336 | 337 | 338 | 339 | 340 | #Functions directly related to downloading stuff 341 | def downloadTrack(track_id_str: str, extra_paths = ""): 342 | global rootPath, skipExistingFiles 343 | 344 | track_id = TrackId.from_base62(track_id_str) 345 | artists, albumName, name, imageUrl, releaseYear, disc_number, track_number, scrapedSongId, isPlayAble = getSongInfo(track_id_str) 346 | 347 | songName = artists[0] + " - " + name 348 | filename = rootPath + extra_paths + songName + '.mp3' 349 | 350 | 351 | if not isPlayAble: 352 | print("### SKIPPING:", songName, "(SONG IS UNAVAILABLE) ###") 353 | else: 354 | if os.path.isfile(filename) and skipExistingFiles: 355 | print("### SKIPPING:", songName, "(SONG ALREADY EXISTS) ###") 356 | else: 357 | if track_id_str != scrapedSongId: 358 | print("### APPLYING PATCH TO LET SONG DOWNLOAD ###") 359 | track_id_str = scrapedSongId 360 | track_id = TrackId.from_base62(track_id_str) 361 | 362 | print("### FOUND SONG:", songName, " ###") 363 | 364 | stream = session.content_feeder().load( 365 | track_id, VorbisOnlyAudioQuality(quality), False, None) 366 | 367 | 368 | print("### DOWNLOADING RAW AUDIO ###") 369 | 370 | if not os.path.isdir(rootPath + extra_paths): 371 | os.makedirs(rootPath + extra_paths) 372 | 373 | with open(filename,'wb') as f: 374 | ''' 375 | chunk_size = 1024 * 16 376 | buffer = bytearray(chunk_size) 377 | bpos = 0 378 | ''' 379 | 380 | #With the updated version of librespot my faster download method broke so we are using the old fallback method 381 | while True: 382 | byte = stream.input_stream.stream().read() 383 | if byte == b'': 384 | break 385 | f.write(byte) 386 | 387 | ''' 388 | while True: 389 | byte = stream.input_stream.stream().read() 390 | 391 | if byte == -1: 392 | # flush buffer before breaking 393 | if bpos > 0: 394 | f.write(buffer[0:bpos]) 395 | break 396 | 397 | print(bpos) 398 | buffer[bpos] = byte 399 | bpos += 1 400 | 401 | if bpos == (chunk_size): 402 | f.write(buffer) 403 | bpos = 0 404 | ''' 405 | convertToMp3(filename) 406 | setAudioTags(filename, artists, name, albumName, releaseYear, disc_number, track_number) 407 | setMusicThumbnail(filename, imageUrl) 408 | 409 | def downloadAlbum(album): 410 | token = session.tokens().get("user-read-email") 411 | artist, album_name = get_album_name(token, album) 412 | tracks = get_album_tracks(token, album) 413 | for track in tracks: 414 | downloadTrack(track['id'], artist + " - " + album_name + "/") 415 | print("\n") 416 | 417 | def downloadFromOurPlaylists(): 418 | token = session.tokens().get("user-read-email") 419 | playlists = get_all_playlists(token) 420 | 421 | count = 1 422 | for playlist in playlists: 423 | print(str(count) + ": " + playlist['name'].strip()) 424 | count += 1 425 | 426 | playlistChoice = input("SELECT A PLAYLIST BY ID: ") 427 | playlistSongs = get_playlist_songs(token, playlists[int(playlistChoice) - 1]['id']) 428 | for song in playlistSongs: 429 | if song['track']['id'] != None: 430 | downloadTrack(song['track']['id'], sanitizeData(playlists[int(playlistChoice) - 1]['name'].strip()) + "/") 431 | print("\n") 432 | 433 | 434 | #Core functions here 435 | def main(): 436 | login() 437 | client() 438 | 439 | if __name__ == "__main__": 440 | main() 441 | 442 | 443 | #Left over code ill probably want to reference at some point 444 | """ 445 | if args[0] == "q" or args[0] == "quality": 446 | if len(args) == 1: 447 | print("Current Quality: " + quality.name) 448 | wait() 449 | elif len(args) == 2: 450 | if args[1] == "normal" or args[1] == "96": 451 | quality = AudioQuality.NORMAL 452 | elif args[1] == "high" or args[1] == "160": 453 | quality = AudioQuality.HIGH 454 | elif args[1] == "veryhigh" or args[1] == "320": 455 | quality = AudioQuality.VERY_HIGH 456 | print("Set Quality to %s" % quality.name) 457 | wait() 458 | """ 459 | 460 | 461 | #TO DO'S: 462 | #MAKE AUDIO SAVING MORE EFFICIENT 463 | --------------------------------------------------------------------------------