This commit is contained in:
joshrmcdaniel
2024-05-12 10:46:36 -05:00
parent 6aaf72bdd1
commit 76f502f2bc
4 changed files with 116 additions and 23 deletions

View File

@@ -1,30 +1,29 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import argparse
from .auth import open_tidal_session, open_spotify_session
from functools import partial from functools import partial
from typing import Sequence, Set from typing import Sequence, Set, Mapping
from multiprocessing import Pool from multiprocessing import Pool
import requests import requests
import sys import sys
import spotipy import spotipy
import tidalapi import tidalapi
from tidalapi_patch import set_tidal_playlist from .tidalapi_patch import set_tidal_playlist
import time import time
from tqdm import tqdm from tqdm import tqdm
import traceback import traceback
import unicodedata import unicodedata
import yaml
from .type import spotify as t_spotify
def normalize(s): def normalize(s) -> str:
return unicodedata.normalize('NFD', s).encode('ascii', 'ignore').decode('ascii') return unicodedata.normalize('NFD', s).encode('ascii', 'ignore').decode('ascii')
def simple(input_string: str) -> str: def simple(input_string: str) -> str:
# only take the first part of a string before any hyphens or brackets to account for different versions # only take the first part of a string before any hyphens or brackets to account for different versions
return input_string.split('-')[0].strip().split('(')[0].strip().split('[')[0].strip() return input_string.split('-')[0].strip().split('(')[0].strip().split('[')[0].strip()
def isrc_match(tidal_track: tidalapi.Track, spotify_track): def isrc_match(tidal_track: tidalapi.Track, spotify_track) -> bool:
if "isrc" in spotify_track["external_ids"]: if "isrc" in spotify_track["external_ids"]:
return tidal_track.isrc == spotify_track["external_ids"]["isrc"] return tidal_track.isrc == spotify_track["external_ids"]["isrc"]
return False return False
@@ -34,7 +33,7 @@ def duration_match(tidal_track: tidalapi.Track, spotify_track, tolerance=2) -> f
return abs(tidal_track.duration - spotify_track['duration_ms']/1000) < tolerance return abs(tidal_track.duration - spotify_track['duration_ms']/1000) < tolerance
def name_match(tidal_track, spotify_track) -> bool: def name_match(tidal_track, spotify_track) -> bool:
def exclusion_rule(pattern: str, tidal_track: tidalapi.Track, spotify_track): def exclusion_rule(pattern: str, tidal_track: tidalapi.Track, spotify_track: t_spotify.SpotifyTrack):
spotify_has_pattern = pattern in spotify_track['name'].lower() spotify_has_pattern = pattern in spotify_track['name'].lower()
tidal_has_pattern = pattern in tidal_track.name.lower() or (not tidal_track.version is None and (pattern in tidal_track.version.lower())) tidal_has_pattern = pattern in tidal_track.name.lower() or (not tidal_track.version is None and (pattern in tidal_track.version.lower()))
return spotify_has_pattern != tidal_has_pattern return spotify_has_pattern != tidal_has_pattern
@@ -68,7 +67,7 @@ def artist_match(tidal_track: tidalapi.Track, spotify_track) -> Set[str]:
result.extend(split_artist_name(artist_name)) result.extend(split_artist_name(artist_name))
return set([simple(x.strip().lower()) for x in result]) return set([simple(x.strip().lower()) for x in result])
def get_spotify_artists(spotify_track, do_normalize=False) -> Set[str]: def get_spotify_artists(spotify_track: t_spotify.SpotifyTrack, do_normalize=False) -> Set[str]:
result = [] result = []
for artist in spotify_track['artists']: for artist in spotify_track['artists']:
if do_normalize: if do_normalize:
@@ -83,7 +82,7 @@ def artist_match(tidal_track: tidalapi.Track, spotify_track) -> Set[str]:
return True return True
return get_tidal_artists(tidal_track, True).intersection(get_spotify_artists(spotify_track, True)) != set() return get_tidal_artists(tidal_track, True).intersection(get_spotify_artists(spotify_track, True)) != set()
def match(tidal_track, spotify_track): def match(tidal_track, spotify_track) -> bool:
return isrc_match(tidal_track, spotify_track) or ( return isrc_match(tidal_track, spotify_track) or (
duration_match(tidal_track, spotify_track) duration_match(tidal_track, spotify_track)
and name_match(tidal_track, spotify_track) and name_match(tidal_track, spotify_track)
@@ -91,7 +90,7 @@ def match(tidal_track, spotify_track):
) )
def tidal_search(spotify_track_and_cache, tidal_session): def tidal_search(spotify_track_and_cache, tidal_session: tidalapi.Session) -> tidalapi.Track | None:
spotify_track, cached_tidal_track = spotify_track_and_cache spotify_track, cached_tidal_track = spotify_track_and_cache
if cached_tidal_track: return cached_tidal_track if cached_tidal_track: return cached_tidal_track
# search for album name and first album artist # search for album name and first album artist
@@ -108,7 +107,7 @@ def tidal_search(spotify_track_and_cache, tidal_session):
if match(track, spotify_track): if match(track, spotify_track):
return track return track
def get_tidal_playlists_dict(tidal_session): def get_tidal_playlists_dict(tidal_session: tidalapi.Session) -> Mapping[str, tidalapi.Playlist]:
# a dictionary of name --> playlist # a dictionary of name --> playlist
tidal_playlists = tidal_session.user.playlists() tidal_playlists = tidal_session.user.playlists()
output = {} output = {}
@@ -152,7 +151,7 @@ def call_async_with_progress(function, values, description, num_processes, **kwa
results[index] = result results[index] = result
return results return results
def get_tracks_from_spotify_playlist(spotify_session, spotify_playlist): def get_tracks_from_spotify_playlist(spotify_session: spotipy.Spotify, spotify_playlist):
output = [] output = []
results = spotify_session.playlist_tracks( results = spotify_session.playlist_tracks(
spotify_playlist["id"], spotify_playlist["id"],
@@ -167,10 +166,10 @@ def get_tracks_from_spotify_playlist(spotify_session, spotify_playlist):
return output return output
class TidalPlaylistCache: class TidalPlaylistCache:
def __init__(self, playlist): def __init__(self, playlist: tidalapi.Playlist):
self._data = playlist.tracks() self._data = playlist.tracks()
def _search(self, spotify_track): def _search(self, spotify_track: t_spotify.SpotifyTrack):
''' check if the given spotify track was already in the tidal playlist.''' ''' check if the given spotify track was already in the tidal playlist.'''
results = [] results = []
for tidal_track in self._data: for tidal_track in self._data:
@@ -178,7 +177,7 @@ class TidalPlaylistCache:
return tidal_track return tidal_track
return None return None
def search(self, spotify_session, spotify_playlist): def search(self, spotify_session: spotipy.Spotify, spotify_playlist):
''' Add the cached tidal track where applicable to a list of spotify tracks ''' ''' Add the cached tidal track where applicable to a list of spotify tracks '''
results = [] results = []
cache_hits = 0 cache_hits = 0
@@ -193,7 +192,7 @@ class TidalPlaylistCache:
results.append( (track, None) ) results.append( (track, None) )
return (results, cache_hits) return (results, cache_hits)
def tidal_playlist_is_dirty(playlist, new_track_ids): def tidal_playlist_is_dirty(playlist: tidalapi.Playlist, new_track_ids: Sequence[str]) -> bool:
old_tracks = playlist.tracks() old_tracks = playlist.tracks()
if len(old_tracks) != len(new_track_ids): if len(old_tracks) != len(new_track_ids):
return True return True
@@ -202,13 +201,12 @@ def tidal_playlist_is_dirty(playlist, new_track_ids):
return True return True
return False return False
def sync_playlist(spotify_session, tidal_session, spotify_id, tidal_id, config): def sync_playlist(spotify_session: spotipy.Spotify, tidal_session: tidalapi.Session, spotify_id: str, tidal_id: int, config):
try: try:
spotify_playlist = spotify_session.playlist(spotify_id) spotify_playlist = spotify_session.playlist(spotify_id)
except spotipy.SpotifyException as e: except spotipy.SpotifyException as e:
print("Error getting Spotify playlist " + spotify_id) print("Error getting Spotify playlist " + spotify_id)
print(e) print(e)
results.append(None)
return return
if tidal_id: if tidal_id:
# if a Tidal playlist was specified then look it up # if a Tidal playlist was specified then look it up
@@ -243,7 +241,7 @@ def sync_playlist(spotify_session, tidal_session, spotify_id, tidal_id, config):
else: else:
print("No changes to write to Tidal playlist") print("No changes to write to Tidal playlist")
def sync_list(spotify_session, tidal_session, playlists, config): def sync_list(spotify_session: spotipy.Spotify, tidal_session: tidalapi.Session, playlists: Mapping[str, tidalapi.Playlist], config):
results = [] results = []
for spotify_id, tidal_id in playlists: for spotify_id, tidal_id in playlists:
# sync the spotify playlist to tidal # sync the spotify playlist to tidal
@@ -251,7 +249,7 @@ def sync_list(spotify_session, tidal_session, playlists, config):
results.append(tidal_id) results.append(tidal_id)
return results return results
def pick_tidal_playlist_for_spotify_playlist(spotify_playlist, tidal_playlists): def pick_tidal_playlist_for_spotify_playlist(spotify_playlist, tidal_playlists: Mapping[str, tidalapi.Playlist]):
if spotify_playlist['name'] in tidal_playlists: if spotify_playlist['name'] in tidal_playlists:
# if there's an existing tidal playlist with the name of the current playlist then use that # if there's an existing tidal playlist with the name of the current playlist then use that
tidal_playlist = tidal_playlists[spotify_playlist['name']] tidal_playlist = tidal_playlists[spotify_playlist['name']]
@@ -260,7 +258,7 @@ def pick_tidal_playlist_for_spotify_playlist(spotify_playlist, tidal_playlists):
return (spotify_playlist['id'], None) return (spotify_playlist['id'], None)
def get_user_playlist_mappings(spotify_session, tidal_session, config): def get_user_playlist_mappings(spotify_session: spotipy.Spotify, tidal_session: tidalapi.Session, config):
results = [] results = []
spotify_playlists = get_playlists_from_spotify(spotify_session, config) spotify_playlists = get_playlists_from_spotify(spotify_session, config)
tidal_playlists = get_tidal_playlists_dict(tidal_session) tidal_playlists = get_tidal_playlists_dict(tidal_session)
@@ -268,7 +266,7 @@ def get_user_playlist_mappings(spotify_session, tidal_session, config):
results.append( pick_tidal_playlist_for_spotify_playlist(spotify_playlist, tidal_playlists) ) results.append( pick_tidal_playlist_for_spotify_playlist(spotify_playlist, tidal_playlists) )
return results return results
def get_playlists_from_spotify(spotify_session, config): def get_playlists_from_spotify(spotify_session: spotipy.Spotify, config):
# get all the user playlists from the Spotify account # get all the user playlists from the Spotify account
playlists = [] playlists = []
spotify_results = spotify_session.user_playlists(config['spotify']['username']) spotify_results = spotify_session.user_playlists(config['spotify']['username'])

View File

@@ -0,0 +1,26 @@
from .config import SpotifyConfig, TidalConfig, PlaylistConfig, SyncConfig
from .playlist import TidalPlaylist
from .spotify import SpotifyTrack
from spotipy import Spotify
from tidalapi import Session, Track
TidalID = str
SpotifyID = str
TidalSession = Session
TidalTrack = Track
SpotifySession = Spotify
__all__ = [
"SpotifyConfig",
"TidalConfig",
"PlaylistConfig",
"SyncConfig",
"TidalPlaylist",
"TidalID",
"SpotifyID",
"SpotifySession",
"TidalSession",
"TidalTrack",
"SpotifyTrack",
]

View File

@@ -0,0 +1,69 @@
from spotipy import Spotify
from typing import TypedDict, List, Dict, Mapping, Literal, Optional
class SpotifyImage(TypedDict):
url: str
height: int
width: int
class SpotifyFollower(TypedDict):
href: str
total: int
SpotifyID = str
SpotifySession = Spotify
class SpotifyArtist(TypedDict):
external_urls: Mapping[str, str]
followers: SpotifyFollower
genres: List[str]
href: str
id: str
images: List[SpotifyImage]
name: str
popularity: int
type: str
uri: str
class SpotifyAlbum(TypedDict):
album_type: Literal["album", "single", "compilation"]
total_tracks: int
available_markets: List[str]
external_urls: Dict[str, str]
href: str
id: str
images: List[SpotifyImage]
name: str
release_date: str
release_date_precision: Literal["year", "month", "day"]
restrictions: Optional[Dict[Literal["reason"], str]]
type: Literal["album"]
uri: str
artists: List[SpotifyArtist]
class SpotifyTrack(TypedDict):
album: SpotifyAlbum
artists: List[SpotifyArtist]
available_markets: List[str]
disc_number: int
duration_ms: int
explicit: bool
external_ids: Dict[str, str]
external_urls: Dict[str, str]
href: str
id: str
is_playable: bool
linked_from: Dict
restrictions: Optional[Dict[Literal["reason"], str]]
name: str
popularity: int
preview_url: str
track_number: int
type: Literal["track"]
uri: str