mirror of
https://github.com/vale981/clay
synced 2025-03-05 09:31:40 -05:00
Merge branch 'ratings' into artists
This commit is contained in:
commit
fbb7b5e416
8 changed files with 158 additions and 135 deletions
|
@ -70,6 +70,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
|
||||
|
@ -88,7 +90,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]
|
||||
|
@ -189,6 +191,8 @@ You *should* get the sound working. Also docker will reuse the Clay config file
|
|||
- `<CTRL> u` - remove highlighted song from the queue
|
||||
- `<CTRL> p` - start station from highlighted song
|
||||
- `<ALT> m` - show context menu for this song
|
||||
- `<ALT> u` - thumb up the highlighted song
|
||||
- `<ALT> d` - thumb down the highlighted song
|
||||
|
||||
## Playback
|
||||
|
||||
|
|
|
@ -21,11 +21,14 @@ 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
|
||||
|
||||
library_view:
|
||||
move_to_beginning: home
|
||||
|
@ -52,6 +55,7 @@ hotkeys:
|
|||
|
||||
clay_settings:
|
||||
x_keybinds: false
|
||||
unicode: true
|
||||
|
||||
play_settings:
|
||||
authtoken:
|
||||
|
|
208
clay/gp.py
208
clay/gp.py
|
@ -85,27 +85,32 @@ class Track(object):
|
|||
SOURCE_PLAYLIST = 'playlist'
|
||||
SOURCE_SEARCH = 'search'
|
||||
|
||||
def __init__( # pylint: disable=too-many-arguments
|
||||
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 = (int(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
|
||||
if self.rating == 5:
|
||||
gp.cached_liked_songs.add_liked_song(self)
|
||||
|
||||
self.album_name = album_name
|
||||
self.album_url = album_url
|
||||
# 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.original_data = original_data
|
||||
self.original_data = data
|
||||
|
||||
@property
|
||||
def id(self): # pylint: disable=invalid-name
|
||||
|
@ -130,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):
|
||||
"""
|
||||
|
@ -231,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',
|
||||
|
@ -315,6 +227,19 @@ 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
|
||||
|
||||
if rating == 5:
|
||||
gp.cached_liked_songs.add_liked_song(self)
|
||||
|
||||
# print(gp.mobile_client.rate_songs(self.original_data, rating))
|
||||
|
||||
def __str__(self):
|
||||
return u'<Track "{} - {}" from {}>'.format(
|
||||
self.artist,
|
||||
|
@ -473,6 +398,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
|
||||
|
@ -493,6 +458,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.cached_stations = None
|
||||
|
||||
|
@ -588,6 +554,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)
|
||||
|
@ -625,14 +592,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)
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -26,8 +26,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',
|
||||
|
@ -59,8 +61,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)
|
||||
|
@ -119,13 +126,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)
|
||||
)
|
||||
|
@ -137,9 +147,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):
|
||||
|
@ -157,20 +168,39 @@ class SongListItem(urwid.Pile):
|
|||
return None
|
||||
return super(SongListItem, self).mouse_event(size, event, button, col, row, focus)
|
||||
|
||||
def thumbs_up(self):
|
||||
"""
|
||||
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):
|
||||
"""
|
||||
|
@ -468,6 +498,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
|
||||
)
|
||||
|
@ -483,6 +517,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.
|
||||
|
|
Loading…
Add table
Reference in a new issue