From 0f5f15a0fde01435969d77b2c1f6e6ba11772fbb Mon Sep 17 00:00:00 2001 From: Dmitrii Morozov Date: Sat, 19 Jul 2025 23:17:15 +0200 Subject: Initial commit --- .gitignore | 2 + main.py | 165 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 19 +++++++ 3 files changed, 186 insertions(+) create mode 100644 .gitignore create mode 100644 main.py create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..25960f3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.cache-* +venv \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..8d618e6 --- /dev/null +++ b/main.py @@ -0,0 +1,165 @@ +import argparse +import logging +import spotipy +import sys +import time +from tqdm import tqdm +from spotipy.oauth2 import SpotifyOAuth +from yandex_music import Client, Artist + +REDIRECT_URI = 'https://open.spotify.com' +DEFAULT_SPOTIFY_LIMIT = 50 + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + +class Synchronizer: + + def __init__(self, arguments): + spotify_auth_manager = spotipy.oauth2.SpotifyOAuth( + client_id=arguments.id, + client_secret=arguments.secret, + redirect_uri=REDIRECT_URI, + scope='playlist-modify-public, user-library-modify, user-library-read', + username=arguments.spotify_user, + ) + self.spotify_client = spotipy.Spotify( + auth_manager=spotify_auth_manager + ) + self.spotify_username = self._handle_spotify_exception(self.spotify_client.me)()['id'] + self.yandex_client = Client(arguments.yandex_token) + self.yandex_client.init() + + def start(self): + self._sync_liked_tracks() + self._sync_playlists() + sys.exit() + + def _sync_liked_tracks(self): + logger.info(f'Start synchronization of liked tracks...') + likes_tracks = self.yandex_client.users_likes_tracks().tracks + tracks = self.yandex_client.tracks([f'{track.id}:{track.album_id}' for track in likes_tracks if track.album_id]) + for track in tqdm(tracks): + time.sleep(0.25) + spotify_item = self._find_spotify_item(track) + if spotify_item is not None: + self._handle_spotify_exception(self.spotify_client.current_user_saved_tracks_add)([spotify_item]) + + def _sync_playlists(self): + logger.info(f'Start syncronization of playlists...') + playlists = self.yandex_client.users_playlists_list() + for playlist in playlists: + logger.info(f'Syncronizing playlist {playlist.title}...') + + spotify_playlist = self._find_or_create_spotify_playlist(playlist.title) + spotify_playlist_id = spotify_playlist['id'] + existing_tracks = self._find_all_playlist_tracks(spotify_playlist_id) + + playlist_tracks = playlist.fetch_tracks() + if not playlist.collective: + tracks = [track.track for track in playlist_tracks] + elif playlist.collective and playlist_tracks: + tracks = self.yandex_client.tracks([track.track_id for track in playlist_tracks]) + else: + tracks = [] + + for track in tqdm(tracks): + time.sleep(0.25) + spotify_item = self._find_spotify_item(track) + if spotify_item is not None: + if spotify_item in existing_tracks: + logger.debug(f'Skipping existing track {spotify_item}') + else: + self._handle_spotify_exception(self.spotify_client.user_playlist_add_tracks)( + self.spotify_username, + spotify_playlist_id, + [spotify_item] + ) + + def _find_or_create_spotify_playlist(self, title): + for existing_spotify_playlist in self.spotify_client.user_playlists(user=self.spotify_username)['items']: + + if existing_spotify_playlist['name'] == title: + logger.debug(f'Found existing playlist with title {title}') + return existing_spotify_playlist + break + + logger.debug(f'Creating new playlist {title}...') + return self._handle_spotify_exception(self.spotify_client.user_playlist_create)(self.spotify_username, title) + + def _find_all_playlist_tracks(self, playlist_id): + result = [] + offset = 0 + existing_tracks_page = self.spotify_client.user_playlist_tracks( + user=self.spotify_username, + playlist_id=playlist_id, + limit=DEFAULT_SPOTIFY_LIMIT) + + while True: + for track in existing_tracks_page['items']: + result.append(track['track']['id']) + if existing_tracks_page['next'] is None: + break; + offset = offset + DEFAULT_SPOTIFY_LIMIT + existing_tracks_page = self.spotify_client.user_playlist_tracks( + user=self.spotify_username, + playlist_id=playlist_id, + limit=DEFAULT_SPOTIFY_LIMIT, + offset=offset) + return result + + def _find_spotify_item(self, item): + type_ = item.__class__.__name__.casefold() + query = f'{", ".join([artist.name for artist in item.artists])} - {item.title}' + found_items = self._handle_spotify_exception(self.spotify_client.search)(query, type=type_)[f'{type_}s']['items'] + + if not len(found_items): + logger.info(f'Item {query} not found') + return None + + return found_items[0]['id'] + + def _handle_spotify_exception(self, func): + def wrapper(*args, **kwargs): + retry = 1 + while True: + try: + return func(*args, **kwargs) + except spotipy.exceptions.SpotifyException as exception: + if exception.http_status != 429: + raise exception + + if 'retry-after' in exception.headers: + sleep(int(exception.headers['retry-after']) + 1) + except ReadTimeout as exception: + logger.info(f'Read timed out. Retrying #{retry}...') + + if retry > MAX_REQUEST_RETRIES: + logger.info('Max retries reached.') + raise exception + + logger.info('Trying again...') + retry += 1 + + return wrapper + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description='Synchronizes playlists data between Yandex Music and Spotify') + parser.add_argument('--spotify_user', required=True, help='Username at spotify.com') + + spotify_oauth = parser.add_argument_group('spotify_oauth') + spotify_oauth.add_argument('--id', required=True, help='Client ID of Spotify app') + spotify_oauth.add_argument('--secret', required=True, help='Client Secret of Spotify app') + + parser.add_argument('--yandex_token', required=True, help='Token from music.yandex.com account') + arguments = parser.parse_args() + + try: + instance = Synchronizer(arguments) + instance.start() + except Exception as e: + logger.error(f'An unexpected error occurred: {str(e)}') diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..28e0fd9 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,19 @@ +aiofiles==24.1.0 +aiohappyeyeballs==2.6.1 +aiohttp==3.12.14 +aiosignal==1.4.0 +attrs==25.3.0 +certifi==2025.7.14 +charset-normalizer==3.4.2 +frozenlist==1.7.0 +idna==3.10 +multidict==6.6.3 +propcache==0.3.2 +PySocks==1.7.1 +redis==6.2.0 +requests==2.32.4 +spotipy==2.25.1 +tqdm==4.67.1 +urllib3==2.5.0 +yandex-music==2.2.0 +yarl==1.20.1 -- cgit v1.2.3