├── .gitignore ├── LICENSE ├── README.md ├── arguments.py ├── config.py ├── downloader.py ├── pyspodl └── utils.py /.gitignore: -------------------------------------------------------------------------------- 1 | credentials.json 2 | config.toml 3 | __pycache__ 4 | __pycache__/* 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 devlocalhost 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pyspodl - a spotify downloader using librespot 2 | 3 | using the module [librespot-python by kokarare1212](https://github.com/kokarare1212/librespot-python) 4 | 5 | # What is different from other downloaders? 6 | Well, maybe nothing. pyspodl can only do these things: download tracks, albums and playlists in "high" (non-premium) and "very high" (premium account required) quality. It downloads them from Spotify, it does not use another source like some other programs do. 7 | 8 | # installation 9 | Make sure you have python3 and git installed. If you don't, go ahead and install them. 10 | 11 | + Clone the repository: `git clone https://github.com/devlocalhost/pyspodl` 12 | + Install the primary dependecy: `pip3 install git+https://github.com/kokarare1212/librespot-python`. 13 | + Now install the extra dependecies (which are needed, yes): `pip3 install requests Pillow mutagen toml tqdm`. 14 | + Generate a `credentials.json` file using [librespot-auth](https://github.com/dspearson/librespot-auth) 15 | + After compiling (or grabbing the prebuilt binaries), run `librespot-auth` like this: `librespot-auth --name "pyspodl" --class tv`. Launch spotify **desktop app**, click the button that allows you to play on a different device, and you should see "pyspodl". CLick it, then copy the generated `credentials.json` file to pyspodl directory 16 | + Open the `credentials.json` file, and do the following: 17 | 1. Replace `"auth_data"` with `"credentials"` 18 | 2. Remove `"auth_type": 1,` 19 | 3. Enter this before the closing `}`: `"type": "AUTHENTICATION_STORED_SPOTIFY_CREDENTIALS"` 20 | 4. Confirm your file looks similar to this: 21 | ```json 22 | {"username": "BLABLA", "credentials": "BLABLABLA", "type": "AUTHENTICATION_STORED_SPOTIFY_CREDENTIALS"} 23 | ``` 24 | + Create an application from [spotify dashboard](https://developer.spotify.com/dashboard/applications), and copy the client ID and secret 25 | + Paste the client ID and secret into the config file, inside the "pyspodl" directory. 26 | + Now you can use pyspodl like this: `python3 pyspodl` 27 | 28 | ## usage - examples 29 | `python pyspodl -l LINK` 30 | 31 | `python pyspodl -l "LINK1 LINK2 LINK3"` 32 | 33 | `python pyspodl -l ... -c /path/to/config.file` 34 | 35 | Or, check `python -h` 36 | 37 | ## updating pyspodl 38 | When there's a new update, you can simply run `git pull` in the directory where you cloned pyspodl. 39 | 40 | # the config file 41 | Before you start using pyspodl, you need to fill out the config file. 42 | 43 | ## config entries 44 | + `credentials_config_file`: the full path to credentials.json file 45 | + `token`: used to send request to spotify api. you do not need to touch this. 46 | + `client_id`: used to get the token 47 | + `client_secret`: used to get the token 48 | + `timeout`: tells the program to wait x seconds before downloading the next song from a playlist or album (to avoid account bans, but I never had an account get banned by using my program). 49 | + `premium_downloads`: download tracks in higher quality. only for premium accounts. 50 | + `download_path`: the path to download the tracks 51 | + `set_metadata`: set tags for the tracks 52 | + `track_format`: the format the tracks will be saved in. check the config file for possible entries 53 | 54 | ## config example 55 | ``` 56 | [account] 57 | credentials_file_path = "" # full path to the credentials.json file 58 | token = "" # used to communicate with spotify api 59 | client_id = "" # used to get the token 60 | client_secret = "" # same thing as above 61 | 62 | [downloading] 63 | timeout = 2 # in seconds 64 | premium_downloads = false # can be false or true 65 | download_path = "" # download path for the tracks 66 | set_metadata = true # can be false or true 67 | track_format = "{artist}/{album}/{title}" # can be: artist, album, title, tracknumber, year 68 | # the above format will save a song like this: download_path/Nas/Illmatic/The World Is Yours.ogg 69 | # artist album title 70 | ``` 71 | 72 | # help 73 | Feel free to fork or make PR's (pull requests). If you're forking, please leave [this repos link](https://github.com/devlocalhost/pyspodl) in your readme and [kokarare1212's module repo link](https://github.com/kokarare1212/librespot-python). 74 | If you have any problems/want to report a bug or request a feature, open an issue 75 | -------------------------------------------------------------------------------- /arguments.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | 3 | 4 | def custom_help_formatter(prog): 5 | """fixing the ugly looking help menu""" 6 | 7 | return argparse.HelpFormatter(prog, max_help_position=46) 8 | 9 | 10 | def get_arguments(): 11 | """creating arguments""" 12 | parser = argparse.ArgumentParser( 13 | formatter_class=custom_help_formatter, 14 | description="pyspodl - a spotify downloader using librespot / https://github.com/devlocalhost/pyspodl", 15 | ) 16 | 17 | parser.add_argument( 18 | "-l", 19 | "--link", 20 | type=str, 21 | help='link (s) to download, all in quotes, separated by a space: "link1 link2 link3"', 22 | ) 23 | parser.add_argument( 24 | "-c", "--config-path", type=str, help="the path of the config file" 25 | ) 26 | 27 | return parser.parse_args() 28 | 29 | 30 | if __name__ == "__main__": 31 | get_arguments() 32 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | import toml 5 | 6 | 7 | class ConfigError(Exception): 8 | """ 9 | triggered when theres an issue with the config file 10 | """ 11 | 12 | 13 | class Config: 14 | """ 15 | config related functions 16 | """ 17 | 18 | def __init__(self, config_path=None): 19 | self.config_file = ( 20 | os.path.abspath(config_path) 21 | if config_path 22 | else os.path.abspath("config.toml") 23 | ) 24 | 25 | def read_config(self): 26 | """ 27 | read the config file and return the data 28 | """ 29 | 30 | try: 31 | with open(self.config_file, encoding="utf-8") as config_file: 32 | return toml.load(config_file) 33 | 34 | except FileNotFoundError: 35 | sys.exit(f'[read_config] Config file "{self.config_file}" not found.') 36 | 37 | def get_config_value(self, section, key): 38 | """ 39 | get value from config file 40 | """ 41 | 42 | config = self.read_config() 43 | value = config.get(section).get(key) 44 | 45 | if value is None or (isinstance(value, str) and not value.strip()): 46 | raise ConfigError( 47 | f"[get_config_value] Value for '{key}' in section '{section}' is missing or blank." 48 | ) 49 | 50 | return value 51 | -------------------------------------------------------------------------------- /downloader.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import time 4 | 5 | import tqdm 6 | import requests 7 | 8 | from librespot.metadata import TrackId 9 | from librespot.audio.decoders import AudioQuality, VorbisOnlyAudioQuality 10 | 11 | 12 | class Downloader: 13 | """ 14 | where the magic happens. 15 | """ 16 | 17 | def __init__(self, config, utils): 18 | """ 19 | define some values i guess...? 20 | """ 21 | 22 | self.config = config 23 | self.utils = utils 24 | 25 | self.session = self.utils.get_session() 26 | 27 | self.premium_downloads = self.config.get_config_value( 28 | "downloading", "premium_downloads" 29 | ) 30 | self.download_path = self.config.get_config_value( 31 | "downloading", "download_path" 32 | ) 33 | self.set_metadata = self.config.get_config_value("downloading", "set_metadata") 34 | 35 | def get_track_urls(self, link): 36 | """ 37 | get all tracks available in a playlist or album (spotify gives max 50 entries) 38 | """ 39 | 40 | track_urls = [] 41 | id_from_url = self.utils.get_id_type_from_url(link) 42 | 43 | url = ( 44 | f"https://api.spotify.com/v1/{id_from_url[1] + 's'}/{id_from_url[0]}/tracks" 45 | ) 46 | 47 | while True: 48 | try: 49 | headers = {"Authorization": f"Bearer {self.utils.get_token()}"} 50 | params = {"offset": 0, "limit": 50} 51 | 52 | response = requests.get(url, headers=headers, params=params, timeout=10) 53 | response_json = response.json() 54 | 55 | items = response_json["items"] 56 | url_next = response_json["next"] 57 | 58 | if url_next: 59 | url = url_next 60 | 61 | for item in items: 62 | try: 63 | track_urls.append( 64 | item["external_urls"]["spotify"] 65 | if id_from_url[1] == "album" 66 | else item["track"]["external_urls"]["spotify"] 67 | ) 68 | 69 | except Exception as e: 70 | print(f"[get_track_urls] Error making request: {e}") 71 | 72 | if len(track_urls) == response_json["total"]: 73 | break 74 | 75 | except requests.exceptions.RequestException as e: 76 | print(f"[get_track_urls] Error making request: {e}") 77 | break 78 | 79 | return track_urls 80 | 81 | def download_playlist_or_album(self, link): 82 | """ 83 | download songs off an album or playlist 84 | """ 85 | 86 | tracks = self.get_track_urls(link) 87 | total_tracks = len(tracks) 88 | 89 | for count, track in enumerate(tracks): 90 | self.download_track(track) 91 | 92 | print( 93 | f"[download_playlist_or_album] Progress: {count + 1}/{total_tracks}\n" 94 | ) 95 | 96 | def download_track(self, url): 97 | """ 98 | download a track 99 | """ 100 | 101 | try: 102 | timeout = self.config.get_config_value("downloading", "timeout") 103 | 104 | if timeout > 0: 105 | print(f"[download_track] Sleeping for {timeout} seconds...") 106 | time.sleep(timeout) 107 | 108 | except TypeError: 109 | sys.exit( 110 | '[download_track] "timeout" from config file must be a number (without quotes).' 111 | ) 112 | 113 | track_id = TrackId.from_uri( 114 | f"spotify:track:{self.utils.get_id_type_from_url(url)[0]}" 115 | ) 116 | headers = {"Authorization": f"Bearer {self.utils.get_token()}"} 117 | 118 | resp = requests.get( 119 | f"https://api.spotify.com/v1/tracks/{self.utils.get_id_type_from_url(url)[0]}", 120 | headers=headers, 121 | timeout=10, 122 | ).json() 123 | 124 | artist = resp["artists"][0]["name"] # artist 125 | track_title = resp["name"] # title 126 | album_name = resp["album"]["name"] # album 127 | album_release = resp["album"]["release_date"] # date 128 | track_number = resp["track_number"] # tracknumber 129 | cover_image = resp["album"]["images"][0] # coverart, width, height 130 | 131 | if self.premium_downloads: 132 | stream = self.session.content_feeder().load( 133 | track_id, VorbisOnlyAudioQuality(AudioQuality.VERY_HIGH), False, None 134 | ) 135 | 136 | else: 137 | stream = self.session.content_feeder().load( 138 | track_id, VorbisOnlyAudioQuality(AudioQuality.HIGH), False, None 139 | ) 140 | 141 | filename_format = self.config.get_config_value("downloading", "track_format") 142 | filename = filename_format.format( 143 | artist=artist, 144 | title=track_title, 145 | album=album_name, 146 | tracknumber=track_number, 147 | year=album_release, 148 | ) 149 | 150 | print(f"[download_track] Downloading {track_title} by {artist}") 151 | 152 | path_filename = f"{self.download_path}/{filename}" 153 | 154 | if os.path.exists(path_filename + ".ogg"): 155 | print("[download_track] Track exists, skipping") 156 | 157 | else: 158 | directory_path = os.path.dirname(path_filename) 159 | 160 | if directory_path and not os.path.exists(directory_path): 161 | os.makedirs(directory_path) 162 | 163 | with ( 164 | open(f"{path_filename}.ogg", "wb+") as track_file, 165 | tqdm.tqdm( 166 | unit="B", 167 | unit_scale=True, 168 | unit_divisor=1024, 169 | total=stream.input_stream.size, 170 | bar_format="{percentage:3.0f}%|{bar:16}|{n_fmt} / {total_fmt} | {rate_fmt}, ETA {remaining}", 171 | ) as progress_bar, 172 | ): 173 | for _ in range(int(stream.input_stream.size / 5000) + 1): 174 | progress_bar.update( 175 | track_file.write(stream.input_stream.stream().read(50000)) 176 | ) 177 | 178 | if self.set_metadata: 179 | tags = { 180 | "artist": artist, 181 | "title": track_title, 182 | "album": album_name, 183 | "date": album_release, # .split("-")[0], 184 | "tracknumber": track_number, 185 | } 186 | 187 | self.utils.set_metadata(tags, cover_image, path_filename) 188 | 189 | def download(self, link): 190 | """ 191 | execute the function based on the link 192 | """ 193 | 194 | link_type = self.utils.get_id_type_from_url(link)[1] 195 | 196 | if link_type == "track": 197 | self.download_track(link) 198 | 199 | elif link_type in ("album", "playlist"): 200 | self.download_playlist_or_album(link) 201 | 202 | else: 203 | sys.exit('[download] Invalid URL. URL must start with "open.spotify.com"') 204 | -------------------------------------------------------------------------------- /pyspodl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | """ 4 | pyspodl - a spotify downloader using librespot 5 | """ 6 | 7 | from arguments import get_arguments 8 | 9 | from downloader import Downloader 10 | from config import Config 11 | from utils import Utils 12 | 13 | def main(): 14 | arguments = get_arguments() 15 | config = Config(arguments.config_path) 16 | utils = Utils(config) 17 | 18 | downloader = Downloader(config, utils) 19 | 20 | for link in arguments.link.split(" "): 21 | downloader.download(link) 22 | 23 | 24 | if __name__ == "__main__": 25 | main() 26 | -------------------------------------------------------------------------------- /utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import base64 4 | 5 | import requests 6 | import toml 7 | 8 | from librespot.core import Session 9 | from mutagen.flac import Picture 10 | from mutagen.oggvorbis import OggVorbis 11 | 12 | from mutagen.oggvorbis import OggVorbisHeaderError 13 | from mutagen.ogg import error 14 | 15 | from config import ConfigError 16 | 17 | 18 | class Utils: 19 | def __init__(self, config): 20 | self.config = config 21 | 22 | def generate_new_token(self): 23 | """ 24 | tokens are not permament, they expire after an hour, 25 | so when invalid, regenerates a new one 26 | """ 27 | 28 | client_id = self.config.get_config_value("account", "client_id") 29 | client_secret = self.config.get_config_value("account", "client_secret") 30 | 31 | resp = requests.post( 32 | "https://accounts.spotify.com/api/token", 33 | { 34 | "grant_type": "client_credentials", 35 | "client_id": client_id, 36 | "client_secret": client_secret, 37 | }, 38 | timeout=10, 39 | ).json() 40 | 41 | config = self.config.read_config() 42 | 43 | print("[generate_new_token] Trying to update token...") 44 | 45 | if config: 46 | config["account"]["token"] = resp["access_token"] 47 | 48 | try: 49 | with open("config.toml", "w", encoding="utf-8") as f: 50 | toml.dump(config, f) 51 | print("[generate_new_token] Token was updated") 52 | 53 | except Exception as e: 54 | sys.exit( 55 | f'[generate_new_token] Error updating the configuration file: {e}\n\nManually update token from the config.toml file by yourself with: "{resp["access_token"]}"' 56 | ) 57 | 58 | try: 59 | os.execv(sys.argv[0], sys.argv) 60 | 61 | except FileNotFoundError: 62 | sys.exit("[generate_new_token] Token was updated, run pyspodl again") 63 | 64 | def get_token(self): 65 | """ 66 | gets the temporary saved token from the config file 67 | """ 68 | 69 | try: 70 | token = self.config.get_config_value("account", "token") 71 | 72 | except ConfigError: 73 | self.generate_new_token() 74 | 75 | headers = {"Authorization": f"Bearer {token}"} 76 | resp = requests.get( 77 | "https://api.spotify.com/v1/search?q=home+resonance&type=track", 78 | headers=headers, 79 | timeout=10, 80 | ) 81 | 82 | if resp.status_code == 401: 83 | self.generate_new_token() 84 | 85 | return token 86 | 87 | def set_metadata(self, metadata, cover_image, filename): 88 | """ 89 | set metadata to a file (these are ogg tags, not id3!) 90 | """ 91 | 92 | file = OggVorbis(f"{filename}.ogg") 93 | 94 | for key, value in metadata.items(): 95 | file[key] = str(value) 96 | 97 | try: 98 | resp = requests.get(cover_image["url"], timeout=10) 99 | picture = Picture() 100 | 101 | picture.data = resp.content 102 | picture.type = 17 103 | picture.mime = "image/jpeg" 104 | picture.width = cover_image["width"] 105 | picture.height = cover_image["height"] 106 | 107 | picture_data = picture.write() 108 | encoded_data = base64.b64encode(picture_data) 109 | vcomment_value = encoded_data.decode("ascii") 110 | 111 | file["metadata_block_picture"] = [vcomment_value] 112 | 113 | except requests.exceptions.RequestException: 114 | pass 115 | 116 | try: 117 | file.save() 118 | 119 | except (OggVorbisHeaderError, error): 120 | pass # fuck you 121 | # seriously fuck it, idk why it happens 122 | 123 | def get_session(self): 124 | """ 125 | create a user session and return it 126 | """ 127 | 128 | try: 129 | print("[get_session] Trying to create a session...") 130 | 131 | session = ( 132 | Session.Builder() 133 | .stored_file( 134 | self.config.get_config_value("account", "credentials_file_path") 135 | ) 136 | .create() 137 | ) 138 | 139 | return session 140 | 141 | except Exception as exc: 142 | sys.exit( 143 | f"[get_session] An issue occured while trying to create session:\n{exc}" 144 | ) 145 | 146 | def get_id_type_from_url(self, url): 147 | """ 148 | get the id of the track or whatever, and the type of whatever (lol.) 149 | """ 150 | 151 | try: 152 | return (url.split("/")[4].split("?")[0], url.split("/")[3]) 153 | 154 | except IndexError: 155 | sys.exit( 156 | '[get_id_type_from_url] Invalid URL? Does it start with "https://open.spotify.com"?' 157 | ) 158 | --------------------------------------------------------------------------------