Merge branch 'develop' into feature/audio-message-handler
This commit is contained in:
commit
d049b07fa9
@ -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>`_
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -52,6 +52,7 @@ MOCK_MODULES = [
|
||||
'glib',
|
||||
'gobject',
|
||||
'gst',
|
||||
'gst.pbutils',
|
||||
'pygst',
|
||||
'pykka',
|
||||
'pykka.actor',
|
||||
|
||||
@ -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
38
docs/sponsors.rst
Normal 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.
|
||||
@ -21,4 +21,4 @@ if (isinstance(pykka.__version__, basestring)
|
||||
warnings.filterwarnings('ignore', 'could not open display')
|
||||
|
||||
|
||||
__version__ = '0.19.3'
|
||||
__version__ = '0.19.4'
|
||||
|
||||
@ -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.*
|
||||
"""
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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 = {}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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')], [], [])
|
||||
|
||||
2
tests/data/conf1.d/file1.conf
Normal file
2
tests/data/conf1.d/file1.conf
Normal file
@ -0,0 +1,2 @@
|
||||
[foo]
|
||||
bar = baz
|
||||
2
tests/data/conf1.d/file2.conf
Normal file
2
tests/data/conf1.d/file2.conf
Normal file
@ -0,0 +1,2 @@
|
||||
[foo2]
|
||||
bar = baz
|
||||
2
tests/data/conf2.d/file1.conf
Normal file
2
tests/data/conf2.d/file1.conf
Normal file
@ -0,0 +1,2 @@
|
||||
[foo]
|
||||
bar = baz
|
||||
2
tests/data/conf2.d/file2.conf.disabled
Normal file
2
tests/data/conf2.d/file2.conf.disabled
Normal file
@ -0,0 +1,2 @@
|
||||
[foo2]
|
||||
bar = baz
|
||||
@ -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):
|
||||
|
||||
@ -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'))
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user