diff --git a/example_config.yml b/example_config.yml index e03971d..ba76f8a 100644 --- a/example_config.yml +++ b/example_config.yml @@ -20,6 +20,11 @@ spotify: # - when false: favorites can only be synced manually via --sync-favorites argument sync_favorites_default: true +# default setting for syncing followed artists when no command line arguments are provided +# - when true: followed artists will be synced by default (overriden when any command line arg provided) +# - when false: artists can only be synced manually via --sync-artists argument +sync_artists_default: false + # increasing these parameters should increase the search speed, while decreasing reduces likelihood of 429 errors max_concurrency: 10 # max concurrent connections at any given time rate_limit: 10 # max sustained connections per second diff --git a/readme.md b/readme.md index 38f9d2d..5466c3e 100644 --- a/readme.md +++ b/readme.md @@ -37,6 +37,12 @@ or sync just your 'Liked Songs' with: spotify_to_tidal --sync-favorites ``` +or sync just your followed artists with: + +```bash +spotify_to_tidal --sync-artists +``` + See example_config.yml for more configuration options, and `spotify_to_tidal --help` for more options. --- diff --git a/src/spotify_to_tidal/__main__.py b/src/spotify_to_tidal/__main__.py index 8a95fc6..32ebd01 100644 --- a/src/spotify_to_tidal/__main__.py +++ b/src/spotify_to_tidal/__main__.py @@ -10,6 +10,7 @@ def main(): parser.add_argument('--config', default='config.yml', help='location of the config file') parser.add_argument('--uri', help='synchronize a specific URI instead of the one in the config') parser.add_argument('--sync-favorites', action=argparse.BooleanOptionalAction, help='synchronize the favorites') + parser.add_argument('--sync-artists', action=argparse.BooleanOptionalAction, help='synchronize followed artists') args = parser.parse_args() with open(args.config, 'r') as f: @@ -27,19 +28,29 @@ def main(): tidal_playlist = _sync.pick_tidal_playlist_for_spotify_playlist(spotify_playlist, tidal_playlists) _sync.sync_playlists_wrapper(spotify_session, tidal_session, [tidal_playlist], config) sync_favorites = args.sync_favorites # only sync favorites if command line argument explicitly passed + sync_artists = args.sync_artists + elif args.sync_artists: + sync_artists = True # sync only the artists + sync_favorites = False elif args.sync_favorites: sync_favorites = True # sync only the favorites + sync_artists = False elif config.get('sync_playlists', None): # if the config contains a sync_playlists list of mappings then use that _sync.sync_playlists_wrapper(spotify_session, tidal_session, _sync.get_playlists_from_config(spotify_session, tidal_session, config), config) sync_favorites = args.sync_favorites is None and config.get('sync_favorites_default', True) + sync_artists = args.sync_artists is None and config.get('sync_artists_default', False) else: # otherwise sync all the user playlists in the Spotify account and favorites unless explicitly disabled _sync.sync_playlists_wrapper(spotify_session, tidal_session, _sync.get_user_playlist_mappings(spotify_session, tidal_session, config), config) sync_favorites = args.sync_favorites is None and config.get('sync_favorites_default', True) + sync_artists = args.sync_artists is None and config.get('sync_artists_default', False) if sync_favorites: _sync.sync_favorites_wrapper(spotify_session, tidal_session, config) + + if sync_artists: + _sync.sync_artists_wrapper(spotify_session, tidal_session, config) if __name__ == '__main__': main() diff --git a/src/spotify_to_tidal/auth.py b/src/spotify_to_tidal/auth.py index cb1762b..648982c 100644 --- a/src/spotify_to_tidal/auth.py +++ b/src/spotify_to_tidal/auth.py @@ -11,7 +11,7 @@ 'open_tidal_session' ] -SPOTIFY_SCOPES = 'playlist-read-private, user-library-read' +SPOTIFY_SCOPES = 'playlist-read-private, user-library-read, user-follow-read' def open_spotify_session(config) -> spotipy.Spotify: credentials_manager = spotipy.SpotifyOAuth(username=config['username'], diff --git a/src/spotify_to_tidal/sync.py b/src/spotify_to_tidal/sync.py index 4494dbf..9075541 100755 --- a/src/spotify_to_tidal/sync.py +++ b/src/spotify_to_tidal/sync.py @@ -17,7 +17,6 @@ from tqdm import tqdm import traceback import unicodedata -import math from .type import spotify as t_spotify @@ -414,3 +413,65 @@ def get_playlist_ids(config): output.append((spotify_playlist, tidal_playlist)) return output +async def get_followed_artists_from_spotify(spotify_session: spotipy.Spotify) -> List[dict]: + """ Get all followed artists from Spotify """ + def _get_followed_artists(after=None): + return spotify_session.current_user_followed_artists(after=after) + + artists = [] + results = await asyncio.to_thread(_get_followed_artists) + artists.extend(results['artists']['items']) + + while results['artists']['next']: + results = await asyncio.to_thread(_get_followed_artists, after=results['artists']['cursors']['after']) + artists.extend(results['artists']['items']) + + return artists + +async def search_artist_on_tidal(artist_name: str, tidal_session: tidalapi.Session) -> tidalapi.Artist | None: + """ Search for an artist on Tidal """ + def _search(): + results = tidal_session.search(artist_name, models=[tidalapi.artist.Artist]) + if results['artists']: + return results['artists'][0] + return None + + return await asyncio.to_thread(_search) + +async def sync_artists(spotify_session: spotipy.Spotify, tidal_session: tidalapi.Session, config: dict): + """ Sync followed artists from Spotify to Tidal """ + print("Loading followed artists from Spotify") + spotify_artists = await get_followed_artists_from_spotify(spotify_session) + + if not spotify_artists: + print("No followed artists found on Spotify") + return + + print(f"Searching for {len(spotify_artists)} artists on Tidal") + failed_artists = [] + + for artist in tqdm(spotify_artists, desc="Syncing artists to Tidal"): + try: + tidal_artist = await search_artist_on_tidal(artist['name'], tidal_session) + if tidal_artist: + try: + tidal_session.user.favorites.add_artist(tidal_artist.id) + except Exception as e: + print(f"Could not follow {artist['name']}: {e}") + failed_artists.append(artist['name']) + else: + failed_artists.append(artist['name']) + except Exception as e: + print(f"Error syncing artist {artist['name']}: {e}") + failed_artists.append(artist['name']) + + if failed_artists: + print(f"\nCould not find/follow {len(failed_artists)} artists:") + for artist in failed_artists[:10]: # Show first 10 + print(f" - {artist}") + if len(failed_artists) > 10: + print(f" ... and {len(failed_artists) - 10} more") + +def sync_artists_wrapper(spotify_session: spotipy.Spotify, tidal_session: tidalapi.Session, config: dict): + asyncio.run(sync_artists(spotify_session, tidal_session, config)) + diff --git a/tests/unit/test_sync.py b/tests/unit/test_sync.py new file mode 100644 index 0000000..6c5cedb --- /dev/null +++ b/tests/unit/test_sync.py @@ -0,0 +1,274 @@ +import pytest +import asyncio +from unittest import mock +from spotify_to_tidal.sync import ( + get_followed_artists_from_spotify, + search_artist_on_tidal, + sync_artists, +) + + +@pytest.fixture +def mock_spotify_session(): + """Create a mock Spotify session""" + session = mock.MagicMock() + return session + + +@pytest.fixture +def mock_tidal_session(): + """Create a mock Tidal session""" + session = mock.MagicMock() + session.user.favorites.add_artist = mock.MagicMock() + return session + + +@pytest.fixture +def mock_config(): + """Create a mock config""" + return { + "max_concurrency": 10, + "rate_limit": 10, + } + + +@pytest.fixture +def sample_spotify_artists(): + """Sample Spotify artists response""" + return { + "artists": { + "items": [ + {"id": "artist1", "name": "Artist One"}, + {"id": "artist2", "name": "Artist Two"}, + ], + "next": None, + "cursors": {"after": None}, + } + } + + +@pytest.fixture +def sample_spotify_artists_paginated(): + """Sample Spotify artists response with pagination""" + return [ + { + "artists": { + "items": [ + {"id": "artist1", "name": "Artist One"}, + {"id": "artist2", "name": "Artist Two"}, + ], + "next": "https://api.spotify.com/v1/me/following?type=artist&limit=20&after=xyz", + "cursors": {"after": "xyz"}, + } + }, + { + "artists": { + "items": [ + {"id": "artist3", "name": "Artist Three"}, + ], + "next": None, + "cursors": {"after": None}, + } + }, + ] + + +@pytest.fixture +def sample_tidal_artist(): + """Sample Tidal artist object""" + artist = mock.MagicMock() + artist.id = 12345 + artist.name = "Artist One" + return artist + + +def test_get_followed_artists_single_page(mock_spotify_session, sample_spotify_artists): + """Test fetching artists from Spotify (single page)""" + mock_spotify_session.current_user_followed_artists.return_value = sample_spotify_artists + + async def _test(): + artists = await get_followed_artists_from_spotify(mock_spotify_session) + assert len(artists) == 2 + assert artists[0]["name"] == "Artist One" + assert artists[1]["name"] == "Artist Two" + mock_spotify_session.current_user_followed_artists.assert_called_once_with(after=None) + + asyncio.run(_test()) + + +def test_get_followed_artists_multiple_pages(mock_spotify_session, sample_spotify_artists_paginated): + """Test fetching artists from Spotify (multiple pages)""" + mock_spotify_session.current_user_followed_artists.side_effect = sample_spotify_artists_paginated + + async def _test(): + artists = await get_followed_artists_from_spotify(mock_spotify_session) + assert len(artists) == 3 + assert artists[0]["name"] == "Artist One" + assert artists[1]["name"] == "Artist Two" + assert artists[2]["name"] == "Artist Three" + assert mock_spotify_session.current_user_followed_artists.call_count == 2 + + asyncio.run(_test()) + + +def test_get_followed_artists_empty(mock_spotify_session): + """Test fetching artists when user has no followed artists""" + mock_spotify_session.current_user_followed_artists.return_value = { + "artists": { + "items": [], + "next": None, + "cursors": {"after": None}, + } + } + + async def _test(): + artists = await get_followed_artists_from_spotify(mock_spotify_session) + assert len(artists) == 0 + + asyncio.run(_test()) + + +def test_search_artist_on_tidal_found(mock_tidal_session, sample_tidal_artist): + """Test searching for an artist on Tidal (found)""" + mock_tidal_session.search.return_value = { + "artists": [sample_tidal_artist] + } + + async def _test(): + artist = await search_artist_on_tidal("Artist One", mock_tidal_session) + assert artist is not None + assert artist.id == 12345 + mock_tidal_session.search.assert_called_once() + + asyncio.run(_test()) + + +def test_search_artist_on_tidal_not_found(mock_tidal_session): + """Test searching for an artist on Tidal (not found)""" + mock_tidal_session.search.return_value = {"artists": []} + + async def _test(): + artist = await search_artist_on_tidal("Nonexistent Artist", mock_tidal_session) + assert artist is None + mock_tidal_session.search.assert_called_once() + + asyncio.run(_test()) + + +def test_sync_artists_success(mock_spotify_session, mock_tidal_session, mock_config, sample_tidal_artist, mocker): + """Test successful artist syncing""" + mock_get_artists = mocker.patch( + "spotify_to_tidal.sync.get_followed_artists_from_spotify", + return_value=[ + {"id": "artist1", "name": "Artist One"}, + {"id": "artist2", "name": "Artist Two"}, + ], + ) + mock_search = mocker.patch( + "spotify_to_tidal.sync.search_artist_on_tidal", + return_value=sample_tidal_artist, + ) + mocker.patch("spotify_to_tidal.sync.tqdm", side_effect=lambda x, **kwargs: x) + + async def _test(): + await sync_artists(mock_spotify_session, mock_tidal_session, mock_config) + mock_get_artists.assert_called_once() + assert mock_search.call_count == 2 + assert mock_tidal_session.user.favorites.add_artist.call_count == 2 + + asyncio.run(_test()) + + +def test_sync_artists_no_artists(mock_spotify_session, mock_tidal_session, mock_config, mocker): + """Test syncing when user has no followed artists""" + mocker.patch( + "spotify_to_tidal.sync.get_followed_artists_from_spotify", + return_value=[], + ) + + async def _test(): + await sync_artists(mock_spotify_session, mock_tidal_session, mock_config) + mock_tidal_session.user.favorites.add_artist.assert_not_called() + + asyncio.run(_test()) + + +def test_sync_artists_partial_failure(mock_spotify_session, mock_tidal_session, mock_config, sample_tidal_artist, mocker): + """Test artist syncing with some artists not found""" + mocker.patch( + "spotify_to_tidal.sync.get_followed_artists_from_spotify", + return_value=[ + {"id": "artist1", "name": "Artist One"}, + {"id": "artist2", "name": "Nonexistent Artist"}, + ], + ) + + mock_search = mocker.patch( + "spotify_to_tidal.sync.search_artist_on_tidal", + side_effect=[sample_tidal_artist, None], + ) + mocker.patch("spotify_to_tidal.sync.tqdm", side_effect=lambda x, **kwargs: x) + + async def _test(): + await sync_artists(mock_spotify_session, mock_tidal_session, mock_config) + assert mock_search.call_count == 2 + assert mock_tidal_session.user.favorites.add_artist.call_count == 1 + + asyncio.run(_test()) + + +def test_sync_artists_add_artist_failure(mock_spotify_session, mock_tidal_session, mock_config, sample_tidal_artist, mocker): + """Test artist syncing when adding to favorites fails""" + mocker.patch( + "spotify_to_tidal.sync.get_followed_artists_from_spotify", + return_value=[ + {"id": "artist1", "name": "Artist One"}, + ], + ) + + mocker.patch( + "spotify_to_tidal.sync.search_artist_on_tidal", + return_value=sample_tidal_artist, + ) + + mock_tidal_session.user.favorites.add_artist.side_effect = Exception("API Error") + mocker.patch("spotify_to_tidal.sync.tqdm", side_effect=lambda x, **kwargs: x) + + async def _test(): + await sync_artists(mock_spotify_session, mock_tidal_session, mock_config) + mock_tidal_session.user.favorites.add_artist.assert_called_once() + + asyncio.run(_test()) + + +def test_sync_artists_search_error(mock_spotify_session, mock_tidal_session, mock_config, mocker): + """Test artist syncing when search raises an exception""" + mocker.patch( + "spotify_to_tidal.sync.get_followed_artists_from_spotify", + return_value=[ + {"id": "artist1", "name": "Artist One"}, + ], + ) + + mock_search = mocker.patch( + "spotify_to_tidal.sync.search_artist_on_tidal", + side_effect=Exception("Search Error"), + ) + mocker.patch("spotify_to_tidal.sync.tqdm", side_effect=lambda x, **kwargs: x) + + async def _test(): + await sync_artists(mock_spotify_session, mock_tidal_session, mock_config) + mock_search.assert_called_once() + + asyncio.run(_test()) + + +def test_sync_artists_wrapper(mock_spotify_session, mock_tidal_session, mock_config, mocker): + """Test the sync_artists_wrapper function""" + mock_sync = mocker.patch("spotify_to_tidal.sync.sync_artists") + + from spotify_to_tidal.sync import sync_artists_wrapper + + sync_artists_wrapper(mock_spotify_session, mock_tidal_session, mock_config) + + mock_sync.assert_called_once()