Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
95c1031
Support for playlist management (creation and tracks add/rm/swap).
blacklight Dec 12, 2019
5333536
Playlist changes support: addressed comments in #236
blacklight Dec 13, 2019
c23eda1
Added support for paging playlist edit calls
blacklight Dec 14, 2019
0acd636
make tests pass
girst Nov 3, 2020
d449225
implement playlist deletion
girst Nov 3, 2020
1dc0e7e
fix SpotifyPlaylistsProvider.partitions() usage
girst Nov 3, 2020
13e98bf
fix creating playlist
girst Nov 3, 2020
2994325
Don't invalidate whole cache on playlist modifications
girst Nov 3, 2020
42a6506
Implement diff algorithm, API calls
girst Nov 6, 2020
ba0a616
Mock up a Spotify API backend that supports editing playlists
girst Nov 7, 2020
46f2935
Write tests for playlist editing operations
girst Nov 7, 2020
e9caa71
Use new short playlist URIs
girst Nov 15, 2020
4ec107c
Keep utils test coverage at 100%
girst Nov 19, 2020
74d405f
remove dead code
girst Nov 19, 2020
d6708ee
Fix flake8 warnings
girst Nov 19, 2020
525f420
Store a backup when saving the playlist fails
girst Nov 20, 2020
e8ce572
Reformat source with black
girst Nov 20, 2020
4adc6f4
prevent modifying other's playlists
girst Nov 20, 2020
4b3a3b4
Disallow adding non-spotify tracks to spotify playlists
girst Nov 21, 2020
468a811
Add some notes about the confusing part of delta_f/t
girst Nov 21, 2020
8af448b
apply review feedback (1)
girst Nov 23, 2020
b3c09d1
use difflib instead of bespoke myers implementation
girst Nov 23, 2020
6fd0a7b
Move Spotify Web API specific stuff to web.py
girst Nov 23, 2020
3ec1c44
apply review feedback (2)
girst Nov 23, 2020
6237b5c
Don't hide unplayable tracks
girst Nov 23, 2020
79317af
Refuse outright if spotify:local tracks are present
girst Nov 29, 2020
609795f
Merge branch 'master' of https://github.com/mopidy/mopidy-spotify int…
girst Dec 6, 2020
47c553f
apply review feedback (3)
girst Jan 2, 2021
f8c69f7
save call to lookup() when creating playlist
girst Jan 2, 2021
fc11efe
save old and new state for every editing failure
girst Jan 4, 2021
0d596ed
optimize MOV finding a bit
girst Jan 4, 2021
400060e
more tests for check_playable
girst Jan 4, 2021
50f1ab3
Merge branch 'master' of https://github.com/mopidy/mopidy-spotify int…
girst Jan 10, 2021
1c0358a
use long-form of "me/playlists" for new editing endpoints
girst Jan 10, 2021
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
2 changes: 2 additions & 0 deletions mopidy_spotify/browse.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,8 @@ def _browse_your_music(web_client, variant):
]
)
if variant == "tracks":
# TODO: without check_playable=False this will hide unplayable
# tracks; this will bite us when implementing library editing.
return list(translator.web_to_track_refs(items))
else:
return list(translator.web_to_album_refs(items))
Expand Down
230 changes: 224 additions & 6 deletions mopidy_spotify/playlists.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,21 @@
import logging
import math
import time

from mopidy import backend

import spotify
from mopidy_spotify import translator, utils
from mopidy_spotify import translator, utils, web, Extension

_sp_links = {}

logger = logging.getLogger(__name__)


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 @@ -40,13 +45,89 @@ def lookup(self, uri):
with utils.time_logger(f"playlists.lookup({uri!r})", logging.DEBUG):
return self._get_playlist(uri)

def _get_playlist(self, uri, as_items=False):
def _get_playlist(self, uri, as_items=False, with_owner=False):
return playlist_lookup(
self._backend._session,
self._backend._web_client,
uri,
self._backend._bitrate,
as_items,
with_owner,
)

@staticmethod
def _split_ended_movs(value, movs):
def _span(p, xs):
# Returns a tuple where first element is the longest prefix
# (possibly empty) of list xs of elements that satisfy predicate p
# and second element is the remainder of the list.
i = next((i for i, v in enumerate(xs) if not p(v)), len(xs))
return xs[:i], xs[i:]

return _span(lambda e: e[0] < value, movs)
Comment thread
girst marked this conversation as resolved.

def _patch_playlist(self, playlist, operations):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think this belongs in web. I think of it as: "if we ripped out web into a stand-alone module, would this be useful to include?".

In fact, I think it'd also want the second half of save() (and then get rid of _replace_playlist() since it's serving little purpose).

