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

Conversation

blacklight
Copy link

@blacklight blacklight commented Dec 12, 2019

MPD isn't that optimal in managing playlist changes - it sends the whole snapshot of the new playlist to the save method, while the Spotify Web API only requires the track changes to be submitted. It means that we have to build the deltas within the save method itself. This implementation gets the job done but I'm sure that it could be further optimized.

Known limitations:

  • In order to make the delta calculation a bit more optimized the old and new playlists are currently indexed by track uri. It means that things can get messy if a playlist has duplicate track URIs - if a user removes an instance of a track that appears twice in the playlist then both the instances will be removed, if a user adds a track to a playlist where the track is already present then no changes will be calculated (this isn't such a bad thing IMHO, even the Spotify frontend asks for user confirmation if a track is added to a playlist where it's already present).

  • The current implementation won't allow the invalidation of single cache items. It means that we have to call self._backend._web_client.clear_cache() and invalidate the whole cache even if we have changed only one playlist.

  • The save method doesn't look that good now, as it handles removals, additions and swaps within the same block and progressively updates the cur_tracks map. I believe however that it can be split into three distinct rm, add and swap methods: all the MPD clients I know allow user interactions that fall into only one of those categories, not "bulk edits" that require a whole playlist to be updated through multiple rm/add/swap at the same time.

  • Tests should be added.

@codecov
Copy link

codecov bot commented Dec 12, 2019

Codecov Report

Merging #236 into master will decrease coverage by 5.18%.
The diff coverage is 16.86%.

Impacted file tree graph

@@            Coverage Diff             @@
##           master     #236      +/-   ##
==========================================
- Coverage   96.56%   91.37%   -5.19%     
==========================================
  Files          13       13              
  Lines        1136     1183      +47     
==========================================
- Hits         1097     1081      -16     
- Misses         39      102      +63
Impacted Files Coverage Δ
mopidy_spotify/web.py 93.19% <72.72%> (-2.89%) ⬇️
mopidy_spotify/playlists.py 52.81% <8.33%> (-41.71%) ⬇️
mopidy_spotify/library.py 100% <0%> (ø) ⬆️
mopidy_spotify/translator.py 98.76% <0%> (+2.83%) ⬆️
mopidy_spotify/browse.py 93.93% <0%> (+4.65%) ⬆️

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 16de1e5...6d567ab. Read the comment docs.

Copy link
Member

@kingosticks kingosticks left a comment

Choose a reason for hiding this comment

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

I've not finished reviewing this but I think we need to decide what we want to do before we go any further. The duplicate track issue is a problem, can we have wildly different behaviour between this and the m3u backend? You don't seem to have handled playlist renaming and there's also restrictions on the number of tracks you can submit to some endpoints which also isn't handled.

Is it possible to reimplement save using their PUT method to simply replace the entire playlist? You'd still have to batch it up in chunks of 100 tracks. Presumably there's no problem with adding new tracks this way. Maybe we can have a simpler algorithm to see if the new playlist is a very simple track addition or subtraction compared to the original and in that case avoid doing an entire replacement.

mopidy_spotify/playlists.py Outdated Show resolved Hide resolved
mopidy_spotify/playlists.py Outdated Show resolved Hide resolved
mopidy_spotify/playlists.py Show resolved Hide resolved
mopidy_spotify/playlists.py Outdated Show resolved Hide resolved
mopidy_spotify/playlists.py Outdated Show resolved Hide resolved
mopidy_spotify/playlists.py Outdated Show resolved Hide resolved
mopidy_spotify/playlists.py Outdated Show resolved Hide resolved
mopidy_spotify/playlists.py Outdated Show resolved Hide resolved
mopidy_spotify/playlists.py Outdated Show resolved Hide resolved
mopidy_spotify/playlists.py Outdated Show resolved Hide resolved
Copy link
Author

@blacklight blacklight left a comment

