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 1 commit
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
113 changes: 110 additions & 3 deletions mopidy_spotify/playlists.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,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}"')
blacklight marked this conversation as resolved.
Show resolved Hide resolved
return playlist
blacklight marked this conversation as resolved.
Show resolved Hide resolved

logger.debug(f'API request: {method} {url}')
response = method(
url, headers={'Content-Type': 'application/json'}, json=kwargs)
blacklight marked this conversation as resolved.
Show resolved Hide resolved

logger.debug(f'API response: {response}')

if response and 'error' not in response:
blacklight marked this conversation as resolved.
Show resolved Hide resolved
# 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
Expand All @@ -68,13 +95,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(
blacklight marked this conversation as resolved.
Show resolved Hide resolved
url, headers={'Content-Type': 'application/json'})
blacklight marked this conversation as resolved.
Show resolved Hide resolved

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([track.uri
blacklight marked this conversation as resolved.
Show resolved Hide resolved
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(
blacklight marked this conversation as resolved.
Show resolved Hide resolved
len(removed_uris), playlist.name, removed_uris))
blacklight marked this conversation as resolved.
Show resolved Hide resolved

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):
Expand Down
19 changes: 16 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 {}
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,9 +92,11 @@ 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('Spotify web API call failed: {} {}: {}'.format(
method.upper(), path, result))
return {}

if self._should_cache_response(cache, result):
Expand All @@ -106,6 +107,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