# Note: We need two distinct delta_f/t to be able to keep track of move
# operations. This is because when moving multiple (distinct) sections
# so their old and new positions overlap, one bound can be inside the
# range and the other outide. Then, only the inside bound must add
# delta_f/t, while the outside one must not.
delta_f = 0
delta_t = 0
unwind_f = []
unwind_t = []
Comment thread
girst marked this conversation as resolved.
for op in operations:
# from the list of "active" mov-deltas, split off the ones newly
# outside the range and neutralize them:
ended_ranges_f, unwind_f = self._split_ended_movs(op.frm, unwind_f)
ended_ranges_t, unwind_t = self._split_ended_movs(op.to, unwind_t)
delta_f -= sum((amount for _, amount in ended_ranges_f))
delta_t -= sum((amount for _, amount in ended_ranges_t))

length = len(op.tracks)
if op.op == "-":
web.remove_tracks_from_playlist(
self._backend._web_client,
playlist,
op.tracks,
op.frm + delta_f,
)
delta_f -= length
delta_t -= length
elif op.op == "+":
web.add_tracks_to_playlist(
self._backend._web_client,
playlist,
op.tracks,
op.frm + delta_f,
)
delta_f += length
delta_t += length
elif op.op == "m":
web.move_tracks_in_playlist(
self._backend._web_client,
playlist,
range_start=op.frm + delta_f,
insert_before=op.to + delta_t,
range_length=length,
)
# if we move right, the delta is active in the range (op.frm, op.to),
# when we move left, it's in the range (op.to, op.frm+length)
position = op.to if op.frm < op.to else op.frm + length
amount = length * (-1 if op.frm < op.to else 1)
# While add/del deltas will be active for the rest of the
# playlist, mov deltas only affect the range of tracks between
# their old end new positions. We must undo them once we get
# outside this range, so we store the position at which point
# to subtract the amount again.
unwind_f.append((position, amount))
unwind_t.append((position, amount))
delta_f += amount
delta_t += amount

def _replace_playlist(self, playlist, tracks):
web.replace_playlist(
self._backend._web_client, playlist, tracks, self._chunk_size
)

def refresh(self):
Expand All @@ -65,16 +146,147 @@ def refresh(self):
self._loaded = True

def create(self, name):
pass # TODO
if not name:
return None
web_playlist = web.create_playlist(self._backend._web_client, name)
if web_playlist is None:
logger.error(f"Failed to create Spotify playlist '{name}'")
return
logger.info(f"Created Spotify playlist '{name}'")
return translator.to_playlist(
web_playlist,
username=self._backend._web_client.user_id,
bitrate=self._backend._bitrate,
# Note: we are not filtering out (currently) unplayable tracks;
# otherwise they would be removed when editing the playlist.
check_playable=False,
)

def delete(self, uri):
pass # TODO
logger.info(f"Deleting Spotify playlist {uri!r}")
ok = web.delete_playlist(self._backend._web_client, uri)
return ok

@staticmethod
def _len_replace(playlist_tracks, n=_chunk_size):
return math.ceil(len(playlist_tracks) / n)

@staticmethod
def _is_spotify_track(track_uri):
try:
return web.WebLink.from_uri(track_uri).type == web.LinkType.TRACK
except ValueError:
return False # not a valid spotify URI

@staticmethod
def _is_spotify_local(track_uri):
return track_uri.startswith("spotify:local:")

def save(self, playlist):
pass # TODO
saved_playlist = self._get_playlist(playlist.uri, with_owner=True)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Arguably worth pulling out first half of playlist_lookup into a separate function which always returns a tuple of (Playlist, owner). We could then avoid doing all the pointless libspotify stuff in our case here. And avoid this optional extra_owner param.

if not saved_playlist:
return

saved_playlist, owner = saved_playlist
# We limit playlist editing to the user's own playlists, since mopidy
# mangles the names of other people's playlists.
if owner and owner != self._backend._web_client.user_id:
logger.error(
f"Cannot modify Spotify playlist {playlist.uri!r} owned by "
f"other user {owner}"
)
return

# We cannot add or (easily) remove spotify:local: tracks, so refuse
# editing if the current playlist contains such tracks.
if any((self._is_spotify_local(t.uri) for t in saved_playlist.tracks)):
logger.error(
"Cannot modify Spotify playlist containing Spotify 'local files'."
)
for t in saved_playlist.tracks:
if t.uri.startswith("spotify:local:"):
logger.debug(
f"Unsupported Spotify local file: '{t.name}' ({t.uri!r})"
)
return

new_tracks = [track.uri for track in playlist.tracks]
cur_tracks = [track.uri for track in saved_playlist.tracks]

if any((not self._is_spotify_track(t) for t in new_tracks)):
new_tracks = list(filter(self._is_spotify_track, new_tracks))
logger.warning(
f"Skipping adding non-Spotify tracks to Spotify playlist "
f"{playlist.uri!r}"
)