Choose a reason for hiding this comment

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

I've not finished reviewing this but I think we need to decide what we want to do before we go any further. The duplicate track issue is a problem, can we have wildly different behaviour between this and the m3u backend? You don't seem to have handled playlist renaming and there's also restrictions on the number of tracks you can submit to some endpoints which also isn't handled.

Is it possible to reimplement save using their PUT method to simply replace the entire playlist? You'd still have to batch it up in chunks of 100 tracks. Presumably there's no problem with adding new tracks this way. Maybe we can have a simpler algorithm to see if the new playlist is a very simple track addition or subtraction compared to the original and in that case avoid doing an entire replacement.

I thought of it but I didn't like that approach much - what if something goes wrong in the middle of the tracks replacement and you end up with half the songs on a playlist removed?

Maybe we could create a temporary auxiliary playlist that will store the original content before performing modifications on the original playlist, and restore the tracks from there if something goes wrong, but while that would make things more robust it'll also add quite some overhead in the case of simple interactions.

I believe however that 99% of the cases supported by most of the mpd clients will be simple additions and removals, not hybrid add/remove/swap operations. Maybe something can indeed optimized by taking that into account.

How does mopidy deal with playlist changes in the m3u backend? Does it replace the whole playlist every time?

mopidy_spotify/playlists.py Outdated Show resolved Hide resolved
mopidy_spotify/playlists.py Show resolved Hide resolved
@kingosticks
Copy link
Member

kingosticks commented Dec 14, 2019

That same argument can be applied to what we have here. If something goes wrong in the remove step or the add step then you've got a mess left behind. And you still need to batch these requests up into chunks of 100 tracks.

If the playlist is 100 tracks or less its one replace call and done, regardless of the operations performed.

You've already got a local copy of the original playlist so if something goes majorly wrong you could then restore the original from that.

The m3u backend replaces the entire playlist. It's by far the simplest and most robust approach, I do appreciate it's not as easy here.

@blacklight
Copy link
Author

blacklight commented Dec 14, 2019

That same argument can be applied to what we have here. If something goes wrong in the remove step or the add step then you've got a mess left behind. And you still need to batch these requests up into chunks of 100 tracks.

The difference is that with the current approach the mess only impacts the newly added/removed/swapped tracks, not the entire playlist.

If the playlist is 100 tracks or less its one replace call and done, regardless of the operations performed.

True, but most of my playlists, for instance, have more than 100 tracks. I also have playlists with >1000 tracks and one with 1500 tracks. In the latter case adding a single track would result in 17 API calls (one for clearing the playlist, 15 to add tracks in chunks of 100 each, and one more to refresh the playlist), and if anything goes wrong in the process the revert will also cost 17 calls. The latency and the risk of hitting the web API rate limits will both be high in this case.

I had even thought of sacrificing the performance gain introduced by the URI-based indexing to be more consistent when it comes to duplicate tracks, but an inner loop to check the changes in the two playlists position-by-position would mean an O(N²) nightmare - 1,000,000 iterations for a 1000 items playlist, the latency and load may really be too high in this case.

Eventually, I chose the current approach because it seemed by far the most efficient, and maybe we could live with the inconsistent behaviour when it comes to duplicate tracks, as long as it's properly documented. And I think we are kind of consistent anyway. The current implementation won't allow addition of duplicate tracks either, and the Spotify apps also show a confirmation message to prevent duplicates from being added.

You've already got a local copy of the original playlist so if something goes majorly wrong you could then restore the original from that.

A rollback try/except logic should indeed wrap the whole save method. My only concern with this approach would be the possibility of a nasty feedback loop if the API calls failed because of a number of calls above the rate limit: in this case, the rollback logic will make even more API calls, resulting either in the limit being broken again and again, or in the playlist tracks being lost. We could still check through the exception message whether the failure was due to rate limit and prevent further calls, but I'm unsure about what should be done in that case - spawn a thread that retries 5 minutes later, or leave the mess in place and advise the user to head to the web client to restore the playlist?

