Merge branch 'develop' into feature/audio-prep-work-for-gapless

This commit is contained in:
Thomas Adamcik 2014-07-29 23:07:08 +02:00
commit e73159dc6c
24 changed files with 228 additions and 77 deletions

View File

@ -15,3 +15,4 @@ Janez Troha <janez.troha@gmail.com> <dz0ny@users.noreply.github.com>
Janez Troha <janez.troha@gmail.com> <dz0ny@ubuntu.si>
Luke Giuliani <luke@giuliani.com.au>
Colin Montgomerie <kiteflyingmonkey@gmail.com>
Ignasi Fosch <natx@y10k.ws> <ifosch@serenity-2.local>

View File

@ -5,6 +5,8 @@ python:
env:
- TOX_ENV=py27
- TOX_ENV=tornado2.3
- TOX_ENV=tornado3.2
- TOX_ENV=docs
- TOX_ENV=flake8

View File

@ -40,3 +40,4 @@
- Pierpaolo Frasa <pfrasa@smail.uni-koeln.de>
- Thomas Scholtes <thomas-scholtes@gmx.de>
- Sam Willcocks <sam@wlcx.cc>
- Ignasi Fosch <natx@y10k.ws>

View File

@ -2,24 +2,57 @@
Mopidy
******
Mopidy is a music server which can play music both from multiple sources, like
your local hard drive, radio streams, and from Spotify and SoundCloud. Searches
combines results from all music sources, and you can mix tracks from all
sources in your play queue. Your playlists from Spotify or SoundCloud are also
available for use.
Mopidy is an extensible music server written in Python.
To control your Mopidy music server, you can use one of Mopidy's web clients,
the Ubuntu Sound Menu, any device on the same network which can control UPnP
MediaRenderers, or any MPD client. MPD clients are available for many
platforms, including Windows, OS X, Linux, Android and iOS.
Mopidy plays music from local disk, Spotify, SoundCloud, Google Play Music, and
more. You edit the playlist from any phone, tablet, or computer using a range
of MPD and web clients.
To get started with Mopidy, check out `the docs <http://docs.mopidy.com/>`_.
**Stream music from the cloud**
Vanilla Mopidy only plays music from your local disk and radio streams.
Through extensions, Mopidy can play music from cloud services like Spotify,
SoundCloud, and Google Play Music. With Mopidy's extension support, backends
for new music sources can be easily added.
**Mopidy is just a server**
Mopidy is a Python application that runs in a terminal or in the background on
Linux computers or Macs that have network connectivity and audio output. Out of
the box, Mopidy is an MPD and HTTP server. Additional frontends for controlling
Mopidy can be installed from extensions.
**Everybody use their favorite client**
You and the people around you can all connect their favorite MPD or web client
to the Mopidy server to search for music and manage the playlist together. With
a browser or MPD client, which is available for all popular operating systems,
you can control the music from any phone, tablet, or computer.
**Mopidy on Raspberry Pi**
The Raspberry Pi is a popular device to run Mopidy on, either using Raspbian or
Arch Linux. It is quite slow, but it is very affordable. In fact, the
Kickstarter funded Gramofon: Modern Cloud Jukebox project used Mopidy on a
Raspberry Pi to prototype the Gramofon device. Mopidy is also a major building
block in the Pi Musicbox integrated audio jukebox system for Raspberry Pi.
**Mopidy is hackable**
Mopidy's extension support and Python, JSON-RPC, and JavaScript APIs makes
Mopidy perfect for building your own hacks. In one project, a Raspberry Pi was
embedded in an old cassette player. The buttons and volume control are wired up
with GPIO on the Raspberry Pi, and is used to control playback through a custom
Mopidy extension. The cassettes have NFC tags used to select playlists from
Spotify.
To get started with Mopidy, check out
`the installation docs <http://docs.mopidy.com/en/latest/installation/>`_.
- `Documentation <http://docs.mopidy.com/>`_
- `Source code <https://github.com/mopidy/mopidy>`_
- `Issue tracker <https://github.com/mopidy/mopidy/issues>`_
- `CI server <https://travis-ci.org/mopidy/mopidy>`_
- `Download development snapshot <https://github.com/mopidy/mopidy/archive/develop.tar.gz#egg=mopidy-dev>`_
- `Development branch tarball <https://github.com/mopidy/mopidy/archive/develop.tar.gz#egg=mopidy-dev>`_
- IRC: ``#mopidy`` at `irc.freenode.net <http://freenode.net/>`_
- Mailing list: `mopidy@googlegroups.com <https://groups.google.com/forum/?fromgroups=#!forum/mopidy>`_