operations = utils.diff(cur_tracks, new_tracks, self._chunk_size)

# calculate number of operations required for each strategy:
ops_patch = len(operations)
ops_replace = self._len_replace(new_tracks)

try:
if ops_replace < ops_patch:
self._replace_playlist(saved_playlist, new_tracks)
else:
self._patch_playlist(saved_playlist, operations)
except web.OAuthClientError as e:
logger.error(f"Failed to save Spotify playlist: {e}")
# In the unlikely event that we used the replace strategy, and the
# first PUT went through but the following POSTs didn't, we have
# truncated the playlist. At this point, we still have the playlist
# data available, so we write it to an m3u file as a last resort
# effort for the user to recover from.
# We'll save a backup of both the old and the new state,
# independent of which strategy was used, though, just to be safe.
filename = self.create_backup(saved_playlist, "old")
logger.error(f'Created backup of old state in "{filename}"')
filename = self.create_backup(playlist, "new")
logger.error(f'Created backup of new state in "{filename}"')
return None

if playlist.name and playlist.name != saved_playlist.name:
try:
web.rename_playlist(
self._backend._web_client, playlist.uri, playlist.name
)
logger.info(
f"Renamed Spotify playlist '{saved_playlist.name}' to "
f"'{playlist.name}'"
)
except web.OAuthClientError as e:
logger.error(
f"Renaming Spotify playlist '{saved_playlist.name}'"
f"to '{playlist.name}' failed with error {e}"
)

return self.lookup(saved_playlist.uri)

def create_backup(self, playlist, extra):
safe_name = playlist.name.translate(
str.maketrans(" @`!\"#$%&'()*+;[{<\\|]}>^~/?", "_" * 27)
)
filename = (
Extension.get_data_dir(self._backend._config)
/ f"{safe_name}-{playlist.uri}-{extra}-{time.time()}.m3u8"
)
with filename.open("w") as f:
f.write("#EXTM3U\n#EXTENC: UTF-8\n\n")
for track in playlist.tracks:
length = int(track.length / 1000)
artists = ", ".join(a.name for a in track.artists)
f.write(
f"#EXTINF:{length},{artists} - {track.name}\n"
f"{track.uri}\n\n"
)

return str(filename)


def playlist_lookup(session, web_client, uri, bitrate, as_items=False):
def playlist_lookup(
session, web_client, uri, bitrate, as_items=False, with_owner=False
):
if web_client is None or not web_client.logged_in:
return

Expand All @@ -90,6 +302,9 @@ def playlist_lookup(session, web_client, uri, bitrate, as_items=False):
username=web_client.user_id,
bitrate=bitrate,
as_items=as_items,
# Note: we are not filtering out (currently) unplayable tracks;
# otherwise they would be removed when editing the playlist.
check_playable=False,
)
if playlist is None:
return
Expand All @@ -109,4 +324,7 @@ def playlist_lookup(session, web_client, uri, bitrate, as_items=False):
except ValueError as exc:
logger.info(f"Failed to get link {track.uri!r}: {exc}")

if with_owner:
owner = web_playlist.get("owner", {}).get("id")
return playlist, owner
return playlist
24 changes: 17 additions & 7 deletions mopidy_spotify/translator.py
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@ def to_playlist(
bitrate=None,
as_ref=False,
as_items=False,
check_playable=True,
Comment thread
girst marked this conversation as resolved.
):
ref = to_playlist_ref(web_playlist, username)
if ref is None or as_ref:
Expand All @@ -224,12 +225,18 @@ def to_playlist(
return

if as_items:
return list(web_to_track_refs(web_tracks))
return list(
web_to_track_refs(web_tracks, check_playable=check_playable)
)

tracks = [
web_to_track(web_track.get("track", {}), bitrate=bitrate)
tracks = (
web_to_track(
web_track.get("track", {}),
bitrate=bitrate,
check_playable=check_playable,
)
for web_track in web_tracks
]
)
tracks = [t for t in tracks if t]

return models.Playlist(uri=ref.uri, name=ref.name, tracks=tracks)
Expand Down Expand Up @@ -315,11 +322,14 @@ def web_to_album(web_album):
]
artists = [a for a in artists if a]

return models.Album(uri=ref.uri, name=ref.name, artists=artists)
# Note: date can by YYYY-MM-DD, YYYY-MM or YYYY.
date = web_album.get("release_date", "").split("-")[0] or None
Comment thread
kingosticks marked this conversation as resolved.
Comment thread
girst marked this conversation as resolved.

return models.Album(uri=ref.uri, name=ref.name, artists=artists, date=date)


def web_to_track(web_track, bitrate=None):
ref = web_to_track_ref(web_track)
def web_to_track(web_track, bitrate=None, *, check_playable=True):
ref = web_to_track_ref(web_track, check_playable=check_playable)
if ref is None:
return

Expand Down
Loading