-
Notifications
You must be signed in to change notification settings - Fork 111
Support for playlist management (v2) #287
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
base: main
Are you sure you want to change the base?
Changes from all commits
95c1031
5333536
c23eda1
0acd636
d449225
1dc0e7e
13e98bf
2994325
42a6506
ba0a616
46f2935
e9caa71
4ec107c
74d405f
d6708ee
525f420
e8ce572
4adc6f4
4b3a3b4
468a811
8af448b
b3c09d1
6fd0a7b
3ec1c44
6237b5c
79317af
609795f
47c553f
f8c69f7
fc11efe
0d596ed
400060e
50f1ab3
1c0358a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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"] | ||
|
|
@@ -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) | ||
|
|
||
| def _patch_playlist(self, playlist, operations): | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this belongs in In fact, I think it'd also want the second half of |
||
| # 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 = [] | ||
|
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): | ||
|
|
@@ -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) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Arguably worth pulling out first half of |
||
| 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 | ||
|
|
||
|
|
@@ -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 | ||
|
|
@@ -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 | ||
Uh oh!
There was an error while loading. Please reload this page.