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 (v2) #287

Open
wants to merge 34 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 27 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
195 changes: 189 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,87 @@ 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 _span(p, xs): # like haskell's Data.List.span
i = next((i for i, v in enumerate(xs) if not p(v)), len(xs))
return xs[:i], xs[i:]

def _patch_playlist(self, playlist, operations):
Copy link
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 = []
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._span(
lambda e: e[0] < op.frm, unwind_f
)
ended_ranges_t, unwind_t = self._span(
lambda e: e[0] < op.to, unwind_t
)
delta_f -= sum((v for k, v in ended_ranges_f))
delta_t -= sum((v for k, v 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 +144,114 @@ def refresh(self):
self._loaded = True

def create(self, name):
pass # TODO
logger.info(f"Creating playlist {name}")
uri = web.create_playlist(self._backend._web_client, name)
self._get_flattened_playlist_refs()
return self.lookup(uri) if uri else None

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

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

@staticmethod
def _is_spotify_track(track_uri):
return track_uri.startswith("spotify:track:")

def save(self, playlist):
pass # TODO
saved_playlist = self._get_playlist(playlist.uri, with_owner=True)
Copy link
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"Refusing to modify someone else's playlist ({playlist.uri})"
)
return

# We cannot add or (easily) remove spotify:local: tracks, so refuse
# editing if the current playlist contains such tracks.
if any(
(t.uri.startswith("spotify:local:") for t in saved_playlist.tracks)
):
logger.error(
"Cannot modify playlist containing 'Spotify Local Files'."
)
for t in saved_playlist.tracks:
if t.uri.startswith("spotify:local:"):
logger.error(f"{t.name} ({t.uri})")
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(
"Cannot add non-spotify tracks to spotify playlist; skipping those."
)

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(playlist)

try:
if ops_replace < ops_patch:
self._replace_playlist(saved_playlist, new_tracks)
else:
self._patch_playlist(saved_playlist, operations)
except RuntimeError 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.
if ops_replace < ops_patch:
Copy link
Member

Choose a reason for hiding this comment

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

Why does it only get used in the replace strategy case? And can't we do this for both playlists?

I agree a backup mechanism is nice and I appreciate this, combined with the M3U backend, provides a simple mechanism to take another shot at saving the new playlist. But this is a little complicated considering it's reimplementing parts of Mopidy-M3U (why do we need length and artists? Should use Path. Why binary mode? Should be ".m3u8") and still needs a manual step to move the backup so Mopidy-M3U can find it. Could we consider something simpler? Maybe just dump (pickle?) both saved_playlist and playlist to disk? And then potentially adding a Spotify playlist restoration Command later?

Whatever is decided, it should be pulled out into its own function.

Copy link
Member Author

Choose a reason for hiding this comment

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

Why does it only get used in the replace strategy case?

because truncation can only happen when we use the replace strategy. i don't think its important to make a backup when patching the playlist. in the latter case, the user will see an error that their change failed, and the playlist is unmodified. but when using replace (and the very specific situation in the code comment happens; see last paragraph), the old playlist is clobbered, and we back it up in that case.

And can't we do this for both playlists?

not sure what 'both'/other playlist you mean. the new state? this feature was only intended to avoid permanent data loss of previously existing data, hence i didn't implement saving the new state. (but see last big paragraph for more info)

(why do we need length and artists?

afaik, m3u-ext requires the length, and filling it in doesn't cost us anything. artist (and the whole extm3u block itself) were added to make the file human-parsable to an extent in this emergency situation.

Should use Path.

i think i am with Extension.get_data_dir()?

Why binary mode? Should be ".m3u8"

i believe a remnant of a previous iteration. will change.

and still needs a manual step to move the backup so Mopidy-M3U can find it. Could we consider something simpler? Maybe just dump (pickle?) both saved_playlist and playlist to disk? And then potentially adding a Spotify playlist restoration Command later?

i believe he likeliness of this code ever being called is extremely low. for it to trigger*, we first have to make a successful PUT request, and then fail at a later POST request to the same endpoint, with the same authentication scope and the same parameters. and in normal use, replace mode will not be used at all, since no client supports batching multiple edit operations anyways (for a single edit, patch mode will always be used, since it's safer and requires the same amount of api requests). this is not something the user should have to deal with on a regular basis, so 'comfort features' are not included. consider this feature due diligence.

* i'm actually creating the backup file not just when we truncate the playlist, but everytime something goes wrong with replace mode, since number of requests made is not available at this level. given that the code is pretty much never going to get called anyways, i didn't bother tightening down the check.

Copy link
Member

@kingosticks kingosticks Jan 3, 2021

Choose a reason for hiding this comment

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

because truncation can only happen when we use the replace strategy.

OK, truncation is one class of issue but isn't data loss possible in either strategy? If a replacement is required and the remove operation succeeds but the corresponding add operation fails, won't that still leave you with a half modified playlist which is neither what we had or what we wanted? A backup of playlist in both cases would at least always leave you with a copy of the requested new state somewhere. Am I missing something?

not sure what 'both'/other playlist you mean. the new state? this feature was only intended to avoid permanent data loss of previously existing data, hence i didn't implement saving the new state.

Your code here makes a backup of playlist (the requested new state), not saved_playlist (the old state). A request failure somewhere can leave us in a mess with our previous existing data now lost. So I was suggesting saving both playlists to give the best choice when it comes to recovery.

afaik, m3u-ext requires the length,

No, there's no spec (yay!) and we (Mopidy-M3U) do not require it or even use it. A list of URIs is all that is required to be useful.

Should use Path.

Sorry, I meant pathlib.Path like the implementation in Mopidy-M3U.

since no client supports batching multiple edit operations anyways

I think we've covered this before, few clients have any sort of playlist editing. We should support what is possible with the Mopidy API, not how we think people should/might/currently use it. Someone also uses it in a way you didn't think they would/should.

i believe he likeliness of this code ever being called is extremely low. for it to trigger*

Or we have a quota/rate limit issue half way through, or something else unlikely. But it if can happen it will happen.

Maybe we should remove this code from the PR and address it later if anyone in the world ever sees (and reports) a problem.

Copy link
Member Author

Choose a reason for hiding this comment

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

truncation is one class of issue but isn't data loss possible in either strategy?

technically yes, but in my opinion only truncation is severe enough to care about. i have no problem with dropping the strategy test; will implement.

So I was suggesting saving both playlists to give the best choice when it comes to recovery.

will implement.

A list of URIs is all that is required to be useful.

useful to a computer; but i wanted it to be as useful as possible to humans as well. if we were to end up in this state, let's be as forthcoming to the user as possible.

Sorry, I meant [pathlib.Path]

and Extension.get_data_dir is returning pathlib.path. we must really be talking about different things..?

Maybe we should remove this code from the PR and address it later if anyone in the world ever sees (and reports) a problem.

strongly disagree. we should have some defence against truncation, since it's irreversible data loss. the trade-off is a few lines of imperfect code vs possible thousands of curated songs. if i were to trust mopidy-spotify with editing my years-old playlists, and it deleted most of their contents, and the maintainers just said "sorry, but thanks for the bugreport", I'd be majorly pissed. it can still be improved after committing.

safe_name = playlist.name.translate(
str.maketrans(" @`!\"#$%&'()*+;[{<\\|]}>^~/?", "_" * 27)
)
filename = (
Extension.get_data_dir(self._backend._config)
/ f"{safe_name}-{playlist.uri}-{time.time()}.m3u"
)
with open(filename, "wb") as f:
f.write(b"#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".encode("utf-8")
)
logger.error(f'Created backup in "{filename}"')
return None

# Playlist rename logic
if playlist.name != saved_playlist.name:
logger.info(
f"Renaming playlist [{saved_playlist.name}] to [{playlist.name}]"
)
web.rename_playlist(
self._backend._web_client, playlist.uri, playlist.name
)

return self.lookup(saved_playlist.uri)


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 +267,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 +289,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,
):
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

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
70 changes: 70 additions & 0 deletions mopidy_spotify/utils.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import contextlib
import difflib
import functools
import logging
import time

Expand Down Expand Up @@ -33,3 +35,71 @@ def time_logger(name, level=TRACE):

def flatten(list_of_lists):
return [item for sublist in list_of_lists for item in sublist]


class op:
def __init__(self, op, tracks, frm, to=0):
self.op = op
self.tracks = tracks
self.frm = frm
self.to = to

def __repr__(self):
length = len(self.tracks)
if length < 1:
return "<invalid op()>"
first = self.tracks[0].split(":")[-1]
last = self.tracks[-1].split(":")[-1]
tracks = f"{first}...{last}" if length > 1 else first
action = {"-": "delete", "+": "insert", "m": "move"}
pos = f"{self.frm} to {self.to}" if self.op == "m" else self.frm
return f"<{action.get(self.op)} {length} tracks [{tracks}] at {pos}>"


def _is_move(op1, op2):
return op1.op == "-" and op2.op == "+" and op1.tracks == op2.tracks


def _op_split(o, chunksize):
def partition(o, n):
def inc(m, i):
return m + i if o.op == "-" or o.op == "m" else m

for i in range(0, len(o.tracks), n):
yield op(o.op, o.tracks[i : i + n], inc(o.frm, i), inc(o.to, i))

return list(partition(o, chunksize)) if o.op != "m" else [o]


def diff(old, new, chunksize=100):
# first, apply python's built-in diff algorithm, remove unmodified ranges,
# split replacements into seperate insertions and deletions and transform
# the data structure into op-objects:
ops = []
for tag, i1, i2, j1, j2 in difflib.SequenceMatcher(
a=old, b=new, autojunk=False
).get_opcodes():
if tag in ("insert", "replace"):
ops.append(op("+", new[j1:j2], i1))
if tag in ("delete", "replace"):
ops.append(op("-", old[i1:i2], i1))

# then, merge pairs of insertions and deletions into a transposition:
# for this, we start from the rightmost element,
for R in reversed(range(len(ops))):
# and compare against all elements on its left
for L in reversed(range(R)):
# if we found a pair of ins/del that can be combined,
if _is_move(ops[R], ops[L]) or _is_move(ops[L], ops[R]):
# replace the left item with a mov
del_at = ops[L].frm if ops[L].op == "-" else ops[R].frm
ins_at = ops[L].frm if ops[L].op == "+" else ops[R].frm
ops[L] = op("m", ops[L].tracks, del_at, ins_at)
# and delete the right one (this is why we go right-to-left)
del ops[R]
break # check the next outer element

# finally, split add/del ops to work on <= 100 tracks (but not mov ops):
ops = functools.reduce(lambda xs, x: xs + _op_split(x, chunksize), ops, [])

return ops
Loading