diff --git a/README.rst b/README.rst
index 10ad4d3c..46880c9d 100644
--- a/README.rst
+++ b/README.rst
@@ -50,6 +50,7 @@ To get started with Mopidy, check out
`the installation docs `_.
- `Documentation `_
+- `Discuss `_
- `Source code `_
- `Issue tracker `_
- `Development branch tarball `_
diff --git a/docs/api/concepts.rst b/docs/api/concepts.rst
index 68718935..d127561b 100644
--- a/docs/api/concepts.rst
+++ b/docs/api/concepts.rst
@@ -6,14 +6,15 @@ Architecture and concepts
The overall architecture of Mopidy is organized around multiple frontends and
backends. The frontends use the core API. The core actor makes multiple backends
-work as one. The backends connect to various music sources. Both the core actor
-and the backends use the audio actor to play audio and control audio volume.
+work as one. The backends connect to various music sources. The core actor use
+the mixer actor to control volume, while the backends use the audio actor to
+play audio.
.. digraph:: overall_architecture
"Multiple frontends" -> Core
Core -> "Multiple backends"
- Core -> Audio
+ Core -> Mixer
"Multiple backends" -> Audio
@@ -93,7 +94,15 @@ Audio
=====
The audio actor is a thin wrapper around the parts of the GStreamer library we
-use. In addition to playback, it's responsible for volume control through both
-GStreamer's own volume mixers, and mixers we've created ourselves. If you
-implement an advanced backend, you may need to implement your own playback
-provider using the :ref:`audio-api`.
+use. If you implement an advanced backend, you may need to implement your own
+playback provider using the :ref:`audio-api`.
+
+
+Mixer
+=====
+
+The mixer actor is responsible for volume control and muting. The default
+mixer use the audio actor to control volume in software. The alternative
+implementations are typically independent of the audio actor, but instead use
+some third party Python library or a serial interface to control other forms
+of volume controls.
diff --git a/docs/changelog.rst b/docs/changelog.rst
index 8948fe6e..7eefd533 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -13,18 +13,42 @@ v0.20.0 (UNRELEASED)
- Add cover URL to all scanned files with MusicBrainz album IDs. (Fixes:
:issue:`697`, PR: :issue:`802`)
+**MPD frontend**
-v0.19.4 (UNRELEASED)
+- In stored playlist names, replace "/", which are illegal, with "|" instead of
+ a whitespace. Pipes are more similar to forward slash.
+
+
+v0.19.4 (2014-09-01)
====================
Bug fix release.
+- Configuration: :option:`mopidy --config` now supports directories.
+
+- Logging: Fix that some loggers would be disabled if
+ :confval:`logging/config_file` was set. (Fixes: :issue:`740`)
+
+- Quit process with exit code 1 when stopping because of a backend, frontend,
+ or mixer initialization error.
+
+- Backend API: Update :meth:`mopidy.backend.LibraryProvider.browse` signature
+ and docs to match how the core use the backend's browse method. (Fixes:
+ :issue:`833`)
+
+- Local library API: Add :attr:`mopidy.local.Library.ROOT_DIRECTORY_URI`
+ constant for use by implementors of :meth:`mopidy.local.Library.browse`.
+ (Related to: :issue:`833`)
+
+- HTTP frontend: Guard against double close of WebSocket, which causes an
+ :exc:`AttributeError` on Tornado < 3.2.
+
- MPD frontend: Make the ``list`` command return albums when sending 3
arguments. This was incorrectly returning artists after the MPD command
changes in 0.19.0. (Fixes: :issue:`817`)
-- Logging: Fix that some loggers would be disabled if
- :confval:`logging/config_file` was set. (Fixes: :issue:`740`)
+- MPD frontend: Fix a race condition where two threads could try to free the
+ same data simultaneously. (Fixes: :issue:`781`)
v0.19.3 (2014-08-03)
diff --git a/docs/command.rst b/docs/command.rst
index 79ca3ed0..881fb513 100644
--- a/docs/command.rst
+++ b/docs/command.rst
@@ -50,11 +50,12 @@ Options
Save debug log to the file specified in the :confval:`logging/debug_file`
config value, typically ``./mopidy.log``.
-.. cmdoption:: --config
+.. cmdoption:: --config
- Specify config file to use. To use multiple config files, separate them
- with a colon. The later files override the earlier ones if there's a
- conflict.
+ Specify config files and directories to use. To use multiple config files
+ or directories, separate them with a colon. The later files override the
+ earlier ones if there's a conflict. When specifying a directory, all files
+ ending in .conf in the directory are used.
.. cmdoption:: --option , -o
diff --git a/docs/conf.py b/docs/conf.py
index 52e84e06..1eb6dd33 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -52,6 +52,7 @@ MOCK_MODULES = [
'glib',
'gobject',
'gst',
+ 'gst.pbutils',
'pygst',
'pykka',
'pykka.actor',
diff --git a/docs/index.rst b/docs/index.rst
index 71e8dee7..bd324af7 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -56,15 +56,20 @@ To get started with Mopidy, start by reading :ref:`installation`.
**Getting help**
-If you get stuck, we usually hang around at ``#mopidy`` at `irc.freenode.net
- `_ (with `searchable logs
- `_) and also have a `mailing list at Google
-Groups `_. If you
-stumble into a bug or got a feature request, please create an issue in the
-`issue tracker `_. The `source code
-`_ may also be of help. If you want to stay
-up to date on Mopidy developments, you can follow `@mopidy
- `_ on Twitter.
+If you get stuck, you can get help at the `Mopidy discussion forum
+ `_. We also hang around at IRC on the ``#mopidy``
+channel at `irc.freenode.net `_. The IRC channel has
+`public searchable logs `_.
+
+If you stumble into a bug or have a feature request, please create an issue in
+the `issue tracker `_. If you're
+unsure if its a bug or not, ask for help in the forum or at IRC first. The
+`source code `_ may also be of help.
+
+If you want to stay up to date on Mopidy developments, you can follow `@mopidy
+ `_ on Twitter. There's also a `mailing list
+`_ used for
+announcements related to Mopidy and Mopidy extensions.
Usage
@@ -118,6 +123,7 @@ About
:maxdepth: 1
authors
+ sponsors
changelog
versioning
diff --git a/docs/sponsors.rst b/docs/sponsors.rst
new file mode 100644
index 00000000..67aef554
--- /dev/null
+++ b/docs/sponsors.rst
@@ -0,0 +1,38 @@
+.. _sponsors:
+
+********
+Sponsors
+********
+
+The Mopidy project would like to thank the following sponsors for supporting
+the project.
+
+
+Rackspace
+=========
+
+`Rackspace `_ lets Mopidy use their hosting services
+for free. We use their services for the following sites:
+
+- Hosting of the APT package repository at https://apt.mopidy.com.
+
+- Hosting of the Discourse forum at https://discuss.mopidy.com.
+
+- Mailgun for sending emails from the Discourse forum.
+
+- Hosting of the Jenkins CI server at https://ci.mopidy.com.
+
+- Hosting of a Linux worker for https://ci.mopidy.com.
+
+- Hosting of a Windows worker for https://ci.mopidy.com.
+
+- CDN hosting at http://dl.mopidy.com, which is used to distribute Pi Musicbox
+ images.
+
+
+GlobalSign
+==========
+
+`GlobalSign `_ provides Mopidy with a free
+wildcard SSL certificate for mopidy.com, which we use to secure access to all
+our web sites.
diff --git a/mopidy/__init__.py b/mopidy/__init__.py
index 47e36e06..7b55f20a 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.19.3'
+__version__ = '0.19.4'
diff --git a/mopidy/backend/__init__.py b/mopidy/backend/__init__.py
index 845eac11..712bfafe 100644
--- a/mopidy/backend/__init__.py
+++ b/mopidy/backend/__init__.py
@@ -81,12 +81,12 @@ class LibraryProvider(object):
def __init__(self, backend):
self.backend = backend
- def browse(self, path):
+ def browse(self, uri):
"""
See :meth:`mopidy.core.LibraryController.browse`.
If you implement this method, make sure to also set
- :attr:`root_directory_name`.
+ :attr:`root_directory`.
*MAY be implemented by subclass.*
"""
diff --git a/mopidy/commands.py b/mopidy/commands.py
index f7c71d4e..237ec86b 100644
--- a/mopidy/commands.py
+++ b/mopidy/commands.py
@@ -265,6 +265,7 @@ class RootCommand(Command):
backend_classes = args.registry['backend']
frontend_classes = args.registry['frontend']
+ exit_status_code = 0
try:
mixer = self.start_mixer(config, mixer_class)
audio = self.start_audio(config, mixer)
@@ -276,8 +277,11 @@ class RootCommand(Command):
exceptions.FrontendError,
exceptions.MixerError):
logger.info('Initialization error. Exiting...')
+ exit_status_code = 1
except KeyboardInterrupt:
logger.info('Interrupted. Exiting...')
+ except Exception:
+ logger.exception('Uncaught exception')
finally:
loop.quit()
self.stop_frontends(frontend_classes)
@@ -286,6 +290,7 @@ class RootCommand(Command):
self.stop_audio()
self.stop_mixer(mixer_class)
process.stop_remaining_actors()
+ return exit_status_code
def get_mixer_class(self, config, mixer_classes):
logger.debug(
diff --git a/mopidy/config/__init__.py b/mopidy/config/__init__.py
index ed8446dd..61971875 100644
--- a/mopidy/config/__init__.py
+++ b/mopidy/config/__init__.py
@@ -114,27 +114,14 @@ def _load(files, defaults, overrides):
# Load config from a series of config files
files = [path.expand_path(f) for f in files]
- for filename in files:
- if not os.path.exists(filename):
- logger.debug(
- 'Loading config from %s failed; it does not exist', filename)
- continue
-
- try:
- logger.info('Loading config from %s', filename)
- with io.open(filename, 'rb') as filehandle:
- parser.readfp(filehandle)
- except configparser.MissingSectionHeaderError as e:
- logger.warning('%s does not have a config section, not loaded.',
- filename)
- except configparser.ParsingError as e:
- linenos = ', '.join(str(lineno) for lineno, line in e.errors)
- logger.warning(
- '%s has errors, line %s has been ignored.', filename, linenos)
- except IOError:
- # TODO: if this is the initial load of logging config we might not
- # have a logger at this point, we might want to handle this better.
- logger.debug('Config file %s not found; skipping', filename)
+ for name in files:
+ if os.path.isdir(name):
+ for filename in os.listdir(name):
+ filename = os.path.join(name, filename)
+ if os.path.isfile(filename) and filename.endswith('.conf'):
+ _load_file(parser, filename)
+ else:
+ _load_file(parser, name)
# If there have been parse errors there is a python bug that causes the
# values to be lists, this little trick coerces these into strings.
@@ -151,6 +138,29 @@ def _load(files, defaults, overrides):
return raw_config
+def _load_file(parser, filename):
+ if not os.path.exists(filename):
+ logger.debug(
+ 'Loading config from %s failed; it does not exist', filename)
+ return
+
+ try:
+ logger.info('Loading config from %s', filename)
+ with io.open(filename, 'rb') as filehandle:
+ parser.readfp(filehandle)
+ except configparser.MissingSectionHeaderError as e:
+ logger.warning('%s does not have a config section, not loaded.',
+ filename)
+ except configparser.ParsingError as e:
+ linenos = ', '.join(str(lineno) for lineno, line in e.errors)
+ logger.warning(
+ '%s has errors, line %s has been ignored.', filename, linenos)
+ except IOError:
+ # TODO: if this is the initial load of logging config we might not
+ # have a logger at this point, we might want to handle this better.
+ logger.debug('Config file %s not found; skipping', filename)
+
+
def _validate(raw_config, schemas):
# Get validated config
config = {}
diff --git a/mopidy/http/handlers.py b/mopidy/http/handlers.py
index e212c77d..3ef10c3b 100644
--- a/mopidy/http/handlers.py
+++ b/mopidy/http/handlers.py
@@ -110,7 +110,10 @@ class WebSocketHandler(tornado.websocket.WebSocketHandler):
self.request.remote_ip, response)
except Exception as e:
logger.error('WebSocket request error: %s', e)
- self.close()
+ if self.ws_connection:
+ # Tornado 3.2+ checks if self.ws_connection is None before
+ # using it, but not older versions.
+ self.close()
def check_origin(self, origin):
# Allow cross-origin WebSocket connections, like Tornado before 4.0
diff --git a/mopidy/local/__init__.py b/mopidy/local/__init__.py
index 8b4a8b1f..104c43af 100644
--- a/mopidy/local/__init__.py
+++ b/mopidy/local/__init__.py
@@ -58,17 +58,28 @@ class Library(object):
:param config: Config dictionary
"""
+ ROOT_DIRECTORY_URI = 'local:directory'
+ """
+ URI of the local backend's root directory.
+
+ This constant should be used by libraries implementing the
+ :meth:`Library.browse` method.
+ """
+
#: Name of the local library implementation, must be overriden.
name = None
def __init__(self, config):
self._config = config
- def browse(self, path):
+ def browse(self, uri):
"""
- Browse directories and tracks at the given path.
+ Browse directories and tracks at the given URI.
- :param string path: path to browse or None for root.
+ The URI for the root directory is a constant available at
+ :attr:`Library.ROOT_DIRECTORY_URI`.
+
+ :param string path: URI to browse.
:rtype: List of :class:`~mopidy.models.Ref` tracks and directories.
"""
raise NotImplementedError
diff --git a/mopidy/local/json.py b/mopidy/local/json.py
index 927f8898..5ae04592 100644
--- a/mopidy/local/json.py
+++ b/mopidy/local/json.py
@@ -61,8 +61,8 @@ class _BrowseCache(object):
splitpath_re = re.compile(r'([^/]+)')
def __init__(self, uris):
- # TODO: local.ROOT_DIRECTORY_URI
- self._cache = {'local:directory': collections.OrderedDict()}
+ self._cache = {
+ local.Library.ROOT_DIRECTORY_URI: collections.OrderedDict()}
for track_uri in uris:
path = translator.local_track_uri_to_path(track_uri, b'/')
@@ -97,10 +97,11 @@ class _BrowseCache(object):
else:
# Loop completed, so final child needs to be added to root.
if child:
- self._cache['local:directory'][child.uri] = child
+ self._cache[
+ local.Library.ROOT_DIRECTORY_URI][child.uri] = child
# If no parent was set we belong in the root.
if not parent_uri:
- parent_uri = 'local:directory'
+ parent_uri = local.Library.ROOT_DIRECTORY_URI
self._cache[parent_uri][track_uri] = track_ref
diff --git a/mopidy/local/library.py b/mopidy/local/library.py
index a626f566..a4645084 100644
--- a/mopidy/local/library.py
+++ b/mopidy/local/library.py
@@ -2,7 +2,7 @@ from __future__ import unicode_literals
import logging
-from mopidy import backend, models
+from mopidy import backend, local, models
logger = logging.getLogger(__name__)
@@ -10,18 +10,18 @@ logger = logging.getLogger(__name__)
class LocalLibraryProvider(backend.LibraryProvider):
"""Proxy library that delegates work to our active local library."""
- root_directory = models.Ref.directory(uri=b'local:directory',
- name='Local media')
+ root_directory = models.Ref.directory(
+ uri=local.Library.ROOT_DIRECTORY_URI, name='Local media')
def __init__(self, backend, library):
super(LocalLibraryProvider, self).__init__(backend)
self._library = library
self.refresh()
- def browse(self, path):
+ def browse(self, uri):
if not self._library:
return []
- return self._library.browse(path)
+ return self._library.browse(uri)
def refresh(self, uri=None):
if not self._library:
diff --git a/mopidy/mpd/dispatcher.py b/mopidy/mpd/dispatcher.py
index 84550698..9c2f3471 100644
--- a/mopidy/mpd/dispatcher.py
+++ b/mopidy/mpd/dispatcher.py
@@ -269,7 +269,7 @@ class MpdContext(object):
if not playlist.name:
continue
# TODO: add scheme to name perhaps 'foo (spotify)' etc.
- name = self._invalid_playlist_chars.sub(' ', playlist.name)
+ name = self._invalid_playlist_chars.sub('|', playlist.name)
self.insert_name_uri_mapping(name, playlist.uri)
def lookup_playlist_from_name(self, name):
diff --git a/mopidy/utils/network.py b/mopidy/utils/network.py
index 11469b47..4ea25026 100644
--- a/mopidy/utils/network.py
+++ b/mopidy/utils/network.py
@@ -268,8 +268,8 @@ class Connection(object):
return True
if not data:
- self.actor_ref.tell({'close': True})
self.disable_recv()
+ self.actor_ref.tell({'close': True})
return True
try:
diff --git a/tests/config/test_config.py b/tests/config/test_config.py
index 52035825..da0e5192 100644
--- a/tests/config/test_config.py
+++ b/tests/config/test_config.py
@@ -60,6 +60,18 @@ class LoadConfigTest(unittest.TestCase):
result = config._load([file1, file2], [], [])
self.assertEqual(expected, result)
+ def test_load_directory(self):
+ directory = path_to_data_dir('conf1.d')
+ expected = {'foo': {'bar': 'baz'}, 'foo2': {'bar': 'baz'}}
+ result = config._load([directory], [], [])
+ self.assertEqual(expected, result)
+
+ def test_load_directory_only_conf_files(self):
+ directory = path_to_data_dir('conf2.d')
+ expected = {'foo': {'bar': 'baz'}}
+ result = config._load([directory], [], [])
+ self.assertEqual(expected, result)
+
def test_load_file_with_utf8(self):
expected = {'foo': {'bar': 'æøå'.encode('utf-8')}}
result = config._load([path_to_data_dir('file3.conf')], [], [])
diff --git a/tests/data/conf1.d/file1.conf b/tests/data/conf1.d/file1.conf
new file mode 100644
index 00000000..e6396bff
--- /dev/null
+++ b/tests/data/conf1.d/file1.conf
@@ -0,0 +1,2 @@
+[foo]
+bar = baz
diff --git a/tests/data/conf1.d/file2.conf b/tests/data/conf1.d/file2.conf
new file mode 100644
index 00000000..ef189703
--- /dev/null
+++ b/tests/data/conf1.d/file2.conf
@@ -0,0 +1,2 @@
+[foo2]
+bar = baz
diff --git a/tests/data/conf2.d/file1.conf b/tests/data/conf2.d/file1.conf
new file mode 100644
index 00000000..e6396bff
--- /dev/null
+++ b/tests/data/conf2.d/file1.conf
@@ -0,0 +1,2 @@
+[foo]
+bar = baz
diff --git a/tests/data/conf2.d/file2.conf.disabled b/tests/data/conf2.d/file2.conf.disabled
new file mode 100644
index 00000000..ef189703
--- /dev/null
+++ b/tests/data/conf2.d/file2.conf.disabled
@@ -0,0 +1,2 @@
+[foo2]
+bar = baz
diff --git a/tests/mpd/protocol/test_stored_playlists.py b/tests/mpd/protocol/test_stored_playlists.py
index 56011435..4dc7dbbb 100644
--- a/tests/mpd/protocol/test_stored_playlists.py
+++ b/tests/mpd/protocol/test_stored_playlists.py
@@ -121,12 +121,12 @@ class PlaylistsHandlerTest(protocol.BaseTestCase):
self.assertNotInResponse('playlist: a\r')
self.assertInResponse('OK')
- def test_listplaylists_replaces_forward_slash_with_space(self):
+ def test_listplaylists_replaces_forward_slash_with_pipe(self):
self.backend.playlists.playlists = [
- Playlist(name='a/', uri='dummy:')]
+ Playlist(name='a/b', uri='dummy:')]
self.sendRequest('listplaylists')
- self.assertInResponse('playlist: a ')
- self.assertNotInResponse('playlist: a/')
+ self.assertInResponse('playlist: a|b')
+ self.assertNotInResponse('playlist: a/b')
self.assertInResponse('OK')
def test_load_appends_to_tracklist(self):
diff --git a/tests/test_version.py b/tests/test_version.py
index 0431beae..96063a1b 100644
--- a/tests/test_version.py
+++ b/tests/test_version.py
@@ -49,5 +49,6 @@ class VersionTest(unittest.TestCase):
self.assertLess(SV('0.18.3'), SV('0.19.0'))
self.assertLess(SV('0.19.0'), SV('0.19.1'))
self.assertLess(SV('0.19.1'), SV('0.19.2'))
- self.assertLess(SV('0.19.2'), SV(__version__))
- self.assertLess(SV(__version__), SV('0.19.4'))
+ self.assertLess(SV('0.19.2'), SV('0.19.3'))
+ self.assertLess(SV('0.19.3'), SV(__version__))
+ self.assertLess(SV(__version__), SV('0.19.5'))
diff --git a/tests/utils/network/test_connection.py b/tests/utils/network/test_connection.py
index 7a25643f..c3200689 100644
--- a/tests/utils/network/test_connection.py
+++ b/tests/utils/network/test_connection.py
@@ -7,7 +7,7 @@ import unittest
import gobject
-from mock import Mock, patch, sentinel
+from mock import Mock, call, patch, sentinel
import pykka
@@ -418,8 +418,11 @@ class ConnectionTest(unittest.TestCase):
self.assertTrue(network.Connection.recv_callback(
self.mock, sentinel.fd, gobject.IO_IN))
- self.mock.actor_ref.tell.assert_called_once_with({'close': True})
- self.mock.disable_recv.assert_called_once_with()
+ self.assertEqual(self.mock.mock_calls, [
+ call.sock.recv(any_int),
+ call.disable_recv(),
+ call.actor_ref.tell({'close': True}),
+ ])
def test_recv_callback_recoverable_error(self):
self.mock.sock = Mock(spec=socket.SocketType)