Merge branch 'master' into ratings

This commit is contained in:
Valentijn 2018-05-26 22:03:29 +02:00
commit 3200c67b72
18 changed files with 399 additions and 45 deletions

143
CHANGELOG.rst Normal file
View file

@ -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

View file

@ -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}

View file

@ -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

View file

@ -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

View file

@ -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()

View file

@ -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"

View file

@ -40,6 +40,9 @@ hotkeys:
playlist_page:
start_playlist: enter
station_page:
start_station: enter
debug_page:
copy_message: enter

View file

@ -1,7 +1,6 @@
"""
Events implemetation for signal handling.
"""
# pylint: disable=too-few-public-methods
class EventHook(object):

View file

@ -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, **_):
"""

View file

@ -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"

View file

@ -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()

View file

@ -97,8 +97,6 @@ class MyPlaylistListBox(urwid.ListBox):
)
items.append(myplaylistlistitem)
# self.notification.close()
self.walker[:] = items
self.app.redraw()

160
clay/pages/mystations.py Normal file
View file

@ -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

View file

@ -18,7 +18,7 @@ class QueuePage(urwid.Columns, AbstractPage):
@property
def key(self):
return 3
return 4
def __init__(self, app):
self.app = app

View file

@ -58,7 +58,7 @@ class SearchPage(urwid.Pile, AbstractPage):
@property
def key(self):
return 4
return 5
def __init__(self, app):
self.app = app

View file

@ -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
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

View file

@ -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()

View file

@ -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