Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a66f5f864 | ||
|
|
299cc3209f | ||
|
|
46771aefc8 | ||
|
|
eb745eab6f | ||
|
|
9ff181ae6e | ||
|
|
d90c5bac41 | ||
|
|
e62a8a80cb | ||
|
|
a5afd975f0 | ||
|
|
03e0396ac0 | ||
|
|
a438cda72b | ||
|
|
2f1985a42b | ||
|
|
bcf2bbca0d | ||
|
|
457da1724f | ||
|
|
4d7c3b0ef0 | ||
|
|
4fb702d008 | ||
|
|
693dcd110f | ||
|
|
17d60c019d | ||
|
|
7148053ad4 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,6 +3,7 @@ config.yml
|
|||||||
config.yaml
|
config.yaml
|
||||||
.cache*
|
.cache*
|
||||||
.session.yml
|
.session.yml
|
||||||
|
songs not found.txt
|
||||||
|
|
||||||
# Byte-compiled / optimized / DLL files
|
# Byte-compiled / optimized / DLL files
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ spotify:
|
|||||||
client_id: your_client_id
|
client_id: your_client_id
|
||||||
client_secret: your_client_secret
|
client_secret: your_client_secret
|
||||||
username: your_spotify_username
|
username: your_spotify_username
|
||||||
redirect_uri: http://localhost:8888/callback
|
redirect_uri: http://127.0.0.1:8888/callback
|
||||||
|
open_browser: True # Set to False if using a headless server environment
|
||||||
|
|
||||||
|
|
||||||
# uncomment this block if you want to only sync specific playlist IDs
|
# uncomment this block if you want to only sync specific playlist IDs
|
||||||
|
|||||||
@@ -4,16 +4,16 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "spotify_to_tidal"
|
name = "spotify_to_tidal"
|
||||||
version = "1.0.0"
|
version = "1.0.7"
|
||||||
requires-python = ">= 3.10"
|
requires-python = ">= 3.10"
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"spotipy~=2.23.0",
|
"spotipy~=2.24",
|
||||||
"tidalapi==0.7.6",
|
"tidalapi~=0.8.10",
|
||||||
"pyyaml~=6.0",
|
"pyyaml~=6.0",
|
||||||
"tqdm~=4.64",
|
"tqdm~=4.64",
|
||||||
"sqlalchemy~=2.0",
|
"sqlalchemy~=2.0",
|
||||||
"pytest~=7.0",
|
"pytest~=8.0",
|
||||||
"pytest-mock~=3.8"
|
"pytest-mock~=3.8"
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ Setup
|
|||||||
Usage
|
Usage
|
||||||
----
|
----
|
||||||
To synchronize all of your Spotify playlists with your Tidal account run the following from the project root directory
|
To synchronize all of your Spotify playlists with your Tidal account run the following from the project root directory
|
||||||
|
Windows ignores python module paths by default, but you can run them using `python3 -m spotify_to_tidal`
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
spotify_to_tidal
|
spotify_to_tidal
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ def open_spotify_session(config) -> spotipy.Spotify:
|
|||||||
client_id=config['client_id'],
|
client_id=config['client_id'],
|
||||||
client_secret=config['client_secret'],
|
client_secret=config['client_secret'],
|
||||||
redirect_uri=config['redirect_uri'],
|
redirect_uri=config['redirect_uri'],
|
||||||
requests_timeout=2)
|
requests_timeout=2,
|
||||||
|
open_browser=config.get('open_browser', True))
|
||||||
try:
|
try:
|
||||||
credentials_manager.get_access_token(as_dict=False)
|
credentials_manager.get_access_token(as_dict=False)
|
||||||
except spotipy.SpotifyOauthError:
|
except spotipy.SpotifyOauthError:
|
||||||
|
|||||||
@@ -178,12 +178,18 @@ async def _fetch_all_from_spotify_in_chunks(fetch_function: Callable) -> List[di
|
|||||||
|
|
||||||
async def get_tracks_from_spotify_playlist(spotify_session: spotipy.Spotify, spotify_playlist):
|
async def get_tracks_from_spotify_playlist(spotify_session: spotipy.Spotify, spotify_playlist):
|
||||||
def _get_tracks_from_spotify_playlist(offset: int, playlist_id: str):
|
def _get_tracks_from_spotify_playlist(offset: int, playlist_id: str):
|
||||||
fields = "next,total,limit,items(track(name,album(name,artists),artists,track_number,duration_ms,id,external_ids(isrc)))"
|
fields = "next,total,limit,items(track(name,album(name,artists),artists,track_number,duration_ms,id,external_ids(isrc))),type"
|
||||||
return spotify_session.playlist_tracks(playlist_id=playlist_id, fields=fields, offset=offset)
|
return spotify_session.playlist_tracks(playlist_id=playlist_id, fields=fields, offset=offset)
|
||||||
|
|
||||||
print(f"Loading tracks from Spotify playlist '{spotify_playlist['name']}'")
|
print(f"Loading tracks from Spotify playlist '{spotify_playlist['name']}'")
|
||||||
return await repeat_on_request_error( _fetch_all_from_spotify_in_chunks, lambda offset: _get_tracks_from_spotify_playlist(offset=offset, playlist_id=spotify_playlist["id"]))
|
items = await repeat_on_request_error( _fetch_all_from_spotify_in_chunks, lambda offset: _get_tracks_from_spotify_playlist(offset=offset, playlist_id=spotify_playlist["id"]))
|
||||||
|
track_filter = lambda item: item.get('type', 'track') == 'track' # type may be 'episode' also
|
||||||
|
sanity_filter = lambda item: ('album' in item
|
||||||
|
and 'name' in item['album']
|
||||||
|
and 'artists' in item['album']
|
||||||
|
and len(item['album']['artists']) > 0
|
||||||
|
and item['album']['artists'][0]['name'] is not None)
|
||||||
|
return list(filter(sanity_filter, filter(track_filter, items)))
|
||||||
|
|
||||||
def populate_track_match_cache(spotify_tracks_: Sequence[t_spotify.SpotifyTrack], tidal_tracks_: Sequence[tidalapi.Track]):
|
def populate_track_match_cache(spotify_tracks_: Sequence[t_spotify.SpotifyTrack], tidal_tracks_: Sequence[tidalapi.Track]):
|
||||||
""" Populate the track match cache with all the existing tracks in Tidal playlist corresponding to Spotify playlist """
|
""" Populate the track match cache with all the existing tracks in Tidal playlist corresponding to Spotify playlist """
|
||||||
@@ -275,21 +281,27 @@ async def search_new_tracks_on_tidal(tidal_session: tidalapi.Session, spotify_tr
|
|||||||
color = ('\033[91m', '\033[0m')
|
color = ('\033[91m', '\033[0m')
|
||||||
print(color[0] + "Could not find the track " + song404[-1] + color[1])
|
print(color[0] + "Could not find the track " + song404[-1] + color[1])
|
||||||
file_name = "songs not found.txt"
|
file_name = "songs not found.txt"
|
||||||
|
header = f"==========================\nPlaylist: {playlist_name}\n==========================\n"
|
||||||
with open(file_name, "a", encoding="utf-8") as file:
|
with open(file_name, "a", encoding="utf-8") as file:
|
||||||
|
file.write(header)
|
||||||
for song in song404:
|
for song in song404:
|
||||||
file.write(f"{song}\n")
|
file.write(f"{song}\n")
|
||||||
|
|
||||||
|
|
||||||
async def sync_playlist(spotify_session: spotipy.Spotify, tidal_session: tidalapi.Session, spotify_playlist, tidal_playlist: tidalapi.Playlist | None, config: dict):
|
async def sync_playlist(spotify_session: spotipy.Spotify, tidal_session: tidalapi.Session, spotify_playlist, tidal_playlist: tidalapi.Playlist | None, config: dict):
|
||||||
""" sync given playlist to tidal """
|
""" sync given playlist to tidal """
|
||||||
# Create a new Tidal playlist if required
|
# Get the tracks from both Spotify and Tidal, creating a new Tidal playlist if necessary
|
||||||
if not tidal_playlist:
|
spotify_tracks = await get_tracks_from_spotify_playlist(spotify_session, spotify_playlist)
|
||||||
|
if len(spotify_tracks) == 0:
|
||||||
|
return # nothing to do
|
||||||
|
if tidal_playlist:
|
||||||
|
old_tidal_tracks = await get_all_playlist_tracks(tidal_playlist)
|
||||||
|
else:
|
||||||
print(f"No playlist found on Tidal corresponding to Spotify playlist: '{spotify_playlist['name']}', creating new playlist")
|
print(f"No playlist found on Tidal corresponding to Spotify playlist: '{spotify_playlist['name']}', creating new playlist")
|
||||||
tidal_playlist = tidal_session.user.create_playlist(spotify_playlist['name'], spotify_playlist['description'])
|
tidal_playlist = tidal_session.user.create_playlist(spotify_playlist['name'], spotify_playlist['description'])
|
||||||
|
old_tidal_tracks = []
|
||||||
|
|
||||||
# Extract the new tracks from the playlist that we haven't already seen before
|
# Extract the new tracks from the playlist that we haven't already seen before
|
||||||
spotify_tracks = await get_tracks_from_spotify_playlist(spotify_session, spotify_playlist)
|
|
||||||
old_tidal_tracks = await get_all_playlist_tracks(tidal_playlist)
|
|
||||||
populate_track_match_cache(spotify_tracks, old_tidal_tracks)
|
populate_track_match_cache(spotify_tracks, old_tidal_tracks)
|
||||||
await search_new_tracks_on_tidal(tidal_session, spotify_tracks, spotify_playlist['name'], config)
|
await search_new_tracks_on_tidal(tidal_session, spotify_tracks, spotify_playlist['name'], config)
|
||||||
new_tidal_track_ids = get_tracks_for_new_tidal_playlist(spotify_tracks)
|
new_tidal_track_ids = get_tracks_for_new_tidal_playlist(spotify_tracks)
|
||||||
@@ -365,20 +377,25 @@ def get_user_playlist_mappings(spotify_session: spotipy.Spotify, tidal_session:
|
|||||||
return results
|
return results
|
||||||
|
|
||||||
async def get_playlists_from_spotify(spotify_session: spotipy.Spotify, config):
|
async def get_playlists_from_spotify(spotify_session: spotipy.Spotify, config):
|
||||||
# get all the user playlists from the Spotify account
|
# get all the playlists from the Spotify account
|
||||||
playlists = []
|
playlists = []
|
||||||
print("Loading Spotify playlists")
|
print("Loading Spotify playlists")
|
||||||
results = spotify_session.current_user_playlists()
|
first_results = spotify_session.current_user_playlists()
|
||||||
exclude_list = set([x.split(':')[-1] for x in config.get('excluded_playlists', [])])
|
exclude_list = set([x.split(':')[-1] for x in config.get('excluded_playlists', [])])
|
||||||
playlists.extend([p for p in results['items'] if p['owner']['id'] == config['spotify']['username'] and not p['id'] in exclude_list])
|
playlists.extend([p for p in first_results['items']])
|
||||||
|
user_id = spotify_session.current_user()['id']
|
||||||
|
|
||||||
# get all the remaining playlists in parallel
|
# get all the remaining playlists in parallel
|
||||||
if results['next']:
|
if first_results['next']:
|
||||||
offsets = [ results['limit'] * n for n in range(1, math.ceil(results['total']/results['limit'])) ]
|
offsets = [ first_results['limit'] * n for n in range(1, math.ceil(first_results['total']/first_results['limit'])) ]
|
||||||
extra_results = await atqdm.gather( *[asyncio.to_thread(spotify_session.current_user_playlists, offset=offset) for offset in offsets ] )
|
extra_results = await atqdm.gather( *[asyncio.to_thread(spotify_session.current_user_playlists, offset=offset) for offset in offsets ] )
|
||||||
for extra_result in extra_results:
|
for extra_result in extra_results:
|
||||||
playlists.extend([p for p in extra_result['items'] if p['owner']['id'] == config['spotify']['username'] and not p['id'] in exclude_list])
|
playlists.extend([p for p in extra_result['items']])
|
||||||
return playlists
|
|
||||||
|
# filter out playlists that don't belong to us or are on the exclude list
|
||||||
|
my_playlist_filter = lambda p: p and p['owner']['id'] == user_id
|
||||||
|
exclude_filter = lambda p: not p['id'] in exclude_list
|
||||||
|
return list(filter( exclude_filter, filter( my_playlist_filter, playlists )))
|
||||||
|
|
||||||
def get_playlists_from_config(spotify_session: spotipy.Spotify, tidal_session: tidalapi.Session, config):
|
def get_playlists_from_config(spotify_session: spotipy.Spotify, tidal_session: tidalapi.Session, config):
|
||||||
# get the list of playlist sync mappings from the configuration file
|
# get the list of playlist sync mappings from the configuration file
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ def test_open_spotify_session(mocker):
|
|||||||
"username": "test_user",
|
"username": "test_user",
|
||||||
"client_id": "test_client_id",
|
"client_id": "test_client_id",
|
||||||
"client_secret": "test_client_secret",
|
"client_secret": "test_client_secret",
|
||||||
"redirect_uri": "http://localhost/",
|
"redirect_uri": "http://127.0.0.1/",
|
||||||
|
"open_browser": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Create a mock SpotifyOAuth instance
|
# Create a mock SpotifyOAuth instance
|
||||||
@@ -39,8 +40,9 @@ def test_open_spotify_session(mocker):
|
|||||||
scope=SPOTIFY_SCOPES,
|
scope=SPOTIFY_SCOPES,
|
||||||
client_id="test_client_id",
|
client_id="test_client_id",
|
||||||
client_secret="test_client_secret",
|
client_secret="test_client_secret",
|
||||||
redirect_uri="http://localhost/",
|
redirect_uri="http://127.0.0.1/",
|
||||||
requests_timeout=2,
|
requests_timeout=2,
|
||||||
|
open_browser=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Assert that the Spotify instance was created
|
# Assert that the Spotify instance was created
|
||||||
@@ -62,7 +64,7 @@ def test_open_spotify_session_oauth_error(mocker):
|
|||||||
"username": "test_user",
|
"username": "test_user",
|
||||||
"client_id": "test_client_id",
|
"client_id": "test_client_id",
|
||||||
"client_secret": "test_client_secret",
|
"client_secret": "test_client_secret",
|
||||||
"redirect_uri": "http://localhost/",
|
"redirect_uri": "http://127.0.0.1/",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Mock sys.exit to prevent the test from exiting
|
# Mock sys.exit to prevent the test from exiting
|
||||||
|
|||||||
Reference in New Issue
Block a user