Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support for playlist management (creation and tracks add/rm/swap). #236

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 118 additions & 3 deletions mopidy_spotify/playlists.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@


class SpotifyPlaylistsProvider(backend.PlaylistsProvider):
# Maximum number of items accepted by the Spotify Web API
_chunk_size = 100

def __init__(self, backend):
self._backend = backend
self._timeout = self._backend._config["spotify"]["timeout"]
Expand Down Expand Up @@ -52,6 +55,35 @@ def _get_playlist(self, uri, as_items=False):
as_items,
)

@staticmethod
def _get_user_and_playlist_id_from_uri(uri):
user_id = uri.split(':')[-3]
playlist_id = uri.split(':')[-1]
return user_id, playlist_id

@staticmethod
def partitions(lst, n):
for i in range(0, len(lst), n):
yield lst[i:i+n]

def _playlist_edit(self, playlist, method, **kwargs):
user_id, playlist_id = self._get_user_and_playlist_id_from_uri(playlist.uri)
url = f'users/{user_id}/playlists/{playlist_id}/tracks'
method = getattr(self._backend._web_client, method.lower())
if not method:
raise AttributeError(f'Invalid HTTP method "{method}"')

logger.debug(f'API request: {method} {url}')
response = method(url, json=kwargs)

logger.debug(f'API response: {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)

def refresh(self):
if not self._backend._web_client.logged_in:
return
Expand All @@ -68,13 +100,96 @@ def refresh(self):
self._loaded = True

def create(self, name):
pass # TODO
logger.info(f'Creating playlist {name}')
url = f'users/{web_client.user_id}/playlists'
response = self._backend._web_client.post(url, json={'name': name})
self.refresh()
return self.lookup(response['uri'])
blacklight marked this conversation as resolved.
Show resolved Hide resolved

def delete(self, uri):
pass # TODO
# Playlist deletion is not implemented in the web API, see
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

# 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(cur_tracks.keys()).difference(set(new_tracks.keys()))

# Remove tracks logic
if removed_uris:
logger.info(f'Removing {len(removed_uris)} tracks from playlist ' +
f'{saved_playlist.name}: {removed_uris}')

for chunk in self.partitions(removed_uris, self._chunk_size):
saved_playlist = self._playlist_edit(saved_playlist, method='delete',
tracks=[{'uri': uri for uri in removed_uris}])
cur_tracks = {track.uri: track for track in saved_playlist.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 {saved_playlist.name}')
processed_tracks = 0

for chunk in self.partitions(uris):
saved_playlist = self._playlist_edit(saved_playlist, method='post',
uris=chunk, position=pos+processed_tracks)

cur_tracks = {track.uri: track for track in saved_playlist.tracks}
processed_tracks += len(chunk)

# 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(f'Moving item position [{cur_pos}] to [{new_pos}] in ' +
f'playlist {saved_playlist.name}')

cur_tracks = {
track.uri: track
for track in self._playlist_edit(
saved_playlist, method='put',
range_start=cur_pos, insert_before=new_pos).tracks
}

# Playlist rename logic
if playlist.name != saved_playlist.name:
logger.info(f'Renaming playlist [{saved_playlist.name}] to [{playlist.name}]')
user_id, playlist_id = self._get_user_and_playlist_id_from_uri(saved_playlist.uri)
self._backend._web_client.put(f'users/{user_id}/playlists/{playlist_id}',
json={'name': playlist.name})

self._backend._web_client.clear_cache()
return self.lookup(saved_playlist.uri)


def playlist_lookup(session, web_client, uri, bitrate, as_items=False):
Expand Down
17 changes: 14 additions & 3 deletions mopidy_spotify/web.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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():
Expand All @@ -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(
Expand All @@ -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

Expand Down