@kingosticks
Copy link
Member

I also have playlists with >1000 tracks and one with 1500 tracks. In the latter case adding a single track would result in 17 API calls (one for clearing the playlist, 15 to add tracks in chunks of 100 each, and one more to refresh the playlist),

I agree that's not acceptable and what's why we'd need to detect these simpler add/remove cases and do something more sensible. Which, in this example, would be a single replace request at the playlist end and a refresh (which I think is always going to be unavoidable so we can ignore it).

Now consider adding new tracks sparsely, in the very worst case consider adding a track after every other track. For your 1500 track playlist that's 750 API requests. Less contrived versions are possible in real-world usuage. I appreciate it might not be how you edit your playlists but we need to consider the broader cases, up to a realistic point.

And for the record, I don't think you can clear a large playlist with a single API call as you are still restricted by the 100 track limit when removing. But you don't need to clear it anyway so that's moot.


Ultimately I think we need to think about this more. And we definitely need to have it soak before including it in a release. I'm going to concentrate on our Mopidy 3 release (including merging parts of Mopidy-Spotify-Web into Mopidy-Spotify) as that's comming up next week. Once that's out of the way I, and hopefully also others, will come back to this.

@kingosticks kingosticks added C-enhancement Category: A PR with an enhancement or an issue with an enhancement proposal A-webapi Area: Spotify Web API labels Dec 16, 2019
@blacklight
Copy link
Author

Now consider adding new tracks sparsely, in the very worst case consider adding a track after every other track. For your 1500 track playlist that's 750 API requests. Less contrived versions are possible in real-world usage. I appreciate it might not be how you edit your playlists but we need to consider the broader cases, up to a realistic point.

Which clients support such a way of inserting tracks in a playlist? I agree that we have to consider the broadest use cases, but none of the MPD/mopidy clients I know of support things like sparse add/remove/swap on the same playlist within the same action. I'm not even sure if any of the clients out there support add/remove/swap within the same atomic action, so anyway the save method will only perform one of the three operations when invoked. Of course it's still technically possible to do it by sending a raw MPD message, but I'm pretty confident that 99% of the use cases out there won't fall into this category :)

I agree however that we must find a trade-off point between handling only the delta and clearing and re-populating the whole playlist. As a thumb rule the logic should minimize the number of API calls. It means that if the number of computed non-contiguous addition/removals grouped in batches of 100 is greater than the size of the playlist divided by 100 then we should clear and re-populate the playlist, otherwise do an incremental addition/removal - what do you think?

And for the record, I don't think you can clear a large playlist with a single API call as you are still restricted by the 100 track limit when removing.

Actually the Spotify Web API provides the DELETE https://api.spotify.com/v1/playlists/{playlist_id}/tracks endpoint to clear a playlist within a single call. Anyway, the issue isn't much with clearing the playlist, it's with inserting back the tracks, especially if the playlist is a large one.

@kingosticks
Copy link
Member

Which clients support such a way of inserting tracks in a playlist?

There's no reason you can't add tracks at whatever position you want to. I've no idea what MPD clients support that as I don't use MPD clients day to day. I had intended musicbox-webclient to allow you to manipulate playlists however you want and save all the modifications in one go.

Actually the Spotify Web API provides the DELETE https://api.spotify.com/v1/playlists/{playlist_id}/tracks endpoint to clear a playlist within a single call.

If you don't sppecify tracks with the DELETE method you get:

{
"error": {
"status": 400,
"message": "Missing tracks"
}
}

If you do specify tracks then you are limited to 100 entries. What am I missing?

@blacklight
Copy link
Author

blacklight commented Dec 16, 2019

I had intended musicbox-webclient to allow you to manipulate playlists however you want and save all the modifications in one go.

