diff --git a/README.md b/README.md index 5f74908..a0a2557 100644 --- a/README.md +++ b/README.md @@ -22,51 +22,62 @@ Open a terminal session and navigate to this folder, using `cd`. cd spotify-to-mp3-python/ ``` -### 2. Installing dependencies +### 2. Install dependencies -We will be installing dependencies using `pip`, the official Python package manager. If you do not have `pip`, I'd recommend checking this [thread](https://stackoverflow.com/questions/6587507/how-to-install-pip-with-python-3/) to install it. +We will be installing dependencies using `uv`, the super fast Python package manager. If you do not have `uv`, I'd recommend checking this [thread](https://open.spotify.com/playlist/2FekNrO1rasjSaIndayN7O?si=66ef762c945e4f0a) to install it. Copy and paste (and run) the following line in your terminal session to install all necessary packages. ```bash -pip3 install spotipy && pip3 install youtube_dl && pip3 install youtube_search && pip3 install yt_dlp && pip3 install ffprobe && pip3 install ffmpeg +uv venv venv && source .venv/bin/activate +uv pip install setuptools -e . ``` -### 3. Setting up Spotify +### 2.1 Install ffmpeg +You will be needing `ffmpeg` to convert the downloaded audio files to MP3. +You can download it: + +* Ubuntu and Debian: `sudo apt-get install ffmpeg` + +* macOS: `brew install ffmpeg` + +* Windows: `choco install ffmpeg` + + + + +### 3. Set up Spotify Unfortunately, I could not find a workaround for this step - it seems like we're forced to go through the Spotify API to fetch information about playlists. But, it doesn't take long at all. -Go to the Spotify [dashboard](https://developer.spotify.com/dashboard/). Log in. Once at the Dashboard, click the green button labeled "Create App". Don't worry - you're not signing up for anything or commiting to something from Spotify. Here, **it really doesn't matter what you put** for "App name" and "App description". For me, I just put "Testing" for both. Make sure to check both agreement boxes and click "Create". +Go to the Spotify [dashboard](https://developer.spotify.com/dashboard/). Log in. Once at the Dashboard, click the green button labeled "Create App". Don't worry - you're not signing up for anything or commiting to something from Spotify. -You should see this: +Here **it really doesn't matter what you put** for "App name" and "App description." +I just put "Testing" for both. -![Spotify App Screen](https://miro.medium.com/max/1400/1*8c7agz6nxmez9-bm2NFCxQ.jpeg) +The next section is "Redirect URIs", which is a bit more important. +You can put anything here, but I'd recommend putting `http://localhost:8888/callback`. +The script won't be using this URL, but it's necessary to put something here. -You will see the "Client ID" field on the left (it's redacted here). Copy and save your Client ID somewhere - you'll need it later. Click "Show client secret" under Client ID and it should show you another long list of characters. Also copy and save your Client Secret. +Make sure to check "I understand and agree with Spotify's Developer Terms of Service and Design Guidelines" and click "Create". -Next, we need your playlist URI. To do this, simply open Spotify, right-click on the playlist you want to download, hover over "Share", and click "Copy Spotify URI". It should look something like this: `spotify:playlist:37i9dQZEVXbJiZcmkrIHGU`. When inputting this into the script, make sure to *only input the characters after "spotify:playlist:"*. So for this example, input `37i9dQZEVXbJiZcmkrIHGU`. Save your URI somewhere handy. -Alternatively, you can find the URI as follows: -1. Right-click on the playlist you want to download -2. Click "Share" -3. Click "Embed Playlist" -4. Click "Show Code" -5. The URI is the code between "https://open.spotify.com/embed/playlist/" and the first "?" +You should see this: -For example in this code snippet: +![Spotify App Screen](https://miro.medium.com/max/1400/1*8c7agz6nxmez9-bm2NFCxQ.jpeg) -```html - -``` +You will see the "Client ID" field on the left (it's redacted here). Copy and save your Client ID somewhere - you'll need it later. Click "Show client secret" under Client ID and it should show you another long list of characters. Also copy and save your Client Secret. + +Next, we need your playlist URI. To do this, simply open Spotify, right-click on the playlist you want to download, hover over "Share", and click "Copy link to playlist". -The code is 11cPCycyvvpL0MDLO648vE +It should look something like this: `https://open.spotify.com/playlist/2FekNrO1rasjSaIndayN7O?si=6666762ba5421f0a`. ### 4. Running Running this script is straightforward. Simply run in your terminal session: ```bash -python3 spotify_to_mp3.py +spotify_to_mp3 ``` If you run into an error saying something like "ffprobe or avprobe not found", check out this [solution](https://stackoverflow.com/questions/30770155/ffprobe-or-avprobe-not-found-please-install-one). @@ -75,12 +86,11 @@ If all goes well, you should see your playlist beginning to download in a folder ## Modifications -If you don't like inputting your Client ID, Client Secret, Username, and URI every time, you can edit lines 96-99 in `spotify_to_mp3.py` to set the respective variables into a string containing your credentials instead of prompting with `input()`. For example, line 98 would become +If you don't like inputting your Client ID and Client Secret every time, you can edit `config.ini` to set the respective variables. -```python -username = "YourUserName" -``` ## Debugging -This script was made in the better part of an afternoon and so it's not, by far, bug-free. Personally, I've run into no problems using this script on any of my playlists, however, your mileage may vary. The most promenant bug I've run into involves the `youtube-search` package not consistantly turning up results, and most of the time, the best solution is to simply try running the script again and giving it more chances to get the search right. +This script was made in the better part of an afternoon, and so it's not by far bug-free. + +Personally, I've run into no problems using this script on any of my playlists, however, your mileage may vary. The most promenant bug I've run into involves the `youtube-search` package not consistantly turning up results, and most of the time, the best solution is to simply try running the script again and giving it more chances to get the search right. diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..de8b68d --- /dev/null +++ b/setup.py @@ -0,0 +1,23 @@ +from setuptools import setup, find_packages + +setup( + name='spotify_to_mp3', + version='1.0.0', + description='A tool to download Spotify playlists as MP3 files', + author='Haim Daniel', + packages=find_packages(), + install_requires=[ + 'spotipy', + 'configparser', + 'youtube_dl', + 'youtube_search', + 'yt_dlp', + 'ffprobe', + 'ffmpeg', + ], + entry_points={ + 'console_scripts': [ + 'spotify_to_mp3=spotify_to_mp3.spotify_to_mp3:main', + ], + }, +) \ No newline at end of file diff --git a/spotify_to_mp3/__init__.py b/spotify_to_mp3/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/spotify_to_mp3.py b/spotify_to_mp3/spotify_to_mp3.py similarity index 55% rename from spotify_to_mp3.py rename to spotify_to_mp3/spotify_to_mp3.py index 2026153..f7a2deb 100644 --- a/spotify_to_mp3.py +++ b/spotify_to_mp3/spotify_to_mp3.py @@ -1,19 +1,23 @@ # Downloads a Spotify playlist into a folder of MP3 tracks # Jason Chen, 21 June 2020 +import multiprocessing import os +import pathlib +import urllib.request + import spotipy -import spotipy.oauth2 as oauth2 import yt_dlp -from youtube_search import YoutubeSearch -import multiprocessing -import urllib.request -from mutagen.mp3 import MP3 from mutagen.id3 import ID3, APIC, error +from mutagen.mp3 import MP3 +from spotipy.oauth2 import SpotifyClientCredentials +from youtube_search import YoutubeSearch + -# **************PLEASE READ THE README.md FOR USE INSTRUCTIONS**************n -def write_tracks(text_file: str, tracks: dict): - # This includins the name, artist, and spotify URL. Each is delimited by a comma. +# **************PLEASE READ THE README.md FOR USE INSTRUCTIONS************** +def write_tracks(client, text_file: str, tracks: dict): + # This includes the name, artist, and spotify URL. + # A comma delimits each field and a newline delimiting each track. with open(text_file, 'w+', encoding='utf-8') as file_out: while True: for item in tracks['items']: @@ -24,61 +28,95 @@ def write_tracks(text_file: str, tracks: dict): try: track_url = track['external_urls']['spotify'] track_name = track['name'] - track_artist = track['artists'][0]['name'] + track_artist = track['artists'][0]['name'] or 'Unknown Artist' album_art_url = track['album']['images'][0]['url'] - csv_line = track_name + "," + track_artist + "," + track_url + "," + album_art_url + "\n" + csv_line = ( + track_name + + "," + + track_artist + + "," + + track_url + + "," + + album_art_url + + "\n" + ) try: file_out.write(csv_line) - except UnicodeEncodeError: # Most likely caused by non-English song names - print("Track named {} failed due to an encoding error. This is \ - most likely due to this song having a non-English name.".format(track_name)) + except ( + UnicodeEncodeError + ): # Most likely caused by non-English song names + print( + "Track named {} failed due to an encoding error. This is \ + most likely due to this song having a non-English name.".format( + track_name + ) + ) except KeyError: - print(u'Skipping track {0} by {1} (local only?)'.format( - track['name'], track['artists'][0]['name'])) + print( + u'Skipping track {0} by {1} (local only?)'.format( + track['name'], track['artists'][0]['name'] + ) + ) # 1 page = 50 results, check if there are more pages if tracks['next']: - tracks = spotify.next(tracks) + tracks = client.next(tracks) else: break -def write_playlist(username: str, playlist_id: str): - results = spotify.user_playlist(username, playlist_id, fields='tracks,next,name') +def get_playlist_id(playlist_uri: str) -> str: + assert playlist_uri.startswith("https://open.spotify.com/playlist/") + return playlist_uri.split('https://open.spotify.com/playlist/')[1].split('?')[0] + + +def write_playlist(client, playlist_uri: str): + playlist_id = get_playlist_id(playlist_uri) + results = client.playlist(playlist_id, fields='tracks,next,name') playlist_name = results['name'] text_file = u'{0}.txt'.format(playlist_name, ok='-_()[]{}') print(u'Writing {0} tracks to {1}.'.format(results['tracks']['total'], text_file)) tracks = results['tracks'] - write_tracks(text_file, tracks) + write_tracks(client, text_file, tracks) - imgURLs = []; + img_urls = [] for item in tracks['items']: - imgURLs.append(item['track']['album']['images'][0]['url']); - return playlist_name, imgURLs + img_urls.append(item['track']['album']['images'][0]['url']) + return playlist_name, img_urls + def find_and_download_songs(reference_file: str): - TOTAL_ATTEMPTS = 10 + total_attempts = 10 with open(reference_file, "r", encoding='utf-8') as file: for line in file: temp = line.split(",") name, artist, album_art_url = temp[0], temp[1], temp[3] text_to_search = artist + " - " + name best_url = None - attempts_left = TOTAL_ATTEMPTS + attempts_left = total_attempts while attempts_left > 0: try: - results_list = YoutubeSearch(text_to_search, max_results=1).to_dict() - best_url = "https://www.youtube.com{}".format(results_list[0]['url_suffix']) + results_list = YoutubeSearch( + text_to_search, max_results=1 + ).to_dict() + best_url = "https://www.youtube.com{}".format( + results_list[0]['url_suffix'] + ) break except IndexError: attempts_left -= 1 - print("No valid URLs found for {}, trying again ({} attempts left).".format( - text_to_search, attempts_left)) + print( + "No valid URLs found for {}, trying again ({} attempts left).".format( + text_to_search, attempts_left + ) + ) if best_url is None: - print("No valid URLs found for {}, skipping track.".format(text_to_search)) + print( + "No valid URLs found for {}, skipping track.".format(text_to_search) + ) continue print("Initiating download for Image {}.".format(album_art_url)) - f = open('{}.jpg'.format(name),'wb') + f = open('{}.jpg'.format(name), 'wb') f.write(urllib.request.urlopen(album_art_url).read()) f.close() @@ -86,15 +124,18 @@ def find_and_download_songs(reference_file: str): print("Initiating download for {}.".format(text_to_search)) ydl_opts = { 'format': 'bestaudio/best', - 'outtmpl':'%(title)s', #name the file the ID of the video + 'outtmpl': '%(title)s', # name the file the ID of the video 'embedthumbnail': True, - 'postprocessors': [{ - 'key': 'FFmpegExtractAudio', - 'preferredcodec': 'mp3', - 'preferredquality': '192', - }, { - 'key': 'FFmpegMetadata', - }] + 'postprocessors': [ + { + 'key': 'FFmpegExtractAudio', + 'preferredcodec': 'mp3', + 'preferredquality': '192', + }, + { + 'key': 'FFmpegMetadata', + }, + ], } with yt_dlp.YoutubeDL(ydl_opts) as ydl: info_dict = ydl.extract_info([best_url][0], download=True) @@ -116,20 +157,19 @@ def find_and_download_songs(reference_file: str): mime="image/jpeg", # can be image/jpeg or image/png type=3, # 3 is for the cover image desc='Cover', - data=open("{}.jpg".format(name), mode='rb').read() + data=open("{}.jpg".format(name), mode='rb').read(), ) ) audio.save() os.remove("{}.jpg".format(name)) +def multicore_find_and_download_songs(reference_file: str, n_cores: int): + """Extract songs from the reference file. - -# Multiprocessed implementation of find_and_download_songs -# This method is responsible for manging and distributing the multi-core workload -def multicore_find_and_download_songs(reference_file: str, cpu_count: int): - # Extract songs from the reference file - + Multiprocessed implementation of find_and_download_songs + This method is responsible for managing and distributing the multicore workload + """ lines = [] with open(reference_file, "r", encoding='utf-8') as file: for line in file: @@ -137,17 +177,17 @@ def multicore_find_and_download_songs(reference_file: str, cpu_count: int): # Process allocation of songs per cpu number_of_songs = len(lines) - songs_per_cpu = number_of_songs // cpu_count + songs_per_cpu = number_of_songs // n_cores - # Calculates number of songs that dont evenly fit into the cpu list + # Calculates number of songs that don't evenly fit into the cpu list # i.e. 4 cores and 5 songs, one core will have to process 1 extra song - extra_songs = number_of_songs - (cpu_count * songs_per_cpu) + extra_songs = number_of_songs - (n_cores * songs_per_cpu) # Create a list of number of songs which by index allocates it to a cpu # 4 core cpu and 5 songs [2, 1, 1, 1] where each item is the number of songs # Core 0^ 1^ 2^ 3^ cpu_count_list = [] - for cpu in range(cpu_count): + for cpu in range(n_cores): songs = songs_per_cpu if cpu < extra_songs: songs = songs + 1 @@ -167,7 +207,9 @@ def multicore_find_and_download_songs(reference_file: str, cpu_count: int): processes = [] segment_index = 0 for segment in file_segments: - p = multiprocessing.Process(target = multicore_handler, args=(segment, segment_index)) + p = multiprocessing.Process( + target=multicore_handler, args=(segment, segment_index) + ) processes.append(p) segment_index = segment_index + 1 @@ -179,9 +221,13 @@ def multicore_find_and_download_songs(reference_file: str, cpu_count: int): for p in processes: p.join() -# Just a wrapper around the original find_and_download_songs method to ensure future compatibility -# Preserves the same functionality just allows for several shorter lists to be used and cleaned up + def multicore_handler(reference_list: list, segment_index: int): + """A wrapper around the original find_and_download_songs method + + Ensures future compatibility, and reserves the same functionality. + Just allows for several shorter lists to be used and cleaned up + """ # Create reference filename based off of the process id (segment_index) reference_filename = "{}.txt".format(segment_index) @@ -194,68 +240,46 @@ def multicore_handler(reference_list: list, segment_index: int): find_and_download_songs(reference_filename) # Clean up the extra list that was generated - if(os.path.exists(reference_filename)): + if os.path.exists(reference_filename): os.remove(reference_filename) -# This is prompt to handle the multicore queries -# An effort has been made to create an easily automated interface -# Autoeneable: bool allows for no prompts and defaults to max core usage -# Maxcores: int allows for automation of set number of cores to be used -# Buffercores: int allows for an allocation of unused cores (default 1) -def enable_multicore(autoenable=False, maxcores=None, buffercores=1): - native_cpu_count = multiprocessing.cpu_count() - buffercores - if autoenable: - if maxcores: - if(maxcores <= native_cpu_count): - return maxcores - else: - print("Too many cores requested, single core operation fallback") - return 1 - return multiprocessing.cpu_count() - 1 - multicore_query = input("Enable multiprocessing (Y or N): ") - if multicore_query not in ["Y","y","Yes","YES","YEs",'yes']: - return 1 - core_count_query = int(input("Max core count (0 for allcores): ")) - if(core_count_query == 0): - return native_cpu_count - if(core_count_query <= native_cpu_count): - return core_count_query - else: - print("Too many cores requested, single core operation fallback") - return 1 - -if __name__ == "__main__": - # Parameters - print("Please read README.md for use instructions.") - if os.path.isfile('config.ini'): +def get_config(): + cur_dir = pathlib.Path(os.path.dirname(os.path.realpath(__file__))) + config_file = cur_dir.parent / "config.ini" + if config_file.exists(): import configparser + config = configparser.ConfigParser() - config.read("config.ini") + config.read(config_file) client_id = config["Settings"]["client_id"] client_secret = config["Settings"]["client_secret"] - username = config["Settings"]["username"] else: client_id = input("Client ID: ") client_secret = input("Client secret: ") - username = input("Spotify username: ") - playlist_uri = input("Playlist URI/Link: ") - if playlist_uri.find("https://open.spotify.com/playlist/") != -1: - playlist_uri = playlist_uri.replace("https://open.spotify.com/playlist/", "") - multicore_support = enable_multicore(autoenable=False, maxcores=None, buffercores=1) - auth_manager = oauth2.SpotifyClientCredentials(client_id=client_id, client_secret=client_secret) - spotify = spotipy.Spotify(auth_manager=auth_manager) - playlist_name, albumArtUrls = write_playlist(username, playlist_uri) - reference_file = "{}.txt".format(playlist_name) + return client_id, client_secret + + +def main(): + print("Please read README.md for use instructions.") + client_id, client_secret = get_config() + playlist_uri = input("Playlist URI: ") + auth_manager = SpotifyClientCredentials( + client_id=client_id, client_secret=client_secret + ) + sp = spotipy.Spotify(auth_manager=auth_manager) + playlist_name, album_art_urls = write_playlist(sp, playlist_uri) + reference_file = f"{playlist_name}.txt" # Create the playlist folder if not os.path.exists(playlist_name): os.makedirs(playlist_name) os.rename(reference_file, playlist_name + "/" + reference_file) os.chdir(playlist_name) - # Enable multicore support - if multicore_support > 1: - multicore_find_and_download_songs(reference_file, multicore_support) - else: - find_and_download_songs(reference_file) + n_cores = multiprocessing.cpu_count() + multicore_find_and_download_songs(reference_file, n_cores) os.remove(f'{reference_file}') - print("Operation complete.") \ No newline at end of file + print("Operation complete.") + + +if __name__ == "__main__": + main()