View File

@ -4,20 +4,50 @@ Changelog
This changelog is used to track all major changes to Mopidy.
v0.19.1 (UNRELEASED)
v0.19.3 (UNRELEASED)
====================
**Dependencies**
Bug fix release.
- Mopidy now requires Tornado >= 2.3, instead of >= 3.1. This should make
Mopidy continue to work on Debian/Raspbian stable, where Tornado 2.3 is the
newest version available.
- Audio: Fix negative track length for radio streams. (Fixes: :issue:`662`,
PR: :issue:`796`)
**Development**
- Zeroconf: Fix discovery by adding ``.local`` to the announced hostname. (PR:
:issue:`795`)
- ``mopidy --version`` and :meth:`mopidy.core.Core.get_version` now returns the
correct version when Mopidy is run from a Git repo other than Mopidy's own.
(Related to :issue:`706`)
v0.19.2 (2014-07-26)
====================
Bug fix release, directly from the Mopidy development sprint at EuroPython 2014
in Berlin.
- Audio: Make :confval:`audio/mixer_volume` work on the software mixer again. This
was broken with the mixer changes in 0.19.0. (Fixes: :issue:`791`)
- HTTP frontend: When using Tornado 4.0, allow WebSocket requests from other
hosts. (Fixes: :issue:`788`)
- MPD frontend: Fix crash when MPD commands are called with the wrong number of
arguments. This was broken with the MPD command changes in 0.19.0. (Fixes:
:issue:`789`)
v0.19.1 (2014-07-23)
====================
Bug fix release.
- Dependencies: Mopidy now requires Tornado >= 2.3, instead of >= 3.1. This
should make Mopidy continue to work on Debian/Raspbian stable, where Tornado
2.3 is the newest version available.
- HTTP frontend: Add missing string interpolation placeholder.
- Development: ``mopidy --version`` and :meth:`mopidy.core.Core.get_version`
now returns the correct version when Mopidy is run from a Git repo other than
Mopidy's own. (Related to :issue:`706`)
v0.19.0 (2014-07-21)

View File