Got it - that's more a kind of edit-and-commit approach as opposed to the edit-on-the-fly approach implemented by most of the clients I use (ncmpcpp, Iris and MPDroid).

However my approach will also work in these cases - it might be slower if compared to a clear-and-repopulate approach, but if we assume that in most of the cases the user adds and removes a couple of tracks instead of restructuring the entire playlist (is it a correct assumption or am I biased by my own use cases?) it may still be an acceptable trade-off.

If you don't sppecify tracks with the DELETE method you get:

{
"error": {
"status": 400,
"message": "Missing tracks"
}
}

You're right, I missed the required part on the tracks attribute...

If that's the case then it's one more argument against the clear-and-repopulate approach: in case of a large playlist the number of calls will really be cumbersome.

What do you think about the trade-off I've proposed in my previous comment?

expected_api_calls_delta = [int(len(block)/100 + 1) for block in contiguous_updates]
expected_api_calls_repopulate = 2 * int(len(playlist.tracks)/100 + 1)

if expected_api_calls_delta < expected_api_calls_repopulate:
    do_delta_update()
else:
    do_clear_and_repopulate()

@kingosticks
Copy link
Member

You're right, I missed the required part on the tracks attribute...

It's easily done, their documentation is poor. They don't even mention the positions argument unless you use their web console.

Sorry, but I need to come back to this after the 3.x release and get into it properly.

@blacklight
Copy link
Author

Any chance to resume the discussion about this topic and/or get more developers involved on what's the best strategy to tackle it?

@kingosticks
Copy link
Member

I, for one, can look again next week.

@fatg3erman
Copy link

(Following this with interest as it's funcionality I desperately want)

As far as an MPD client is concerned, the only things it can do with a stored playlist are:
Add a track to the end
Move a track (or range of tracks) to a new position
Remove a track (or range of tracks)
Rename the playlist

Each of these is one operation, however MPD has the concept of command lists, where multiple operations can be batched into one atomic operation, so it's perfectly possible for a client to do any combination of the above in one go. 50 commands is the default maximum length of a command list; so 49 adds and one move would be the sort of thing you'd expect to have to handle. If a user tires to add more than 50 tracks, this would result in 50 adds, a write, 50 more adds, and so on.

As far as a client goes, responsiveness is important. As far as the user of a client is concerned, it's everything. 17 API calls for a simple playlist operation is far too slow. Some clients will freeze while they wait for a response, most users will get impatient. In the early days of Rompr it was quite slow at retrieving the play queue, and people complaied because it took about 3 seconds to populate when there were more than 5000 tracks in the queue. I thought that was a ridiculous amout of tracks and therefore hadn't optimised any of the code, but it actually seems to be not unusual. People like big playlists. In my view, anything that can be done to make the process as efficient as possible should be done, or people will moan :)

@blacklight
Copy link
Author

blacklight commented Jan 28, 2020

@fatg3erman I totally agree, I also feel that minimizing the number of API calls, by treating as many changes as possible as deltas compared to the current state, should be favoured over the wipe-and-populate approach. And responsiveness should be everything, even if the cost is a loss of consistency in some corner cases like e.g. duplicate IDs within the same playlist in order to implement a hash lookup instead of a linear lookup.

However, I also acknowledge that in some corner cases the wipe-and-populate approach may be more efficient. If I want to remove all the tracks in a playlist and add a single new track then it's faster to do a wipe-all instead of a scan-the-deltas (provided that the client actually supports such a feature within a single atomic call). So my questions are:

  • How much more common are such use cases compared to the addition/removal of n tracks, where n << size of the playlist?

  • Is it worth to implement an alternative wipe-and-populate logic to cover such cases?

  • If so, is it worth to heuristically estimate, before performing the operation, how many API calls that operation will involve both with the scan-the-deltas and wipe-and-populate approach, and opt for the one that minimizes the amount?

