From cf353e8199d3fce0e1f3d3f9e6479843810b4604 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Sat, 8 Apr 2023 12:06:24 +0200 Subject: [PATCH] apply isort and black --- pyproject.toml | 11 ++- spotify_to_ytmusic/Setup.py | 19 +++-- spotify_to_ytmusic/SpotifyExport.py | 73 ++++++++++------- spotify_to_ytmusic/YouTube.py | 76 +++++------------- spotify_to_ytmusic/main.py | 100 ++++++++++++++++++------ spotify_to_ytmusic/match.py | 64 +++++++++++++++ spotify_to_ytmusic/settings.py | 10 ++- tests/{test_spotipy => test_spotipy.py} | 0 8 files changed, 227 insertions(+), 126 deletions(-) create mode 100644 spotify_to_ytmusic/match.py rename tests/{test_spotipy => test_spotipy.py} (100%) diff --git a/pyproject.toml b/pyproject.toml index 2f612fe..5095887 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ dynamic = ["version", "readme"] dev = ['pre-commit', 'flake8', 'yapf', 'coverage', 'sphinx', 'sphinx-rtd-theme'] [project.scripts] -ytmusicapi = "spotify_to_ytmusic.main:main" +spotify_to_ytmusic = "spotify_to_ytmusic.main:main" [project.urls] homepage = "https://github.com/sigma67/spotify_to_ytmusic" @@ -39,9 +39,12 @@ include-package-data=false [tool.setuptools.package-data] "*" = ["**.example", "**.py"] -[tool.yapf] -column_limit = 99 -split_before_arithmetic_operator = true +[tool.isort] +profile = "hug" +src_paths = ["spotify_to_ytmusic"] + +[tool.black] +src_paths = ["spotify_to_ytmusic"] [tool.coverage.run] command_line = "-m unittest discover tests" diff --git a/spotify_to_ytmusic/Setup.py b/spotify_to_ytmusic/Setup.py index 8cfb220..09e2cd4 100644 --- a/spotify_to_ytmusic/Setup.py +++ b/spotify_to_ytmusic/Setup.py @@ -1,16 +1,17 @@ import sys -from settings import Settings import ytmusicapi +from spotify_to_ytmusic.settings import Settings + settings = Settings() + def setup(): - choice = input("Choose which API to set up\n" - "(1) Spotify\n" - "(2) YouTube\n" - "(3) both") - choices = ["1","2","3"] + choice = input( + "Choose which API to set up\n" "(1) Spotify\n" "(2) YouTube\n" "(3) both" + ) + choices = ["1", "2", "3"] if choice not in choices: sys.exit("Invalid choice") @@ -22,10 +23,12 @@ def setup(): setup_spotify() setup_youtube() + def setup_youtube(): - settings['youtube']['headers'] = ytmusicapi.setup_oauth() + settings["youtube"]["headers"] = ytmusicapi.setup_oauth() settings.save() + def setup_spotify(): pass - #settings['spotipy'] \ No newline at end of file + # settings['spotipy'] diff --git a/spotify_to_ytmusic/SpotifyExport.py b/spotify_to_ytmusic/SpotifyExport.py index 7ae68f6..d57ee9d 100644 --- a/spotify_to_ytmusic/SpotifyExport.py +++ b/spotify_to_ytmusic/SpotifyExport.py @@ -1,58 +1,69 @@ -from spotipy.oauth2 import SpotifyClientCredentials -import spotipy -import settings import html from urllib.parse import urlparse +import spotipy +from spotipy.oauth2 import SpotifyClientCredentials + +from spotify_to_ytmusic.settings import Settings + class Spotify: def __init__(self): - conf = settings['spotify'] - client_credentials_manager = SpotifyClientCredentials(client_id=conf['client_id'], client_secret=conf['client_secret']) - self.api = spotipy.Spotify(client_credentials_manager=client_credentials_manager) + settings = Settings() + conf = settings["spotify"] + client_credentials_manager = SpotifyClientCredentials( + client_id=conf["client_id"], client_secret=conf["client_secret"] + ) + self.api = spotipy.Spotify( + client_credentials_manager=client_credentials_manager + ) def getSpotifyPlaylist(self, url): playlistId = get_id_from_url(url) if len(playlistId) != 22: - raise Exception(f'Bad playlist id: {playlistId}') + raise Exception(f"Bad playlist id: {playlistId}") print("Getting Spotify tracks...") results = self.api.playlist(playlistId) - name = results['name'] - total = int(results['tracks']['total']) - tracks = build_results(results['tracks']['items']) + name = results["name"] + total = int(results["tracks"]["total"]) + tracks = build_results(results["tracks"]["items"]) count = len(tracks) print(f"Spotify tracks: {count}/{total}") while count < total: more_tracks = self.api.playlist_items(playlistId, offset=count, limit=100) - tracks += build_results(more_tracks['items']) + tracks += build_results(more_tracks["items"]) count = count + 100 print(f"Spotify tracks: {len(tracks)}/{total}") - return {'tracks': tracks, 'name': name, 'description': html.unescape(results['description'])} + return { + "tracks": tracks, + "name": name, + "description": html.unescape(results["description"]), + } def getUserPlaylists(self, user): - pl = self.api.user_playlists(user)['items'] + pl = self.api.user_playlists(user)["items"] count = 1 more = len(pl) == 50 while more: - results = self.api.user_playlists(user, offset=count * 50)['items'] + results = self.api.user_playlists(user, offset=count * 50)["items"] pl.extend(results) more = len(results) == 50 count = count + 1 - return [p for p in pl if p['owner']['id'] == user and p['tracks']['total'] > 0] + return [p for p in pl if p["owner"]["id"] == user and p["tracks"]["total"] > 0] def get_tracks(self, url): tracks = [] url_parts = parse_url(url) - path = url_parts.path.split('/') + path = url_parts.path.split("/") id = path[2] - if 'album' == path[1]: + if "album" == path[1]: album = self.api.album(id) - tracks.extend(build_results(album['tracks']['items'], album['name'])) - elif 'track' == path[1]: + tracks.extend(build_results(album["tracks"]["items"], album["name"])) + elif "track" == path[1]: track = self.api.track(id) tracks.extend(build_results([track])) return tracks @@ -61,24 +72,26 @@ def get_tracks(self, url): def build_results(tracks, album=None): results = [] for track in tracks: - if 'track' in track: - track = track['track'] - if not track or track['duration_ms'] == 0: + if "track" in track: + track = track["track"] + if not track or track["duration_ms"] == 0: continue - album_name = album if album else track['album']['name'] - results.append({ - 'artist': ' '.join([artist['name'] for artist in track['artists']]), - 'name': track['name'], - 'album': album_name, - 'duration': track['duration_ms']/1000 - }) + album_name = album if album else track["album"]["name"] + results.append( + { + "artist": " ".join([artist["name"] for artist in track["artists"]]), + "name": track["name"], + "album": album_name, + "duration": track["duration_ms"] / 1000, + } + ) return results def get_id_from_url(url): url_parts = parse_url(url) - return url_parts.path.split('/')[2] + return url_parts.path.split("/")[2] def parse_url(url): diff --git a/spotify_to_ytmusic/YouTube.py b/spotify_to_ytmusic/YouTube.py index 0669e31..bd60b97 100644 --- a/spotify_to_ytmusic/YouTube.py +++ b/spotify_to_ytmusic/YouTube.py @@ -1,73 +1,38 @@ -from ytmusicapi import YTMusic import os import re -import difflib from collections import OrderedDict -import settings + +from ytmusicapi import YTMusic + +from spotify_to_ytmusic.match import get_best_fit_song_id +from spotify_to_ytmusic.settings import Settings path = os.path.dirname(os.path.realpath(__file__)) + os.sep class YTMusicTransfer: def __init__(self): - self.api = YTMusic(settings['youtube']['headers'], settings['youtube']['user_id']) + settings = Settings() + self.api = YTMusic( + settings["youtube"]["headers"], settings["youtube"]["user_id"] + ) def create_playlist(self, name, info, privacy="PRIVATE", tracks=None): return self.api.create_playlist(name, info, privacy, video_ids=tracks) - def get_best_fit_song_id(self, results, song): - match_score = {} - title_score = {} - for res in results: - if 'resultType' not in res or res['resultType'] not in ['song', 'video']: - continue - - durationMatch = None - if 'duration' in res and res['duration'] and song['duration']: - durationItems = res['duration'].split(':') - duration = int(durationItems[0]) * 60 + int(durationItems[1]) - durationMatch = 1 - abs(duration - song['duration']) * 2 / song['duration'] - - title = res['title'] - # for videos, - if res['resultType'] == 'video': - titleSplit = title.split('-') - if len(titleSplit) == 2: - title = titleSplit[1] - - artists = ' '.join([a['name'] for a in res['artists']]) - - title_score[res['videoId']] = difflib.SequenceMatcher(a=title.lower(), b=song['name'].lower()).ratio() - scores = [title_score[res['videoId']], - difflib.SequenceMatcher(a=artists.lower(), b=song['artist'].lower()).ratio()] - if durationMatch: - scores.append(durationMatch * 5) - - #add album for songs only - if res['resultType'] == 'song' and res['album'] is not None: - scores.append(difflib.SequenceMatcher(a=res['album']['name'].lower(), b=song['album'].lower()).ratio()) - - match_score[res['videoId']] = sum(scores) / len(scores) * max(1, int(res['resultType'] == 'song') * 2) - - if len(match_score) == 0: - return None - - max_score = max(match_score, key=match_score.get) - return max_score - def search_songs(self, tracks): videoIds = [] songs = list(tracks) notFound = list() for i, song in enumerate(songs): - name = re.sub(r' \(feat.*\..+\)', '', song['name']) - query = song['artist'] + ' ' + name + name = re.sub(r" \(feat.*\..+\)", "", song["name"]) + query = song["artist"] + " " + name query = query.replace(" &", "") result = self.api.search(query) if len(result) == 0: notFound.append(query) else: - targetSong = self.get_best_fit_song_id(result, song) + targetSong = get_best_fit_song_id(result, song) if targetSong is None: notFound.append(query) else: @@ -77,7 +42,7 @@ def search_songs(self, tracks): if i > 0 and i % 10 == 0: print(f"YouTube tracks: {i}/{len(songs)}") - with open(path + 'noresults_youtube.txt', 'w', encoding="utf-8") as f: + with open(path + "noresults_youtube.txt", "w", encoding="utf-8") as f: f.write("\n".join(notFound)) f.write("\n") f.close() @@ -91,28 +56,27 @@ def add_playlist_items(self, playlistId, videoIds): def get_playlist_id(self, name): pl = self.api.get_library_playlists(10000) try: - playlist = next(x for x in pl if x['title'].find(name) != -1)['playlistId'] + playlist = next(x for x in pl if x["title"].find(name) != -1)["playlistId"] return playlist except: raise Exception("Playlist title not found in playlists") def remove_songs(self, playlistId): items = self.api.get_playlist(playlistId, 10000) - if 'tracks' in items: - self.api.remove_playlist_items(playlistId, items['tracks']) + if "tracks" in items: + self.api.remove_playlist_items(playlistId, items["tracks"]) def remove_playlists(self, pattern): playlists = self.api.get_library_playlists(10000) p = re.compile("{0}".format(pattern)) - matches = [pl for pl in playlists if p.match(pl['title'])] + matches = [pl for pl in playlists if p.match(pl["title"])] print("The following playlists will be removed:") - print("\n".join([pl['title'] for pl in matches])) + print("\n".join([pl["title"] for pl in matches])) print("Please confirm (y/n):") choice = input().lower() - if choice[:1] == 'y': - [self.api.delete_playlist(pl['playlistId']) for pl in matches] + if choice[:1] == "y": + [self.api.delete_playlist(pl["playlistId"]) for pl in matches] print(str(len(matches)) + " playlists deleted.") else: print("Aborted. No playlists were deleted.") - diff --git a/spotify_to_ytmusic/main.py b/spotify_to_ytmusic/main.py index 5eb52b0..e12f18c 100644 --- a/spotify_to_ytmusic/main.py +++ b/spotify_to_ytmusic/main.py @@ -8,15 +8,52 @@ def get_args(): - parser = argparse.ArgumentParser(description='Transfer spotify playlist to YouTube Music.') + parser = argparse.ArgumentParser( + description="Transfer spotify playlist to YouTube Music." + ) parser.add_argument("playlist", type=str, help="Provide a playlist Spotify link.") - parser.add_argument("-u", "--update", action='store_true', help="Delete all entries in the provided Google Play Music playlist and update the playlist with entries from the Spotify playlist.") - parser.add_argument("-n", "--name", type=str, help="Provide a name for the YouTube Music playlist. Default: Spotify playlist name") - parser.add_argument("-i", "--info", type=str, help="Provide description information for the YouTube Music Playlist. Default: Spotify playlist description") - parser.add_argument("-d", "--date", action='store_true', help="Append the current date to the playlist name") - parser.add_argument("-p", "--public", action='store_true', help="Make the playlist public. Default: private") - parser.add_argument("-r", "--remove", action='store_true', help="Remove playlists with specified regex pattern.") - parser.add_argument("-a", "--all", action='store_true', help="Transfer all public playlists of the specified user (Spotify User ID).") + parser.add_argument( + "-u", + "--update", + action="store_true", + help="Delete all entries in the provided Google Play Music playlist and update the playlist with entries from the Spotify playlist.", + ) + parser.add_argument( + "-n", + "--name", + type=str, + help="Provide a name for the YouTube Music playlist. Default: Spotify playlist name", + ) + parser.add_argument( + "-i", + "--info", + type=str, + help="Provide description information for the YouTube Music Playlist. Default: Spotify playlist description", + ) + parser.add_argument( + "-d", + "--date", + action="store_true", + help="Append the current date to the playlist name", + ) + parser.add_argument( + "-p", + "--public", + action="store_true", + help="Make the playlist public. Default: private", + ) + parser.add_argument( + "-r", + "--remove", + action="store_true", + help="Remove playlists with specified regex pattern.", + ) + parser.add_argument( + "-a", + "--all", + action="store_true", + help="Transfer all public playlists of the specified user (Spotify User ID).", + ) parser.add_argument("--setup", help="Set up credentials") return parser.parse_args() @@ -35,17 +72,22 @@ def main(): print(str(len(pl)) + " playlists found. Starting transfer...") count = 1 for p in pl: - print("Playlist " + str(count) + ": " + p['name']) + print("Playlist " + str(count) + ": " + p["name"]) count = count + 1 try: - playlist = Spotify().getSpotifyPlaylist(p['external_urls']['spotify']) - videoIds = ytmusic.search_songs(playlist['tracks']) - playlist_id = ytmusic.create_playlist(p['name'], p['description'], - 'PUBLIC' if args.public else 'PRIVATE', - videoIds) + playlist = Spotify().getSpotifyPlaylist(p["external_urls"]["spotify"]) + videoIds = ytmusic.search_songs(playlist["tracks"]) + playlist_id = ytmusic.create_playlist( + p["name"], + p["description"], + "PUBLIC" if args.public else "PRIVATE", + videoIds, + ) print(playlist_id) except Exception as ex: - print("Could not transfer playlist " + p['name'] + ". Exception" + str(ex)) + print( + "Could not transfer playlist " + p["name"] + ". Exception" + str(ex) + ) return if args.remove: @@ -54,28 +96,38 @@ def main(): date = "" if args.date: - date = " " + datetime.today().strftime('%m/%d/%Y') + date = " " + datetime.today().strftime("%m/%d/%Y") try: playlist = Spotify().getSpotifyPlaylist(args.playlist) except Exception as ex: - print("Could not get Spotify playlist. Please check the playlist link.\n Error: " + repr(ex)) + print( + "Could not get Spotify playlist. Please check the playlist link.\n Error: " + + repr(ex) + ) return - name = args.name + date if args.name else playlist['name'] + date - info = playlist['description'] if (args.info is None) else args.info + name = args.name + date if args.name else playlist["name"] + date + info = playlist["description"] if (args.info is None) else args.info if args.update: playlistId = ytmusic.get_playlist_id(name) - videoIds = ytmusic.search_songs(playlist['tracks']) + videoIds = ytmusic.search_songs(playlist["tracks"]) ytmusic.remove_songs(playlistId) ytmusic.add_playlist_items(playlistId, videoIds) else: - videoIds = ytmusic.search_songs(playlist['tracks']) - playlistId = ytmusic.create_playlist(name, info, 'PUBLIC' if args.public else 'PRIVATE', videoIds) + videoIds = ytmusic.search_songs(playlist["tracks"]) + playlistId = ytmusic.create_playlist( + name, info, "PUBLIC" if args.public else "PRIVATE", videoIds + ) - print("Success: created playlist \"" + name + "\"\n" + - "https://music.youtube.com/playlist?list=" + playlistId) + print( + 'Success: created playlist "' + + name + + '"\n' + + "https://music.youtube.com/playlist?list=" + + playlistId + ) if __name__ == "__main__": diff --git a/spotify_to_ytmusic/match.py b/spotify_to_ytmusic/match.py new file mode 100644 index 0000000..aa283f4 --- /dev/null +++ b/spotify_to_ytmusic/match.py @@ -0,0 +1,64 @@ +import difflib + + +def get_best_fit_song_id(ytm_results, spoti) -> str: + """ + Find the best match for track spoti in a list of ytmusicapi results + + :param ytm_results: List of ytmusicapi search results + :param spoti: Spotify track + :return: videoId of best matching result + """ + match_score = {} + title_score = {} + for ytm in ytm_results: + if "resultType" not in ytm or ytm["resultType"] not in ["song", "video"]: + continue + + duration_match_score = None + if "duration" in ytm and ytm["duration"] and spoti["duration"]: + duration_items = ytm["duration"].split(":") + duration = int(duration_items[0]) * 60 + int(duration_items[1]) + duration_match_score = ( + 1 - abs(duration - spoti["duration"]) * 2 / spoti["duration"] + ) + + title = ytm["title"] + # for videos, + if ytm["resultType"] == "video": + title_split = title.split("-") + if len(title_split) == 2: + title = title_split[1] + + artists = " ".join([a["name"] for a in ytm["artists"]]) + + title_score[ytm["videoId"]] = difflib.SequenceMatcher( + a=title.lower(), b=spoti["name"].lower() + ).ratio() + scores = [ + title_score[ytm["videoId"]], + difflib.SequenceMatcher( + a=artists.lower(), b=spoti["artist"].lower() + ).ratio(), + ] + if duration_match_score: + scores.append(duration_match_score * 5) + + # add album for songs only + if ytm["resultType"] == "song" and ytm["album"] is not None: + scores.append( + difflib.SequenceMatcher( + a=ytm["album"]["name"].lower(), b=spoti["album"].lower() + ).ratio() + ) + + match_score[ytm["videoId"]] = ( + sum(scores) / len(scores) * max(1, int(ytm["resultType"] == "song") * 2) + ) + + if len(match_score) == 0: + return None + + max_score_video_id = max(match_score, key=match_score.get) + + return max_score_video_id diff --git a/spotify_to_ytmusic/settings.py b/spotify_to_ytmusic/settings.py index c9f61ac..197e33e 100644 --- a/spotify_to_ytmusic/settings.py +++ b/spotify_to_ytmusic/settings.py @@ -4,11 +4,13 @@ class Settings: - config: configparser.ConfigParser + def __init__(self, filepath: Optional[Path] = None): self.config = configparser.ConfigParser(interpolation=None) - self.filepath = filepath if filepath else Path(__file__).parent.joinpath('settings.ini') + self.filepath = ( + filepath if filepath else Path(__file__).parent.joinpath("settings.ini") + ) self.config.read(self.filepath) def __getitem__(self, key): @@ -18,5 +20,5 @@ def __setitem__(self, section, key, value): self.config.set(section, key, value) def save(self): - with open(self.filepath, 'w') as f: - self.config.write(f) \ No newline at end of file + with open(self.filepath, "w") as f: + self.config.write(f) diff --git a/tests/test_spotipy b/tests/test_spotipy.py similarity index 100% rename from tests/test_spotipy rename to tests/test_spotipy.py