@ -5,9 +5,9 @@ Extension development
*********************
Mopidy started as simply an MPD server that could play music from Spotify.
Early on Mopidy got multiple "frontends" to expose Mopidy to more than just MPD
clients: for example the scrobbler frontend what scrobbles what you've listened
to to your Last.fm account, the MPRIS frontend that integrates Mopidy into the
Early on, Mopidy got multiple "frontends" to expose Mopidy to more than just MPD
clients: for example the scrobbler frontend that scrobbles your listening
history to your Last.fm account, the MPRIS frontend that integrates Mopidy into the
Ubuntu Sound Menu, and the HTTP server and JavaScript player API making web
based Mopidy clients possible. In Mopidy 0.9 we added support for multiple
music sources without stopping and reconfiguring Mopidy: for example the local
@ -27,7 +27,7 @@ Anatomy of an extension
Extensions are located in a Python package called ``mopidy_something`` where
"something" is the name of the application, library or web service you want to
integrated with Mopidy. So for example if you plan to add support for a service
integrate with Mopidy. So, for example, if you plan to add support for a service
named Soundspot to Mopidy, you would name your extension's Python package
``mopidy_soundspot``.
@ -37,10 +37,6 @@ be something like "Mopidy-Soundspot". Make sure to include the name "Mopidy"
somewhere in that name and that you check the capitalization. This is the name
users will use when they install your extension from PyPI.
Also make sure the development version link in your package details work so
that people can easily install the development version into their virtualenv
simply by running e.g. ``pip install Mopidy-Soundspot==dev``.
Mopidy extensions must be licensed under an Apache 2.0 (like Mopidy itself),
BSD, MIT or more liberal license to be able to be enlisted in the Mopidy
documentation. The license text should be included in the ``LICENSE`` file in
@ -79,11 +75,11 @@ the readme of `cookiecutter-mopidy-ext
Example README.rst
==================
The README file should quickly tell what the extension does, how to install it,
and how to configure it. The README should contain a development snapshot link
to a tarball of the latest development version of the extension. It's important
that the development snapshot link ends with ``#egg=Mopidy-Something-dev`` for
installation using ``pip install Mopidy-Something==dev`` to work.
The README file should quickly explain what the extension does, how to install
it, and how to configure it. It should also contain a link to a tarball of the
latest development version of the extension. It's important that this link ends
with ``#egg=Mopidy-Something-dev`` for installation using
``pip install Mopidy-Something==dev`` to work.
.. code-block:: rst
@ -124,7 +120,7 @@ installation using ``pip install Mopidy-Something==dev`` to work.
- `Source code <https://github.com/mopidy/mopidy-soundspot>`_
- `Issue tracker <https://github.com/mopidy/mopidy-soundspot/issues>`_
- `Download development snapshot <https://github.com/mopidy/mopidy-soundspot/tarball/master#egg=Mopidy-Soundspot-dev>`_
- `Development branch tarball <https://github.com/mopidy/mopidy-soundspot/tarball/master#egg=Mopidy-Soundspot-dev>`_
Changelog
@ -239,9 +235,9 @@ The root of your Python package should have an ``__version__`` attribute with a
class named ``Extension`` which inherits from Mopidy's extension base class,
:class:`mopidy.ext.Extension`. This is the class referred to in the
``entry_points`` part of ``setup.py``. Any imports of other files in your
extension should be kept inside methods. This ensures that this file can be
imported without raising :exc:`ImportError` exceptions for missing
dependencies, etc.
extension, outside of Mopidy and it's core requirements, should be kept inside
methods. This ensures that this file can be imported without raising
:exc:`ImportError` exceptions for missing dependencies, etc.
The default configuration for the extension is defined by the
``get_default_config()`` method in the ``Extension`` class which returns a
@ -252,10 +248,10 @@ an ``enabled`` config which normally should default to ``true``. Provide good
defaults for all config values so that as few users as possible will need to
change them. The exception is if the config value has security implications; in
that case you should default to the most secure configuration. Leave any
configurations that doesn't have meaningful defaults blank, like ``username``
configurations that don't have meaningful defaults blank, like ``username``
and ``password``. In the example below, we've chosen to maintain the default
config as a separate file named ``ext.conf``. This makes it easy to e.g.
include the default config in documentation without duplicating it.
config as a separate file named ``ext.conf``. This makes it easy to include the
default config in documentation without duplicating it.
This is ``mopidy_soundspot/__init__.py``::
@ -321,6 +317,9 @@ This is ``mopidy_soundspot/__init__.py``::
gobject.type_register(SoundspotMixer)
gst.element_register(
SoundspotMixer, 'soundspotmixer', gst.RANK_MARGINAL)
# Or nothing to register e.g. command extension
pass
And this is ``mopidy_soundspot/ext.conf``:
@ -393,7 +392,7 @@ such as scanning for media, adding a command is the way to go. Your top level
command name will always match your extension name, but you are free to add
sub-commands with names of your choosing.
The skeleton of a commands would look like this. See :ref:`commands-api` for
The skeleton of a command would look like this. See :ref:`commands-api` for
more details.
::
@ -409,14 +408,14 @@ more details.
self.add_argument('--foo')
def run(self, args, config, extensions):
# Your backend implementation
# Your command implementation
return 0
Example web application
=======================
As of Mopidy 0.19, extensions can use Mopidy's builtin web server to host
As of Mopidy 0.19, extensions can use Mopidy's built-in web server to host
static web clients as well as Tornado and WSGI web applications. For several
examples, see the :ref:`http-server-api` docs or explore with
:ref:`http-explore-extension` extension.
@ -433,6 +432,17 @@ your :meth:`~mopidy.ext.Extension.setup` method register all your custom
GStreamer elements.
Running an extension
====================
Once your extension is ready to go, to see it in action you'll need to register
it with Mopidy. Typically this is done by running ``python setup.py install``
from your extension's Git repo root directory. While developing your extension
and to avoid doing this every time you make a change, you can instead run
``python setup.py develop`` to effectively link Mopidy directly with your
development files.
Python conventions
==================
@ -447,13 +457,13 @@ Use of Mopidy APIs
When writing an extension, you should only use APIs documented at
:ref:`api-ref`. Other parts of Mopidy, like :mod:`mopidy.utils`, may change at
any time, and is not something extensions should use.
any time and are not something extensions should use.
Logging in extensions
=====================
When making servers like Mopidy, logging is essential for understanding what's
For servers like Mopidy, logging is essential for understanding what's
going on. We use the :mod:`logging` module from Python's standard library. When
creating a logger, always namespace the logger using your Python package name
as this will be visible in Mopidy's debug log::

