├── requirements.txt ├── screenshots ├── fix1.png ├── fix2.png └── preview.png ├── access.py ├── LICENSE ├── README.md └── SpotifyDownloader.py /requirements.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Heir-of-God/SpotifyDownloader/HEAD/requirements.txt -------------------------------------------------------------------------------- /screenshots/fix1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Heir-of-God/SpotifyDownloader/HEAD/screenshots/fix1.png -------------------------------------------------------------------------------- /screenshots/fix2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Heir-of-God/SpotifyDownloader/HEAD/screenshots/fix2.png -------------------------------------------------------------------------------- /screenshots/preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Heir-of-God/SpotifyDownloader/HEAD/screenshots/preview.png -------------------------------------------------------------------------------- /access.py: -------------------------------------------------------------------------------- 1 | from dotenv import load_dotenv 2 | from os import getenv 3 | from base64 import b64encode 4 | from requests import post 5 | from json import loads 6 | 7 | 8 | def get_access_token_header() -> dict[str, str]: 9 | auth_string: str = client_id + ":" + client_secret 10 | auth_bytes: bytes = auth_string.encode("utf-8") 11 | auth_base64 = str(b64encode(auth_bytes), "utf-8") 12 | 13 | url = "https://accounts.spotify.com/api/token" 14 | 15 | headers: dict[str, str] = { 16 | "Authorization": "Basic " + auth_base64, 17 | "Content-Type": "application/x-www-form-urlencoded", 18 | } 19 | 20 | data: dict[str, str] = {"grant_type": "client_credentials"} 21 | 22 | result = post(url=url, headers=headers, data=data) 23 | json_result = loads(result.content) 24 | token: str = json_result["access_token"] 25 | 26 | return {"Authorization": "Bearer " + token} 27 | 28 | 29 | load_dotenv() 30 | client_id: str = getenv("CLIENT_ID") 31 | client_secret: str = getenv("CLIENT_SECRET") 32 | BASE_URL: str = "https://api.spotify.com/v1/" 33 | ACCESS_HEADER: dict[str, str] = get_access_token_header() 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2024, Heir-of-God 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SpotifyDownloader 2 | 3 | Welcome to this repository! My program allows you to download playlists and songs from Spotify absolutely free (you don’t need Spotify premium to use the program, it doesn’t even need to know anything about your personal account, to be honest). 4 | 5 | ### Preview: 6 | Preview 7 | 8 | ## Features 9 | 10 | - **Playlists**: Download Spotify playlists with just 1 url 11 | - **Songs**: Download Spotify song with just 1 url 12 | - **Metadata**: All needed metadata from songs downloads with them (including name, artists, album and cover art) 13 | - **Cutting**: You can "cut out" the piece of your playlist your want by using flags when run the program 14 | - **Progress tracking**: You don't have to sit in front of your computer and check if each song has been downloaded - program inform you about progress after every song downloaded so you can check it all later. 15 | - **Safety**: You don't need to worry about songs that have the same name or that song has age restriction etc. This program cares for all of this (though, there still might be unexpecting errors but program will inform you about this) 16 | > [!IMPORTANT] 17 | > If you encounter any problem, don't be afraid to report it in the bugs and issues section of this repository. 18 | 19 | ## Repository 20 | 21 | Explore the source code and contribute to the development on GitHub: 22 | 23 | [![GitHub Repo](https://img.shields.io/badge/GitHub-Repository-blue?logo=github)](https://github.com/Heir-of-God/SpotifyDownloader) 24 | 25 | ## Installation 26 | 27 | Follow these steps to set up the SpotifyDownloader on your local machine: 28 | 29 | 1. **Give a star this repo if you find it interesting or useful**⭐\ 30 | **Thanks ❤️** 31 | 32 | 2. **Clone the repository**: 33 | 34 | ```bash 35 | git clone https://github.com/Heir-of-God/SpotifyDownloader 36 | ``` 37 | 38 | 3. **Install dependencies**: 39 | 40 | ```bash 41 | cd SpotifyDownloader 42 | pip install -r requirements.txt 43 | ``` 44 | 4. **Create Spotify API**: \ 45 | To grab information from Spotify you need Spotify API app. Here how to create it in 3 steps: 46 | 1. Go to https://developer.spotify.com/. Create or login into your account. 47 | 2. After agreeing to all their requirements, you need to create the application. Click on your profile -> Dashboard -> Create app. Enter any app name, description, left website empty and add to redirect URIs just http://localhost/. In API choose only "Web API" and click save. 48 | 3. After creating app go to its page, click settings and from "Basic Information" you will need cliend id and client secret. Go to the next step with this information. 49 | 50 | 5. **Set up environment variables**: 51 | - Create a `.env` file in the root directory. 52 | - Define the following environment variables: 53 | - `CLIENT_ID`: Copy there your client_id from Spotify API 54 | - `CLIENT_SECRET`: Copy there your client_secret from Spotify API 55 | 56 | 6. **Setting up ffmpeg**: \ 57 | To convert .webm files from PyTube to .mp3 files my program uses ffmpeg, so you need to install it and add it to PATH variable. 58 | ##### There is the guide how to install ffmpeg correctly: [Installation guide](https://phoenixnap.com/kb/ffmpeg-windows). 59 | 60 | 7. **VERY IMPORTANT**: \ 61 | If you want program to work correctly you will need to change PyTube's source code a bit. I'm sorry, I have nothing to do with it and have struggled to fix their "bugs" as I could. 62 | 1. You need to add some lines of code in search.py. Add this at line 151: 63 | ```python 64 | if 'reelShelfRenderer' in video_details: 65 | continue 66 | 67 | if 'adSlotRenderer' in video_details: 68 | continue 69 | 70 | if 'movieRenderer' in video_details: 71 | continue 72 | ``` 73 | Fix1Screenshot 74 | 75 | 2. You need to document or delete some lines of code in \_\_main\_\_.py like this (from 177 to 190): 76 | 77 | Fix2Screenshot 78 | 79 | After completing these steps, you'll have the SpotifyDownloader ready to use locally. 80 | 81 | ## Usage 82 | 83 | 1. The program is CLI (Command Line Interface) so you will need basic knowledge about cmd or bash or whatever you will use as a command line. 84 | 2. Run your command line and go to the root directory of SpotifyDownloader 85 | 3. Run and check all information about CLI interface. 86 | ```bash 87 | py SpotifyDownloader.py -h 88 | ``` 89 | Run your first downloading and enjoy results :) 90 | 91 | ## Contributing 92 | 93 | Contributions welcome! Open issues or submit pull requests. 94 | 95 | ## License 96 | 97 | This project is licensed under the BSD 3-Clause License - see the [LICENSE](LICENSE) file for details. 98 | -------------------------------------------------------------------------------- /SpotifyDownloader.py: -------------------------------------------------------------------------------- 1 | from typing import Self 2 | from requests import Response, get 3 | from access import BASE_URL, ACCESS_HEADER 4 | from json import loads 5 | from pytube import Search, YouTube 6 | from os import mkdir, getcwd, remove 7 | from os.path import isdir, exists 8 | import subprocess 9 | from mutagen.id3 import ID3, TALB, TIT2, TPE1, APIC, TLEN, ID3NoHeaderError 10 | from argparse import ArgumentParser, Namespace 11 | 12 | 13 | def get_image_binary(img_url: str) -> bytes: 14 | """Returns byte representation of image by its url""" 15 | response = get(img_url) 16 | # region SavingImage 17 | # if response.status_code: 18 | # output = open("output.png", "wb") 19 | # output.write(response.content) 20 | # output.close() 21 | # endregion 22 | return response.content 23 | 24 | 25 | def get_spotify_id(url: str) -> str: 26 | """Returns only Spotify's id from provided url""" 27 | return url.split("/")[-1].split("?")[0] 28 | 29 | 30 | class Track: 31 | """Class for representing Spotify's tracks details""" 32 | 33 | def __init__(self, name: str, artists: list[str], album: str, duration: int, binary_image: bytes) -> None: 34 | self.name: str = name 35 | self.artists: list[str] = artists 36 | self.album: str = album 37 | self.duration: int = duration # in milliseconds 38 | self.binary_image: bytes = binary_image 39 | 40 | @classmethod 41 | def get_track_by_data(cls, data: dict) -> Self: 42 | """Creates track from provided data (dict from Spotify API about this track)""" 43 | name: str = data["name"] 44 | album: str = data["album"]["name"] 45 | artists: list[str] = [artist["name"] for artist in data["artists"]] 46 | duration: int = data["duration_ms"] 47 | binary_image: bytes = get_image_binary(data["album"]["images"][0]["url"]) 48 | return cls(name, artists, album, duration, binary_image) 49 | 50 | def __repr__(self) -> str: 51 | keys = list(self.__dict__) 52 | values: list[str] = [ 53 | str(self.__dict__[key]) if not isinstance(self.__dict__[key], str) else f"'{self.__dict__[key]}'" 54 | for key in keys 55 | ] 56 | 57 | return f"{self.__class__.__name__}({', '.join([f'{k}={v}' for k, v in zip(keys, values)])})" 58 | 59 | def __str__(self) -> str: 60 | return f'Track {self.name} by {", ".join(self.artists)}. Album: {self.album}' 61 | 62 | 63 | class Playlist: 64 | """Class for representing Spotify's playlists details (As in Spotify, playlist consists of Track()s)""" 65 | 66 | def __init__(self, link: str, start_from: int, end_at: int) -> None: 67 | self.id: str = get_spotify_id(link) 68 | response: Response = get(BASE_URL + f"playlists/{self.id}", headers=ACCESS_HEADER) 69 | response_dict = loads(response.content) 70 | self.name: str = response_dict["name"] 71 | self.description: str = response_dict["description"] 72 | self.owner: str = ( 73 | response_dict["owner"]["display_name"] if response_dict["owner"]["display_name"] else "Unknown_User" 74 | ) # Unknown_user can appear when API hasn't access to user account details 75 | self.tracks: list[Track] = self._extract_tracks(response_dict["tracks"]["href"]) 76 | 77 | # Cutting out the desired part 78 | if start_from and end_at: 79 | self.tracks = self.tracks[start_from - 1 : end_at] 80 | elif start_from: 81 | self.tracks = self.tracks[start_from - 1 :] 82 | elif end_at: 83 | self.tracks = self.tracks[:end_at] 84 | 85 | self.tracks = [ 86 | Track.get_track_by_data(track) for track in [i["track"] for i in self.tracks] if track["track"] 87 | ] # converting all tracks in Track() objects 88 | 89 | def _extract_tracks(self, url: str) -> list[dict]: 90 | """Returns all tracks from playlist via recursion""" 91 | response = get(url, headers=ACCESS_HEADER) 92 | response = loads(response.content) 93 | res: list[dict] = ( 94 | response["items"] 95 | if not response["next"] 96 | else response["items"] + self._extract_tracks(response["next"]) 97 | ) 98 | return res 99 | 100 | def get_tracks(self) -> list[Track]: 101 | """Returns playlist's tracks""" 102 | return self.tracks 103 | 104 | 105 | class YoutubeDownloader: 106 | """The class responsible for downloading, searching and other work with YouTube.""" 107 | 108 | def __init__(self) -> None: 109 | self.path_to_save: str = getcwd() + "\\tracks" 110 | if not isdir(self.path_to_save): 111 | mkdir(self.path_to_save) 112 | 113 | def search_for_video(self, track: Track) -> list[YouTube]: # Returns up to 3 YouTube objects in list 114 | """Searching for videos on YouTube with simillar name and duration 115 | 116 | Args: 117 | track (Track): the track you want to search YouTube for 118 | 119 | Returns: 120 | list[YouTube]: list object which contains UP TO 3 YouTube objects - 1 main and 2 spares. Have chances to contain less than 3 items! 121 | """ 122 | searching: Search = Search(f"{track.artists[0]} - {track.name} audio only") 123 | searching_results: list[YouTube] = searching.results 124 | searched = 1 125 | ms_range = 2000 126 | results: list[YouTube] = [] 127 | video_count = 0 128 | 129 | while not (video_count != 0 and not results) and (video_count != 3): 130 | if not searching_results: 131 | searching.get_next_results() 132 | searching_results = searching.results 133 | cur_video: YouTube = searching_results.pop(0) 134 | if abs(cur_video.length * 1000 - track.duration) <= ms_range: 135 | results.append(cur_video) 136 | video_count += 1 137 | 138 | searched += 1 139 | if searched == 15: 140 | ms_range = 15000 141 | if searched >= 40 and not results: # limit there! 142 | print( 143 | f"Seems like there's no this song '{track.name} {track.artists}' with this duration {track.duration}ms on Youtube, you can try to change limit in search_for_video function" 144 | ) 145 | return results 146 | return results 147 | 148 | def _correct_metadata(self, track: Track, path: str) -> None: 149 | """Helper method which helps to set right metadata after downloading a song""" 150 | try: 151 | id3 = ID3(path) 152 | except ID3NoHeaderError: 153 | id3 = ID3() 154 | id3.delete() 155 | id3["TPE1"] = TPE1(encoding=3, text=f"{', '.join(track.artists)}") 156 | id3["TALB"] = TALB(encoding=3, text=f"{track.album}") 157 | id3["TIT2"] = TIT2(encoding=3, text=f"{track.name}") 158 | id3["APIC"] = APIC(encoding=3, mime="image/png", type=3, desc="Cover", data=track.binary_image) 159 | id3["TLEN"] = TLEN(encoding=3, text=f"{track.duration}") 160 | id3.save(path) 161 | 162 | def _get_correct_name(self, track: Track) -> str: 163 | """There's low chance to face conflicts with file names, but this method cares so there's no chance at all""" 164 | new_name: str = track.name 165 | for char in '"/\<>:|?*': # replacing forbidden characters for file's name in windows and linux 166 | new_name = new_name.replace(char, "") 167 | num_to_add = 1 168 | if exists(self.path_to_save + f"\\{new_name}.mp3"): 169 | new_name = f"{track.artists[0]} - {track.name}" 170 | while exists(self.path_to_save + f"\\{new_name}.mp3"): 171 | new_name += str(num_to_add) 172 | num_to_add += 1 173 | return new_name 174 | 175 | def download_track(self, videos: list[YouTube], track: Track) -> str: 176 | """Method to download a single track from its YouTube instances 177 | 178 | Args: 179 | videos (list[YouTube]): up to 3 YouTube objects (up to 3 attempts to download this song) 180 | track (Track): track associated with list of YouTube videos (used by helper methods to rename, correct metadata etc) 181 | 182 | Returns: 183 | str: download result message 184 | """ 185 | if not videos: 186 | return None 187 | downloaded = False 188 | cur_candidate_ind = 0 189 | file_name: str = self._get_correct_name(track) 190 | 191 | while not downloaded and cur_candidate_ind != len(videos): 192 | youtube_obj: YouTube = videos[cur_candidate_ind] 193 | try: 194 | stream = youtube_obj.streams.get_by_itag(251) 195 | stream.download(output_path=self.path_to_save, filename=file_name + ".webm") 196 | file_path: str = self.path_to_save + "/" + file_name 197 | subprocess.run( 198 | f'ffmpeg -i "{file_path}.webm" -vn -ab 128k -ar 44100 -y -map_metadata -1 "{file_path}.mp3" -loglevel quiet' 199 | ) # Using ffmpeg to convert webm file to mp3. remove -loglevel quiet if you want to see output from ffmpeg 200 | remove(file_path + ".webm") 201 | file_path += ".mp3" 202 | self._correct_metadata(track, file_path) 203 | downloaded = True 204 | except Exception as e: 205 | print( 206 | f"Encountering unexpected error while downloading the track {track.name}. Error: {e}. Attempt: {cur_candidate_ind + 1}" 207 | ) 208 | cur_candidate_ind += 1 209 | 210 | if not downloaded: 211 | return f"Sorry, can't download track {track.name} by {track.artists[0]}" 212 | else: 213 | return f"Successfully downloaded {track.name} by {track.artists[0]}" 214 | 215 | def download_playlist(self, playlist: list[Track]) -> None: 216 | """Separate method to download full Spotify playlist""" 217 | length: int = len(playlist) 218 | for track_num, track in enumerate(playlist, 1): 219 | videos: list[YouTube] = self.search_for_video(track) 220 | download_output: str = self.download_track(videos, track) 221 | if download_output: 222 | print(f"Progress: {track_num}/{length}. " + download_output) 223 | return 224 | 225 | 226 | if __name__ == "__main__": 227 | 228 | # region CLI interface creating 229 | parser = ArgumentParser() 230 | parser.add_argument("url", help="this parameter must be either url to your playlist or song on Spotify") 231 | parser.add_argument( 232 | "-sa", 233 | "--start_at", 234 | type=int, 235 | help="If specified start downloading your songs in playlist from this song (1-indexed)", 236 | ) 237 | parser.add_argument( 238 | "-ea", 239 | "--end_at", 240 | type=int, 241 | help="If specified end downloading your songs in playlist after this song (1-indexed)", 242 | ) 243 | args: Namespace = parser.parse_args() 244 | # endregion 245 | 246 | searching_for: str = args.url 247 | YD = YoutubeDownloader() 248 | 249 | if "track" in searching_for: 250 | track_id: str = get_spotify_id(searching_for) 251 | response: Response = get(BASE_URL + f"tracks/{track_id}", headers=ACCESS_HEADER) 252 | response_dict = loads(response.content) 253 | 254 | targeted_track: Track = Track.get_track_by_data(response_dict) 255 | candidates: list[YouTube] = YD.search_for_video(targeted_track) 256 | download_output: str = YD.download_track(candidates, targeted_track) 257 | 258 | if download_output: 259 | print(download_output) 260 | 261 | elif "playlist" in searching_for: 262 | start_from = args.start_at 263 | end_at = args.end_at 264 | 265 | # validating parameters 266 | if ( 267 | (start_from and start_from < 1) 268 | or (end_at and end_at < 1) 269 | or (start_from and end_at and end_at < start_from) 270 | ): 271 | raise ValueError( 272 | f"You must provide positive integers for params -sa and -ea and -ea >= -sa. You've provided: {start_from} {end_at}" 273 | ) 274 | 275 | playlist_obj: Playlist = Playlist(searching_for, start_from, end_at) 276 | YD.download_playlist(playlist_obj.get_tracks()) 277 | 278 | else: 279 | print("Encountered error! Your parameter must be a valid link to the PUBLIC playlist or track!") 280 | 281 | exit(0) 282 | --------------------------------------------------------------------------------