From d607018ff21fc0f6ef2a2a312926780b19b845cf Mon Sep 17 00:00:00 2001 From: sacklippe Date: Thu, 4 Jul 2024 14:44:54 +0200 Subject: [PATCH 01/11] refactor: set added date to default None --- musync/entity/track.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/musync/entity/track.py b/musync/entity/track.py index 833cff9..7b7ea49 100644 --- a/musync/entity/track.py +++ b/musync/entity/track.py @@ -18,7 +18,7 @@ class Track: artist_ids: list[str] name: str duration: timedelta - date_added: Optional[dt] # relates to playlist, requires better structure + date_added: Optional[dt] = None # relates to playlist, requires better structure origin: Origin = Origin.UNKNOWN @classmethod From 11af2480aa5ef11de948820721dbddf38e6ee55c Mon Sep 17 00:00:00 2001 From: sacklippe Date: Thu, 4 Jul 2024 14:46:16 +0200 Subject: [PATCH 02/11] feat: add custom error for missing priviliges and incompatible entities --- musync/error.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/musync/error.py b/musync/error.py index 5848105..5d03f98 100644 --- a/musync/error.py +++ b/musync/error.py @@ -1,2 +1,10 @@ class PlaylistNotFoundError(Exception): """Raised when a playlist is not found in the database.""" + + +class MissingPrivilegesError(Exception): + """Raised when a user does not have the necessary privileges.""" + + +class IncompatibleEntityError(Exception): + """Raised when an entity is not compatible with the operation.""" From d5ef4cbcbb457d86a1111f4ceaca9c851f89eb5c Mon Sep 17 00:00:00 2001 From: sacklippe Date: Thu, 4 Jul 2024 14:46:52 +0200 Subject: [PATCH 03/11] refactor: add abstract method to add track to playlist --- musync/session.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/musync/session.py b/musync/session.py index 55af16d..1205eff 100644 --- a/musync/session.py +++ b/musync/session.py @@ -1,4 +1,5 @@ from abc import ABC, abstractmethod +from typing import Iterable import spotipy import tidalapi @@ -29,3 +30,7 @@ def load_playlist_tracks(self, playlist: Playlist) -> list[Track]: @abstractmethod def find_track(self, track: Track) -> Track | None: pass + + @abstractmethod + def add_to_playlist(self, playlist: Playlist, tracks: Iterable[Track]) -> None: + pass From 99e13ad7fe4dcf078f08af8979273728d3c2e57a Mon Sep 17 00:00:00 2001 From: sacklippe Date: Thu, 4 Jul 2024 14:48:29 +0200 Subject: [PATCH 04/11] fix: artist id expects a string value instead of an integer --- musync/tidal/session.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/musync/tidal/session.py b/musync/tidal/session.py index 8079199..77ad42b 100644 --- a/musync/tidal/session.py +++ b/musync/tidal/session.py @@ -44,7 +44,7 @@ def find_track(self, track: Track) -> Track | None: return None def load_artist(self, artist_id: str) -> Artist | None: - artist = self._client.artist(int(artist_id)) + artist = self._client.artist(artist_id) if not isinstance(artist, tidalapi.Artist): return None From b5397a02a60d3fe370e5c9251766d861a42634de Mon Sep 17 00:00:00 2001 From: sacklippe Date: Thu, 4 Jul 2024 14:49:05 +0200 Subject: [PATCH 05/11] feat: add tracks to playlist --- musync/tidal/session.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/musync/tidal/session.py b/musync/tidal/session.py index 77ad42b..1a54779 100644 --- a/musync/tidal/session.py +++ b/musync/tidal/session.py @@ -1,8 +1,10 @@ from pathlib import Path +from typing import Iterable import tidalapi -from musync.entity import Artist, Playlist, Track, User +from musync.entity import Artist, Origin, Playlist, Track, User +from musync.error import IncompatibleEntityError, MissingPrivilegesError from musync.session import Session TIDAL_DIR = Path(__file__).parent.parent.parent.resolve() @@ -49,3 +51,21 @@ def load_artist(self, artist_id: str) -> Artist | None: return None return Artist.from_tidal(artist) + + def add_to_playlist(self, playlist: Playlist, tracks: Iterable[Track]) -> None: + if playlist.origin != Origin.TIDAL: + raise IncompatibleEntityError(f"Playlist is not from Tidal ({playlist=}).") + + if not all(tr.origin == Origin.TIDAL for tr in tracks): + raise IncompatibleEntityError( + f"The tracks contain tracks that are not from Tidal ({tracks=})." + ) + + if self.user.user_id != playlist.owner_id: + raise MissingPrivilegesError( + f"The session user does not own the playlist ({playlist=})." + ) + + track_ids = [tr.track_id for tr in tracks] + tidal_playlist = self._client.playlist(playlist.playlist_id) + tidal_playlist.add(track_ids) From 373eb43784fa23a96dd5affb55447ce853ee9dc6 Mon Sep 17 00:00:00 2001 From: sacklippe Date: Thu, 4 Jul 2024 14:50:00 +0200 Subject: [PATCH 06/11] test: integration test for adding to tidal playlist --- tests/integrationtests/tidal/test_session.py | 61 +++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/tests/integrationtests/tidal/test_session.py b/tests/integrationtests/tidal/test_session.py index 1a60d14..a9754a0 100644 --- a/tests/integrationtests/tidal/test_session.py +++ b/tests/integrationtests/tidal/test_session.py @@ -1,8 +1,9 @@ +import datetime import pickle import pytest -from musync.entity import Playlist, Track, User +from musync.entity import Origin, Playlist, Track, User from musync.tidal import TidalSession from tests.unittests.entity import DATA_DIR @@ -18,6 +19,52 @@ def spotify_track(): return Track.from_spotify(pickle.load(f)) +@pytest.fixture +def tidal_track(): + with open(DATA_DIR / "test_track_tidal.pkl", "rb") as f: + return Track.from_tidal(pickle.load(f)) + + +@pytest.fixture +def tidal_track_list(): + tracks = [ + Track( + track_id="338383176", + artist_ids=["7343085"], + name="Cabalero", + duration=datetime.timedelta(seconds=345), + origin=Origin.TIDAL, + ), + Track( + track_id="304574491", + artist_ids=["10644748", "6576000"], + name="Tension", + duration=datetime.timedelta(seconds=263), + origin=Origin.TIDAL, + ), + Track( + track_id="121277192", + artist_ids=["15910495"], + name="Overdub (Original Mix)", + duration=datetime.timedelta(seconds=348), + origin=Origin.TIDAL, + ), + ] + return tracks + + +@pytest.fixture +def create_playlist(tidal_session): + title = "Test Playlist" + description = "This playlist was created by https://github.com/klepp0/musync" + new_tidal_playlist = tidal_session._client.user.create_playlist(title, description) + new_playlist = Playlist.from_tidal(new_tidal_playlist) + + yield new_playlist + + tidal_session._client.playlist(new_playlist.playlist_id).delete() + + def test_session_is_logged_in(tidal_session): assert tidal_session.check_login() @@ -33,3 +80,15 @@ def test_session_load_playlists(tidal_session): def test_search_track(tidal_session, spotify_track): tidal_track = tidal_session.find_track(spotify_track) assert spotify_track.equals(tidal_track) + + +def test_add_to_playlist(tidal_session, create_playlist, tidal_track_list): + playlist = create_playlist + n_tracks_before = playlist.n_tracks + tidal_session.add_to_playlist(playlist, tidal_track_list) + + updated_tidal_playlist = tidal_session._client.playlist(playlist.playlist_id) + updated_playlist = Playlist.from_tidal(updated_tidal_playlist) + n_tracks_after = updated_playlist.n_tracks + + assert n_tracks_before < n_tracks_after From d3a52fbdd27cbda5b0bdd3e9b94e16e9471726a1 Mon Sep 17 00:00:00 2001 From: sacklippe Date: Thu, 4 Jul 2024 14:51:05 +0200 Subject: [PATCH 07/11] refactor: compare tracks with new equals method --- musync/sync_manager.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/musync/sync_manager.py b/musync/sync_manager.py index 883563c..9e14206 100644 --- a/musync/sync_manager.py +++ b/musync/sync_manager.py @@ -45,6 +45,4 @@ def get_missing_tracks( "Invalid playlist" ) # TODO: Create more expressive custom error - dest_track_names = [t.name for t in dest_tracks] - - return [t for t in src_tracks if t.name not in dest_track_names] + return [st for st in src_tracks if all(not st.equals(dt) for dt in dest_tracks)] From bd7d9b0a27936fb4b363b302b3aeb5b404c8eb73 Mon Sep 17 00:00:00 2001 From: sacklippe Date: Thu, 4 Jul 2024 16:39:59 +0200 Subject: [PATCH 08/11] feat: add tracks to playlist --- musync/spotify/session.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/musync/spotify/session.py b/musync/spotify/session.py index a78316b..c31f81f 100644 --- a/musync/spotify/session.py +++ b/musync/spotify/session.py @@ -1,9 +1,10 @@ import os +from typing import Iterable import spotipy from dotenv import load_dotenv -from musync.entity import Artist, Playlist, Track, User +from musync.entity import Artist, Origin, Playlist, Track, User from musync.session import Session load_dotenv() @@ -88,3 +89,22 @@ def load_artist(self, artist_id: str) -> Artist | None: return Artist.from_spotify(artist) return None + + def add_to_playlist(self, playlist: Playlist, tracks: Iterable[Track]) -> None: + if playlist.origin != Origin.SPOTIFY: + raise ValueError(f"Playlist is not from Spotify ({playlist=}).") + + if not all(tr.origin == Origin.SPOTIFY for tr in tracks): + raise ValueError( + f"The tracks contain tracks that are not from Spotify ({tracks=})." + ) + + if self.user.user_id != playlist.owner_id: + raise ValueError( + f"The session user does not own the playlist ({playlist=})." + ) + + user_id = self.user.user_id + playlist_id = playlist.playlist_id + track_ids = [tr.track_id for tr in tracks] + self._client.user_playlist_add_tracks(user_id, playlist_id, track_ids) From bc238488fe6b121ac61f269b007b12793b0bca65 Mon Sep 17 00:00:00 2001 From: sacklippe Date: Thu, 4 Jul 2024 16:40:31 +0200 Subject: [PATCH 09/11] test: add tracks to playlist --- .../integrationtests/spotify/test_session.py | 66 ++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/tests/integrationtests/spotify/test_session.py b/tests/integrationtests/spotify/test_session.py index 3b7b256..e4878bc 100644 --- a/tests/integrationtests/spotify/test_session.py +++ b/tests/integrationtests/spotify/test_session.py @@ -1,8 +1,9 @@ +import datetime import pickle import pytest -from musync.entity import Playlist, Track, User +from musync.entity import Origin, Playlist, Track, User from musync.spotify import SpotifySession from tests.unittests.entity import DATA_DIR @@ -18,6 +19,57 @@ def tidal_track(): return Track.from_tidal(pickle.load(f)) +@pytest.fixture +def spotify_track_list(): + tracks = [ + Track( + track_id="2Wqw8VqMlpNYZTfIsRqYSA", + artist_ids=["15kBh7iMF03XANu9pcSAdN", "5MUtPjZ8UJxONYzEGZeArf"], + name="Hymnesia", + duration=datetime.timedelta(seconds=374, microseconds=345000), + origin=Origin.SPOTIFY, + ), + Track( + track_id="1uRFqDldISYmGJAESdoi75", + artist_ids=["11OUxHFoGgo2NDSdT6YiEC"], + name="Closer", + duration=datetime.timedelta(seconds=245, microseconds=277000), + origin=Origin.SPOTIFY, + ), + Track( + track_id="3cefg1pboKTNqgi3k2t62h", + artist_ids=["1khyIydqanugacJyKdmceT"], + name="Omnia", + duration=datetime.timedelta(seconds=393, microseconds=24000), + origin=Origin.SPOTIFY, + ), + ] + + return tracks + + +@pytest.fixture +def create_playlist(spotify_session): + title = "Test Playlist" + description = ( + "This playlist was created by the https://github.com/klepp0/musync project." + ) + user_id = spotify_session.user.user_id + spotify_response = spotify_session._client.user_playlist_create( + user_id, + title, + public=False, + collaborative=False, + description=description, + ) + new_playlist = Playlist.from_spotify(spotify_response) + + yield new_playlist + + new_playlist_id = new_playlist.playlist_id + spotify_session._client.current_user_unfollow_playlist(new_playlist_id) + + def test_session_is_logged_in(spotify_session): return spotify_session.check_login() @@ -33,3 +85,15 @@ def test_session_playlists(spotify_session): def test_find_track(spotify_session, tidal_track): spotify_track = spotify_session.find_track(tidal_track) assert tidal_track.equals(spotify_track) + + +def test_add_to_playlist(spotify_session, create_playlist, spotify_track_list): + playlist = create_playlist + n_tracks_before = playlist.n_tracks + spotify_session.add_to_playlist(playlist, spotify_track_list) + + spotify_response = spotify_session._client.playlist(playlist.playlist_id) + updated_playlist = Playlist.from_spotify(spotify_response) + n_tracks_after = updated_playlist.n_tracks + + assert n_tracks_before < n_tracks_after From 9bf974ff3c8100a81a94add30ac4f15c26530df7 Mon Sep 17 00:00:00 2001 From: sacklippe Date: Thu, 4 Jul 2024 16:41:39 +0200 Subject: [PATCH 10/11] refactor: increase variable expressivness --- tests/integrationtests/tidal/test_session.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/tests/integrationtests/tidal/test_session.py b/tests/integrationtests/tidal/test_session.py index a9754a0..aac807b 100644 --- a/tests/integrationtests/tidal/test_session.py +++ b/tests/integrationtests/tidal/test_session.py @@ -56,9 +56,11 @@ def tidal_track_list(): @pytest.fixture def create_playlist(tidal_session): title = "Test Playlist" - description = "This playlist was created by https://github.com/klepp0/musync" - new_tidal_playlist = tidal_session._client.user.create_playlist(title, description) - new_playlist = Playlist.from_tidal(new_tidal_playlist) + description = ( + "This playlist was created by the https://github.com/klepp0/musync project." + ) + tidal_response = tidal_session._client.user.create_playlist(title, description) + new_playlist = Playlist.from_tidal(tidal_response) yield new_playlist @@ -87,8 +89,8 @@ def test_add_to_playlist(tidal_session, create_playlist, tidal_track_list): n_tracks_before = playlist.n_tracks tidal_session.add_to_playlist(playlist, tidal_track_list) - updated_tidal_playlist = tidal_session._client.playlist(playlist.playlist_id) - updated_playlist = Playlist.from_tidal(updated_tidal_playlist) + tidal_response = tidal_session._client.playlist(playlist.playlist_id) + updated_playlist = Playlist.from_tidal(tidal_response) n_tracks_after = updated_playlist.n_tracks assert n_tracks_before < n_tracks_after From caff18bf52fffa5b7c2b57a74a5983c582c3c9a9 Mon Sep 17 00:00:00 2001 From: sacklippe Date: Thu, 4 Jul 2024 19:19:53 +0200 Subject: [PATCH 11/11] feat: sync common playlists --- musync/error.py | 4 ++ musync/sync_manager.py | 83 ++++++++++++++++++++++++++++++++++++------ 2 files changed, 75 insertions(+), 12 deletions(-) diff --git a/musync/error.py b/musync/error.py index 5d03f98..29aa5fd 100644 --- a/musync/error.py +++ b/musync/error.py @@ -8,3 +8,7 @@ class MissingPrivilegesError(Exception): class IncompatibleEntityError(Exception): """Raised when an entity is not compatible with the operation.""" + + +class TrackNotFoundWarning(Warning): + """Raised when a track is not found in the database.""" diff --git a/musync/sync_manager.py b/musync/sync_manager.py index 9e14206..3e7837d 100644 --- a/musync/sync_manager.py +++ b/musync/sync_manager.py @@ -1,4 +1,7 @@ +import warnings + from musync.entity import Playlist, Track +from musync.error import MissingPrivilegesError, TrackNotFoundWarning from musync.spotify import SpotifySession from musync.tidal import TidalSession @@ -14,7 +17,7 @@ def __init__(self, session1: SessionType, session2: SessionType) -> None: self._session1 = session1 self._session2 = session2 - def get_common_playlists(self) -> list[Playlist]: + def get_common_playlists(self) -> list[tuple[Playlist, Playlist]]: playlists1 = self._session1.load_playlists() playlists2 = self._session2.load_playlists() common_playlists = [] @@ -28,21 +31,77 @@ def get_common_playlists(self) -> list[Playlist]: def get_missing_tracks( self, src_playlist: Playlist, dest_playlist: Playlist ) -> list[Track]: + if src_playlist.origin == self._session1.user.origin: + src_session = self._session1 + elif src_playlist.origin == self._session2.user.origin: + src_session = self._session2 + else: + raise MissingPrivilegesError( + f"The user does not have access to the source playlist ({src_playlist=})" + ) + if ( - self._session1.user.origin == dest_playlist.origin - and self._session1.user.user_id == dest_playlist.owner_id + dest_playlist.origin == self._session1.user.origin + and dest_playlist.owner_id == self._session1.user.user_id ): - src_tracks = self._session1.load_playlist_tracks(dest_playlist) - dest_tracks = self._session2.load_playlist_tracks(src_playlist) + dest_session = self._session1 elif ( - self._session2.user.user_id == dest_playlist.owner_id - and self._session2.user.origin == dest_playlist.origin + dest_playlist.origin == self._session2.user.origin + and dest_playlist.owner_id == self._session2.user.user_id ): - src_tracks = self._session2.load_playlist_tracks(dest_playlist) - dest_tracks = self._session1.load_playlist_tracks(src_playlist) + dest_session = self._session2 else: - raise ValueError( - "Invalid playlist" - ) # TODO: Create more expressive custom error + raise MissingPrivilegesError( + f"The user does not have access to the destination playlist ({dest_playlist=})" + ) + + src_tracks = src_session.load_playlist_tracks(src_playlist) + dest_tracks = dest_session.load_playlist_tracks(dest_playlist) return [st for st in src_tracks if all(not st.equals(dt) for dt in dest_tracks)] + + def sync_common_playlists(self) -> None: + common_playlists = self.get_common_playlists() + + for src_playlist, dest_playlist in common_playlists: + if dest_playlist.owner_id == self._session2.user.user_id: + self.sync_playlists(src_playlist, dest_playlist) + + for dest_playlist, src_playlist in common_playlists: + if dest_playlist.owner_id == self._session1.user.user_id: + self.sync_playlists(src_playlist, dest_playlist) + + def sync_playlists(self, src_playlist, dest_playlist) -> None: + if ( + dest_playlist.origin == self._session1.user.origin + and dest_playlist.owner_id == self._session1.user.user_id + ): + dest_session = self._session1 + elif ( + dest_playlist.origin == self._session2.user.origin + and dest_playlist.owner_id == self._session2.user.user_id + ): + dest_session = self._session2 + else: + raise MissingPrivilegesError( + f"The user does not have access to the destination playlist ({dest_playlist=})" + ) + + missing_tracks_src = self.get_missing_tracks(src_playlist, dest_playlist) + missing_tracks_dest = [] + for src_track in missing_tracks_src: + if src_track.origin == dest_session.user.origin: + dest_track = src_track + else: + dest_track = dest_session.find_track(src_track) + + if dest_track is None: + warnings.warn( + f"Track {src_track} not found in {dest_session}", + TrackNotFoundWarning, + ) + else: + missing_tracks_dest.append(dest_track) + + if len(missing_tracks_dest) > 0: + dest_session.add_to_playlist(dest_playlist, missing_tracks_dest)