Merge branch 'develop' into feature/audio-message-handler

This commit is contained in:
Thomas Adamcik 2014-09-07 21:38:19 +02:00
commit d049b07fa9
25 changed files with 205 additions and 71 deletions

View File

@ -50,6 +50,7 @@ To get started with Mopidy, check out
`the installation docs <http://docs.mopidy.com/en/latest/installation/>`_.
- `Documentation <http://docs.mopidy.com/>`_
- `Discuss <https://discuss.mopidy.com/>`_
- `Source code <https://github.com/mopidy/mopidy>`_
- `Issue tracker <https://github.com/mopidy/mopidy/issues>`_
- `Development branch tarball <https://github.com/mopidy/mopidy/archive/develop.tar.gz#egg=mopidy-dev>`_

View File

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

View File

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

View File

@ -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 <file>
.. cmdoption:: --config <file|directory>
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 <option>, -o <option>

View File

@ -52,6 +52,7 @@ MOCK_MODULES = [
'glib',
'gobject',
'gst',
'gst.pbutils',
'pygst',
'pykka',
'pykka.actor',

View File

@ -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
<http://freenode.net/>`_ (with `searchable logs
<https://botbot.me/freenode/mopidy/>`_) and also have a `mailing list at Google
Groups <https://groups.google.com/forum/?fromgroups=#!forum/mopidy>`_. If you
stumble into a bug or got a feature request, please create an issue in the
`issue tracker <https://github.com/mopidy/mopidy/issues>`_. The `source code
<https://github.com/mopidy/mopidy>`_ may also be of help. If you want to stay
up to date on Mopidy developments, you can follow `@mopidy
<https://twitter.com/mopidy/>`_ on Twitter.
If you get stuck, you can get help at the `Mopidy discussion forum
<https://discuss.mopidy.com/>`_. We also hang around at IRC on the ``#mopidy``
channel at `irc.freenode.net <http://freenode.net/>`_. The IRC channel has
`public searchable logs <https://botbot.me/freenode/mopidy/>`_.
If you stumble into a bug or have a feature request, please create an issue in
the `issue tracker <https://github.com/mopidy/mopidy/issues>`_. If you're
unsure if its a bug or not, ask for help in the forum or at IRC first. The
`source code <https://github.com/mopidy/mopidy>`_ may also be of help.
If you want to stay up to date on Mopidy developments, you can follow `@mopidy
<https://twitter.com/mopidy/>`_ on Twitter. There's also a `mailing list
<https://groups.google.com/forum/?fromgroups=#!forum/mopidy>`_ used for
announcements related to Mopidy and Mopidy extensions.
Usage
@ -118,6 +123,7 @@ About
:maxdepth: 1
authors
sponsors
changelog
versioning

38
docs/sponsors.rst Normal file
View File

@ -0,0 +1,38 @@
.. _sponsors:
********
Sponsors
********
The Mopidy project would like to thank the following sponsors for supporting
the project.
Rackspace
=========
`Rackspace <http://www.rackspace.com/>`_ 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 <https://www.globalsign.com/>`_ provides Mopidy with a free
wildcard SSL certificate for mopidy.com, which we use to secure access to all
our web sites.

View File

@ -21,4 +21,4 @@ if (isinstance(pykka.__version__, basestring)
warnings.filterwarnings('ignore', 'could not open display')
__version__ = '0.19.3'
__version__ = '0.19.4'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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')], [], [])

View File

@ -0,0 +1,2 @@
[foo]
bar = baz

View File

@ -0,0 +1,2 @@
[foo2]
bar = baz

View File

@ -0,0 +1,2 @@
[foo]
bar = baz

View File

@ -0,0 +1,2 @@
[foo2]
bar = baz

View File

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

View File

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

View File

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