├── 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 |
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 | [](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 |
74 |
75 | 2. You need to document or delete some lines of code in \_\_main\_\_.py like this (from 177 to 190):
76 |
77 |
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 |
--------------------------------------------------------------------------------