diff --git a/README.md b/README.md index 34c4ebf..7363f95 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,8 @@ Tools for moving from Spotify to YTMusic # Overview This is a set of scripts for copying "liked" songs and playlists from Spotify to YTMusic. -There is also [a GUI version by Yoween available](https://github.com/Yoween/spotify_to_ytmusic_gui). +It provides both CLI tools and a GUI (implemented by Yoween, formerly called +[spotify_to_ytmusic_gui](https://github.com/Yoween/spotify_to_ytmusic_gui)). # Getting Started @@ -32,15 +33,97 @@ git clone git@github.com:linsomniac/spotify_to_ytmusic.git cd spotify_to_ytmusic ``` -Then you can run the following commands to run the individual s2yt commands: +Then you can prefix the command you want to run with `python3 -m spotify2ytmusic`, for +example: -- For s2yt_copy_playlist: `python3 -m spotify2ytmusic.copy_playlist` -- For s2yt_create_playlist: `python3 -m spotify2ytmusic.create_playlist` -- For s2yt_list_playlists: `python3 -m spotify2ytmusic.list_playlists` -- For s2yt_load_liked: `python3 -m spotify2ytmusic.load_liked` -- For s2yt_load_liked_albums: `python3 -m spotify2ytmusic.load_liked_albums` +```shell +python3 -m spotify2ytmusic list_playlists +python3 -m spotify2ytmusic load_liked +[etc...] +``` + +## Graphical UI + +On Windows: `python gui.py` + +Or on Linux: `python3 gui.py` + +### Login to YTMusic - Tab 0 + +#### Click the `login` button on the first tab + +OR + +Enter `ytmusicapi oauth` in a console. + +This will give you a URL, visit that URL and authorize the application. When you are +done with the import you can remove the authorization for this app. + +This will write a file "oauth.json". Keep this file secret while the app is authorized. +This file includes a logged in session token. + +ytmusicapi is a dependency of this software and should be installed as part of the "pip +install". + +### Backup Your Spotify Playlists - Tab 1 + +#### Click the `Backup` button, and wait until it finished and switched to the next tab. + +**OR** do all the steps below -## Login to YTMusic +Download +[spotify-backup](https://raw.githubusercontent.com/caseychu/spotify-backup/master/spotify-backup.py). + +Run `spotify-backup.py` and it will help you authorize access to your spotify account. + +Run: `python3 spotify-backup.py playlists.json --dump=liked,playlists --format=json` + +This will save your playlists and liked songs into the file "playlists.json". + +### Reverse your playlists - Tab 2 +As mentionned below, the original program adds the songs in the 'wrong' order. That's a +feature I don't like, so I created a script to reverse them. It seems to be reliable, +but if you find anything weird, please open an issue. It creates a backup of the +original file just in case anyway. + +### Import Your Liked Songs - Tab 3 +#### Click the `import` button, and wait until it finished and switched to the next tab. + +It will go through your Spotify liked songs, and like them on YTMusic. It will display +the song from spotify and then the song that it found on YTMusic that it is liking. I've +spot-checked my songs and it seems to be doing a good job of matching YTMusic songs with +Spotify. So far I haven't seen a single failure across a couple hundread songs, but more +esoteric titles it may have issues with. + +### List Your Playlists - Tab 4 + +#### Click the `list` button, and wait until it finished and switched to the next tab. + +This will list the playlists you have on both Spotify and YTMusic, so you can individually copy them. + +### Copy Your Playlists - Tab 5 + +You can either copy **all** playlists, or do a more surgical copy of individual playlists. +Copying all playlists will use the name of the Spotify playlist as the destination playlist name on YTMusic. + +#### To copy all the playlists click the `copy` button, and wait until it finished and switched to the next tab. + +**NOTE**: This does not copy the Liked playlist (see above to do that). + +### Copy specific Playlist - Tab 6 + +In the list output, find the "playlist id" (the first column) of the Spotify playlist and of the YTMusic playlist. +#### Then fill both input fields and click the `copy` button. + +The copy playlist will take the name of the YTMusic playlist and will create the +playlist if it does not exist, if you start the YTMusic playlist with a "+": + +Re-running "copy_playlist" or "load_liked" in the event that it fails should be safe, it +will not duplicate entries on the playlist. + +## Command Line Usage + +### Login to YTMusic `ytmusicapi oauth` @@ -53,7 +136,7 @@ This file includes a logged in session token. ytmusicapi is a dependency of this software and should be installed as part of the "pip install". -## Backup Your Spotify Playlists +### Backup Your Spotify Playlists Download [spotify-backup](https://raw.githubusercontent.com/caseychu/spotify-backup/master/spotify-backup.py). @@ -64,7 +147,7 @@ Run: `python3 spotify-backup.py playlists.json --dump=liked,playlists --format=j This will save your playlists and liked songs into the file "playlists.json". -## Import Your Liked Songs +### Import Your Liked Songs Run: `s2yt_load_liked` @@ -74,21 +157,21 @@ spot-checked my songs and it seems to be doing a good job of matching YTMusic so Spotify. So far I haven't seen a single failure across a couple thousand songs, but more esoteric titles it may have issues with. -## Import Your Liked Albums +### Import Your Liked Albums Run: `s2yt_load_liked_albums` Spotify stores liked albums outside of the "Liked Songs" playlist. This is the command to load your liked albums into YTMusic liked songs. -## List Your Playlists +### List Your Playlists Run `s2yt_list_playlists` This will list the playlists you have on both Spotify and YTMusic. You will need to individually copy them. -## Copy Your Playlists +### Copy Your Playlists You can either copy **all** playlists, or do a more surgical copy of individual playlists. Copying all playlists will use the name of the Spotify playlist as the destination @@ -119,7 +202,7 @@ For example: Re-running "copy_playlist" or "load_liked" in the event that it fails should be safe, it will not duplicate entries on the playlist. -## Searching for YTMusic Tracks +### Searching for YTMusic Tracks This is mostly for debugging, but there is a command to search for tracks in YTMusic: @@ -149,4 +232,7 @@ This is mostly for debugging, but there is a command to search for tracks in YTM Creative Commons Zero v1.0 Universal +spotify-backup.py licensed under MIT License +See https://github.com/caseychu/spotify-backup for more information + [//]: # ( vim: set tw=90 ts=4 sw=4 ai: ) diff --git a/spotify2ytmusic/__main__.py b/spotify2ytmusic/__main__.py new file mode 100644 index 0000000..e9e5ea6 --- /dev/null +++ b/spotify2ytmusic/__main__.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python3 + +from . import cli +import sys + +if len(sys.argv) < 2: + print(f"usage: spotify2ytmusic [COMMAND] ") + print(" For example, try 'list_playlists'") + sys.exit(1) + +if not hasattr(cli, sys.argv[1]): + print( + f"ERROR: Unknown command, see https://github.com/linsomniac/spotify_to_ytmusic" + ) + sys.exit(1) + +fn = getattr(cli, sys.argv[1]) +sys.argv = sys.argv[1:] +fn() diff --git a/spotify2ytmusic/backend.py b/spotify2ytmusic/backend.py new file mode 100644 index 0000000..afcace3 --- /dev/null +++ b/spotify2ytmusic/backend.py @@ -0,0 +1,431 @@ +#!/usr/bin/env python3 + +import json +import sys +import os +import time +import re + +from ytmusicapi import YTMusic +from typing import Optional, Union, Iterator +from collections import namedtuple + + +SongInfo = namedtuple("SongInfo", ["title", "artist", "album"]) + + +def get_ytmusic() -> YTMusic: + """ + @@@ + """ + if not os.path.exists("oauth.json"): + print("ERROR: No file 'oauth.json' exists in the current directory.") + print(" Have you logged in to YTMusic? Run 'ytmusicapi oauth' to login") + sys.exit(1) + + try: + return YTMusic("oauth.json") + except json.decoder.JSONDecodeError as e: + print(f"ERROR: JSON Decode error while trying start YTMusic: {e}") + print(" This typically means a problem with a 'oauth.json' file.") + print(" Have you logged in to YTMusic? Run 'ytmusicapi oauth' to login") + sys.exit(1) + + +def _ytmusic_create_playlist(yt: YTMusic, title: str, description: str) -> str: + """Wrapper on ytmusic.create_playlist + + This wrapper does retries with back-off because sometimes YouTube Music will + rate limit requests or otherwise fail. + """ + + def _create(yt: YTMusic, title: str, description: str) -> Union[str, dict]: + exception_sleep = 5 + for _ in range(10): + try: + """Create a playlist on YTMusic, retrying if it fails.""" + id = yt.create_playlist(title=title, description=description) + return id + except Exception as e: + print( + f"ERROR: (Retrying create_playlist: {title}) {e} in {exception_sleep} seconds" + ) + time.sleep(exception_sleep) + exception_sleep *= 2 + + return { + "s2yt error": 'ERROR: Could not create playlist "{title}" after multiple retries' + } + + id = _create(yt, title, description) + # create_playlist returns a dict if there was an error + if isinstance(id, dict): + print(f"ERROR: Failed to create playlist (name: {title}): {id}") + sys.exit(1) + + time.sleep(1) # seems to be needed to avoid missing playlist ID error + + return id + + +def load_playlists_json(filename: str = "playlists.json", encoding: str = "utf-8"): + """Load the `playlists.json` Spotify playlist file""" + return json.load(open(filename, "r", encoding=encoding)) + + +def create_playlist(pl_name: str): + """ + Create a YTMusic playlist + """ + yt = get_ytmusic() + + id = _ytmusic_create_playlist(yt, title=pl_name, description=pl_name) + print(f"Playlist ID: {id}") + + +def iter_spotify_liked_albums( + spotify_playlist_file: str = "playlists.json", + spotify_encoding: str = "utf-8", +) -> Iterator[SongInfo]: + """Songs from liked albums on Spotify.""" + spotify_pls = load_playlists_json(spotify_playlist_file, spotify_encoding) + + if not "albums" in spotify_pls: + return None + + for album in [x["album"] for x in spotify_pls["albums"]]: + for track in album["tracks"]["items"]: + yield SongInfo(track["name"], track["artists"][0]["name"], album["name"]) + + +def iter_spotify_playlist( + src_pl_id: Optional[str] = None, + spotify_playlist_file: str = "playlists.json", + spotify_encoding: str = "utf-8", +) -> Iterator[SongInfo]: + """Songs from a specific album ("Liked Songs" if None)""" + spotify_pls = load_playlists_json(spotify_playlist_file, spotify_encoding) + + for src_pl in spotify_pls["playlists"]: + if src_pl_id is None: + if str(src_pl.get("name")) != "Liked Songs": + continue + else: + if str(src_pl.get("id")) != src_pl_id: + continue + + src_pl_name = src_pl["name"] + + print(f"== Spotify Playlist: {src_pl_name}") + + for src_track in reversed(src_pl["tracks"]): + if src_track["track"] is None: + print( + f"WARNING: Spotify track seems to be malformed, Skipping. Track: {src_track!r}" + ) + continue + + try: + src_album_name = src_track["track"]["album"]["name"] + src_track_artist = src_track["track"]["artists"][0]["name"] + except TypeError as e: + print( + f"ERROR: Spotify track seems to be malformed. Track: {src_track!r}" + ) + raise (e) + src_track_name = src_track["track"]["name"] + + yield SongInfo(src_track_name, src_track_artist, src_album_name) + + +def get_playlist_id_by_name(yt: YTMusic, title: str) -> Optional[str]: + """Look up a YTMusic playlist ID by name. Return None if not found.""" + for pl in yt.get_library_playlists(limit=5000): + if pl["title"] == title: + return pl["playlistId"] + + return None + + +def lookup_song( + yt: YTMusic, track_name: str, artist_name: str, album_name, yt_search_algo: int +) -> dict: + """Look up a song on YTMusic + + Given the Spotify track information, it does a lookup for the album by the same + artist on YTMusic, then looks at the first 3 hits looking for a track with exactly + the same name. In the event that it can't find that exact track, it then does + a search of songs for the track name by the same artist and simply returns the + first hit. + + The idea is that finding the album and artist and then looking for the exact track + match will be more likely to be accurate than searching for the song and artist and + relying on the YTMusic yt_search_algorithm to figure things out, especially for short tracks + that might be have many contradictory hits like "Survival by Yes". + + Args: + yt (YTMusic) + track_name (str): The name of the researched track + artist_name (str): The name of the researched track's artist + album_name (str): The name of the researched track's album + yt_search_algo (int): 0 for exact matching, 1 for extended matching (search past 1st result), 2 for approximate matching (search in videos) + + Raises: + ValueError: If no track is found, it returns an error + + Returns: + dict: The infos of the researched song + """ + albums = yt.search(query=f"{album_name} by {artist_name}", filter="albums") + for album in albums[:3]: + # print(album) + # print(f"ALBUM: {album['browseId']} - {album['title']} - {album['artists'][0]['name']}") + + try: + for track in yt.get_album(album["browseId"])["tracks"]: + if track["title"] == track_name: + return track + # print(f"{track['videoId']} - {track['title']} - {track['artists'][0]['name']}") + except Exception as e: + print(f"Unable to lookup album ({e}), continuing...") + + songs = yt.search(query=f"{track_name} by {artist_name}", filter="songs") + + match yt_search_algo: + case 0: + return songs[0] + + case 1: + for song in songs: + if ( + song["title"] == track_name + and song["artists"][0]["name"] == artist_name + and song["album"]["name"] == album_name + ): + return song + # print(f"SONG: {song['videoId']} - {song['title']} - {song['artists'][0]['name']} - {song['album']['name']}") + + raise ValueError( + f"Did not find {track_name} by {artist_name} from {album_name}" + ) + + case 2: + # This would need to do fuzzy matching + for song in songs: + # Remove everything in brackets in the song title + song_title_without_brackets = re.sub( + r"[\[\(].*?[\]\)]", "", song["title"] + ) + if ( + ( + song_title_without_brackets == track_name + and song["album"]["name"] == album_name + ) + or (song_title_without_brackets == track_name) + or (song_title_without_brackets in track_name) + or (track_name in song_title_without_brackets) + ) and ( + song["artists"][0]["name"] == artist_name + or artist_name in song["artists"][0]["name"] + ): + return song + + # Finds approximate match + # This tries to find a song anyway. Works when the song is not released as a music but a video. + else: + track_name = track_name.lower() + first_song_title = songs[0]["title"].lower() + if ( + track_name not in first_song_title + or songs[0]["artists"][0]["name"] != artist_name + ): # If the first song is not the one we are looking for + print("Not found in songs, searching videos") + new_songs = yt.search( + query=f"{track_name} by {artist_name}", filter="videos" + ) # Search videos + + # From here, we search for videos reposting the song. They often contain the name of it and the artist. Like with 'Nekfeu - Ecrire'. + for new_song in new_songs: + new_song_title = new_song[ + "title" + ].lower() # People sometimes mess up the capitalization in the title + if ( + track_name in new_song_title + and artist_name in new_song_title + ) or (track_name in new_song_title): + print("Found a video") + return new_song + else: + # Basically we only get here if the song isnt present anywhere on youtube + raise ValueError( + f"Did not find {track_name} by {artist_name} from {album_name}" + ) + else: + return songs[0] + + +def copier( + src_tracks: Iterator[SongInfo], + dst_pl_id: Optional[str] = None, + dry_run: bool = False, + track_sleep: float = 0.1, + *, + yt: Optional[YTMusic] = None, +): + """ + @@@ + """ + if yt is None: + yt = get_ytmusic() + + if dst_pl_id is not None: + try: + yt_pl = yt.get_playlist(playlistId=dst_pl_id) + except Exception as e: + print(f"ERROR: Unable to find YTMusic playlist {dst_pl_id}: {e}") + print( + " Make sure the YTMusic playlist ID is correct, it should be something like " + ) + print(" 'PL_DhcdsaJ7echjfdsaJFhdsWUd73HJFca'") + sys.exit(1) + print(f"== Youtube Playlist: {yt_pl['title']}") + + tracks_added_set = set() + duplicate_count = 0 + error_count = 0 + + for src_track in src_tracks: + print(f"Spotify: {src_track.title} - {src_track.artist} - {src_track.album}") + + try: + dst_track = lookup_song( + yt, src_track.title, src_track.artist, src_track.album + ) + except Exception as e: + print(f"ERROR: Unable to look up song on YTMusic: {e}") + error_count += 1 + continue + + yt_artist_name = "" + if "artists" in dst_track and len(dst_track["artists"]) > 0: + yt_artist_name = dst_track["artists"][0]["name"] + print( + f" Youtube: {dst_track['title']} - {yt_artist_name} - {dst_track['album']}" + ) + + if dst_track["videoId"] in tracks_added_set: + print("(DUPLICATE, this track has already been added)") + duplicate_count += 1 + tracks_added_set.add(dst_track["videoId"]) + + if not dry_run: + exception_sleep = 5 + for _ in range(10): + try: + if dst_pl_id is not None: + yt.add_playlist_items( + playlistId=dst_pl_id, + videoIds=[dst_track["videoId"]], + duplicates=False, + ) + else: + yt.rate_song(dst_track["videoId"], "LIKE") + break + except Exception as e: + print( + f"ERROR: (Retrying add_playlist_items: {dst_pl_id} {dst_track['videoId']}) {e} in {exception_sleep} seconds" + ) + time.sleep(exception_sleep) + exception_sleep *= 2 + + if track_sleep: + time.sleep(track_sleep) + + print() + print( + f"Added {len(tracks_added_set)} tracks, encountered {duplicate_count} duplicates, {error_count} errors" + ) + + +def copy_playlist( + spotify_playlist_id: str, + ytmusic_playlist_id: str, + spotify_playlists_encoding: str = "utf-8", + dry_run: bool = False, + track_sleep: float = 0.1, +): + """ + Copy a Spotify playlist to a YTMusic playlist + @@@ + """ + yt = get_ytmusic() + if ytmusic_playlist_id.startswith("+"): + pl_name = ytmusic_playlist_id[1:] + + ytmusic_playlist_id = get_playlist_id_by_name(yt, pl_name) + print(f"Looking up playlist '{pl_name}': id={ytmusic_playlist_id}") + if ytmusic_playlist_id is None: + ytmusic_playlist_id = _ytmusic_create_playlist( + yt, title=pl_name, description=pl_name + ) + time.sleep(1) # seems to be needed to avoid missing playlist ID error + + # create_playlist returns a dict if there was an error + if isinstance(ytmusic_playlist_id, dict): + print(f"ERROR: Failed to create playlist: {ytmusic_playlist_id}") + sys.exit(1) + print(f"NOTE: Created playlist '{pl_name}' with ID: {ytmusic_playlist_id}") + + copier( + iter_spotify_playlist( + spotify_playlist_id, spotify_encoding=spotify_playlists_encoding + ), + ytmusic_playlist_id, + dry_run, + track_sleep, + yt=yt, + ) + + +def copy_all_playlists( + track_sleep: float = 0.1, + dry_run: bool = False, + spotify_playlists_encoding: str = "utf-8", +): + """ + Copy all Spotify playlists (except Liked Songs) to YTMusic playlists + """ + spotify_pls = load_playlists_json() + yt = get_ytmusic() + + for src_pl in spotify_pls["playlists"]: + if str(src_pl.get("name")) == "Liked Songs": + continue + + pl_name = src_pl["name"] + if pl_name == "": + pl_name = f"Unnamed Spotify Playlist {src_pl['id']}" + + dst_pl_id = get_playlist_id_by_name(yt, pl_name) + print(f"Looking up playlist '{pl_name}': id={dst_pl_id}") + if dst_pl_id is None: + dst_pl_id = _ytmusic_create_playlist(yt, title=pl_name, description=pl_name) + time.sleep(1) # seems to be needed to avoid missing playlist ID error + + # create_playlist returns a dict if there was an error + if isinstance(dst_pl_id, dict): + print(f"ERROR: Failed to create playlist: {dst_pl_id}") + sys.exit(1) + print(f"NOTE: Created playlist '{pl_name}' with ID: {dst_pl_id}") + + copier( + iter_spotify_playlist( + src_pl["id"], spotify_encoding=spotify_playlists_encoding + ), + dst_pl_id, + dry_run, + track_sleep, + ) + print("\nPlaylist done!\n") + + print("All done!") diff --git a/spotify2ytmusic/cli.py b/spotify2ytmusic/cli.py index ce6f506..93d8ab6 100644 --- a/spotify2ytmusic/cli.py +++ b/spotify2ytmusic/cli.py @@ -1,102 +1,17 @@ #!/usr/bin/env python3 -import json import sys import os -import time from argparse import ArgumentParser -from ytmusicapi import YTMusic -from typing import Optional, Union, Iterator -from collections import namedtuple - -SongInfo = namedtuple("SongInfo", ["title", "artist", "album"]) - - -def iter_spotify_liked_albums( - spotify_playlist_file: str = "playlists.json", - spotify_encoding: str = "utf-8", -) -> Iterator[SongInfo]: - """Songs from liked albums on Spotify.""" - spotify_pls = load_playlists_json(spotify_playlist_file, spotify_encoding) - - if not "albums" in spotify_pls: - return None - - for album in [x["album"] for x in spotify_pls["albums"]]: - for track in album["tracks"]["items"]: - yield SongInfo(track["name"], track["artists"][0]["name"], album["name"]) - - -def iter_spotify_playlist( - src_pl_id: Optional[str] = None, - spotify_playlist_file: str = "playlists.json", - spotify_encoding: str = "utf-8", -) -> Iterator[SongInfo]: - """Songs from a specific album ("Liked Songs" if None)""" - spotify_pls = load_playlists_json(spotify_playlist_file, spotify_encoding) - - for src_pl in spotify_pls["playlists"]: - if src_pl_id is None: - if str(src_pl.get("name")) != "Liked Songs": - continue - else: - if str(src_pl.get("id")) != src_pl_id: - continue - - src_pl_name = src_pl["name"] - - print(f"== Spotify Playlist: {src_pl_name}") - - for src_track in reversed(src_pl["tracks"]): - if src_track["track"] is None: - print( - f"WARNING: Spotify track seems to be malformed, Skipping. Track: {src_track!r}" - ) - continue - - try: - src_album_name = src_track["track"]["album"]["name"] - src_track_artist = src_track["track"]["artists"][0]["name"] - except TypeError as e: - print( - f"ERROR: Spotify track seems to be malformed. Track: {src_track!r}" - ) - raise (e) - src_track_name = src_track["track"]["name"] - - yield SongInfo(src_track_name, src_track_artist, src_album_name) - - -def get_ytmusic() -> YTMusic: - if not os.path.exists("oauth.json"): - print("ERROR: No file 'oauth.json' exists in the current directory.") - print(" Have you logged in to YTMusic? Run 'ytmusicapi oauth' to login") - sys.exit(1) - - try: - return YTMusic("oauth.json") - except json.decoder.JSONDecodeError as e: - print(f"ERROR: JSON Decode error while trying start YTMusic: {e}") - print(" This typically means a problem with a 'oauth.json' file.") - print(" Have you logged in to YTMusic? Run 'ytmusicapi oauth' to login") - sys.exit(1) - - -def get_playlist_id_by_name(yt: YTMusic, title: str) -> Optional[str]: - """Look up a YTMusic playlist ID by name. Return None if not found.""" - for pl in yt.get_library_playlists(limit=5000): - if pl["title"] == title: - return pl["playlistId"] - - return None +import backend def list_liked_albums(): """ List albums that have been liked. """ - for song in iter_spotify_liked_albums(): + for song in backend.iter_spotify_liked_albums(): print(f"{song.album} - {song.artist} - {song.title}") @@ -104,9 +19,9 @@ def list_playlists(): """ List the playlists on Spotify and YTMusic """ - yt = get_ytmusic() + yt = backend.get_ytmusic() - spotify_pls = load_playlists_json() + spotify_pls = backend.load_playlists_json() # Liked music print("== Spotify") @@ -121,36 +36,6 @@ def list_playlists(): print(f"{pl['playlistId']} - {pl['title']:40} ({pl.get('count', '?')} tracks)") -def _ytmusic_create_playlist(yt: YTMusic, title: str, description: str) -> str: - def _create(yt: YTMusic, title: str, description: str) -> Union[str, dict]: - exception_sleep = 5 - for _ in range(10): - try: - """Create a playlist on YTMusic, retrying if it fails.""" - id = yt.create_playlist(title=title, description=description) - return id - except Exception as e: - print( - f"ERROR: (Retrying create_playlist: {title}) {e} in {exception_sleep} seconds" - ) - time.sleep(exception_sleep) - exception_sleep *= 2 - - return { - "s2yt error": 'ERROR: Could not create playlist "{title}" after multiple retries' - } - - id = _create(yt, title, description) - # create_playlist returns a dict if there was an error - if isinstance(id, dict): - print(f"ERROR: Failed to create playlist (name: {title}): {id}") - sys.exit(1) - - time.sleep(1) # seems to be needed to avoid missing playlist ID error - - return id - - def create_playlist(): """ Create a YTMusic playlist @@ -160,54 +45,7 @@ def create_playlist(): sys.exit(1) pl_name = sys.argv[1] - - yt = get_ytmusic() - - id = _ytmusic_create_playlist(yt, title=pl_name, description=pl_name) - print(f"Playlist ID: {id}") - - -def lookup_song(yt, track_name, artist_name, album_name): - """Look up a song on YTMusic - - Given the Spotify track information, it does a lookup for the album by the same - artist on YTMusic, then looks at the first 3 hits looking for a track with exactly - the same name. In the event that it can't find that exact track, it then does - a search of songs for the track name by the same artist and simply returns the - first hit. - - The idea is that finding the album and artist and then looking for the exact track - match will be more likely to be accurate than searching for the song and artist and - relying on the YTMusic algorithm to figure things out, especially for short tracks - that might be have many contradictory hits like "Survival by Yes". - """ - albums = yt.search(query=f"{album_name} by {artist_name}", filter="albums") - for album in albums[:3]: - # print(album) - # print(f"ALBUM: {album['browseId']} - {album['title']} - {album['artists'][0]['name']}") - - try: - for track in yt.get_album(album["browseId"])["tracks"]: - if track["title"] == track_name: - return track - # print(f"{track['videoId']} - {track['title']} - {track['artists'][0]['name']}") - except Exception as e: - print(f"Unable to lookup album ({e}), continuing...") - - songs = yt.search(query=f"{track_name} by {artist_name}", filter="songs") - return songs[0] - - # This would need to do fuzzy matching - for song in songs: - if ( - song["title"] == track_name - and song["artists"][0]["name"] == artist_name - and song["album"]["name"] == album_name - ): - return song - # print(f"SONG: {song['videoId']} - {song['title']} - {song['artists'][0]['name']} - {song['album']['name']}") - - raise ValueError(f"Did not find {track_name} by {artist_name} from {album_name}") + backend.create_playlist(pl_name) def search(): @@ -230,12 +68,18 @@ def parse_arguments(): type=str, help="Album name", ) + parser.add_argument( + "--algo", + type=int, + default=0, + help="Algorithm to use for search (0 = exact, 1 = extended, 2 = approximate)", + ) return parser.parse_args() args = parse_arguments() - yt = get_ytmusic() - ret = lookup_song(yt, args.track_name, args.artist, args.album) + yt = backend.get_ytmusic() + ret = backend.lookup_song(yt, args.track_name, args.artist, args.album, args.algo) print(ret) @@ -263,18 +107,27 @@ def parse_arguments(): default="utf-8", help="The encoding of the `playlists.json` file.", ) + parser.add_argument( + "--algo", + type=int, + default=0, + help="Algorithm to use for search (0 = exact, 1 = extended, 2 = approximate)", + ) return parser.parse_args() args = parse_arguments() - spotify_pls = load_playlists_json() + spotify_pls = backend.load_playlists_json() - copier( - iter_spotify_liked_albums(spotify_encoding=args.spotify_playlists_encoding), + backend.copier( + backend.iter_spotify_liked_albums( + spotify_encoding=args.spotify_playlists_encoding + ), None, args.dry_run, args.track_sleep, + args.algo, ) @@ -301,19 +154,28 @@ def parse_arguments(): default="utf-8", help="The encoding of the `playlists.json` file.", ) + parser.add_argument( + "--algo", + type=int, + default=0, + help="Algorithm to use for search (0 = exact, 1 = extended, 2 = approximate)", + ) return parser.parse_args() args = parse_arguments() - copier( - iter_spotify_playlist(None, spotify_encoding=args.spotify_playlists_encoding), + backend.copier( + backend.iter_spotify_playlist( + None, spotify_encoding=args.spotify_playlists_encoding + ), None, args.dry_run, args.track_sleep, ) +# @@@@@@@@@@ def copy_playlist(): """ Copy a Spotify playlist to a YTMusic playlist @@ -351,33 +213,12 @@ def parse_arguments(): return parser.parse_args() args = parse_arguments() - src_pl_id = args.spotify_playlist_id - dst_pl_id = args.ytmusic_playlist_id - - yt = get_ytmusic() - if dst_pl_id.startswith("+"): - pl_name = dst_pl_id[1:] - - dst_pl_id = get_playlist_id_by_name(yt, pl_name) - print(f"Looking up playlist '{pl_name}': id={dst_pl_id}") - if dst_pl_id is None: - dst_pl_id = _ytmusic_create_playlist(yt, title=pl_name, description=pl_name) - time.sleep(1) # seems to be needed to avoid missing playlist ID error - - # create_playlist returns a dict if there was an error - if isinstance(dst_pl_id, dict): - print(f"ERROR: Failed to create playlist: {dst_pl_id}") - sys.exit(1) - print(f"NOTE: Created playlist '{pl_name}' with ID: {dst_pl_id}") - - copier( - iter_spotify_playlist( - src_pl_id, spotify_encoding=args.spotify_playlists_encoding - ), - dst_pl_id, - args.dry_run, - args.track_sleep, - yt=yt, + backend.copy_playlist( + spotify_playlist_id=args.spotify_playlist_id, + ytmusic_playlist_id=args.ytmusic_playlist_id, + track_sleep=args.track_sleep, + dry_run=args.dry_run, + spotify_playlists_encoding=args.spotify_playlists_encoding, ) @@ -385,7 +226,6 @@ def copy_all_playlists(): """ Copy all Spotify playlists (except Liked Songs) to YTMusic playlists """ - yt = get_ytmusic() def parse_arguments(): parser = ArgumentParser() @@ -409,121 +249,8 @@ def parse_arguments(): return parser.parse_args() args = parse_arguments() - spotify_pls = load_playlists_json() - - for src_pl in spotify_pls["playlists"]: - if str(src_pl.get("name")) == "Liked Songs": - continue - - pl_name = src_pl["name"] - if pl_name == "": - pl_name = f"Unnamed Spotify Playlist {src_pl['id']}" - - dst_pl_id = get_playlist_id_by_name(yt, pl_name) - print(f"Looking up playlist '{pl_name}': id={dst_pl_id}") - if dst_pl_id is None: - dst_pl_id = _ytmusic_create_playlist(yt, title=pl_name, description=pl_name) - time.sleep(1) # seems to be needed to avoid missing playlist ID error - - # create_playlist returns a dict if there was an error - if isinstance(dst_pl_id, dict): - print(f"ERROR: Failed to create playlist: {dst_pl_id}") - sys.exit(1) - print(f"NOTE: Created playlist '{pl_name}' with ID: {dst_pl_id}") - - copier( - iter_spotify_playlist( - src_pl["id"], spotify_encoding=args.spotify_playlists_encoding - ), - dst_pl_id, - args.dry_run, - args.track_sleep, - ) - print("\nPlaylist done!\n") - - print("All done!") - - -def load_playlists_json(filename: str = "playlists.json", encoding: str = "utf-8"): - """Load the `playlists.json` Spotify playlist file""" - return json.load(open(filename, "r", encoding=encoding)) - - -def copier( - src_tracks: Iterator[SongInfo], - dst_pl_id: Optional[str] = None, - dry_run: bool = False, - track_sleep: float = 0.1, - *, - yt: Optional[YTMusic] = None, -): - if yt is None: - yt = get_ytmusic() - - if dst_pl_id is not None: - try: - yt_pl = yt.get_playlist(playlistId=dst_pl_id) - except Exception as e: - print(f"ERROR: Unable to find YTMusic playlist {dst_pl_id}: {e}") - print( - " Make sure the YTMusic playlist ID is correct, it should be something like " - ) - print(" 'PL_DhcdsaJ7echjfdsaJFhdsWUd73HJFca'") - sys.exit(1) - print(f"== Youtube Playlist: {yt_pl['title']}") - - tracks_added_set = set() - duplicate_count = 0 - error_count = 0 - - for src_track in src_tracks: - print(f"Spotify: {src_track.title} - {src_track.artist} - {src_track.album}") - - try: - dst_track = lookup_song( - yt, src_track.title, src_track.artist, src_track.album - ) - except Exception as e: - print(f"ERROR: Unable to look up song on YTMusic: {e}") - error_count += 1 - continue - - yt_artist_name = "" - if "artists" in dst_track and len(dst_track["artists"]) > 0: - yt_artist_name = dst_track["artists"][0]["name"] - print( - f" Youtube: {dst_track['title']} - {yt_artist_name} - {dst_track['album']}" - ) - - if dst_track["videoId"] in tracks_added_set: - print("(DUPLICATE, this track has already been added)") - duplicate_count += 1 - tracks_added_set.add(dst_track["videoId"]) - - if not dry_run: - exception_sleep = 5 - for _ in range(10): - try: - if dst_pl_id is not None: - yt.add_playlist_items( - playlistId=dst_pl_id, - videoIds=[dst_track["videoId"]], - duplicates=False, - ) - else: - yt.rate_song(dst_track["videoId"], "LIKE") - break - except Exception as e: - print( - f"ERROR: (Retrying add_playlist_items: {dst_pl_id} {dst_track['videoId']}) {e} in {exception_sleep} seconds" - ) - time.sleep(exception_sleep) - exception_sleep *= 2 - - if track_sleep: - time.sleep(track_sleep) - - print() - print( - f"Added {len(tracks_added_set)} tracks, encountered {duplicate_count} duplicates, {error_count} errors" + backend.copy_all_playlists( + track_sleep=args.track_sleep, + dry_run=args.dry_run, + spotify_playlists_encoding=args.spotify_playlists_encoding, ) diff --git a/spotify2ytmusic/copy_playlist.py b/spotify2ytmusic/copy_playlist.py deleted file mode 100644 index ca6062a..0000000 --- a/spotify2ytmusic/copy_playlist.py +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env python3 - -from . import cli - -if __name__ == "__main__": - cli.copy_playlist() diff --git a/spotify2ytmusic/create_playlist.py b/spotify2ytmusic/create_playlist.py deleted file mode 100644 index dee5ffd..0000000 --- a/spotify2ytmusic/create_playlist.py +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env python3 - -from . import cli - -if __name__ == "__main__": - cli.create_playlist() diff --git a/spotify2ytmusic/gui.py b/spotify2ytmusic/gui.py new file mode 100644 index 0000000..3bb6952 --- /dev/null +++ b/spotify2ytmusic/gui.py @@ -0,0 +1,352 @@ +#!/usr/bin/env python3 + +import os +import subprocess +import sys +import threading +import json +import tkinter as tk +from tkinter import ttk + +import cli +import backend +import spotify_backup +from reverse_playlist import reverse_playlist + + +def create_label(parent, text, **kwargs) -> tk.Label: + return tk.Label( + parent, + text=text, + font=("Helvetica", 14), + background="#26242f", + foreground="white", + **kwargs, + ) + + +def create_button(parent, text, **kwargs): + return tk.Button( + parent, + text=text, + font=("Helvetica", 14), + background="#696969", + foreground="white", + border=1, + **kwargs, + ) + + +class Window: + def __init__(self) -> None: + self.root = tk.Tk() + self.root.title("Spotify to YT Music") + self.root.geometry("1280x720") + self.root.config(background="#26242f") + + style = ttk.Style() + style.theme_use("default") + style.configure( + "TNotebook.Tab", background="#121212", foreground="white" + ) # Set the background color to #121212 when not selected + style.map( + "TNotebook.Tab", + background=[("selected", "#26242f")], + foreground=[("selected", "#ffffff")], + ) # Set the background color to #26242f and text color to white when selected + style.configure("TFrame", background="#26242f") + style.configure("TNotebook", background="#121212") + + # Redirect stdout to GUI + sys.stdout.write = self.redirector + + self.root.after(1, lambda: self.yt_login(auto=True)) + self.root.after(1, lambda: self.load_write_settings(0)) + + # Create a PanedWindow with vertical orientation + self.paned_window = ttk.PanedWindow(self.root, orient=tk.VERTICAL) + self.paned_window.pack(fill=tk.BOTH, expand=1) + + # Create a Frame for the tabs + self.tab_frame = ttk.Frame(self.paned_window) + self.paned_window.add(self.tab_frame, weight=2) + + # Create the TabControl (notebook) + self.tabControl = ttk.Notebook(self.tab_frame) + self.tabControl.pack(fill=tk.BOTH, expand=1) + + # Create the tabs + self.tab0 = ttk.Frame(self.tabControl) + self.tab1 = ttk.Frame(self.tabControl) + self.tab2 = ttk.Frame(self.tabControl) + self.tab3 = ttk.Frame(self.tabControl) + self.tab4 = ttk.Frame(self.tabControl) + self.tab5 = ttk.Frame(self.tabControl) + self.tab6 = ttk.Frame(self.tabControl) + self.tab7 = ttk.Frame(self.tabControl) + + self.tabControl.add(self.tab0, text="Login to YT Music") + self.tabControl.add(self.tab1, text="Spotify backup") + self.tabControl.add(self.tab2, text="Reverse playlist") + self.tabControl.add(self.tab3, text="Load liked songs") + self.tabControl.add(self.tab4, text="List playlists") + self.tabControl.add(self.tab5, text="Copy all playlists") + self.tabControl.add(self.tab6, text="Copy a specific playlist") + self.tabControl.add(self.tab7, text="Settings") + + # Create a Frame for the logs + self.log_frame = ttk.Frame(self.paned_window) + self.paned_window.add(self.log_frame, weight=1) + + # Create the Text widget for the logs + self.logs = tk.Text(self.log_frame, font=("Helvetica", 14)) + self.logs.pack(fill=tk.BOTH, expand=1) + self.logs.config(background="#26242f", foreground="white") + + # tab 0 + create_label( + self.tab0, + text="Welcome to Spotify to YT Music!\nTo start, you need to login to YT Music.", + ).pack(anchor=tk.CENTER, expand=True) + create_button(self.tab0, text="Login", command=self.yt_login).pack( + anchor=tk.CENTER, expand=True + ) + + # tab1 + create_label( + self.tab1, text="First, you need to backup your spotify playlists" + ).pack(anchor=tk.CENTER, expand=True) + create_button( + self.tab1, + text="Backup", + command=lambda: self.call_func(spotify_backup.main, self.tab2), + ).pack(anchor=tk.CENTER, expand=True) + + # tab2 + create_label( + self.tab2, + text="Since this program likes the last added song first, you need to reverse the playlist if " + "you want to keep the exact same playlists.\nBut this step is not mandatory, you can skip " + "it if you don't mind by clicking here.", + ).pack(anchor=tk.CENTER, expand=True) + create_button( + self.tab2, text="Skip", command=lambda: self.tabControl.select(self.tab3) + ).pack(anchor=tk.CENTER, expand=True) + create_button(self.tab2, text="Reverse", command=self.call_reverse).pack( + anchor=tk.CENTER, expand=True + ) + + # tab3 + create_label(self.tab3, text="Now, you can load your liked songs.").pack( + anchor=tk.CENTER, expand=True + ) + create_button( + self.tab3, + text="Load", + command=lambda: self.call_func(cli.load_liked, self.tab4), + ).pack(anchor=tk.CENTER, expand=True) + + # tab4 + create_label( + self.tab4, text="Here, you can get a list of your playlists, with their ID." + ).pack(anchor=tk.CENTER, expand=True) + create_button( + self.tab4, + text="List", + command=lambda: self.call_func(cli.list_playlists, self.tab5), + ).pack(anchor=tk.CENTER, expand=True) + + # tab5 + create_label( + self.tab5, + text="Here, you can copy all your playlists from Spotify to YT Music. Please note that this step " + "can take a long time since songs are added one by one.", + ).pack(anchor=tk.CENTER, expand=True) + create_button( + self.tab5, + text="Copy", + command=lambda: self.call_func(backend.copy_all_playlists, self.tab6), + ).pack(anchor=tk.CENTER, expand=True) + + # tab6 + create_label( + self.tab6, + text="Here, you can copy a specific playlist from Spotify to YT Music.", + ).pack(anchor=tk.CENTER, expand=True) + create_label(self.tab6, text="Spotify playlist ID:").pack( + anchor=tk.CENTER, expand=True + ) + self.spotify_playlist_id = tk.Entry(self.tab6) + self.spotify_playlist_id.pack(anchor=tk.CENTER, expand=True) + create_label(self.tab6, text="YT Music playlist ID:").pack( + anchor=tk.CENTER, expand=True + ) + self.yt_playlist_id = tk.Entry(self.tab6) + self.yt_playlist_id.pack(anchor=tk.CENTER, expand=True) + create_button(self.tab6, text="Copy", command=self.call_copy_playlist).pack( + anchor=tk.CENTER, expand=True + ) + + # tab7 + self.var_scroll = tk.BooleanVar() + + auto_scroll = tk.Checkbutton( + self.tab7, + text="Auto scroll", + variable=self.var_scroll, + command=lambda: self.load_write_settings(1), + background="#696969", + foreground="#ffffff", + selectcolor="#26242f", + border=1, + ) + auto_scroll.pack(anchor=tk.CENTER, expand=True) + auto_scroll.select() + + self.var_algo = tk.IntVar() + self.var_algo.set(0) + + self.algo_label = create_label(self.tab7, text=f"Algorithm: ") + self.algo_label.pack(anchor=tk.CENTER, expand=True) + + menu_algo = tk.OptionMenu( + self.tab7, + self.var_algo, + 0, + *[1, 2], + command=lambda x: self.load_write_settings(1), + ) + menu_algo.pack(anchor=tk.CENTER, expand=True) + menu_algo.config(background="#696969", foreground="#ffffff", border=1) + + def redirector(self, input_str="") -> None: + """ + Inserts the input string into the logs widget and disables editing. + + Args: + self: The instance of the class. + input_str (str): The string to be inserted into the logs' widget. + """ + self.logs.config(state=tk.NORMAL) + self.logs.insert(tk.INSERT, input_str) + self.logs.config(state=tk.DISABLED) + if self.var_scroll.get(): + self.logs.see(tk.END) + + def call_func(self, func, next_tab): + th = threading.Thread(target=func) + th.start() + while th.is_alive(): + self.root.update() + self.tabControl.select(next_tab) + print() + + def call_copy_playlist(self): + spotify_playlist_id = self.spotify_playlist_id.get() + yt_playlist_id = self.yt_playlist_id.get() + + print() + + if spotify_playlist_id == "": + print("Please enter the Spotify playlist ID") + return + if yt_playlist_id == "": + print( + "No Youtube playlist ID, creating one and naming it by the source playlist ID." + ) + backend.create_playlist(spotify_playlist_id) + th = threading.Thread( + target=backend.copy_playlist, args=(spotify_playlist_id, yt_playlist_id) + ) + th.start() + while th.is_alive(): + self.root.update() + + self.tabControl.select(self.tab6) + print() + + def call_reverse(self): + result = [0] # Shared data structure + + def target(): + result[0] = reverse_playlist( + replace=True + ) # Call the function with specific arguments + + th = threading.Thread(target=target) + th.start() + while th.is_alive(): + self.root.update() + + if result[0] == 0: # Access the return value + self.tabControl.select(self.tab3) + print() + + def yt_login(self, auto=False) -> None: + def run_in_thread(): + if os.path.exists("oauth.json"): + print("File detected, auto login") + elif auto: + print("No file detected. Manual login required") + return + else: + print("File not detected, login required") + command = ["ytmusicapi", "oauth"] + + # Open a new console window to run the command + if os.name == "nt": # If the OS is Windows + process = subprocess.Popen( + command, creationflags=subprocess.CREATE_NEW_CONSOLE + ) + process.communicate() + else: # For Unix and Linux + try: + subprocess.call( + "x-terminal-emulator -e ytmusicapi oauth", + shell=True, + stdout=subprocess.PIPE, + ) + except: + subprocess.call( + "xterm -e ytmusicapi oauth", + shell=True, + stdout=subprocess.PIPE, + ) + + self.tabControl.select(self.tab1) + print() + + # Run the function in a separate thread + th = threading.Thread(target=run_in_thread) + th.start() + + def load_write_settings(self, action: int) -> None: + texts = {0: "Exact match", 1: "Fuzzy match", 2: "Fuzzy match with videos"} + + exist = True + if action == 0: + with open("settings.json", "a+") as f: + pass + with open("settings.json", "r+") as f: + value = f.read() + if value == "": + exist = False + if exist: + with open("settings.json", "r+") as f: + settings = json.load(f) + self.var_scroll.set(settings["auto_scroll"]) + self.var_algo.set(settings["algo_number"]) + else: + with open("settings.json", "w+") as f: + settings = {} + settings["auto_scroll"] = self.var_scroll.get() + settings["algo_number"] = self.var_algo.get() + json.dump(settings, f) + + self.algo_label.config(text=f"Algorithm: {texts[self.var_algo.get()]}") + self.root.update() + + +if __name__ == "__main__": + ui = Window() + ui.root.mainloop() diff --git a/spotify2ytmusic/list_playlists.py b/spotify2ytmusic/list_playlists.py deleted file mode 100644 index 059ad1b..0000000 --- a/spotify2ytmusic/list_playlists.py +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env python3 - -from . import cli - -if __name__ == "__main__": - cli.list_playlists() diff --git a/spotify2ytmusic/load_liked.py b/spotify2ytmusic/load_liked.py deleted file mode 100644 index a96a68e..0000000 --- a/spotify2ytmusic/load_liked.py +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env python3 - -from . import cli - -if __name__ == "__main__": - cli.load_liked() diff --git a/spotify2ytmusic/reverse_playlist.py b/spotify2ytmusic/reverse_playlist.py new file mode 100644 index 0000000..035452d --- /dev/null +++ b/spotify2ytmusic/reverse_playlist.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 + +import json +import os +import shutil +from argparse import ArgumentParser + + +def reverse_playlist(input_file="playlists.json", verbose=True, replace=False) -> int: + if os.path.exists(input_file) and not replace: + if verbose: + print( + "Output file already exists and no replace argument detected, exiting..." + ) + return 1 + + print("Backing up file...") + shutil.copyfile(input_file, input_file.split(".")[0] + "_backup.json") + # Load the JSON file + with open(input_file, "r") as file: + if verbose: + print("Loading initial JSON file...") + data = json.load(file) + + # Copy the data to a new dictionary + data2 = data.copy() + + if verbose: + print("Reversing playlists...") + # Reverse the order of items in the "tracks" list + for i in range(len(data2["playlists"])): + # Reverse the tracks in the playlist + data2["playlists"][i]["tracks"] = data2["playlists"][i]["tracks"][::-1] + + if verbose: + print("Writing to file... (this can take a while)") + # Write the modified JSON back to the file + with open(input_file, "w") as file: + json.dump(data2, file) + + if verbose: + print("Done!") + print(f"File can be found at {input_file}") + + return 0 + + +if __name__ == "__main__": + parser = ArgumentParser() + parser.add_argument("input_file", type=str, help="Path to the input file") + parser.add_argument( + "-v", "--verbose", action="store_false", help="Enable verbose mode" + ) + parser.add_argument( + "-r", + "--replace", + action="store_true", + help="Replace the output file if already existing", + ) + + args = parser.parse_args() + + reverse_playlist(args.input_file, args.verbose, args.replace) diff --git a/spotify2ytmusic/settings.json b/spotify2ytmusic/settings.json new file mode 100644 index 0000000..e69de29 diff --git a/spotify2ytmusic/spotify_backup.py b/spotify2ytmusic/spotify_backup.py new file mode 100644 index 0000000..1933ac9 --- /dev/null +++ b/spotify2ytmusic/spotify_backup.py @@ -0,0 +1,219 @@ +#!/usr/bin/env python3 + +## A modified version of spotify-backup by 'caseychu' from https://github.com/caseychu/spotify-backup + +import codecs, http.client, http.server, json, re, sys, time, urllib.error, urllib.parse, urllib.request, webbrowser + + +class SpotifyAPI: + # Requires an OAuth token. + def __init__(self, auth): + self._auth = auth + + # Gets a resource from the Spotify API and returns the object. + def get(self, url, params={}, tries=3): + # Construct the correct URL. + if not url.startswith("https://api.spotify.com/v1/"): + url = "https://api.spotify.com/v1/" + url + if params: + url += ("&" if "?" in url else "?") + urllib.parse.urlencode(params) + + # Try the sending off the request a specified number of times before giving up. + for _ in range(tries): + try: + req = urllib.request.Request(url) + req.add_header("Authorization", "Bearer " + self._auth) + res = urllib.request.urlopen(req) + reader = codecs.getreader("utf-8") + return json.load(reader(res)) + except Exception as err: + print("Couldn't load URL: {} ({})".format(url, err)) + time.sleep(2) + print("Trying again...") + sys.exit(1) + + # The Spotify API breaks long lists into multiple pages. This method automatically + # fetches all pages and joins them, returning in a single list of objects. + def list(self, url, params={}): + last_log_time = time.time() + response = self.get(url, params) + items = response["items"] + + while response["next"]: + if time.time() > last_log_time + 15: + last_log_time = time.time() + print(f"Loaded {len(items)}/{response['total']} items") + + response = self.get(response["next"]) + items += response["items"] + return items + + # Pops open a browser window for a user to log in and authorize API access. + @staticmethod + def authorize(client_id, scope): + url = "https://accounts.spotify.com/authorize?" + urllib.parse.urlencode( + { + "response_type": "token", + "client_id": client_id, + "scope": scope, + "redirect_uri": "http://127.0.0.1:{}/redirect".format( + SpotifyAPI._SERVER_PORT + ), + } + ) + print(f"Logging in (click if it doesn't open automatically): {url}") + webbrowser.open(url) + + # Start a simple, local HTTP server to listen for the authorization token... (i.e. a hack). + server = SpotifyAPI._AuthorizationServer("127.0.0.1", SpotifyAPI._SERVER_PORT) + try: + while True: + server.handle_request() + except SpotifyAPI._Authorization as auth: + return SpotifyAPI(auth.access_token) + + # The port that the local server listens on. Don't change this, + # as Spotify only will redirect to certain predefined URLs. + _SERVER_PORT = 43019 + + class _AuthorizationServer(http.server.HTTPServer): + def __init__(self, host, port): + http.server.HTTPServer.__init__( + self, (host, port), SpotifyAPI._AuthorizationHandler + ) + + # Disable the default error handling. + def handle_error(self, request, client_address): + raise + + class _AuthorizationHandler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + # The Spotify API has redirected here, but access_token is hidden in the URL fragment. + # Read it using JavaScript and send it to /token as an actual query string... + if self.path.startswith("/redirect"): + self.send_response(200) + self.send_header("Content-Type", "text/html") + self.end_headers() + self.wfile.write( + b'' + ) + + # Read access_token and use an exception to kill the server listening... + elif self.path.startswith("/token?"): + self.send_response(200) + self.send_header("Content-Type", "text/html") + self.end_headers() + self.wfile.write( + b"Thanks! You may now close this window." + ) + + access_token = re.search("access_token=([^&]*)", self.path).group(1) + print(f"Received access token from Spotify: {access_token}") + raise SpotifyAPI._Authorization(access_token) + + else: + self.send_error(404) + + # Disable the default logging. + def log_message(self, format, *args): + pass + + class _Authorization(Exception): + def __init__(self, access_token): + self.access_token = access_token + + +def main(dump="playlists,liked", format="json", file="playlists.json", token=""): + print("Starting backup...") + # If they didn't give a filename, then just prompt them. (They probably just double-clicked.) + while file == "": + file = input("Enter a file name (e.g. playlists.txt): ") + format = file.split(".")[-1] + + # Log into the Spotify API. + if token != "": + spotify = SpotifyAPI(token) + else: + spotify = SpotifyAPI.authorize( + client_id="5c098bcc800e45d49e476265bc9b6934", + scope="playlist-read-private playlist-read-collaborative user-library-read", + ) + + # Get the ID of the logged in user. + print("Loading user info...") + me = spotify.get("me") + print("Logged in as {display_name} ({id})".format(**me)) + + playlists = [] + liked_albums = [] + + # List liked albums and songs + if "liked" in dump: + print("Loading liked albums and songs...") + liked_tracks = spotify.list( + "users/{user_id}/tracks".format(user_id=me["id"]), {"limit": 50} + ) + liked_albums = spotify.list("me/albums", {"limit": 50}) + playlists += [{"name": "Liked Songs", "tracks": liked_tracks}] + + # List all playlists and the tracks in each playlist + if "playlists" in dump: + print("Loading playlists...") + playlist_data = spotify.list( + "users/{user_id}/playlists".format(user_id=me["id"]), {"limit": 50} + ) + print(f"Found {len(playlist_data)} playlists") + + # List all tracks in each playlist + for playlist in playlist_data: + print("Loading playlist: {name} ({tracks[total]} songs)".format(**playlist)) + playlist["tracks"] = spotify.list( + playlist["tracks"]["href"], {"limit": 100} + ) + playlists += playlist_data + + # Write the file. + print("Writing files...") + with open(file, "w", encoding="utf-8") as f: + # JSON file. + if format == "json": + json.dump({"playlists": playlists, "albums": liked_albums}, f) + + # Tab-separated file. + else: + f.write("Playlists: \r\n\r\n") + for playlist in playlists: + f.write(playlist["name"] + "\r\n") + for track in playlist["tracks"]: + if track["track"] is None: + continue + f.write( + "{name}\t{artists}\t{album}\t{uri}\t{release_date}\r\n".format( + uri=track["track"]["uri"], + name=track["track"]["name"], + artists=", ".join( + [artist["name"] for artist in track["track"]["artists"]] + ), + album=track["track"]["album"]["name"], + release_date=track["track"]["album"]["release_date"], + ) + ) + f.write("\r\n") + if len(liked_albums) > 0: + f.write("Liked Albums: \r\n\r\n") + for album in liked_albums: + uri = album["album"]["uri"] + name = album["album"]["name"] + artists = ", ".join( + [artist["name"] for artist in album["album"]["artists"]] + ) + release_date = album["album"]["release_date"] + album = f"{artists} - {name}" + + f.write(f"{name}\t{artists}\t-\t{uri}\t{release_date}\r\n") + + print("Wrote file: " + file) + + +if __name__ == "__main__": + main()