From 16f75046d2f0a44fa967e0531da3fe82c31ecb23 Mon Sep 17 00:00:00 2001 From: Valentijn Date: Sat, 10 Mar 2018 19:40:50 +0100 Subject: [PATCH 1/4] Refactoring of the code creating the tracks and the fundations of thumbs up/down --- clay/config.yaml | 2 + clay/gp.py | 159 ++++++++++++----------------------------------- clay/hotkeys.py | 1 + clay/player.py | 11 ++-- clay/songlist.py | 16 +++++ 5 files changed, 65 insertions(+), 124 deletions(-) diff --git a/clay/config.yaml b/clay/config.yaml index e1591cf..8d08b81 100644 --- a/clay/config.yaml +++ b/clay/config.yaml @@ -26,6 +26,8 @@ hotkeys: unappend: mod + u request_station: mod + p show_context_menu: meta + p + thumbs_up: meta + u + thumbs_down: meta + d library_view: move_to_beginning: home diff --git a/clay/gp.py b/clay/gp.py index 825e816..543204c 100644 --- a/clay/gp.py +++ b/clay/gp.py @@ -16,6 +16,8 @@ from gmusicapi.clients import Mobileclient from clay.eventhook import EventHook from clay.log import logger +import sys + def asynchronous(func): """ Decorates a function to become asynchronous. @@ -86,27 +88,29 @@ class Track(object): SOURCE_PLAYLIST = 'playlist' SOURCE_SEARCH = 'search' - def __init__( - self, - title, artist, duration, source, - library_id=None, store_id=None, playlist_item_id=None, - album_name=None, album_url=None, original_data=None - ): - self.title = title - self.artist = artist - self.duration = duration - self.source = source + def __init__(self, source, data): + # In playlist items and user uploaded songs the storeIds are missing so + self.store_id = (data['storeId'] if 'storeId' in data else data['id']) + self.playlist_item_id = (UUID(data['id']) if source == self.SOURCE_PLAYLIST else None) + self.library_id = (UUID(data['id']) if source == self.SOURCE_LIBRARY else None) + # To filter out the playlist items we need to reassign the store_id when fetching the track + if 'track' in data: + data = data['track'] + self.store_id = data['storeId'] + + self.title = data['title'] + self.artist = data['artist'] + self.duration = int(data['durationMillis']) + self.rating = (data['rating'] if 'rating' in data else 0) + self.source = source self.cached_url = None - self.library_id = library_id - self.store_id = store_id - self.playlist_item_id = playlist_item_id + # User uploaded songs miss a store_id + self.album_name = data['album'] + self.album_url = (data['albumArtRef'][0]['url'] if 'albumArtRef' in data else "") - self.album_name = album_name - self.album_url = album_url - - self.original_data = original_data + self.original_data = data @property def id(self): # pylint: disable=invalid-name @@ -131,100 +135,6 @@ class Track(object): (self.playlist_item_id and self.playlist_item_id == other.playlist_item_id) ) - @classmethod - def _from_search(cls, data): - """ - Create track from search result data. - """ - # Data contains a nested track representation. - return Track( - title=data['track']['title'], - artist=data['track']['artist'], - duration=int(data['track']['durationMillis']), - source=cls.SOURCE_SEARCH, - store_id=data['track']['storeId'], # or data['trackId'] - album_name=data['track']['album'], - album_url=data['track']['albumArtRef'][0]['url'], - original_data=data - ) - - @classmethod - def _from_station(cls, data): - """ - Create track from station track data. - """ - # Station tracks have all the info in place. - return Track( - title=data['title'], - artist=data['artist'], - duration=int(data['durationMillis']), - source=cls.SOURCE_STATION, - store_id=data['storeId'], - album_name=data['album'], - album_url=data['albumArtRef'][0]['url'], - original_data=data - ) - - @classmethod - def _from_library(cls, data): - """ - Create track from library track data. - """ - # Data contains all info about track - # including ID in library and ID in store. - UUID(data['id']) - return Track( - title=data['title'], - artist=data['artist'], - duration=int(data['durationMillis']), - source=cls.SOURCE_LIBRARY, - store_id=data['storeId'], - library_id=data['id'], - album_name=data['album'], - album_url=data['albumArtRef'][0]['url'], - original_data=data - ) - - @classmethod - def _from_playlist(cls, data): - """ - Create track from playlist track data. - """ - if 'track' in data: - # Data contains a nested track representation that can be used - # to construct new track. - return Track( - title=data['track']['title'], - artist=data['track']['artist'], - duration=int(data['track']['durationMillis']), - source=cls.SOURCE_PLAYLIST, - store_id=data['track']['storeId'], # or data['trackId'] - playlist_item_id=data['id'], - album_name=data['track']['album'], - album_url=data['track']['albumArtRef'][0]['url'], - original_data=data - ) - # We need to find a track in Library by trackId. - UUID(data['trackId']) - track = gp.get_track_by_id(data['trackId']) - return Track( - title=track.title, - artist=track.artist, - duration=track.duration, - source=cls.SOURCE_PLAYLIST, - store_id=track.store_id, - album_name=track.album_name, - album_url=track.album_url, - original_data=data - ) - - _CREATE_TRACK = { - SOURCE_SEARCH: '_from_search', - SOURCE_STATION: '_from_station', - SOURCE_LIBRARY: '_from_library', - SOURCE_PLAYLIST: '_from_playlist', - } - @classmethod def from_data(cls, data, source, many=False): """ @@ -232,15 +142,16 @@ class Track(object): from Google Play Music API response. """ if many: - return [ - track - for track - in [cls.from_data(one, source) for one in data] - if track is not None - ] - + return [track for track in + [cls.from_data(one, source) for one in data] + if track is not None] try: - return getattr(cls, cls._CREATE_TRACK[source])(data) + if source == cls.SOURCE_PLAYLIST and 'track' not in data: + track = gp.get_track_by_id(UUID(data['trackId'])) + else: + track = Track(source, data) + + return track except Exception as error: # pylint: disable=bare-except logger.error( 'Failed to parse track data: %s, failing data: %s', @@ -642,6 +553,16 @@ class _GP(object): self.invalidate_caches() return result + def set_track_rating(self, id_, rating): + """ + Set the rating for song with the specified ID. + + 0 for no thumb, 1 for down thumb and 5 for up thumb + """ + song = self.mobile_client.get_track_info(id_) + song['rating'] = rating + self.mobileclient.change_song_metadata(song) + @property def is_authenticated(self): """ diff --git a/clay/hotkeys.py b/clay/hotkeys.py index 694aea9..153dcb7 100644 --- a/clay/hotkeys.py +++ b/clay/hotkeys.py @@ -124,6 +124,7 @@ class _HotkeyManager(object): hotkey[0] = mod_key hotkeys[hotkey_name][' '.join(hotkey)] = action + return hotkeys def keypress(self, name, caller, super_, size, key): diff --git a/clay/player.py b/clay/player.py index 93b308d..20a0165 100644 --- a/clay/player.py +++ b/clay/player.py @@ -143,6 +143,7 @@ def _dummy_log(data, level, ctx, fmt, args): pass #+pylint: disable=unused-argument + class _Player(object): """ Interface to libVLC. Uses Queue as a playback plan. @@ -161,11 +162,11 @@ class _Player(object): def __init__(self): self.instance = vlc.Instance() print_func = CFUNCTYPE(c_void_p, - c_void_p, # data - c_int, # level - c_void_p, # context - c_char_p, # fmt - c_void_p) #args + c_void_p, # data + c_int, # level + c_void_p, # context + c_char_p, # fmt + c_void_p) # args self.instance.log_set(print_func(_dummy_log), None) diff --git a/clay/songlist.py b/clay/songlist.py index a9c41b9..3514ca2 100644 --- a/clay/songlist.py +++ b/clay/songlist.py @@ -107,6 +107,7 @@ class SongListItem(urwid.Pile): """ return SongListItem.STATE_ICONS[state] + def update_text(self): """ Update text of this item from the attached track. @@ -155,6 +156,21 @@ class SongListItem(urwid.Pile): return None return super(SongListItem, self).mouse_event(size, event, button, col, row, focus) + def thumbs_up(self): + """ + Toggle the thumbs up of this song. + """ + if self.track.rating == 5: + gp.set_track_rating(self.track.id, 0) + else: + gp.set_track_rating(self.track.id, 5) + + def thumbs_down(self): + if self.track.rating == 1: + gp.set_track_rating(self.track.id, 0) + else: + gp.set_track_rating(self.track.id, 1) + def _send_signal(self, signal): urwid.emit_signal(self, signal, self) From 8e622266b3cb2522f55cc3781da570888f4a7f0f Mon Sep 17 00:00:00 2001 From: Valentijn Date: Sat, 10 Mar 2018 20:21:25 +0100 Subject: [PATCH 2/4] You can rate a song (once) now --- clay/gp.py | 21 ++++++++++----------- clay/songlist.py | 10 ++-------- 2 files changed, 12 insertions(+), 19 deletions(-) diff --git a/clay/gp.py b/clay/gp.py index 543204c..80bcbc3 100644 --- a/clay/gp.py +++ b/clay/gp.py @@ -16,7 +16,6 @@ from gmusicapi.clients import Mobileclient from clay.eventhook import EventHook from clay.log import logger -import sys def asynchronous(func): """ @@ -226,6 +225,16 @@ class Track(object): remove_from_my_library_async = asynchronous(remove_from_my_library) + def rate_song(self, rating): + """ + Rate the song either 0 (no thumb), 1 (down thumb) or 5 (up thumb). + """ + gp.mobile_client.rate_songs(self.original_data, rating) + self.original_data['rating'] = rating + self.rating = rating + + # print(gp.mobile_client.rate_songs(self.original_data, rating)) + def __str__(self): return u''.format( self.artist, @@ -553,16 +562,6 @@ class _GP(object): self.invalidate_caches() return result - def set_track_rating(self, id_, rating): - """ - Set the rating for song with the specified ID. - - 0 for no thumb, 1 for down thumb and 5 for up thumb - """ - song = self.mobile_client.get_track_info(id_) - song['rating'] = rating - self.mobileclient.change_song_metadata(song) - @property def is_authenticated(self): """ diff --git a/clay/songlist.py b/clay/songlist.py index 3514ca2..8771685 100644 --- a/clay/songlist.py +++ b/clay/songlist.py @@ -160,16 +160,10 @@ class SongListItem(urwid.Pile): """ Toggle the thumbs up of this song. """ - if self.track.rating == 5: - gp.set_track_rating(self.track.id, 0) - else: - gp.set_track_rating(self.track.id, 5) + self.track.rate_song((0 if self.track.rating == 5 else 5)) def thumbs_down(self): - if self.track.rating == 1: - gp.set_track_rating(self.track.id, 0) - else: - gp.set_track_rating(self.track.id, 1) + self.track.rate_song((0 if self.track.rating == 1 else 1)) def _send_signal(self, signal): urwid.emit_signal(self, signal, self) From 0fc0d0ae793d162d2033d956f4505757ddbcc4be Mon Sep 17 00:00:00 2001 From: Valentijn Date: Sun, 11 Mar 2018 01:20:03 +0100 Subject: [PATCH 3/4] Add a semi-playlist of the liked songs --- README.md | 6 +++++- clay/gp.py | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index ed679eb..aba2287 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,8 @@ Documentation is ![available here](http://clay.readthedocs.io/en/latest/). # What works - Playback +- Configurable keybinds and colours +- Liked songs playlist (cur. static) - Music library browsing & management - Playlists - Radio stations @@ -87,7 +89,7 @@ Documentation is ![available here](http://clay.readthedocs.io/en/latest/). # What is being developed -- Like/dislike tracks +- Like/dislike tracks (mostly done but currently is a one time only action) - Playlist editing - Artist/album search - Other functionality that is supported by [gmusicapi] @@ -188,6 +190,8 @@ You *should* get the sound working. Also docker will reuse the Clay config file - ` u` - remove highlighted song from the queue - ` p` - start station from highlighted song - ` m` - show context menu for this song +- ` u` - thumb up the highlighted song +- ` d` - thumb down the highlighted song ## Playback diff --git a/clay/gp.py b/clay/gp.py index 80bcbc3..079abba 100644 --- a/clay/gp.py +++ b/clay/gp.py @@ -101,10 +101,13 @@ class Track(object): self.title = data['title'] self.artist = data['artist'] self.duration = int(data['durationMillis']) - self.rating = (data['rating'] if 'rating' in data else 0) + self.rating = (int(data['rating']) if 'rating' in data else 0) self.source = source self.cached_url = None + if self.rating == 5: + gp.cached_liked_songs.add_liked_song(self) + # User uploaded songs miss a store_id self.album_name = data['album'] self.album_url = (data['albumArtRef'][0]['url'] if 'albumArtRef' in data else "") @@ -233,6 +236,9 @@ class Track(object): self.original_data['rating'] = rating self.rating = rating + if rating == 5: + gp.cached_liked_songs.add_liked_song(self) + # print(gp.mobile_client.rate_songs(self.original_data, rating)) def __str__(self): @@ -375,6 +381,46 @@ class Playlist(object): ) +class LikedSongs(object): + """ + A local model that represents the songs that a user liked and displays them as a faux playlist. + + This mirrors the "liked songs" generated playlist feature of the Google Play Music apps. + """ + def __init__(self): + self._id = None # pylint: disable=invalid-name + self.name = "Liked Songs" + self._tracks = [] + self._sorted = False + + @property + def tracks(self): + """ + Get a sorted list of liked tracks. + """ + if self._sorted: + tracks = self._tracks + else: + self._tracks.sort(key=lambda k: k.original_data['lastRatingChangeTimestamp'], + reverse=True) + self._sorted = True + tracks = self._tracks + + return tracks + + def add_liked_song(self, song): + """ + Add a liked song to the list. + """ + self._tracks.append(song) + + def remove_liked_song(self, song): + """ + Remove a liked song from the list + """ + self._tracks.remove(song) + + class _GP(object): """ Interface to :class:`gmusicapi.Mobileclient`. Implements @@ -395,6 +441,7 @@ class _GP(object): # self.debug_file = open('/tmp/clay-api-log.json', 'w') # self._last_call_index = 0 self.cached_tracks = None + self.cached_liked_songs = LikedSongs() self.cached_playlists = None self.invalidate_caches() @@ -488,6 +535,7 @@ class _GP(object): return self.cached_tracks data = self.mobile_client.get_all_songs() self.cached_tracks = Track.from_data(data, Track.SOURCE_LIBRARY, True) + return self.cached_tracks get_all_tracks_async = asynchronous(get_all_tracks) @@ -506,14 +554,15 @@ class _GP(object): Return list of :class:`.Playlist` instances. """ if self.cached_playlists: - return self.cached_playlists + return [self.cached_liked_songs] + self.cached_playlists + self.get_all_tracks() self.cached_playlists = Playlist.from_data( self.mobile_client.get_all_user_playlist_contents(), True ) - return self.cached_playlists + return [self.cached_liked_songs] + self.cached_playlists get_all_user_playlist_contents_async = ( # pylint: disable=invalid-name asynchronous(get_all_user_playlist_contents) From 78a297e279e6125636054a61756f0b67f75b03eb Mon Sep 17 00:00:00 2001 From: Valentijn Date: Sat, 26 May 2018 21:56:17 +0200 Subject: [PATCH 4/4] Auto start songs; Unicode config option; Rating icons --- clay/config.yaml | 6 ++++-- clay/playbar.py | 9 +++++++-- clay/settings.py | 1 - clay/songlist.py | 43 +++++++++++++++++++++++++++++++++++++------ 4 files changed, 48 insertions(+), 11 deletions(-) diff --git a/clay/config.yaml b/clay/config.yaml index 8d08b81..50c5b31 100644 --- a/clay/config.yaml +++ b/clay/config.yaml @@ -21,10 +21,11 @@ hotkeys: handle_escape: esc, mod + _ library_item: - play: enter + activate: enter + play: mod + p append: mod + a unappend: mod + u - request_station: mod + p + request_station: meta + s show_context_menu: meta + p thumbs_up: meta + u thumbs_down: meta + d @@ -51,6 +52,7 @@ hotkeys: clay_settings: x_keybinds: false + unicode: true play_settings: authtoken: diff --git a/clay/playbar.py b/clay/playbar.py index 3804af7..daf0ef6 100644 --- a/clay/playbar.py +++ b/clay/playbar.py @@ -5,6 +5,7 @@ PlayBar widget. import urwid from clay.player import player +from clay.settings import settings from clay import meta @@ -71,8 +72,11 @@ class PlayBar(urwid.Pile): """ A widget that shows currently played track, playback progress and flags. """ - + _unicode = settings.get('unicode', 'clay_settings') ROTATING = u'|' u'/' u'\u2014' u'\\' + RATING_ICONS = {0: ' ', + 1: u'\U0001F593' if _unicode else '-', + 5: u'\U0001F592' if _unicode else '+'} def __init__(self, app): # super(PlayBar, self).__init__(*args, **kwargs) @@ -130,7 +134,7 @@ class PlayBar(urwid.Pile): ) progress = player.get_play_progress_seconds() total = player.get_length_seconds() - return (self.get_style(), u' {} {} - {} [{:02d}:{:02d} / {:02d}:{:02d}]'.format( + return (self.get_style(), u' {} {} - {} {} [{:02d}:{:02d} / {:02d}:{:02d}]'.format( # u'|>' if player.is_playing else u'||', # self.get_rotating_bar(), u'\u2505' if player.is_loading @@ -138,6 +142,7 @@ class PlayBar(urwid.Pile): else u'\u25A0', track.artist, track.title, + self.RATING_ICONS[track.rating], progress // 60, progress % 60, total // 60, diff --git a/clay/settings.py b/clay/settings.py index 80533a2..981b70e 100644 --- a/clay/settings.py +++ b/clay/settings.py @@ -144,7 +144,6 @@ class _Settings(object): except (KeyError, TypeError): return self._get_section(self._default_config, *sections) - def get_default_config_section(self, *sections): """ Always get a section from the default/system configuration. You would use this whenever diff --git a/clay/songlist.py b/clay/songlist.py index 8771685..b7cb97e 100644 --- a/clay/songlist.py +++ b/clay/songlist.py @@ -24,8 +24,10 @@ class SongListItem(urwid.Pile): """ Widget that represents single song item. """ + _unicode = settings.get('unicode', 'clay_settings') signals = [ 'activate', + 'play', 'append-requested', 'unappend-requested', 'station-requested', @@ -57,8 +59,13 @@ class SongListItem(urwid.Pile): 3: u'\u25A0' } + RATING_ICONS = {0: ' ', + 1: u'\U0001F593' if _unicode else '-', + 5: u'\U0001F592' if _unicode else '+'} + def __init__(self, track): self.track = track + self.rating = self.RATING_ICONS[track.rating] self.index = 0 self.state = SongListItem.STATE_IDLE self.line1_left = urwid.SelectableIcon('', cursor_position=1000) @@ -107,7 +114,6 @@ class SongListItem(urwid.Pile): """ return SongListItem.STATE_ICONS[state] - def update_text(self): """ Update text of this item from the attached track. @@ -118,13 +124,16 @@ class SongListItem(urwid.Pile): icon=self.get_state_icon(self.state), title=self.track.title, minutes=self.track.duration // (1000 * 60), - seconds=(self.track.duration // 1000) % 60 + seconds=(self.track.duration // 1000) % 60, ) ) + if settings.get_is_file_cached(self.track.filename): self.line1_right.set_text(u' \u25bc Cached') else: self.line1_right.set_text(u'') + + self.line1_right.set_text(u'{rating}'.format(rating=self.rating)) self.line2.set_text( u' {} \u2015 {}'.format(self.track.artist, self.track.album_name) ) @@ -136,9 +145,10 @@ class SongListItem(urwid.Pile): """ Return song artist and title. """ - return u'{} - {}'.format( + return u'{} - {} {}'.format( self.track.artist, - self.track.title + self.track.title, + self.rating ) def keypress(self, size, key): @@ -158,27 +168,37 @@ class SongListItem(urwid.Pile): def thumbs_up(self): """ - Toggle the thumbs up of this song. + Thumb the currently selected song up. """ self.track.rate_song((0 if self.track.rating == 5 else 5)) def thumbs_down(self): + """ + Thumb the currently selected song down. + """ self.track.rate_song((0 if self.track.rating == 1 else 1)) def _send_signal(self, signal): urwid.emit_signal(self, signal, self) + def activate(self): + """ + Add the entire list to queue and begin playing + """ + self._send_signal("activate") + def play(self): """ Play this song. """ - self._send_signal("activate") + self._send_signal("play") def append(self): """ Add this song to the queue. """ self._send_signal("append-requested") + self.play() def unappend(self): """ @@ -466,6 +486,10 @@ class SongListBox(urwid.Frame): urwid.connect_signal( songitem, 'activate', self.item_activated ) + + urwid.connect_signal( + songitem, 'play', self.item_play_pause + ) urwid.connect_signal( songitem, 'append-requested', self.item_append_requested ) @@ -481,6 +505,13 @@ class SongListBox(urwid.Frame): items.append(songitem) return (items, current_index) + def item_play_pause(self, songitem): + """ + Called when you want to start playing a song. + """ + if songitem.is_currently_played: + player.play_pause() + def item_activated(self, songitem): """ Called when specific song item is activated.