From a604410028905e8f2084b4c0839e80624ae7034c Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Thu, 12 Dec 2019 20:08:02 +0100 Subject: [PATCH] Support for playlist management (creation and tracks add/rm/swap). See #22 (https://github.com/mopidy/mopidy-spotify/issues/22) --- mopidy_spotify/playlists.py | 113 +++++++++++++++++++++++++++++++++++- mopidy_spotify/web.py | 17 +++++- 2 files changed, 124 insertions(+), 6 deletions(-) diff --git a/mopidy_spotify/playlists.py b/mopidy_spotify/playlists.py index 0fc1ea65..da925a88 100644 --- a/mopidy_spotify/playlists.py +++ b/mopidy_spotify/playlists.py @@ -49,6 +49,33 @@ def _get_playlist(self, uri, as_items=False): as_items, ) + def _playlist_edit(self, playlist, method, **kwargs): + user_id = playlist.uri.split(':')[-3] + playlist_id = playlist.uri.split(':')[-1] + url = f'users/{user_id}/playlists/{playlist_id}/tracks' + method = getattr(self._backend._web_client, method.lower()) + if not method: + self.logger.error(f'Invalid HTTP method "{method}"') + return playlist + + logger.debug(f'API request: {method} {url}') + response = method( + url, headers={'Content-Type': 'application/json'}, json=kwargs) + + logger.debug(f'API response: {response}') + + if response and 'error' not in response: + # TODO invalidating the whole cache is probably a bit much if we have + # updated only one playlist - maybe we should expose an API to clear + # cache items by key? + self._backend._web_client.clear_cache() + return self.lookup(playlist.uri) + else: + logging.error('Error on playlist item(s) removal: {}'.format( + response['error'] if response else '(Unknown error)')) + + return playlist + def refresh(self): if not self._backend._web_client.logged_in: return @@ -65,13 +92,93 @@ def refresh(self): self._loaded = True def create(self, name): - pass # TODO + logger.info(f'Creating playlist {name}') + url = f'users/{user_id}/playlists' + response = self._backend._web_client.post( + url, headers={'Content-Type': 'application/json'}) + + return self.lookup(response['uri']) def delete(self, uri): - pass # TODO + # Playlist deletion is not implemented in the web API, see + # https://github.com/spotify/web-api/issues/555 + pass def save(self, playlist): - pass # TODO + # Note that for sake of simplicity the diff calculation between the + # old and new playlist won't take duplicate items into account + # (i.e. tracks that occur multiple times in the same playlist) + saved_playlist = self.lookup(playlist.uri) + if not saved_playlist: + return + + new_tracks = {track.uri: track for track in playlist.tracks} + cur_tracks = {track.uri: track for track in saved_playlist.tracks} + removed_uris = set([track.uri + for track in saved_playlist.tracks + if track.uri not in new_tracks]) + + # Remove tracks logic + if removed_uris: + logger.info('Removing {} tracks from playlist {}: {}'.format( + len(removed_uris), playlist.name, removed_uris)) + + cur_tracks = { + track.uri: track + for track in self._playlist_edit( + playlist, method='delete', + tracks=[{'uri': uri for uri in removed_uris}]).tracks + } + + # Add tracks logic + position = None + added_uris = {} + + for i, track in enumerate(playlist.tracks): + if track.uri not in cur_tracks: + if position is None: + position = i + added_uris[position] = [] + added_uris[position].append(track.uri) + else: + position = None + + if added_uris: + for pos, uris in added_uris.items(): + logger.info(f'Adding {uris} to playlist {playlist.name}') + + cur_tracks = { + track.uri: track + for track in self._playlist_edit( + playlist, method='post', + uris=uris, position=pos).tracks + } + + # Swap tracks logic + cur_tracks_by_uri = {} + + for i, track in enumerate(playlist.tracks): + if i >= len(saved_playlist.tracks): + break + + if track.uri != saved_playlist.tracks[i].uri: + cur_tracks_by_uri[saved_playlist.tracks[i].uri] = i + + if track.uri in cur_tracks_by_uri: + cur_pos = cur_tracks_by_uri[track.uri] + new_pos = i+1 + logger.info('Moving item position [{}] to [{}] in playlist {}'. + format(cur_pos, new_pos, playlist.name)) + + cur_tracks = { + track.uri: track + for track in self._playlist_edit( + playlist, method='put', + range_start=cur_pos, insert_before=new_pos).tracks + } + + self._backend._web_client.clear_cache() + return self.lookup(saved_playlist.uri) def playlist_lookup(session, web_client, uri, bitrate, as_items=False): diff --git a/mopidy_spotify/web.py b/mopidy_spotify/web.py index a4a3545d..bba52471 100644 --- a/mopidy_spotify/web.py +++ b/mopidy_spotify/web.py @@ -65,7 +65,7 @@ def __init__( self._headers = {"Content-Type": "application/json"} self._session = utils.get_requests_session(proxy_config or {}) - def get(self, path, cache=None, *args, **kwargs): + def request(self, method, path, *args, cache=None, **kwargs): if self._authorization_failed: logger.debug("Blocking request as previous authorization failed.") return WebResponse(None, None) @@ -82,7 +82,6 @@ def get(self, path, cache=None, *args, **kwargs): return cached_result kwargs.setdefault("headers", {}).update(cached_result.etag_headers) - # TODO: Factor this out once we add more methods. # TODO: Don't silently error out. try: if self._should_refresh_token(): @@ -93,7 +92,7 @@ def get(self, path, cache=None, *args, **kwargs): # Make sure our headers always override user supplied ones. kwargs.setdefault("headers", {}).update(self._headers) - result = self._request_with_retries("GET", path, *args, **kwargs) + result = self._request_with_retries(method.upper(), path, *args, **kwargs) if result is None or "error" in result: logger.error( @@ -110,6 +109,18 @@ def get(self, path, cache=None, *args, **kwargs): return result + def get(self, path, cache=None, *args, **kwargs): + return self.request('GET', path, cache, *args, **kwargs) + + def post(self, path, *args, **kwargs): + return self.request('POST', path, cache=None, *args, **kwargs) + + def put(self, path, *args, **kwargs): + return self.request('PUT', path, cache=None, *args, **kwargs) + + def delete(self, path, *args, **kwargs): + return self.request('DELETE', path, cache=None, *args, **kwargs) + def _should_cache_response(self, cache, response): return cache is not None and response.status_ok