View File

@ -20,6 +20,9 @@ If you are running OS X, you can install everything needed with Homebrew.
brew update
brew upgrade
Notice that this will upgrade all software on your system that have been
installed with Homebrew.
#. Mopidy works out of box if you have installed Python from Homebrew::
brew install python
@ -36,6 +39,10 @@ If you are running OS X, you can install everything needed with Homebrew.
export PYTHONPATH=$(brew --prefix)/lib/python2.7/site-packages:$PYTHONPATH
And then reload the shell's init file or restart your terminal::
source ~/.bashrc
Or, you can prefix the Mopidy command every time you run it::
PYTHONPATH=$(brew --prefix)/lib/python2.7/site-packages mopidy

View File

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

View File

@ -221,6 +221,14 @@ class Audio(pykka.ThreadingActor):
self._connect(self._playbin, 'notify::volume', self._on_mixer_change)
self._connect(self._playbin, 'notify::mute', self._on_mixer_change)
# The Mopidy startup procedure will set the initial volume of a mixer,
# but this happens before the audio actor is injected into the software
# mixer and has no effect. Thus, we need to set the initial volume
# again.
initial_volume = self._config['audio']['mixer_volume']
if initial_volume is not None:
self._mixer.set_volume(initial_volume)
def _on_mixer_change(self, element, gparamspec):
self._mixer.trigger_events_for_changed_values()

View File

@ -150,6 +150,18 @@ def _date(tags):
return None
def add_musicbrainz_cover_art(track):
if track.album and track.album.musicbrainz_id:
base = "http://coverartarchive.org/release"
images = frozenset(
["{}/{}/front".format(
base,
track.album.musicbrainz_id)])
album = track.album.copy(images=images)
track = track.copy(album=album)
return track
def audio_data_to_track(data):
"""Convert taglist data + our extras to a track."""
tags = data['tags']
@ -186,7 +198,8 @@ def audio_data_to_track(data):
track_kwargs['date'] = _date(tags)
track_kwargs['last_modified'] = int(data.get('mtime') or 0)
track_kwargs['length'] = (data.get(gst.TAG_DURATION) or 0) // gst.MSECOND
track_kwargs['length'] = max(
0, (data.get(gst.TAG_DURATION) or 0)) // gst.MSECOND
# Clear out any empty values we found
track_kwargs = {k: v for k, v in track_kwargs.items() if v}

View File

@ -219,7 +219,7 @@ class Command(object):
def run(self, *args, **kwargs):
"""Run the command.
Must be implemented by sub-classes that are not simply and intermediate
Must be implemented by sub-classes that are not simply an intermediate
in the command namespace.
"""
raise NotImplementedError

View File

@ -58,10 +58,10 @@ class HttpFrontend(pykka.ThreadingActor, CoreListener):
if self.zeroconf_name:
self.zeroconf_http = zeroconf.Zeroconf(
stype='_http._tcp', name=self.zeroconf_name,
host=self.hostname, port=self.port)
port=self.port)
self.zeroconf_mopidy_http = zeroconf.Zeroconf(
stype='_mopidy-http._tcp', name=self.zeroconf_name,
host=self.hostname, port=self.port)
port=self.port)
self.zeroconf_http.publish()
self.zeroconf_mopidy_http.publish()