Once settled, we've still got the rollback issue to tackle. I believe that if some calls inside save() fail then we should try to get the playlist back to the previous state, but if we already successfully performed some of the required actions we may be in a quite inconsistent state. Worst, if the code failed because the API rate limit exceeded, the restore process will result in even more API calls and in an endless loop, so that case should probably be targeted separately. In the worst case scenario, we may just return a message to the user such as "there was a problem while manipulating your playlist: please restore it from the Spotify web interface".


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.

girst pushed a commit to fork-graveyard/mopidy-spotify that referenced this pull request Nov 3, 2020
girst pushed a commit to fork-graveyard/mopidy-spotify that referenced this pull request Nov 3, 2020
girst pushed a commit to fork-graveyard/mopidy-spotify that referenced this pull request Nov 3, 2020
girst pushed a commit to fork-graveyard/mopidy-spotify that referenced this pull request Nov 3, 2020
@girst
Copy link
Member

girst commented Nov 3, 2020

@blacklight: What's the state of this PR? As far as I can see from the comments, minimizing API requests is still to-do? I'm not sure if you and @kingosticks have agreed a plan for this yet or not.

If you aren't interested in working on this anymore, I can pick it up, given a list of outstanding tasks. I've worked a bit on that code (fix some errors, make tests pass, implement deletion, less cache invalidation) at https://github.com/fork-graveyard/mopidy-spotify/tree/bug22, fyi.

ETA: i had a play around with ncmpcpp and cantata; both seem to add/move tracks to playlists one-by-one. this means, that for many clients, the api request optimisation might be a moot point.

ETA2: one more thing I forgot to mention is that the current api scope is too narrow: we're missing playlist-modify-public, meaning we can't modify playlists that have its visibility set to public (we can modify private playlists, though). While at it, we might as well request user-library-modify, to allow modifying "my music" later on.

@blacklight
Copy link
Author

blacklight commented Nov 3, 2020

@girst I believe that we eventually want a reasonable trade-off depending on the case - if the user is adding a single track to a playlist then updating the whole playlist is overkill; if the user is clearing all the tracks in a playlist then doing track-by-track updates is overkill.

I think that we just need to agree on where to draw the dividing line for the cases between these two extremes - the code should probably try and estimate the average-case number of required API calls in both the cases, given the size of the playlist and the number of requested changes, and apply the logic that minimizes such number. However, for large playlists the estimation logic itself can be overkill - the mpd save command only sends the snapshot of the new playlist and diffing doesn't come for free. @kingosticks what do you think?

I don't have much time to work on this PR in this period (I've recently expanded the family and babies seem to require much more maintenance than code), but feel free to pick it up if you have some time.

@girst
Copy link
Member

girst commented Nov 4, 2020 via email

@fatg3erman
Copy link

fatg3erman commented Nov 4, 2020 via email

@girst
Copy link
Member

girst commented Nov 4, 2020 via email

@fatg3erman
Copy link

There's this one, for a start

https://fatg3erman.github.io/RompR/

this will, if required, use command lists of up to 50 commands (which is the default setting of MPD)

@girst
Copy link
Member

girst commented Nov 4, 2020 via email

@kingosticks
Copy link
Member

I think we covered not assuming one-track-at-a-time changes previously. Regardless of what some MPD clients do (I'm not sure if the current Mopidy-MPD implementation has anything to prevent each stored playlist command in your list performing a save, but I've not verified that), web clients and potentially other frontends allow you to make multiple changes at a time so we need to support that fully.

I think that we just need to agree on where to draw the dividing line for the cases between these two extremes

Sounds good to me. I'd really like to avoid making this crazy complicated. We do have to test this.

And a belated congratulations to you @blacklight!

@fatg3erman
Copy link

fatg3erman commented Nov 4, 2020 via email

@girst
Copy link
Member

girst commented Nov 4, 2020 via email

@girst
Copy link
Member

girst commented Nov 4, 2020 via email

