diff --git a/AUTHORS b/AUTHORS index 6853d5ab..d7841635 100644 --- a/AUTHORS +++ b/AUTHORS @@ -37,3 +37,4 @@ - Simon de Bakker - Arnaud Barisain-Monrose - nathanharper +- Pierpaolo Frasa diff --git a/README.rst b/README.rst index 0b003815..f201ae80 100644 --- a/README.rst +++ b/README.rst @@ -19,7 +19,7 @@ To get started with Mopidy, check out `the docs `_. - `Source code `_ - `Issue tracker `_ - `CI server `_ -- `Download development snapshot `_ +- `Download development snapshot `_ - IRC: ``#mopidy`` at `irc.freenode.net `_ - Mailing list: `mopidy@googlegroups.com `_ diff --git a/docs/changelog.rst b/docs/changelog.rst index 05becbe8..c7b0a078 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -5,21 +5,9 @@ Changelog This changelog is used to track all major changes to Mopidy. -v0.19.0 (unreleased) +v0.19.0 (UNRELEASED) ==================== -**Models** - -- The type of :attr:`mopidy.models.Playlist.last_modified` has been redefined - from a :class:`datetime.datetime` instance to the number of milliseconds - since Unix epoch as an integer. This makes serialization of the time stamp - simpler. - -**MPD** - -- Minor refactor of context such that it stores password instead of config. - (Fixes: :issue:`646`) - - Proper command tokenization for MPD requests. This replaces the old regex based system with an MPD protocol specific tokenizer responsible for breaking requests into pieces before the handlers have at them. @@ -35,9 +23,49 @@ v0.19.0 (unreleased) - Adds placeholders for missing MPD commands, preparing the way for bumping the protocol version once they have been added. -**Windows** -- Network and signal handling has been updated to play nice on windows systems. +v0.18.3 (2014-02-16) +==================== + +Bug fix release. + +- Fix documentation build. + + +v0.18.2 (2014-02-16) +==================== + +Bug fix release. + +- We now log warnings for wrongly configured extensions, and clearly label them + in ``mopidy config``, but does no longer stop Mopidy from starting because of + misconfigured extensions. (Fixes: :issue:`682`) + +- Fix a crash in the server side WebSocket handler caused by connection + problems with clients. (Fixes: :issue:`428`, :issue:`571`) + +- Fix the ``time_position`` field of the ``track_playback_ended`` event, which + has been always 0 since v0.18.0. This made scrobbles by Mopidy-Scrobbler not + be persisted by Last.fm, because Mopidy reported that you listened to 0 + seconds of each track. (Fixes: :issue:`674`) + +- Fix the log setup so that it is possible to increase the amount of logging + from a specific logger using the ``loglevels`` config section. (Fixes: + :issue:`684`) + +- Serialization of :class:`~mopidy.models.Playlist` models with the + ``last_modified`` field set to a :class:`datetime.datetime` instance did not + work. The type of :attr:`mopidy.models.Playlist.last_modified` has been + redefined from a :class:`datetime.datetime` instance to the number of + milliseconds since Unix epoch as an integer. This makes serialization of the + time stamp simpler. + +**Windows** +======= +- Minor refactor of the MPD server context so that Mopidy's MPD protocol + implementation can easier be reused. (Fixes: :issue:`646`) + +- Network and signal handling has been updated to play nice on Windows systems. v0.18.1 (2014-01-23) diff --git a/docs/ext/external.rst b/docs/ext/external.rst index 0ead8ac2..33d94451 100644 --- a/docs/ext/external.rst +++ b/docs/ext/external.rst @@ -51,6 +51,15 @@ Provides a backend for playing music from `Google Play Music `_. +Mopidy-InternetArchive +====================== + +https://github.com/tkem/mopidy-internetarchive + +Extension for playing music and audio from the `Internet Archive +`_. + + Mopidy-MPRIS ============ @@ -76,6 +85,14 @@ https://github.com/sauberfred/mopidy-notifier Extension for displaying track info as User Notifications in Mac OS X. +Mopidy-Podcast +============== + +https://github.com/tkem/mopidy-podcast + +Extension for browsing RSS feeds of podcasts and stream the episodes. + + Mopidy-radio-de =============== @@ -137,3 +154,12 @@ https://github.com/sibuser/mopidy-vkontakte Provides a backend for playing music from the `VKontakte social network `_. + + +Mopidy-Yamaha +============= + +https://github.com/knutz3n/mopidy-yamaha + +Extension for controlling volume using an external Yamaha network connected +amplifier. diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 1367a219..c1646fa9 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -21,4 +21,4 @@ if (isinstance(pykka.__version__, basestring) warnings.filterwarnings('ignore', 'could not open display') -__version__ = '0.18.1' +__version__ = '0.18.3' diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 05394bc2..b447e77d 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -74,20 +74,28 @@ def main(): log.setup_logging(config, verbosity_level, args.save_debug_log) - enabled_extensions = [] + extensions = { + 'validate': [], 'config': [], 'disabled': [], 'enabled': []} for extension in installed_extensions: if not ext.validate_extension(extension): config[extension.ext_name] = {'enabled': False} config_errors[extension.ext_name] = { 'enabled': 'extension disabled by self check.'} + extensions['validate'].append(extension) elif not config[extension.ext_name]['enabled']: config[extension.ext_name] = {'enabled': False} config_errors[extension.ext_name] = { 'enabled': 'extension disabled by user config.'} + extensions['disabled'].append(extension) + elif config_errors.get(extension.ext_name): + config[extension.ext_name]['enabled'] = False + config_errors[extension.ext_name]['enabled'] = ( + 'extension disabled due to config errors.') + extensions['config'].append(extension) else: - enabled_extensions.append(extension) + extensions['enabled'].append(extension) - log_extension_info(installed_extensions, enabled_extensions) + log_extension_info(installed_extensions, extensions['enabled']) # Config and deps commands are simply special cased for now. if args.command == config_cmd: @@ -96,22 +104,22 @@ def main(): elif args.command == deps_cmd: return args.command.run() - # Remove errors for extensions that are not enabled: - for extension in installed_extensions: - if extension not in enabled_extensions: - config_errors.pop(extension.ext_name, None) - check_config_errors(config_errors) + check_config_errors(config, config_errors, extensions) + + if not extensions['enabled']: + logger.error('No extension enabled, exiting...') + sys.exit(1) # Read-only config from here on, please. proxied_config = config_lib.Proxy(config) - if args.extension and args.extension not in enabled_extensions: + if args.extension and args.extension not in extensions['enabled']: logger.error( 'Unable to run command provided by disabled extension %s', args.extension.ext_name) return 1 - for extension in enabled_extensions: + for extension in extensions['enabled']: extension.setup(registry) # Anything that wants to exit after this point must use @@ -173,13 +181,39 @@ def log_extension_info(all_extensions, enabled_extensions): 'Disabled extensions: %s', ', '.join(disabled_names) or 'none') -def check_config_errors(errors): - if not errors: - return - for section in errors: - for key, msg in errors[section].items(): - logger.error('Config value %s/%s %s', section, key, msg) - sys.exit(1) +def check_config_errors(config, errors, extensions): + fatal_errors = [] + extension_names = {} + all_extension_names = set() + + for state in extensions: + extension_names[state] = set(e.ext_name for e in extensions[state]) + all_extension_names.update(extension_names[state]) + + for section in sorted(errors): + if not errors[section]: + continue + + if section not in all_extension_names: + logger.warning('Found fatal %s configuration errors:', section) + fatal_errors.append(section) + elif section in extension_names['config']: + del errors[section]['enabled'] + logger.warning('Found %s configuration errors, the extension ' + 'has been automatically disabled:', section) + else: + continue + + for field, msg in errors[section].items(): + logger.warning(' %s/%s %s', section, field, msg) + + if extensions['config']: + logger.warning('Please fix the extension configuration errors or ' + 'disable the extensions to silence these messages.') + + if fatal_errors: + logger.error('Please fix fatal configuration errors, exiting...') + sys.exit(1) if __name__ == '__main__': diff --git a/mopidy/config/keyring.py b/mopidy/config/keyring.py index 6800d2c4..4d251f52 100644 --- a/mopidy/config/keyring.py +++ b/mopidy/config/keyring.py @@ -19,20 +19,25 @@ else: EMPTY_STRING = '' +FETCH_ERROR = ( + 'Fetching passwords from your keyring failed. Any passwords ' + 'stored in the keyring will not be available.') + + def fetch(): if not dbus: - logger.debug('Fetching from keyring failed: dbus not installed.') + logger.debug('%s (dbus not installed)', FETCH_ERROR) return [] try: bus = dbus.SessionBus() except dbus.exceptions.DBusException as e: - logger.debug('Fetching from keyring failed: %s', e) + logger.debug('%s (%s)', FETCH_ERROR, e) return [] if not bus.name_has_owner('org.freedesktop.secrets'): logger.debug( - 'Fetching from keyring failed: secrets service not running.') + '%s (org.freedesktop.secrets service not running)', FETCH_ERROR) return [] service = _service(bus) @@ -47,7 +52,7 @@ def fetch(): items, prompt = service.Unlock(locked) if prompt != '/': _prompt(bus, prompt).Dismiss() - logger.debug('Fetching from keyring failed: keyring is locked.') + logger.debug('%s (Keyring is locked)', FETCH_ERROR) return [] result = [] @@ -65,19 +70,20 @@ def set(section, key, value): Indicates if storage failed or succeeded. """ if not dbus: - logger.debug('Saving %s/%s to keyring failed: dbus not installed.', + logger.debug('Saving %s/%s to keyring failed. (dbus not installed)', section, key) return False try: bus = dbus.SessionBus() except dbus.exceptions.DBusException as e: - logger.debug('Saving %s/%s to keyring failed: %s', section, key, e) + logger.debug('Saving %s/%s to keyring failed. (%s)', section, key, e) return False if not bus.name_has_owner('org.freedesktop.secrets'): logger.debug( - 'Saving %s/%s to keyring failed: secrets service not running.', + 'Saving %s/%s to keyring failed. ' + '(org.freedesktop.secrets service not running)', section, key) return False @@ -101,14 +107,14 @@ def set(section, key, value): item, prompt = collection.CreateItem(properties, secret, True) except dbus.exceptions.DBusException as e: # TODO: catch IsLocked errors etc. - logger.debug('Saving %s/%s to keyring failed: %s', section, key, e) + logger.debug('Saving %s/%s to keyring failed. (%s)', section, key, e) return False if prompt == '/': return True _prompt(bus, prompt).Dismiss() - logger.debug('Saving secret %s/%s failed: Keyring is locked', + logger.debug('Saving secret %s/%s failed. (Keyring is locked)', section, key) return False diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index b2acb35a..3164e3b3 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -306,9 +306,10 @@ class PlaybackController(object): """ if self.state != PlaybackState.STOPPED: backend = self._get_backend() + time_position_before_stop = self.time_position if not backend or backend.playback.stop().get(): self.state = PlaybackState.STOPPED - self._trigger_track_playback_ended() + self._trigger_track_playback_ended(time_position_before_stop) if clear_current_track: self.current_tl_track = None @@ -336,13 +337,14 @@ class PlaybackController(object): 'track_playback_started', tl_track=self.current_tl_track) - def _trigger_track_playback_ended(self): + def _trigger_track_playback_ended(self, time_position_before_stop): logger.debug('Triggering track playback ended event') if self.current_tl_track is None: return listener.CoreListener.send( 'track_playback_ended', - tl_track=self.current_tl_track, time_position=self.time_position) + tl_track=self.current_tl_track, + time_position=time_position_before_stop) def _trigger_playback_state_changed(self, old_state, new_state): logger.debug('Triggering playback state change event') diff --git a/mopidy/http/actor.py b/mopidy/http/actor.py index e7b5cb66..c5787fec 100644 --- a/mopidy/http/actor.py +++ b/mopidy/http/actor.py @@ -100,10 +100,11 @@ class HttpFrontend(pykka.ThreadingActor, CoreListener): host=self.hostname, port=self.port) if self.zeroconf_service.publish(): - logger.info('Registered HTTP with Zeroconf as "%s"', - self.zeroconf_service.name) + logger.debug( + 'Registered HTTP with Zeroconf as "%s"', + self.zeroconf_service.name) else: - logger.info('Registering HTTP with Zeroconf failed.') + logger.debug('Registering HTTP with Zeroconf failed.') def on_stop(self): if self.zeroconf_service: diff --git a/mopidy/http/ext.conf b/mopidy/http/ext.conf index fc239230..d35229bc 100644 --- a/mopidy/http/ext.conf +++ b/mopidy/http/ext.conf @@ -4,6 +4,3 @@ hostname = 127.0.0.1 port = 6680 static_dir = zeroconf = Mopidy HTTP server on $hostname - -[loglevels] -cherrypy = warning diff --git a/mopidy/http/ws.py b/mopidy/http/ws.py index 4d7aa9a2..286e97e6 100644 --- a/mopidy/http/ws.py +++ b/mopidy/http/ws.py @@ -1,14 +1,14 @@ from __future__ import unicode_literals import logging +import socket import cherrypy -from ws4py.websocket import WebSocket +import ws4py.websocket from mopidy import core, models from mopidy.utils import jsonrpc - logger = logging.getLogger(__name__) @@ -43,7 +43,28 @@ class WebSocketResource(object): cherrypy.request.ws_handler.jsonrpc = self.jsonrpc -class WebSocketHandler(WebSocket): +class _WebSocket(ws4py.websocket.WebSocket): + """Sub-class ws4py WebSocket with better error handling.""" + + def send(self, *args, **kwargs): + try: + super(_WebSocket, self).send(*args, **kwargs) + return True + except socket.error as e: + logger.warning('Send message failed: %s', e) + # This isn't really correct, but its the only way to break of out + # the loop in run and trick ws4py into cleaning up. + self.client_terminated = self.server_terminated = True + return False + + def close(self, *args, **kwargs): + try: + super(_WebSocket, self).close(*args, **kwargs) + except socket.error as e: + logger.warning('Closing WebSocket failed: %s', e) + + +class WebSocketHandler(_WebSocket): def opened(self): remote = cherrypy.request.remote logger.debug( @@ -66,8 +87,7 @@ class WebSocketHandler(WebSocket): remote.ip, remote.port, request) response = self.jsonrpc.handle_json(request) - if response: - self.send(response) + if response and self.send(response): logger.debug( 'Sent WebSocket message to %s:%d: %r', remote.ip, remote.port, response) diff --git a/mopidy/local/commands.py b/mopidy/local/commands.py index 85939b43..fb8f5368 100644 --- a/mopidy/local/commands.py +++ b/mopidy/local/commands.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import print_function, unicode_literals import logging import os @@ -37,17 +37,17 @@ class ClearCommand(commands.Command): def run(self, args, config): library = _get_library(args, config) - prompt = 'Are you sure you want to clear the library? [y/N] ' + prompt = '\nAre you sure you want to clear the library? [y/N] ' if raw_input(prompt).lower() != 'y': - logging.info('Clearing library aborted.') + print('Clearing library aborted.') return 0 if library.clear(): - logging.info('Library succesfully cleared.') + print('Library successfully cleared.') return 0 - logging.warning('Unable to clear library.') + print('Unable to clear library.') return 1 diff --git a/mopidy/mpd/actor.py b/mopidy/mpd/actor.py index 20417a4d..684b4968 100644 --- a/mopidy/mpd/actor.py +++ b/mopidy/mpd/actor.py @@ -48,10 +48,11 @@ class MpdFrontend(pykka.ThreadingActor, CoreListener): host=self.hostname, port=self.port) if self.zeroconf_service.publish(): - logger.info('Registered MPD with Zeroconf as "%s"', - self.zeroconf_service.name) + logger.debug( + 'Registered MPD with Zeroconf as "%s"', + self.zeroconf_service.name) else: - logger.info('Registering MPD with Zeroconf failed.') + logger.debug('Registering MPD with Zeroconf failed.') def on_stop(self): if self.zeroconf_service: diff --git a/mopidy/utils/log.py b/mopidy/utils/log.py index 6004b9f9..cde07693 100644 --- a/mopidy/utils/log.py +++ b/mopidy/utils/log.py @@ -39,7 +39,6 @@ def setup_logging(config, verbosity_level, save_debug_log): # added. If not, the other handlers will have no effect. logging.config.fileConfig(config['logging']['config_file']) - setup_log_levels(config) setup_console_logging(config, verbosity_level) if save_debug_log: setup_debug_logging_to_file(config) @@ -47,11 +46,6 @@ def setup_logging(config, verbosity_level, save_debug_log): _delayed_handler.release() -def setup_log_levels(config): - for name, level in config['loglevels'].items(): - logging.getLogger(name).setLevel(level) - - LOG_LEVELS = { -1: dict(root=logging.ERROR, mopidy=logging.WARNING), 0: dict(root=logging.ERROR, mopidy=logging.INFO), @@ -62,10 +56,15 @@ LOG_LEVELS = { class VerbosityFilter(logging.Filter): - def __init__(self, verbosity_level): + def __init__(self, verbosity_level, loglevels): self.verbosity_level = verbosity_level + self.loglevels = loglevels def filter(self, record): + for name, required_log_level in self.loglevels.items(): + if record.name == name or record.name.startswith(name + '.'): + return record.levelno >= required_log_level + if record.name.startswith('mopidy'): required_log_level = LOG_LEVELS[self.verbosity_level]['mopidy'] else: @@ -79,9 +78,13 @@ def setup_console_logging(config, verbosity_level): if verbosity_level > max(LOG_LEVELS.keys()): verbosity_level = max(LOG_LEVELS.keys()) - verbosity_filter = VerbosityFilter(verbosity_level) + loglevels = config.get('loglevels', {}) + has_debug_loglevels = any([ + level < logging.INFO for level in loglevels.values()]) - if verbosity_level < 1: + verbosity_filter = VerbosityFilter(verbosity_level, loglevels) + + if verbosity_level < 1 and not has_debug_loglevels: log_format = config['logging']['console_format'] else: log_format = config['logging']['debug_format'] diff --git a/mopidy/zeroconf.py b/mopidy/zeroconf.py index e95b1792..1111975f 100644 --- a/mopidy/zeroconf.py +++ b/mopidy/zeroconf.py @@ -63,7 +63,7 @@ class Zeroconf(object): """ if _is_loopback_address(self.host): - logger.info( + logger.debug( 'Zeroconf publish on loopback interface is not supported.') return False diff --git a/tests/test_version.py b/tests/test_version.py index 23c93f01..b05f356e 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -43,5 +43,7 @@ class VersionTest(unittest.TestCase): self.assertLess(SV('0.15.0'), SV('0.16.0')) self.assertLess(SV('0.16.0'), SV('0.17.0')) self.assertLess(SV('0.17.0'), SV('0.18.0')) - self.assertLess(SV('0.18.0'), SV(__version__)) - self.assertLess(SV(__version__), SV('0.18.2')) + self.assertLess(SV('0.18.0'), SV('0.18.1')) + self.assertLess(SV('0.18.1'), SV('0.18.2')) + self.assertLess(SV('0.18.2'), SV(__version__)) + self.assertLess(SV(__version__), SV('0.18.4'))