View File

@ -112,6 +112,11 @@ class WebSocketHandler(tornado.websocket.WebSocketHandler):
logger.error('WebSocket request error: %s', e)
self.close()
def check_origin(self, origin):
# Allow cross-origin WebSocket connections, like Tornado before 4.0
# defaulted to.
return True
def set_mopidy_headers(request_handler):
request_handler.set_header('Cache-Control', 'no-cache')
@ -144,7 +149,7 @@ class JsonRpcHandler(tornado.web.RequestHandler):
'Sent RPC message to %s: %r',
self.request.remote_ip, response)
except Exception as e:
logger.error('HTTP JSON-RPC request error:', e)
logger.error('HTTP JSON-RPC request error: %s', e)
self.write_error(500)
def set_extra_headers(self):

View File

@ -118,7 +118,8 @@ class ScanCommand(commands.Command):
relpath = translator.local_track_uri_to_path(uri, media_dir)
file_uri = path.path_to_uri(os.path.join(media_dir, relpath))
data = scanner.scan(file_uri)
track = scan.audio_data_to_track(data).copy(uri=uri)
track = scan.add_musicbrainz_cover_art(
scan.audio_data_to_track(data).copy(uri=uri)).copy(uri=uri)
library.add(track)
logger.debug('Added %s', track.uri)
except exceptions.ScannerError as error:

View File

@ -43,7 +43,7 @@ class MpdFrontend(pykka.ThreadingActor, CoreListener):
if self.zeroconf_name:
self.zeroconf_service = zeroconf.Zeroconf(
stype='_mpd._tcp', name=self.zeroconf_name,
host=self.hostname, port=self.port)
port=self.port)
self.zeroconf_service.publish()
def on_stop(self):

View File

@ -138,7 +138,13 @@ class Commands(object):
def validate(*args, **kwargs):
if varargs:
return func(*args, **kwargs)
callargs = inspect.getcallargs(func, *args, **kwargs)
try:
callargs = inspect.getcallargs(func, *args, **kwargs)
except TypeError:
raise exceptions.MpdArgError(
'wrong number of arguments for "%s"' % name)
for key, value in callargs.items():
default = defaults.get(key, object())
if key in validators and value != default:
@ -146,6 +152,7 @@ class Commands(object):
callargs[key] = validators[key](value)
except ValueError:
raise exceptions.MpdArgError('incorrect arguments')
return func(**callargs)
validate.auth_required = auth_required

View File

@ -143,21 +143,23 @@ def _gstreamer_check_elements():
# Spotify
'appsrc',
# Mixers and sinks
'alsamixer',
# Audio sinks
'alsasink',
'ossmixer',
'osssink',
'oss4mixer',
'oss4sink',
'pulsemixer',
'pulsesink',
# MP3 encoding and decoding
'mp3parse',
#
# One of flump3dec, mad, and mpg123audiodec is required for MP3
# playback.
'flump3dec',
'id3demux',
'id3v2mux',
'lame',
'mad',
'mp3parse',
# 'mpg123audiodec', # Only available in GStreamer 1.x
# Ogg Vorbis encoding and decoding
'vorbisdec',

View File