@girst
Copy link
Member

girst commented Nov 5, 2020 via email

@kingosticks
Copy link
Member

kingosticks commented Nov 5, 2020

turns out, my mpd clients were not the culprit: mopidy-mpd is issuing a
context.core.playlists.save(playlist) for every mpd command

Yes, I mentioned this.

I think it's a bit of a chicken and egg problem. There hasn't so far been a compelling use-case for rich playlist modification using Mopidy's API - m3u playlists are not very exciting - so web clients implementations have so far been basic, if at all. For the record, musicbox-webclient allows you to edit a "favourites" m3u playlist, but that is it.

If you want to test the functionality, write some tests. If you want to test the user experience then we are a bit stuck right now. If you are trying to better understand the status quo in order to support a limited subset of the full Mopidy Playlist API then that is something else and I don't think that's a good idea considering the chicken+egg mentioned. If we have to limit the scope (why?) then we can but we need to reason about that rather than looking at current frontend implementations.

EDIT:
And I will also add: Mopidy's playlist API is not set in stone. It could change. If we did an updated survey of backends and what playlist editing support they wanted we could look at improvements to better support non-local backends. I'm not even sure what there is currently other than Spotify that supports playlist modification. My 5 min survey shows the only backend to do any playlist modification is m3u and mopidy_bookmarks!

@girst
Copy link
Member

girst commented Nov 5, 2020 via email

@kingosticks
Copy link
Member

I'm up for attempting to write the feature for musicbox webclient (or maybe just a standalone proof of concept).

@kingosticks
Copy link
Member

And if you can come up with a way for Mopidy-MPD to do something more optimised with a command list of playlist modifications targeting the same playlist then that sounds interesting. My thoughts right now are all massive hacks that we'd never merge (I hope).

@fatg3erman
Copy link

fatg3erman commented Nov 5, 2020 via email

@girst
Copy link
Member

girst commented Nov 6, 2020 via email

@blacklight
Copy link
Author

blacklight commented Nov 7, 2020

There hasn't so far been a compelling use-case for rich playlist modification using Mopidy's API - m3u playlists are not very exciting - so web clients implementations have so far been basic, if at all.

There's still a valid use-case when it comes to mpd-based clients though. ncmpcpp and other clients can add/remove/swap items in playlists, although that only applies AFAIK to single tracks (that's the main use-case I had in mind for my PR, like adding single tracks from the Discover Weekly/Release Radar playlists into my playlists). Also, AFAIK ncmpcpp can only append a song at the end of a playlist, not insert it somewhere in the middle.

I'm not aware of clients that allow richer playlist modifications either (like scattered inserts/deletes of multiple tracks in the middle of the list), but I also believe that once mopidy provides a consistent API for this use-case the uses will come.

@girst
Copy link
Member

girst commented Nov 7, 2020 via email

@girst
Copy link
Member

girst commented Nov 15, 2020

For those following along in this thread: I've opened a new PR #287. To me, it's pretty much done, with the exception of what to do when the API returns an error, which I'd like to discuss. Feedback on the PR more than welcome!

kingosticks pushed a commit to kingosticks/mopidy-spotify that referenced this pull request Jan 6, 2021
kingosticks pushed a commit to kingosticks/mopidy-spotify that referenced this pull request Jan 6, 2021
kingosticks pushed a commit to kingosticks/mopidy-spotify that referenced this pull request Jan 6, 2021
kingosticks pushed a commit to kingosticks/mopidy-spotify that referenced this pull request Jan 6, 2021
kingosticks pushed a commit to kingosticks/mopidy-spotify that referenced this pull request Jan 8, 2021
kingosticks pushed a commit to kingosticks/mopidy-spotify that referenced this pull request Jan 8, 2021
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
A-webapi Area: Spotify Web API C-enhancement Category: A PR with an enhancement or an issue with an enhancement proposal
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

4 participants