From 808c0177e538f2223009131355d75e989d32f436 Mon Sep 17 00:00:00 2001 From: Andrew Dunai Date: Tue, 13 Feb 2018 20:29:49 +0200 Subject: [PATCH 01/17] Added better settings support. --- clay/app.py | 8 +-- clay/hotkeys.py | 5 +- clay/pages/settings.py | 23 ++++---- clay/player.py | 9 ++-- clay/settings.py | 117 +++++++++++++++++++++++++++++------------ 5 files changed, 105 insertions(+), 57 deletions(-) diff --git a/clay/app.py b/clay/app.py index 1daefd3..900e19a 100755 --- a/clay/app.py +++ b/clay/app.py @@ -23,7 +23,7 @@ from clay.pages.myplaylists import MyPlaylistsPage from clay.pages.playerqueue import QueuePage from clay.pages.search import SearchPage from clay.pages.settings import SettingsPage -from clay.settings import Settings +from clay.settings import settings from clay.notifications import NotificationArea from clay.gp import GP @@ -185,9 +185,8 @@ class AppWidget(urwid.Frame): Request user authorization. """ - config = Settings.get_config() username, password, device_id, authtoken = [ - config.get(x) + settings.get(x) for x in ('username', 'password', 'device_id', 'authtoken') ] @@ -247,7 +246,8 @@ class AppWidget(urwid.Frame): ) return - Settings.set_config(dict(authtoken=GP.get().get_authtoken())) + with settings.edit() as config: + config['authtoken'] = GP.get().get_authtoken() self._login_notification.close() diff --git a/clay/hotkeys.py b/clay/hotkeys.py index fa8a0f7..d0e6199 100644 --- a/clay/hotkeys.py +++ b/clay/hotkeys.py @@ -5,7 +5,7 @@ Requires "gi" package and "Gtk" & "Keybinder" modules. # pylint: disable=broad-except import threading -from clay.settings import Settings +from clay.settings import settings from clay.eventhook import EventHook from clay.notifications import NotificationArea from clay.log import Logger @@ -84,8 +84,7 @@ class HotkeyManager(object): """ Load hotkey config from settings. """ - config = Settings.get_config() - hotkeys = config.get('hotkeys', {}) + hotkeys = settings.get('hotkeys', {}) for operation, default_key in HotkeyManager.DEFAULT_HOTKEYS.items(): if operation not in hotkeys or not hotkeys[operation]: hotkeys[operation] = default_key diff --git a/clay/pages/settings.py b/clay/pages/settings.py index 773e459..e2faacb 100644 --- a/clay/pages/settings.py +++ b/clay/pages/settings.py @@ -4,7 +4,7 @@ Components for "Settings" page. import urwid from clay.pages.page import AbstractPage -from clay.settings import Settings +from clay.settings import settings from clay.player import Player @@ -128,19 +128,18 @@ class SettingsPage(urwid.Columns, AbstractPage): def __init__(self, app): self.app = app - config = Settings.get_config() self.username = urwid.Edit( - edit_text=config.get('username', '') + edit_text=settings.get('username', '') ) self.password = urwid.Edit( - mask='*', edit_text=config.get('password', '') + mask='*', edit_text=settings.get('password', '') ) self.device_id = urwid.Edit( - edit_text=config.get('device_id', '') + edit_text=settings.get('device_id', '') ) self.download_tracks = urwid.CheckBox( 'Download tracks before playback', - state=config.get('download_tracks', False) + state=settings.get('download_tracks', False) ) self.equalizer = Equalizer() super(SettingsPage, self).__init__([urwid.ListBox(urwid.SimpleListWalker([ @@ -168,12 +167,12 @@ class SettingsPage(urwid.Columns, AbstractPage): """ Called when "Save" button is pressed. """ - Settings.set_config(dict( - username=self.username.edit_text, - password=self.password.edit_text, - device_id=self.device_id.edit_text, - download_tracks=self.download_tracks.state - )) + with settings.edit() as config: + config['username'] = self.username.edit_text + config['password'] = self.password.edit_text + config['device_id'] = self.device_id.edit_text + config['download_tracks'] = self.download_tracks.state + self.app.set_page('MyLibraryPage') self.app.log_in() diff --git a/clay/player.py b/clay/player.py index 36a9b4d..7510a6b 100644 --- a/clay/player.py +++ b/clay/player.py @@ -15,9 +15,10 @@ except ImportError: # Python 2.x from clay import vlc, meta from clay.eventhook import EventHook from clay.notifications import NotificationArea -from clay.settings import Settings +from clay.settings import settings from clay.log import Logger + class Queue(object): """ Model that represents player queue (local playlist), @@ -346,8 +347,8 @@ class Player(object): self.broadcast_state() self.track_changed.fire(track) - if Settings.get_config().get('download_tracks', False): - path = Settings.get_cached_file_path(track.store_id + '.mp3') + if settings.get('download_tracks', False): + path = settings.get_cached_file_path(track.store_id + '.mp3') if path is None: self.logger.debug('Track %s not in cache, downloading...', track.store_id) track.get_url(callback=self._download_track) @@ -368,7 +369,7 @@ class Player(object): ) return response = urlopen(url) - path = Settings.save_file_to_cache(track.store_id + '.mp3', response.read()) + path = settings.save_file_to_cache(track.store_id + '.mp3', response.read()) self._play_ready(path, None, track) def _play_ready(self, url, error, track): diff --git a/clay/settings.py b/clay/settings.py index 9677d9f..77a82d3 100644 --- a/clay/settings.py +++ b/clay/settings.py @@ -1,78 +1,127 @@ """ Application settings manager. """ +from threading import Lock import os +import copy import errno import yaml import appdirs -class Settings(object): +class _SettingsEditor(dict): + """ + Thread-safe settings editor context manager. + + For example see :py:meth:`~._Settings.edit`. + """ + _lock = Lock() + + def __init__(self, original_config, commit_callback): + super(_SettingsEditor, self).__init__() + _SettingsEditor._lock.acquire() + self._commit_callback = commit_callback + self.update(copy.deepcopy(original_config)) + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, exc_tb): + _SettingsEditor._lock.release() + if exc_tb is None: + self._commit_callback(self) + else: + # TODO: Handle this + pass + + +class _Settings(object): """ Settings management class. """ - @classmethod - def get_config_filename(cls): + def __init__(self): + self._config = {} + + self._ensure_directories() + self._load_config() + + def _ensure_directories(self): """ - Return full path to config file. + Create config dir, config file & cache dir if they do not exist yet. """ - filedir = appdirs.user_config_dir('clay', 'Clay') + self._config_dir = appdirs.user_config_dir('clay', 'Clay') + self._config_file_path = os.path.join(self._config_dir, 'config.yaml') try: - os.makedirs(filedir) + os.makedirs(self._config_dir) except OSError as error: if error.errno != errno.EEXIST: raise + self._cache_dir = appdirs.user_cache_dir('clay', 'Clay') try: - os.makedirs(appdirs.user_cache_dir('clay', 'Clay')) + os.makedirs(self._cache_dir) except OSError as error: if error.errno != errno.EEXIST: raise - path = os.path.join(filedir, 'config.yaml') - if not os.path.exists(path): - with open(path, 'w') as settings: - settings.write('{}') - return path + if not os.path.exists(self._config_file_path): + with open(self._config_file_path, 'w') as settings_file: + settings_file.write('{}') - @classmethod - def get_config(cls): + def _load_config(self): """ - Read config dictionary. + Read config from file. """ - with open(Settings.get_config_filename(), 'r') as settings: - return yaml.load(settings.read()) + with open(self._config_file_path, 'r') as settings_file: + self._config = yaml.load(settings_file.read()) - @classmethod - def set_config(cls, new_config): - """ - Write config dictionary. - """ - config = Settings.get_config() - config.update(new_config) - with open(Settings.get_config_filename(), 'w') as settings: - settings.write(yaml.dump(config, default_flow_style=False)) + def _commit_edits(self, config): + self._config.update(config) + with open(self._config_file_path, 'w') as settings_file: + settings_file.write(yaml.dump(self._config, default_flow_style=False)) - @classmethod - def get_cached_file_path(cls, filename): + def get(self, key, default=None): + """ + Return config value. + """ + return self._config.get(key, default) + + def edit(self): + """ + Return :py:class:`._SettingsEditor` context manager to edit config. + + Settings are saved to file once the returned context manager exists. + + Example usage: + + .. code-block:: python + + from clay.settings import settings + + with settings.edit() as config: + config['foo']['bar'] = 'baz' + """ + return _SettingsEditor(self._config, self._commit_edits) + + def get_cached_file_path(self, filename): """ Get full path to cached file. """ - cache_dir = appdirs.user_cache_dir('clay', 'Clay') - path = os.path.join(cache_dir, filename) + path = os.path.join(self._cache_dir, filename) if os.path.exists(path): return path return None - @classmethod - def save_file_to_cache(cls, filename, content): + def save_file_to_cache(self, filename, content): """ Save content into file in cache. """ - cache_dir = appdirs.user_cache_dir('clay', 'Clay') - path = os.path.join(cache_dir, filename) + path = os.path.join(self._cache_dir, filename) with open(path, 'wb') as cachefile: cachefile.write(content) return path + + +settings = _Settings() # pylint: disable=invalid-name From 130d5ad1c20119306fc3264065be07e633714163 Mon Sep 17 00:00:00 2001 From: Andrew Dunai Date: Tue, 13 Feb 2018 20:36:28 +0200 Subject: [PATCH 02/17] Docs. --- clay/settings.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/clay/settings.py b/clay/settings.py index 77a82d3..2498c7c 100644 --- a/clay/settings.py +++ b/clay/settings.py @@ -78,6 +78,12 @@ class _Settings(object): self._config = yaml.load(settings_file.read()) def _commit_edits(self, config): + """ + Write config to file. + + This method is supposed to be called only + from :py:meth:`~._SettingsEditor.__exit__`. + """ self._config.update(config) with open(self._config_file_path, 'w') as settings_file: settings_file.write(yaml.dump(self._config, default_flow_style=False)) From d69a019fdc1ba99f7f5c7b17b6739e1fa66825b0 Mon Sep 17 00:00:00 2001 From: Andrew Dunai Date: Tue, 13 Feb 2018 23:23:17 +0200 Subject: [PATCH 03/17] Badges! --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e64a26b..c1a7dee 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ # Clay [beta] -[![Build Status](https://travis-ci.org/and3rson/clay.svg?branch=master)](https://travis-ci.org/and3rson/clay) [![Documentation Status](https://readthedocs.org/projects/clay/badge/?version=latest)](http://clay.readthedocs.io/en/latest/?badge=latest) +[![Build Status](https://travis-ci.org/and3rson/clay.svg?branch=master)](https://travis-ci.org/and3rson/clay) [![Documentation Status](https://readthedocs.org/projects/clay/badge/?version=latest)](http://clay.readthedocs.io/en/latest/?badge=latest) [![PyPI version](https://badge.fury.io/py/clay-player.svg)](https://badge.fury.io/py/clay-player) Standalone command line player for Google Play Music. From c9bac58c4df670dd426b0e8913764476cada59b3 Mon Sep 17 00:00:00 2001 From: Andrew Dunai Date: Tue, 13 Feb 2018 23:30:49 +0200 Subject: [PATCH 04/17] Added IRC info. --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 0b8d4d0..7ef022f 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,11 @@ This project is neither affiliated nor endorsed by Google. It's being actively developed, but is still in the early beta stage, so many features are missing and/or may be bugged. +We're on IRC! + +- Server: irc.oftc.net +- Channel: **#clay** + Screenshot: ![Clay Player screenshot](./images/clay-screenshot.png) From c1803ed276ce608e2328c490222ad23f80f728ed Mon Sep 17 00:00:00 2001 From: Andrew Dunai Date: Wed, 14 Feb 2018 18:25:23 +0200 Subject: [PATCH 05/17] Added cached tracks indicators. --- clay/gp.py | 7 +++++++ clay/player.py | 6 +++--- clay/settings.py | 19 +++++++++++++++++++ clay/songlist.py | 17 ++++++++++++++--- 4 files changed, 43 insertions(+), 6 deletions(-) diff --git a/clay/gp.py b/clay/gp.py index e9ba98e..29e126d 100644 --- a/clay/gp.py +++ b/clay/gp.py @@ -117,6 +117,13 @@ class Track(object): return self.library_id return self.store_id + @property + def filename(self): + """ + Return a filename for this track. + """ + return self.store_id + '.mp3' + def __eq__(self, other): return ( (self.library_id and self.library_id == other.library_id) or diff --git a/clay/player.py b/clay/player.py index 7510a6b..a254c63 100644 --- a/clay/player.py +++ b/clay/player.py @@ -347,8 +347,8 @@ class Player(object): self.broadcast_state() self.track_changed.fire(track) - if settings.get('download_tracks', False): - path = settings.get_cached_file_path(track.store_id + '.mp3') + if settings.get('download_tracks', False) or settings.get_is_file_cached(track.filename): + path = settings.get_cached_file_path(track.filename) if path is None: self.logger.debug('Track %s not in cache, downloading...', track.store_id) track.get_url(callback=self._download_track) @@ -369,7 +369,7 @@ class Player(object): ) return response = urlopen(url) - path = settings.save_file_to_cache(track.store_id + '.mp3', response.read()) + path = settings.save_file_to_cache(track.filename, response.read()) self._play_ready(path, None, track) def _play_ready(self, url, error, track): diff --git a/clay/settings.py b/clay/settings.py index 2498c7c..ec46786 100644 --- a/clay/settings.py +++ b/clay/settings.py @@ -42,9 +42,15 @@ class _Settings(object): """ def __init__(self): self._config = {} + self._cached_files = set() + + self._config_dir = None + self._config_file_path = None + self._cache_dir = None self._ensure_directories() self._load_config() + self._load_cache() def _ensure_directories(self): """ @@ -77,6 +83,12 @@ class _Settings(object): with open(self._config_file_path, 'r') as settings_file: self._config = yaml.load(settings_file.read()) + def _load_cache(self): + """ + Load cached files. + """ + self._cached_files = set(os.listdir(self._cache_dir)) + def _commit_edits(self, config): """ Write config to file. @@ -120,6 +132,12 @@ class _Settings(object): return path return None + def get_is_file_cached(self, filename): + """ + Return ``True`` if *filename* is present in cache. + """ + return filename in self._cached_files + def save_file_to_cache(self, filename, content): """ Save content into file in cache. @@ -127,6 +145,7 @@ class _Settings(object): path = os.path.join(self._cache_dir, filename) with open(path, 'wb') as cachefile: cachefile.write(content) + self._cached_files.add(filename) return path diff --git a/clay/songlist.py b/clay/songlist.py index 825af12..c45fd63 100644 --- a/clay/songlist.py +++ b/clay/songlist.py @@ -16,6 +16,7 @@ from clay.notifications import NotificationArea from clay.player import Player from clay.gp import GP from clay.clipboard import copy +from clay.settings import settings class SongListItem(urwid.Pile): @@ -46,8 +47,14 @@ class SongListItem(urwid.Pile): self.track = track self.index = 0 self.state = SongListItem.STATE_IDLE - self.line1 = urwid.SelectableIcon('', cursor_position=1000) - self.line1.set_layout('left', 'clip', None) + self.line1_left = urwid.SelectableIcon('', cursor_position=1000) + self.line1_left.set_layout('left', 'clip', None) + self.line1_right = urwid.Text('x') + self.line1 = urwid.Columns([ + self.line1_left, + ('pack', self.line1_right), + ('pack', urwid.Text(' ')) + ]) self.line2 = urwid.Text('', wrap='clip') self.line1_wrap = urwid.AttrWrap(self.line1, 'line1') @@ -96,7 +103,7 @@ class SongListItem(urwid.Pile): title_attr = 'line1_active_focus' if self.is_focused else 'line1_active' artist_attr = 'line2_focus' if self.is_focused else 'line2' - self.line1.set_text( + self.line1_left.set_text( u'{index:3d} {icon} {title} [{minutes:02d}:{seconds:02d}]'.format( index=self.index + 1, icon=self.get_state_icon(self.state), @@ -105,6 +112,10 @@ class SongListItem(urwid.Pile): 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.line2.set_text( u' {} \u2015 {}'.format(self.track.artist, self.track.album_name) ) From fa8e7564a3c14006c4f58f552469be013277b652 Mon Sep 17 00:00:00 2001 From: Andrew Dunai Date: Wed, 14 Feb 2018 20:39:59 +0200 Subject: [PATCH 06/17] Refactored all classes to use unified singleton pattern. --- clay/app.py | 49 ++++++++++---------------- clay/clipboard.py | 4 +-- clay/gp.py | 38 ++++++++------------ clay/hotkeys.py | 41 ++++++++++------------ clay/log.py | 22 ++++-------- clay/notifications.py | 74 +++++++++++---------------------------- clay/pages/debug.py | 15 ++++---- clay/pages/mylibrary.py | 15 ++++---- clay/pages/myplaylists.py | 12 +++---- clay/pages/playerqueue.py | 5 ++- clay/pages/search.py | 8 ++--- clay/pages/settings.py | 6 ++-- clay/playbar.py | 6 +--- clay/player.py | 61 +++++++++++++------------------- clay/songlist.py | 38 ++++++++++---------- 15 files changed, 149 insertions(+), 245 deletions(-) diff --git a/clay/app.py b/clay/app.py index 900e19a..b0b1edb 100755 --- a/clay/app.py +++ b/clay/app.py @@ -7,7 +7,7 @@ Main app entrypoint. """ import sys -sys.path.insert(0, '.') # noqa +sys.path.insert(0, '.') import argparse @@ -15,7 +15,7 @@ import os import urwid from clay import meta -from clay.player import Player +from clay.player import player from clay.playbar import PlayBar from clay.pages.debug import DebugPage from clay.pages.mylibrary import MyLibraryPage @@ -24,8 +24,8 @@ from clay.pages.playerqueue import QueuePage from clay.pages.search import SearchPage from clay.pages.settings import SettingsPage from clay.settings import settings -from clay.notifications import NotificationArea -from clay.gp import GP +from clay.notifications import notification_area +from clay.gp import gp def create_palette(transparent=False): @@ -146,35 +146,24 @@ class AppWidget(urwid.Frame): self.current_page = None self.loop = None - NotificationArea.set_app(self) + notification_area.set_app(self) self._login_notification = None self._cancel_actions = [] self.header = urwid.Pile([ - # urwid.Divider('\u2500'), urwid.AttrWrap(urwid.Columns([ ('pack', tab) for tab in self.tabs ], dividechars=0), 'panel'), - NotificationArea.get() - # urwid.Divider('\u2500') ]) self.playbar = PlayBar(self) - # self.panel = urwid.Pile([ - # urwid.Columns([ - # urwid.Divider(u'\u2500'), - # ]), - # self.playbar - # ]) - # self.current_page = self.pages[0] super(AppWidget, self).__init__( header=self.header, footer=self.playbar, body=urwid.Filler(urwid.Text('Loading...', align='center')) ) - # self.current_page.activate() self.set_page('MyLibraryPage') self.log_in() @@ -193,22 +182,22 @@ class AppWidget(urwid.Frame): if self._login_notification: self._login_notification.close() if use_token and authtoken: - self._login_notification = NotificationArea.notify('Using cached auth token...') - GP.get().use_authtoken_async( + self._login_notification = notification_area.notify('Using cached auth token...') + gp.use_authtoken_async( authtoken, device_id, callback=self.on_check_authtoken ) elif username and password and device_id: - self._login_notification = NotificationArea.notify('Logging in...') - GP.get().login_async( + self._login_notification = notification_area.notify('Logging in...') + gp.login_async( username, password, device_id, callback=self.on_login ) else: - self._login_notification = NotificationArea.notify( + self._login_notification = notification_area.notify( 'Please set your credentials on the settings page.' ) @@ -247,7 +236,7 @@ class AppWidget(urwid.Frame): return with settings.edit() as config: - config['authtoken'] = GP.get().get_authtoken() + config['authtoken'] = gp.get_authtoken() self._login_notification.close() @@ -317,42 +306,41 @@ class AppWidget(urwid.Frame): """ Seek to the start of the song. """ - Player.get().seek_absolute(0) + player.seek_absolute(0) @staticmethod def play_pause(): """ Toggle play/pause. """ - Player.get().play_pause() + player.play_pause() @staticmethod def next_song(): """ Play next song. """ - Player.get().next(True) + player.next(True) @staticmethod def seek_backward(): """ Seek 5% backward. """ - Player.get().seek(-0.05) + player.seek(-0.05) @staticmethod def seek_forward(): """ Seek 5% forward. """ - Player.get().seek(0.05) + player.seek(0.05) @staticmethod def toggle_shuffle(): """ Toggle random playback. """ - player = Player.get() player.set_random(not player.get_is_random()) @staticmethod @@ -360,7 +348,6 @@ class AppWidget(urwid.Frame): """ Toggle repeat mode. """ - player = Player.get() player.set_repeat_one(not player.get_is_repeat_one()) @staticmethod @@ -377,7 +364,7 @@ class AppWidget(urwid.Frame): try: action = self._cancel_actions.pop() except IndexError: - NotificationArea.close_newest() + notification_area.close_newest() else: action() @@ -427,7 +414,7 @@ def main(): exit(0) if args.with_x_keybinds: - Player.get().enable_xorg_bindings() + player.enable_xorg_bindings() # Run the actual program app_widget = AppWidget() diff --git a/clay/clipboard.py b/clay/clipboard.py index f32bf28..93f647a 100644 --- a/clay/clipboard.py +++ b/clay/clipboard.py @@ -3,7 +3,7 @@ Clipboard utils. """ from subprocess import Popen, PIPE -from clay.notifications import NotificationArea +from clay.notifications import notification_area COMMANDS = [ @@ -24,7 +24,7 @@ def copy(text): if proc.returncode == 0: return True - NotificationArea.notify( + notification_area.notify( 'Failed to copy text to clipboard. ' 'Please install "xclip" or "xsel".' ) diff --git a/clay/gp.py b/clay/gp.py index 29e126d..5dc3a3b 100644 --- a/clay/gp.py +++ b/clay/gp.py @@ -14,7 +14,7 @@ from uuid import UUID from gmusicapi.clients import Mobileclient from clay.eventhook import EventHook -from clay.log import Logger +from clay.log import logger def asynchronous(func): @@ -202,7 +202,7 @@ class Track(object): ) # We need to find a track in Library by trackId. UUID(data['trackId']) - track = GP.get().get_track_by_id(data['trackId']) + track = gp.get_track_by_id(data['trackId']) return Track( title=track.title, artist=track.artist, @@ -214,7 +214,7 @@ class Track(object): original_data=data ) except Exception as error: # pylint: disable=bare-except - Logger.get().error( + logger.error( 'Failed to parse track data: %s, failing data: %s', repr(error), data @@ -248,11 +248,11 @@ class Track(object): self.cached_url = url callback(url, error, self) - if GP.get().is_subscribed: + if gp.is_subscribed: track_id = self.store_id else: track_id = self.library_id - GP.get().get_stream_url_async(track_id, callback=on_get_url) + gp.get_stream_url_async(track_id, callback=on_get_url) @synchronized def create_station(self): @@ -261,7 +261,7 @@ class Track(object): Returns :class:`.Station` instance. """ - station_id = GP.get().mobile_client.create_station( + station_id = gp.mobile_client.create_station( name=u'Station - {}'.format(self.title), track_id=self.store_id ) @@ -275,7 +275,7 @@ class Track(object): """ Add a track to my library. """ - return GP.get().add_to_my_library(self) + return gp.add_to_my_library(self) add_to_my_library_async = asynchronous(add_to_my_library) @@ -283,7 +283,7 @@ class Track(object): """ Remove a track from my library. """ - return GP.get().remove_from_my_library(self) + return gp.remove_from_my_library(self) remove_from_my_library_async = asynchronous(remove_from_my_library) @@ -348,7 +348,7 @@ class Station(object): Fetch tracks related to this station and populate it with :class:`Track` instances. """ - data = GP.get().mobile_client.get_station_tracks(self.id, 100) + data = gp.mobile_client.get_station_tracks(self.id, 100) self._tracks = Track.from_data(data, Track.SOURCE_STATION, many=True) self._tracks_loaded = True @@ -427,7 +427,7 @@ class Playlist(object): ) -class GP(object): +class _GP(object): """ Interface to :class:`gmusicapi.Mobileclient`. Implements asynchronous API calls, caching and some other perks. @@ -435,12 +435,9 @@ class GP(object): Singleton. """ # TODO: Switch to urwid signals for more explicitness? - instance = None - caches_invalidated = EventHook() def __init__(self): - assert self.__class__.instance is None, 'Can be created only once!' # self.is_debug = os.getenv('CLAY_DEBUG') self.mobile_client = Mobileclient() self.mobile_client._make_call = self._make_call_proxy( @@ -456,16 +453,6 @@ class GP(object): self.auth_state_changed = EventHook() - @classmethod - def get(cls): - """ - Create new :class:`.GP` instance or return existing one. - """ - if cls.instance is None: - cls.instance = GP() - - return cls.instance - def _make_call_proxy(self, func): """ Return a function that wraps *fn* and logs args & return values. @@ -474,7 +461,7 @@ class GP(object): """ Wrapper function. """ - Logger.get().debug('GP::{}(*{}, **{})'.format( + logger.debug('GP::{}(*{}, **{})'.format( protocol.__name__, args, kwargs @@ -640,3 +627,6 @@ class GP(object): Return True if user is subscribed on Google Play Music, false otherwise. """ return self.mobile_client.is_subscribed + + +gp = _GP() # pylint: disable=invalid-name diff --git a/clay/hotkeys.py b/clay/hotkeys.py index d0e6199..3c96e80 100644 --- a/clay/hotkeys.py +++ b/clay/hotkeys.py @@ -7,14 +7,17 @@ import threading from clay.settings import settings from clay.eventhook import EventHook -from clay.notifications import NotificationArea -from clay.log import Logger +from clay.notifications import notification_area +from clay.log import logger IS_INIT = False -def report_error(error_msg): + + +def report_error(exc): "Print an error message to the debug screen" - Logger.get().error("{0}: {1}".format(error.__class__.__name__, error_msg)) + logger.error("{0}: {1}".format(exc.__class__.__name__, exc)) + try: # pylint: disable=import-error @@ -36,7 +39,7 @@ else: IS_INIT = True -class HotkeyManager(object): +class _HotkeyManager(object): """ Manages configs. Runs Gtk main loop in a thread. @@ -47,10 +50,7 @@ class HotkeyManager(object): 'prev': 'XF86AudioPrev' } - instance = None - def __init__(self): - assert self.__class__.instance is None, 'Can be created only once!' self.hotkeys = {} self.config = None @@ -64,20 +64,12 @@ class HotkeyManager(object): threading.Thread(target=Gtk.main).start() else: - Logger.get().debug("Not loading the global shortcuts.") - NotificationArea.notify(ERROR_MESSAGE + - ", this means the global shortcuts will not work.\n" + - "You can check the log for more details.") - - @classmethod - def get(cls): - """ - Create new :class:`.HotkeyManager` instance or return existing one. - """ - if cls.instance is None: - cls.instance = HotkeyManager() - - return cls.instance + logger.debug("Not loading the global shortcuts.") + notification_area.notify( + ERROR_MESSAGE + + ", this means the global shortcuts will not work.\n" + + "You can check the log for more details." + ) @staticmethod def load_keys(): @@ -85,7 +77,7 @@ class HotkeyManager(object): Load hotkey config from settings. """ hotkeys = settings.get('hotkeys', {}) - for operation, default_key in HotkeyManager.DEFAULT_HOTKEYS.items(): + for operation, default_key in _HotkeyManager.DEFAULT_HOTKEYS.items(): if operation not in hotkeys or not hotkeys[operation]: hotkeys[operation] = default_key return hotkeys @@ -106,3 +98,6 @@ class HotkeyManager(object): """ assert key getattr(self, operation).fire() + + +hotkey_manager = _HotkeyManager() # pylint: disable=invalid-name diff --git a/clay/log.py b/clay/log.py index 4f5c345..a695739 100644 --- a/clay/log.py +++ b/clay/log.py @@ -8,7 +8,7 @@ from datetime import datetime from clay.eventhook import EventHook -class LoggerRecord(object): +class _LoggerRecord(object): """ Represents a logger record. """ @@ -40,17 +40,14 @@ class LoggerRecord(object): return self._message % self._args -class Logger(object): +class _Logger(object): """ Global logger. Allows subscribing to log events. """ - instance = None def __init__(self): - assert self.__class__.instance is None, 'Can be created only once!' - self.logs = [] self.logfile = open('/tmp/clay.log', 'w') @@ -58,23 +55,13 @@ class Logger(object): self.on_log_event = EventHook() - @classmethod - def get(cls): - """ - Create new :class:`.Logger` instance or return existing one. - """ - if cls.instance is None: - cls.instance = Logger() - - return cls.instance - def log(self, level, message, *args): """ Add log item. """ self._lock.acquire() try: - logger_record = LoggerRecord(level, message, args) + logger_record = _LoggerRecord(level, message, args) self.logs.append(logger_record) self.logfile.write('{} {:8} {}\n'.format( logger_record.formatted_timestamp, @@ -117,3 +104,6 @@ class Logger(object): Return all logs. """ return self.logs + + +logger = _Logger() # pylint: disable=invalid-name diff --git a/clay/notifications.py b/clay/notifications.py index 94ec227..3e03d0d 100644 --- a/clay/notifications.py +++ b/clay/notifications.py @@ -4,7 +4,7 @@ Notification widgets. import urwid -class Notification(urwid.Columns): +class _Notification(urwid.Columns): """ Single notification widget. Can be updated or closed. @@ -16,7 +16,7 @@ class Notification(urwid.Columns): self._id = notification_id self.text = urwid.Text('') self._set_text(message) - super(Notification, self).__init__([ + super(_Notification, self).__init__([ urwid.AttrWrap( urwid.Columns([ self.text, @@ -41,7 +41,7 @@ class Notification(urwid.Columns): message = '\n'.join([ message[0] ] + [' {}'.format(line) for line in message[1:]]) - self.text.set_text(Notification.TEMPLATE.format(message)) + self.text.set_text(_Notification.TEMPLATE.format(message)) def update(self, message): """ @@ -50,7 +50,7 @@ class Notification(urwid.Columns): self._set_text(message) if not self.is_alive: self.area.append_notification(self) - self.area.__class__.app.redraw() + self.area.app.redraw() @property def is_alive(self): @@ -70,73 +70,36 @@ class Notification(urwid.Columns): if notification is self: self.area.contents.remove((notification, props)) - if self.area.__class__.app is not None: - self.area.__class__.app.redraw() + if self.area.app is not None: + self.area.app.redraw() -class NotificationArea(urwid.Pile): +class _NotificationArea(urwid.Pile): """ Notification area widget. """ - instance = None - app = None def __init__(self): - assert self.__class__.instance is None, 'Can be created only once!' + self.app = None self.last_id = 0 self.notifications = {} - super(NotificationArea, self).__init__([]) + super(_NotificationArea, self).__init__([]) - @classmethod - def get(cls): - """ - Create new :class:`.NotificationArea` instance or return existing one. - """ - if cls.instance is None: - cls.instance = NotificationArea() - - return cls.instance - - @classmethod - def set_app(cls, app): + def set_app(self, app): """ Set app instance. Required for proper screen redraws when new notifications are created asynchronously. """ - cls.app = app + self.app = app - @classmethod - def notify(cls, message): - """ - Create new notification with message. - This is a class method. - """ - return cls.get().do_notify(message) - - @classmethod - def close_all(cls): - """ - Close all notfiications. - This is a class method. - """ - cls.get().do_close_all() - - @classmethod - def close_newest(cls): - """ - Close newest notification. - This is a class method. - """ - cls.get().do_close_newest() - - def do_notify(self, message): + def notify(self, message): """ Create new notification with message. """ self.last_id += 1 - notification = Notification(self, self.last_id, message) + notification = _Notification(self, self.last_id, message) self.append_notification(notification) return notification @@ -150,20 +113,23 @@ class NotificationArea(urwid.Pile): ('weight', 1) ) ) - if self.__class__.app is not None: - self.__class__.app.redraw() + if self.app is not None: + self.app.redraw() - def do_close_all(self): + def close_all(self): """ Close all notifications. """ while self.contents: self.contents[0][0].close() - def do_close_newest(self): + def close_newest(self): """ Close newest notification """ if not self.contents: return self.contents[-1][0].close() + + +notification_area = _NotificationArea() # pylint: disable=invalid-name diff --git a/clay/pages/debug.py b/clay/pages/debug.py index 6545537..dc86352 100644 --- a/clay/pages/debug.py +++ b/clay/pages/debug.py @@ -4,9 +4,9 @@ Debug page. import urwid from clay.pages.page import AbstractPage -from clay.log import Logger +from clay.log import logger from clay.clipboard import copy -from clay.gp import GP +from clay.gp import gp class DebugItem(urwid.AttrMap): @@ -49,9 +49,9 @@ class DebugPage(urwid.Pile, AbstractPage): def __init__(self, app): self.app = app self.walker = urwid.SimpleListWalker([]) - for log_record in Logger.get().get_logs(): + for log_record in logger.get_logs(): self._append_log(log_record) - Logger.get().on_log_event += self._append_log + logger.on_log_event += self._append_log self.listbox = urwid.ListBox(self.walker) self.debug_data = urwid.Text('') @@ -64,7 +64,7 @@ class DebugPage(urwid.Pile, AbstractPage): self.listbox ]) - GP.get().auth_state_changed += self.update + gp.auth_state_changed += self.update self.update() @@ -72,12 +72,11 @@ class DebugPage(urwid.Pile, AbstractPage): """ Update this widget. """ - gpclient = GP.get() self.debug_data.set_text( '- Is authenticated: {}\n' '- Is subscribed: {}'.format( - gpclient.is_authenticated, - gpclient.is_subscribed if gpclient.is_authenticated else None + gp.is_authenticated, + gp.is_subscribed if gp.is_authenticated else None ) ) diff --git a/clay/pages/mylibrary.py b/clay/pages/mylibrary.py index e5ce51a..15d853b 100644 --- a/clay/pages/mylibrary.py +++ b/clay/pages/mylibrary.py @@ -3,9 +3,9 @@ Library page. """ import urwid -from clay.gp import GP +from clay.gp import gp from clay.songlist import SongListBox -from clay.notifications import NotificationArea +from clay.notifications import notification_area from clay.pages.page import AbstractPage @@ -28,8 +28,8 @@ class MyLibraryPage(urwid.Columns, AbstractPage): self.songlist = SongListBox(app) self.notification = None - GP.get().auth_state_changed += self.get_all_songs - GP.get().caches_invalidated += self.get_all_songs + gp.auth_state_changed += self.get_all_songs + gp.caches_invalidated += self.get_all_songs super(MyLibraryPage, self).__init__([ self.songlist @@ -41,7 +41,7 @@ class MyLibraryPage(urwid.Columns, AbstractPage): Populate song list. """ if error: - NotificationArea.notify('Failed to load my library: {}'.format(str(error))) + notification_area.notify('Failed to load my library: {}'.format(str(error))) return # self.notification.close() self.songlist.populate(tracks) @@ -51,12 +51,11 @@ class MyLibraryPage(urwid.Columns, AbstractPage): """ Called when auth state changes or GP caches are invalidated. """ - if GP.get().is_authenticated: + if gp.is_authenticated: self.songlist.set_placeholder(u'\n \uf01e Loading song list...') - GP.get().get_all_tracks_async(callback=self.on_get_all_songs) + gp.get_all_tracks_async(callback=self.on_get_all_songs) self.app.redraw() - # self.notification = NotificationArea.notify('Loading library...') def activate(self): pass diff --git a/clay/pages/myplaylists.py b/clay/pages/myplaylists.py index 1f75b3e..22ba440 100644 --- a/clay/pages/myplaylists.py +++ b/clay/pages/myplaylists.py @@ -3,9 +3,9 @@ Components for "My playlists" page. """ import urwid -from clay.gp import GP +from clay.gp import gp from clay.songlist import SongListBox -from clay.notifications import NotificationArea +from clay.notifications import notification_area from clay.pages.page import AbstractPage @@ -59,7 +59,7 @@ class MyPlaylistListBox(urwid.ListBox): ]) self.notification = None - GP.get().auth_state_changed += self.auth_state_changed + gp.auth_state_changed += self.auth_state_changed super(MyPlaylistListBox, self).__init__(self.walker) @@ -73,9 +73,7 @@ class MyPlaylistListBox(urwid.ListBox): urwid.Text(u'\n \uf01e Loading playlists...', align='center') ] - GP.get().get_all_user_playlist_contents_async(callback=self.on_get_playlists) - - # self.notification = NotificationArea.notify('Loading playlists...') + gp.get_all_user_playlist_contents_async(callback=self.on_get_playlists) def on_get_playlists(self, playlists, error): """ @@ -83,7 +81,7 @@ class MyPlaylistListBox(urwid.ListBox): Populates list of playlists. """ if error: - NotificationArea.notify('Failed to get playlists: {}'.format(str(error))) + notification_area.notify('Failed to get playlists: {}'.format(str(error))) items = [] for playlist in playlists: diff --git a/clay/pages/playerqueue.py b/clay/pages/playerqueue.py index 54748b2..b61600c 100644 --- a/clay/pages/playerqueue.py +++ b/clay/pages/playerqueue.py @@ -4,7 +4,7 @@ Components for "Queue" page. import urwid from clay.songlist import SongListBox -from clay.player import Player +from clay.player import player from clay.pages.page import AbstractPage @@ -24,7 +24,6 @@ class QueuePage(urwid.Columns, AbstractPage): self.app = app self.songlist = SongListBox(app) - player = Player.get() self.songlist.populate(player.get_queue_tracks()) player.queue_changed += self.queue_changed player.track_appended += self.track_appended @@ -39,7 +38,7 @@ class QueuePage(urwid.Columns, AbstractPage): Called when player queue is changed. Updates this queue widget. """ - self.songlist.populate(Player.get().get_queue_tracks()) + self.songlist.populate(player.get_queue_tracks()) def track_appended(self, track): """ diff --git a/clay/pages/search.py b/clay/pages/search.py index 8f3f1ac..c2b39a7 100644 --- a/clay/pages/search.py +++ b/clay/pages/search.py @@ -3,9 +3,9 @@ Components for search page. """ import urwid -from clay.gp import GP +from clay.gp import gp from clay.songlist import SongListBox -from clay.notifications import NotificationArea +from clay.notifications import notification_area from clay.pages.page import AbstractPage @@ -76,14 +76,14 @@ class SearchPage(urwid.Pile, AbstractPage): self.songlist.set_placeholder(u' \U0001F50D Searching for "{}"...'.format( query )) - GP.get().search_async(query, callback=self.search_finished) + gp.search_async(query, callback=self.search_finished) def search_finished(self, results, error): """ Populate song list with search results. """ if error: - NotificationArea.notify('Failed to search: {}'.format(str(error))) + notification_area.notify('Failed to search: {}'.format(str(error))) else: self.songlist.populate(results.get_tracks()) self.app.redraw() diff --git a/clay/pages/settings.py b/clay/pages/settings.py index e2faacb..0cd29f1 100644 --- a/clay/pages/settings.py +++ b/clay/pages/settings.py @@ -5,7 +5,7 @@ import urwid from clay.pages.page import AbstractPage from clay.settings import settings -from clay.player import Player +from clay.player import player class Slider(urwid.Widget): @@ -95,7 +95,7 @@ class Slider(urwid.Widget): """ Update player equalizer & toggle redraw. """ - Player.get().set_equalizer_value(self.index, self.value) + player.set_equalizer_value(self.index, self.value) self._invalidate() @@ -107,7 +107,7 @@ class Equalizer(urwid.Columns): self.bands = [ Slider(index, freq) for index, freq - in enumerate(Player.get().get_equalizer_freqs()) + in enumerate(player.get_equalizer_freqs()) ] super(Equalizer, self).__init__( self.bands diff --git a/clay/playbar.py b/clay/playbar.py index c300b40..3804af7 100644 --- a/clay/playbar.py +++ b/clay/playbar.py @@ -4,7 +4,7 @@ PlayBar widget. # pylint: disable=too-many-instance-attributes import urwid -from clay.player import Player +from clay.player import player from clay import meta @@ -98,7 +98,6 @@ class PlayBar(urwid.Pile): ]) self.update() - player = Player.get() player.media_position_changed += self.update player.media_state_changed += self.update player.track_changed += self.update @@ -115,7 +114,6 @@ class PlayBar(urwid.Pile): """ Return the style for current playback state. """ - player = Player.get() if player.is_loading or player.is_playing: return 'title-playing' return 'title-idle' @@ -124,7 +122,6 @@ class PlayBar(urwid.Pile): """ Return text for display in this bar. """ - player = Player.get() track = player.get_current_track() if track is None: return u'{} {}'.format( @@ -153,7 +150,6 @@ class PlayBar(urwid.Pile): Called when something unrelated to completion value changes, e.g. current track or playback flags. """ - player = Player.get() self.text.set_text(self.get_text()) self.progressbar.set_progress(player.get_play_progress()) self.progressbar.set_done_style( diff --git a/clay/player.py b/clay/player.py index a254c63..ac00c5b 100644 --- a/clay/player.py +++ b/clay/player.py @@ -14,12 +14,12 @@ except ImportError: # Python 2.x from clay import vlc, meta from clay.eventhook import EventHook -from clay.notifications import NotificationArea +from clay.notifications import notification_area from clay.settings import settings -from clay.log import Logger +from clay.log import logger -class Queue(object): +class _Queue(object): """ Model that represents player queue (local playlist), i.e. list of tracks to be played. @@ -112,15 +112,13 @@ class Queue(object): return self.tracks -class Player(object): +class _Player(object): """ Interface to libVLC. Uses Queue as a playback plan. Emits various events if playback state, tracks or play flags change. Singleton. """ - instance = None - media_position_changed = EventHook() media_state_changed = EventHook() track_changed = EventHook() @@ -130,9 +128,6 @@ class Player(object): track_removed = EventHook() def __init__(self): - assert self.__class__.instance is None, 'Can be created only once!' - self.logger = Logger.get() - self.instance = vlc.Instance() self.instance.set_user_agent( meta.APP_NAME, @@ -164,26 +159,15 @@ class Player(object): self._create_station_notification = None self._is_loading = False - self.queue = Queue() - - @classmethod - def get(cls): - """ - Create new :class:`.Player` instance or return existing one. - """ - if cls.instance is None: - cls.instance = Player() - - return cls.instance + self.queue = _Queue() def enable_xorg_bindings(self): """Enable the global X bindings using keybinder""" if os.environ.get("DISPLAY") is None: - self.logger.debug("X11 isn't running so we can't load the global keybinds") + logger.debug("X11 isn't running so we can't load the global keybinds") return - from clay.hotkeys import HotkeyManager - hotkey_manager = HotkeyManager.get() + from clay.hotkeys import hotkey_manager hotkey_manager.play_pause += self.play_pause hotkey_manager.next += self.next hotkey_manager.prev += lambda: self.seek_absolute(0) @@ -248,7 +232,7 @@ class Player(object): Load queue & start playback. Fires :attr:`.queue_changed` event. - See :meth:`.Queue.load`. + See :meth:`._Queue.load`. """ self.queue.load(data, current_index) self.queue_changed.fire() @@ -259,7 +243,7 @@ class Player(object): Append track to queue. Fires :attr:`.track_appended` event. - See :meth:`.Queue.append` + See :meth:`._Queue.append` """ self.queue.append(track) self.track_appended.fire(track) @@ -270,7 +254,7 @@ class Player(object): Remove track from queue. Fires :attr:`.track_removed` event. - See :meth:`.Queue.remove` + See :meth:`._Queue.remove` """ self.queue.remove(track) self.track_removed.fire(track) @@ -280,7 +264,7 @@ class Player(object): Request creation of new station from some track. Runs in background. """ - self._create_station_notification = NotificationArea.notify('Creating station...') + self._create_station_notification = notification_area.notify('Creating station...') track.create_station_async(callback=self._create_station_from_track_ready) def _create_station_from_track_ready(self, station, error): @@ -331,7 +315,7 @@ class Player(object): def get_queue_tracks(self): """ - Return :attr:`.Queue.get_tracks` + Return :attr:`._Queue.get_tracks` """ return self.queue.get_tracks() @@ -350,19 +334,19 @@ class Player(object): if settings.get('download_tracks', False) or settings.get_is_file_cached(track.filename): path = settings.get_cached_file_path(track.filename) if path is None: - self.logger.debug('Track %s not in cache, downloading...', track.store_id) + logger.debug('Track %s not in cache, downloading...', track.store_id) track.get_url(callback=self._download_track) else: - self.logger.debug('Track %s in cache, playing', track.store_id) + logger.debug('Track %s in cache, playing', track.store_id) self._play_ready(path, None, track) else: - self.logger.debug('Starting to stream %s', track.store_id) + logger.debug('Starting to stream %s', track.store_id) track.get_url(callback=self._play_ready) def _download_track(self, url, error, track): if error: - NotificationArea.notify('Failed to request media URL: {}'.format(str(error))) - self.logger.error( + notification_area.notify('Failed to request media URL: {}'.format(str(error))) + logger.error( 'Failed to request media URL for track %s: %s', track.original_data, str(error) @@ -379,8 +363,8 @@ class Player(object): """ self._is_loading = False if error: - NotificationArea.notify('Failed to request media URL: {}'.format(str(error))) - self.logger.error( + notification_area.notify('Failed to request media URL: {}'.format(str(error))) + logger.error( 'Failed to request media URL for track %s: %s', track.original_data, str(error) @@ -436,7 +420,7 @@ class Player(object): def next(self, force=False): """ Advance to next track in queue. - See :meth:`.Queue.next`. + See :meth:`._Queue.next`. """ self.queue.next(force) self._play() @@ -444,7 +428,7 @@ class Player(object): def get_current_track(self): """ Return currently played track. - See :meth:`.Queue.get_current_track`. + See :meth:`._Queue.get_current_track`. """ return self.queue.get_current_track() @@ -509,3 +493,6 @@ class Player(object): index ) == 0 self.media_player.set_equalizer(self.equalizer) + + +player = _Player() # pylint: disable=invalid-name diff --git a/clay/songlist.py b/clay/songlist.py index c45fd63..cd052c4 100644 --- a/clay/songlist.py +++ b/clay/songlist.py @@ -12,9 +12,9 @@ except ImportError: # Python 2.3 from string import letters as ascii_letters import urwid -from clay.notifications import NotificationArea -from clay.player import Player -from clay.gp import GP +from clay.notifications import notification_area +from clay.player import player +from clay.gp import gp from clay.clipboard import copy from clay.settings import settings @@ -113,7 +113,7 @@ class SongListItem(urwid.Pile): ) ) if settings.get_is_file_cached(self.track.filename): - self.line1_right.set_text(u'\u25bc Cached') + self.line1_right.set_text(u' \u25bc Cached') else: self.line1_right.set_text(u'') self.line2.set_text( @@ -214,7 +214,7 @@ class SongListBoxPopup(urwid.LineBox): 'panel_divider', 'panel_divider_focus' )) - if not GP.get().get_track_by_id(songitem.track.id): + if not gp.get_track_by_id(songitem.track.id): options.append(urwid.AttrWrap( urwid.Button('Add to my library', on_press=self.add_to_my_library), 'panel', @@ -241,7 +241,7 @@ class SongListBoxPopup(urwid.LineBox): 'panel_divider', 'panel_divider_focus' )) - if self.songitem.track in Player.get().get_queue_tracks(): + if self.songitem.track in player.get_queue_tracks(): options.append(urwid.AttrWrap( urwid.Button('Remove from queue', on_press=self.remove_from_queue), 'panel', @@ -282,11 +282,11 @@ class SongListBoxPopup(urwid.LineBox): Show notification with song addition result. """ if error or not result: - NotificationArea.notify('Error while adding track to my library: {}'.format( + notification_area.notify('Error while adding track to my library: {}'.format( str(error) if error else 'reason is unknown :(' )) else: - NotificationArea.notify('Track added to library!') + notification_area.notify('Track added to library!') self.songitem.track.add_to_my_library_async(callback=on_add_to_my_library) self.close() @@ -299,11 +299,11 @@ class SongListBoxPopup(urwid.LineBox): Show notification with song removal result. """ if error or not result: - NotificationArea.notify('Error while removing track from my library: {}'.format( + notification_area.notify('Error while removing track from my library: {}'.format( str(error) if error else 'reason is unknown :(' )) else: - NotificationArea.notify('Track removed from library!') + notification_area.notify('Track removed from library!') self.songitem.track.remove_from_my_library_async(callback=on_remove_from_my_library) self.close() @@ -311,21 +311,21 @@ class SongListBoxPopup(urwid.LineBox): """ Appends related track to queue. """ - Player.get().append_to_queue(self.songitem.track) + player.append_to_queue(self.songitem.track) self.close() def remove_from_queue(self, _): """ Removes related track from queue. """ - Player.get().remove_from_queue(self.songitem.track) + player.remove_from_queue(self.songitem.track) self.close() def create_station(self, _): """ Create a station from this track. """ - Player.get().create_station_from_track(self.songitem.track) + player.create_station_from_track(self.songitem.track) self.close() def copy_url(self, _): @@ -355,7 +355,6 @@ class SongListBox(urwid.Frame): self.tracks = [] self.walker = urwid.SimpleFocusListWalker([]) - player = Player.get() player.track_changed += self.track_changed player.media_state_changed += self.media_state_changed @@ -443,7 +442,7 @@ class SongListBox(urwid.Frame): """ Convert list of track data items into list of :class:`.SongListItem` instances. """ - current_track = Player.get().get_current_track() + current_track = player.get_current_track() items = [] current_index = None for index, track in enumerate(tracks): @@ -476,7 +475,6 @@ class SongListBox(urwid.Frame): Toggles track playback state or loads entire playlist that contains current track into player queue. """ - player = Player.get() if songitem.is_currently_played: player.play_pause() else: @@ -488,7 +486,7 @@ class SongListBox(urwid.Frame): Called when specific item emits *append-requested* item. Appends track to player queue. """ - Player.get().append_to_queue(songitem.track) + player.append_to_queue(songitem.track) @staticmethod def item_unappend_requested(songitem): @@ -496,7 +494,7 @@ class SongListBox(urwid.Frame): Called when specific item emits *remove-requested* item. Removes track from player queue. """ - Player.get().remove_from_queue(songitem.track) + player.remove_from_queue(songitem.track) @staticmethod def item_station_requested(songitem): @@ -504,7 +502,7 @@ class SongListBox(urwid.Frame): Called when specific item emits *station-requested* item. Requests new station creation. """ - Player.get().create_station_from_track(songitem.track) + player.create_station_from_track(songitem.track) def context_menu_requested(self, songitem): """ @@ -551,7 +549,7 @@ class SongListBox(urwid.Frame): Called when player media state changes. Updates corresponding song item state (if found in this song list). """ - current_track = Player.get().get_current_track() + current_track = player.get_current_track() if current_track is None: return From ea11b6193438047c1dc690a0b7e762d6742fc4a6 Mon Sep 17 00:00:00 2001 From: Andrew Dunai Date: Wed, 14 Feb 2018 22:19:18 +0200 Subject: [PATCH 07/17] Cleanup. --- .pylintrc | 437 ++-------------------------------------------------- clay/app.py | 53 ++++--- 2 files changed, 42 insertions(+), 448 deletions(-) mode change 100755 => 100644 clay/app.py diff --git a/.pylintrc b/.pylintrc index cb89e14..0eb6d0f 100644 --- a/.pylintrc +++ b/.pylintrc @@ -1,430 +1,17 @@ -[MASTER] +[pylint] +max-line-length = 100 +max-args = 8 -# A comma-separated list of package or module names from where C extensions may -# be loaded. Extensions are loading into the active Python interpreter and may -# run arbitrary code -extension-pkg-whitelist= +[messages control] +disable = + too-few-public-methods, + too-many-public-methods, + too-many-instance-attributes, + no-self-use, -# Add files or directories to the blacklist. They should be base names, not -# paths. -ignore=CVS + too-many-ancestors -# Add files or directories matching the regex patterns to the blacklist. The -# regex matches against base names, not paths. -ignore-patterns= - -# Python code to execute, usually for sys.path manipulation such as -# pygtk.require(). -#init-hook= - -# Use multiple processes to speed up Pylint. -jobs=1 - -# List of plugins (as comma separated values of python modules names) to load, -# usually to register additional checkers. -load-plugins= - -# Pickle collected data for later comparisons. -persistent=yes - -# Specify a configuration file. -#rcfile= - -# Allow loading of arbitrary C extensions. Extensions are imported into the -# active Python interpreter and may run arbitrary code. -unsafe-load-any-extension=no - - -[MESSAGES CONTROL] - -# Only show warnings with the listed confidence levels. Leave empty to show -# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED -confidence= - -# Disable the message, report, category or checker with the given id(s). You -# can either give multiple identifiers separated by comma (,) or put this -# option multiple times (only on the command line, not in the configuration -# file where it should appear only once).You can also use "--disable=all" to -# disable everything first and then reenable specific checks. For example, if -# you want to run only the similarities checker, you can use "--disable=all -# --enable=similarities". If you want to run only the classes checker, but have -# no Warning level messages displayed, use"--disable=all --enable=classes -# --disable=W" -# disable=print-statement,parameter-unpacking,unpacking-in-except,old-raise-syntax,backtick,long-suffix,old-ne-operator,old-octal-literal,import-star-module-level,raw-checker-failed,bad-inline-option,locally-disabled,locally-enabled,file-ignored,suppressed-message,useless-suppression,deprecated-pragma,apply-builtin,basestring-builtin,buffer-builtin,cmp-builtin,coerce-builtin,execfile-builtin,file-builtin,long-builtin,raw_input-builtin,reduce-builtin,standarderror-builtin,unicode-builtin,xrange-builtin,coerce-method,delslice-method,getslice-method,setslice-method,no-absolute-import,old-division,dict-iter-method,dict-view-method,next-method-called,metaclass-assignment,indexing-exception,raising-string,reload-builtin,oct-method,hex-method,nonzero-method,cmp-method,input-builtin,round-builtin,intern-builtin,unichr-builtin,map-builtin-not-iterating,zip-builtin-not-iterating,range-builtin-not-iterating,filter-builtin-not-iterating,using-cmp-argument,eq-without-hash,div-method,idiv-method,rdiv-method,exception-message-attribute,invalid-str-codec,sys-max-int,bad-python3-import,deprecated-string-function,deprecated-str-translate-call - -# Enable the message, report, category or checker with the given id(s). You can -# either give multiple identifier separated by comma (,) or put this option -# multiple time (only on the command line, not in the configuration file where -# it should appear only once). See also the "--disable" option for examples. -enable= - - -[REPORTS] - -# Python expression which should return a note less than 10 (10 is the highest -# note). You have access to the variables errors warning, statement which -# respectively contain the number of errors / warnings messages and the total -# number of statements analyzed. This is used by the global evaluation report -# (RP0004). -evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) - -# Template used to display messages. This is a python new-style format string -# used to format the message information. See doc for all details -#msg-template= - -# Set the output format. Available formats are text, parseable, colorized, json -# and msvs (visual studio).You can also give a reporter class, eg -# mypackage.mymodule.MyReporterClass. -output-format=text - -# Tells whether to display a full report or only the messages -reports=no - -# Activate the evaluation score. -score=yes - - -[REFACTORING] - -# Maximum number of nested blocks for function / method body -max-nested-blocks=5 - - -[VARIABLES] - -# List of additional names supposed to be defined in builtins. Remember that -# you should avoid to define new builtins when possible. -additional-builtins= - -# Tells whether unused global variables should be treated as a violation. -allow-global-unused-variables=yes - -# List of strings which can identify a callback function by name. A callback -# name must start or end with one of those strings. -callbacks=cb_,_cb - -# A regular expression matching the name of dummy variables (i.e. expectedly -# not used). -dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ - -# Argument names that match this expression will be ignored. Default to name -# with leading underscore -ignored-argument-names=_.*|^ignored_|^unused_ - -# Tells whether we should check for unused import in __init__ files. -init-import=no - -# List of qualified module names which can have objects that can redefine -# builtins. -redefining-builtins-modules=six.moves,future.builtins - - -[TYPECHECK] - -# List of decorators that produce context managers, such as -# contextlib.contextmanager. Add to this list to register other decorators that -# produce valid context managers. -contextmanager-decorators=contextlib.contextmanager - -# List of members which are set dynamically and missed by pylint inference -# system, and so shouldn't trigger E1101 when accessed. Python regular -# expressions are accepted. -generated-members= - -# Tells whether missing members accessed in mixin class should be ignored. A -# mixin class is detected if its name ends with "mixin" (case insensitive). -ignore-mixin-members=yes - -# This flag controls whether pylint should warn about no-member and similar -# checks whenever an opaque object is returned when inferring. The inference -# can return multiple potential results while evaluating a Python object, but -# some branches might not be evaluated, which results in partial inference. In -# that case, it might be useful to still emit no-member and other checks for -# the rest of the inferred objects. -ignore-on-opaque-inference=yes - -# List of class names for which member attributes should not be checked (useful -# for classes with dynamically set attributes). This supports the use of -# qualified names. -ignored-classes=optparse.Values,thread._local,_thread._local - -# List of module names for which member attributes should not be checked -# (useful for modules/projects where namespaces are manipulated during runtime -# and thus existing member attributes cannot be deduced by static analysis. It -# supports qualified module names, as well as Unix pattern matching. -ignored-modules= - -# Show a hint with possible names when a member name was not found. The aspect -# of finding the hint is based on edit distance. -missing-member-hint=yes - -# The minimum edit distance a name should have in order to be considered a -# similar match for a missing member name. -missing-member-hint-distance=1 - -# The total number of similar names that should be taken in consideration when -# showing a hint for a missing member. -missing-member-max-choices=1 - - -[BASIC] - -# Naming hint for argument names -argument-name-hint=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ - -# Regular expression matching correct argument names -argument-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ - -# Naming hint for attribute names -attr-name-hint=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ - -# Regular expression matching correct attribute names -attr-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ - -# Bad variable names which should always be refused, separated by a comma -bad-names=foo,bar,baz,toto,tutu,tata - -# Naming hint for class attribute names -class-attribute-name-hint=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ - -# Regular expression matching correct class attribute names -class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ - -# Naming hint for class names -class-name-hint=[A-Z_][a-zA-Z0-9]+$ - -# Regular expression matching correct class names -class-rgx=[A-Z_][a-zA-Z0-9]+$ - -# Naming hint for constant names -const-name-hint=(([A-Z_][A-Z0-9_]*)|(__.*__))$ - -# Regular expression matching correct constant names -const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$ - -# Minimum line length for functions/classes that require docstrings, shorter -# ones are exempt. -docstring-min-length=-1 - -# Naming hint for function names -function-name-hint=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ - -# Regular expression matching correct function names -function-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ - -# Good variable names which should always be accepted, separated by a comma -good-names=i,j,k,ex,Run,_ - -# Include a hint for the correct naming format with invalid-name -include-naming-hint=no - -# Naming hint for inline iteration names -inlinevar-name-hint=[A-Za-z_][A-Za-z0-9_]*$ - -# Regular expression matching correct inline iteration names -inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ - -# Naming hint for method names -method-name-hint=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ - -# Regular expression matching correct method names -method-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ - -# Naming hint for module names -module-name-hint=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ - -# Regular expression matching correct module names -module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ - -# Colon-delimited sets of names that determine each other's naming style when -# the name regexes allow several styles. -name-group= - -# Regular expression which should only match function or class names that do -# not require a docstring. -no-docstring-rgx=^_ - -# List of decorators that produce properties, such as abc.abstractproperty. Add -# to this list to register other decorators that produce valid properties. -property-classes=abc.abstractproperty - -# Naming hint for variable names -variable-name-hint=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ - -# Regular expression matching correct variable names -variable-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ - - -[FORMAT] - -# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. -expected-line-ending-format= - -# Regexp for a line that is allowed to be longer than the limit. -ignore-long-lines=^\s*(# )??$ - -# Number of spaces of indent required inside a hanging or continued line. -indent-after-paren=4 - -# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 -# tab). -indent-string=' ' - -# Maximum number of characters on a single line. -max-line-length=100 - -# Maximum number of lines in a module -max-module-lines=1000 - -# List of optional constructs for which whitespace checking is disabled. `dict- -# separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. -# `trailing-comma` allows a space between comma and closing bracket: (a, ). -# `empty-line` allows space-only lines. -no-space-check=trailing-comma,dict-separator - -# Allow the body of a class to be on the same line as the declaration if body -# contains single statement. -single-line-class-stmt=no - -# Allow the body of an if to be on the same line as the test if there is no -# else. -single-line-if-stmt=no - - -[SIMILARITIES] - -# Ignore comments when computing similarities. -ignore-comments=yes - -# Ignore docstrings when computing similarities. -ignore-docstrings=yes - -# Ignore imports when computing similarities. -ignore-imports=yes - -# Minimum lines number of a similarity. -min-similarity-lines=4 - - -[MISCELLANEOUS] +[miscellaneous] # List of note tags to take in consideration, separated by a comma. -notes=FIXME,XXX - - -[SPELLING] - -# Spelling dictionary name. Available dictionaries: en_GH (hunspell), en_IE -# (hunspell), en_BW (hunspell), en_HK (hunspell), en_TT (hunspell), en_US -# (hunspell), en_BZ (hunspell), en_NA (hunspell), he (hspell), uk (aspell), -# en_NG (hunspell), en (aspell), en_CA (aspell), en_JM (hunspell), en_PH -# (hunspell), en_AG (hunspell), en_IN (hunspell), en_ZW (hunspell), en_SG -# (hunspell), en_GB (hunspell), en_DK (hunspell), en_ZA (hunspell), en_NZ -# (hunspell), en_BS (hunspell). -spelling-dict= - -# List of comma separated words that should not be checked. -spelling-ignore-words= - -# A path to a file that contains private dictionary; one word per line. -spelling-private-dict-file= - -# Tells whether to store unknown words to indicated private dictionary in -# --spelling-private-dict-file option instead of raising a message. -spelling-store-unknown-words=no - - -[LOGGING] - -# Logging modules to check that the string format arguments are in logging -# function parameter format -logging-modules=logging - - -[DESIGN] - -# Maximum number of arguments for function / method -max-args=5 - -# Maximum number of attributes for a class (see R0902). -max-attributes=7 - -# Maximum number of boolean expressions in a if statement -max-bool-expr=5 - -# Maximum number of branch for function / method body -max-branches=12 - -# Maximum number of locals for function / method body -max-locals=15 - -# Maximum number of parents for a class (see R0901). -max-parents=7 - -# Maximum number of public methods for a class (see R0904). -max-public-methods=20 - -# Maximum number of return / yield for function / method body -max-returns=6 - -# Maximum number of statements in function / method body -max-statements=50 - -# Minimum number of public methods for a class (see R0903). -min-public-methods=2 - - -[CLASSES] - -# List of method names used to declare (i.e. assign) instance attributes. -defining-attr-methods=__init__,__new__,setUp - -# List of member names, which should be excluded from the protected access -# warning. -exclude-protected=_asdict,_fields,_replace,_source,_make - -# List of valid names for the first argument in a class method. -valid-classmethod-first-arg=cls - -# List of valid names for the first argument in a metaclass class method. -valid-metaclass-classmethod-first-arg=mcs - - -[IMPORTS] - -# Allow wildcard imports from modules that define __all__. -allow-wildcard-with-all=no - -# Analyse import fallback blocks. This can be used to support both Python 2 and -# 3 compatible code, which means that the block might have code that exists -# only in one or another interpreter, leading to false positives when analysed. -analyse-fallback-blocks=no - -# Deprecated modules which should not be used, separated by a comma -deprecated-modules=optparse,tkinter.tix - -# Create a graph of external dependencies in the given file (report RP0402 must -# not be disabled) -ext-import-graph= - -# Create a graph of every (i.e. internal and external) dependencies in the -# given file (report RP0402 must not be disabled) -import-graph= - -# Create a graph of internal dependencies in the given file (report RP0402 must -# not be disabled) -int-import-graph= - -# Force import order to recognize a module as part of the standard -# compatibility libraries. -known-standard-library= - -# Force import order to recognize a module as part of a third party library. -known-third-party=enchant - - -[EXCEPTIONS] - -# Exceptions that will emit a warning when being caught. Defaults to -# "Exception" -overgeneral-exceptions=Exception +notes= diff --git a/clay/app.py b/clay/app.py old mode 100755 new mode 100644 index b0b1edb..4bc47d6 --- a/clay/app.py +++ b/clay/app.py @@ -1,13 +1,11 @@ #!/usr/bin/env python3 # pylint: disable=wrong-import-position -# pylint: disable=too-many-instance-attributes -# pylint: disable=too-many-public-methods """ Main app entrypoint. """ import sys -sys.path.insert(0, '.') +sys.path.insert(0, '.') # noqa import argparse @@ -29,15 +27,18 @@ from clay.gp import gp def create_palette(transparent=False): + """ + Return urwid palette. + """ if transparent: - bg = '' + bgcolor = '' else: - bg = '#222' + bgcolor = '#222' return [ - (None, '', '', '', '#FFF', bg), - ('default', '', '', '', '#FFF', bg), - ('logo', '', '', '', '#F54', bg), + (None, '', '', '', '#FFF', bgcolor), + ('default', '', '', '', '#FFF', bgcolor), + ('logo', '', '', '', '#F54', bgcolor), ('bg', '', '', '', '#FFF', '#222'), ('primary', '', '', '', '#F54', '#FFF'), @@ -48,30 +49,30 @@ def create_palette(transparent=False): ('progress', '', '', '', '#FFF', '#F54'), ('progress_remaining', '', '', '', '#FFF', '#444'), - ('progressbar_done', '', '', '', '#F54', bg), - ('progressbar_done_paused', '', '', '', '', bg), - ('progressbar_remaining', '', '', '', '#222', bg), + ('progressbar_done', '', '', '', '#F54', bgcolor), + ('progressbar_done_paused', '', '', '', '', bgcolor), + ('progressbar_remaining', '', '', '', '#222', bgcolor), - ('title-idle', '', '', '', '', bg), - ('title-playing', '', '', '', '#F54', bg), + ('title-idle', '', '', '', '', bgcolor), + ('title-playing', '', '', '', '#F54', bgcolor), ('panel', '', '', '', '#FFF', '#222'), ('panel_focus', '', '', '', '#FFF', '#F54'), ('panel_divider', '', '', '', '#444', '#222'), ('panel_divider_focus', '', '', '', '#444', '#F54'), - ('line1', '', '', '', '#FFF', bg), + ('line1', '', '', '', '#FFF', bgcolor), ('line1_focus', '', '', '', '#FFF', '#333'), - ('line1_active', '', '', '', '#F54', bg), + ('line1_active', '', '', '', '#F54', bgcolor), ('line1_active_focus', '', '', '', '#F54', '#333'), - ('line2', '', '', '', '#AAA', bg), + ('line2', '', '', '', '#AAA', bgcolor), ('line2_focus', '', '', '', '#AAA', '#333'), ('input', '', '', '', '#FFF', '#444'), ('input_focus', '', '', '', '#FFF', '#F54'), - ('flag', '', '', '', '#AAA', bg), - ('flag-active', '', '', '', '#F54', bg), + ('flag', '', '', '', '#AAA', bgcolor), + ('flag-active', '', '', '', '#F54', bgcolor), ('notification', '', '', '', '#F54', '#222'), ] @@ -389,24 +390,30 @@ class MultilineVersionAction(argparse.Action): def main(): - # This method is required to allow Clay to be ran as script via setuptools installation. - # pylint: disable-all + """ + Application entrypoint. + + This method is required to allow Clay to be ran an application when installed via setuptools. + """ parser = argparse.ArgumentParser( prog=meta.APP_NAME, description=meta.DESCRIPTION, - epilog="This project is neither affiliated nor endorsed by Google.") + epilog="This project is neither affiliated nor endorsed by Google." + ) parser.add_argument("-v", "--version", action=MultilineVersionAction) parser.add_argument( "--with-x-keybinds", help="define global X keybinds (requires Keybinder and PyGObject)", - action='store_true') + action='store_true' + ) parser.add_argument( "--transparent", help="use transparent background", - action='store_true') + action='store_true' + ) args = parser.parse_args() From 59414ed89cbcffdd8c08e464d43583ceca9c31d3 Mon Sep 17 00:00:00 2001 From: Andrew Dunai Date: Wed, 14 Feb 2018 22:26:54 +0200 Subject: [PATCH 08/17] Added --ignore-imports=y to ignore import duplications. --- .pylintrc | 1 - tox.ini | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.pylintrc b/.pylintrc index 0eb6d0f..cbd44a8 100644 --- a/.pylintrc +++ b/.pylintrc @@ -8,7 +8,6 @@ disable = too-many-public-methods, too-many-instance-attributes, no-self-use, - too-many-ancestors diff --git a/tox.ini b/tox.ini index 305c324..86a93c7 100644 --- a/tox.ini +++ b/tox.ini @@ -12,5 +12,5 @@ deps = gmusicapi pylint commands = - pylint clay + pylint clay --ignore-imports=y From 035cd71cd976d61b4518d5abcf914d96dfdca10f Mon Sep 17 00:00:00 2001 From: Andrew Dunai Date: Wed, 14 Feb 2018 22:44:00 +0200 Subject: [PATCH 09/17] CI updates (radon + make check) --- .travis.yml | 2 +- Makefile | 3 + clay/gp.py | 162 ++++++++++++++++++++++++++++++---------------------- tox.ini | 3 +- 4 files changed, 100 insertions(+), 70 deletions(-) diff --git a/.travis.yml b/.travis.yml index adfcdcc..4ce9bdc 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,7 +9,7 @@ before_install: - "sudo apt-get update" - "sudo apt-get install python-gi python3-gi" install: - - "pip install tox" + - "pip install tox radon" script: - "tox" diff --git a/Makefile b/Makefile index f98de82..2cc9032 100644 --- a/Makefile +++ b/Makefile @@ -24,3 +24,6 @@ run: | build docs: make -C docs html +check: + pylint clay --ignore-imports=y + radon cc -a -s -nB -e clay/vlc.py clay diff --git a/clay/gp.py b/clay/gp.py index 5dc3a3b..1c15c6c 100644 --- a/clay/gp.py +++ b/clay/gp.py @@ -131,6 +131,100 @@ 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): """ @@ -146,73 +240,7 @@ class Track(object): ] try: - if source == Track.SOURCE_SEARCH: - # Data contains a nested track representation. - return Track( - title=data['track']['title'], - artist=data['track']['artist'], - duration=int(data['track']['durationMillis']), - source=source, - store_id=data['track']['storeId'], # or data['trackId'] - album_name=data['track']['album'], - album_url=data['track']['albumArtRef'][0]['url'], - original_data=data - ) - elif source == Track.SOURCE_STATION: - # Station tracks have all the info in place. - return Track( - title=data['title'], - artist=data['artist'], - duration=int(data['durationMillis']), - source=source, - store_id=data['storeId'], - album_name=data['album'], - album_url=data['albumArtRef'][0]['url'], - original_data=data - ) - elif source == Track.SOURCE_LIBRARY: - # 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=source, - store_id=data['storeId'], - library_id=data['id'], - album_name=data['album'], - album_url=data['albumArtRef'][0]['url'], - original_data=data - ) - elif source == Track.SOURCE_PLAYLIST: - 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=source, - 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=source, - store_id=track.store_id, - album_name=track.album_name, - album_url=track.album_url, - original_data=data - ) + return cls._CREATE_TRACK[source](data) except Exception as error: # pylint: disable=bare-except logger.error( 'Failed to parse track data: %s, failing data: %s', diff --git a/tox.ini b/tox.ini index 86a93c7..1d6ce10 100644 --- a/tox.ini +++ b/tox.ini @@ -12,5 +12,4 @@ deps = gmusicapi pylint commands = - pylint clay --ignore-imports=y - + make check From d74c66d15072e7a5f8daab5140c04467c303220d Mon Sep 17 00:00:00 2001 From: Andrew Dunai Date: Wed, 14 Feb 2018 22:45:30 +0200 Subject: [PATCH 10/17] Radon B -> C --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 2cc9032..ece1a37 100644 --- a/Makefile +++ b/Makefile @@ -26,4 +26,4 @@ docs: check: pylint clay --ignore-imports=y - radon cc -a -s -nB -e clay/vlc.py clay + radon cc -a -s -nC -e clay/vlc.py clay From d75ff9353c17a7ca4bb5f2e1481048ad81953c63 Mon Sep 17 00:00:00 2001 From: Andrew Dunai Date: Wed, 14 Feb 2018 22:54:15 +0200 Subject: [PATCH 11/17] Testing code climate --- .codeclimate.yml | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 .codeclimate.yml diff --git a/.codeclimate.yml b/.codeclimate.yml new file mode 100644 index 0000000..7a872bc --- /dev/null +++ b/.codeclimate.yml @@ -0,0 +1,29 @@ +version: "2" # required to adjust maintainability checks +engines: + duplication: + enabled: false +checks: + argument-count: + config: + threshold: 8 + complex-logic: + config: + threshold: 4 + file-lines: + config: + threshold: 1000 + method-complexity: + config: + threshold: 5 + method-count: + config: + threshold: 40 + method-lines: + config: + threshold: 25 + nested-control-flow: + config: + threshold: 4 + return-statements: + config: + threshold: 4 From 5483250560d17bdeb57f8809ace49b513a2947ac Mon Sep 17 00:00:00 2001 From: Andrew Dunai Date: Wed, 14 Feb 2018 22:55:45 +0200 Subject: [PATCH 12/17] Code climate badge. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7ef022f..f382c79 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ # Clay [beta] -[![Build Status](https://travis-ci.org/and3rson/clay.svg?branch=master)](https://travis-ci.org/and3rson/clay) [![Documentation Status](https://readthedocs.org/projects/clay/badge/?version=latest)](http://clay.readthedocs.io/en/latest/?badge=latest) [![PyPI version](https://badge.fury.io/py/clay-player.svg)](https://badge.fury.io/py/clay-player) +[![Build Status](https://travis-ci.org/and3rson/clay.svg?branch=master)](https://travis-ci.org/and3rson/clay) [![Documentation Status](https://readthedocs.org/projects/clay/badge/?version=latest)](http://clay.readthedocs.io/en/latest/?badge=latest) [![PyPI version](https://badge.fury.io/py/clay-player.svg)](https://badge.fury.io/py/clay-player) [![Maintainability](https://api.codeclimate.com/v1/badges/33fc2ac7949ddd9a51ee/maintainability)](https://codeclimate.com/github/and3rson/clay/maintainability) Standalone command line player for Google Play Music. From a4f070b9f7b4798cce0508b692cae0fbd95de091 Mon Sep 17 00:00:00 2001 From: Andrew Dunai Date: Wed, 14 Feb 2018 22:57:01 +0200 Subject: [PATCH 13/17] I just love badges. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f382c79..ed679eb 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ # Clay [beta] -[![Build Status](https://travis-ci.org/and3rson/clay.svg?branch=master)](https://travis-ci.org/and3rson/clay) [![Documentation Status](https://readthedocs.org/projects/clay/badge/?version=latest)](http://clay.readthedocs.io/en/latest/?badge=latest) [![PyPI version](https://badge.fury.io/py/clay-player.svg)](https://badge.fury.io/py/clay-player) [![Maintainability](https://api.codeclimate.com/v1/badges/33fc2ac7949ddd9a51ee/maintainability)](https://codeclimate.com/github/and3rson/clay/maintainability) +[![Build Status](https://travis-ci.org/and3rson/clay.svg?branch=master)](https://travis-ci.org/and3rson/clay) [![Documentation Status](https://readthedocs.org/projects/clay/badge/?version=latest)](http://clay.readthedocs.io/en/latest/?badge=latest) [![PyPI version](https://badge.fury.io/py/clay-player.svg)](https://badge.fury.io/py/clay-player) [![Maintainability](https://api.codeclimate.com/v1/badges/33fc2ac7949ddd9a51ee/maintainability?1)](https://codeclimate.com/github/and3rson/clay/maintainability) Standalone command line player for Google Play Music. From ad6c7224daf20d49c66a1e203cd87e2159147b35 Mon Sep 17 00:00:00 2001 From: Andrew Dunai Date: Wed, 14 Feb 2018 23:00:06 +0200 Subject: [PATCH 14/17] Sane configs for code climate. --- .codeclimate.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.codeclimate.yml b/.codeclimate.yml index 7a872bc..f92b4df 100644 --- a/.codeclimate.yml +++ b/.codeclimate.yml @@ -1,11 +1,11 @@ -version: "2" # required to adjust maintainability checks +version: "2" # required to adjust maintainability checks engines: duplication: enabled: false checks: argument-count: config: - threshold: 8 + threshold: 8 # maximum complexity for ~ B mark in Radon complex-logic: config: threshold: 4 @@ -20,10 +20,10 @@ checks: threshold: 40 method-lines: config: - threshold: 25 + threshold: 40 # to fit in 1366x768 screen nested-control-flow: config: threshold: 4 return-statements: config: - threshold: 4 + threshold: 5 From 291a2f4be4a6bb1ad7f9cd9e00afc3da4a8507ed Mon Sep 17 00:00:00 2001 From: Andrew Dunai Date: Thu, 15 Feb 2018 12:29:32 +0200 Subject: [PATCH 15/17] Fixed notifications, track parsing complexity & codeclimate settings. --- .codeclimate.yml | 2 +- clay/app.py | 1 + clay/gp.py | 10 +++--- clay/pages/settings.py | 6 ++-- clay/songlist.py | 82 ++++++++++++++---------------------------- 5 files changed, 37 insertions(+), 64 deletions(-) mode change 100644 => 100755 clay/app.py diff --git a/.codeclimate.yml b/.codeclimate.yml index f92b4df..17b8dce 100644 --- a/.codeclimate.yml +++ b/.codeclimate.yml @@ -14,7 +14,7 @@ checks: threshold: 1000 method-complexity: config: - threshold: 5 + threshold: 10 method-count: config: threshold: 40 diff --git a/clay/app.py b/clay/app.py old mode 100644 new mode 100755 index 4bc47d6..a280bbf --- a/clay/app.py +++ b/clay/app.py @@ -158,6 +158,7 @@ class AppWidget(urwid.Frame): for tab in self.tabs ], dividechars=0), 'panel'), + notification_area ]) self.playbar = PlayBar(self) super(AppWidget, self).__init__( diff --git a/clay/gp.py b/clay/gp.py index 1c15c6c..7df902d 100644 --- a/clay/gp.py +++ b/clay/gp.py @@ -219,10 +219,10 @@ class Track(object): ) _CREATE_TRACK = { - SOURCE_SEARCH: _from_search, - SOURCE_STATION: _from_station, - SOURCE_LIBRARY: _from_library, - SOURCE_PLAYLIST: _from_playlist, + SOURCE_SEARCH: '_from_search', + SOURCE_STATION: '_from_station', + SOURCE_LIBRARY: '_from_library', + SOURCE_PLAYLIST: '_from_playlist', } @classmethod @@ -240,7 +240,7 @@ class Track(object): ] try: - return cls._CREATE_TRACK[source](data) + return getattr(cls, cls._CREATE_TRACK[source])(data) except Exception as error: # pylint: disable=bare-except logger.error( 'Failed to parse track data: %s, failing data: %s', diff --git a/clay/pages/settings.py b/clay/pages/settings.py index 0cd29f1..cb158fe 100644 --- a/clay/pages/settings.py +++ b/clay/pages/settings.py @@ -31,9 +31,9 @@ class Slider(urwid.Widget): freq = int(freq) self.freq = freq if freq >= 1000: - self.freq_str = str(freq // 1000) + ' KHz' + self.freq_str = str(freq // 1000) + '\nKHz' else: - self.freq_str = str(freq) + ' Hz' + self.freq_str = str(freq) + '\nHz' self.value = 0 self.slider_height = 5 self.max_value = 20 @@ -43,7 +43,7 @@ class Slider(urwid.Widget): """ Return count of rows required to render this widget. """ - return self.slider_height + 2 + return self.slider_height + 3 def render(self, size, focus=None): """ diff --git a/clay/songlist.py b/clay/songlist.py index cd052c4..3570749 100644 --- a/clay/songlist.py +++ b/clay/songlist.py @@ -209,70 +209,42 @@ class SongListBoxPopup(urwid.LineBox): 'panel_divider' ) ] - options.append(urwid.AttrWrap( - urwid.Divider(u'\u2500'), - 'panel_divider', - 'panel_divider_focus' - )) + options.append(self._create_divider()) if not gp.get_track_by_id(songitem.track.id): - options.append(urwid.AttrWrap( - urwid.Button('Add to my library', on_press=self.add_to_my_library), - 'panel', - 'panel_focus' - )) + options.append(self._create_button('Add to library', self.add_to_my_library)) else: - options.append(urwid.AttrWrap( - urwid.Button('Remove from my library', on_press=self.remove_from_my_library), - 'panel', - 'panel_focus' - )) - options.append(urwid.AttrWrap( - urwid.Divider(u'\u2500'), - 'panel_divider', - 'panel_divider_focus' - )) - options.append(urwid.AttrWrap( - urwid.Button('Create station', on_press=self.create_station), - 'panel', - 'panel_focus' - )) - options.append(urwid.AttrWrap( - urwid.Divider(u'\u2500'), - 'panel_divider', - 'panel_divider_focus' - )) + options.append(self._create_button('Remove from library', self.remove_from_my_library)) + options.append(self._create_divider()) + options.append(self._create_button('Create station', self.create_station)) + options.append(self._create_divider()) if self.songitem.track in player.get_queue_tracks(): - options.append(urwid.AttrWrap( - urwid.Button('Remove from queue', on_press=self.remove_from_queue), - 'panel', - 'panel_focus' - )) + options.append(self._create_button('Remove from queue', self.remove_from_queue)) else: - options.append(urwid.AttrWrap( - urwid.Button('Append to queue', on_press=self.append_to_queue), - 'panel', - 'panel_focus' - )) - options.append(urwid.AttrWrap( - urwid.Divider(u'\u2500'), - 'panel_divider', - 'panel_divider_focus' - )) + options.append(self._create_button('Append to queue', self.append_to_queue)) if self.songitem.track.cached_url is not None: - options.append(urwid.AttrWrap( - urwid.Button('Copy URL to clipboard', on_press=self.copy_url), - 'panel', - 'panel_focus' - )) - options.append(urwid.AttrWrap( - urwid.Button('Close', on_press=self.close), - 'panel', - 'panel_focus' - )) + options.append(self._create_button('Copy URL to clipboard', self.copy_url)) + options.append(self._create_button('Close', self.close)) super(SongListBoxPopup, self).__init__( urwid.Pile(options) ) + def _create_divider(self): + """ + Return a divider widget. + """ + return urwid.AttrWrap( + urwid.Divider(u'\u2500'), + 'panel_divider', + 'panel_divider_focus' + ) + + def _create_button(self, title, on_press): + return urwid.AttrWrap( + urwid.Button(title, on_press=on_press), + 'panel', + 'panel_focus' + ) + def add_to_my_library(self, _): """ Add related track to my library. From 9780e4797b82f606c3b7affb37dfec6e174c0cde Mon Sep 17 00:00:00 2001 From: Andrew Dunai Date: Thu, 15 Feb 2018 12:42:49 +0200 Subject: [PATCH 16/17] Code cleanup. --- clay/songlist.py | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/clay/songlist.py b/clay/songlist.py index 3570749..f70287a 100644 --- a/clay/songlist.py +++ b/clay/songlist.py @@ -36,6 +36,19 @@ class SongListItem(urwid.Pile): STATE_PLAYING = 2 STATE_PAUSED = 3 + LINE1_ATTRS = { + STATE_IDLE: ('line1', 'line1_focus'), + STATE_LOADING: ('line1_active', 'line1_active_focus'), + STATE_PLAYING: ('line1_active', 'line1_active_focus'), + STATE_PAUSED: ('line1_active', 'line1_active_focus') + } + LINE2_ATTRS = { + STATE_IDLE: ('line2', 'line2_focus'), + STATE_LOADING: ('line2', 'line2_focus'), + STATE_PLAYING: ('line2', 'line2_focus'), + STATE_PAUSED: ('line2', 'line2_focus') + } + STATE_ICONS = { 0: ' ', 1: u'\u2505', @@ -97,12 +110,6 @@ class SongListItem(urwid.Pile): """ Update text of this item from the attached track. """ - if self.state == SongListItem.STATE_IDLE: - title_attr = 'line1_focus' if self.is_focused else 'line1' - else: - title_attr = 'line1_active_focus' if self.is_focused else 'line1_active' - artist_attr = 'line2_focus' if self.is_focused else 'line2' - self.line1_left.set_text( u'{index:3d} {icon} {title} [{minutes:02d}:{seconds:02d}]'.format( index=self.index + 1, @@ -119,8 +126,8 @@ class SongListItem(urwid.Pile): self.line2.set_text( u' {} \u2015 {}'.format(self.track.artist, self.track.album_name) ) - self.line1_wrap.set_attr(title_attr) - self.line2_wrap.set_attr(artist_attr) + self.line1_wrap.set_attr(SongListItem.LINE1_ATTRS[self.state][self.is_focused]) + self.line2_wrap.set_attr(SongListItem.LINE2_ATTRS[self.state][self.is_focused]) @property def full_title(self): From 37dd18c3a1d5b91db5d03265431dbdc5dfd0333d Mon Sep 17 00:00:00 2001 From: Andrew Dunai Date: Thu, 15 Feb 2018 12:45:48 +0200 Subject: [PATCH 17/17] Added vlc.py to excludes. --- .codeclimate.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.codeclimate.yml b/.codeclimate.yml index 17b8dce..f293f13 100644 --- a/.codeclimate.yml +++ b/.codeclimate.yml @@ -1,4 +1,6 @@ version: "2" # required to adjust maintainability checks +exclude_patterns: + - "clay/vlc.py" engines: duplication: enabled: false