diff --git a/CHANGELOG.rst b/CHANGELOG.rst new file mode 100644 index 0000000..ebcb9e6 --- /dev/null +++ b/CHANGELOG.rst @@ -0,0 +1,143 @@ +Changelog +--------- + +Clay 1.0.0 +========== + +2018-04-06 + + * Configurable keybinds (by Valentijn) + * Configurable colors (by Valentijn) + * Pluggable X keybinds (by Valentijn) + * "My stations" page (by @Fluctuz) + * Better settings management (by Valentijn) + * Equalizer + * Track caching indicator + * Optimized settings & cache + * Code complexity & code climate integration + * Countless fixes + * Badges! + * IRC channel! + +Clay 0.7.2 +========== + +2018-02-12 + + * Added --transparent flag + +Clay 0.7.1 +========== + +2018-02-08 + + * Late fix for broken setup.py entrypoint support + +Clay 0.7.0 +========== + +2018-02-08 + + * Added Dockerfile + * Fixed installation instructions in README (by Valentijn) + * Load global hotkeys when X is running only (by Valentijn) + * Clarified in README that keybinder and pygobject are optional (by Valentijn) + * Improved error handling and reporting (by Valentijn) + * Version, help and keybinder command line arguments (by Valentijn) + * Added copyright (by Valentijn) + +Clay 0.6.2 +========== + +2018-02-02 + + * Fixed playback for non-subscribed accounts + +Clay 0.6.1 +========== + +2018-02-02 + + * Attempt to fix purchased song while not on paid subscription + +Clay 0.6.0 +========== + +2018-02-01 + + * Added track caching option + * More debugging + +Clay 0.5.6 +========== + +2018-01-31 + + * Added debug page + +Clay 0.5.5 +========== + +2018-01-31 + + * Added CLAY_DEBUG to log Google Play Music traffic + * Fixed typo in install_requires + * Updated README + +Clay 0.5.3 +========== + +2018-01-30 + + * Added codename + * Linter fixes + +Clay 0.5.2 +========== + +2018-01-30 + + * Fixed versioning + +Clay 0.5.1 +========== + +2018-01-30 + + * Debugging + * Cleanup & typos + * Fixed issue with uploaded tracks + +Clay 0.5 +======== + +2018-01-29 + + * Added slider for eqializer + * Updated README + * Misc fixes + +Clay 0.4 +======== + * Added equalizer + +2018-01-29 + +Clay 0.3 +======== + +2018-01-26 + + * Initial functionality + * Cleanups + * Notifications + * Hotkeys + * Linting + * Documentation + * Song search + * Song context menu + * Clearer song IDs + * Auth token caching + * Colors + * Copy URL to clipboard + diff --git a/Dockerfile b/Dockerfile index 8f81443..2aca90a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,8 +15,6 @@ RUN apt-get install -y python3.6-dev python3-pip libvlc-dev vlc locales language RUN locale-gen en_US.UTF-8 RUN useradd ${HOST_USER} -m -G audio -u ${HOST_UID} -#RUN mkdir -p /home/${HOST_USER}/.config/clay -#RUN chown ${HOST_USER} /home/${HOST_USER}/.config/clay WORKDIR /home/${HOST_USER} diff --git a/Makefile b/Makefile index ece1a37..29f9bf7 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,11 @@ CMD ?= "./clay/app.py" +# Build Clay Docker image build: echo $(shell id -u) docker build -t clay --build-arg HOST_USER=${USER} --build-arg HOST_UID=$(shell id -u) . +# Run Clay Docker image run: | build docker run -it \ --rm \ @@ -20,10 +22,12 @@ run: | build clay \ ${CMD} +# Generate Sphinx docs .PHONY: docs docs: make -C docs html +# Run pylint & radon check: pylint clay --ignore-imports=y radon cc -a -s -nC -e clay/vlc.py clay diff --git a/README.md b/README.md index aba2287..e1dd557 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ * [Misc](#misc) - [Troubleshooting](#troubleshooting) - [Credits](#credits) +- [Changelog](./CHANGELOG.rst) # Clay [beta] @@ -55,7 +56,7 @@ clay # Documentation -Documentation is ![available here](http://clay.readthedocs.io/en/latest/). +Documentation is [available here](http://clay.readthedocs.io/en/latest/). # Requirements @@ -235,14 +236,15 @@ Made by Andrew Dunai. Regards to [gmusicapi] and [VLC] who made this possible. -Special thanks to the people who contribute to this project: +People who contribute to this project: -- [Valentijn (@ValentijnvdBeek)](https://github.com/ValentijnvdBeek) -- [Sam Kingston (@sjkingo)](https://github.com/sjkingo) +- [@ValentijnvdBeek (Valentijn)](https://github.com/ValentijnvdBeek) +- [@Fluctuz](https://github.com/Fluctuz) +- [@sjkingo (Sam Kingston)](https://github.com/sjkingo) [gmusicapi]: https://github.com/simon-weber/gmusicapi [VLC]: https://wiki.videolan.org/python_bindings -[urwid]: urwid.org/ +[urwid]: http://www.urwid.org/ [pyyaml]: https://github.com/yaml/pyyaml [PyGObject]: https://pygobject.readthedocs.io/en/latest/getting_started.html [Keybinder]: https://github.com/kupferlauncher/keybinder diff --git a/clay/app.py b/clay/app.py index a98b0ce..30d8122 100755 --- a/clay/app.py +++ b/clay/app.py @@ -18,6 +18,7 @@ from clay.playbar import PlayBar from clay.pages.debug import DebugPage from clay.pages.mylibrary import MyLibraryPage from clay.pages.myplaylists import MyPlaylistsPage +from clay.pages.mystations import MyStationsPage from clay.pages.playerqueue import QueuePage from clay.pages.search import SearchPage from clay.pages.settings import SettingsPage @@ -68,6 +69,7 @@ class AppWidget(urwid.Frame): DebugPage(self), MyLibraryPage(self), MyPlaylistsPage(self), + MyStationsPage(self), QueuePage(self), SearchPage(self), SettingsPage(self) @@ -105,7 +107,11 @@ class AppWidget(urwid.Frame): Request user authorization. """ - authtoken, device_id, _, password, username = settings.get_section("play_settings").values() + authtoken, device_id, username, password = [ + settings.get(key, "play_settings") + for key + in ('authtoken', 'device_id', 'username', 'password') + ] if self._login_notification: self._login_notification.close() diff --git a/clay/colours.yaml b/clay/colours.yaml index 785027f..67657cf 100644 --- a/clay/colours.yaml +++ b/clay/colours.yaml @@ -1,10 +1,10 @@ default: &default foreground: "#FFF" - background: "#222" + background: null primary: &primary foreground: "#F54" - background: "#FFF" + background: null primary_inv: &primary_inv foreground: "#FFF" diff --git a/clay/config.yaml b/clay/config.yaml index 50c5b31..fd60f87 100644 --- a/clay/config.yaml +++ b/clay/config.yaml @@ -40,6 +40,9 @@ hotkeys: playlist_page: start_playlist: enter + station_page: + start_station: enter + debug_page: copy_message: enter diff --git a/clay/eventhook.py b/clay/eventhook.py index a528b94..b75cbb5 100644 --- a/clay/eventhook.py +++ b/clay/eventhook.py @@ -1,7 +1,6 @@ """ Events implemetation for signal handling. """ -# pylint: disable=too-few-public-methods class EventHook(object): diff --git a/clay/gp.py b/clay/gp.py index 079abba..241fc4d 100644 --- a/clay/gp.py +++ b/clay/gp.py @@ -2,11 +2,7 @@ Google Play Music integration via gmusicapi. """ # pylint: disable=broad-except -# pylint: disable=too-many-arguments -# pylint: disable=too-many-instance-attributes -# pylint: disable=too-many-return-statements # pylint: disable=protected-access -# pylint: disable=no-self-use from __future__ import print_function from threading import Thread, Lock from uuid import UUID @@ -16,6 +12,8 @@ from gmusicapi.clients import Mobileclient from clay.eventhook import EventHook from clay.log import logger +STATION_FETCH_LEN = 50 + def asynchronous(func): """ @@ -202,11 +200,12 @@ class Track(object): Returns :class:`.Station` instance. """ + station_name = u'Station - {}'.format(self.title) station_id = gp.mobile_client.create_station( - name=u'Station - {}'.format(self.title), + name=station_name, track_id=self.store_id ) - station = Station(station_id) + station = Station(station_id, station_name) station.load_tracks() return station @@ -285,7 +284,8 @@ class Station(object): """ Model that represents specific station on Google Play Music. """ - def __init__(self, station_id): + def __init__(self, station_id, name): + self.name = name self._id = station_id self._tracks = [] self._tracks_loaded = False @@ -302,9 +302,12 @@ class Station(object): Fetch tracks related to this station and populate it with :class:`Track` instances. """ - data = gp.mobile_client.get_station_tracks(self.id, 100) + data = gp.mobile_client.get_station_tracks(self.id, STATION_FETCH_LEN) self._tracks = Track.from_data(data, Track.SOURCE_STATION, many=True) self._tracks_loaded = True + return self + + load_tracks_async = asynchronous(load_tracks) def get_tracks(self): """ @@ -313,6 +316,20 @@ class Station(object): assert self._tracks_loaded, 'Must call ".load_tracks()" before ".get_tracks()"' return self._tracks + @classmethod + def from_data(cls, data, many=False): + """ + Construct and return one or many :class:`.Station` instances + from Google Play Music API response. + """ + if many: + return [cls.from_data(one) for one in data if one['inLibrary']] + + return Station( + station_id=data['id'], + name=data['name'] + ) + class SearchResults(object): """ @@ -443,6 +460,7 @@ class _GP(object): self.cached_tracks = None self.cached_liked_songs = LikedSongs() self.cached_playlists = None + self.cached_stations = None self.invalidate_caches() @@ -475,10 +493,11 @@ class _GP(object): def invalidate_caches(self): """ - Clear cached tracks & playlists. + Clear cached tracks & playlists & stations. """ self.cached_tracks = None self.cached_playlists = None + self.cached_stations = None self.caches_invalidated.fire() @synchronized @@ -548,6 +567,25 @@ class _GP(object): get_stream_url_async = asynchronous(get_stream_url) + @synchronized + def get_all_user_station_contents(self, **_): + """ + Return list of :class:`.Station` instances. + """ + if self.cached_stations: + return self.cached_stations + self.get_all_tracks() + + self.cached_stations = Station.from_data( + self.mobile_client.get_all_stations(), + True + ) + return self.cached_stations + + get_all_user_station_contents_async = ( # pylint: disable=invalid-name + asynchronous(get_all_user_station_contents) + ) + @synchronized def get_all_user_playlist_contents(self, **_): """ diff --git a/clay/meta.py b/clay/meta.py index 899dd93..7864e89 100644 --- a/clay/meta.py +++ b/clay/meta.py @@ -2,7 +2,7 @@ Predefined values. """ APP_NAME = 'Clay Player' -VERSION = '0.7.2' +VERSION = '1.0.0' AUTHOR = "Andrew Dunai" DESCRIPTION = "Awesome standalone command line player for Google Play Music" diff --git a/clay/pages/mylibrary.py b/clay/pages/mylibrary.py index 15d853b..3159c7c 100644 --- a/clay/pages/mylibrary.py +++ b/clay/pages/mylibrary.py @@ -43,7 +43,6 @@ class MyLibraryPage(urwid.Columns, AbstractPage): if error: notification_area.notify('Failed to load my library: {}'.format(str(error))) return - # self.notification.close() self.songlist.populate(tracks) self.app.redraw() diff --git a/clay/pages/myplaylists.py b/clay/pages/myplaylists.py index 4d7a000..49d7d4e 100644 --- a/clay/pages/myplaylists.py +++ b/clay/pages/myplaylists.py @@ -97,8 +97,6 @@ class MyPlaylistListBox(urwid.ListBox): ) items.append(myplaylistlistitem) - # self.notification.close() - self.walker[:] = items self.app.redraw() diff --git a/clay/pages/mystations.py b/clay/pages/mystations.py new file mode 100644 index 0000000..cf1d74d --- /dev/null +++ b/clay/pages/mystations.py @@ -0,0 +1,160 @@ +""" +Components for "My stations" page. +""" +import urwid + +from clay.gp import gp +from clay.songlist import SongListBox +from clay.notifications import notification_area +from clay.pages.page import AbstractPage +from clay.hotkeys import hotkey_manager + + +class MyStationListItem(urwid.Columns): + """ + One station in the list of stations. + """ + signals = ['activate'] + + def __init__(self, station): + self.station = station + self.text = urwid.SelectableIcon(u' \u2708 {} '.format( + self.station.name + ), cursor_position=3) + self.text.set_layout('left', 'clip', None) + self.content = urwid.AttrWrap( + self.text, + 'default', + 'selected' + ) + super(MyStationListItem, self).__init__([self.content]) + + def keypress(self, size, key): + """ + Handle keypress. + """ + return hotkey_manager.keypress("station_page", self, super(MyStationListItem, self), + size, key) + + def start_station(self): + """ + Start playing the selected station + """ + urwid.emit_signal(self, 'activate', self) + return None + + +class MyStationListBox(urwid.ListBox): + """ + List of stations. + """ + signals = ['activate'] + + def __init__(self, app): + self.app = app + + self.walker = urwid.SimpleListWalker([ + urwid.Text('Not ready') + ]) + self.notification = None + + gp.auth_state_changed += self.auth_state_changed + + super(MyStationListBox, self).__init__(self.walker) + + def auth_state_changed(self, is_auth): + """ + Called when auth state changes (e. g. user is logged in). + Requests fetching of station. + """ + if is_auth: + self.walker[:] = [ + urwid.Text(u'\n \uf01e Loading stations...', align='center') + ] + + gp.get_all_user_station_contents_async(callback=self.on_get_stations) + + def on_get_stations(self, stations, error): + """ + Called when a list of stations fetch completes. + Populates list of stations. + """ + if error: + notification_area.notify('Failed to get stations: {}'.format(str(error))) + + items = [] + for station in stations: + mystationlistitem = MyStationListItem(station) + urwid.connect_signal( + mystationlistitem, 'activate', self.item_activated + ) + items.append(mystationlistitem) + + self.walker[:] = items + + self.app.redraw() + + def item_activated(self, mystationlistitem): + """ + Called when a specific station is selected. + Re-emits this event. + """ + urwid.emit_signal(self, 'activate', mystationlistitem) + + +class MyStationsPage(urwid.Columns, AbstractPage): + """ + Stations page. + + Contains two parts: + + - List of stations (:class:`.MyStationBox`) + - List of songs in selected station (:class:`clay:songlist:SongListBox`) + """ + @property + def name(self): + return 'Stations' + + @property + def key(self): + return 3 + + def __init__(self, app): + self.app = app + + self.stationlist = MyStationListBox(app) + self.songlist = SongListBox(app) + self.songlist.set_placeholder('\n Select a station.') + + urwid.connect_signal( + self.stationlist, 'activate', self.mystationlistitem_activated + ) + + super(MyStationsPage, self).__init__([ + self.stationlist, + self.songlist + ]) + + def mystationlistitem_activated(self, mystationlistitem): + """ + Called when specific station is selected. + Requests fetching of station tracks + """ + self.songlist.set_placeholder(u'\n \uf01e Loading station tracks...') + mystationlistitem.station.load_tracks_async(callback=self.on_station_loaded) + + def on_station_loaded(self, station, error): + """ + Called when station tracks fetch completes. + Populates songlist with tracks from the selected station. + """ + if error: + notification_area.notify('Failed to get station tracks: {}'.format(str(error))) + + self.songlist.populate( + station.get_tracks() + ) + self.app.redraw() + + def activate(self): + pass diff --git a/clay/pages/playerqueue.py b/clay/pages/playerqueue.py index b61600c..b5c7852 100644 --- a/clay/pages/playerqueue.py +++ b/clay/pages/playerqueue.py @@ -18,7 +18,7 @@ class QueuePage(urwid.Columns, AbstractPage): @property def key(self): - return 3 + return 4 def __init__(self, app): self.app = app diff --git a/clay/pages/search.py b/clay/pages/search.py index f76604c..f9aa32f 100644 --- a/clay/pages/search.py +++ b/clay/pages/search.py @@ -58,7 +58,7 @@ class SearchPage(urwid.Pile, AbstractPage): @property def key(self): - return 4 + return 5 def __init__(self, app): self.app = app diff --git a/clay/pages/settings.py b/clay/pages/settings.py index 5bc9813..12cf496 100644 --- a/clay/pages/settings.py +++ b/clay/pages/settings.py @@ -17,11 +17,11 @@ class Slider(urwid.Widget): _sizing = frozenset([urwid.FLOW]) CHARS = [ - # '_', - u'\u2581', - u'\u2500', - u'\u2594' + u'\u2584', + u'\u25A0', + u'\u2580', ] + ZERO_CHAR = u'\u2500' def selectable(self): return True @@ -36,7 +36,7 @@ class Slider(urwid.Widget): else: self.freq_str = str(freq) + '\nHz' self.value = 0 - self.slider_height = 5 + self.slider_height = 13 self.max_value = 20 super(Slider, self).__init__() @@ -50,16 +50,18 @@ class Slider(urwid.Widget): """ Render widget. """ - rows = [('+' if self.value >= 0 else '') + str(self.value) + ' dB'] + rows = [('+' if self.value > 0 else '') + str(self.value) + ' dB'] - chars = [' '] * 5 + chars = [' '] * self.slider_height - k = ((float(self.value) / (self.max_value + 1)) + 1) / 2 # Convert value to [0;1] range - section_index = int(k * self.slider_height) - char_index = int(k * self.slider_height * len(Slider.CHARS)) % len(Slider.CHARS) - chars[section_index] = Slider.CHARS[char_index] + if self.value == 0: + chars[self.slider_height // 2] = Slider.ZERO_CHAR + else: + k = ((float(self.value) / (self.max_value + 1)) + 1) / 2 # Convert value to [0;1] range + section_index = int(k * self.slider_height) + char_index = int(k * self.slider_height * len(Slider.CHARS)) % len(Slider.CHARS) + chars[section_index] = Slider.CHARS[char_index] - # rows.extend(['X'] * self.slider_height) rows.extend([ ( u'\u2524{}\u251C' @@ -139,17 +141,17 @@ class SettingsPage(urwid.Columns, AbstractPage): def __init__(self, app): self.app = app self.username = urwid.Edit( - edit_text=settings.get('username', 'play_settings') + edit_text=settings.get('username', 'play_settings') or '' ) self.password = urwid.Edit( - mask='*', edit_text=settings.get('password', 'play_settings') + mask='*', edit_text=settings.get('password', 'play_settings') or '' ) self.device_id = urwid.Edit( - edit_text=settings.get('device_id', 'play_settings') + edit_text=settings.get('device_id', 'play_settings') or '' ) self.download_tracks = urwid.CheckBox( 'Download tracks before playback', - state=settings.get('download_tracks', 'play_settings') + state=settings.get('download_tracks', 'play_settings') or False ) self.equalizer = Equalizer() super(SettingsPage, self).__init__([urwid.ListBox(urwid.SimpleListWalker([ @@ -178,6 +180,8 @@ class SettingsPage(urwid.Columns, AbstractPage): Called when "Save" button is pressed. """ with settings.edit() as config: + if 'play_settings' not in config: + config['play_settings'] = {} config['play_settings']['username'] = self.username.edit_text config['play_settings']['password'] = self.password.edit_text config['play_settings']['device_id'] = self.device_id.edit_text diff --git a/clay/settings.py b/clay/settings.py index 981b70e..db31473 100644 --- a/clay/settings.py +++ b/clay/settings.py @@ -121,10 +121,10 @@ class _Settings(object): section = self.get_section(*sections) try: - return section[key] + return section.get(key) except (KeyError, TypeError): section = self.get_default_config_section(*sections) - return section[key] + return section.get(key) def _get_section(self, config, *sections): config = config.copy() diff --git a/clay/songlist.py b/clay/songlist.py index b7cb97e..6378d5b 100644 --- a/clay/songlist.py +++ b/clay/songlist.py @@ -9,7 +9,7 @@ try: # Python 3.x from string import ascii_letters except ImportError: - # Python 2.3 + # Python 2.x from string import letters as ascii_letters import urwid from clay.notifications import notification_area