@ -43,21 +43,17 @@ class Zeroconf(object):
:type text: list of str
"""
def __init__(self, name, port, stype=None, domain=None,
host=None, text=None):
def __init__(self, name, port, stype=None, domain=None, text=None):
self.group = None
self.stype = stype or '_http._tcp'
self.domain = domain or ''
self.port = port
self.text = text or []
if host in ('::', '0.0.0.0'):
self.host = ''
else:
self.host = host
template = string.Template(name)
self.name = template.safe_substitute(
hostname=self.host or socket.getfqdn(), port=self.port)
hostname=socket.getfqdn(), port=self.port)
self.host = '%s.local' % socket.getfqdn()
def __str__(self):
return 'Zeroconf service %s at [%s]:%d' % (

View File

@ -71,6 +71,11 @@ class TranslatorTest(unittest.TestCase):
actual = scan.audio_data_to_track(self.data)
self.assertEqual(expected, actual)
def check_local(self, expected):
actual = scan.add_musicbrainz_cover_art(
scan.audio_data_to_track(self.data))
self.assertEqual(expected, actual)
def test_track(self):
self.check(self.track)
@ -197,13 +202,20 @@ class TranslatorTest(unittest.TestCase):
def test_missing_album_musicbrainz_id(self):
del self.data['tags']['musicbrainz-albumid']
album = self.track.album.copy(musicbrainz_id=None)
album = self.track.album.copy(musicbrainz_id=None,
images=[])
self.check(self.track.copy(album=album))
def test_multiple_album_musicbrainz_id(self):
self.data['tags']['musicbrainz-albumid'].append('id')
self.check(self.track)
def test_album_musicbrainz_id_cover(self):
album = self.track.album.copy(
images=frozenset(
['http://coverartarchive.org/release/albumid/front']))
self.check_local(self.track.copy(album=album))
def test_missing_album_num_tracks(self):
del self.data['tags']['track-count']
album = self.track.album.copy(num_tracks=None)

View File

@ -19,6 +19,12 @@ class AuthenticationActiveTest(protocol.BaseTestCase):
self.assertFalse(self.dispatcher.authenticated)
self.assertEqualResponse('ACK [3@0] {password} incorrect password')
def test_authentication_without_password_fails(self):
self.sendRequest('password')
self.assertFalse(self.dispatcher.authenticated)
self.assertEqualResponse(
'ACK [2@0] {password} wrong number of arguments for "password"')
def test_anything_when_not_authenticated_should_fail(self):
self.sendRequest('any request at all')
self.assertFalse(self.dispatcher.authenticated)

View File

@ -169,15 +169,15 @@ class TestCommands(unittest.TestCase):
def test_call_incorrect_args(self):
self.commands.add('foo')(lambda context: context)
with self.assertRaises(TypeError):
with self.assertRaises(exceptions.MpdArgError):
self.commands.call(['foo', 'bar'])
self.commands.add('bar')(lambda context, required: context)
with self.assertRaises(TypeError):
with self.assertRaises(exceptions.MpdArgError):
self.commands.call(['bar', 'bar', 'baz'])
self.commands.add('baz')(lambda context, optional=None: context)
with self.assertRaises(TypeError):
with self.assertRaises(exceptions.MpdArgError):
self.commands.call(['baz', 'bar', 'baz'])
def test_validator_gets_applied_to_required_arg(self):

View File

@ -14,7 +14,10 @@ class HelpTest(unittest.TestCase):
args = [sys.executable, mopidy_dir, '--help']
process = subprocess.Popen(
args,
env={'PYTHONPATH': os.path.join(mopidy_dir, '..')},
env={'PYTHONPATH': ':'.join([
os.path.join(mopidy_dir, '..'),
os.environ.get('PYTHONPATH', '')
])},
stdout=subprocess.PIPE)
output = process.communicate()[0]
self.assertIn('--version', output)

View File

@ -46,5 +46,7 @@ class VersionTest(unittest.TestCase):
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('0.18.3'))
self.assertLess(SV('0.18.3'), SV(__version__))
self.assertLess(SV(__version__), SV('0.19.1'))
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(__version__))
self.assertLess(SV(__version__), SV('0.19.3'))

16
tox.ini
View File

@ -1,13 +1,25 @@
[tox]
envlist = py27, docs, flake8
envlist = py27, py27-tornado23, py27-tornado31, docs, flake8
[testenv]
sitepackages = true
commands = nosetests -v --with-xunit --xunit-file=xunit-{envname}.xml --with-coverage --cover-package=mopidy
deps =
coverage
mock
nose
commands = nosetests -v --with-xunit --xunit-file=xunit-{envname}.xml --with-coverage --cover-package=mopidy
[testenv:py27-tornado23]
commands = nosetests -v tests/http
deps =
{[testenv]deps}
tornado==2.3
[testenv:py27-tornado31]
commands = nosetests -v tests/http
deps =
{[testenv]deps}
tornado==3.1
[testenv:docs]
deps = -r{toxinidir}/docs/requirements.txt