diff --git a/.gitignore b/.gitignore index 1ec12cbc..863c9796 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ *.egg-info +*.orig *.pyc *.swp +*~ .coverage .idea .noseids @@ -11,9 +13,8 @@ cover/ coverage.xml dist/ docs/_build/ +js/test/lib/ mopidy.log* node_modules/ nosetests.xml -*~ -*.orig -js/test/lib/ +xunit-*.xml diff --git a/.mailmap b/.mailmap index a427c69c..a3a35c2a 100644 --- a/.mailmap +++ b/.mailmap @@ -12,5 +12,6 @@ Javier Domingo Cansino Lasse Bigum Nick Steel Janez Troha +Janez Troha Luke Giuliani Colin Montgomerie diff --git a/.travis.yml b/.travis.yml index 8e991c3e..c838e7d0 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,21 +1,25 @@ language: python +python: + - "2.7_with_system_site_packages" + +env: + - TOX_ENV=py27 + - TOX_ENV=docs + - TOX_ENV=flake8 + install: - "wget -O - http://apt.mopidy.com/mopidy.gpg | sudo apt-key add -" - "sudo wget -O /etc/apt/sources.list.d/mopidy.list http://apt.mopidy.com/mopidy.list" - "sudo apt-get update || true" - - "sudo apt-get install $(apt-cache depends mopidy | awk '$2 !~ /mopidy|python:any/ {print $2}')" - - "pip install coveralls flake8" - -before_script: - - "rm $VIRTUAL_ENV/lib/python$TRAVIS_PYTHON_VERSION/no-global-site-packages.txt" + - "sudo apt-get install mopidy graphviz-dev" + - "pip install tox" script: - - "flake8 $(find . -iname '*.py')" - - "nosetests --with-coverage --cover-package=mopidy" + - "tox -e $TOX_ENV" after_success: - - "coveralls" + - "if [ $TOX_ENV == 'py27' ]; then pip install coveralls; coveralls; fi" notifications: irc: diff --git a/AUTHORS b/AUTHORS index 6853d5ab..1bc7c73b 100644 --- a/AUTHORS +++ b/AUTHORS @@ -37,3 +37,6 @@ - Simon de Bakker - Arnaud Barisain-Monrose - nathanharper +- Pierpaolo Frasa +- Thomas Scholtes +- Sam Willcocks diff --git a/MANIFEST.in b/MANIFEST.in index 51ba5919..000fc1ad 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,11 +1,13 @@ include *.py include *.rst +include *.txt include .coveragerc include .mailmap include .travis.yml include AUTHORS include LICENSE include MANIFEST.in +include tox.ini recursive-include data * diff --git a/README.rst b/README.rst index 0b003815..784abd24 100644 --- a/README.rst +++ b/README.rst @@ -19,24 +19,24 @@ 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 `_ - Twitter: `@mopidy `_ -.. image:: https://pypip.in/v/Mopidy/badge.png +.. image:: https://img.shields.io/pypi/v/Mopidy.svg?style=flat :target: https://pypi.python.org/pypi/Mopidy/ :alt: Latest PyPI version -.. image:: https://pypip.in/d/Mopidy/badge.png +.. image:: https://img.shields.io/pypi/dm/Mopidy.svg?style=flat :target: https://pypi.python.org/pypi/Mopidy/ :alt: Number of PyPI downloads -.. image:: https://travis-ci.org/mopidy/mopidy.png?branch=develop +.. image:: https://img.shields.io/travis/mopidy/mopidy/develop.svg?style=flat :target: https://travis-ci.org/mopidy/mopidy :alt: Travis CI build status -.. image:: https://coveralls.io/repos/mopidy/mopidy/badge.png?branch=develop +.. image:: https://img.shields.io/coveralls/mopidy/mopidy/develop.svg?style=flat :target: https://coveralls.io/r/mopidy/mopidy?branch=develop :alt: Test coverage diff --git a/dev-requirements.txt b/dev-requirements.txt new file mode 100644 index 00000000..b7367219 --- /dev/null +++ b/dev-requirements.txt @@ -0,0 +1,28 @@ +# Automate tasks +fabric + +# Build documentation +sphinx + +# Check code style, errors, etc +flake8 +flake8-import-order + +# Mock dependencies in tests +mock + +# Test runners +nose +tox + +# Measure test's code coverage +coverage + +# Check that MANIFEST.in matches Git repo contents before making a release +check-manifest + +# To make wheel packages +wheel + +# Securely upload packages to PyPI +twine diff --git a/docs/api/backends.rst b/docs/api/backends.rst index b1ba3128..5e938357 100644 --- a/docs/api/backends.rst +++ b/docs/api/backends.rst @@ -93,27 +93,7 @@ Backend listener :members: -.. _backend-implementations: - Backend implementations ======================= -- `Mopidy-Beets `_ - -- `Mopidy-GMusic `_ - -- :ref:`ext-local` - -- `Mopidy-radio-de `_ - -- `Mopidy-SomaFM `_ - -- `Mopidy-SoundCloud `_ - -- `Mopidy-Spotify `_ - -- :ref:`ext-stream` - -- `Mopidy-Subsonic `_ - -- `Mopidy-VKontakte `_ +See :ref:`ext-backends`. diff --git a/docs/api/frontends.rst b/docs/api/frontends.rst index 5e2f8d6c..27ce39a1 100644 --- a/docs/api/frontends.rst +++ b/docs/api/frontends.rst @@ -29,12 +29,12 @@ The following requirements applies to any frontend implementation: - The main actor MUST be able to start and stop the frontend when the main actor is started and stopped. -- The frontend MAY require additional settings to be set for it to - work. +- The frontend MAY require additional config values to be set for it to work. -- Such settings MUST be documented. +- Such config values MUST be documented. -- The main actor MUST stop itself if the defined settings are not adequate for +- The main actor MUST raise the :exc:`mopidy.exceptions.FrontendError` with a + descriptive error message if the defined config values are not adequate for the frontend to work properly. - Any actor which is part of the frontend MAY implement the @@ -42,17 +42,7 @@ The following requirements applies to any frontend implementation: specified events. -.. _frontend-implementations: - Frontend implementations ======================== -- :ref:`ext-http` - -- :ref:`ext-mpd` - -- `Mopidy-MPRIS `_ - -- `Mopidy-Notifier `_ - -- `Mopidy-Scrobbler `_ +See :ref:`ext-frontends`. diff --git a/docs/api/http-server.rst b/docs/api/http-server.rst new file mode 100644 index 00000000..ee6f55fb --- /dev/null +++ b/docs/api/http-server.rst @@ -0,0 +1,195 @@ +.. _http-server-api: + +******************** +HTTP server side API +******************** + +The :ref:`ext-http` extension comes with an HTTP server to host Mopidy's +:ref:`http-api`. This web server can also be used by other extensions that need +to expose something over HTTP. + +The HTTP server side API can be used to: + +- host static files for e.g. a Mopidy client written in pure JavaScript, +- host a `Tornado `__ application, or +- host a WSGI application, including e.g. Flask applications. + +To host static files using the web server, an extension needs to register a +name and a file path in the extension registry under the ``http:static`` key. + +To extend the web server with a web application, an extension must register a +name and a factory function in the extension registry under the ``http:app`` +key. + +For details on how to make a Mopidy extension, see the :ref:`extensiondev` +guide. + + +Static web client example +========================= + +To serve static files, you just need to register an ``http:static`` dictionary +in the extension registry. The dictionary must have two keys: ``name`` and +``path``. The ``name`` is used to build the URL the static files will be +served on. By convention, it should be identical with the extension's +:attr:`~mopidy.ext.Extension.ext_name`, like in the following example. The +``path`` tells Mopidy where on the disk the static files are located. + +Assuming that the code below is located in the file +:file:`mywebclient/__init__.py`, the files in the directory +:file:`mywebclient/static/` will be made available at ``/mywebclient/`` on +Mopidy's web server. For example, :file:`mywebclient/static/foo.html` will be +available at http://localhost:6680/mywebclient/foo.html. + +:: + + from __future__ import unicode_literals + + import os + + from mopidy import ext + + + class MyWebClientExtension(ext.Extension): + ext_name = 'mywebclient' + + def setup(self, registry): + registry.add('http:static', { + 'name': self.ext_name, + 'path': os.path.join(os.path.dirname(__file__), 'static'), + }) + + # See the Extension API for the full details on this class + + +Tornado application example +=========================== + +The :ref:`ext-http` extension's web server is based on the `Tornado +`__ web framework. Thus, it has first class support +for Tornado request handlers. + +In the following example, we create a :class:`tornado.web.RequestHandler` +called :class:`MyRequestHandler` that responds to HTTP GET requests with the +string ``Hello, world! This is Mopidy $version``, where it gets the Mopidy +version from Mopidy's core API. + +To hook the request handler into Mopidy's web server, we must register a +dictionary under the ``http:app`` key in the extension registry. The +dictionary must have two keys: ``name`` and ``factory``. + +The ``name`` is used to build the URL the app will be served on. By convention, +it should be identical with the extension's +:attr:`~mopidy.ext.Extension.ext_name`, like in the following example. + +The ``factory`` must be a function that accepts two arguments, ``config`` and +``core``, respectively a dict structure of Mopidy's config and a +:class:`pykka.ActorProxy` to the full Mopidy core API. The ``factory`` function +must return a list of Tornado request handlers. The URL patterns of the request +handlers should not include the ``name``, as that will be prepended to the URL +patterns by the web server. + +When the extension is installed, Mopidy will respond to requests to +http://localhost:6680/mywebclient/ with the string ``Hello, world! This is +Mopidy $version``. + +:: + + from __future__ import unicode_literals + + import os + + import tornado.web + + from mopidy import ext + + + class MyRequestHandler(tornado.web.RequestHandler): + def initialize(self, core): + self.core = core + + def get(self): + self.write( + 'Hello, world! This is Mopidy %s' % + self.core.get_version().get()) + + + def my_app_factory(config, core): + return [ + ('/', MyRequestHandler, {'core': core}) + ] + + + class MyWebClientExtension(ext.Extension): + ext_name = 'mywebclient' + + def setup(self, registry): + registry.add('http:app', { + 'name': self.ext_name, + 'factory': my_app_factory, + }) + + # See the Extension API for the full details on this class + + + +WSGI application example +======================== + +WSGI applications are second-class citizens on Mopidy's HTTP server. The WSGI +applications are run inside Tornado, which is based on non-blocking I/O and a +single event loop. In other words, your WSGI applications will only have a +single thread to run on, and if your application is doing blocking I/O, it will +block all other requests from being handled by the web server as well. + +The example below shows how a WSGI application that returns the string +``Hello, world! This is Mopidy $version`` on all requests. The WSGI application +is wrapped as a Tornado application and mounted at +http://localhost:6680/mywebclient/. + +:: + + from __future__ import unicode_literals + + import os + + import tornado.web + import tornado.wsgi + + from mopidy import ext + + + def my_app_factory(config, core): + + def wsgi_app(environ, start_response): + status = '200 OK' + response_headers = [('Content-type', 'text/plain')] + start_response(status, response_headers) + return [ + 'Hello, world! This is Mopidy %s\n' % + self.core.get_version().get() + ] + + return [ + ('(.*)', tornado.web.FallbackHandler, { + 'fallback': tornado.wsgi.WSGIContainer(wsgi_app), + }), + ] + + + class MyWebClientExtension(ext.Extension): + ext_name = 'mywebclient' + + def setup(self, registry): + registry.add('http:app', { + 'name': self.ext_name, + 'factory': my_app_factory, + }) + + # See the Extension API for the full details on this class + + +API implementors +================ + +See :ref:`ext-web`. diff --git a/docs/api/http.rst b/docs/api/http.rst index 5561955d..3eff14fd 100644 --- a/docs/api/http.rst +++ b/docs/api/http.rst @@ -1,18 +1,23 @@ .. _http-api: -******** -HTTP API -******** +***************** +HTTP JSON-RPC API +***************** -The :ref:`ext-http` extension makes Mopidy's :ref:`core-api` available over -HTTP using WebSockets. We also provide a JavaScript wrapper, called -:ref:`Mopidy.js ` around the HTTP API for use both from browsers and -Node.js. +.. module:: mopidy.http + :synopsis: The HTTP frontend APIs + +The :ref:`ext-http` extension makes Mopidy's :ref:`core-api` available using +JSON-RPC over HTTP using HTTP POST and WebSockets. We also provide a JavaScript +wrapper, called :ref:`Mopidy.js `, around the JSON-RPC over +WebSocket API for use both from browsers and Node.js. The +:ref:`http-explore-extension` extension, can also be used to get you +familiarized with HTTP based APIs. .. warning:: API stability - Since the HTTP API exposes our internal core API directly it is to be - regarded as **experimental**. We cannot promise to keep any form of + Since the HTTP JSON-RPC API exposes our internal core API directly it is to + be regarded as **experimental**. We cannot promise to keep any form of backwards compatibility between releases as we will need to change the core API while working out how to support new use cases. Thus, if you use this API, you must expect to do small adjustments to your client for every @@ -22,36 +27,50 @@ Node.js. stable. +.. _http-post-api: + +HTTP POST API +============= + +The Mopidy web server accepts HTTP requests with the POST method to +http://localhost:6680/mopidy/rpc, where the ``localhost:6680`` part will vary +with your local setup. The HTTP POST endpoint gives you access to Mopidy's +full core API, but does not give you notification on events. If you need +to listen to events, you should probably use the WebSocket API instead. + +Example usage from the command line:: + + $ curl -d '{"jsonrpc": "2.0", "id": 1, "method": "core.playback.get_state"}' http://localhost:6680/mopidy/rpc + {"jsonrpc": "2.0", "id": 1, "result": "stopped"} + +For details on the request and response format, see :ref:`json-rpc`. + + .. _websocket-api: WebSocket API ============= -The web server exposes a WebSocket at ``/mopidy/ws/``. The WebSocket gives you -access to Mopidy's full API and enables Mopidy to instantly push events to the -client, as they happen. +The Mopidy web server exposes a WebSocket at http://localhost:6680/mopidy/ws, +where the ``localhost:6680`` part will vary with your local setup. The +WebSocket gives you access to Mopidy's full API and enables Mopidy to instantly +push events to the client, as they happen. On the WebSocket we send two different kind of messages: The client can send -JSON-RPC 2.0 requests, and the server will respond with JSON-RPC 2.0 responses. -In addition, the server will send event messages when something happens on the -server. Both message types are encoded as JSON objects. +:ref:`JSON-RPC 2.0 requests `, and the server will respond with +JSON-RPC 2.0 responses. In addition, the server will send :ref:`event messages +` when something happens on the server. Both message types are +encoded as JSON objects. + +If you're using the API from JavaScript, either in the browser or in Node.js, +you should use :ref:`mopidy-js` which wraps the WebSocket API in a nice +JavaScript API. -Event messages --------------- +.. _json-rpc: -Event objects will always have a key named ``event`` whose value is the event -type. Depending on the event type, the event may include additional fields for -related data. The events maps directly to the :class:`mopidy.core.CoreListener` -API. Refer to the ``CoreListener`` method names is the available event types. -The ``CoreListener`` method's keyword arguments are all included as extra -fields on the event objects. Example event message:: - - {"event": "track_playback_started", "track": {...}} - - -JSON-RPC 2.0 messaging ----------------------- +JSON-RPC 2.0 messages +===================== JSON-RPC 2.0 messages can be recognized by checking for the key named ``jsonrpc`` with the string value ``2.0``. For details on the messaging format, @@ -80,360 +99,17 @@ available methods. If you're unsure how the core API maps to JSON-RPC, having a look at the ``core.describe`` response can be helpful. -.. _mopidy-js: +.. _json-events: -Mopidy.js JavaScript library -============================ +Event messages +============== -We've made a JavaScript library, Mopidy.js, which wraps the WebSocket and gets -you quickly started with working on your client instead of figuring out how to -communicate with Mopidy. - - -Getting the library for browser use ------------------------------------ - -Regular and minified versions of Mopidy.js, ready for use, is installed -together with Mopidy. When the HTTP extension is enabled, the files are -available at: - -- http://localhost:6680/mopidy/mopidy.js -- http://localhost:6680/mopidy/mopidy.min.js - -You may need to adjust hostname and port for your local setup. - -Thus, if you use Mopidy to host your web client, like described above, you can -load the latest version of Mopidy.js by adding the following script tag to your -HTML file: - -.. code-block:: html - - - -If you don't use Mopidy to host your web client, you can find the JS files in -the Git repo at: - -- ``mopidy/http/data/mopidy.js`` -- ``mopidy/http/data/mopidy.min.js`` - - -Getting the library for Node.js use ------------------------------------ - -If you want to use Mopidy.js from Node.js instead of a browser, you can install -Mopidy.js using npm:: - - npm install mopidy - -After npm completes, you can import Mopidy.js using ``require()``: - -.. code-block:: js - - var Mopidy = require("mopidy"); - - -Getting the library for development on the library --------------------------------------------------- - -If you want to work on the Mopidy.js library itself, you'll find a complete -development setup in the ``js/`` dir in our repo. The instructions in -``js/README.md`` will guide you on your way. - - -Creating an instance --------------------- - -Once you got Mopidy.js loaded, you need to create an instance of the wrapper: - -.. code-block:: js - - var mopidy = new Mopidy(); - -When you instantiate ``Mopidy()`` without arguments, it will connect to -the WebSocket at ``/mopidy/ws/`` on the current host. Thus, if you don't host -your web client using Mopidy's web server, or if you use Mopidy.js from a -Node.js environment, you'll need to pass the URL to the WebSocket end point: - -.. code-block:: js - - var mopidy = new Mopidy({ - webSocketUrl: "ws://localhost:6680/mopidy/ws/" - }); - -It is also possible to create an instance first and connect to the WebSocket -later: - -.. code-block:: js - - var mopidy = new Mopidy({autoConnect: false}); - // ... do other stuff, like hooking up events ... - mopidy.connect(); - - -Hooking up to events --------------------- - -Once you have a Mopidy.js object, you can hook up to the events it emits. To -explore your possibilities, it can be useful to subscribe to all events and log -them: - -.. code-block:: js - - mopidy.on(console.log.bind(console)); - -Several types of events are emitted: - -- You can get notified about when the Mopidy.js object is connected to the - server and ready for method calls, when it's offline, and when it's trying to - reconnect to the server by looking at the events ``state:online``, - ``state:offline``, ``reconnectionPending``, and ``reconnecting``. - -- You can get events sent from the Mopidy server by looking at the events with - the name prefix ``event:``, like ``event:trackPlaybackStarted``. - -- You can introspect what happens internally on the WebSocket by looking at the - events emitted with the name prefix ``websocket:``. - -Mopidy.js uses the event emitter library `BANE -`_, so you should refer to BANE's -short API documentation to see how you can hook up your listeners to the -different events. - - -Calling core API methods ------------------------- - -Once your Mopidy.js object has connected to the Mopidy server and emits the -``state:online`` event, it is ready to accept core API method calls: - -.. code-block:: js - - mopidy.on("state:online", function () { - mopidy.playback.next(); - }); - -Any calls you make before the ``state:online`` event is emitted will fail. If -you've hooked up an errback (more on that a bit later) to the promise returned -from the call, the errback will be called with an error message. - -All methods in Mopidy's :ref:`core-api` is available via Mopidy.js. The core -API attributes is *not* available, but that shouldn't be a problem as we've -added (undocumented) getters and setters for all of them, so you can access the -attributes as well from JavaScript. - -Both the WebSocket API and the JavaScript API are based on introspection of the -core Python API. Thus, they will always be up to date and immediately reflect -any changes we do to the core API. - -The best way to explore the JavaScript API, is probably by opening your -browser's console, and using its tab completion to navigate the API. You'll -find the Mopidy core API exposed under ``mopidy.playback``, -``mopidy.tracklist``, ``mopidy.playlists``, and ``mopidy.library``. - -All methods in the JavaScript API have an associated data structure describing -the Python params it expects, and most methods also have the Python API -documentation available. This is available right there in the browser console, -by looking at the method's ``description`` and ``params`` attributes: - -.. code-block:: js - - console.log(mopidy.playback.next.params); - console.log(mopidy.playback.next.description); - -JSON-RPC 2.0 limits method parameters to be sent *either* by-position or -by-name. Combinations of both, like we're used to from Python, isn't supported -by JSON-RPC 2.0. To further limit this, Mopidy.js currently only supports -passing parameters by-position. - -Obviously, you'll want to get a return value from many of your method calls. -Since everything is happening across the WebSocket and maybe even across the -network, you'll get the results asynchronously. Instead of having to pass -callbacks and errbacks to every method you call, the methods return "promise" -objects, which you can use to pipe the future result as input to another -method, or to hook up callback and errback functions. - -.. code-block:: js - - var track = mopidy.playback.getCurrentTrack(); - // => ``track`` isn't a track, but a "promise" object - -Instead, typical usage will look like this: - -.. code-block:: js - - var printCurrentTrack = function (track) { - if (track) { - console.log("Currently playing:", track.name, "by", - track.artists[0].name, "from", track.album.name); - } else { - console.log("No current track"); - } - }; - - mopidy.playback.getCurrentTrack().then( - printCurrentTrack, console.error.bind(console)); - -The first function passed to ``then()``, ``printCurrentTrack``, is the callback -that will be called if the method call succeeds. The second function, -``console.error``, is the errback that will be called if anything goes wrong. -If you don't hook up an errback, debugging will be hard as errors will silently -go missing. - -For debugging, you may be interested in errors from function without -interesting return values as well. In that case, you can pass ``null`` as the -callback: - -.. code-block:: js - - mopidy.playback.next().then(null, console.error.bind(console)); - -The promise objects returned by Mopidy.js adheres to the `CommonJS Promises/A -`_ standard. We use the -implementation known as `when.js `_. Please -refer to when.js' documentation or the standard for further details on how to -work with promise objects. - - -Cleaning up ------------ - -If you for some reason want to clean up after Mopidy.js before the web page is -closed or navigated away from, you can close the WebSocket, unregister all -event listeners, and delete the object like this: - -.. code-block:: js - - // Close the WebSocket without reconnecting. Letting the object be garbage - // collected will have the same effect, so this isn't strictly necessary. - mopidy.close(); - - // Unregister all event listeners. If you don't do this, you may have - // lingering references to the object causing the garbage collector to not - // clean up after it. - mopidy.off(); - - // Delete your reference to the object, so it can be garbage collected. - mopidy = null; - - -Example to get started with ---------------------------- - -1. Make sure that you've installed all dependencies required by - :ref:`ext-http`. - -2. Create an empty directory for your web client. - -3. Change the :confval:`http/static_dir` config value to point to your new - directory. - -4. Start/restart Mopidy. - -5. Create a file in the directory named ``index.html`` containing e.g. "Hello, - world!". - -6. Visit http://localhost:6680/ to confirm that you can view your new HTML file - there. - -7. Include Mopidy.js in your web page: - - .. code-block:: html - - - -8. Add one of the following Mopidy.js examples of how to queue and start - playback of your first playlist either to your web page or a JavaScript file - that you include in your web page. - - "Imperative" style: - - .. code-block:: js - - var consoleError = console.error.bind(console); - - var trackDesc = function (track) { - return track.name + " by " + track.artists[0].name + - " from " + track.album.name; - }; - - var queueAndPlayFirstPlaylist = function () { - mopidy.playlists.getPlaylists().then(function (playlists) { - var playlist = playlists[0]; - console.log("Loading playlist:", playlist.name); - mopidy.tracklist.add(playlist.tracks).then(function (tlTracks) { - mopidy.playback.play(tlTracks[0]).then(function () { - mopidy.playback.getCurrentTrack().then(function (track) { - console.log("Now playing:", trackDesc(track)); - }, consoleError); - }, consoleError); - }, consoleError); - }, consoleError); - }; - - var mopidy = new Mopidy(); // Connect to server - mopidy.on(console.log.bind(console)); // Log all events - mopidy.on("state:online", queueAndPlayFirstPlaylist); - - Approximately the same behavior in a more functional style, using chaining - of promisies. - - .. code-block:: js - - var consoleError = console.error.bind(console); - - var getFirst = function (list) { - return list[0]; - }; - - var extractTracks = function (playlist) { - return playlist.tracks; - }; - - var printTypeAndName = function (model) { - console.log(model.__model__ + ": " + model.name); - // By returning the playlist, this function can be inserted - // anywhere a model with a name is piped in the chain. - return model; - }; - - var trackDesc = function (track) { - return track.name + " by " + track.artists[0].name + - " from " + track.album.name; - }; - - var printNowPlaying = function () { - // By returning any arguments we get, the function can be inserted - // anywhere in the chain. - var args = arguments; - return mopidy.playback.getCurrentTrack().then(function (track) { - console.log("Now playing:", trackDesc(track)); - return args; - }); - }; - - var queueAndPlayFirstPlaylist = function () { - mopidy.playlists.getPlaylists() - // => list of Playlists - .then(getFirst, consoleError) - // => Playlist - .then(printTypeAndName, consoleError) - // => Playlist - .then(extractTracks, consoleError) - // => list of Tracks - .then(mopidy.tracklist.add, consoleError) - // => list of TlTracks - .then(getFirst, consoleError) - // => TlTrack - .then(mopidy.playback.play, consoleError) - // => null - .then(printNowPlaying, consoleError); - }; - - var mopidy = new Mopidy(); // Connect to server - mopidy.on(console.log.bind(console)); // Log all events - mopidy.on("state:online", queueAndPlayFirstPlaylist); - -9. The web page should now queue and play your first playlist every time your - load it. See the browser's console for output from the function, any errors, - and all events that are emitted. +Event objects will always have a key named ``event`` whose value is the event +type. Depending on the event type, the event may include additional fields for +related data. The events maps directly to the :class:`mopidy.core.CoreListener` +API. Refer to the :class:`~mopidy.core.CoreListener` method names is the +available event types. The :class:`~mopidy.core.CoreListener` method's keyword +arguments are all included as extra fields on the event objects. Example event +message:: + {"event": "track_playback_started", "track": {...}} diff --git a/docs/api/index.rst b/docs/api/index.rst index bede978b..5aac825c 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -21,9 +21,12 @@ API reference backends core audio + mixer frontends commands ext config zeroconf + http-server http + js diff --git a/docs/api/js.rst b/docs/api/js.rst new file mode 100644 index 00000000..372e7f4e --- /dev/null +++ b/docs/api/js.rst @@ -0,0 +1,440 @@ +.. _mopidy-js: + +**************************** +Mopidy.js JavaScript library +**************************** + +We've made a JavaScript library, Mopidy.js, which wraps the +:ref:`websocket-api` and gets you quickly started with working on your client +instead of figuring out how to communicate with Mopidy. + +.. warning:: API stability + + Since the Mopidy.js API exposes our internal core API directly it is to be + regarded as **experimental**. We cannot promise to keep any form of + backwards compatibility between releases as we will need to change the core + API while working out how to support new use cases. Thus, if you use this + API, you must expect to do small adjustments to your client for every + release of Mopidy. + + From Mopidy 1.0 and onwards, we intend to keep the core API far more + stable. + + +Getting the library for browser use +=================================== + +Regular and minified versions of Mopidy.js, ready for use, is installed +together with Mopidy. When the HTTP extension is enabled, the files are +available at: + +- http://localhost:6680/mopidy/mopidy.js +- http://localhost:6680/mopidy/mopidy.min.js + +You may need to adjust hostname and port for your local setup. + +Thus, if you use Mopidy to host your web client, like described above, you can +load the latest version of Mopidy.js by adding the following script tag to your +HTML file: + +.. code-block:: html + + + +If you don't use Mopidy to host your web client, you can find the JS files in +the Git repo at: + +- ``mopidy/http/data/mopidy.js`` +- ``mopidy/http/data/mopidy.min.js`` + + +Getting the library for Node.js or Browserify use +================================================= + +If you want to use Mopidy.js from Node.js or on the web through Browserify, you +can install Mopidy.js using npm:: + + npm install mopidy + +After npm completes, you can import Mopidy.js using ``require()``: + +.. code-block:: js + + var Mopidy = require("mopidy"); + + +Getting the library for development on the library +================================================== + +If you want to work on the Mopidy.js library itself, you'll find a complete +development setup in the ``js/`` dir in our repo. The instructions in +``js/README.md`` will guide you on your way. + + +Creating an instance +==================== + +Once you have Mopidy.js loaded, you need to create an instance of the wrapper: + +.. code-block:: js + + var mopidy = new Mopidy(); + +When you instantiate ``Mopidy()`` without arguments, it will connect to +the WebSocket at ``/mopidy/ws/`` on the current host. Thus, if you don't host +your web client using Mopidy's web server, or if you use Mopidy.js from a +Node.js environment, you'll need to pass the URL to the WebSocket end point: + +.. code-block:: js + + var mopidy = new Mopidy({ + webSocketUrl: "ws://localhost:6680/mopidy/ws/" + }); + +It is also possible to create an instance first and connect to the WebSocket +later: + +.. code-block:: js + + var mopidy = new Mopidy({autoConnect: false}); + // ... do other stuff, like hooking up events ... + mopidy.connect(); + +When creating an instance, you can specify the following settings: + +``autoConnect`` + Whether or not to connect to the WebSocket on instance creation. Defaults + to true. + +``backoffDelayMin`` + The minimum number of milliseconds to wait after a connection error before + we try to reconnect. For every failed attempt, the backoff delay is doubled + until it reaches ``backoffDelayMax``. Defaults to 1000. + +``backoffDelayMax`` + The maximum number of milliseconds to wait after a connection error before + we try to reconnect. Defaults to 64000. + +``callingConvention`` + Which calling convention to use when calling methods. + + If set to "by-position-only", methods expect to be called with positional + arguments, like ``mopidy.foo.bar(null, true, 2)``. + + If set to "by-position-or-by-name", methods expect to be called either with + an array of position arguments, like ``mopidy.foo.bar([null, true, 2])``, + or with an object of named arguments, like ``mopidy.foo.bar({id: 2})``. The + advantage of the "by-position-or-by-name" calling convention is that + arguments with default values can be left out of the named argument object. + Using named arguments also makes the code more readable, and more resistent + to future API changes. + + .. note:: + + For backwards compatibility, the default is "by-position-only". In the + future, the default will change to "by-position-or-by-name". You should + explicitly set this setting to your choice, so you won't be affected + when the default changes. + + .. versionadded:: 0.19 (Mopidy.js 0.4) + +``console`` + If set, this object will be used to log errors from Mopidy.js. This is + mostly useful for testing Mopidy.js. + +``webSocket`` + An existing WebSocket object to be used instead of creating a new + WebSocket. Defaults to undefined. + +``webSocketUrl`` + URL used when creating new WebSocket objects. Defaults to + ``ws:///mopidy/ws``, or + ``ws://localhost/mopidy/ws`` if ``document.location.host`` isn't + available, like it is in the browser environment. + + +Hooking up to events +==================== + +Once you have a Mopidy.js object, you can hook up to the events it emits. To +explore your possibilities, it can be useful to subscribe to all events and log +them: + +.. code-block:: js + + mopidy.on(console.log.bind(console)); + +Several types of events are emitted: + +- You can get notified about when the Mopidy.js object is connected to the + server and ready for method calls, when it's offline, and when it's trying to + reconnect to the server by looking at the events ``state:online``, + ``state:offline``, ``reconnectionPending``, and ``reconnecting``. + +- You can get events sent from the Mopidy server by looking at the events with + the name prefix ``event:``, like ``event:trackPlaybackStarted``. + +- You can introspect what happens internally on the WebSocket by looking at the + events emitted with the name prefix ``websocket:``. + +Mopidy.js uses the event emitter library `BANE +`_, so you should refer to BANE's +short API documentation to see how you can hook up your listeners to the +different events. + + +Calling core API methods +======================== + +Once your Mopidy.js object has connected to the Mopidy server and emits the +``state:online`` event, it is ready to accept core API method calls: + +.. code-block:: js + + mopidy.on("state:online", function () { + mopidy.playback.next(); + }); + +Any calls you make before the ``state:online`` event is emitted will fail. If +you've hooked up an errback (more on that a bit later) to the promise returned +from the call, the errback will be called with a ``Mopidy.ConnectionError`` +instance. + +All methods in Mopidy's :ref:`core-api` is available via Mopidy.js. The core +API attributes is *not* available, but that shouldn't be a problem as we've +added (undocumented) getters and setters for all of them, so you can access the +attributes as well from JavaScript. For example, the +:attr:`mopidy.core.PlaybackController.state` attribute is available in +JSON-RPC as the method ``core.playback.get_state`` and in Mopidy.js as +``mopidy.playback.getState()``. + +Both the WebSocket API and the JavaScript API are based on introspection of the +core Python API. Thus, they will always be up to date and immediately reflect +any changes we do to the core API. + +The best way to explore the JavaScript API, is probably by opening your +browser's console, and using its tab completion to navigate the API. You'll +find the Mopidy core API exposed under ``mopidy.playback``, +``mopidy.tracklist``, ``mopidy.playlists``, and ``mopidy.library``. + +All methods in the JavaScript API have an associated data structure describing +the Python params it expects, and most methods also have the Python API +documentation available. This is available right there in the browser console, +by looking at the method's ``description`` and ``params`` attributes: + +.. code-block:: js + + console.log(mopidy.playback.next.params); + console.log(mopidy.playback.next.description); + +JSON-RPC 2.0 limits method parameters to be sent *either* by-position or +by-name. Combinations of both, like we're used to from Python, isn't supported +by JSON-RPC 2.0. To further limit this, Mopidy.js currently only supports +passing parameters by-position. + +Obviously, you'll want to get a return value from many of your method calls. +Since everything is happening across the WebSocket and maybe even across the +network, you'll get the results asynchronously. Instead of having to pass +callbacks and errbacks to every method you call, the methods return "promise" +objects, which you can use to pipe the future result as input to another +method, or to hook up callback and errback functions. + +.. code-block:: js + + var track = mopidy.playback.getCurrentTrack(); + // => ``track`` isn't a track, but a "promise" object + +Instead, typical usage will look like this: + +.. code-block:: js + + var printCurrentTrack = function (track) { + if (track) { + console.log("Currently playing:", track.name, "by", + track.artists[0].name, "from", track.album.name); + } else { + console.log("No current track"); + } + }; + + mopidy.playback.getCurrentTrack() + .done(printCurrentTrack); + +The function passed to ``done()``, ``printCurrentTrack``, is the callback +that will be called if the method call succeeds. If anything goes wrong, +``done()`` will throw an exception. + +If you want to explicitly handle any errors and avoid an exception being +thrown, you can register an error handler function anywhere in a promise +chain. The function will be called with the error object as the only argument: + +.. code-block:: js + + mopidy.playback.getCurrentTrack() + .catch(console.error.bind(console)); + .done(printCurrentTrack); + +You can also register the error handler at the end of the promise chain by +passing it as the second argument to ``done()``: + +.. code-block:: js + + mopidy.playback.getCurrentTrack() + .done(printCurrentTrack, console.error.bind(console)); + +If you don't hook up an error handler function and never call ``done()`` on the +promise object, when.js will log warnings to the console that you have +unhandled errors. In general, unhandled errors will not go silently missing. + +The promise objects returned by Mopidy.js adheres to the `CommonJS Promises/A +`_ standard. We use the +implementation known as `when.js `_. Please +refer to when.js' documentation or the standard for further details on how to +work with promise objects. + + +Cleaning up +=========== + +If you for some reason want to clean up after Mopidy.js before the web page is +closed or navigated away from, you can close the WebSocket, unregister all +event listeners, and delete the object like this: + +.. code-block:: js + + // Close the WebSocket without reconnecting. Letting the object be garbage + // collected will have the same effect, so this isn't strictly necessary. + mopidy.close(); + + // Unregister all event listeners. If you don't do this, you may have + // lingering references to the object causing the garbage collector to not + // clean up after it. + mopidy.off(); + + // Delete your reference to the object, so it can be garbage collected. + mopidy = null; + + +Example to get started with +=========================== + +1. Make sure that you've installed all dependencies required by + :ref:`ext-http`. + +2. Create an empty directory for your web client. + +3. Change the :confval:`http/static_dir` config value to point to your new + directory. + +4. Start/restart Mopidy. + +5. Create a file in the directory named ``index.html`` containing e.g. "Hello, + world!". + +6. Visit http://localhost:6680/ to confirm that you can view your new HTML file + there. + +7. Include Mopidy.js in your web page: + + .. code-block:: html + + + +8. Add one of the following Mopidy.js examples of how to queue and start + playback of your first playlist either to your web page or a JavaScript file + that you include in your web page. + + "Imperative" style: + + .. code-block:: js + + var trackDesc = function (track) { + return track.name + " by " + track.artists[0].name + + " from " + track.album.name; + }; + + var queueAndPlay = function (playlistNum, trackNum) { + playlistNum = playlistNum || 0; + trackNum = trackNum || 0; + mopidy.playlists.getPlaylists().then(function (playlists) { + var playlist = playlists[playlistNum]; + console.log("Loading playlist:", playlist.name); + return mopidy.tracklist.add(playlist.tracks).then(function (tlTracks) { + return mopidy.playback.play(tlTracks[trackNum]).then(function () { + return mopidy.playback.getCurrentTrack().then(function (track) { + console.log("Now playing:", trackDesc(track)); + }); + }); + }); + }) + .catch(console.error.bind(console)) // Handle errors here + .done(); // ...or they'll be thrown here + }; + + var mopidy = new Mopidy(); // Connect to server + mopidy.on(console.log.bind(console)); // Log all events + mopidy.on("state:online", queueAndPlay); + + Approximately the same behavior in a more functional style, using chaining + of promises. + + .. code-block:: js + + var get = function (key, object) { + return object[key]; + }; + + var printTypeAndName = function (model) { + console.log(model.__model__ + ": " + model.name); + // By returning the playlist, this function can be inserted + // anywhere a model with a name is piped in the chain. + return model; + }; + + var trackDesc = function (track) { + return track.name + " by " + track.artists[0].name + + " from " + track.album.name; + }; + + var printNowPlaying = function () { + // By returning any arguments we get, the function can be inserted + // anywhere in the chain. + var args = arguments; + return mopidy.playback.getCurrentTrack() + .then(function (track) { + console.log("Now playing:", trackDesc(track)); + return args; + }); + }; + + var queueAndPlay = function (playlistNum, trackNum) { + playlistNum = playlistNum || 0; + trackNum = trackNum || 0; + mopidy.playlists.getPlaylists() + // => list of Playlists + .fold(get, playlistNum) + // => Playlist + .then(printTypeAndName) + // => Playlist + .fold(get, 'tracks') + // => list of Tracks + .then(mopidy.tracklist.add) + // => list of TlTracks + .fold(get, trackNum) + // => TlTrack + .then(mopidy.playback.play) + // => null + .then(printNowPlaying) + // => null + .catch(console.error.bind(console)) // Handle errors here + // => null + .done(); // ...or they'll be thrown here + }; + + var mopidy = new Mopidy(); // Connect to server + mopidy.on(console.log.bind(console)); // Log all events + mopidy.on("state:online", queueAndPlay); + +9. The web page should now queue and play your first playlist every time you + load it. See the browser's console for output from the function, any errors, + and all events that are emitted. diff --git a/docs/api/mixer.rst b/docs/api/mixer.rst new file mode 100644 index 00000000..6f02e3c9 --- /dev/null +++ b/docs/api/mixer.rst @@ -0,0 +1,20 @@ +.. _mixer-api: + +*************** +Audio mixer API +*************** + +.. module:: mopidy.mixer + :synopsis: The audio mixer API + +.. autoclass:: mopidy.mixer.Mixer + :members: + +.. autoclass:: mopidy.mixer.MixerListener + :members: + + +Mixer implementations +===================== + +See :ref:`ext-mixers`. diff --git a/docs/changelog.rst b/docs/changelog.rst index df7de99a..c461db63 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,18 +4,281 @@ Changelog This changelog is used to track all major changes to Mopidy. - -v0.19.0 (unreleased) +v0.19.1 (UNRELEASED) ==================== -**MPD** +**Dependencies** -- Minor refactor of context such that it stores password instead of config. - (Fixes: :issue:`646`) +- 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. -**Windows** +**Development** -- Network and signal handling has been updated to play nice on windows systems. +- ``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) +==================== + +The focus of 0.19 have been on improving the MPD implementation, replacing +GStreamer mixers with our own mixer API, and on making web clients installable +with ``pip``, like any other Mopidy extension. + +Since the release of 0.18, we've closed or merged 53 issues and pull requests +through about 445 commits by :ref:`12 people `, including five new +guys. Thanks to everyone that has contributed! + +**Dependencies** + +- Mopidy now requires Tornado >= 3.1. + +- Mopidy no longer requires CherryPy or ws4py. Previously, these were optional + dependencies required for the HTTP frontend to work. + +**Backend API** + +- *Breaking change:* Imports of the backend API from + :mod:`mopidy.backends` no longer works. The new API introuced in v0.18 is now + required. Most extensions already use the new API location. + +**Commands** + +- The ``mopidy-convert-config`` tool for migrating the ``setings.py`` + configuration file used by Mopidy up until 0.14 to the new config file format + has been removed after over a year of trusty service. If you still need to + convert your old ``settings.py`` configuration file, do so using and older + release, like Mopidy 0.18, or migrate the configuration to the new format by + hand. + +**Configuration** + +- Add ``optional=True`` support to :class:`mopidy.config.Boolean`. + +**Logging** + +- Fix proper decoding of exception messages that depends on the user's locale. + +- Colorize logs depending on log level. This can be turned off with the new + :confval:`logging/color` configuration. (Fixes: :issue:`772`) + +**Extension support** + +- *Breaking change:* Removed the :class:`~mopidy.ext.Extension` methods that + were deprecated in 0.18: :meth:`~mopidy.ext.Extension.get_backend_classes`, + :meth:`~mopidy.ext.Extension.get_frontend_classes`, and + :meth:`~mopidy.ext.Extension.register_gstreamer_elements`. Use + :meth:`mopidy.ext.Extension.setup` instead, as most extensions already do. + +**Audio** + +- *Breaking change:* Removed support for GStreamer mixers. GStreamer 1.x does + not support volume control, so we changed to use software mixing by default + in v0.17.0. Now, we're removing support for all other GStreamer mixers and + are reintroducing mixers as something extensions can provide independently of + GStreamer. (Fixes: :issue:`665`, PR: :issue:`760`) + +- *Breaking change:* Changed the :confval:`audio/mixer` config value to refer + to Mopidy mixer extensions instead of GStreamer mixers. The default value, + ``software``, still has the same behavior. All other values will either no + longer work or will at the very least require you to install an additional + extension. + +- Changed the :confval:`audio/mixer_volume` config value behavior from + affecting GStreamer mixers to affecting Mopidy mixer extensions instead. The + end result should be the same without any changes to this config value. + +- Deprecated the :confval:`audio/mixer_track` config value. This config value + is no longer in use. Mixer extensions that need additional configuration + handle this themselves. + +- Use :ref:`proxy-config` when streaming media from the Internet. (Partly + fixing :issue:`390`) + +- Fix proper decoding of exception messages that depends on the user's locale. + +- Fix recognition of ASX and XSPF playlists with tags in all caps or with + carriage return line endings. (Fixes: :issue:`687`) + +- Support simpler ASX playlist variant with ```` elements without + children. + +- Added ``target_state`` attribute to the audio layer's + :meth:`~mopidy.audio.AudioListener.state_changed` event. Currently, it is + :class:`None` except when we're paused because of buffering. Then the new + field exposes our target state after buffering has completed. + +**Mixers** + +- Added new :class:`mopidy.mixer.Mixer` API which can be implemented by + extensions. + +- Created a bundled extension, :ref:`ext-softwaremixer`, for controlling volume + in software in GStreamer's pipeline. This is Mopidy's default mixer. To use + this mixer, set the :confval:`audio/mixer` config value to ``software``. + +- Created an external extension, `Mopidy-ALSAMixer + `_, for controlling volume with + hardware through ALSA. To use this mixer, install the extension, and set the + :confval:`audio/mixer` config value to ``alsamixer``. + +**HTTP frontend** + +- CherryPy and ws4py have been replaced with Tornado. This will hopefully + reduce CPU usage on OS X (:issue:`445`) and improve error handling in corner + cases, like when returning from suspend (:issue:`718`). + +- Added support for packaging web clients as Mopidy extensions and installing + them using pip. See the :ref:`http-server-api` for details. (Fixes: + :issue:`440`) + +- Added web page at ``/mopidy/`` which lists all web clients installed as + Mopidy extensions. (Fixes: :issue:`440`) + +- Added support for extending the HTTP frontend with additional server side + functionality. See :ref:`http-server-api` for details. + +- Exposed the core API using HTTP POST requests with JSON-RPC payloads at + ``/mopidy/rpc``. This is the same JSON-RPC interface as is exposed over the + WebSocket at ``/mopidy/ws``, so you can run any core API command. + + The HTTP POST interfaces does not give you access to events from Mopidy, like + the WebSocket does. The WebSocket interface is still recommended for web + clients. The HTTP POST interface may be easier to use for simpler programs, + that just needs to query the currently playing track or similar. See + :ref:`http-post-api` for details. + +- If Zeroconf is enabled, we now announce the ``_mopidy-http._tcp`` service in + addition to ``_http._tcp``. This is to make it easier to automatically find + Mopidy's HTTP server among other Zeroconf-published HTTP servers on the + local network. + +**Mopidy.js client library** + +This version has been released to npm as Mopidy.js v0.4.0. + +- Update Mopidy.js to use when.js 3. If you maintain a Mopidy client, you + should review the `differences between when.js 2 and 3 + `_ + and the `when.js debugging guide + `_. + +- All of Mopidy.js' promise rejection values are now of the Error type. This + ensures that all JavaScript VMs will show a useful stack trace if a rejected + promise's value is used to throw an exception. To allow catch clauses to + handle different errors differently, server side errors are of the type + ``Mopidy.ServerError``, and connection related errors are of the type + ``Mopidy.ConnectionError``. + +- Add support for method calls with by-name arguments. The old calling + convention, ``by-position-only``, is still the default, but this will + change in the future. A warning is logged to the console if you don't + explicitly select a calling convention. See the :ref:`mopidy-js` docs for + details. + +**MPD frontend** + +- 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. + (Fixes: :issue:`591` and :issue:`592`) + +- Updated command handler system. As part of the tokenizer cleanup we've + updated how commands are registered and making it simpler to create new + handlers. + +- Simplified a bunch of handlers. All the "browse" type commands now use a + common browse helper under the hood for less repetition. Likewise the query + handling of "search" commands has been somewhat simplified. + +- Adds placeholders for missing MPD commands, preparing the way for bumping the + protocol version once they have been added. + +- Respond to all pending requests before closing connection. (PR: :issue:`722`) + +- Stop incorrectly catching `LookupError` in command handling. + (Fixes: :issue:`741`) + +- Browse support for playlists and albums has been added. (PR: :issue:`749`, + :issue:`754`) + +- The ``lsinfo`` command now returns browse results before local playlists. + This is helpful as not all clients sort the returned items. (PR: + :issue:`755`) + +- Browse now supports different entries with identical names. (PR: + :issue:`762`) + +- Search terms that are empty or consists of only whitespace are no longer + included in the search query sent to backends. (PR: :issue:`758`) + +**Local backend** + +- The JSON local library backend now logs a friendly message telling you about + ``mopidy local scan`` if you don't have a local library cache. (Fixes: + :issue:`711`) + +- The ``local scan`` command now use multiple threads to walk the file system + and check files' modification time. This speeds up scanning, escpecially + when scanning remote file systems over e.g. NFS. + +- the ``local scan`` command now creates necessary folders if they don't + already exist. Previously, this was only done by the Mopidy server, so doing + a ``local scan`` before running the server the first time resulted in a + crash. (Fixes: :issue:`703`) + +- Fix proper decoding of exception messages that depends on the user's locale. + +**Stream backend** + +- Add config value :confval:`stream/metadata_blacklist` to blacklist certain + URIs we should not open to read metadata from before they are opened for + playback. This is typically needed for services that invalidate URIs after a + single use. (Fixes: :issue:`660`) + + +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. + +- 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) @@ -620,7 +883,7 @@ one new. To ease migration we've made a tool named :option:`mopidy-convert-config` for automatically converting the old ``settings.py`` to a new ``mopidy.conf`` file. This tool takes care of all the renamed config values as well. See - :ref:`mopidy-convert-config` for details on how to use it. + ``mopidy-convert-config`` for details on how to use it. - A long wanted feature: You can now enable or disable specific frontends or backends without having to redefine :attr:`~mopidy.settings.FRONTENDS` or diff --git a/docs/clients/dz0ny-mopidy-lux.png b/docs/clients/dz0ny-mopidy-lux.png deleted file mode 100644 index ffdd67ac..00000000 Binary files a/docs/clients/dz0ny-mopidy-lux.png and /dev/null differ diff --git a/docs/clients/http.rst b/docs/clients/http.rst index 9ef3b131..bbd4d888 100644 --- a/docs/clients/http.rst +++ b/docs/clients/http.rst @@ -15,52 +15,33 @@ created one, please notify us so we can include your client on this page. See :ref:`http-api` for details on how to build your own web client. -woutervanwijk/Mopidy-Webclient -============================== +Mopidy MusicBox Webclient +========================= -.. image:: woutervanwijk-mopidy-webclient.png +.. image:: mopidy-musicbox-webclient.png :width: 1275 :height: 600 The first web client for Mopidy, made with jQuery Mobile by Wouter van Wijk. Also the web client used for Wouter's popular `Pi Musicbox -`_ image for Raspberry Pi. +`_ image for Raspberry Pi. - With Mopidy Browser Client, you can play your music on your computer (or - Rapsberry Pi) and remotely control it from a computer, phone, tablet, + With Mopidy MusicBox Webclient, you can play your music on your computer + (Raspberry Pi) and remotely control it from a computer, phone, tablet, laptop. From your couch. - -- https://github.com/woutervanwijk/Mopidy-WebClient + This is a responsive HTML/JS/CSS client especially written for Mopidy, a + music server. Responsive, so it works on desktop and mobile browsers. You + can browse, search and play albums, artists, playlists, and it has cover + art from Last.fm. + + -- https://github.com/woutervanwijk/Mopidy-MusicBox-Webclient -Mopidy Lux -========== - -.. image:: dz0ny-mopidy-lux.png - :width: 1000 - :height: 645 - -A Mopidy web client made with AngularJS by Janez Troha. - - A shiny new remote web control interface for Mopidy player. - - -- https://github.com/dz0ny/mopidy-lux +.. include:: /ext/lux.rst -Moped -===== - -.. image:: martijnboland-moped.png - :width: 720 - :height: 450 - -A Mopidy web client made with Durandal and KnockoutJS by Martijn Boland. - - Moped is a responsive web client for the Mopidy music server. It is - inspired by Mopidy-Webclient, but built from scratch based on a different - technology stack with Durandal and Bootstrap 3. - - -- https://github.com/martijnboland/moped +.. include:: /ext/moped.rst JukePi @@ -76,6 +57,46 @@ office jukebox. -- https://github.com/meantimeit/jukepi +Apollo Player +============= + +A Mopidy web client made by Argonaut in SF for their office jukebox. + + Mopidy frontend community driven playlist. Driven by Node.js, Backbone.js, + and Require.js. + +-- https://github.com/samcreate/Apollo-Player + + We've released ARGONAUT's first open source playlist app, APOLLO. It uses + Mopidy (and the Mopidy Spotify plugin) to create a social listening + experience for your home or office. + + Users authenticate via Twitter, then search the Spotify library and add + tracks to the playlist, which plays on the local machine (via Node.js + server). When the playlist runs out, it switches to a default playlist that + you define. + + Users can also bomb a track---if it receives 3 down votes, it plays a + Spotify track to announce the bomb (we use "children booing")---then skips + to the next track. + +-- http://blog.argonautinc.com/post/83027259908/music-is-pretty-important-to-our-culture-and + + +Mopify +====== + +An in-development web client that clones the Spotify user interface on top of +Mopidy and the Spotify web APIs. + + A Mopidy web client based on the Spotify webbased interface. If you use + Mopidy in combination with local music this client probably won't work. + This client uses the Spotify and EchoNest API to speed up searching and + artist/album lookup. + + -- https://github.com/dirkgroenen/Mopify + + Other web clients ================= diff --git a/docs/clients/woutervanwijk-mopidy-webclient.png b/docs/clients/mopidy-musicbox-webclient.png similarity index 100% rename from docs/clients/woutervanwijk-mopidy-webclient.png rename to docs/clients/mopidy-musicbox-webclient.png diff --git a/docs/clients/mpd.rst b/docs/clients/mpd.rst index 4a2736fe..91d0f8db 100644 --- a/docs/clients/mpd.rst +++ b/docs/clients/mpd.rst @@ -60,6 +60,14 @@ supported)" mode because the client tries to fetch all known metadata and do the search on the client side. The two other search modes works nicely, so this is not a problem. +The library view is very slow when used together with Mopidy-Spotify. A +workaround is to edit the ncmpcpp configuration file +(:file:`~/.ncmpcpp/config`) and set:: + + media_library_display_date = "no" + +With this change ncmpcpp's library view will still be a bit slow, but usable. + ncmpc ----- diff --git a/docs/commands/mopidy.rst b/docs/command.rst similarity index 96% rename from docs/commands/mopidy.rst rename to docs/command.rst index 75515a8d..79ca3ed0 100644 --- a/docs/commands/mopidy.rst +++ b/docs/command.rst @@ -83,11 +83,11 @@ Additionally, extensions can provide extra commands. Run `mopidy --help` for a list of what is available on your system and command-specific help. Commands for disabled extensions will be listed, but can not be run. -.. cmdoption:: local clear +.. describe:: local clear Clear local media files from the local library. -.. cmdoption:: local scan +.. describe:: local scan Scan local media files present in your library. @@ -130,12 +130,6 @@ The ``mopidy config`` output shows the effect of the :option:`--option` flags:: mopidy -o mpd/enabled=false -o spotify/bitrate=320 config -See also -======== - -:ref:`mopidy-convert-config(1) ` - - Reporting bugs ============== diff --git a/docs/commands/index.rst b/docs/commands/index.rst deleted file mode 100644 index bc169465..00000000 --- a/docs/commands/index.rst +++ /dev/null @@ -1,13 +0,0 @@ -.. _commands: - -******** -Commands -******** - -Mopidy comes with the following commands: - -.. toctree:: - :maxdepth: 1 - :glob: - - ** diff --git a/docs/commands/mopidy-convert-config.rst b/docs/commands/mopidy-convert-config.rst deleted file mode 100644 index 83bb7ae3..00000000 --- a/docs/commands/mopidy-convert-config.rst +++ /dev/null @@ -1,98 +0,0 @@ -.. _mopidy-convert-config: - -***************************** -mopidy-convert-config command -***************************** - -Synopsis -======== - -mopidy-convert-config - - -Description -=========== - -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. - -The ``mopidy-convert-config`` command is used to convert :file:`settings.py` -configuration files used by ``mopidy`` < 0.14 to the :file:`mopidy.conf` config -file used by ``mopidy`` >= 0.14. - - -Options -======= - -.. program:: mopidy-convert-config - -This program does not take any options. It looks for the pre-0.14 settings file -at :file:`{$XDG_CONFIG_DIR}/mopidy/settings.py`, and if it exists it converts -it and ouputs a Mopidy 0.14 compatible ini-format configuration. If you don't -already have a config file at :file:`{$XDG_CONFIG_DIR}/mopidy/mopidy.conf``, -you're asked if you want to save the converted config to that file. - - -Example -======= - -Given the following contents in :file:`~/.config/mopidy/settings.py`: - -:: - - LOCAL_MUSIC_PATH = u'~/music' - MPD_SERVER_HOSTNAME = u'::' - SPOTIFY_PASSWORD = u'secret' - SPOTIFY_USERNAME = u'alice' - -Running ``mopidy-convert-config`` will convert the config and create a new -:file:`mopidy.conf` config file: - -.. code-block:: none - - $ mopidy-convert-config - Checking /home/alice/.config/mopidy/settings.py - Converted config: - - [spotify] - username = alice - password = ******** - - [mpd] - hostname = :: - - [local] - media_dir = ~/music - - Write new config to /home/alice/.config/mopidy/mopidy.conf? [yN] y - Done. - -Contents of :file:`~/.config/mopidy/mopidy.conf` after the conversion: - -.. code-block:: ini - - [spotify] - username = alice - password = secret - - [mpd] - hostname = :: - - [local] - media_dir = ~/music - - -See also -======== - -:ref:`mopidy(1) ` - - -Reporting bugs -============== - -Report bugs to Mopidy's issue tracker at - diff --git a/docs/conf.py b/docs/conf.py index 737fb07a..52e84e06 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -35,8 +35,6 @@ class Mock(object): # glib.get_user_config_dir() return str elif (name[0] == name[0].upper() - # gst.interfaces.MIXER_TRACK_* - and not name.startswith('MIXER_TRACK_') # gst.PadTemplate and not name.startswith('PadTemplate') # dbus.String() @@ -89,6 +87,7 @@ extensions = [ 'sphinx.ext.autodoc', 'sphinx.ext.extlinks', 'sphinx.ext.graphviz', + 'sphinx.ext.intersphinx', 'sphinx.ext.viewcode', ] @@ -141,19 +140,12 @@ latex_documents = [ man_pages = [ ( - 'commands/mopidy', + 'command', 'mopidy', 'music server', '', '1' ), - ( - 'commands/mopidy-convert-config', - 'mopidy-convert-config', - 'migrate config files from mopidy pre-0.14', - '', - '1' - ), ] @@ -165,3 +157,12 @@ extlinks = { 'mpris': ( 'https://github.com/mopidy/mopidy-mpris/issues/%s', 'mopidy-mpris#'), } + + +# -- Options for intersphinx extension ---------------------------------------- + +intersphinx_mapping = { + 'python': ('http://docs.python.org/2', None), + 'pykka': ('http://www.pykka.org/en/latest/', None), + 'tornado': ('http://www.tornadoweb.org/en/stable/', None), +} diff --git a/docs/config.rst b/docs/config.rst index d1752ba5..f5f6bd19 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -26,13 +26,18 @@ will create an empty config file for you and print what config values must be set to successfully start Mopidy. When you have created the configuration file, open it in a text editor, and add -the config values you want to change. If you want to keep the default for a -config value, you **should not** add it to -:file:`~/.config/mopidy/mopidy.conf`. +the config values you want to change. If you want to keep the default for a +config value, you **should not** add it to the config file, but leave it out so +that when we change the default value in a future version, you won't have to +change your configuration accordingly. To see what's the effective configuration for your Mopidy installation, you can -run ``mopidy config``. It will print your full effective config with passwords -masked out so that you safely can share the output with others for debugging. +run:: + + mopidy config + +This will print your full effective config with passwords masked out so that +you safely can share the output with others for debugging. You can find a description of all config values belonging to Mopidy's core below, together with their default values. In addition, all :ref:`extensions @@ -40,14 +45,6 @@ below, together with their default values. In addition, all :ref:`extensions defaults are documented on the :ref:`extension pages `. -Migrating from pre 0.14 -======================= - -For those users upgrading from versions prior to 0.14 we made -the :option:`mopidy-convert-config` tool, to ease the process of migrating -settings to the new config format. - - Default core configuration ========================== @@ -58,27 +55,24 @@ Default core configuration Core configuration values ========================= +Mopidy's core has the following configuration values that you can change. + +Audio configuration +------------------- + .. confval:: audio/mixer Audio mixer to use. - Expects a GStreamer mixer to use, typical values are: ``software``, - ``autoaudiomixer``, ``alsamixer``, ``pulsemixer``, ``ossmixer``, and - ``oss4mixer``. - The default is ``software``, which does volume control inside Mopidy before the audio is sent to the audio output. This mixer does not affect the volume of any other audio playback on the system. It is the only mixer that will affect the audio volume if you're streaming the audio from Mopidy through Shoutcast. - If you want to use a hardware mixer, try ``autoaudiomixer``. It attempts to - select a sane hardware mixer for you automatically. When Mopidy is started, - it will log what mixer ``autoaudiomixer`` selected, for example:: - - INFO Audio mixer set to "alsamixer" using track "Master" - - Setting the config value to blank turns off volume control. + If you want to use a hardware mixer, you need to install a Mopidy extension + which integrates with your sound subsystem. E.g. for ALSA, install + `Mopidy-ALSAMixer `_. .. confval:: audio/mixer_volume @@ -89,14 +83,6 @@ Core configuration values Setting the config value to blank leaves the audio mixer volume unchanged. For the software mixer blank means 100. -.. confval:: audio/mixer_track - - Audio mixer track to use. - - Name of the mixer track to use. If this is not set we will try to find the - master output track. As an example, using ``alsamixer`` you would typically - set this to ``Master`` or ``PCM``. - .. confval:: audio/output Audio output to use. @@ -117,6 +103,15 @@ Core configuration values .. _libvisual: http://gstreamer.freedesktop.org/data/doc/gstreamer/head/gst-plugins-base-plugins/html/gst-plugins-base-plugins-plugin-libvisual.html + +Logging configuration +--------------------- + +.. confval:: logging/color + + Whether or not to colorize the console log based on log level. Defaults to + ``true``. + .. confval:: logging/console_format The log format used for informational logging. @@ -147,11 +142,33 @@ Core configuration values level to use for that logger, one of ``debug``, ``info``, ``warning``, ``error``, or ``critical``. +.. _the Python logging docs: http://docs.python.org/2/library/logging.config.html + + +.. _proxy-config: + +Proxy configuration +------------------- + +Not all parts of Mopidy or all Mopidy extensions respect the proxy +server configuration when connecting to the Internt. Currently, this is at +least used when Mopidy's audio subsystem reads media directly from the network, +like when listening to Internet radio streams, and by the Mopidy-Spotify +extension. With time, we hope that more of the Mopidy ecosystem will respect +these configurations to help users on locked down networks. + +.. confval:: proxy/scheme + + URI scheme for the proxy server. Typically ``http``, ``https``, ``socks4``, + or ``socks5``. + .. confval:: proxy/hostname - Proxy server to use for communication with the Internet. + Hostname of the proxy server. - Currently only used by the Spotify extension. +.. confval:: proxy/port + + Port number of the proxy server. .. confval:: proxy/username @@ -161,8 +178,6 @@ Core configuration values Password for the proxy server, if needed. -.. _the Python logging docs: http://docs.python.org/2/library/logging.config.html - Extension configuration ======================= @@ -246,13 +261,21 @@ server simultaneously. To use the SHOUTcast output, do the following: #. You might also need to change the ``shout2send`` default settings, run ``gst-inspect-0.10 shout2send`` to see the available settings. Most likely - you want to change ``ip``, ``username``, ``password``, and ``mount``. For - example: + you want to change ``ip``, ``username``, ``password``, and ``mount``. + + Example for MP3 streaming: .. code-block:: ini [audio] - output = lame ! shout2send username="alice" password="secret" mount="mopidy" + output = lame ! shout2send mount=mopidy ip=127.0.0.1 port=8000 password=hackme + + Example for Ogg Vorbis streaming: + + .. code-block:: ini + + [audio] + output = audioresample ! audioconvert ! vorbisenc ! oggmux ! shout2send mount=mopidy ip=127.0.0.1 port=8000 password=hackme Other advanced setups are also possible for outputs. Basically, anything you can use with the ``gst-launch-0.10`` command can be plugged into diff --git a/docs/contributing.rst b/docs/contributing.rst index 2436ffc0..8526f192 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -35,6 +35,10 @@ Making changes #. Install dependencies as described in the :ref:`installation` section. +#. Install additional development dependencies:: + + pip install -r dev-requirements.txt + #. Checkout a new branch (usually based on ``develop``) and name it accordingly to what you intend to do. @@ -82,26 +86,33 @@ Testing Mopidy has quite good test coverage, and we would like all new code going into Mopidy to come with tests. -#. To run tests, you need a couple of dependencies. They can be installed using - ``pip``:: - - pip install --upgrade coverage flake8 mock nose - -#. Then, to run all tests, go to the project directory and run:: +#. To run all tests, go to the project directory and run:: nosetests - To run tests with test coverage statistics, remember to specify the tests - dir:: + To run tests with test coverage statistics:: - nosetests --with-coverage tests/ + nosetests --with-coverage -#. Check the code for errors and style issues using flake8:: + Test coverage statistics can also be viewed online at + `coveralls.io `_. - flake8 . +#. Always check the code for errors and style issues using flake8:: -For more documentation on testing, check out the `nose documentation -`_. + flake8 + + If successful, the command will not print anything at all. + +#. Finally, there is the ultimate but a bit slower command. To run both tests, + docs build, and flake8 linting, run:: + + tox + + This will run exactly the same tests as `Travis CI + `_ runs for all our branches and pull + requests. If this command turns green, you can be quite confident that your + pull request will get the green flag from Travis as well, which is a + requirement for it to be merged. Submitting changes diff --git a/docs/debian.rst b/docs/debian.rst new file mode 100644 index 00000000..e0a4bd45 --- /dev/null +++ b/docs/debian.rst @@ -0,0 +1,105 @@ +.. _debian: + +************** +Debian package +************** + +The Mopidy Debian package is available from `apt.mopidy.com +`__ as well as from Debian, Ubuntu and other +Debian-based Linux distributions. + + +Installation +============ + +See :ref:`debian-install`. + + +Running as a system service +=========================== + +The Debian package comes with an init script. It starts Mopidy as a system +service running as the ``mopidy`` user, which is created by the package. + +The Debian package version 0.18.3-1 and older starts Mopidy as a system +service by default. Version 0.18.3-2 and newer asks if you want to run Mopidy +as a system service, defaulting to not doing so. + +If you're running 0.18.3-2 or newer, and you've changed your mind about whether +or not to run Mopidy as a system service, just run the following command to +reconfigure the package:: + + sudo dpkg-reconfigure mopidy + +If you're running 0.18.3-1 or older, and don't want to use the init script to +run Mopidy as a system service, but instead just run Mopidy manually using your +own user, you need to disable the init script and stop Mopidy by running:: + + sudo update-rc.d mopidy disable + sudo service mopidy stop + +This way of disabling the system service is compatible with the improved +0.18.3-2 or newer version of the Debian package, so if you later upgrade to a +newer version, you can change your mind using the ``dpkg-reconfigure`` command +above. + + +Differences when running as a system service +============================================ + +If you want to run Mopidy using the init script, there's a few differences +from a regular Mopidy setup you'll want to know about. + +- All configuration is in :file:`/etc/mopidy`, not in your user's home + directory. The main configuration file is :file:`/etc/mopidy/mopidy.conf`. + You can do all your changes in this file. + +- Mopidy extensions installed from Debian packages will sometimes install + additional configuration files in :file:`/etc/mopidy/extensions.d/`. These + files just provide different defaults for the extension when run as a system + service. You can override anything from :file:`/etc/mopidy/extensions.d/` in + the :file:`/etc/mopidy/mopidy.conf` configuration file. + +- The init script runs Mopidy as the ``mopidy`` user. The ``mopidy`` user will + need read access to any local music you want Mopidy to play. + +- To run Mopidy subcommands with the same arguments, and thus the same + configuration files, as the init script uses, you can use ``sudo service + mopidy run ``. In other words, where you'll usually run:: + + mopidy config + + You should instead run the following to inspect the system service's + configuration:: + + sudo service mopidy run config + + The same applies to scanning your local music collection. Where you'll + normally run:: + + mopidy local scan + + You should instead run:: + + sudo service mopidy run local scan + +- Mopidy is started, stopped, and restarted just like any other system + service:: + + sudo service mopidy start + sudo service mopidy stop + sudo service mopidy restart + +- You can check if Mopidy is currently running as a system service by running:: + + sudo service mopidy status + +- Mopidy installed from a Debian package can use both Mopidy extensions + installed both from Debian packages and extensions installed with pip. + + The other way around does not work: Mopidy installed with pip can use + extensions installed with pip, but not extensions installed from a Debian + package. This is because the Debian packages install extensions into + :file:`/usr/share/mopidy` which is normally not on your ``PYTHONPATH``. + Thus, your pip-installed Mopidy will not find the Debian package-installed + extensions. diff --git a/docs/devtools.rst b/docs/devtools.rst index 64bb7e6b..93798071 100644 --- a/docs/devtools.rst +++ b/docs/devtools.rst @@ -54,7 +54,7 @@ Creating releases #. Update changelog and commit it. #. Bump the version number in ``mopidy/__init__.py``. Remember to update the - test case in ``tests/version_test.py``. + test case in ``tests/test_version.py``. #. Merge the release branch (``develop`` in the example) into master:: @@ -118,6 +118,11 @@ packages is maintained. sudo apt-get install build-essential git-buildpackage +#. Create a Wheezy pbuilder env if running on Ubuntu and this the first time. + See :issue:`561` for details about why this is needed:: + + DIST=wheezy sudo git-pbuilder update --mirror=http://mirror.rackspace.com/debian/ --debootstrapopts --keyring=/usr/share/keyrings/debian-archive-keyring.gpg + #. Check out the ``debian`` branch of the repo:: git checkout -t origin/debian @@ -142,15 +147,27 @@ packages is maintained. git buildpackage -uc -us + If you are using the pbuilder make sure this command is:: + + sudo git buildpackage -uc -us --git-ignore-new --git-pbuilder --git-dist=wheezy --git-no-pbuilder-autoconf + #. Install and test newly built package:: sudo debi + Again for pbuilder use:: + + sudo debi --debs-dir /var/cache/pbuilder/result/ + #. If everything is OK, build the package a final time to tag the package version:: git buildpackage -uc -us --git-tag + Pbuilder:: + + sudo git buildpackage -uc -us --git-ignore-new --git-pbuilder --git-dist=wheezy --git-no-pbuilder-autoconf --git-tag + #. Push the changes you've done to the ``debian`` branch and the new tag:: git push @@ -161,6 +178,8 @@ packages is maintained. git buildpackage -uc -us + Modify as above to use the pbuilder as needed. + #. Copy files to the APT server. Make sure to select the correct part of the repo, e.g. main, contrib, or non-free:: diff --git a/docs/ext/api_explorer.png b/docs/ext/api_explorer.png new file mode 100644 index 00000000..390dbee0 Binary files /dev/null and b/docs/ext/api_explorer.png differ diff --git a/docs/ext/api_explorer.rst b/docs/ext/api_explorer.rst new file mode 100644 index 00000000..351ddf15 --- /dev/null +++ b/docs/ext/api_explorer.rst @@ -0,0 +1,12 @@ +.. _http-explore-extension: + +Mopidy-API-Explorer +=================== + +https://github.com/dz0ny/mopidy-api-explorer + +Web extension for browsing the Mopidy HTTP API. + +.. image:: /ext/api_explorer.png + :width: 1176 + :height: 713 diff --git a/docs/ext/backends.rst b/docs/ext/backends.rst new file mode 100644 index 00000000..1b0bf112 --- /dev/null +++ b/docs/ext/backends.rst @@ -0,0 +1,186 @@ +.. _ext-backends: + +****************** +Backend extensions +****************** + +Here you can find a list of external packages that extend Mopidy with +additional music sources by implementing the :ref:`backend-api`. + +This list is moderated and updated on a regular basis. If you want your package +to show up here, follow the :ref:`guide on creating extensions `. + + +Mopidy-Beets +============ + +https://github.com/mopidy/mopidy-beets + +Provides a backend for playing music from your `Beets +`_ music library through Beets' web extension. + + +Mopidy-Dirble +============= + +https://github.com/mopidy/mopidy-dirble + +Provides a backend for browsing the Internet radio channels from the `Dirble +`_ directory. + + +Mopidy-GMusic +============= + +https://github.com/hechtus/mopidy-gmusic + +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-Local +============ + +Bundled with Mopidy. See :ref:`ext-local`. + + +Mopidy-Podcast +============== + +https://github.com/tkem/mopidy-podcast + +Extension for browsing RSS feeds of podcasts and stream the episodes. + + +Mopidy-Podcast-gpodder.net +========================== + +https://github.com/tkem/mopidy-podcast-gpodder + +Extension for Mopidy-Podcast that lets you search and browse podcasts from the +`gpodder.net `_ web site. + + +Mopidy-Podcast-iTunes +===================== + +https://github.com/tkem/mopidy-podcast-itunes + +Extension for Mopidy-Podcast that lets you search and browse podcasts from the +Apple iTunes Store. + + +Mopidy-Podcast-gpodder.net +========================== + +https://github.com/tkem/mopidy-podcast-gpodder + +Extension for Mopidy-Podcast that lets you search and browse podcasts from the +`gpodder.net `_ web site. + + +Mopidy-Podcast-iTunes +===================== + +https://github.com/tkem/mopidy-podcast-itunes + +Extension for Mopidy-Podcast that lets you search and browse podcasts from the +Apple iTunes Store. + + +Mopidy-radio-de +=============== + +https://github.com/hechtus/mopidy-radio-de + +Extension for listening to Internet radio stations and podcasts listed at +`radio.de `_, `rad.io `_, +`radio.fr `_, and `radio.at `_. + + +Mopidy-SomaFM +============= + +https://github.com/AlexandrePTJ/mopidy-somafm + +Provides a backend for playing music from the `SomaFM `_ +service. + + +Mopidy-SoundCloud +================= + +https://github.com/mopidy/mopidy-soundcloud + +Provides a backend for playing music from the `SoundCloud +`_ service. + + +Mopidy-Spotify +============== + +https://github.com/mopidy/mopidy-spotify + +Extension for playing music from the `Spotify `_ music +streaming service. + + +Mopidy-Spotify-Tunigo +===================== + +https://github.com/trygveaa/mopidy-spotify-tunigo + +Extension for providing the browse feature of `Spotify +`_. This lets you browse playlists, genres and new +releases. + + +Mopidy-Stream +============= + +Bundled with Mopidy. See :ref:`ext-stream`. + + +Mopidy-Subsonic +=============== + +https://github.com/rattboi/mopidy-subsonic + +Provides a backend for playing music from a `Subsonic Music Streamer +`_ library. + + +Mopidy-TuneIn +============= + +https://github.com/kingosticks/mopidy-tunein + +Provides a backend for playing music from the `TuneIn +`_ online radio service. + + +Mopidy-VKontakte +================ + +https://github.com/sibuser/mopidy-vkontakte + +Provides a backend for playing music from the `VKontakte social network +`_. + + +Mopidy-YouTube +============== + +https://github.com/dz0ny/mopidy-youtube + +Provides a backend for playing music from the `YouTube +`_ service. diff --git a/docs/ext/external.rst b/docs/ext/external.rst deleted file mode 100644 index 0ead8ac2..00000000 --- a/docs/ext/external.rst +++ /dev/null @@ -1,139 +0,0 @@ -******************* -External extensions -******************* - -Here you can find a list of external packages that extend Mopidy with -additional functionality. This list is moderated and updated on a regular -basis. If you want your package to show up here, follow the :ref:`guide on -creating extensions `. - -Mopidy also bundles some extensions: - -- :ref:`ext-local` -- :ref:`ext-stream` -- :ref:`ext-http` -- :ref:`ext-mpd` - - -Mopidy-Arcam -============ - -https://github.com/TooDizzy/mopidy-arcam - -Extension for controlling volume using an external Arcam amplifier. Developed -and tested with an Arcam AVR-300. - - -Mopidy-Beets -============ - -https://github.com/mopidy/mopidy-beets - -Provides a backend for playing music from your `Beets -`_ music library through Beets' web extension. - - -Mopidy-Dirble -============= - -https://github.com/mopidy/mopidy-dirble - -Provides a backend for browsing the Internet radio channels from the `Dirble -`_ directory. - - -Mopidy-GMusic -============= - -https://github.com/hechtus/mopidy-gmusic - -Provides a backend for playing music from `Google Play Music -`_. - - -Mopidy-MPRIS -============ - -https://github.com/mopidy/mopidy-mpris - -Extension for controlling Mopidy through the `MPRIS `_ -D-Bus interface, for example using the Ubuntu Sound Menu. - - -Mopidy-NAD -========== - -https://github.com/mopidy/mopidy-nad - -Extension for controlling volume using an external NAD amplifier. - - -Mopidy-Notifier -=============== - -https://github.com/sauberfred/mopidy-notifier - -Extension for displaying track info as User Notifications in Mac OS X. - - -Mopidy-radio-de -=============== - -https://github.com/hechtus/mopidy-radio-de - -Extension for listening to Internet radio stations and podcasts listed at -`radio.de `_, `rad.io `_, -`radio.fr `_, and `radio.at `_. - - -Mopidy-Scrobbler -================ - -https://github.com/mopidy/mopidy-scrobbler - -Extension for scrobbling played tracks to Last.fm. - - -Mopidy-SomaFM -============= - -https://github.com/AlexandrePTJ/mopidy-somafm - -Provides a backend for playing music from the `SomaFM `_ -service. - - -Mopidy-SoundCloud -================= - -https://github.com/mopidy/mopidy-soundcloud - -Provides a backend for playing music from the `SoundCloud -`_ service. - - -Mopidy-Spotify -============== - -https://github.com/mopidy/mopidy-spotify - -Extension for playing music from the `Spotify `_ music -streaming service. - - -Mopidy-Subsonic -=============== - -https://github.com/rattboi/mopidy-subsonic - -Provides a backend for playing music from a `Subsonic Music Streamer -`_ library. - - -Mopidy-VKontakte -================ - -https://github.com/sibuser/mopidy-vkontakte - -Provides a backend for playing music from the `VKontakte social network -`_. diff --git a/docs/ext/frontends.rst b/docs/ext/frontends.rst new file mode 100644 index 00000000..481ac510 --- /dev/null +++ b/docs/ext/frontends.rst @@ -0,0 +1,49 @@ +.. _ext-frontends: + +******************* +Frontend extensions +******************* + +Here you can find a list of external packages that extend Mopidy with +additional frontends, which includes just about anything that use the +:ref:`core-api`. + +This list is moderated and updated on a regular basis. If you want your package +to show up here, follow the :ref:`guide on creating extensions `. + + +Mopidy-HTTP +=========== + +Bundled with Mopidy. See :ref:`ext-http`. + + +Mopidy-MPD +========== + +Bundled with Mopidy. See :ref:`ext-mpd`. + + +Mopidy-MPRIS +============ + +https://github.com/mopidy/mopidy-mpris + +Extension for controlling Mopidy through the `MPRIS `_ +D-Bus interface, for example using the Ubuntu Sound Menu. + + +Mopidy-Notifier +=============== + +https://github.com/sauberfred/mopidy-notifier + +Extension for displaying track info as User Notifications in Mac OS X. + + +Mopidy-Scrobbler +================ + +https://github.com/mopidy/mopidy-scrobbler + +Extension for scrobbling played tracks to Last.fm. diff --git a/docs/ext/http.rst b/docs/ext/http.rst index 1b5b0119..0175fe1d 100644 --- a/docs/ext/http.rst +++ b/docs/ext/http.rst @@ -6,7 +6,7 @@ Mopidy-HTTP Mopidy-HTTP is an extension that lets you control Mopidy through HTTP and WebSockets, for example from a web client. It is bundled with Mopidy and -enabled by default if all dependencies are available. +enabled by default. When it is enabled it starts a web server at the port specified by the :confval:`http/port` config value. @@ -47,24 +47,24 @@ you're looking for a web based client for Mopidy, go check out :ref:`http-clients`. -Dependencies -============ +Extending the server's functionality +==================================== -In addition to Mopidy's dependencies, Mopidy-HTTP requires the following: +If you wish to extend the server with additional server side functionality you +must create class that implements the :class:`mopidy.http.Router` interface and +install it in the extension registry under the ``http:router`` name. -- cherrypy >= 3.2.2. Available as python-cherrypy3 in Debian/Ubuntu. +The default implementation of :class:`mopidy.http.Router` already supports +serving static files. If you just want to serve static files you only need to +define the class variables :attr:`mopidy.http.Router.name` and +:attr:`mopidy.http.Router.path`. For example:: -- ws4py >= 0.2.3. Available as python-ws4py in newer Debian/Ubuntu and from - `apt.mopidy.com `__ for older releases of - Debian/Ubuntu. + class MyWebClient(http.Router): + name = 'mywebclient' + path = os.path.join(os.path.dirname(__file__), 'public_html') -If you're installing Mopidy with pip, you can run the following command to -install Mopidy with the extra dependencies for required for Mopidy-HTTP:: - - pip install --upgrade Mopidy[http] - -If you're installing Mopidy from APT, the additional dependencies needed for -Mopidy-HTTP are always included. +If you wish to extend server with custom methods you can override the method +:meth:`mopidy.http.Router.setup_routes` and define custom routes. Configuration @@ -108,4 +108,7 @@ See :ref:`config` for general help on configuring Mopidy. Name of the HTTP service when published through Zeroconf. The variables ``$hostname`` and ``$port`` can be used in the name. + If set, the Zeroconf services ``_http._tcp`` and ``_mopidy-http._tcp`` will + be published. + Set to an empty string to disable Zeroconf for HTTP. diff --git a/docs/ext/lux.png b/docs/ext/lux.png new file mode 100644 index 00000000..ce496c16 Binary files /dev/null and b/docs/ext/lux.png differ diff --git a/docs/ext/lux.rst b/docs/ext/lux.rst new file mode 100644 index 00000000..cdcc2d19 --- /dev/null +++ b/docs/ext/lux.rst @@ -0,0 +1,10 @@ +Mopidy-Lux +========== + +https://github.com/dz0ny/mopidy-lux + +A Mopidy web client made with AngularJS by Janez Troha. + +.. image:: /ext/lux.png + :width: 1275 + :height: 795 diff --git a/docs/ext/mixers.rst b/docs/ext/mixers.rst new file mode 100644 index 00000000..f934efce --- /dev/null +++ b/docs/ext/mixers.rst @@ -0,0 +1,53 @@ +.. _ext-mixers: + +**************** +Mixer extensions +**************** + +Here you can find a list of external packages that extend Mopidy with +additional audio mixers by implementing the :ref:`mixer-api` which was added +in Mopidy 0.19. + +This list is moderated and updated on a regular basis. If you want your package +to show up here, follow the :ref:`guide on creating extensions `. + + +Mopidy-ALSAMixer +================ + +https://github.com/mopidy/mopidy-alsamixer + +Extension for controlling volume one a Linux system using ALSA. + + +Mopidy-Arcam +============ + +https://github.com/TooDizzy/mopidy-arcam + +Extension for controlling volume using an external Arcam amplifier. Developed +and tested with an Arcam AVR-300. + + +Mopidy-NAD +========== + +https://github.com/mopidy/mopidy-nad + +Extension for controlling volume using an external NAD amplifier. Developed +and tested with a NAD C355BEE. + + +Mopidy-SoftwareMixer +==================== + +Bundled with Mopidy. See :ref:`ext-softwaremixer`. + + +Mopidy-Yamaha +============= + +https://github.com/knutz3n/mopidy-yamaha + +Extension for controlling volume using an external Yamaha network connected +amplifier. diff --git a/docs/clients/martijnboland-moped.png b/docs/ext/moped.png similarity index 100% rename from docs/clients/martijnboland-moped.png rename to docs/ext/moped.png diff --git a/docs/ext/moped.rst b/docs/ext/moped.rst new file mode 100644 index 00000000..38fa50cc --- /dev/null +++ b/docs/ext/moped.rst @@ -0,0 +1,10 @@ +Moped +===== + +https://github.com/martijnboland/moped + +A Mopidy web client made with AnbularJS by Martijn Boland. + +.. image:: /ext/moped.png + :width: 720 + :height: 450 diff --git a/docs/ext/softwaremixer.rst b/docs/ext/softwaremixer.rst new file mode 100644 index 00000000..4297870b --- /dev/null +++ b/docs/ext/softwaremixer.rst @@ -0,0 +1,35 @@ +.. _ext-softwaremixer: + +******************** +Mopidy-SoftwareMixer +******************** + +Mopidy-SoftwareMixer is an extension for controlling audio volume in software +through GStreamer. It is the only mixer bundled with Mopidy and is enabled by +default. + +If you use PulseAudio, the software mixer will control the per-application +volume for Mopidy in PulseAudio, and any changes to the per-application volume +done from outside Mopidy will be reflected by the software mixer. + +If you don't use PulseAudio, the mixer will adjust the volume internally in +Mopidy's GStreamer pipeline. + + +Configuration +============= + +Multiple mixers can be installed and enabled at the same time, but only the +mixer pointed to by the :confval:`audio/mixer` config value will actually be +used. + +See :ref:`config` for general help on configuring Mopidy. + +.. literalinclude:: ../../mopidy/softwaremixer/ext.conf + :language: ini + +.. confval:: softwaremixer/enabled + + If the software mixer should be enabled or not. Usually you don't want to + change this, but instead change the :confval:`audio/mixer` config value to + decide which mixer is actually used. diff --git a/docs/ext/stream.rst b/docs/ext/stream.rst index 88dc5ade..b0b92fbd 100644 --- a/docs/ext/stream.rst +++ b/docs/ext/stream.rst @@ -42,3 +42,10 @@ See :ref:`config` for general help on configuring Mopidy. .. confval:: stream/timeout Number of milliseconds before giving up looking up stream metadata. + +.. confval:: stream/metadata_blacklist + + List of URI globs to not fetch metadata from before playing. This feature + is typically needed for play once URIs provided by certain streaming + providers. Regular POSIX glob semantics apply, so ``http://*.example.com/*`` + would match all example.com sub-domains. diff --git a/docs/ext/web.rst b/docs/ext/web.rst new file mode 100644 index 00000000..57e2e1af --- /dev/null +++ b/docs/ext/web.rst @@ -0,0 +1,21 @@ +.. _ext-web: + +************** +Web extensions +************** + +Here you can find a list of external packages that extend Mopidy with +additional web interfaces by implementing the :ref:`http-server-api`, which +was added in Mopidy 0.19, and optionally using the :ref:`http-api`. + +This list is moderated and updated on a regular basis. If you want your package +to show up here, follow the :ref:`guide on creating extensions `. + + +.. include:: /ext/api_explorer.rst + + +.. include:: /ext/lux.rst + + +.. include:: /ext/moped.rst diff --git a/docs/extensiondev.rst b/docs/extensiondev.rst index 517fd027..dba0fa83 100644 --- a/docs/extensiondev.rst +++ b/docs/extensiondev.rst @@ -285,7 +285,7 @@ This is ``mopidy_soundspot/__init__.py``:: version = __version__ def get_default_config(self): - conf_file = os.path.join(os.path.dirname(__file__, 'ext.conf')) + conf_file = os.path.join(os.path.dirname(__file__), 'ext.conf') return config.read(conf_file) def get_config_schema(self): @@ -413,6 +413,15 @@ more details. return 0 +Example web application +======================= + +As of Mopidy 0.19, extensions can use Mopidy's builtin 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. + + Example GStreamer element ========================= @@ -423,9 +432,6 @@ Basically, you just implement your GStreamer element in Python and then make your :meth:`~mopidy.ext.Extension.setup` method register all your custom GStreamer elements. -For examples of custom GStreamer elements implemented in Python, see -:mod:`mopidy.audio.mixers`. - Python conventions ================== diff --git a/docs/index.rst b/docs/index.rst index e5f98a3a..aedc0fb0 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -33,10 +33,10 @@ Usage :maxdepth: 2 installation/index - installation/raspberrypi config running troubleshooting + debian .. _ext: @@ -51,7 +51,11 @@ Extensions ext/stream ext/http ext/mpd - ext/external + ext/softwaremixer + ext/mixers + ext/backends + ext/frontends + ext/web Clients @@ -96,7 +100,7 @@ Reference :maxdepth: 2 glossary - commands/index + command api/index modules/index diff --git a/docs/installation/arch.rst b/docs/installation/arch.rst new file mode 100644 index 00000000..3f85bf51 --- /dev/null +++ b/docs/installation/arch.rst @@ -0,0 +1,27 @@ +.. _arch-install: + +**************************** +Arch Linux: Install from AUR +**************************** + +If you are running Arch Linux, you can install Mopidy using the +`mopidy `_ package found in AUR. + +#. To install Mopidy with all dependencies, you can use + for example `yaourt `_:: + + yaourt -S mopidy + + To upgrade Mopidy to future releases, just upgrade your system using:: + + yaourt -Syu + +#. Optional: If you want to use any Mopidy extensions, like Spotify support or + Last.fm scrobbling, AUR also has `packages for several Mopidy extensions + `_. + + For a full list of available Mopidy extensions, including those not + installable from AUR, see :ref:`ext`. + +#. Finally, you need to set a couple of :doc:`config values `, and + then you're ready to :doc:`run Mopidy `. diff --git a/docs/installation/debian.rst b/docs/installation/debian.rst new file mode 100644 index 00000000..26864986 --- /dev/null +++ b/docs/installation/debian.rst @@ -0,0 +1,64 @@ +.. _debian-install: + +****************************************** +Debian/Ubuntu: Install from apt.mopidy.com +****************************************** + +If you run a Debian based Linux distribution, like Ubuntu, the easiest way to +install Mopidy is from the `Mopidy APT archive `_. +When installing from the APT archive, you will automatically get updates to +Mopidy in the same way as you get updates to the rest of your system. + +If you're on a Raspberry Pi running Debian or Raspbian, the following +instructions should work for you as well. If you're setting up a Raspberry Pi +from scratch, we have a guide for installing Debian/Raspbian and Mopidy. See +:ref:`raspberrypi-installation`. + +#. Add the archive's GPG key:: + + wget -q -O - https://apt.mopidy.com/mopidy.gpg | sudo apt-key add - + +#. Add the following to ``/etc/apt/sources.list``, or if you have the directory + ``/etc/apt/sources.list.d/``, add it to a file called ``mopidy.list`` in + that directory:: + + # Mopidy APT archive + deb http://apt.mopidy.com/ stable main contrib non-free + deb-src http://apt.mopidy.com/ stable main contrib non-free + + For the lazy, you can simply run the following command to create + ``/etc/apt/sources.list.d/mopidy.list``:: + + sudo wget -q -O /etc/apt/sources.list.d/mopidy.list https://apt.mopidy.com/mopidy.list + +#. Install Mopidy and all dependencies:: + + sudo apt-get update + sudo apt-get install mopidy + +#. Optional: If you want to use any Mopidy extensions, like Spotify support or + Last.fm scrobbling, you need to install additional packages. + + To list all the extensions available from apt.mopidy.com, you can run:: + + apt-cache search mopidy + + To install one of the listed packages, e.g. ``mopidy-spotify``, simply run:: + + sudo apt-get install mopidy-spotify + + For a full list of available Mopidy extensions, including those not + installable from apt.mopidy.com, see :ref:`ext`. + +#. Before continuing, make sure you've read the :ref:`debian` section to learn + about the differences between running Mopidy as a system service and + manually as your own system user. + +#. Finally, you need to set a couple of :doc:`config values `, and then + you're ready to :doc:`run Mopidy `. + +When a new release of Mopidy is out, and you can't wait for you system to +figure it out for itself, run the following to upgrade right away:: + + sudo apt-get update + sudo apt-get dist-upgrade diff --git a/docs/installation/index.rst b/docs/installation/index.rst index 73c2f08f..c8deae59 100644 --- a/docs/installation/index.rst +++ b/docs/installation/index.rst @@ -5,300 +5,15 @@ Installation ************ There are several ways to install Mopidy. What way is best depends upon your OS -and/or distribution. If you want to contribute to the development of Mopidy, -you should first read this page, then have a look at :ref:`run-from-git`. +and/or distribution. -.. contents:: Installation guides - :local: +If you want to contribute to the development of Mopidy, you should first read +the general installation instructions, then have a look at :ref:`run-from-git`. +.. toctree:: -Debian/Ubuntu: Install from apt.mopidy.com -========================================== - -If you run a Debian based Linux distribution, like Ubuntu, the easiest way to -install Mopidy is from the `Mopidy APT archive `_. When -installing from the APT archive, you will automatically get updates to Mopidy -in the same way as you get updates to the rest of your distribution. - -#. Add the archive's GPG key:: - - wget -q -O - http://apt.mopidy.com/mopidy.gpg | sudo apt-key add - - -#. Add the following to ``/etc/apt/sources.list``, or if you have the directory - ``/etc/apt/sources.list.d/``, add it to a file called ``mopidy.list`` in - that directory:: - - # Mopidy APT archive - deb http://apt.mopidy.com/ stable main contrib non-free - deb-src http://apt.mopidy.com/ stable main contrib non-free - - For the lazy, you can simply run the following command to create - ``/etc/apt/sources.list.d/mopidy.list``:: - - sudo wget -q -O /etc/apt/sources.list.d/mopidy.list http://apt.mopidy.com/mopidy.list - -#. Install Mopidy and all dependencies:: - - sudo apt-get update - sudo apt-get install mopidy - - Note that this will only install the main Mopidy package. For e.g. Spotify - or SoundCloud support you need to install the respective extension packages. - To list all the extensions available from apt.mopidy.com, you can run:: - - apt-cache search mopidy - - To install one of the listed packages, e.g. ``mopidy-spotify``, simply run:: - - sudo apt-get install mopidy-spotify - - For a full list of available Mopidy extensions, including those not - installable from apt.mopidy.com, see :ref:`ext`. - -#. Finally, you need to set a couple of :doc:`config values `, and then - you're ready to :doc:`run Mopidy `. - -When a new release of Mopidy is out, and you can't wait for you system to -figure it out for itself, run the following to upgrade right away:: - - sudo apt-get update - sudo apt-get dist-upgrade - - -Raspberry Pi running Debian ---------------------------- - -Fred Hatfull has created a guide for installing a Raspberry Pi from scratch -with Debian and Mopidy. See :ref:`raspberrypi-installation`. - - -Vagrant virtual machine running Ubuntu --------------------------------------- - -Paul Sturgess has created a Vagrant and Chef setup that automatically creates -and sets up a virtual machine which runs Mopidy. Check out -https://github.com/paulsturgess/mopidy-vagrant if you're interested in trying -it out. - - -Arch Linux: Install from AUR -============================ - -If you are running Arch Linux, you can install Mopidy -using the `mopidy `_ -package found in AUR. - -#. To install Mopidy with all dependencies, you can use - for example `yaourt `_:: - - yaourt -S mopidy - - To upgrade Mopidy to future releases, just upgrade your system using:: - - yaourt -Syu - -#. Optional: If you want to use any Mopidy extensions, like Spotify support or - Last.fm scrobbling, AUR also got `packages for several Mopidy extensions - `_. - -#. Finally, you need to set a couple of :doc:`config values `, and - then you're ready to :doc:`run Mopidy `. - - -OS X: Install from Homebrew and pip -=================================== - -If you are running OS X, you can install everything needed with Homebrew and -pip. - -#. Install `Homebrew `_. - - If you are already using Homebrew, make sure your installation is up to - date before you continue:: - - brew update - brew upgrade - -#. Mopidy requires GStreamer 0.10, but Homebrew's main formula repo has - upgraded its GStreamer packages to 1.0. Thus, you'll need to add an - alternative formula repo (aka "tap") that has the old GStreamer formulas:: - - brew tap homebrew/versions - -#. Install the required packages from Homebrew:: - - brew install gst-python010 gst-plugins-good010 gst-plugins-ugly010 - -#. Make sure to include Homebrew's Python ``site-packages`` directory in your - ``PYTHONPATH``. If you don't include this, Mopidy will not find GStreamer - and it will crash. - - You can either amend your ``PYTHONPATH`` permanently, by adding the - following statement to your shell's init file, e.g. ``~/.bashrc``:: - - export PYTHONPATH=$(brew --prefix)/lib/python2.7/site-packages:$PYTHONPATH - - Or, you can prefix the Mopidy command every time you run it:: - - PYTHONPATH=$(brew --prefix)/lib/python2.7/site-packages mopidy - -#. Next up, you need to install some Python packages. To do so, we use pip. If - you don't have the ``pip`` command, you can install it now:: - - sudo easy_install pip - -#. Then, install the latest release of Mopidy using pip:: - - sudo pip install -U mopidy - -#. Optionally, install additional extensions to Mopidy. - - For HTTP frontend support, so you can run Mopidy web clients:: - - sudo pip install -U mopidy[http] - - For playing music from Spotify:: - - brew install libspotify - sudo pip install -U mopidy-spotify - - For scrobbling to Last.fm:: - - sudo pip install -U mopidy-scrobbler - - For more extensions, see :ref:`ext`. - -#. Finally, you need to set a couple of :doc:`config values `, and - then you're ready to :doc:`run Mopidy `. - - -Otherwise: Install from source using pip -======================================== - -If you are on on Linux, but can't install from the APT archive or from AUR, you -can install Mopidy from PyPI using pip. - -#. First of all, you need Python 2.7. Check if you have Python and what - version by running:: - - python --version - -#. When you install using pip, you need to make sure you have pip. You'll also - need a C compiler and the Python development headers to build pyspotify - later. - - This is how you install it on Debian/Ubuntu:: - - sudo apt-get install build-essential python-dev python-pip - - And on Arch Linux from the official repository:: - - sudo pacman -S base-devel python2-pip - - And on Fedora Linux from the official repositories:: - - sudo yum install -y gcc python-devel python-pip - - .. note:: - - On Fedora Linux, you must replace ``pip`` with ``pip-python`` in the - following steps. - -#. Then you'll need to install GStreamer 0.10 (>= 0.10.31, < 0.11), with Python - bindings. GStreamer is packaged for most popular Linux distributions. Search - for GStreamer in your package manager, and make sure to install the Python - bindings, and the "good" and "ugly" plugin sets. - - If you use Debian/Ubuntu you can install GStreamer like this:: - - sudo apt-get install python-gst0.10 gstreamer0.10-plugins-good \ - gstreamer0.10-plugins-ugly gstreamer0.10-tools - - If you use Arch Linux, install the following packages from the official - repository:: - - sudo pacman -S gstreamer0.10-python gstreamer0.10-good-plugins \ - gstreamer0.10-ugly-plugins - - If you use Fedora you can install GStreamer like this:: - - sudo yum install -y python-gst0.10 gstreamer0.10-plugins-good \ - gstreamer0.10-plugins-ugly gstreamer0.10-tools - - If you use Gentoo you need to be careful because GStreamer 0.10 is in a - different lower slot than 1.0, the default. Your emerge commands will need - to include the slot:: - - emerge -av gst-python gst-plugins-bad:0.10 gst-plugins-good:0.10 \ - gst-plugins-ugly:0.10 gst-plugins-meta:0.10 - - ``gst-plugins-meta:0.10`` is the one that actually pulls in the plugins you - want, so pay attention to the use flags, e.g. ``alsa``, ``mp3``, etc. - -#. Install the latest release of Mopidy:: - - sudo pip install -U mopidy - - To upgrade Mopidy to future releases, just rerun this command. - - Alternatively, if you want to track Mopidy development closer, you may - install a snapshot of Mopidy's ``develop`` Git branch using pip:: - - sudo pip install mopidy==dev - -#. Optional: If you want to use the HTTP frontend and web clients, you need - some additional dependencies:: - - sudo pip install -U mopidy[http] - -#. Optional: If you want Spotify support in Mopidy, you'll need to install - libspotify and the Mopidy-Spotify extension. - - #. Download and install the latest version of libspotify for your OS and CPU - architecture from `Spotify - `_. - - For libspotify 12.1.51 for 64-bit Linux the process is as follows:: - - wget https://developer.spotify.com/download/libspotify/libspotify-12.1.51-Linux-x86_64-release.tar.gz - tar zxfv libspotify-12.1.51-Linux-x86_64-release.tar.gz - cd libspotify-12.1.51-Linux-x86_64-release/ - sudo make install prefix=/usr/local - - Remember to adjust the above example for the latest libspotify version - supported by pyspotify, your OS, and your CPU architecture. - - #. If you're on Fedora, you must add a configuration file so libspotify.so - can be found:: - - su -c 'echo "/usr/local/lib" > /etc/ld.so.conf.d/libspotify.conf' - sudo ldconfig - - #. Then install the latest release of Mopidy-Spotify using pip:: - - sudo pip install -U mopidy-spotify - -#. Optional: If you want to scrobble your played tracks to Last.fm, you need - to install Mopidy-Scrobbler:: - - sudo pip install -U mopidy-scrobbler - -#. Optional: To use Mopidy-MPRIS, e.g. for controlling Mopidy from the Ubuntu - Sound Menu or from an UPnP client via Rygel, you need some additional - dependencies and the Mopidy-MPRIS extension. - - #. Install the Python bindings for libindicate, and the Python bindings for - libdbus, the reference D-Bus library. - - On Debian/Ubuntu:: - - sudo apt-get install python-dbus python-indicate - - #. Then install the latest release of Mopidy-MPRIS using pip:: - - sudo pip install -U mopidy-mpris - -#. For more Mopidy extensions, see :ref:`ext`. - -#. Finally, you need to set a couple of :doc:`config values `, and - then you're ready to :doc:`run Mopidy `. + debian + arch + osx + source + raspberrypi diff --git a/docs/installation/osx.rst b/docs/installation/osx.rst new file mode 100644 index 00000000..1513620c --- /dev/null +++ b/docs/installation/osx.rst @@ -0,0 +1,65 @@ +*************************** +OS X: Install from Homebrew +*************************** + +If you are running OS X, you can install everything needed with Homebrew. + +#. Install Xcode command line developer tools. Do this even if you already have + Xcode installed:: + + xcode-select --install + +#. Install `XQuartz `_. This is needed by + GStreamer which Mopidy use heavily. + +#. Install `Homebrew `_. + +#. If you are already using Homebrew, make sure your installation is up to + date before you continue:: + + brew update + brew upgrade + +#. Mopidy works out of box if you have installed Python from Homebrew:: + + brew install python + + .. note:: + + If you want to use the Python version bundled with OS X, you'll need to + include Python packages installed by Homebrew in your ``PYTHONPATH``. + If you don't do this, the ``mopidy`` executable will not find its + dependencies and will crash. + + You can either amend your ``PYTHONPATH`` permanently, by adding the + following statement to your shell's init file, e.g. ``~/.bashrc``:: + + export PYTHONPATH=$(brew --prefix)/lib/python2.7/site-packages:$PYTHONPATH + + Or, you can prefix the Mopidy command every time you run it:: + + PYTHONPATH=$(brew --prefix)/lib/python2.7/site-packages mopidy + +#. Mopidy has its own `Homebrew formula repo + `_, called a "tap". To enable our + Homebrew tap, run:: + + brew tap mopidy/mopidy + +#. To install Mopidy, run:: + + brew install mopidy + +#. Optional: If you want to use any Mopidy extensions, like Spotify support or + Last.fm scrobbling, the Homebrew tap has formulas for several Mopidy + extensions as well. + + To list all the extensions available from our tap, you can run:: + + brew search mopidy + + For a full list of available Mopidy extensions, including those not + installable from Homebrew, see :ref:`ext`. + +#. Finally, you need to set a couple of :doc:`config values `, and + then you're ready to :doc:`run Mopidy `. diff --git a/docs/installation/raspberrypi.rst b/docs/installation/raspberrypi.rst index fe958e81..c13e3ec4 100644 --- a/docs/installation/raspberrypi.rst +++ b/docs/installation/raspberrypi.rst @@ -1,8 +1,8 @@ .. _raspberrypi-installation: -**************************** -Installation on Raspberry Pi -**************************** +************************************* +Raspberry Pi: Mopidy on a credit card +************************************* Mopidy runs nicely on a `Raspberry Pi `_. As of January 2013, Mopidy will run with Spotify support on both the armel @@ -21,7 +21,7 @@ How to for Raspbian "wheezy" and Debian "wheezy" This guide applies for both: -- Raspian "wheezy" for armhf (hard-float), and +- Raspbian "wheezy" for armhf (hard-float), and - Debian "wheezy" for armel (soft-float) If you don't know which one to select, go for the armhf variant, as it'll give @@ -71,11 +71,12 @@ you a lot better performance. command to e.g. ``/etc/rc.local``, which will be executed when the system is booting. -#. Install Mopidy and its dependencies from `apt.mopidy.com - `_, as described in :ref:`installation`. +#. Install Mopidy and its dependencies as described in :ref:`debian-install`. #. Finally, you need to set a couple of :doc:`config values `, and - then you're ready to :doc:`run Mopidy `. + then you're ready to :doc:`run Mopidy `. Alternatively you may + want to have Mopidy run as a :doc:`system service `, automatically + starting at boot. Appendix: Fixing audio quality issues @@ -109,14 +110,17 @@ card. Note that if you have an ``~/.asoundrc`` it will overide any global settings from ``/etc/asound.conf``. -#. Update your ``~/.config/mopidy/mopidy.conf`` to contain:: +#. For Mopidy to output audio directly to ALSA, instead of Jack which + GStreamer usually defaults to on Raspberry Pi, install the + ``gstreamer0.10-alsa`` package:: + + sudo apt-get install gstreamer0.10-alsa + + Then update your ``~/.config/mopidy/mopidy.conf`` to contain:: [audio] output = alsasink - This is to tell GStreamer not to pick Jack which it seems to like picking on - Raspberry Pis for some reason. - Following these steps you should be able to get crackle free sound on either HDMI or analog. Note that you might need to ensure that PulseAudio is no longer running to get this working nicely. diff --git a/docs/installation/source.rst b/docs/installation/source.rst new file mode 100644 index 00000000..c2c4161a --- /dev/null +++ b/docs/installation/source.rst @@ -0,0 +1,114 @@ +.. _source-install: + +******************* +Install from source +******************* + +If you are on Linux, but can't install :ref:`from the APT archive +` or :ref:`from AUR `, you can install Mopidy +from source by hand. + +#. First of all, you need Python 2.7. Check if you have Python and what + version by running:: + + python --version + +#. You need to make sure you have ``pip``, the Python package installer. You'll + also need a C compiler and the Python development headers to build pyspotify + later. + + This is how you install it on Debian/Ubuntu:: + + sudo apt-get install build-essential python-dev python-pip + + And on Arch Linux from the official repository:: + + sudo pacman -S base-devel python2-pip + + And on Fedora Linux from the official repositories:: + + sudo yum install -y gcc python-devel python-pip + + .. note:: + + On Fedora Linux, you must replace ``pip`` with ``pip-python`` in the + following steps. + +#. Then you'll need to install GStreamer 0.10 (>= 0.10.31, < 0.11), with Python + bindings. GStreamer is packaged for most popular Linux distributions. Search + for GStreamer in your package manager, and make sure to install the Python + bindings, and the "good" and "ugly" plugin sets. + + If you use Debian/Ubuntu you can install GStreamer like this:: + + sudo apt-get install python-gst0.10 gstreamer0.10-plugins-good \ + gstreamer0.10-plugins-ugly gstreamer0.10-tools + + If you use Arch Linux, install the following packages from the official + repository:: + + sudo pacman -S gstreamer0.10-python gstreamer0.10-good-plugins \ + gstreamer0.10-ugly-plugins + + If you use Fedora you can install GStreamer like this:: + + sudo yum install -y python-gst0.10 gstreamer0.10-plugins-good \ + gstreamer0.10-plugins-ugly gstreamer0.10-tools + + If you use Gentoo you need to be careful because GStreamer 0.10 is in a + different lower slot than 1.0, the default. Your emerge commands will need + to include the slot:: + + emerge -av gst-python gst-plugins-bad:0.10 gst-plugins-good:0.10 \ + gst-plugins-ugly:0.10 gst-plugins-meta:0.10 + + ``gst-plugins-meta:0.10`` is the one that actually pulls in the plugins you + want, so pay attention to the use flags, e.g. ``alsa``, ``mp3``, etc. + +#. Install the latest release of Mopidy:: + + sudo pip install -U mopidy + + To upgrade Mopidy to future releases, just rerun this command. + + Alternatively, if you want to track Mopidy development closer, you may + install a snapshot of Mopidy's ``develop`` Git branch using pip:: + + sudo pip install --allow-unverified=mopidy mopidy==dev + +#. Optional: If you want Spotify support in Mopidy, you'll need to install + libspotify and the Mopidy-Spotify extension. + + #. Download and install the latest version of libspotify for your OS and CPU + architecture from `Spotify + `_. + + For libspotify 12.1.51 for 64-bit Linux the process is as follows:: + + wget https://developer.spotify.com/download/libspotify/libspotify-12.1.51-Linux-x86_64-release.tar.gz + tar zxfv libspotify-12.1.51-Linux-x86_64-release.tar.gz + cd libspotify-12.1.51-Linux-x86_64-release/ + sudo make install prefix=/usr/local + + Remember to adjust the above example for the latest libspotify version + supported by pyspotify, your OS, and your CPU architecture. + + #. If you're on Fedora, you must add a configuration file so libspotify.so + can be found:: + + echo /usr/local/lib | sudo tee /etc/ld.so.conf.d/libspotify.conf + sudo ldconfig + + #. Then install the latest release of Mopidy-Spotify using pip:: + + sudo pip install -U mopidy-spotify + +#. Optional: If you want to scrobble your played tracks to Last.fm, you need + to install Mopidy-Scrobbler:: + + sudo pip install -U mopidy-scrobbler + +#. For a full list of available Mopidy extensions, see :ref:`ext`. + +#. Finally, you need to set a couple of :doc:`config values `, and + then you're ready to :doc:`run Mopidy `. diff --git a/docs/modules/mpd.rst b/docs/modules/mpd.rst index 4a9eb7e8..1826e535 100644 --- a/docs/modules/mpd.rst +++ b/docs/modules/mpd.rst @@ -7,6 +7,13 @@ For details on how to use Mopidy's MPD server, see :ref:`ext-mpd`. .. automodule:: mopidy.mpd :synopsis: MPD server frontend +MPD tokenizer +============= + +.. automodule:: mopidy.mpd.tokenize + :synopsis: MPD request tokenizer + :members: + MPD dispatcher ============== diff --git a/docs/running.rst b/docs/running.rst index d357afe6..af37d481 100644 --- a/docs/running.rst +++ b/docs/running.rst @@ -30,7 +30,8 @@ Init scripts - The ``mopidy`` package at `apt.mopidy.com `__ comes with an `sysvinit init script - `_. + `_. For + more details, see the :ref:`debian` section of the docs. - The ``mopidy`` package in `Arch Linux AUR `__ comes with a systemd init diff --git a/docs/troubleshooting.rst b/docs/troubleshooting.rst index 9e065ed7..883abc3b 100644 --- a/docs/troubleshooting.rst +++ b/docs/troubleshooting.rst @@ -7,7 +7,7 @@ Troubleshooting If you run into problems with Mopidy, we usually hang around at ``#mopidy`` at `irc.freenode.net `_ 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 +If you stumble into a bug or have a feature request, please create an issue in the `issue tracker `_. When you're debugging yourself or asking for help, there are some tools built @@ -64,7 +64,7 @@ docs for the :confval:`loglevels/*` config section. Debugging deadlocks =================== -If Mopidy hangs without and obvious explanation, you can send the ``SIGUSR1`` +If Mopidy hangs without an obvious explanation, you can send the ``SIGUSR1`` signal to the Mopidy process. If Mopidy's main thread is still responsive, it will log a traceback for each running thread, showing what the threads are currently doing. This is a very useful tool for understanding exactly how the diff --git a/js/Gruntfile.js b/js/Gruntfile.js index 812ecec4..81221676 100644 --- a/js/Gruntfile.js +++ b/js/Gruntfile.js @@ -2,8 +2,9 @@ module.exports = function (grunt) { grunt.initConfig({ + pkg: grunt.file.readJSON("package.json"), meta: { - banner: "/*! Mopidy.js - built " + + banner: "/*! Mopidy.js v<%= pkg.version %> - built " + "<%= grunt.template.today('yyyy-mm-dd') %>\n" + " * http://www.mopidy.com/\n" + " * Copyright (c) <%= grunt.template.today('yyyy') %> " + @@ -26,7 +27,7 @@ module.exports = function (grunt) { }, options: { postBundleCB: function (err, src, next) { - next(null, grunt.template.process("<%= meta.banner %>") + src); + next(err, grunt.template.process("<%= meta.banner %>") + src); }, standalone: "Mopidy" } @@ -45,7 +46,7 @@ module.exports = function (grunt) { }, options: { postBundleCB: function (err, src, next) { - next(null, grunt.template.process("<%= meta.banner %>") + src); + next(err, grunt.template.process("<%= meta.banner %>") + src); }, standalone: "Mopidy" } diff --git a/js/README.md b/js/README.md index 5a04cd66..1b368bf5 100644 --- a/js/README.md +++ b/js/README.md @@ -41,20 +41,15 @@ After npm completes, you can import Mopidy.js using ``require()``: Using the library ----------------- -See Mopidy's [HTTP API -documentation](http://docs.mopidy.com/en/latest/api/http/). +See the [Mopidy.js documentation](http://docs.mopidy.com/en/latest/api/js/). Building from source -------------------- -1. Install [Node.js](http://nodejs.org/) and npm. There is a PPA if you're - running Ubuntu: +1. Install [Node.js](http://nodejs.org/) and npm. If you're running Ubuntu: - sudo apt-get install python-software-properties - sudo add-apt-repository ppa:chris-lea/node.js - sudo apt-get update - sudo apt-get install nodejs + sudo apt-get install nodejs-legacy npm 2. Enter the `js/` in Mopidy's Git repo dir and install all dependencies: @@ -85,6 +80,27 @@ To run other [grunt](http://gruntjs.com/) targets which isn't predefined in Changelog --------- +### 0.4.0 (2014-06-24) + +- Add support for method calls with by-name arguments. The old calling + convention, "by-position-only", is still the default, but this will change in + the future. A warning is printed to the console if you don't explicitly + select a calling convention. See the docs for details. + +### 0.3.0 (2014-06-16) + +- Upgrade to when.js 3, which brings great performance improvements and better + debugging facilities. If you maintain a Mopidy client, you should review the + [differences between when.js 2 and 3](https://github.com/cujojs/when/blob/master/docs/api.md#upgrading-to-30-from-2x) + and the + [when.js debugging guide](https://github.com/cujojs/when/blob/master/docs/api.md#debugging-promises). + +- All promise rejection values are now of the Error type. This ensures that all + JavaScript VMs will show a useful stack trace if a rejected promise's value + is used to throw an exception. To allow catch clauses to handle different + errors differently, server side errors are of the type `Mopidy.ServerError`, + and connection related errors are of the type `Mopidy.ConnectionError`. + ### 0.2.0 (2014-01-04) - **Backwards incompatible change for Node.js users:** diff --git a/js/package.json b/js/package.json index d16cfaa9..b2b63f84 100644 --- a/js/package.json +++ b/js/package.json @@ -1,36 +1,60 @@ { "name": "mopidy", - "version": "0.2.0", + "version": "0.4.0", "description": "Client lib for controlling a Mopidy music server over a WebSocket", + "keywords": [ + "mopidy", + "music", + "client", + "websocket", + "json-rpc" + ], "homepage": "http://www.mopidy.com/", + "bugs": "https://github.com/mopidy/mopidy/issues", + "license": "Apache-2.0", "author": { "name": "Stein Magnus Jodal", "email": "stein.magnus@jodal.no", "url": "http://www.jodal.no" }, + "contributors": [ + { + "name": "Stein Magnus Jodal", + "email": "stein.magnus@jodal.no", + "url": "http://www.jodal.no" + }, + { + "name": "Paul Connolley", + "email": "paul.connolley@gmail.com" + } + ], + "main": "src/mopidy.js", "repository": { "type": "git", "url": "git://github.com/mopidy/mopidy.git" }, - "main": "src/mopidy.js", - "dependencies": { - "bane": "~1.1.0", - "faye-websocket": "~0.7.2", - "when": "~2.7.1" - }, - "devDependencies": { - "buster": "~0.7.8", - "grunt": "~0.4.2", - "grunt-buster": "~0.3.1", - "grunt-browserify": "~1.3.0", - "grunt-contrib-jshint": "~0.8.0", - "grunt-contrib-uglify": "~0.2.7", - "grunt-contrib-watch": "~0.5.3", - "phantomjs": "~1.9.2-6" - }, "scripts": { "test": "grunt test", "build": "grunt build", "start": "grunt watch" + }, + "dependencies": { + "bane": "~1.1.0", + "faye-websocket": "~0.7.2", + "when": "~3.2.3" + }, + "devDependencies": { + "buster": "~0.7.13", + "browserify": "~3", + "grunt": "~0.4.5", + "grunt-buster": "~0.3.1", + "grunt-browserify": "~1.3.2", + "grunt-contrib-jshint": "~0.10.0", + "grunt-contrib-uglify": "~0.5.0", + "grunt-contrib-watch": "~0.6.1", + "phantomjs": "~1.9.7-8" + }, + "engines": { + "node": "*" } } diff --git a/js/src/mopidy.js b/js/src/mopidy.js index 1667f9b1..7e019dd4 100644 --- a/js/src/mopidy.js +++ b/js/src/mopidy.js @@ -9,8 +9,8 @@ function Mopidy(settings) { return new Mopidy(settings); } + this._console = this._getConsole(settings || {}); this._settings = this._configure(settings || {}); - this._console = this._getConsole(); this._backoffDelay = this._settings.backoffDelayMin; this._pendingRequests = {}; @@ -24,13 +24,41 @@ function Mopidy(settings) { } } +Mopidy.ConnectionError = function (message) { + this.name = "ConnectionError"; + this.message = message; +}; +Mopidy.ConnectionError.prototype = new Error(); +Mopidy.ConnectionError.prototype.constructor = Mopidy.ConnectionError; + +Mopidy.ServerError = function (message) { + this.name = "ServerError"; + this.message = message; +}; +Mopidy.ServerError.prototype = new Error(); +Mopidy.ServerError.prototype.constructor = Mopidy.ServerError; + Mopidy.WebSocket = websocket.Client; +Mopidy.prototype._getConsole = function (settings) { + if (typeof settings.console !== "undefined") { + return settings.console; + } + + var con = typeof console !== "undefined" && console || {}; + + con.log = con.log || function () {}; + con.warn = con.warn || function () {}; + con.error = con.error || function () {}; + + return con; +}; + Mopidy.prototype._configure = function (settings) { var currentHost = (typeof document !== "undefined" && document.location.host) || "localhost"; settings.webSocketUrl = settings.webSocketUrl || - "ws://" + currentHost + "/mopidy/ws/"; + "ws://" + currentHost + "/mopidy/ws"; if (settings.autoConnect !== false) { settings.autoConnect = true; @@ -39,19 +67,18 @@ Mopidy.prototype._configure = function (settings) { settings.backoffDelayMin = settings.backoffDelayMin || 1000; settings.backoffDelayMax = settings.backoffDelayMax || 64000; + if (typeof settings.callingConvention === "undefined") { + this._console.warn( + "Mopidy.js is using the default calling convention. The " + + "default will change in the future. You should explicitly " + + "specify which calling convention you use."); + } + settings.callingConvention = ( + settings.callingConvention || "by-position-only"); + return settings; }; -Mopidy.prototype._getConsole = function () { - var console = typeof console !== "undefined" && console || {}; - - console.log = console.log || function () {}; - console.warn = console.warn || function () {}; - console.error = console.error || function () {}; - - return console; -}; - Mopidy.prototype._delegateEvents = function () { // Remove existing event handlers this.off("websocket:close"); @@ -102,10 +129,9 @@ Mopidy.prototype._cleanup = function (closeEvent) { Object.keys(this._pendingRequests).forEach(function (requestId) { var resolver = this._pendingRequests[requestId]; delete this._pendingRequests[requestId]; - resolver.reject({ - message: "WebSocket closed", - closeEvent: closeEvent - }); + var error = new Mopidy.ConnectionError("WebSocket closed"); + error.closeEvent = closeEvent; + resolver.reject(error); }.bind(this)); this.emit("state:offline"); @@ -141,33 +167,25 @@ Mopidy.prototype._handleWebSocketError = function (error) { }; Mopidy.prototype._send = function (message) { - var deferred = when.defer(); - switch (this._webSocket.readyState) { case Mopidy.WebSocket.CONNECTING: - deferred.resolver.reject({ - message: "WebSocket is still connecting" - }); - break; + return when.reject( + new Mopidy.ConnectionError("WebSocket is still connecting")); case Mopidy.WebSocket.CLOSING: - deferred.resolver.reject({ - message: "WebSocket is closing" - }); - break; + return when.reject( + new Mopidy.ConnectionError("WebSocket is closing")); case Mopidy.WebSocket.CLOSED: - deferred.resolver.reject({ - message: "WebSocket is closed" - }); - break; + return when.reject( + new Mopidy.ConnectionError("WebSocket is closed")); default: + var deferred = when.defer(); message.jsonrpc = "2.0"; message.id = this._nextRequestId(); this._pendingRequests[message.id] = deferred.resolver; this._webSocket.send(JSON.stringify(message)); this.emit("websocket:outgoingMessage", message); + return deferred.promise; } - - return deferred.promise; }; Mopidy.prototype._nextRequestId = (function () { @@ -208,19 +226,22 @@ Mopidy.prototype._handleResponse = function (responseMessage) { return; } + var error; var resolver = this._pendingRequests[responseMessage.id]; delete this._pendingRequests[responseMessage.id]; if (responseMessage.hasOwnProperty("result")) { resolver.resolve(responseMessage.result); } else if (responseMessage.hasOwnProperty("error")) { - resolver.reject(responseMessage.error); + error = new Mopidy.ServerError(responseMessage.error.message); + error.code = responseMessage.error.code; + error.data = responseMessage.error.data; + resolver.reject(error); this._console.warn("Server returned error:", responseMessage.error); } else { - resolver.reject({ - message: "Response without 'result' or 'error' received", - data: {response: responseMessage} - }); + error = new Error("Response without 'result' or 'error' received"); + error.data = {response: responseMessage}; + resolver.reject(error); this._console.warn( "Response without 'result' or 'error' received. Message was:", responseMessage); @@ -237,18 +258,36 @@ Mopidy.prototype._handleEvent = function (eventMessage) { Mopidy.prototype._getApiSpec = function () { return this._send({method: "core.describe"}) - .then(this._createApi.bind(this), this._handleWebSocketError) - .then(null, this._handleWebSocketError); + .then(this._createApi.bind(this)) + .catch(this._handleWebSocketError); }; Mopidy.prototype._createApi = function (methods) { + var byPositionOrByName = ( + this._settings.callingConvention === "by-position-or-by-name"); + var caller = function (method) { return function () { - var params = Array.prototype.slice.call(arguments); - return this._send({ - method: method, - params: params - }); + var message = {method: method}; + if (arguments.length === 0) { + return this._send(message); + } + if (!byPositionOrByName) { + message.params = Array.prototype.slice.call(arguments); + return this._send(message); + } + if (arguments.length > 1) { + return when.reject(new Error( + "Expected zero arguments, a single array, " + + "or a single object.")); + } + if (!Array.isArray(arguments[0]) && + arguments[0] !== Object(arguments[0])) { + return when.reject(new TypeError( + "Expected an array or an object.")); + } + message.params = arguments[0]; + return this._send(message); }.bind(this); }.bind(this); diff --git a/js/test/mopidy-test.js b/js/test/mopidy-test.js index 9f2509fc..caf4ce21 100644 --- a/js/test/mopidy-test.js +++ b/js/test/mopidy-test.js @@ -33,7 +33,10 @@ buster.testCase("Mopidy", { close: this.stub(), send: this.stub() }; - this.mopidy = new Mopidy({webSocket: this.webSocket}); + this.mopidy = new Mopidy({ + callingConvention: "by-position-or-by-name", + webSocket: this.webSocket + }); }, tearDown: function () { @@ -42,31 +45,86 @@ buster.testCase("Mopidy", { "constructor": { "connects when autoConnect is true": function () { - new Mopidy({autoConnect: true}); + new Mopidy({ + autoConnect: true, + callingConvention: "by-position-or-by-name" + }); var currentHost = typeof document !== "undefined" && document.location.host || "localhost"; assert.calledOnceWith(this.webSocketConstructorStub, - "ws://" + currentHost + "/mopidy/ws/"); + "ws://" + currentHost + "/mopidy/ws"); }, "does not connect when autoConnect is false": function () { - new Mopidy({autoConnect: false}); + new Mopidy({ + autoConnect: false, + callingConvention: "by-position-or-by-name" + }); refute.called(this.webSocketConstructorStub); }, "does not connect when passed a WebSocket": function () { - new Mopidy({webSocket: {}}); + new Mopidy({ + callingConvention: "by-position-or-by-name", + webSocket: {} + }); refute.called(this.webSocketConstructorStub); }, + "defaults to by-position-only calling convention": function () { + var console = { + warn: function () {} + }; + var mopidy = new Mopidy({ + console: console, + webSocket: this.webSocket, + }); + + assert.equals( + mopidy._settings.callingConvention, + "by-position-only"); + }, + + "warns if no calling convention explicitly selected": function () { + var console = { + warn: function () {} + }; + var stub = this.stub(console, "warn"); + + new Mopidy({console: console}); + + assert.calledOnceWith( + stub, + "Mopidy.js is using the default calling convention. The " + + "default will change in the future. You should explicitly " + + "specify which calling convention you use."); + }, + + "does not warn if calling convention chosen explicitly": function () { + var console = { + warn: function () {} + }; + var stub = this.stub(console, "warn"); + + new Mopidy({ + callingConvention: "by-position-or-by-name", + console: console + }); + + refute.called(stub); + }, + "works without 'new' keyword": function () { var mopidyConstructor = Mopidy; // To trick jshint into submission - var mopidy = mopidyConstructor({webSocket: {}}); + var mopidy = mopidyConstructor({ + callingConvention: "by-position-or-by-name", + webSocket: {} + }); assert.isObject(mopidy); assert(mopidy instanceof Mopidy); @@ -75,7 +133,10 @@ buster.testCase("Mopidy", { ".connect": { "connects when autoConnect is false": function () { - var mopidy = new Mopidy({autoConnect: false}); + var mopidy = new Mopidy({ + autoConnect: false, + callingConvention: "by-position-or-by-name" + }); refute.called(this.webSocketConstructorStub); mopidy.connect(); @@ -84,12 +145,15 @@ buster.testCase("Mopidy", { document.location.host || "localhost"; assert.calledOnceWith(this.webSocketConstructorStub, - "ws://" + currentHost + "/mopidy/ws/"); + "ws://" + currentHost + "/mopidy/ws"); }, "does nothing when the WebSocket is open": function () { this.webSocket.readyState = Mopidy.WebSocket.OPEN; - var mopidy = new Mopidy({webSocket: this.webSocket}); + var mopidy = new Mopidy({ + callingConvention: "by-position-or-by-name", + webSocket: this.webSocket + }); mopidy.connect(); @@ -169,12 +233,18 @@ buster.testCase("Mopidy", { this.mopidy._cleanup(closeEvent); assert.equals(Object.keys(this.mopidy._pendingRequests).length, 0); - when.join(promise1, promise2).then(done(function () { - assert(false, "Promises should be rejected"); - }), done(function (error) { - assert.equals(error.message, "WebSocket closed"); - assert.same(error.closeEvent, closeEvent); - })); + when.settle([promise1, promise2]).done( + done(function (descriptors) { + assert.equals(descriptors.length, 2); + descriptors.forEach(function (d) { + assert.equals(d.state, "rejected"); + assert(d.reason instanceof Error); + assert(d.reason instanceof Mopidy.ConnectionError); + assert.equals(d.reason.message, "WebSocket closed"); + assert.same(d.reason.closeEvent, closeEvent); + }); + }) + ); }, "emits 'state:offline' event when done": function () { @@ -388,12 +458,17 @@ buster.testCase("Mopidy", { var promise = this.mopidy._send({method: "foo"}); refute.called(this.mopidy._webSocket.send); - promise.then(done(function () { - assert(false); - }), done(function (error) { - assert.equals( - error.message, "WebSocket is still connecting"); - })); + promise.done( + done(function () { + assert(false); + }), + done(function (error) { + assert(error instanceof Error); + assert(error instanceof Mopidy.ConnectionError); + assert.equals( + error.message, "WebSocket is still connecting"); + }) + ); }, "immediately rejects request if CLOSING": function (done) { @@ -402,12 +477,16 @@ buster.testCase("Mopidy", { var promise = this.mopidy._send({method: "foo"}); refute.called(this.mopidy._webSocket.send); - promise.then(done(function () { - assert(false); - }), done(function (error) { - assert.equals( - error.message, "WebSocket is closing"); - })); + promise.done( + done(function () { + assert(false); + }), + done(function (error) { + assert(error instanceof Error); + assert(error instanceof Mopidy.ConnectionError); + assert.equals(error.message, "WebSocket is closing"); + }) + ); }, "immediately rejects request if CLOSED": function (done) { @@ -416,12 +495,16 @@ buster.testCase("Mopidy", { var promise = this.mopidy._send({method: "foo"}); refute.called(this.mopidy._webSocket.send); - promise.then(done(function () { - assert(false); - }), done(function (error) { - assert.equals( - error.message, "WebSocket is closed"); - })); + promise.done( + done(function () { + assert(false); + }), + done(function (error) { + assert(error instanceof Error); + assert(error instanceof Mopidy.ConnectionError); + assert.equals(error.message, "WebSocket is closed"); + }) + ); } }, @@ -544,7 +627,11 @@ buster.testCase("Mopidy", { "rejects and logs requests which get errors back": function (done) { var stub = this.stub(this.mopidy._console, "warn"); var promise = this.mopidy._send({method: "bar"}); - var responseError = {message: "Error", data: {}}; + var responseError = { + code: -32601, + message: "Method not found", + data: {} + }; var responseMessage = { jsonrpc: "2.0", id: Object.keys(this.mopidy._pendingRequests)[0], @@ -555,11 +642,49 @@ buster.testCase("Mopidy", { assert.calledOnceWith(stub, "Server returned error:", responseError); - promise.then(done(function () { - assert(false); - }), done(function (error) { - assert.equals(error, responseError); - })); + promise.done( + done(function () { + assert(false); + }), + done(function (error) { + assert(error instanceof Error); + assert.equals(error.code, responseError.code); + assert.equals(error.message, responseError.message); + assert.equals(error.data, responseError.data); + }) + ); + }, + + "rejects and logs requests which get errors without data": function (done) { + var stub = this.stub(this.mopidy._console, "warn"); + var promise = this.mopidy._send({method: "bar"}); + var responseError = { + code: -32601, + message: "Method not found" + // 'data' key intentionally missing + }; + var responseMessage = { + jsonrpc: "2.0", + id: Object.keys(this.mopidy._pendingRequests)[0], + error: responseError + }; + + this.mopidy._handleResponse(responseMessage); + + assert.calledOnceWith(stub, + "Server returned error:", responseError); + promise.done( + done(function () { + assert(false); + }), + done(function (error) { + assert(error instanceof Error); + assert(error instanceof Mopidy.ServerError); + assert.equals(error.code, responseError.code); + assert.equals(error.message, responseError.message); + refute.defined(error.data); + }) + ); }, "rejects and logs responses without result or error": function (done) { @@ -575,14 +700,18 @@ buster.testCase("Mopidy", { assert.calledOnceWith(stub, "Response without 'result' or 'error' received. Message was:", responseMessage); - promise.then(done(function () { - assert(false); - }), done(function (error) { - assert.equals( - error.message, - "Response without 'result' or 'error' received"); - assert.equals(error.data.response, responseMessage); - })); + promise.done( + done(function () { + assert(false); + }), + done(function (error) { + assert(error instanceof Error); + assert.equals( + error.message, + "Response without 'result' or 'error' received"); + assert.equals(error.data.response, responseMessage); + }) + ); } }, @@ -699,6 +828,137 @@ buster.testCase("Mopidy", { this.mopidy._createApi({}); assert.calledOnceWith(spy); + }, + + "by-position-only calling convention": { + setUp: function () { + this.mopidy = new Mopidy({ + webSocket: this.webSocket, + callingConvention: "by-position-only" + }); + this.mopidy._createApi({ + foo: { + params: ["bar", "baz"] + } + }); + this.sendStub = this.stub(this.mopidy, "_send"); + + }, + + "sends no params if no arguments passed to function": function () { + this.mopidy.foo(); + + assert.calledOnceWith(this.sendStub, {method: "foo"}); + }, + + "sends messages with function arguments unchanged": function () { + this.mopidy.foo(31, 97); + + assert.calledOnceWith(this.sendStub, { + method: "foo", + params: [31, 97] + }); + }, + }, + + "by-position-or-by-name calling convention": { + setUp: function () { + this.mopidy = new Mopidy({ + webSocket: this.webSocket, + callingConvention: "by-position-or-by-name" + }); + this.mopidy._createApi({ + foo: { + params: ["bar", "baz"] + } + }); + this.sendStub = this.stub(this.mopidy, "_send"); + }, + + "must be turned on manually": function () { + assert.equals( + this.mopidy._settings.callingConvention, + "by-position-or-by-name"); + }, + + "sends no params if no arguments passed to function": function () { + this.mopidy.foo(); + + assert.calledOnceWith(this.sendStub, {method: "foo"}); + }, + + "sends by-position if argument is a list": function () { + this.mopidy.foo([31, 97]); + + assert.calledOnceWith(this.sendStub, { + method: "foo", + params: [31, 97] + }); + }, + + "sends by-name if argument is an object": function () { + this.mopidy.foo({bar: 31, baz: 97}); + + assert.calledOnceWith(this.sendStub, { + method: "foo", + params: {bar: 31, baz: 97} + }); + }, + + "rejects with error if more than one argument": function (done) { + var promise = this.mopidy.foo([1, 2], {c: 3, d: 4}); + + refute.called(this.sendStub); + + promise.done( + done(function () { + assert(false); + }), + done(function (error) { + assert(error instanceof Error); + assert.equals( + error.message, + "Expected zero arguments, a single array, " + + "or a single object."); + }) + ); + }, + + "rejects with error if string": function (done) { + var promise = this.mopidy.foo("hello"); + + refute.called(this.sendStub); + + promise.done( + done(function () { + assert(false); + }), + done(function (error) { + assert(error instanceof Error); + assert(error instanceof TypeError); + assert.equals( + error.message, "Expected an array or an object."); + }) + ); + }, + + "rejects with error if number": function (done) { + var promise = this.mopidy.foo(1337); + + refute.called(this.sendStub); + + promise.done( + done(function () { + assert(false); + }), + done(function (error) { + assert(error instanceof Error); + assert(error instanceof TypeError); + assert.equals( + error.message, "Expected an array or an object."); + }) + ); + } } } }); diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 1367a219..6018a2ca 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -1,8 +1,8 @@ from __future__ import unicode_literals -from distutils.version import StrictVersion as SV import sys import warnings +from distutils.version import StrictVersion as SV import pykka @@ -21,4 +21,4 @@ if (isinstance(pykka.__version__, basestring) warnings.filterwarnings('ignore', 'could not open display') -__version__ = '0.18.1' +__version__ = '0.19.0' diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 05394bc2..9620b936 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -25,9 +25,8 @@ mopidy_args = sys.argv[1:] sys.argv[1:] = [] -from mopidy import commands, ext -from mopidy import config as config_lib -from mopidy.utils import log, path, process, versioning +from mopidy import commands, config as config_lib, ext +from mopidy.utils import encoding, log, path, process, versioning logger = logging.getLogger(__name__) @@ -74,20 +73,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 +103,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 @@ -142,9 +149,10 @@ def create_file_structures_and_config(args, extensions): default = config_lib.format_initial(extensions) path.get_or_create_file(config_file, mkdir=False, content=default) logger.info('Initialized %s with default config', config_file) - except IOError as e: - logger.warning('Unable to initialize %s with default config: %s', - config_file, e) + except IOError as error: + logger.warning( + 'Unable to initialize %s with default config: %s', + config_file, encoding.locale_decode(error)) def check_old_locations(): @@ -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/audio/__init__.py b/mopidy/audio/__init__.py index d38358c4..fd6d41c9 100644 --- a/mopidy/audio/__init__.py +++ b/mopidy/audio/__init__.py @@ -5,5 +5,6 @@ from .actor import Audio from .dummy import DummyAudio from .listener import AudioListener from .constants import PlaybackState -from .utils import (calculate_duration, create_buffer, millisecond_to_clocktime, - supported_uri_schemes) +from .utils import ( + calculate_duration, create_buffer, millisecond_to_clocktime, + supported_uri_schemes) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 41206c75..d0e18e89 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -1,27 +1,30 @@ from __future__ import unicode_literals -import pygst -pygst.require('0.10') -import gst -import gobject - import logging +import gobject + +import pygst +pygst.require('0.10') +import gst # noqa + import pykka +from mopidy.audio import playlists, utils +from mopidy.audio.constants import PlaybackState +from mopidy.audio.listener import AudioListener from mopidy.utils import process -from . import mixers, playlists, utils -from .constants import PlaybackState -from .listener import AudioListener logger = logging.getLogger(__name__) -mixers.register_mixers() - playlists.register_typefinders() playlists.register_elements() +_GST_STATE_MAPPING = { + gst.STATE_PLAYING: PlaybackState.PLAYING, + gst.STATE_PAUSED: PlaybackState.PAUSED, + gst.STATE_NULL: PlaybackState.STOPPED} MB = 1 << 20 @@ -51,21 +54,18 @@ class Audio(pykka.ThreadingActor): #: The GStreamer state mapped to :class:`mopidy.audio.PlaybackState` state = PlaybackState.STOPPED - def __init__(self, config): + def __init__(self, config, mixer): super(Audio, self).__init__() self._config = config + self._mixer = mixer + self._target_state = gst.STATE_NULL + self._buffering = False self._playbin = None self._signal_ids = {} # {(element, event): signal_id} self._about_to_finish_callback = None - self._mixer = None - self._mixer_track = None - self._mixer_scale = None - self._software_mixing = False - self._volume_set = None - self._appsrc = None self._appsrc_caps = None self._appsrc_need_data_callback = None @@ -76,8 +76,8 @@ class Audio(pykka.ThreadingActor): try: self._setup_playbin() self._setup_output() - self._setup_visualizer() self._setup_mixer() + self._setup_visualizer() self._setup_message_processor() except gobject.GError as ex: logger.exception(ex) @@ -102,8 +102,12 @@ class Audio(pykka.ThreadingActor): playbin = gst.element_factory_make('playbin2') playbin.set_property('flags', PLAYBIN_FLAGS) + playbin.set_property('buffer-size', 2*1024*1024) + playbin.set_property('buffer-duration', 2*gst.SECOND) + self._connect(playbin, 'about-to-finish', self._on_about_to_finish) self._connect(playbin, 'notify::source', self._on_new_source) + self._connect(playbin, 'source-setup', self._on_source_setup) self._playbin = playbin @@ -137,6 +141,22 @@ class Audio(pykka.ThreadingActor): self._appsrc = source + def _on_source_setup(self, element, source): + scheme = 'http' + hostname = self._config['proxy']['hostname'] + port = 80 + + if hasattr(source.props, 'proxy') and hostname: + if self._config['proxy']['port']: + port = self._config['proxy']['port'] + if self._config['proxy']['scheme']: + scheme = self._config['proxy']['scheme'] + + proxy = "%s://%s:%d" % (scheme, hostname, port) + source.set_property('proxy', proxy) + source.set_property('proxy-id', self._config['proxy']['username']) + source.set_property('proxy-pw', self._config['proxy']['password']) + def _appsrc_on_need_data(self, appsrc, gst_length_hint): length_hint = utils.clocktime_to_millisecond(gst_length_hint) if self._appsrc_need_data_callback is not None: @@ -157,6 +177,7 @@ class Audio(pykka.ThreadingActor): def _teardown_playbin(self): self._disconnect(self._playbin, 'about-to-finish') self._disconnect(self._playbin, 'notify::source') + self._disconnect(self._playbin, 'source-setup') self._playbin.set_state(gst.STATE_NULL) def _setup_output(self): @@ -193,6 +214,23 @@ class Audio(pykka.ThreadingActor): logger.info('Audio output set to "%s"', output_desc) self._playbin.set_property('audio-sink', output) + def _setup_mixer(self): + if self._config['audio']['mixer'] != 'software': + return + self._mixer.audio = self.actor_ref.proxy() + self._connect(self._playbin, 'notify::volume', self._on_mixer_change) + self._connect(self._playbin, 'notify::mute', self._on_mixer_change) + + def _on_mixer_change(self, element, gparamspec): + self._mixer.trigger_events_for_changed_values() + + def _teardown_mixer(self): + if self._config['audio']['mixer'] != 'software': + return + self._disconnect(self._playbin, 'notify::volume') + self._disconnect(self._playbin, 'notify::mute') + self._mixer.audio = None + def _setup_visualizer(self): visualizer_element = self._config['audio']['visualizer'] if not visualizer_element: @@ -207,86 +245,6 @@ class Audio(pykka.ThreadingActor): 'Failed to create audio visualizer "%s": %s', visualizer_element, ex) - def _setup_mixer(self): - mixer_desc = self._config['audio']['mixer'] - track_desc = self._config['audio']['mixer_track'] - volume = self._config['audio']['mixer_volume'] - - if mixer_desc is None: - logger.info('Not setting up audio mixer') - return - - if mixer_desc == 'software': - self._software_mixing = True - logger.info('Audio mixer is using software mixing') - if volume is not None: - self.set_volume(volume) - logger.info('Audio mixer volume set to %d', volume) - return - - try: - mixerbin = gst.parse_bin_from_description( - mixer_desc, ghost_unconnected_pads=False) - except gobject.GError as ex: - logger.warning( - 'Failed to create audio mixer "%s": %s', mixer_desc, ex) - return - - # We assume that the bin will contain a single mixer. - mixer = mixerbin.get_by_interface(b'GstMixer') - if not mixer: - logger.warning( - 'Did not find any audio mixers in "%s"', mixer_desc) - return - - if mixerbin.set_state(gst.STATE_READY) != gst.STATE_CHANGE_SUCCESS: - logger.warning( - 'Setting audio mixer "%s" to READY failed', mixer_desc) - return - - track = self._select_mixer_track(mixer, track_desc) - if not track: - logger.warning('Could not find usable audio mixer track') - return - - self._mixer = mixer - self._mixer_track = track - self._mixer_scale = ( - self._mixer_track.min_volume, self._mixer_track.max_volume) - - logger.info( - 'Audio mixer set to "%s" using track "%s"', - str(mixer.get_factory().get_name()).decode('utf-8'), - str(track.label).decode('utf-8')) - - if volume is not None: - self.set_volume(volume) - logger.info('Audio mixer volume set to %d', volume) - - def _select_mixer_track(self, mixer, track_label): - # Ignore tracks without volumes, then look for track with - # label equal to the audio/mixer_track config value, otherwise fallback - # to first usable track hoping the mixer gave them to us in a sensible - # order. - - usable_tracks = [] - for track in mixer.list_tracks(): - if not mixer.get_volume(track): - continue - - if track_label and track.label == track_label: - return track - elif track.flags & (gst.interfaces.MIXER_TRACK_MASTER | - gst.interfaces.MIXER_TRACK_OUTPUT): - usable_tracks.append(track) - - if usable_tracks: - return usable_tracks[0] - - def _teardown_mixer(self): - if self._mixer is not None: - self._mixer.set_state(gst.STATE_NULL) - def _setup_message_processor(self): bus = self._playbin.get_bus() bus.add_signal_watch() @@ -306,35 +264,20 @@ class Audio(pykka.ThreadingActor): AudioListener.send('position_changed', position=position) return True - def _on_message(self, bus, message): - if (message.type == gst.MESSAGE_STATE_CHANGED - and message.src == self._playbin): - old_state, new_state, pending_state = message.parse_state_changed() - self._on_playbin_state_changed(old_state, new_state, pending_state) - elif message.type == gst.MESSAGE_BUFFERING: - percent = message.parse_buffering() - logger.debug('Buffer %d%% full', percent) - elif message.type == gst.MESSAGE_EOS: + def _on_message(self, bus, msg): + if msg.type == gst.MESSAGE_STATE_CHANGED and msg.src == self._playbin: + self._on_playbin_state_changed(*msg.parse_state_changed()) + elif msg.type == gst.MESSAGE_BUFFERING: + self._on_buffering(msg.parse_buffering()) + elif msg.type == gst.MESSAGE_EOS: self._on_end_of_stream() - elif message.type == gst.MESSAGE_ERROR: - error, debug = message.parse_error() - logger.error( - '%s Debug message: %s', - str(error).decode('utf-8'), debug.decode('utf-8') or 'None') - self.stop_playback() - elif message.type == gst.MESSAGE_WARNING: - error, debug = message.parse_warning() - logger.warning( - '%s Debug message: %s', - str(error).decode('utf-8'), debug.decode('utf-8') or 'None') - elif message.type == gst.MESSAGE_ELEMENT: - if message.structure.has_name('playbin2-stream-changed'): - self._on_stream_changed(message) - - def _on_stream_changed(self, message): - uri = message.structure['uri'] - logger.debug('Triggering event: stream_changed(uri=%s)', uri) - AudioListener.send('stream_changed', uri=uri) + elif msg.type == gst.MESSAGE_ERROR: + self._on_error(*msg.parse_error()) + elif msg.type == gst.MESSAGE_WARNING: + self._on_warning(*msg.parse_warning()) + elif msg.type == gst.MESSAGE_ELEMENT: + if msg.structure.has_name('playbin2-stream-changed'): + self._on_stream_changed(msg.structure['uri']) def _on_playbin_state_changed(self, old_state, new_state, pending_state): if new_state == gst.STATE_READY and pending_state == gst.STATE_NULL: @@ -350,27 +293,51 @@ class Audio(pykka.ThreadingActor): if new_state == gst.STATE_READY: return # Ignore READY state as it's GStreamer specific - if new_state == gst.STATE_PLAYING: - new_state = PlaybackState.PLAYING - elif new_state == gst.STATE_PAUSED: - new_state = PlaybackState.PAUSED - elif new_state == gst.STATE_NULL: - new_state = PlaybackState.STOPPED - + new_state = _GST_STATE_MAPPING[new_state] old_state, self.state = self.state, new_state + target_state = _GST_STATE_MAPPING[self._target_state] + if target_state == new_state: + target_state = None + logger.debug( - 'Triggering event: state_changed(old_state=%s, new_state=%s)', - old_state, new_state) - AudioListener.send( - 'state_changed', old_state=old_state, new_state=new_state) + 'Triggering event: state_changed(old_state=%s, new_state=%s, ' + 'target_state=%s)', old_state, new_state, target_state) + AudioListener.send('state_changed', old_state=old_state, + new_state=new_state, target_state=target_state) if new_state == PlaybackState.STOPPED: AudioListener.send('stream_changed', uri=None) + def _on_buffering(self, percent): + if percent < 10 and not self._buffering: + self._playbin.set_state(gst.STATE_PAUSED) + self._buffering = True + if percent == 100: + self._buffering = False + if self._target_state == gst.STATE_PLAYING: + self._playbin.set_state(gst.STATE_PLAYING) + + logger.debug('Buffer %d%% full', percent) + def _on_end_of_stream(self): logger.debug('Triggering event: reached_end_of_stream event') AudioListener.send('reached_end_of_stream') + def _on_error(self, error, debug): + logger.error( + '%s Debug message: %s', + str(error).decode('utf-8'), debug.decode('utf-8') or 'None') + self.stop_playback() + + def _on_warning(self, error, debug): + logger.warning( + '%s Debug message: %s', + str(error).decode('utf-8'), debug.decode('utf-8') or 'None') + + def _on_stream_changed(self, uri): + logger.debug('Triggering event: stream_changed(uri=%s)', uri) + AudioListener.send('stream_changed', uri=uri) + def set_uri(self, uri): """ Set URI of audio to be played. @@ -506,6 +473,7 @@ class Audio(pykka.ThreadingActor): :rtype: :class:`True` if successfull, else :class:`False` """ + self._buffering = False return self._set_state(gst.STATE_NULL) def wait_for_state_change(self): @@ -548,6 +516,7 @@ class Audio(pykka.ThreadingActor): :type state: :class:`gst.State` :rtype: :class:`True` if successfull, else :class:`False` """ + self._target_state = state result = self._playbin.set_state(state) if result == gst.STATE_CHANGE_FAILURE: logger.warning( @@ -564,108 +533,49 @@ class Audio(pykka.ThreadingActor): def get_volume(self): """ - Get volume level of the installed mixer. + Get volume level of the software mixer. Example values: 0: - Muted. + Minimum volume. 100: - Max volume for given system. - :class:`None`: - No mixer present, so the volume is unknown. + Maximum volume. - :rtype: int in range [0..100] or :class:`None` + :rtype: int in range [0..100] """ - if self._software_mixing: - return int(round(self._playbin.get_property('volume') * 100)) - - if self._mixer is None: - return None - - volumes = self._mixer.get_volume(self._mixer_track) - avg_volume = float(sum(volumes)) / len(volumes) - - internal_scale = (0, 100) - - if self._volume_set is not None: - volume_set_on_mixer_scale = self._rescale( - self._volume_set, old=internal_scale, new=self._mixer_scale) - else: - volume_set_on_mixer_scale = None - - if volume_set_on_mixer_scale == avg_volume: - return self._volume_set - else: - return self._rescale( - avg_volume, old=self._mixer_scale, new=internal_scale) + return int(round(self._playbin.get_property('volume') * 100)) def set_volume(self, volume): """ - Set volume level of the installed mixer. + Set volume level of the software mixer. :param volume: the volume in the range [0..100] :type volume: int :rtype: :class:`True` if successful, else :class:`False` """ - if self._software_mixing: - self._playbin.set_property('volume', volume / 100.0) - return True - - if self._mixer is None: - return False - - self._volume_set = volume - - internal_scale = (0, 100) - - volume = self._rescale( - volume, old=internal_scale, new=self._mixer_scale) - - volumes = (volume,) * self._mixer_track.num_channels - self._mixer.set_volume(self._mixer_track, volumes) - - return self._mixer.get_volume(self._mixer_track) == volumes - - def _rescale(self, value, old=None, new=None): - """Convert value between scales.""" - new_min, new_max = new - old_min, old_max = old - if old_min == old_max: - return old_max - scaling = float(new_max - new_min) / (old_max - old_min) - return int(round(scaling * (value - old_min) + new_min)) + self._playbin.set_property('volume', volume / 100.0) + return True def get_mute(self): """ - Get mute status of the installed mixer. + Get mute status of the software mixer. :rtype: :class:`True` if muted, :class:`False` if unmuted, :class:`None` if no mixer is installed. """ - if self._software_mixing: - return self._playbin.get_property('mute') - - if self._mixer_track is None: - return None - - return bool(self._mixer_track.flags & gst.interfaces.MIXER_TRACK_MUTE) + return self._playbin.get_property('mute') def set_mute(self, mute): """ - Mute or unmute of the installed mixer. + Mute or unmute of the software mixer. - :param mute: Wether to mute the mixer or not. + :param mute: Whether to mute the mixer or not. :type mute: bool :rtype: :class:`True` if successful, else :class:`False` """ - if self._software_mixing: - return self._playbin.set_property('mute', bool(mute)) - - if self._mixer_track is None: - return False - - return self._mixer.set_mute(self._mixer_track, bool(mute)) + self._playbin.set_property('mute', bool(mute)) + return True def set_metadata(self, track): """ diff --git a/mopidy/audio/dummy.py b/mopidy/audio/dummy.py index ee7e73b7..fe749888 100644 --- a/mopidy/audio/dummy.py +++ b/mopidy/audio/dummy.py @@ -13,7 +13,7 @@ from .listener import AudioListener class DummyAudio(pykka.ThreadingActor): - def __init__(self, config=None): + def __init__(self, config=None, mixer=None): super(DummyAudio, self).__init__() self.state = PlaybackState.STOPPED self._volume = 0 @@ -88,8 +88,8 @@ class DummyAudio(pykka.ThreadingActor): AudioListener.send('stream_changed', uri=self._uri) old_state, self.state = self.state, new_state - AudioListener.send( - 'state_changed', old_state=old_state, new_state=new_state) + AudioListener.send('state_changed', old_state=old_state, + new_state=new_state, target_state=None) return self._state_change_result diff --git a/mopidy/audio/listener.py b/mopidy/audio/listener.py index 5b33ffe6..11613149 100644 --- a/mopidy/audio/listener.py +++ b/mopidy/audio/listener.py @@ -47,17 +47,31 @@ class AudioListener(listener.Listener): """ pass - def state_changed(self, old_state, new_state): + def state_changed(self, old_state, new_state, target_state): """ Called after the playback state have changed. Will be called for both immediate and async state changes in GStreamer. + Target state is used to when we should be in the target state, but + temporarily need to switch to an other state. A typical example of this + is buffering. When this happens an event with + `old=PLAYING, new=PAUSED, target=PLAYING` will be emitted. Once we have + caught up a `old=PAUSED, new=PLAYING, target=None` event will be + be generated. + + Regular state changes will not have target state set as they are final + states which should be stable. + *MAY* be implemented by actor. :param old_state: the state before the change :type old_state: string from :class:`mopidy.core.PlaybackState` field :param new_state: the state after the change + :type new_state: A :class:`mopidy.core.PlaybackState` field :type new_state: string from :class:`mopidy.core.PlaybackState` field + :param target_state: the intended state + :type target_state: string from :class:`mopidy.core.PlaybackState` + field or :class:`None` if this is a final state. """ pass diff --git a/mopidy/audio/mixers/__init__.py b/mopidy/audio/mixers/__init__.py deleted file mode 100644 index 9b14974f..00000000 --- a/mopidy/audio/mixers/__init__.py +++ /dev/null @@ -1,20 +0,0 @@ -from __future__ import unicode_literals - -import pygst -pygst.require('0.10') -import gst -import gobject - -from .auto import AutoAudioMixer -from .fake import FakeMixer - - -def register_mixer(mixer_class): - gobject.type_register(mixer_class) - gst.element_register( - mixer_class, mixer_class.__name__.lower(), gst.RANK_MARGINAL) - - -def register_mixers(): - register_mixer(AutoAudioMixer) - register_mixer(FakeMixer) diff --git a/mopidy/audio/mixers/auto.py b/mopidy/audio/mixers/auto.py deleted file mode 100644 index 7e59b602..00000000 --- a/mopidy/audio/mixers/auto.py +++ /dev/null @@ -1,76 +0,0 @@ -"""Mixer element that automatically selects the real mixer to use. - -Set the :confval:`audio/mixer` config value to ``autoaudiomixer`` to use this -mixer. -""" - -from __future__ import unicode_literals - -import pygst -pygst.require('0.10') -import gst - -import logging - -logger = logging.getLogger(__name__) - - -# TODO: we might want to add some ranking to the mixers we know about? -class AutoAudioMixer(gst.Bin): - __gstdetails__ = ( - 'AutoAudioMixer', - 'Mixer', - 'Element automatically selects a mixer.', - 'Mopidy') - - def __init__(self): - gst.Bin.__init__(self) - mixer = self._find_mixer() - if mixer: - self.add(mixer) - logger.debug('AutoAudioMixer chose: %s', mixer.get_name()) - else: - logger.debug('AutoAudioMixer did not find any usable mixers') - - def _find_mixer(self): - registry = gst.registry_get_default() - - factories = registry.get_feature_list(gst.TYPE_ELEMENT_FACTORY) - factories.sort(key=lambda f: (-f.get_rank(), f.get_name())) - - for factory in factories: - # Avoid sink/srcs that implement mixing. - if factory.get_klass() != 'Generic/Audio': - continue - # Avoid anything that doesn't implement mixing. - elif not factory.has_interface('GstMixer'): - continue - - if self._test_mixer(factory): - return factory.create() - - return None - - def _test_mixer(self, factory): - element = factory.create() - if not element: - return False - - try: - result = element.set_state(gst.STATE_READY) - if result != gst.STATE_CHANGE_SUCCESS: - return False - - # Trust that the default device is sane and just check tracks. - return self._test_tracks(element) - finally: - element.set_state(gst.STATE_NULL) - - def _test_tracks(self, element): - # Only allow elements that have a least one output track. - flags = gst.interfaces.MIXER_TRACK_OUTPUT - - for track in element.list_tracks(): - if track.flags & flags: - return True - return False diff --git a/mopidy/audio/mixers/fake.py b/mopidy/audio/mixers/fake.py deleted file mode 100644 index 7daea6a1..00000000 --- a/mopidy/audio/mixers/fake.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Fake mixer for use in tests. - -Set the :confval:`audio/mixer:` config value to ``fakemixer`` to use this -mixer. -""" - -from __future__ import unicode_literals - -import pygst -pygst.require('0.10') -import gobject -import gst - -from . import utils - - -class FakeMixer(gst.Element, gst.ImplementsInterface, gst.interfaces.Mixer): - __gstdetails__ = ( - 'FakeMixer', - 'Mixer', - 'Fake mixer for use in tests.', - 'Mopidy') - - track_label = gobject.property(type=str, default='Master') - track_initial_volume = gobject.property(type=int, default=0) - track_min_volume = gobject.property(type=int, default=0) - track_max_volume = gobject.property(type=int, default=100) - track_num_channels = gobject.property(type=int, default=2) - track_flags = gobject.property(type=int, default=( - gst.interfaces.MIXER_TRACK_MASTER | gst.interfaces.MIXER_TRACK_OUTPUT)) - - def list_tracks(self): - track = utils.create_track( - self.track_label, - self.track_initial_volume, - self.track_min_volume, - self.track_max_volume, - self.track_num_channels, - self.track_flags) - return [track] - - def get_volume(self, track): - return track.volumes - - def set_volume(self, track, volumes): - track.volumes = volumes - - def set_record(self, track, record): - pass diff --git a/mopidy/audio/mixers/utils.py b/mopidy/audio/mixers/utils.py deleted file mode 100644 index 8d0ce280..00000000 --- a/mopidy/audio/mixers/utils.py +++ /dev/null @@ -1,37 +0,0 @@ -from __future__ import unicode_literals - -import pygst -pygst.require('0.10') -import gst -import gobject - - -def create_track(label, initial_volume, min_volume, max_volume, - num_channels, flags): - - class Track(gst.interfaces.MixerTrack): - def __init__(self): - super(Track, self).__init__() - self.volumes = (initial_volume,) * self.num_channels - - @gobject.property - def label(self): - return label - - @gobject.property - def min_volume(self): - return min_volume - - @gobject.property - def max_volume(self): - return max_volume - - @gobject.property - def num_channels(self): - return num_channels - - @gobject.property - def flags(self): - return flags - - return Track() diff --git a/mopidy/audio/playlists.py b/mopidy/audio/playlists.py index e3f51e41..35e0800d 100644 --- a/mopidy/audio/playlists.py +++ b/mopidy/audio/playlists.py @@ -1,13 +1,14 @@ from __future__ import unicode_literals -import pygst -pygst.require('0.10') -import gst -import gobject - import ConfigParser as configparser import io +import gobject + +import pygst +pygst.require('0.10') +import gst # noqa + try: import xml.etree.cElementTree as elementtree except ImportError: @@ -17,16 +18,16 @@ except ImportError: # TODO: make detect_FOO_header reusable in general mopidy code. # i.e. give it just a "peek" like function. def detect_m3u_header(typefind): - return typefind.peek(0, 8) == b'#EXTM3U\n' + return typefind.peek(0, 7).upper() == b'#EXTM3U' def detect_pls_header(typefind): - return typefind.peek(0, 11).lower() == b'[playlist]\n' + return typefind.peek(0, 10).lower() == b'[playlist]' def detect_xspf_header(typefind): data = typefind.peek(0, 150) - if b'xspf' not in data: + if b'xspf' not in data.lower(): return False try: @@ -40,7 +41,7 @@ def detect_xspf_header(typefind): def detect_asx_header(typefind): data = typefind.peek(0, 50) - if b'asx' not in data: + if b'asx' not in data.lower(): return False try: @@ -81,6 +82,7 @@ def parse_pls(data): def parse_xspf(data): try: + # Last element will be root. for event, element in elementtree.iterparse(data): element.tag = element.tag.lower() # normalize except elementtree.ParseError: @@ -93,14 +95,18 @@ def parse_xspf(data): def parse_asx(data): try: + # Last element will be root. for event, element in elementtree.iterparse(data): element.tag = element.tag.lower() # normalize except elementtree.ParseError: return - for ref in element.findall('entry/ref'): + for ref in element.findall('entry/ref[@href]'): yield ref.get('href', '').strip() + for entry in element.findall('entry[@href]'): + yield entry.get('href', '').strip() + def parse_urilist(data): for line in data.readlines(): diff --git a/mopidy/audio/scan.py b/mopidy/audio/scan.py index 56f385e3..e872d88c 100644 --- a/mopidy/audio/scan.py +++ b/mopidy/audio/scan.py @@ -1,16 +1,16 @@ from __future__ import unicode_literals -import pygst -pygst.require('0.10') -import gst - import datetime import os import time +import pygst +pygst.require('0.10') +import gst # noqa + from mopidy import exceptions -from mopidy.models import Track, Artist, Album -from mopidy.utils import path +from mopidy.models import Album, Artist, Track +from mopidy.utils import encoding, path class Scanner(object): @@ -90,7 +90,8 @@ class Scanner(object): message = self._bus.pop() if message.type == gst.MESSAGE_ERROR: - raise exceptions.ScannerError(message.parse_error()[0]) + raise exceptions.ScannerError( + encoding.locale_decode(message.parse_error()[0])) elif message.type == gst.MESSAGE_EOS: return tags elif message.type == gst.MESSAGE_ASYNC_DONE: diff --git a/mopidy/audio/utils.py b/mopidy/audio/utils.py index 15196b20..f9036748 100644 --- a/mopidy/audio/utils.py +++ b/mopidy/audio/utils.py @@ -2,7 +2,7 @@ from __future__ import unicode_literals import pygst pygst.require('0.10') -import gst +import gst # noqa def calculate_duration(num_samples, sample_rate): diff --git a/mopidy/backend/__init__.py b/mopidy/backend/__init__.py index 6f895985..317cf762 100644 --- a/mopidy/backend/__init__.py +++ b/mopidy/backend/__init__.py @@ -6,6 +6,19 @@ from mopidy import listener class Backend(object): + """Backend API + + If the backend has problems during initialization it should raise + :exc:`mopidy.exceptions.BackendError` with a descriptive error message. + This will make Mopidy print the error message and exit so that the user can + fix the issue. + + :param config: the entire Mopidy configuration + :type config: dict + :param audio: actor proxy for the audio subsystem + :type audio: :class:`pykka.ActorProxy` for :class:`mopidy.audio.Audio` + """ + #: Actor proxy to an instance of :class:`mopidy.audio.Audio`. #: #: Should be passed to the backend constructor as the kwarg ``audio``, diff --git a/mopidy/backends/__init__.py b/mopidy/backends/__init__.py deleted file mode 100644 index baffc488..00000000 --- a/mopidy/backends/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from __future__ import unicode_literals diff --git a/mopidy/backends/base.py b/mopidy/backends/base.py deleted file mode 100644 index aed6ce3e..00000000 --- a/mopidy/backends/base.py +++ /dev/null @@ -1,17 +0,0 @@ -from __future__ import unicode_literals - -from mopidy.backend import ( - Backend, - LibraryProvider as BaseLibraryProvider, - PlaybackProvider as BasePlaybackProvider, - PlaylistsProvider as BasePlaylistsProvider) - - -# Make classes previously residing here available in the old location for -# backwards compatibility with extensions targeting Mopidy < 0.18. -__all__ = [ - 'Backend', - 'BaseLibraryProvider', - 'BasePlaybackProvider', - 'BasePlaylistsProvider', -] diff --git a/mopidy/backends/dummy.py b/mopidy/backends/dummy.py deleted file mode 100644 index 7c13c9b1..00000000 --- a/mopidy/backends/dummy.py +++ /dev/null @@ -1,5 +0,0 @@ -from __future__ import unicode_literals - -# Make classes previously residing here available in the old location for -# backwards compatibility with extensions targeting Mopidy < 0.18. -from mopidy.backend.dummy import * # noqa diff --git a/mopidy/backends/listener.py b/mopidy/backends/listener.py deleted file mode 100644 index 0b551f26..00000000 --- a/mopidy/backends/listener.py +++ /dev/null @@ -1,8 +0,0 @@ -from __future__ import unicode_literals - -from mopidy.backend import BackendListener - - -# Make classes previously residing here available in the old location for -# backwards compatibility with extensions targeting Mopidy < 0.18. -__all__ = ['BackendListener'] diff --git a/mopidy/commands.py b/mopidy/commands.py index ad20c47f..e43f182e 100644 --- a/mopidy/commands.py +++ b/mopidy/commands.py @@ -7,9 +7,10 @@ import os import sys import glib + import gobject -from mopidy import config as config_lib +from mopidy import config as config_lib, exceptions from mopidy.audio import Audio from mopidy.core import Core from mopidy.utils import deps, process, versioning @@ -99,7 +100,7 @@ class Command(object): self._children[name] = command def add_argument(self, *args, **kwargs): - """Add am argument to the parser. + """Add an argument to the parser. This method takes all the same arguments as the :class:`argparse.ArgumentParser` version of this method. @@ -260,29 +261,70 @@ class RootCommand(Command): def run(self, args, config): loop = gobject.MainLoop() + mixer_class = self.get_mixer_class(config, args.registry['mixer']) backend_classes = args.registry['backend'] frontend_classes = args.registry['frontend'] try: - audio = self.start_audio(config) + mixer = self.start_mixer(config, mixer_class) + audio = self.start_audio(config, mixer) backends = self.start_backends(config, backend_classes, audio) - core = self.start_core(audio, backends) + core = self.start_core(mixer, backends) self.start_frontends(config, frontend_classes, core) loop.run() + except (exceptions.BackendError, + exceptions.FrontendError, + exceptions.MixerError): + logger.info('Initialization error. Exiting...') except KeyboardInterrupt: logger.info('Interrupted. Exiting...') - return finally: loop.quit() self.stop_frontends(frontend_classes) self.stop_core() self.stop_backends(backend_classes) self.stop_audio() + self.stop_mixer(mixer_class) process.stop_remaining_actors() - def start_audio(self, config): + def get_mixer_class(self, config, mixer_classes): + logger.debug( + 'Available Mopidy mixers: %s', + ', '.join(m.__name__ for m in mixer_classes) or 'none') + + selected_mixers = [ + m for m in mixer_classes if m.name == config['audio']['mixer']] + if len(selected_mixers) != 1: + logger.error( + 'Did not find unique mixer "%s". Alternatives are: %s', + config['audio']['mixer'], + ', '.join([m.name for m in mixer_classes])) + process.exit_process() + return selected_mixers[0] + + def start_mixer(self, config, mixer_class): + try: + logger.info('Starting Mopidy mixer: %s', mixer_class.__name__) + mixer = mixer_class.start(config=config).proxy() + self.configure_mixer(config, mixer) + return mixer + except exceptions.MixerError as exc: + logger.error( + 'Mixer (%s) initialization error: %s', + mixer_class.__name__, exc.message) + raise + + def configure_mixer(self, config, mixer): + volume = config['audio']['mixer_volume'] + if volume is not None: + mixer.set_volume(volume) + logger.info('Mixer volume set to %d', volume) + else: + logger.debug('Mixer volume left unchanged') + + def start_audio(self, config, mixer): logger.info('Starting Mopidy audio') - return Audio.start(config=config).proxy() + return Audio.start(config=config, mixer=mixer).proxy() def start_backends(self, config, backend_classes, audio): logger.info( @@ -291,14 +333,21 @@ class RootCommand(Command): backends = [] for backend_class in backend_classes: - backend = backend_class.start(config=config, audio=audio).proxy() - backends.append(backend) + try: + backend = backend_class.start( + config=config, audio=audio).proxy() + backends.append(backend) + except exceptions.BackendError as exc: + logger.error( + 'Backend (%s) initialization error: %s', + backend_class.__name__, exc.message) + raise return backends - def start_core(self, audio, backends): + def start_core(self, mixer, backends): logger.info('Starting Mopidy core') - return Core.start(audio=audio, backends=backends).proxy() + return Core.start(mixer=mixer, backends=backends).proxy() def start_frontends(self, config, frontend_classes, core): logger.info( @@ -306,7 +355,13 @@ class RootCommand(Command): ', '.join(f.__name__ for f in frontend_classes) or 'none') for frontend_class in frontend_classes: - frontend_class.start(config=config, core=core) + try: + frontend_class.start(config=config, core=core) + except exceptions.FrontendError as exc: + logger.error( + 'Frontend (%s) initialization error: %s', + frontend_class.__name__, exc.message) + raise def stop_frontends(self, frontend_classes): logger.info('Stopping Mopidy frontends') @@ -326,6 +381,10 @@ class RootCommand(Command): logger.info('Stopping Mopidy audio') process.stop_actors_by_class(Audio) + def stop_mixer(self, mixer_class): + logger.info('Stopping Mopidy mixer') + process.stop_actors_by_class(mixer_class) + class ConfigCommand(Command): help = 'Show currently active configuration.' diff --git a/mopidy/config/__init__.py b/mopidy/config/__init__.py index 2b740549..3b63a1ab 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -15,6 +15,7 @@ from mopidy.utils import path, versioning logger = logging.getLogger(__name__) _logging_schema = ConfigSchema('logging') +_logging_schema['color'] = Boolean() _logging_schema['console_format'] = String() _logging_schema['debug_format'] = String() _logging_schema['debug_file'] = Path() @@ -24,7 +25,7 @@ _loglevels_schema = LogLevelConfigSchema('loglevels') _audio_schema = ConfigSchema('audio') _audio_schema['mixer'] = String() -_audio_schema['mixer_track'] = String(optional=True) +_audio_schema['mixer_track'] = Deprecated() _audio_schema['mixer_volume'] = Integer(optional=True, minimum=0, maximum=100) _audio_schema['output'] = String() _audio_schema['visualizer'] = String(optional=True) @@ -38,7 +39,7 @@ _proxy_schema['username'] = String(optional=True) _proxy_schema['password'] = Secret(optional=True) # NOTE: if multiple outputs ever comes something like LogLevelConfigSchema -#_outputs_schema = config.AudioOutputConfigSchema() +# _outputs_schema = config.AudioOutputConfigSchema() _schemas = [_logging_schema, _loglevels_schema, _audio_schema, _proxy_schema] diff --git a/mopidy/config/convert.py b/mopidy/config/convert.py deleted file mode 100644 index a3ae5273..00000000 --- a/mopidy/config/convert.py +++ /dev/null @@ -1,126 +0,0 @@ -from __future__ import print_function, unicode_literals - -import io -import os.path -import sys - -from mopidy import config as config_lib, ext -from mopidy.utils import path - - -def load(): - settings_file = path.expand_path(b'$XDG_CONFIG_DIR/mopidy/settings.py') - print('Checking %s' % settings_file) - - setting_globals = {} - try: - execfile(settings_file, setting_globals) - except Exception as e: - print('Problem loading settings: %s' % e) - return setting_globals - - -def convert(settings): - config = {} - - def helper(confval, setting_name): - if settings.get(setting_name) is not None: - section, key = confval.split('/') - config.setdefault(section, {})[key] = settings[setting_name] - - # Perform all the simple mappings using our helper: - - helper('logging/console_format', 'CONSOLE_LOG_FORMAT') - helper('logging/debug_format', 'DEBUG_LOG_FORMAT') - helper('logging/debug_file', 'DEBUG_LOG_FILENAME') - - helper('audio/mixer', 'MIXER') - helper('audio/mixer_track', 'MIXER_TRACK') - helper('audio/mixer_volume', 'MIXER_VOLUME') - helper('audio/output', 'OUTPUT') - - helper('proxy/hostname', 'SPOTIFY_PROXY_HOST') - helper('proxy/port', 'SPOTIFY_PROXY_PORT') - helper('proxy/username', 'SPOTIFY_PROXY_USERNAME') - helper('proxy/password', 'SPOTIFY_PROXY_PASSWORD') - - helper('local/media_dir', 'LOCAL_MUSIC_PATH') - helper('local/playlists_dir', 'LOCAL_PLAYLIST_PATH') - - helper('spotify/username', 'SPOTIFY_USERNAME') - helper('spotify/password', 'SPOTIFY_PASSWORD') - helper('spotify/bitrate', 'SPOTIFY_BITRATE') - helper('spotify/timeout', 'SPOTIFY_TIMEOUT') - helper('spotify/cache_dir', 'SPOTIFY_CACHE_PATH') - - helper('stream/protocols', 'STREAM_PROTOCOLS') - - helper('http/hostname', 'HTTP_SERVER_HOSTNAME') - helper('http/port', 'HTTP_SERVER_PORT') - helper('http/static_dir', 'HTTP_SERVER_STATIC_DIR') - - helper('mpd/hostname', 'MPD_SERVER_HOSTNAME') - helper('mpd/port', 'MPD_SERVER_PORT') - helper('mpd/password', 'MPD_SERVER_PASSWORD') - helper('mpd/max_connections', 'MPD_SERVER_MAX_CONNECTIONS') - helper('mpd/connection_timeout', 'MPD_SERVER_CONNECTION_TIMEOUT') - - helper('mpris/desktop_file', 'DESKTOP_FILE') - - helper('scrobbler/username', 'LASTFM_USERNAME') - helper('scrobbler/password', 'LASTFM_PASSWORD') - - # Assume FRONTENDS/BACKENDS = None implies all enabled, otherwise disable - # if our module path is missing from the setting. - - frontends = settings.get('FRONTENDS') - if frontends is not None: - if 'mopidy.frontends.http.HttpFrontend' not in frontends: - config.setdefault('http', {})['enabled'] = False - if 'mopidy.frontends.mpd.MpdFrontend' not in frontends: - config.setdefault('mpd', {})['enabled'] = False - if 'mopidy.frontends.lastfm.LastfmFrontend' not in frontends: - config.setdefault('scrobbler', {})['enabled'] = False - if 'mopidy.frontends.mpris.MprisFrontend' not in frontends: - config.setdefault('mpris', {})['enabled'] = False - - backends = settings.get('BACKENDS') - if backends is not None: - if 'mopidy.backends.local.LocalBackend' not in backends: - config.setdefault('local', {})['enabled'] = False - if 'mopidy.backends.spotify.SpotifyBackend' not in backends: - config.setdefault('spotify', {})['enabled'] = False - if 'mopidy.backends.stream.StreamBackend' not in backends: - config.setdefault('stream', {})['enabled'] = False - - return config - - -def main(): - settings = load() - if not settings: - return - - config = convert(settings) - - known = [ - 'spotify', 'scrobbler', 'mpd', 'mpris', 'local', 'stream', 'http'] - extensions = [e for e in ext.load_extensions() if e.ext_name in known] - - print(b'Converted config:\n') - print(config_lib.format(config, extensions)) - - conf_file = path.expand_path(b'$XDG_CONFIG_DIR/mopidy/mopidy.conf') - if os.path.exists(conf_file): - print('%s exists, exiting.' % conf_file) - sys.exit(1) - - print('Write new config to %s? [yN]' % conf_file, end=' ') - if raw_input() != 'y': - print('Not saving, exiting.') - sys.exit(0) - - serialized_config = config_lib.format(config, extensions, display=False) - with io.open(conf_file, 'wb') as filehandle: - filehandle.write(serialized_config) - print('Done.') diff --git a/mopidy/config/default.conf b/mopidy/config/default.conf index 839c983d..6a900cf9 100644 --- a/mopidy/config/default.conf +++ b/mopidy/config/default.conf @@ -1,4 +1,5 @@ [logging] +color = true console_format = %(levelname)-8s %(message)s debug_format = %(levelname)-8s %(asctime)s [%(process)d:%(threadName)s] %(name)s\n %(message)s debug_file = mopidy.log @@ -6,7 +7,6 @@ config_file = [audio] mixer = software -mixer_track = mixer_volume = output = autoaudiosink visualizer = 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/config/types.py b/mopidy/config/types.py index 6aeaaaa7..4498cb67 100644 --- a/mopidy/config/types.py +++ b/mopidy/config/types.py @@ -4,8 +4,8 @@ import logging import re import socket -from mopidy.utils import path from mopidy.config import validators +from mopidy.utils import path def decode(value): @@ -151,7 +151,13 @@ class Boolean(ConfigValue): true_values = ('1', 'yes', 'true', 'on') false_values = ('0', 'no', 'false', 'off') + def __init__(self, optional=False): + self._required = not optional + def deserialize(self, value): + validators.validate_required(value, self._required) + if not value: + return None if value.lower() in self.true_values: return True elif value.lower() in self.false_values: @@ -185,6 +191,8 @@ class List(ConfigValue): return tuple(values) def serialize(self, value, display=False): + if not value: + return b'' return b'\n ' + b'\n '.join(encode(v) for v in value if v) diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index b27bb3cc..66f2aa82 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -5,18 +5,20 @@ import itertools import pykka -from mopidy import audio, backend +from mopidy import audio, backend, mixer from mopidy.audio import PlaybackState +from mopidy.core.library import LibraryController +from mopidy.core.listener import CoreListener +from mopidy.core.playback import PlaybackController +from mopidy.core.playlists import PlaylistsController +from mopidy.core.tracklist import TracklistController from mopidy.utils import versioning -from .library import LibraryController -from .listener import CoreListener -from .playback import PlaybackController -from .playlists import PlaylistsController -from .tracklist import TracklistController +class Core( + pykka.ThreadingActor, audio.AudioListener, backend.BackendListener, + mixer.MixerListener): -class Core(pykka.ThreadingActor, audio.AudioListener, backend.BackendListener): library = None """The library controller. An instance of :class:`mopidy.core.LibraryController`.""" @@ -33,7 +35,7 @@ class Core(pykka.ThreadingActor, audio.AudioListener, backend.BackendListener): """The tracklist controller. An instance of :class:`mopidy.core.TracklistController`.""" - def __init__(self, audio=None, backends=None): + def __init__(self, mixer=None, backends=None): super(Core, self).__init__() self.backends = Backends(backends) @@ -41,7 +43,7 @@ class Core(pykka.ThreadingActor, audio.AudioListener, backend.BackendListener): self.library = LibraryController(backends=self.backends, core=self) self.playback = PlaybackController( - audio=audio, backends=self.backends, core=self) + mixer=mixer, backends=self.backends, core=self) self.playlists = PlaylistsController( backends=self.backends, core=self) @@ -66,14 +68,17 @@ class Core(pykka.ThreadingActor, audio.AudioListener, backend.BackendListener): def reached_end_of_stream(self): self.playback.on_end_of_track() - def state_changed(self, old_state, new_state): + def state_changed(self, old_state, new_state, target_state): # XXX: This is a temporary fix for issue #232 while we wait for a more # permanent solution with the implementation of issue #234. When the # Spotify play token is lost, the Spotify backend pauses audio # playback, but mopidy.core doesn't know this, so we need to update # mopidy.core's state to match the actual state in mopidy.audio. If we # don't do this, clients will think that we're still playing. - if (new_state == PlaybackState.PAUSED + + # We ignore cases when target state is set as this is buffering + # updates (at least for now) and we need to get #234 fixed... + if (new_state == PlaybackState.PAUSED and not target_state and self.playback.state != PlaybackState.PAUSED): self.playback.state = new_state self.playback._trigger_track_playback_paused() @@ -82,6 +87,14 @@ class Core(pykka.ThreadingActor, audio.AudioListener, backend.BackendListener): # Forward event from backend to frontends CoreListener.send('playlists_loaded') + def volume_changed(self, volume): + # Forward event from mixer to frontends + CoreListener.send('volume_changed', volume=volume) + + def mute_changed(self, mute): + # Forward event from mixer to frontends + CoreListener.send('mute_changed', mute=mute) + class Backends(list): def __init__(self, backends): @@ -93,26 +106,26 @@ class Backends(list): self.with_playlists = collections.OrderedDict() backends_by_scheme = {} - name = lambda backend: backend.actor_ref.actor_class.__name__ + name = lambda b: b.actor_ref.actor_class.__name__ - for backend in backends: - has_library = backend.has_library().get() - has_library_browse = backend.has_library_browse().get() - has_playback = backend.has_playback().get() - has_playlists = backend.has_playlists().get() + for b in backends: + has_library = b.has_library().get() + has_library_browse = b.has_library_browse().get() + has_playback = b.has_playback().get() + has_playlists = b.has_playlists().get() - for scheme in backend.uri_schemes.get(): + for scheme in b.uri_schemes.get(): assert scheme not in backends_by_scheme, ( 'Cannot add URI scheme %s for %s, ' 'it is already handled by %s' - ) % (scheme, name(backend), name(backends_by_scheme[scheme])) - backends_by_scheme[scheme] = backend + ) % (scheme, name(b), name(backends_by_scheme[scheme])) + backends_by_scheme[scheme] = b if has_library: - self.with_library[scheme] = backend + self.with_library[scheme] = b if has_library_browse: - self.with_library_browse[scheme] = backend + self.with_library_browse[scheme] = b if has_playback: - self.with_playback[scheme] = backend + self.with_playback[scheme] = b if has_playlists: - self.with_playlists[scheme] = backend + self.with_playlists[scheme] = b diff --git a/mopidy/core/library.py b/mopidy/core/library.py index 1ff4e874..50d7df19 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -1,6 +1,7 @@ from __future__ import unicode_literals import collections +import operator import urlparse import pykka @@ -62,7 +63,8 @@ class LibraryController(object): """ if uri is None: backends = self.backends.with_library_browse.values() - return [b.library.root_directory.get() for b in backends] + unique_dirs = {b.library.root_directory.get() for b in backends} + return sorted(unique_dirs, key=operator.attrgetter('name')) scheme = urlparse.urlparse(uri).scheme backend = self.backends.with_library_browse.get(scheme) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 5f296c89..df48422d 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -4,8 +4,7 @@ import logging import urlparse from mopidy.audio import PlaybackState - -from . import listener +from mopidy.core import listener logger = logging.getLogger(__name__) @@ -15,8 +14,8 @@ logger = logging.getLogger(__name__) class PlaybackController(object): pykka_traversable = True - def __init__(self, audio, backends, core): - self.audio = audio + def __init__(self, mixer, backends, core): + self.mixer = mixer self.backends = backends self.core = core @@ -32,7 +31,7 @@ class PlaybackController(object): uri_scheme = urlparse.urlparse(uri).scheme return self.backends.with_playback.get(uri_scheme, None) - ### Properties + # Properties def get_current_tl_track(self): return self.current_tl_track @@ -91,45 +90,43 @@ class PlaybackController(object): """Time position in milliseconds.""" def get_volume(self): - if self.audio: - return self.audio.get_volume().get() + if self.mixer: + return self.mixer.get_volume().get() else: # For testing return self._volume def set_volume(self, volume): - if self.audio: - self.audio.set_volume(volume) + if self.mixer: + self.mixer.set_volume(volume) else: # For testing self._volume = volume - self._trigger_volume_changed(volume) - volume = property(get_volume, set_volume) - """Volume as int in range [0..100] or :class:`None`""" + """Volume as int in range [0..100] or :class:`None` if unknown. The volume + scale is linear. + """ def get_mute(self): - if self.audio: - return self.audio.get_mute().get() + if self.mixer: + return self.mixer.get_mute().get() else: # For testing return self._mute def set_mute(self, value): value = bool(value) - if self.audio: - self.audio.set_mute(value) + if self.mixer: + self.mixer.set_mute(value) else: # For testing self._mute = value - self._trigger_mute_changed(value) - mute = property(get_mute, set_mute) """Mute state as a :class:`True` if muted, :class:`False` otherwise""" - ### Methods + # Methods # TODO: remove this. def change_track(self, tl_track, on_error_step=1): @@ -328,9 +325,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 @@ -358,13 +356,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') @@ -372,14 +371,6 @@ class PlaybackController(object): 'playback_state_changed', old_state=old_state, new_state=new_state) - def _trigger_volume_changed(self, volume): - logger.debug('Triggering volume changed event') - listener.CoreListener.send('volume_changed', volume=volume) - - def _trigger_mute_changed(self, mute): - logger.debug('Triggering mute changed event') - listener.CoreListener.send('mute_changed', mute=mute) - def _trigger_seeked(self, time_position): logger.debug('Triggering seeked event') listener.CoreListener.send('seeked', time_position=time_position) diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 816e7b65..1bce8734 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -4,10 +4,9 @@ import collections import logging import random +from mopidy.core import listener from mopidy.models import TlTrack -from . import listener - logger = logging.getLogger(__name__) @@ -23,7 +22,7 @@ class TracklistController(object): self._shuffled = [] - ### Properties + # Properties def get_tl_tracks(self): return self._tl_tracks[:] @@ -136,7 +135,7 @@ class TracklistController(object): Playback continues after current song. """ - ### Methods + # Methods def index(self, tl_track): """ diff --git a/mopidy/exceptions.py b/mopidy/exceptions.py index 025d8fad..bf9b6dd9 100644 --- a/mopidy/exceptions.py +++ b/mopidy/exceptions.py @@ -16,9 +16,21 @@ class MopidyException(Exception): self._message = message +class BackendError(MopidyException): + pass + + class ExtensionError(MopidyException): pass +class FrontendError(MopidyException): + pass + + +class MixerError(MopidyException): + pass + + class ScannerError(MopidyException): pass diff --git a/mopidy/ext.py b/mopidy/ext.py index 4887f295..d259b686 100644 --- a/mopidy/ext.py +++ b/mopidy/ext.py @@ -2,10 +2,10 @@ from __future__ import unicode_literals import collections import logging + import pkg_resources -from mopidy import exceptions -from mopidy import config as config_lib +from mopidy import config as config_lib, exceptions logger = logging.getLogger(__name__) @@ -99,42 +99,6 @@ class Extension(object): :param registry: the extension registry :type registry: :class:`Registry` """ - for backend_class in self.get_backend_classes(): - registry.add('backend', backend_class) - - for frontend_class in self.get_frontend_classes(): - registry.add('frontend', frontend_class) - - self.register_gstreamer_elements() - - def get_frontend_classes(self): - """List of frontend actor classes - - .. deprecated:: 0.18 - Use :meth:`setup` instead. - - :returns: list of :class:`pykka.Actor` subclasses - """ - return [] - - def get_backend_classes(self): - """List of backend actor classes - - .. deprecated:: 0.18 - Use :meth:`setup` instead. - - :returns: list of :class:`~mopidy.backend.Backend` subclasses - """ - return [] - - def register_gstreamer_elements(self): - """Hook for registering custom GStreamer elements. - - .. deprecated:: 0.18 - Use :meth:`setup` instead. - - :returns: :class:`None` - """ pass diff --git a/mopidy/http/__init__.py b/mopidy/http/__init__.py index 25e2dd46..95675386 100644 --- a/mopidy/http/__init__.py +++ b/mopidy/http/__init__.py @@ -1,40 +1,48 @@ from __future__ import unicode_literals +import logging import os import mopidy -from mopidy import config, exceptions, ext +from mopidy import config as config_lib, exceptions, ext + + +logger = logging.getLogger(__name__) class Extension(ext.Extension): - dist_name = 'Mopidy-HTTP' ext_name = 'http' version = mopidy.__version__ def get_default_config(self): conf_file = os.path.join(os.path.dirname(__file__), 'ext.conf') - return config.read(conf_file) + return config_lib.read(conf_file) def get_config_schema(self): schema = super(Extension, self).get_config_schema() - schema['hostname'] = config.Hostname() - schema['port'] = config.Port() - schema['static_dir'] = config.Path(optional=True) - schema['zeroconf'] = config.String(optional=True) + schema['hostname'] = config_lib.Hostname() + schema['port'] = config_lib.Port() + schema['static_dir'] = config_lib.Path(optional=True) + schema['zeroconf'] = config_lib.String(optional=True) return schema def validate_environment(self): try: - import cherrypy # noqa + import tornado.web # noqa except ImportError as e: - raise exceptions.ExtensionError('cherrypy library not found', e) - - try: - import ws4py # noqa - except ImportError as e: - raise exceptions.ExtensionError('ws4py library not found', e) + raise exceptions.ExtensionError('tornado library not found', e) def setup(self, registry): from .actor import HttpFrontend + from .handlers import make_mopidy_app_factory + + HttpFrontend.apps = registry['http:app'] + HttpFrontend.statics = registry['http:static'] + registry.add('frontend', HttpFrontend) + registry.add('http:app', { + 'name': 'mopidy', + 'factory': make_mopidy_app_factory( + registry['http:app'], registry['http:static']), + }) diff --git a/mopidy/http/actor.py b/mopidy/http/actor.py index e7b5cb66..a477d939 100644 --- a/mopidy/http/actor.py +++ b/mopidy/http/actor.py @@ -1,128 +1,182 @@ from __future__ import unicode_literals -import logging import json +import logging import os +import threading -import cherrypy import pykka -from ws4py.messaging import TextMessage -from ws4py.server.cherrypyserver import WebSocketPlugin, WebSocketTool -from mopidy import models, zeroconf +import tornado.httpserver +import tornado.ioloop +import tornado.netutil +import tornado.web +import tornado.websocket + +from mopidy import exceptions, models, zeroconf from mopidy.core import CoreListener -from . import ws +from mopidy.http import handlers +from mopidy.utils import encoding, formatting, network logger = logging.getLogger(__name__) class HttpFrontend(pykka.ThreadingActor, CoreListener): + apps = [] + statics = [] + def __init__(self, config, core): super(HttpFrontend, self).__init__() - self.config = config - self.core = core - self.hostname = config['http']['hostname'] + self.hostname = network.format_hostname(config['http']['hostname']) self.port = config['http']['port'] + tornado_hostname = config['http']['hostname'] + if tornado_hostname == '::': + tornado_hostname = None + + try: + logger.debug('Starting HTTP server') + sockets = tornado.netutil.bind_sockets(self.port, tornado_hostname) + self.server = HttpServer( + config=config, core=core, sockets=sockets, + apps=self.apps, statics=self.statics) + except IOError as error: + raise exceptions.FrontendError( + 'HTTP server startup failed: %s' % + encoding.locale_decode(error)) + self.zeroconf_name = config['http']['zeroconf'] - self.zeroconf_service = None - - self._setup_server() - self._setup_websocket_plugin() - app = self._create_app() - self._setup_logging(app) - - def _setup_server(self): - cherrypy.config.update({ - 'engine.autoreload_on': False, - 'server.socket_host': self.hostname, - 'server.socket_port': self.port, - }) - - def _setup_websocket_plugin(self): - WebSocketPlugin(cherrypy.engine).subscribe() - cherrypy.tools.websocket = WebSocketTool() - - def _create_app(self): - root = RootResource() - root.mopidy = MopidyResource() - root.mopidy.ws = ws.WebSocketResource(self.core) - - if self.config['http']['static_dir']: - static_dir = self.config['http']['static_dir'] - else: - static_dir = os.path.join(os.path.dirname(__file__), 'data') - logger.debug('HTTP server will serve "%s" at /', static_dir) - - mopidy_dir = os.path.join(os.path.dirname(__file__), 'data') - favicon = os.path.join(mopidy_dir, 'favicon.png') - - config = { - b'/': { - 'tools.staticdir.on': True, - 'tools.staticdir.index': 'index.html', - 'tools.staticdir.dir': static_dir, - }, - b'/favicon.ico': { - 'tools.staticfile.on': True, - 'tools.staticfile.filename': favicon, - }, - b'/mopidy': { - 'tools.staticdir.on': True, - 'tools.staticdir.index': 'mopidy.html', - 'tools.staticdir.dir': mopidy_dir, - }, - b'/mopidy/ws': { - 'tools.websocket.on': True, - 'tools.websocket.handler_cls': ws.WebSocketHandler, - }, - } - - return cherrypy.tree.mount(root, '/', config) - - def _setup_logging(self, app): - cherrypy.log.access_log.setLevel(logging.NOTSET) - cherrypy.log.error_log.setLevel(logging.NOTSET) - cherrypy.log.screen = False - - app.log.access_log.setLevel(logging.NOTSET) - app.log.error_log.setLevel(logging.NOTSET) + self.zeroconf_http = None + self.zeroconf_mopidy_http = None def on_start(self): - logger.debug('Starting HTTP server') - cherrypy.engine.start() - logger.info('HTTP server running at %s', cherrypy.server.base()) + logger.info( + 'HTTP server running at [%s]:%s', self.hostname, self.port) + self.server.start() if self.zeroconf_name: - self.zeroconf_service = zeroconf.Zeroconf( + self.zeroconf_http = zeroconf.Zeroconf( stype='_http._tcp', name=self.zeroconf_name, host=self.hostname, port=self.port) - - if self.zeroconf_service.publish(): - logger.info('Registered HTTP with Zeroconf as "%s"', - self.zeroconf_service.name) - else: - logger.info('Registering HTTP with Zeroconf failed.') + self.zeroconf_mopidy_http = zeroconf.Zeroconf( + stype='_mopidy-http._tcp', name=self.zeroconf_name, + host=self.hostname, port=self.port) + self.zeroconf_http.publish() + self.zeroconf_mopidy_http.publish() def on_stop(self): - if self.zeroconf_service: - self.zeroconf_service.unpublish() + if self.zeroconf_http: + self.zeroconf_http.unpublish() + if self.zeroconf_mopidy_http: + self.zeroconf_mopidy_http.unpublish() - logger.debug('Stopping HTTP server') - cherrypy.engine.exit() - logger.info('Stopped HTTP server') + self.server.stop() def on_event(self, name, **data): - event = data - event['event'] = name - message = json.dumps(event, cls=models.ModelJSONEncoder) - cherrypy.engine.publish('websocket-broadcast', TextMessage(message)) + on_event(name, **data) -class RootResource(object): - pass +def on_event(name, **data): + event = data + event['event'] = name + message = json.dumps(event, cls=models.ModelJSONEncoder) + handlers.WebSocketHandler.broadcast(message) -class MopidyResource(object): - pass +class HttpServer(threading.Thread): + name = 'HttpServer' + + def __init__(self, config, core, sockets, apps, statics): + super(HttpServer, self).__init__() + + self.config = config + self.core = core + self.sockets = sockets + self.apps = apps + self.statics = statics + + self.app = None + self.server = None + + def run(self): + self.app = tornado.web.Application(self._get_request_handlers()) + self.server = tornado.httpserver.HTTPServer(self.app) + self.server.add_sockets(self.sockets) + + tornado.ioloop.IOLoop.instance().start() + + logger.debug('Stopped HTTP server') + + def stop(self): + logger.debug('Stopping HTTP server') + tornado.ioloop.IOLoop.instance().add_callback( + tornado.ioloop.IOLoop.instance().stop) + + def _get_request_handlers(self): + request_handlers = [] + request_handlers.extend(self._get_app_request_handlers()) + request_handlers.extend(self._get_static_request_handlers()) + request_handlers.extend(self._get_mopidy_request_handlers()) + + logger.debug( + 'HTTP routes from extensions: %s', + formatting.indent('\n'.join( + '%r: %r' % (r[0], r[1]) for r in request_handlers))) + + return request_handlers + + def _get_app_request_handlers(self): + result = [] + for app in self.apps: + result.append(( + r'/%s' % app['name'], + handlers.AddSlashHandler + )) + request_handlers = app['factory'](self.config, self.core) + for handler in request_handlers: + handler = list(handler) + handler[0] = '/%s%s' % (app['name'], handler[0]) + result.append(tuple(handler)) + logger.debug('Loaded HTTP extension: %s', app['name']) + return result + + def _get_static_request_handlers(self): + result = [] + for static in self.statics: + result.append(( + r'/%s' % static['name'], + handlers.AddSlashHandler + )) + result.append(( + r'/%s/(.*)' % static['name'], + handlers.StaticFileHandler, + { + 'path': static['path'], + 'default_filename': 'index.html' + } + )) + logger.debug('Loaded static HTTP extension: %s', static['name']) + return result + + def _get_mopidy_request_handlers(self): + # Either default Mopidy or user defined path to files + + static_dir = self.config['http']['static_dir'] + + if static_dir and not os.path.exists(static_dir): + logger.warning( + 'Configured http/static_dir %s does not exist. ' + 'Falling back to default HTTP handler.', static_dir) + static_dir = None + + if static_dir: + return [(r'/(.*)', handlers.StaticFileHandler, { + 'path': self.config['http']['static_dir'], + 'default_filename': 'index.html', + })] + else: + return [(r'/', tornado.web.RedirectHandler, { + 'url': '/mopidy/', + 'permanent': False, + })] diff --git a/mopidy/http/data/clients.html b/mopidy/http/data/clients.html new file mode 100644 index 00000000..feff4fee --- /dev/null +++ b/mopidy/http/data/clients.html @@ -0,0 +1,31 @@ + + + + + + Mopidy + + + +
+

Mopidy

+ +

This web server is a part of the Mopidy music server. To learn more + about Mopidy, please visit + www.mopidy.com.

+
+ +
+

Web clients

+ + + +

Web clients which are installed as Mopidy extensions will + automatically appear here.

+
+ + diff --git a/mopidy/http/data/favicon.png b/mopidy/http/data/favicon.ico similarity index 100% rename from mopidy/http/data/favicon.png rename to mopidy/http/data/favicon.ico diff --git a/mopidy/http/data/index.html b/mopidy/http/data/index.html deleted file mode 100644 index 85d3d331..00000000 --- a/mopidy/http/data/index.html +++ /dev/null @@ -1,29 +0,0 @@ - - - - - Mopidy HTTP frontend - - - -
-

Mopidy HTTP frontend

- -

This web server is a part of the music server Mopidy. To learn more - about Mopidy, please visit - www.mopidy.com.

-
- -
-

Static content serving

- -

To see your own content instead of this placeholder page, change the - setting HTTP_SERVER_STATIC_DIR to point to the directory - containing your static files. This can be used to host e.g. a pure - HTML/CSS/JavaScript Mopidy client.

- -

If you replace this page with your own content, the Mopidy resources - at /mopidy/ will still be available.

-
- - diff --git a/mopidy/http/data/mopidy.css b/mopidy/http/data/mopidy.css index c5042769..0bf4522b 100644 --- a/mopidy/http/data/mopidy.css +++ b/mopidy/http/data/mopidy.css @@ -1,40 +1,17 @@ html { - background: #e8ecef; + background: #f8f8f8; color: #555; - font-family: "Droid Serif", "Georgia", "Times New Roman", "Palatino", - "Hoefler Text", "Baskerville", serif; - font-size: 150%; + font-family: Geneva, Tahoma, Verdana, sans-serif; line-height: 1.4em; } body { - max-width: 20em; + max-width: 600px; margin: 0 auto; } -div.box { - background: white; - border-radius: 5px; - box-shadow: 5px 5px 5px #d8dcdf; - margin: 2em 0; - padding: 1em; -} -div.box.focus { - background: #465158; - color: #e8ecef; -} -div.icon { - float: right; -} h1, h2 { - font-family: "Ubuntu", "Arial", "Helvetica", "Lucida Grande", - "Verdana", "Gill Sans", sans-serif; + font-weight: 500; line-height: 1.1em; } -h2 { - margin: 0.2em 0 0; -} -p.next { - text-align: right; -} a { color: #555; text-decoration: none; @@ -43,20 +20,18 @@ a { img { border: 0; } -code, pre { - font-family: "Droid Sans Mono", Menlo, Courier New, Courier, Mono, monospace; - font-size: 9pt; - line-height: 1.2em; - padding: 0.5em 1em; - margin: 1em 0; - white-space: pre; - overflow: auto; + +.box { + background: white; + box-shadow: 0px 5px 5px #f0f0f0; + margin: 1em; + padding: 1em; } -.box code, -.box pre { - background: #e8ecef; - color: #555; +.box.focus { + background: #465158; + color: #e8ecef; } + .box a { color: #465158; } @@ -66,10 +41,3 @@ code, pre { .box.focus a { color: #e8ecef; } -.center { - text-align: center; -} -#ws-console { - height: 200px; - overflow: auto; -} diff --git a/mopidy/http/data/mopidy.html b/mopidy/http/data/mopidy.html deleted file mode 100644 index c756cd6c..00000000 --- a/mopidy/http/data/mopidy.html +++ /dev/null @@ -1,51 +0,0 @@ - - - - - Mopidy HTTP frontend - - - -
-

Mopidy HTTP frontend

- -

This web server is a part of the music server Mopidy. To learn more - about Mopidy, please visit www.mopidy.com.

-
- -
-

WebSocket endpoint

- -

Mopidy has a WebSocket endpoint at /mopidy/ws/. You can use - this end point to access Mopidy's full API, and to get notified about - events happening in Mopidy.

-
- -
-

Example

- -

Here you can see events arriving from Mopidy in real time:

- -

-
-      

Nothing to see? Try playing a track using your MPD client.

-
- -
-

Documentation

- -

For more information, please refer to the Mopidy documentation at - docs.mopidy.com.

-
- - - diff --git a/mopidy/http/data/mopidy.js b/mopidy/http/data/mopidy.js index cc72e3e6..af54f768 100644 --- a/mopidy/http/data/mopidy.js +++ b/mopidy/http/data/mopidy.js @@ -1,11 +1,11 @@ -/*! Mopidy.js - built 2014-01-04 +/*! Mopidy.js v0.4.0 - built 2014-06-24 * http://www.mopidy.com/ * Copyright (c) 2014 Stein Magnus Jodal and contributors * Licensed under the Apache License, Version 2.0 */ -!function(e){if("object"==typeof exports)module.exports=e();else if("function"==typeof define&&define.amd)define(e);else{var f;"undefined"!=typeof window?f=window:"undefined"!=typeof global?f=global:"undefined"!=typeof self&&(f=self),f.Mopidy=e()}}(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);throw new Error("Cannot find module '"+o+"'")}var f=n[o]={exports:{}};t[o][0].call(f.exports,function(e){var n=t[o][1][e];return s(n?n:e)},f,f.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o 0) { + --nFulfill; + results.push(x); + + if(nFulfill === 0) { + resolve(results); + } + } + } + + function handleReject(e) { + if(nReject > 0) { + --nReject; + errors.push(e); + + if(nReject === 0) { + reject(errors); + } + } + } + }); + } + + /** + * Apply f to the value of each promise in a list of promises + * and return a new list containing the results. + * @param {array} promises + * @param {function} f + * @param {function} fallback + * @returns {Promise} + */ + function map(promises, f, fallback) { + return all(arrayMap.call(promises, function(x) { + return toPromise(x).then(f, fallback); + })); + } + + /** + * Return a promise that will always fulfill with an array containing + * the outcome states of all input promises. The returned promise + * will never reject. + * @param {array} promises + * @returns {Promise} + */ + function settle(promises) { + return all(arrayMap.call(promises, function(p) { + p = toPromise(p); + return p.then(inspect, inspect); + + function inspect() { + return p.inspect(); + } + })); + } + + function reduce(promises, f) { + return arguments.length > 2 + ? arrayReduce.call(promises, reducer, arguments[2]) + : arrayReduce.call(promises, reducer); + + function reducer(result, x, i) { + return toPromise(result).then(function(r) { + return toPromise(x).then(function(x) { + return f(r, x, i); + }); + }); + } + } + + function reduceRight(promises, f) { + return arguments.length > 2 + ? arrayReduceRight.call(promises, reducer, arguments[2]) + : arrayReduceRight.call(promises, reducer); + + function reducer(result, x, i) { + return toPromise(result).then(function(r) { + return toPromise(x).then(function(x) { + return f(r, x, i); + }); + }); + } + } + }; + + +}); +}(typeof define === 'function' && define.amd ? define : function(factory) { module.exports = factory(); })); + +},{}],9:[function(_dereq_,module,exports){ +/** @license MIT License (c) copyright 2010-2014 original author or authors */ +/** @author Brian Cavalier */ +/** @author John Hann */ + +(function(define) { 'use strict'; +define(function() { + + return function flow(Promise) { + + var reject = Promise.reject; + var origCatch = Promise.prototype['catch']; + + /** + * Handle the ultimate fulfillment value or rejection reason, and assume + * responsibility for all errors. If an error propagates out of result + * or handleFatalError, it will be rethrown to the host, resulting in a + * loud stack track on most platforms and a crash on some. + * @param {function?} onResult + * @param {function?} onError + * @returns {undefined} + */ + Promise.prototype.done = function(onResult, onError) { + var h = this._handler; + h.when({ resolve: this._maybeFatal, notify: noop, context: this, + receiver: h.receiver, fulfilled: onResult, rejected: onError, + progress: void 0 }); + }; + + /** + * Add Error-type and predicate matching to catch. Examples: + * promise.catch(TypeError, handleTypeError) + * .catch(predicate, handleMatchedErrors) + * .catch(handleRemainingErrors) + * @param onRejected + * @returns {*} + */ + Promise.prototype['catch'] = Promise.prototype.otherwise = function(onRejected) { + if (arguments.length === 1) { + return origCatch.call(this, onRejected); + } else { + if(typeof onRejected !== 'function') { + return this.ensure(rejectInvalidPredicate); + } + + return origCatch.call(this, createCatchFilter(arguments[1], onRejected)); + } + }; + + /** + * Wraps the provided catch handler, so that it will only be called + * if the predicate evaluates truthy + * @param {?function} handler + * @param {function} predicate + * @returns {function} conditional catch handler + */ + function createCatchFilter(handler, predicate) { + return function(e) { + return evaluatePredicate(e, predicate) + ? handler.call(this, e) + : reject(e); + }; + } + + /** + * Ensures that onFulfilledOrRejected will be called regardless of whether + * this promise is fulfilled or rejected. onFulfilledOrRejected WILL NOT + * receive the promises' value or reason. Any returned value will be disregarded. + * onFulfilledOrRejected may throw or return a rejected promise to signal + * an additional error. + * @param {function} handler handler to be called regardless of + * fulfillment or rejection + * @returns {Promise} + */ + Promise.prototype['finally'] = Promise.prototype.ensure = function(handler) { + if(typeof handler !== 'function') { + // Optimization: result will not change, return same promise + return this; + } + + handler = isolate(handler, this); + return this.then(handler, handler); + }; + + /** + * Recover from a failure by returning a defaultValue. If defaultValue + * is a promise, it's fulfillment value will be used. If defaultValue is + * a promise that rejects, the returned promise will reject with the + * same reason. + * @param {*} defaultValue + * @returns {Promise} new promise + */ + Promise.prototype['else'] = Promise.prototype.orElse = function(defaultValue) { + return this.then(void 0, function() { + return defaultValue; + }); + }; + + /** + * Shortcut for .then(function() { return value; }) + * @param {*} value + * @return {Promise} a promise that: + * - is fulfilled if value is not a promise, or + * - if value is a promise, will fulfill with its value, or reject + * with its reason. + */ + Promise.prototype['yield'] = function(value) { + return this.then(function() { + return value; + }); + }; + + /** + * Runs a side effect when this promise fulfills, without changing the + * fulfillment value. + * @param {function} onFulfilledSideEffect + * @returns {Promise} + */ + Promise.prototype.tap = function(onFulfilledSideEffect) { + return this.then(onFulfilledSideEffect)['yield'](this); + }; + + return Promise; + }; + + function rejectInvalidPredicate() { + throw new TypeError('catch predicate must be a function'); + } + + function evaluatePredicate(e, predicate) { + return isError(predicate) ? e instanceof predicate : predicate(e); + } + + function isError(predicate) { + return predicate === Error + || (predicate != null && predicate.prototype instanceof Error); + } + + // prevent argument passing to f and ignore return value + function isolate(f, x) { + return function() { + f.call(this); + return x; + }; + } + + function noop() {} + +}); +}(typeof define === 'function' && define.amd ? define : function(factory) { module.exports = factory(); })); + +},{}],10:[function(_dereq_,module,exports){ +/** @license MIT License (c) copyright 2010-2014 original author or authors */ +/** @author Brian Cavalier */ +/** @author John Hann */ +/** @author Jeff Escalante */ + +(function(define) { 'use strict'; +define(function() { + + return function fold(Promise) { + + Promise.prototype.fold = function(fn, arg) { + var promise = this._beget(); + this._handler.fold(promise._handler, fn, arg); + return promise; + }; + + return Promise; + }; + +}); +}(typeof define === 'function' && define.amd ? define : function(factory) { module.exports = factory(); })); + +},{}],11:[function(_dereq_,module,exports){ +/** @license MIT License (c) copyright 2010-2014 original author or authors */ +/** @author Brian Cavalier */ +/** @author John Hann */ + +(function(define) { 'use strict'; +define(function() { + + return function inspect(Promise) { + + Promise.prototype.inspect = function() { + return this._handler.inspect(); + }; + + return Promise; + }; + +}); +}(typeof define === 'function' && define.amd ? define : function(factory) { module.exports = factory(); })); + +},{}],12:[function(_dereq_,module,exports){ +/** @license MIT License (c) copyright 2010-2014 original author or authors */ +/** @author Brian Cavalier */ +/** @author John Hann */ + +(function(define) { 'use strict'; +define(function() { + + return function generate(Promise) { + + var resolve = Promise.resolve; + + Promise.iterate = iterate; + Promise.unfold = unfold; + + return Promise; + + /** + * Generate a (potentially infinite) stream of promised values: + * x, f(x), f(f(x)), etc. until condition(x) returns true + * @param {function} f function to generate a new x from the previous x + * @param {function} condition function that, given the current x, returns + * truthy when the iterate should stop + * @param {function} handler function to handle the value produced by f + * @param {*|Promise} x starting value, may be a promise + * @return {Promise} the result of the last call to f before + * condition returns true + */ + function iterate(f, condition, handler, x) { + return unfold(function(x) { + return [x, f(x)]; + }, condition, handler, x); + } + + /** + * Generate a (potentially infinite) stream of promised values + * by applying handler(generator(seed)) iteratively until + * condition(seed) returns true. + * @param {function} unspool function that generates a [value, newSeed] + * given a seed. + * @param {function} condition function that, given the current seed, returns + * truthy when the unfold should stop + * @param {function} handler function to handle the value produced by unspool + * @param x {*|Promise} starting value, may be a promise + * @return {Promise} the result of the last value produced by unspool before + * condition returns true + */ + function unfold(unspool, condition, handler, x) { + return resolve(x).then(function(seed) { + return resolve(condition(seed)).then(function(done) { + return done ? seed : resolve(unspool(seed)).spread(next); + }); + }); + + function next(item, newSeed) { + return resolve(handler(item)).then(function() { + return unfold(unspool, condition, handler, newSeed); + }); + } + } + }; + +}); +}(typeof define === 'function' && define.amd ? define : function(factory) { module.exports = factory(); })); + +},{}],13:[function(_dereq_,module,exports){ +/** @license MIT License (c) copyright 2010-2014 original author or authors */ +/** @author Brian Cavalier */ +/** @author John Hann */ + +(function(define) { 'use strict'; +define(function() { + + return function progress(Promise) { + + /** + * Register a progress handler for this promise + * @param {function} onProgress + * @returns {Promise} + */ + Promise.prototype.progress = function(onProgress) { + return this.then(void 0, void 0, onProgress); + }; + + return Promise; + }; + +}); +}(typeof define === 'function' && define.amd ? define : function(factory) { module.exports = factory(); })); + +},{}],14:[function(_dereq_,module,exports){ +/** @license MIT License (c) copyright 2010-2014 original author or authors */ +/** @author Brian Cavalier */ +/** @author John Hann */ + +(function(define) { 'use strict'; +define(function(_dereq_) { + + var timer = _dereq_('../timer'); + var TimeoutError = _dereq_('../TimeoutError'); + + return function timed(Promise) { + /** + * Return a new promise whose fulfillment value is revealed only + * after ms milliseconds + * @param {number} ms milliseconds + * @returns {Promise} + */ + Promise.prototype.delay = function(ms) { + var p = this._beget(); + var h = p._handler; + + this._handler.map(function delay(x) { + timer.set(function() { h.resolve(x); }, ms); + }, h); + + return p; + }; + + /** + * Return a new promise that rejects after ms milliseconds unless + * this promise fulfills earlier, in which case the returned promise + * fulfills with the same value. + * @param {number} ms milliseconds + * @param {Error|*=} reason optional rejection reason to use, defaults + * to an Error if not provided + * @returns {Promise} + */ + Promise.prototype.timeout = function(ms, reason) { + var hasReason = arguments.length > 1; + var p = this._beget(); + var h = p._handler; + + var t = timer.set(onTimeout, ms); + + this._handler.chain(h, + function onFulfill(x) { + timer.clear(t); + this.resolve(x); // this = p._handler + }, + function onReject(x) { + timer.clear(t); + this.reject(x); // this = p._handler + }, + h.notify); + + return p; + + function onTimeout() { + h.reject(hasReason + ? reason : new TimeoutError('timed out after ' + ms + 'ms')); + } + }; + + return Promise; + }; + +}); +}(typeof define === 'function' && define.amd ? define : function(factory) { module.exports = factory(_dereq_); })); + +},{"../TimeoutError":6,"../timer":19}],15:[function(_dereq_,module,exports){ +/** @license MIT License (c) copyright 2010-2014 original author or authors */ +/** @author Brian Cavalier */ +/** @author John Hann */ + +(function(define) { 'use strict'; +define(function(_dereq_) { + + var timer = _dereq_('../timer'); + + return function unhandledRejection(Promise) { + var logError = noop; + var logInfo = noop; + + if(typeof console !== 'undefined') { + logError = typeof console.error !== 'undefined' + ? function (e) { console.error(e); } + : function (e) { console.log(e); }; + + logInfo = typeof console.info !== 'undefined' + ? function (e) { console.info(e); } + : function (e) { console.log(e); }; + } + + Promise.onPotentiallyUnhandledRejection = function(rejection) { + enqueue(report, rejection); + }; + + Promise.onPotentiallyUnhandledRejectionHandled = function(rejection) { + enqueue(unreport, rejection); + }; + + Promise.onFatalRejection = function(rejection) { + enqueue(throwit, rejection.value); + }; + + var tasks = []; + var reported = []; + var running = false; + + function report(r) { + if(!r.handled) { + reported.push(r); + logError('Potentially unhandled rejection [' + r.id + '] ' + formatError(r.value)); + } + } + + function unreport(r) { + var i = reported.indexOf(r); + if(i >= 0) { + reported.splice(i, 1); + logInfo('Handled previous rejection [' + r.id + '] ' + formatObject(r.value)); + } + } + + function enqueue(f, x) { + tasks.push(f, x); + if(!running) { + running = true; + running = timer.set(flush, 0); + } + } + + function flush() { + running = false; + while(tasks.length > 0) { + tasks.shift()(tasks.shift()); + } + } + + return Promise; + }; + + function formatError(e) { + var s = typeof e === 'object' && e.stack ? e.stack : formatObject(e); + return e instanceof Error ? s : s + ' (WARNING: non-Error used)'; + } + + function formatObject(o) { + var s = String(o); + if(s === '[object Object]' && typeof JSON !== 'undefined') { + s = tryStringify(o, s); + } + return s; + } + + function tryStringify(e, defaultValue) { + try { + return JSON.stringify(e); + } catch(e) { + // Ignore. Cannot JSON.stringify e, stick with String(e) + return defaultValue; + } + } + + function throwit(e) { + throw e; + } + + function noop() {} + +}); +}(typeof define === 'function' && define.amd ? define : function(factory) { module.exports = factory(_dereq_); })); + +},{"../timer":19}],16:[function(_dereq_,module,exports){ +/** @license MIT License (c) copyright 2010-2014 original author or authors */ +/** @author Brian Cavalier */ +/** @author John Hann */ + +(function(define) { 'use strict'; +define(function() { + + return function addWith(Promise) { + /** + * Returns a promise whose handlers will be called with `this` set to + * the supplied `thisArg`. Subsequent promises derived from the + * returned promise will also have their handlers called with `thisArg`. + * Calling `with` with undefined or no arguments will return a promise + * whose handlers will again be called in the usual Promises/A+ way (no `this`) + * thus safely undoing any previous `with` in the promise chain. + * + * WARNING: Promises returned from `with`/`withThis` are NOT Promises/A+ + * compliant, specifically violating 2.2.5 (http://promisesaplus.com/#point-41) + * + * @param {object} thisArg `this` value for all handlers attached to + * the returned promise. + * @returns {Promise} + */ + Promise.prototype['with'] = Promise.prototype.withThis + = Promise.prototype._bindContext; + + return Promise; + }; + +}); +}(typeof define === 'function' && define.amd ? define : function(factory) { module.exports = factory(); })); + + +},{}],17:[function(_dereq_,module,exports){ +/** @license MIT License (c) copyright 2010-2014 original author or authors */ +/** @author Brian Cavalier */ +/** @author John Hann */ + +(function(define) { 'use strict'; +define(function() { + + return function makePromise(environment) { + + var tasks = environment.scheduler; + + var objectCreate = Object.create || + function(proto) { + function Child() {} + Child.prototype = proto; + return new Child(); + }; + + /** + * Create a promise whose fate is determined by resolver + * @constructor + * @returns {Promise} promise + * @name Promise + */ + function Promise(resolver, handler) { + this._handler = resolver === Handler ? handler : init(resolver); + } + + /** + * Run the supplied resolver + * @param resolver + * @returns {makePromise.DeferredHandler} + */ + function init(resolver) { + var handler = new DeferredHandler(); + + try { + resolver(promiseResolve, promiseReject, promiseNotify); + } catch (e) { + promiseReject(e); + } + + return handler; + + /** + * Transition from pre-resolution state to post-resolution state, notifying + * all listeners of the ultimate fulfillment or rejection + * @param {*} x resolution value + */ + function promiseResolve (x) { + handler.resolve(x); + } + /** + * Reject this promise with reason, which will be used verbatim + * @param {Error|*} reason rejection reason, strongly suggested + * to be an Error type + */ + function promiseReject (reason) { + handler.reject(reason); + } + + /** + * Issue a progress event, notifying all progress listeners + * @param {*} x progress event payload to pass to all listeners + */ + function promiseNotify (x) { + handler.notify(x); + } + } + + // Creation + + Promise.resolve = resolve; + Promise.reject = reject; + Promise.never = never; + + Promise._defer = defer; + + /** + * Returns a trusted promise. If x is already a trusted promise, it is + * returned, otherwise returns a new trusted Promise which follows x. + * @param {*} x + * @return {Promise} promise + */ + function resolve(x) { + return isPromise(x) ? x + : new Promise(Handler, new AsyncHandler(getHandler(x))); + } + + /** + * Return a reject promise with x as its reason (x is used verbatim) + * @param {*} x + * @returns {Promise} rejected promise + */ + function reject(x) { + return new Promise(Handler, new AsyncHandler(new RejectedHandler(x))); + } + + /** + * Return a promise that remains pending forever + * @returns {Promise} forever-pending promise. + */ + function never() { + return foreverPendingPromise; // Should be frozen + } + + /** + * Creates an internal {promise, resolver} pair + * @private + * @returns {Promise} + */ + function defer() { + return new Promise(Handler, new DeferredHandler()); + } + + // Transformation and flow control + + /** + * Transform this promise's fulfillment value, returning a new Promise + * for the transformed result. If the promise cannot be fulfilled, onRejected + * is called with the reason. onProgress *may* be called with updates toward + * this promise's fulfillment. + * @param {function=} onFulfilled fulfillment handler + * @param {function=} onRejected rejection handler + * @deprecated @param {function=} onProgress progress handler + * @return {Promise} new promise + */ + Promise.prototype.then = function(onFulfilled, onRejected) { + var parent = this._handler; + + if (typeof onFulfilled !== 'function' && parent.join().state() > 0) { + // Short circuit: value will not change, simply share handler + return new Promise(Handler, parent); + } + + var p = this._beget(); + var child = p._handler; + + parent.when({ + resolve: child.resolve, + notify: child.notify, + context: child, + receiver: parent.receiver, + fulfilled: onFulfilled, + rejected: onRejected, + progress: arguments.length > 2 ? arguments[2] : void 0 + }); + + return p; + }; + + /** + * If this promise cannot be fulfilled due to an error, call onRejected to + * handle the error. Shortcut for .then(undefined, onRejected) + * @param {function?} onRejected + * @return {Promise} + */ + Promise.prototype['catch'] = function(onRejected) { + return this.then(void 0, onRejected); + }; + + /** + * Private function to bind a thisArg for this promise's handlers + * @private + * @param {object} thisArg `this` value for all handlers attached to + * the returned promise. + * @returns {Promise} + */ + Promise.prototype._bindContext = function(thisArg) { + return new Promise(Handler, new BoundHandler(this._handler, thisArg)); + }; + + /** + * Creates a new, pending promise of the same type as this promise + * @private + * @returns {Promise} + */ + Promise.prototype._beget = function() { + var parent = this._handler; + var child = new DeferredHandler(parent.receiver, parent.join().context); + return new this.constructor(Handler, child); + }; + + /** + * Check if x is a rejected promise, and if so, delegate to handler._fatal + * @private + * @param {*} x + */ + Promise.prototype._maybeFatal = function(x) { + if(!maybeThenable(x)) { + return; + } + + var handler = getHandler(x); + var context = this._handler.context; + handler.catchError(function() { + this._fatal(context); + }, handler); + }; + + // Array combinators + + Promise.all = all; + Promise.race = race; + + /** + * Return a promise that will fulfill when all promises in the + * input array have fulfilled, or will reject when one of the + * promises rejects. + * @param {array} promises array of promises + * @returns {Promise} promise for array of fulfillment values + */ + function all(promises) { + /*jshint maxcomplexity:8*/ + var resolver = new DeferredHandler(); + var pending = promises.length >>> 0; + var results = new Array(pending); + + var i, h, x, s; + for (i = 0; i < promises.length; ++i) { + x = promises[i]; + + if (x === void 0 && !(i in promises)) { + --pending; + continue; + } + + if (maybeThenable(x)) { + h = isPromise(x) + ? x._handler.join() + : getHandlerUntrusted(x); + + s = h.state(); + if (s === 0) { + resolveOne(resolver, results, h, i); + } else if (s > 0) { + results[i] = h.value; + --pending; + } else { + resolver.become(h); + break; + } + + } else { + results[i] = x; + --pending; + } + } + + if(pending === 0) { + resolver.become(new FulfilledHandler(results)); + } + + return new Promise(Handler, resolver); + function resolveOne(resolver, results, handler, i) { + handler.map(function(x) { + results[i] = x; + if(--pending === 0) { + this.become(new FulfilledHandler(results)); + } + }, resolver); + } + } + + /** + * Fulfill-reject competitive race. Return a promise that will settle + * to the same state as the earliest input promise to settle. + * + * WARNING: The ES6 Promise spec requires that race()ing an empty array + * must return a promise that is pending forever. This implementation + * returns a singleton forever-pending promise, the same singleton that is + * returned by Promise.never(), thus can be checked with === + * + * @param {array} promises array of promises to race + * @returns {Promise} if input is non-empty, a promise that will settle + * to the same outcome as the earliest input promise to settle. if empty + * is empty, returns a promise that will never settle. + */ + function race(promises) { + // Sigh, race([]) is untestable unless we return *something* + // that is recognizable without calling .then() on it. + if(Object(promises) === promises && promises.length === 0) { + return never(); + } + + var h = new DeferredHandler(); + var i, x; + for(i=0; i 0) { + q.shift().run(); + } + + this._running = false; + + q = this._afterQueue; + while(q.length > 0) { + q.shift()(q.shift(), q.shift()); + } + }; + + return Scheduler; + +}); +}(typeof define === 'function' && define.amd ? define : function(factory) { module.exports = factory(_dereq_); })); + +},{"./Queue":5}],19:[function(_dereq_,module,exports){ +/** @license MIT License (c) copyright 2010-2014 original author or authors */ +/** @author Brian Cavalier */ +/** @author John Hann */ + +(function(define) { 'use strict'; +define(function(_dereq_) { + /*global setTimeout,clearTimeout*/ + var cjsRequire, vertx, setTimer, clearTimer; + + cjsRequire = _dereq_; + + try { + vertx = cjsRequire('vertx'); + setTimer = function (f, ms) { return vertx.setTimer(ms, f); }; + clearTimer = vertx.cancelTimer; + } catch (e) { + setTimer = function(f, ms) { return setTimeout(f, ms); }; + clearTimer = function(t) { return clearTimeout(t); }; + } + + return { + set: setTimer, + clear: clearTimer + }; + +}); +}(typeof define === 'function' && define.amd ? define : function(factory) { module.exports = factory(_dereq_); })); + +},{}],20:[function(_dereq_,module,exports){ +/** @license MIT License (c) copyright 2010-2014 original author or authors */ /** - * A lightweight CommonJS Promises/A and when() implementation - * when is part of the cujo.js family of libraries (http://cujojs.com/) - * - * Licensed under the MIT License at: - * http://www.opensource.org/licenses/mit-license.php - * + * Promises/A+ and when() implementation + * when is part of the cujoJS family of libraries (http://cujojs.com/) * @author Brian Cavalier * @author John Hann - * @version 2.7.1 + * @version 3.2.3 */ (function(define) { 'use strict'; -define(function (require) { +define(function (_dereq_) { + + var timed = _dereq_('./lib/decorators/timed'); + var array = _dereq_('./lib/decorators/array'); + var flow = _dereq_('./lib/decorators/flow'); + var fold = _dereq_('./lib/decorators/fold'); + var inspect = _dereq_('./lib/decorators/inspect'); + var generate = _dereq_('./lib/decorators/iterate'); + var progress = _dereq_('./lib/decorators/progress'); + var withThis = _dereq_('./lib/decorators/with'); + var unhandledRejection = _dereq_('./lib/decorators/unhandledRejection'); + var TimeoutError = _dereq_('./lib/TimeoutError'); + + var Promise = [array, flow, fold, generate, progress, + inspect, withThis, timed, unhandledRejection] + .reduce(function(Promise, feature) { + return feature(Promise); + }, _dereq_('./lib/Promise')); + + var slice = Array.prototype.slice; // Public API - when.promise = promise; // Create a pending promise - when.resolve = resolve; // Create a resolved promise - when.reject = reject; // Create a rejected promise - when.defer = defer; // Create a {promise, resolver} pair + when.promise = promise; // Create a pending promise + when.resolve = Promise.resolve; // Create a resolved promise + when.reject = Promise.reject; // Create a rejected promise - when.join = join; // Join 2 or more promises + when.lift = lift; // lift a function to return promises + when['try'] = attempt; // call a function and return a promise + when.attempt = attempt; // alias for when.try - when.all = all; // Resolve a list of promises - when.map = map; // Array.map() for promises - when.reduce = reduce; // Array.reduce() for promises - when.settle = settle; // Settle a list of promises + when.iterate = Promise.iterate; // Generate a stream of promises + when.unfold = Promise.unfold; // Generate a stream of promises - when.any = any; // One-winner race - when.some = some; // Multi-winner race + when.join = join; // Join 2 or more promises - when.isPromise = isPromiseLike; // DEPRECATED: use isPromiseLike - when.isPromiseLike = isPromiseLike; // Is something promise-like, aka thenable + when.all = all; // Resolve a list of promises + when.settle = settle; // Settle a list of promises + + when.any = lift(Promise.any); // One-winner race + when.some = lift(Promise.some); // Multi-winner race + + when.map = map; // Array.map() for promises + when.reduce = reduce; // Array.reduce() for promises + when.reduceRight = reduceRight; // Array.reduceRight() for promises + + when.isPromiseLike = isPromiseLike; // Is something promise-like, aka thenable + + when.Promise = Promise; // Promise constructor + when.defer = defer; // Create a {promise, resolve, reject} tuple + + // Error types + + when.TimeoutError = TimeoutError; /** - * Register an observer for a promise or immediate value. + * Get a trusted promise for x, or by transforming x with onFulfilled * - * @param {*} promiseOrValue - * @param {function?} [onFulfilled] callback to be called when promiseOrValue is + * @param {*} x + * @param {function?} onFulfilled callback to be called when x is * successfully fulfilled. If promiseOrValue is an immediate value, callback * will be invoked immediately. - * @param {function?} [onRejected] callback to be called when promiseOrValue is + * @param {function?} onRejected callback to be called when x is * rejected. - * @param {function?} [onProgress] callback to be called when progress updates - * are issued for promiseOrValue. - * @returns {Promise} a new {@link Promise} that will complete with the return + * @deprecated @param {function?} onProgress callback to be called when progress updates + * are issued for x. + * @returns {Promise} a new promise that will fulfill with the return * value of callback or errback or the completion value of promiseOrValue if * callback and/or errback is not supplied. */ - function when(promiseOrValue, onFulfilled, onRejected, onProgress) { - // Get a trusted promise for the input promiseOrValue, and then - // register promise handlers - return cast(promiseOrValue).then(onFulfilled, onRejected, onProgress); + function when(x, onFulfilled, onRejected) { + var p = Promise.resolve(x); + if(arguments.length < 2) { + return p; + } + + return arguments.length > 3 + ? p.then(onFulfilled, onRejected, arguments[3]) + : p.then(onFulfilled, onRejected); } /** @@ -300,443 +2164,63 @@ define(function (require) { * @returns {Promise} promise whose fate is determine by resolver */ function promise(resolver) { - return new Promise(resolver, - monitorApi.PromiseStatus && monitorApi.PromiseStatus()); + return new Promise(resolver); } /** - * Trusted Promise constructor. A Promise created from this constructor is - * a trusted when.js promise. Any other duck-typed promise is considered - * untrusted. - * @constructor - * @returns {Promise} promise whose fate is determine by resolver - * @name Promise + * Lift the supplied function, creating a version of f that returns + * promises, and accepts promises as arguments. + * @param {function} f + * @returns {Function} version of f that returns promises */ - function Promise(resolver, status) { - var self, value, consumers = []; - - self = this; - this._status = status; - this.inspect = inspect; - this._when = _when; - - // Call the provider resolver to seal the promise's fate - try { - resolver(promiseResolve, promiseReject, promiseNotify); - } catch(e) { - promiseReject(e); - } - - /** - * Returns a snapshot of this promise's current status at the instant of call - * @returns {{state:String}} - */ - function inspect() { - return value ? value.inspect() : toPendingState(); - } - - /** - * Private message delivery. Queues and delivers messages to - * the promise's ultimate fulfillment value or rejection reason. - * @private - */ - function _when(resolve, notify, onFulfilled, onRejected, onProgress) { - consumers ? consumers.push(deliver) : enqueue(function() { deliver(value); }); - - function deliver(p) { - p._when(resolve, notify, onFulfilled, onRejected, onProgress); - } - } - - /** - * Transition from pre-resolution state to post-resolution state, notifying - * all listeners of the ultimate fulfillment or rejection - * @param {*} val resolution value - */ - function promiseResolve(val) { - if(!consumers) { - return; - } - - var queue = consumers; - consumers = undef; - - enqueue(function () { - value = coerce(self, val); - if(status) { - updateStatus(value, status); - } - runHandlers(queue, value); - }); - } - - /** - * Reject this promise with the supplied reason, which will be used verbatim. - * @param {*} reason reason for the rejection - */ - function promiseReject(reason) { - promiseResolve(new RejectedPromise(reason)); - } - - /** - * Issue a progress event, notifying all progress listeners - * @param {*} update progress event payload to pass to all listeners - */ - function promiseNotify(update) { - if(consumers) { - var queue = consumers; - enqueue(function () { - runHandlers(queue, new ProgressingPromise(update)); - }); - } - } + function lift(f) { + return function() { + return _apply(f, this, slice.call(arguments)); + }; } - promisePrototype = Promise.prototype; - /** - * Register handlers for this promise. - * @param [onFulfilled] {Function} fulfillment handler - * @param [onRejected] {Function} rejection handler - * @param [onProgress] {Function} progress handler - * @return {Promise} new Promise - */ - promisePrototype.then = function(onFulfilled, onRejected, onProgress) { - var self = this; - - return new Promise(function(resolve, reject, notify) { - self._when(resolve, notify, onFulfilled, onRejected, onProgress); - }, this._status && this._status.observed()); - }; - - /** - * Register a rejection handler. Shortcut for .then(undefined, onRejected) - * @param {function?} onRejected - * @return {Promise} - */ - promisePrototype['catch'] = promisePrototype.otherwise = function(onRejected) { - return this.then(undef, onRejected); - }; - - /** - * Ensures that onFulfilledOrRejected will be called regardless of whether - * this promise is fulfilled or rejected. onFulfilledOrRejected WILL NOT - * receive the promises' value or reason. Any returned value will be disregarded. - * onFulfilledOrRejected may throw or return a rejected promise to signal - * an additional error. - * @param {function} onFulfilledOrRejected handler to be called regardless of - * fulfillment or rejection + * Call f in a future turn, with the supplied args, and return a promise + * for the result. + * @param {function} f * @returns {Promise} */ - promisePrototype['finally'] = promisePrototype.ensure = function(onFulfilledOrRejected) { - return typeof onFulfilledOrRejected === 'function' - ? this.then(injectHandler, injectHandler)['yield'](this) - : this; - - function injectHandler() { - return resolve(onFulfilledOrRejected()); - } - }; - - /** - * Terminate a promise chain by handling the ultimate fulfillment value or - * rejection reason, and assuming responsibility for all errors. if an - * error propagates out of handleResult or handleFatalError, it will be - * rethrown to the host, resulting in a loud stack track on most platforms - * and a crash on some. - * @param {function?} handleResult - * @param {function?} handleError - * @returns {undefined} - */ - promisePrototype.done = function(handleResult, handleError) { - this.then(handleResult, handleError)['catch'](crash); - }; - - /** - * Shortcut for .then(function() { return value; }) - * @param {*} value - * @return {Promise} a promise that: - * - is fulfilled if value is not a promise, or - * - if value is a promise, will fulfill with its value, or reject - * with its reason. - */ - promisePrototype['yield'] = function(value) { - return this.then(function() { - return value; - }); - }; - - /** - * Runs a side effect when this promise fulfills, without changing the - * fulfillment value. - * @param {function} onFulfilledSideEffect - * @returns {Promise} - */ - promisePrototype.tap = function(onFulfilledSideEffect) { - return this.then(onFulfilledSideEffect)['yield'](this); - }; - - /** - * Assumes that this promise will fulfill with an array, and arranges - * for the onFulfilled to be called with the array as its argument list - * i.e. onFulfilled.apply(undefined, array). - * @param {function} onFulfilled function to receive spread arguments - * @return {Promise} - */ - promisePrototype.spread = function(onFulfilled) { - return this.then(function(array) { - // array may contain promises, so resolve its contents. - return all(array, function(array) { - return onFulfilled.apply(undef, array); - }); - }); - }; - - /** - * Shortcut for .then(onFulfilledOrRejected, onFulfilledOrRejected) - * @deprecated - */ - promisePrototype.always = function(onFulfilledOrRejected, onProgress) { - return this.then(onFulfilledOrRejected, onFulfilledOrRejected, onProgress); - }; - - /** - * Casts x to a trusted promise. If x is already a trusted promise, it is - * returned, otherwise a new trusted Promise which follows x is returned. - * @param {*} x - * @returns {Promise} - */ - function cast(x) { - return x instanceof Promise ? x : resolve(x); + function attempt(f /*, args... */) { + /*jshint validthis:true */ + return _apply(f, this, slice.call(arguments, 1)); } /** - * Returns a resolved promise. The returned promise will be - * - fulfilled with promiseOrValue if it is a value, or - * - if promiseOrValue is a promise - * - fulfilled with promiseOrValue's value after it is fulfilled - * - rejected with promiseOrValue's reason after it is rejected - * In contract to cast(x), this always creates a new Promise - * @param {*} value - * @return {Promise} + * try/lift helper that allows specifying thisArg + * @private */ - function resolve(value) { - return promise(function(resolve) { - resolve(value); - }); - } - - /** - * Returns a rejected promise for the supplied promiseOrValue. The returned - * promise will be rejected with: - * - promiseOrValue, if it is a value, or - * - if promiseOrValue is a promise - * - promiseOrValue's value after it is fulfilled - * - promiseOrValue's reason after it is rejected - * @param {*} promiseOrValue the rejected value of the returned {@link Promise} - * @return {Promise} rejected {@link Promise} - */ - function reject(promiseOrValue) { - return when(promiseOrValue, function(e) { - return new RejectedPromise(e); + function _apply(f, thisArg, args) { + return Promise.all(args).then(function(args) { + return f.apply(thisArg, args); }); } /** * Creates a {promise, resolver} pair, either or both of which * may be given out safely to consumers. - * The resolver has resolve, reject, and progress. The promise - * has then plus extended promise API. - * - * @return {{ - * promise: Promise, - * resolve: function:Promise, - * reject: function:Promise, - * notify: function:Promise - * resolver: { - * resolve: function:Promise, - * reject: function:Promise, - * notify: function:Promise - * }}} + * @return {{promise: Promise, resolve: function, reject: function, notify: function}} */ function defer() { - var deferred, pending, resolved; - - // Optimize object shape - deferred = { - promise: undef, resolve: undef, reject: undef, notify: undef, - resolver: { resolve: undef, reject: undef, notify: undef } - }; - - deferred.promise = pending = promise(makeDeferred); - - return deferred; - - function makeDeferred(resolvePending, rejectPending, notifyPending) { - deferred.resolve = deferred.resolver.resolve = function(value) { - if(resolved) { - return resolve(value); - } - resolved = true; - resolvePending(value); - return pending; - }; - - deferred.reject = deferred.resolver.reject = function(reason) { - if(resolved) { - return resolve(new RejectedPromise(reason)); - } - resolved = true; - rejectPending(reason); - return pending; - }; - - deferred.notify = deferred.resolver.notify = function(update) { - notifyPending(update); - return update; - }; - } + return new Deferred(); } - /** - * Run a queue of functions as quickly as possible, passing - * value to each. - */ - function runHandlers(queue, value) { - for (var i = 0; i < queue.length; i++) { - queue[i](value); - } - } + function Deferred() { + var p = Promise._defer(); - /** - * Coerces x to a trusted Promise - * @param {*} x thing to coerce - * @returns {*} Guaranteed to return a trusted Promise. If x - * is trusted, returns x, otherwise, returns a new, trusted, already-resolved - * Promise whose resolution value is: - * * the resolution value of x if it's a foreign promise, or - * * x if it's a value - */ - function coerce(self, x) { - if (x === self) { - return new RejectedPromise(new TypeError()); - } + function resolve(x) { p._handler.resolve(x); } + function reject(x) { p._handler.reject(x); } + function notify(x) { p._handler.notify(x); } - if (x instanceof Promise) { - return x; - } - - try { - var untrustedThen = x === Object(x) && x.then; - - return typeof untrustedThen === 'function' - ? assimilate(untrustedThen, x) - : new FulfilledPromise(x); - } catch(e) { - return new RejectedPromise(e); - } - } - - /** - * Safely assimilates a foreign thenable by wrapping it in a trusted promise - * @param {function} untrustedThen x's then() method - * @param {object|function} x thenable - * @returns {Promise} - */ - function assimilate(untrustedThen, x) { - return promise(function (resolve, reject) { - fcall(untrustedThen, x, resolve, reject); - }); - } - - makePromisePrototype = Object.create || - function(o) { - function PromisePrototype() {} - PromisePrototype.prototype = o; - return new PromisePrototype(); - }; - - /** - * Creates a fulfilled, local promise as a proxy for a value - * NOTE: must never be exposed - * @private - * @param {*} value fulfillment value - * @returns {Promise} - */ - function FulfilledPromise(value) { - this.value = value; - } - - FulfilledPromise.prototype = makePromisePrototype(promisePrototype); - - FulfilledPromise.prototype.inspect = function() { - return toFulfilledState(this.value); - }; - - FulfilledPromise.prototype._when = function(resolve, _, onFulfilled) { - try { - resolve(typeof onFulfilled === 'function' ? onFulfilled(this.value) : this.value); - } catch(e) { - resolve(new RejectedPromise(e)); - } - }; - - /** - * Creates a rejected, local promise as a proxy for a value - * NOTE: must never be exposed - * @private - * @param {*} reason rejection reason - * @returns {Promise} - */ - function RejectedPromise(reason) { - this.value = reason; - } - - RejectedPromise.prototype = makePromisePrototype(promisePrototype); - - RejectedPromise.prototype.inspect = function() { - return toRejectedState(this.value); - }; - - RejectedPromise.prototype._when = function(resolve, _, __, onRejected) { - try { - resolve(typeof onRejected === 'function' ? onRejected(this.value) : this); - } catch(e) { - resolve(new RejectedPromise(e)); - } - }; - - /** - * Create a progress promise with the supplied update. - * @private - * @param {*} value progress update value - * @return {Promise} progress promise - */ - function ProgressingPromise(value) { - this.value = value; - } - - ProgressingPromise.prototype = makePromisePrototype(promisePrototype); - - ProgressingPromise.prototype._when = function(_, notify, f, r, u) { - try { - notify(typeof u === 'function' ? u(this.value) : this.value); - } catch(e) { - notify(e); - } - }; - - /** - * Update a PromiseStatus monitor object with the outcome - * of the supplied value promise. - * @param {Promise} value - * @param {PromiseStatus} status - */ - function updateStatus(value, status) { - value.then(statusFulfilled, statusRejected); - - function statusFulfilled() { status.fulfilled(); } - function statusRejected(r) { status.rejected(r); } + this.promise = p; + this.resolve = resolve; + this.reject = reject; + this.notify = notify; + this.resolver = { resolve: resolve, reject: reject, notify: notify }; } /** @@ -752,196 +2236,48 @@ define(function (require) { } /** - * Initiates a competitive race, returning a promise that will resolve when - * howMany of the supplied promisesOrValues have resolved, or will reject when - * it becomes impossible for howMany to resolve, for example, when - * (promisesOrValues.length - howMany) + 1 input promises reject. - * - * @param {Array} promisesOrValues array of anything, may contain a mix - * of promises and values - * @param howMany {number} number of promisesOrValues to resolve - * @param {function?} [onFulfilled] DEPRECATED, use returnedPromise.then() - * @param {function?} [onRejected] DEPRECATED, use returnedPromise.then() - * @param {function?} [onProgress] DEPRECATED, use returnedPromise.then() - * @returns {Promise} promise that will resolve to an array of howMany values that - * resolved first, or will reject with an array of - * (promisesOrValues.length - howMany) + 1 rejection reasons. - */ - function some(promisesOrValues, howMany, onFulfilled, onRejected, onProgress) { - - return when(promisesOrValues, function(promisesOrValues) { - - return promise(resolveSome).then(onFulfilled, onRejected, onProgress); - - function resolveSome(resolve, reject, notify) { - var toResolve, toReject, values, reasons, fulfillOne, rejectOne, len, i; - - len = promisesOrValues.length >>> 0; - - toResolve = Math.max(0, Math.min(howMany, len)); - values = []; - - toReject = (len - toResolve) + 1; - reasons = []; - - // No items in the input, resolve immediately - if (!toResolve) { - resolve(values); - - } else { - rejectOne = function(reason) { - reasons.push(reason); - if(!--toReject) { - fulfillOne = rejectOne = identity; - reject(reasons); - } - }; - - fulfillOne = function(val) { - // This orders the values based on promise resolution order - values.push(val); - if (!--toResolve) { - fulfillOne = rejectOne = identity; - resolve(values); - } - }; - - for(i = 0; i < len; ++i) { - if(i in promisesOrValues) { - when(promisesOrValues[i], fulfiller, rejecter, notify); - } - } - } - - function rejecter(reason) { - rejectOne(reason); - } - - function fulfiller(val) { - fulfillOne(val); - } - } - }); - } - - /** - * Initiates a competitive race, returning a promise that will resolve when - * any one of the supplied promisesOrValues has resolved or will reject when - * *all* promisesOrValues have rejected. - * - * @param {Array|Promise} promisesOrValues array of anything, may contain a mix - * of {@link Promise}s and values - * @param {function?} [onFulfilled] DEPRECATED, use returnedPromise.then() - * @param {function?} [onRejected] DEPRECATED, use returnedPromise.then() - * @param {function?} [onProgress] DEPRECATED, use returnedPromise.then() - * @returns {Promise} promise that will resolve to the value that resolved first, or - * will reject with an array of all rejected inputs. - */ - function any(promisesOrValues, onFulfilled, onRejected, onProgress) { - - function unwrapSingleResult(val) { - return onFulfilled ? onFulfilled(val[0]) : val[0]; - } - - return some(promisesOrValues, 1, unwrapSingleResult, onRejected, onProgress); - } - - /** - * Return a promise that will resolve only once all the supplied promisesOrValues + * Return a promise that will resolve only once all the supplied arguments * have resolved. The resolution value of the returned promise will be an array - * containing the resolution values of each of the promisesOrValues. - * @memberOf when - * - * @param {Array|Promise} promisesOrValues array of anything, may contain a mix - * of {@link Promise}s and values - * @param {function?} [onFulfilled] DEPRECATED, use returnedPromise.then() - * @param {function?} [onRejected] DEPRECATED, use returnedPromise.then() - * @param {function?} [onProgress] DEPRECATED, use returnedPromise.then() + * containing the resolution values of each of the arguments. + * @param {...*} arguments may be a mix of promises and values * @returns {Promise} */ - function all(promisesOrValues, onFulfilled, onRejected, onProgress) { - return _map(promisesOrValues, identity).then(onFulfilled, onRejected, onProgress); - } - - /** - * Joins multiple promises into a single returned promise. - * @return {Promise} a promise that will fulfill when *all* the input promises - * have fulfilled, or will reject when *any one* of the input promises rejects. - */ function join(/* ...promises */) { - return _map(arguments, identity); + return Promise.all(arguments); } /** - * Settles all input promises such that they are guaranteed not to - * be pending once the returned promise fulfills. The returned promise - * will always fulfill, except in the case where `array` is a promise - * that rejects. - * @param {Array|Promise} array or promise for array of promises to settle - * @returns {Promise} promise that always fulfills with an array of - * outcome snapshots for each input promise. + * Return a promise that will fulfill once all input promises have + * fulfilled, or reject when any one input promise rejects. + * @param {array|Promise} promises array (or promise for an array) of promises + * @returns {Promise} */ - function settle(array) { - return _map(array, toFulfilledState, toRejectedState); + function all(promises) { + return when(promises, Promise.all); + } + + /** + * Return a promise that will always fulfill with an array containing + * the outcome states of all input promises. The returned promise + * will only reject if `promises` itself is a rejected promise. + * @param {array|Promise} promises array (or promise for an array) of promises + * @returns {Promise} + */ + function settle(promises) { + return when(promises, Promise.settle); } /** * Promise-aware array map function, similar to `Array.prototype.map()`, * but input array may contain promises or values. - * @param {Array|Promise} array array of anything, may contain promises and values + * @param {Array|Promise} promises array of anything, may contain promises and values * @param {function} mapFunc map function which may return a promise or value * @returns {Promise} promise that will fulfill with an array of mapped values * or reject if any input promise rejects. */ - function map(array, mapFunc) { - return _map(array, mapFunc); - } - - /** - * Internal map that allows a fallback to handle rejections - * @param {Array|Promise} array array of anything, may contain promises and values - * @param {function} mapFunc map function which may return a promise or value - * @param {function?} fallback function to handle rejected promises - * @returns {Promise} promise that will fulfill with an array of mapped values - * or reject if any input promise rejects. - */ - function _map(array, mapFunc, fallback) { - return when(array, function(array) { - - return new Promise(resolveMap); - - function resolveMap(resolve, reject, notify) { - var results, len, toResolve, i; - - // Since we know the resulting length, we can preallocate the results - // array to avoid array expansions. - toResolve = len = array.length >>> 0; - results = []; - - if(!toResolve) { - resolve(results); - return; - } - - // Since mapFunc may be async, get all invocations of it into flight - for(i = 0; i < len; i++) { - if(i in array) { - resolveOne(array[i], i); - } else { - --toResolve; - } - } - - function resolveOne(item, i) { - when(item, mapFunc, fallback).then(function(mapped) { - results[i] = mapped; - - if(!--toResolve) { - resolve(results); - } - }, reject, notify); - } - } + function map(promises, mapFunc) { + return when(promises, function(promises) { + return Promise.map(promises, mapFunc); }); } @@ -951,235 +2287,58 @@ define(function (require) { * may return either a value or a promise, *and* initialValue may * be a promise for the starting value. * - * @param {Array|Promise} promise array or promise for an array of anything, + * @param {Array|Promise} promises array or promise for an array of anything, * may contain a mix of promises and values. - * @param {function} reduceFunc reduce function reduce(currentValue, nextValue, index, total), - * where total is the total number of items being reduced, and will be the same - * in each call to reduceFunc. + * @param {function} f reduce function reduce(currentValue, nextValue, index) * @returns {Promise} that will resolve to the final reduced value */ - function reduce(promise, reduceFunc /*, initialValue */) { - var args = fcall(slice, arguments, 1); - - return when(promise, function(array) { - var total; - - total = array.length; - - // Wrap the supplied reduceFunc with one that handles promises and then - // delegates to the supplied. - args[0] = function (current, val, i) { - return when(current, function (c) { - return when(val, function (value) { - return reduceFunc(c, value, i, total); - }); - }); - }; - - return reduceArray.apply(array, args); + function reduce(promises, f /*, initialValue */) { + /*jshint unused:false*/ + var args = slice.call(arguments, 1); + return when(promises, function(array) { + args.unshift(array); + return Promise.reduce.apply(Promise, args); }); } - // Snapshot states - /** - * Creates a fulfilled state snapshot - * @private - * @param {*} x any value - * @returns {{state:'fulfilled',value:*}} + * Traditional reduce function, similar to `Array.prototype.reduceRight()`, but + * input may contain promises and/or values, and reduceFunc + * may return either a value or a promise, *and* initialValue may + * be a promise for the starting value. + * + * @param {Array|Promise} promises array or promise for an array of anything, + * may contain a mix of promises and values. + * @param {function} f reduce function reduce(currentValue, nextValue, index) + * @returns {Promise} that will resolve to the final reduced value */ - function toFulfilledState(x) { - return { state: 'fulfilled', value: x }; - } - - /** - * Creates a rejected state snapshot - * @private - * @param {*} x any reason - * @returns {{state:'rejected',reason:*}} - */ - function toRejectedState(x) { - return { state: 'rejected', reason: x }; - } - - /** - * Creates a pending state snapshot - * @private - * @returns {{state:'pending'}} - */ - function toPendingState() { - return { state: 'pending' }; - } - - // - // Internals, utilities, etc. - // - - var promisePrototype, makePromisePrototype, reduceArray, slice, fcall, nextTick, handlerQueue, - funcProto, call, arrayProto, monitorApi, - capturedSetTimeout, cjsRequire, MutationObs, undef; - - cjsRequire = require; - - // - // Shared handler queue processing - // - // Credit to Twisol (https://github.com/Twisol) for suggesting - // this type of extensible queue + trampoline approach for - // next-tick conflation. - - handlerQueue = []; - - /** - * Enqueue a task. If the queue is not currently scheduled to be - * drained, schedule it. - * @param {function} task - */ - function enqueue(task) { - if(handlerQueue.push(task) === 1) { - nextTick(drainQueue); - } - } - - /** - * Drain the handler queue entirely, being careful to allow the - * queue to be extended while it is being processed, and to continue - * processing until it is truly empty. - */ - function drainQueue() { - runHandlers(handlerQueue); - handlerQueue = []; - } - - // Allow attaching the monitor to when() if env has no console - monitorApi = typeof console !== 'undefined' ? console : when; - - // Sniff "best" async scheduling option - // Prefer process.nextTick or MutationObserver, then check for - // vertx and finally fall back to setTimeout - /*global process,document,setTimeout,MutationObserver,WebKitMutationObserver*/ - if (typeof process === 'object' && process.nextTick) { - nextTick = process.nextTick; - } else if(MutationObs = - (typeof MutationObserver === 'function' && MutationObserver) || - (typeof WebKitMutationObserver === 'function' && WebKitMutationObserver)) { - nextTick = (function(document, MutationObserver, drainQueue) { - var el = document.createElement('div'); - new MutationObserver(drainQueue).observe(el, { attributes: true }); - - return function() { - el.setAttribute('x', 'x'); - }; - }(document, MutationObs, drainQueue)); - } else { - try { - // vert.x 1.x || 2.x - nextTick = cjsRequire('vertx').runOnLoop || cjsRequire('vertx').runOnContext; - } catch(ignore) { - // capture setTimeout to avoid being caught by fake timers - // used in time based tests - capturedSetTimeout = setTimeout; - nextTick = function(t) { capturedSetTimeout(t, 0); }; - } - } - - // - // Capture/polyfill function and array utils - // - - // Safe function calls - funcProto = Function.prototype; - call = funcProto.call; - fcall = funcProto.bind - ? call.bind(call) - : function(f, context) { - return f.apply(context, slice.call(arguments, 2)); - }; - - // Safe array ops - arrayProto = []; - slice = arrayProto.slice; - - // ES5 reduce implementation if native not available - // See: http://es5.github.com/#x15.4.4.21 as there are many - // specifics and edge cases. ES5 dictates that reduce.length === 1 - // This implementation deviates from ES5 spec in the following ways: - // 1. It does not check if reduceFunc is a Callable - reduceArray = arrayProto.reduce || - function(reduceFunc /*, initialValue */) { - /*jshint maxcomplexity: 7*/ - var arr, args, reduced, len, i; - - i = 0; - arr = Object(this); - len = arr.length >>> 0; - args = arguments; - - // If no initialValue, use first item of array (we know length !== 0 here) - // and adjust i to start at second item - if(args.length <= 1) { - // Skip to the first real element in the array - for(;;) { - if(i in arr) { - reduced = arr[i++]; - break; - } - - // If we reached the end of the array without finding any real - // elements, it's a TypeError - if(++i >= len) { - throw new TypeError(); - } - } - } else { - // If initialValue provided, use it - reduced = args[1]; - } - - // Do the actual reduce - for(;i < len; ++i) { - if(i in arr) { - reduced = reduceFunc(reduced, arr[i], i, arr); - } - } - - return reduced; - }; - - function identity(x) { - return x; - } - - function crash(fatalError) { - if(typeof monitorApi.reportUnhandled === 'function') { - monitorApi.reportUnhandled(); - } else { - enqueue(function() { - throw fatalError; - }); - } - - throw fatalError; + function reduceRight(promises, f /*, initialValue */) { + /*jshint unused:false*/ + var args = slice.call(arguments, 1); + return when(promises, function(array) { + args.unshift(array); + return Promise.reduceRight.apply(Promise, args); + }); } return when; }); -})(typeof define === 'function' && define.amd ? define : function (factory) { module.exports = factory(require); }); +})(typeof define === 'function' && define.amd ? define : function (factory) { module.exports = factory(_dereq_); }); -},{"__browserify_process":3}],5:[function(require,module,exports){ +},{"./lib/Promise":4,"./lib/TimeoutError":6,"./lib/decorators/array":8,"./lib/decorators/flow":9,"./lib/decorators/fold":10,"./lib/decorators/inspect":11,"./lib/decorators/iterate":12,"./lib/decorators/progress":13,"./lib/decorators/timed":14,"./lib/decorators/unhandledRejection":15,"./lib/decorators/with":16}],21:[function(_dereq_,module,exports){ /*global module:true, require:false*/ -var bane = require("bane"); -var websocket = require("../lib/websocket/"); -var when = require("when"); +var bane = _dereq_("bane"); +var websocket = _dereq_("../lib/websocket/"); +var when = _dereq_("when"); function Mopidy(settings) { if (!(this instanceof Mopidy)) { return new Mopidy(settings); } + this._console = this._getConsole(settings || {}); this._settings = this._configure(settings || {}); - this._console = this._getConsole(); this._backoffDelay = this._settings.backoffDelayMin; this._pendingRequests = {}; @@ -1193,13 +2352,41 @@ function Mopidy(settings) { } } +Mopidy.ConnectionError = function (message) { + this.name = "ConnectionError"; + this.message = message; +}; +Mopidy.ConnectionError.prototype = new Error(); +Mopidy.ConnectionError.prototype.constructor = Mopidy.ConnectionError; + +Mopidy.ServerError = function (message) { + this.name = "ServerError"; + this.message = message; +}; +Mopidy.ServerError.prototype = new Error(); +Mopidy.ServerError.prototype.constructor = Mopidy.ServerError; + Mopidy.WebSocket = websocket.Client; +Mopidy.prototype._getConsole = function (settings) { + if (typeof settings.console !== "undefined") { + return settings.console; + } + + var con = typeof console !== "undefined" && console || {}; + + con.log = con.log || function () {}; + con.warn = con.warn || function () {}; + con.error = con.error || function () {}; + + return con; +}; + Mopidy.prototype._configure = function (settings) { var currentHost = (typeof document !== "undefined" && document.location.host) || "localhost"; settings.webSocketUrl = settings.webSocketUrl || - "ws://" + currentHost + "/mopidy/ws/"; + "ws://" + currentHost + "/mopidy/ws"; if (settings.autoConnect !== false) { settings.autoConnect = true; @@ -1208,19 +2395,18 @@ Mopidy.prototype._configure = function (settings) { settings.backoffDelayMin = settings.backoffDelayMin || 1000; settings.backoffDelayMax = settings.backoffDelayMax || 64000; + if (typeof settings.callingConvention === "undefined") { + this._console.warn( + "Mopidy.js is using the default calling convention. The " + + "default will change in the future. You should explicitly " + + "specify which calling convention you use."); + } + settings.callingConvention = ( + settings.callingConvention || "by-position-only"); + return settings; }; -Mopidy.prototype._getConsole = function () { - var console = typeof console !== "undefined" && console || {}; - - console.log = console.log || function () {}; - console.warn = console.warn || function () {}; - console.error = console.error || function () {}; - - return console; -}; - Mopidy.prototype._delegateEvents = function () { // Remove existing event handlers this.off("websocket:close"); @@ -1271,10 +2457,9 @@ Mopidy.prototype._cleanup = function (closeEvent) { Object.keys(this._pendingRequests).forEach(function (requestId) { var resolver = this._pendingRequests[requestId]; delete this._pendingRequests[requestId]; - resolver.reject({ - message: "WebSocket closed", - closeEvent: closeEvent - }); + var error = new Mopidy.ConnectionError("WebSocket closed"); + error.closeEvent = closeEvent; + resolver.reject(error); }.bind(this)); this.emit("state:offline"); @@ -1310,33 +2495,25 @@ Mopidy.prototype._handleWebSocketError = function (error) { }; Mopidy.prototype._send = function (message) { - var deferred = when.defer(); - switch (this._webSocket.readyState) { case Mopidy.WebSocket.CONNECTING: - deferred.resolver.reject({ - message: "WebSocket is still connecting" - }); - break; + return when.reject( + new Mopidy.ConnectionError("WebSocket is still connecting")); case Mopidy.WebSocket.CLOSING: - deferred.resolver.reject({ - message: "WebSocket is closing" - }); - break; + return when.reject( + new Mopidy.ConnectionError("WebSocket is closing")); case Mopidy.WebSocket.CLOSED: - deferred.resolver.reject({ - message: "WebSocket is closed" - }); - break; + return when.reject( + new Mopidy.ConnectionError("WebSocket is closed")); default: + var deferred = when.defer(); message.jsonrpc = "2.0"; message.id = this._nextRequestId(); this._pendingRequests[message.id] = deferred.resolver; this._webSocket.send(JSON.stringify(message)); this.emit("websocket:outgoingMessage", message); + return deferred.promise; } - - return deferred.promise; }; Mopidy.prototype._nextRequestId = (function () { @@ -1377,19 +2554,22 @@ Mopidy.prototype._handleResponse = function (responseMessage) { return; } + var error; var resolver = this._pendingRequests[responseMessage.id]; delete this._pendingRequests[responseMessage.id]; if (responseMessage.hasOwnProperty("result")) { resolver.resolve(responseMessage.result); } else if (responseMessage.hasOwnProperty("error")) { - resolver.reject(responseMessage.error); + error = new Mopidy.ServerError(responseMessage.error.message); + error.code = responseMessage.error.code; + error.data = responseMessage.error.data; + resolver.reject(error); this._console.warn("Server returned error:", responseMessage.error); } else { - resolver.reject({ - message: "Response without 'result' or 'error' received", - data: {response: responseMessage} - }); + error = new Error("Response without 'result' or 'error' received"); + error.data = {response: responseMessage}; + resolver.reject(error); this._console.warn( "Response without 'result' or 'error' received. Message was:", responseMessage); @@ -1406,18 +2586,36 @@ Mopidy.prototype._handleEvent = function (eventMessage) { Mopidy.prototype._getApiSpec = function () { return this._send({method: "core.describe"}) - .then(this._createApi.bind(this), this._handleWebSocketError) - .then(null, this._handleWebSocketError); + .then(this._createApi.bind(this)) + .catch(this._handleWebSocketError); }; Mopidy.prototype._createApi = function (methods) { + var byPositionOrByName = ( + this._settings.callingConvention === "by-position-or-by-name"); + var caller = function (method) { return function () { - var params = Array.prototype.slice.call(arguments); - return this._send({ - method: method, - params: params - }); + var message = {method: method}; + if (arguments.length === 0) { + return this._send(message); + } + if (!byPositionOrByName) { + message.params = Array.prototype.slice.call(arguments); + return this._send(message); + } + if (arguments.length > 1) { + return when.reject(new Error( + "Expected zero arguments, a single array, " + + "or a single object.")); + } + if (!Array.isArray(arguments[0]) && + arguments[0] !== Object(arguments[0])) { + return when.reject(new TypeError( + "Expected an array or an object.")); + } + message.params = arguments[0]; + return this._send(message); }.bind(this); }.bind(this); @@ -1460,6 +2658,6 @@ Mopidy.prototype._snakeToCamel = function (name) { module.exports = Mopidy; -},{"../lib/websocket/":1,"bane":2,"when":4}]},{},[5]) -(5) +},{"../lib/websocket/":1,"bane":2,"when":20}]},{},[21]) +(21) }); \ No newline at end of file diff --git a/mopidy/http/data/mopidy.min.js b/mopidy/http/data/mopidy.min.js index 450911bd..ef3431b3 100644 --- a/mopidy/http/data/mopidy.min.js +++ b/mopidy/http/data/mopidy.min.js @@ -1,5 +1,5 @@ -/*! Mopidy.js - built 2014-01-04 +/*! Mopidy.js v0.4.0 - built 2014-06-24 * http://www.mopidy.com/ * Copyright (c) 2014 Stein Magnus Jodal and contributors * Licensed under the Apache License, Version 2.0 */ -!function(a){if("object"==typeof exports)module.exports=a();else if("function"==typeof define&&define.amd)define(a);else{var b;"undefined"!=typeof window?b=window:"undefined"!=typeof global?b=global:"undefined"!=typeof self&&(b=self),b.Mopidy=a()}}(function(){var a;return function b(a,c,d){function e(g,h){if(!c[g]){if(!a[g]){var i="function"==typeof require&&require;if(!h&&i)return i(g,!0);if(f)return f(g,!0);throw new Error("Cannot find module '"+g+"'")}var j=c[g]={exports:{}};a[g][0].call(j.exports,function(b){var c=a[g][1][b];return e(c?c:b)},j,j.exports,b,a,c,d)}return c[g].exports}for(var f="function"==typeof require&&require,g=0;g0)for(d=0;e>d;++d)c[d](a,b);else setTimeout(function(){throw b.message=a+" listener threw error: "+b.message,b},0)}function b(a){if("function"!=typeof a)throw new TypeError("Listener is not function");return a}function c(a){return a.supervisors||(a.supervisors=[]),a.supervisors}function d(a,b){return a.listeners||(a.listeners={}),b&&!a.listeners[b]&&(a.listeners[b]=[]),b?a.listeners[b]:a.listeners}function e(a){return a.errbacks||(a.errbacks=[]),a.errbacks}function f(f){function h(b,c,d){try{c.listener.apply(c.thisp||f,d)}catch(g){a(b,g,e(f))}}return f=f||{},f.on=function(a,e,f){return"function"==typeof a?c(this).push({listener:a,thisp:e}):(d(this,a).push({listener:b(e),thisp:f}),void 0)},f.off=function(a,b){var f,g,h,i;if(!a){f=c(this),f.splice(0,f.length),g=d(this);for(h in g)g.hasOwnProperty(h)&&(f=d(this,h),f.splice(0,f.length));return f=e(this),f.splice(0,f.length),void 0}if("function"==typeof a?(f=c(this),b=a):f=d(this,a),!b)return f.splice(0,f.length),void 0;for(h=0,i=f.length;i>h;++h)if(f[h].listener===b)return f.splice(h,1),void 0},f.once=function(a,b,c){var d=function(){f.off(a,d),b.apply(this,arguments)};f.on(a,d,c)},f.bind=function(a,b){var c,d,e;if(b)for(d=0,e=b.length;e>d;++d){if("function"!=typeof a[b[d]])throw new Error("No such method "+b[d]);this.on(b[d],a[b[d]],a)}else for(c in a)"function"==typeof a[c]&&this.on(c,a[c],a);return a},f.emit=function(a){var b,e,f=c(this),i=g.call(arguments);for(b=0,e=f.length;e>b;++b)h(a,f[b],i);for(f=d(this,a).slice(),i=g.call(arguments,1),b=0,e=f.length;e>b;++b)h(a,f[b],i)},f.errback=function(a){this.errbacks||(this.errbacks=[]),this.errbacks.push(b(a))},f}var g=Array.prototype.slice;return{createEventEmitter:f,aggregate:function(a){var b=f();return a.forEach(function(a){a.on(function(a,c){b.emit(a,c)})}),b}}})},{}],3:[function(a,b){var c=b.exports={};c.nextTick=function(){var a="undefined"!=typeof window&&window.setImmediate,b="undefined"!=typeof window&&window.postMessage&&window.addEventListener;if(a)return function(a){return window.setImmediate(a)};if(b){var c=[];return window.addEventListener("message",function(a){var b=a.source;if((b===window||null===b)&&"process-tick"===a.data&&(a.stopPropagation(),c.length>0)){var d=c.shift();d()}},!0),function(a){c.push(a),window.postMessage("process-tick","*")}}return function(a){setTimeout(a,0)}}(),c.title="browser",c.browser=!0,c.env={},c.argv=[],c.binding=function(){throw new Error("process.binding is not supported")},c.cwd=function(){return"/"},c.chdir=function(){throw new Error("process.chdir is not supported")}},{}],4:[function(b,c){var d=b("__browserify_process");!function(a){"use strict";a(function(a){function b(a,b,c,d){return f(a).then(b,c,d)}function c(a){return new e(a,Q.PromiseStatus&&Q.PromiseStatus())}function e(a,b){function c(){return i?i.inspect():B()}function d(a,b,c,d,e){function f(f){f._when(a,b,c,d,e)}l?l.push(f):C(function(){f(i)})}function e(a){if(l){var c=l;l=U,C(function(){i=k(h,a),b&&p(i,b),j(c,i)})}}function f(a){e(new n(a))}function g(a){if(l){var b=l;C(function(){j(b,new o(a))})}}var h,i,l=[];h=this,this._status=b,this.inspect=c,this._when=d;try{a(e,f,g)}catch(m){f(m)}}function f(a){return a instanceof e?a:g(a)}function g(a){return c(function(b){b(a)})}function h(a){return b(a,function(a){return new n(a)})}function i(){function a(a,c,f){b.resolve=b.resolver.resolve=function(b){return e?g(b):(e=!0,a(b),d)},b.reject=b.resolver.reject=function(a){return e?g(new n(a)):(e=!0,c(a),d)},b.notify=b.resolver.notify=function(a){return f(a),a}}var b,d,e;return b={promise:U,resolve:U,reject:U,notify:U,resolver:{resolve:U,reject:U,notify:U}},b.promise=d=c(a),b}function j(a,b){for(var c=0;c>>0,i=Math.max(0,Math.min(d,o)),k=[],j=o-i+1,l=[],i)for(n=function(a){l.push(a),--j||(m=n=E,e(l))},m=function(a){k.push(a),--i||(m=n=E,c(k))},p=0;o>p;++p)p in a&&b(a[p],h,g,f);else c(k)}return c(h).then(e,f,g)})}function s(a,b,c,d){function e(a){return b?b(a[0]):a[0]}return r(a,1,e,c,d)}function t(a,b,c,d){return x(a,E).then(b,c,d)}function u(){return x(arguments,E)}function v(a){return x(a,z,A)}function w(a,b){return x(a,b)}function x(a,c,d){return b(a,function(a){function f(e,f,g){function h(a,h){b(a,c,d).then(function(a){i[h]=a,--k||e(i)},f,g)}var i,j,k,l;if(k=j=a.length>>>0,i=[],!k)return e(i),void 0;for(l=0;j>l;l++)l in a?h(a[l],l):--k}return new e(f)})}function y(a,c){var d=K(J,arguments,1);return b(a,function(a){var e;return e=a.length,d[0]=function(a,d,f){return b(a,function(a){return b(d,function(b){return c(a,b,f,e)})})},I.apply(a,d)})}function z(a){return{state:"fulfilled",value:a}}function A(a){return{state:"rejected",reason:a}}function B(){return{state:"pending"}}function C(a){1===M.push(a)&&L(D)}function D(){j(M),M=[]}function E(a){return a}function F(a){throw"function"==typeof Q.reportUnhandled?Q.reportUnhandled():C(function(){throw a}),a}b.promise=c,b.resolve=g,b.reject=h,b.defer=i,b.join=u,b.all=t,b.map=w,b.reduce=y,b.settle=v,b.any=s,b.some=r,b.isPromise=q,b.isPromiseLike=q,G=e.prototype,G.then=function(a,b,c){var d=this;return new e(function(e,f,g){d._when(e,g,a,b,c)},this._status&&this._status.observed())},G["catch"]=G.otherwise=function(a){return this.then(U,a)},G["finally"]=G.ensure=function(a){function b(){return g(a())}return"function"==typeof a?this.then(b,b).yield(this):this},G.done=function(a,b){this.then(a,b)["catch"](F)},G.yield=function(a){return this.then(function(){return a})},G.tap=function(a){return this.then(a).yield(this)},G.spread=function(a){return this.then(function(b){return t(b,function(b){return a.apply(U,b)})})},G.always=function(a,b){return this.then(a,a,b)},H=Object.create||function(a){function b(){}return b.prototype=a,new b},m.prototype=H(G),m.prototype.inspect=function(){return z(this.value)},m.prototype._when=function(a,b,c){try{a("function"==typeof c?c(this.value):this.value)}catch(d){a(new n(d))}},n.prototype=H(G),n.prototype.inspect=function(){return A(this.value)},n.prototype._when=function(a,b,c,d){try{a("function"==typeof d?d(this.value):this)}catch(e){a(new n(e))}},o.prototype=H(G),o.prototype._when=function(a,b,c,d,e){try{b("function"==typeof e?e(this.value):this.value)}catch(f){b(f)}};var G,H,I,J,K,L,M,N,O,P,Q,R,S,T,U;if(S=a,M=[],Q="undefined"!=typeof console?console:b,"object"==typeof d&&d.nextTick)L=d.nextTick;else if(T="function"==typeof MutationObserver&&MutationObserver||"function"==typeof WebKitMutationObserver&&WebKitMutationObserver)L=function(a,b,c){var d=a.createElement("div");return new b(c).observe(d,{attributes:!0}),function(){d.setAttribute("x","x")}}(document,T,D);else try{L=S("vertx").runOnLoop||S("vertx").runOnContext}catch(V){R=setTimeout,L=function(a){R(a,0)}}return N=Function.prototype,O=N.call,K=N.bind?O.bind(O):function(a,b){return a.apply(b,J.call(arguments,2))},P=[],J=P.slice,I=P.reduce||function(a){var b,c,d,e,f;if(f=0,b=Object(this),e=b.length>>>0,c=arguments,c.length<=1)for(;;){if(f in b){d=b[f++];break}if(++f>=e)throw new TypeError}else d=c[1];for(;e>f;++f)f in b&&(d=a(d,b[f],f,b));return d},b})}("function"==typeof a&&a.amd?a:function(a){c.exports=a(b)})},{__browserify_process:3}],5:[function(a,b){function c(a){return this instanceof c?(this._settings=this._configure(a||{}),this._console=this._getConsole(),this._backoffDelay=this._settings.backoffDelayMin,this._pendingRequests={},this._webSocket=null,d.createEventEmitter(this),this._delegateEvents(),this._settings.autoConnect&&this.connect(),void 0):new c(a)}var d=a("bane"),e=a("../lib/websocket/"),f=a("when");c.WebSocket=e.Client,c.prototype._configure=function(a){var b="undefined"!=typeof document&&document.location.host||"localhost";return a.webSocketUrl=a.webSocketUrl||"ws://"+b+"/mopidy/ws/",a.autoConnect!==!1&&(a.autoConnect=!0),a.backoffDelayMin=a.backoffDelayMin||1e3,a.backoffDelayMax=a.backoffDelayMax||64e3,a},c.prototype._getConsole=function(){var a="undefined"!=typeof a&&a||{};return a.log=a.log||function(){},a.warn=a.warn||function(){},a.error=a.error||function(){},a},c.prototype._delegateEvents=function(){this.off("websocket:close"),this.off("websocket:error"),this.off("websocket:incomingMessage"),this.off("websocket:open"),this.off("state:offline"),this.on("websocket:close",this._cleanup),this.on("websocket:error",this._handleWebSocketError),this.on("websocket:incomingMessage",this._handleMessage),this.on("websocket:open",this._resetBackoffDelay),this.on("websocket:open",this._getApiSpec),this.on("state:offline",this._reconnect)},c.prototype.connect=function(){if(this._webSocket){if(this._webSocket.readyState===c.WebSocket.OPEN)return;this._webSocket.close()}this._webSocket=this._settings.webSocket||new c.WebSocket(this._settings.webSocketUrl),this._webSocket.onclose=function(a){this.emit("websocket:close",a)}.bind(this),this._webSocket.onerror=function(a){this.emit("websocket:error",a)}.bind(this),this._webSocket.onopen=function(){this.emit("websocket:open")}.bind(this),this._webSocket.onmessage=function(a){this.emit("websocket:incomingMessage",a)}.bind(this)},c.prototype._cleanup=function(a){Object.keys(this._pendingRequests).forEach(function(b){var c=this._pendingRequests[b];delete this._pendingRequests[b],c.reject({message:"WebSocket closed",closeEvent:a})}.bind(this)),this.emit("state:offline")},c.prototype._reconnect=function(){this.emit("reconnectionPending",{timeToAttempt:this._backoffDelay}),setTimeout(function(){this.emit("reconnecting"),this.connect()}.bind(this),this._backoffDelay),this._backoffDelay=2*this._backoffDelay,this._backoffDelay>this._settings.backoffDelayMax&&(this._backoffDelay=this._settings.backoffDelayMax)},c.prototype._resetBackoffDelay=function(){this._backoffDelay=this._settings.backoffDelayMin},c.prototype.close=function(){this.off("state:offline",this._reconnect),this._webSocket.close()},c.prototype._handleWebSocketError=function(a){this._console.warn("WebSocket error:",a.stack||a)},c.prototype._send=function(a){var b=f.defer();switch(this._webSocket.readyState){case c.WebSocket.CONNECTING:b.resolver.reject({message:"WebSocket is still connecting"});break;case c.WebSocket.CLOSING:b.resolver.reject({message:"WebSocket is closing"});break;case c.WebSocket.CLOSED:b.resolver.reject({message:"WebSocket is closed"});break;default:a.jsonrpc="2.0",a.id=this._nextRequestId(),this._pendingRequests[a.id]=b.resolver,this._webSocket.send(JSON.stringify(a)),this.emit("websocket:outgoingMessage",a)}return b.promise},c.prototype._nextRequestId=function(){var a=-1;return function(){return a+=1}}(),c.prototype._handleMessage=function(a){try{var b=JSON.parse(a.data);b.hasOwnProperty("id")?this._handleResponse(b):b.hasOwnProperty("event")?this._handleEvent(b):this._console.warn("Unknown message type received. Message was: "+a.data)}catch(c){if(!(c instanceof SyntaxError))throw c;this._console.warn("WebSocket message parsing failed. Message was: "+a.data)}},c.prototype._handleResponse=function(a){if(!this._pendingRequests.hasOwnProperty(a.id))return this._console.warn("Unexpected response received. Message was:",a),void 0;var b=this._pendingRequests[a.id];delete this._pendingRequests[a.id],a.hasOwnProperty("result")?b.resolve(a.result):a.hasOwnProperty("error")?(b.reject(a.error),this._console.warn("Server returned error:",a.error)):(b.reject({message:"Response without 'result' or 'error' received",data:{response:a}}),this._console.warn("Response without 'result' or 'error' received. Message was:",a))},c.prototype._handleEvent=function(a){var b=a.event,c=a;delete c.event,this.emit("event:"+this._snakeToCamel(b),c)},c.prototype._getApiSpec=function(){return this._send({method:"core.describe"}).then(this._createApi.bind(this),this._handleWebSocketError).then(null,this._handleWebSocketError)},c.prototype._createApi=function(a){var b=function(a){return function(){var b=Array.prototype.slice.call(arguments);return this._send({method:a,params:b})}.bind(this)}.bind(this),c=function(a){var b=a.split(".");return b.length>=1&&"core"===b[0]&&(b=b.slice(1)),b},d=function(a){var b=this;return a.forEach(function(a){a=this._snakeToCamel(a),b[a]=b[a]||{},b=b[a]}.bind(this)),b}.bind(this),e=function(e){var f=c(e),g=this._snakeToCamel(f.slice(-1)[0]),h=d(f.slice(0,-1));h[g]=b(e),h[g].description=a[e].description,h[g].params=a[e].params}.bind(this);Object.keys(a).forEach(e),this.emit("state:online")},c.prototype._snakeToCamel=function(a){return a.replace(/(_[a-z])/g,function(a){return a.toUpperCase().replace("_","")})},b.exports=c},{"../lib/websocket/":1,bane:2,when:4}]},{},[5])(5)}); \ No newline at end of file +!function(a){if("object"==typeof exports)module.exports=a();else if("function"==typeof define&&define.amd)define(a);else{var b;"undefined"!=typeof window?b=window:"undefined"!=typeof global?b=global:"undefined"!=typeof self&&(b=self),b.Mopidy=a()}}(function(){var a;return function b(a,c,d){function e(g,h){if(!c[g]){if(!a[g]){var i="function"==typeof require&&require;if(!h&&i)return i(g,!0);if(f)return f(g,!0);throw new Error("Cannot find module '"+g+"'")}var j=c[g]={exports:{}};a[g][0].call(j.exports,function(b){var c=a[g][1][b];return e(c?c:b)},j,j.exports,b,a,c,d)}return c[g].exports}for(var f="function"==typeof require&&require,g=0;g0)for(d=0;e>d;++d)c[d](a,b);else setTimeout(function(){throw b.message=a+" listener threw error: "+b.message,b},0)}function b(a){if("function"!=typeof a)throw new TypeError("Listener is not function");return a}function c(a){return a.supervisors||(a.supervisors=[]),a.supervisors}function d(a,b){return a.listeners||(a.listeners={}),b&&!a.listeners[b]&&(a.listeners[b]=[]),b?a.listeners[b]:a.listeners}function e(a){return a.errbacks||(a.errbacks=[]),a.errbacks}function f(f){function h(b,c,d){try{c.listener.apply(c.thisp||f,d)}catch(g){a(b,g,e(f))}}return f=f||{},f.on=function(a,e,f){return"function"==typeof a?c(this).push({listener:a,thisp:e}):void d(this,a).push({listener:b(e),thisp:f})},f.off=function(a,b){var f,g,h,i;if(!a){f=c(this),f.splice(0,f.length),g=d(this);for(h in g)g.hasOwnProperty(h)&&(f=d(this,h),f.splice(0,f.length));return f=e(this),void f.splice(0,f.length)}if("function"==typeof a?(f=c(this),b=a):f=d(this,a),!b)return void f.splice(0,f.length);for(h=0,i=f.length;i>h;++h)if(f[h].listener===b)return void f.splice(h,1)},f.once=function(a,b,c){var d=function(){f.off(a,d),b.apply(this,arguments)};f.on(a,d,c)},f.bind=function(a,b){var c,d,e;if(b)for(d=0,e=b.length;e>d;++d){if("function"!=typeof a[b[d]])throw new Error("No such method "+b[d]);this.on(b[d],a[b[d]],a)}else for(c in a)"function"==typeof a[c]&&this.on(c,a[c],a);return a},f.emit=function(a){var b,e,f=c(this),i=g.call(arguments);for(b=0,e=f.length;e>b;++b)h(a,f[b],i);for(f=d(this,a).slice(),i=g.call(arguments,1),b=0,e=f.length;e>b;++b)h(a,f[b],i)},f.errback=function(a){this.errbacks||(this.errbacks=[]),this.errbacks.push(b(a))},f}var g=Array.prototype.slice;return{createEventEmitter:f,aggregate:function(a){var b=f();return a.forEach(function(a){a.on(function(a,c){b.emit(a,c)})}),b}}})},{}],3:[function(a,b){function c(){}var d=b.exports={};d.nextTick=function(){var a="undefined"!=typeof window&&window.setImmediate,b="undefined"!=typeof window&&window.postMessage&&window.addEventListener;if(a)return function(a){return window.setImmediate(a)};if(b){var c=[];return window.addEventListener("message",function(a){var b=a.source;if((b===window||null===b)&&"process-tick"===a.data&&(a.stopPropagation(),c.length>0)){var d=c.shift();d()}},!0),function(a){c.push(a),window.postMessage("process-tick","*")}}return function(a){setTimeout(a,0)}}(),d.title="browser",d.browser=!0,d.env={},d.argv=[],d.on=c,d.addListener=c,d.once=c,d.off=c,d.removeListener=c,d.removeAllListeners=c,d.emit=c,d.binding=function(){throw new Error("process.binding is not supported")},d.cwd=function(){return"/"},d.chdir=function(){throw new Error("process.chdir is not supported")}},{}],4:[function(b,c){!function(a){"use strict";a(function(a){var b=a("./makePromise"),c=a("./scheduler"),d=a("./async");return b({scheduler:new c(d)})})}("function"==typeof a&&a.amd?a:function(a){c.exports=a(b)})},{"./async":7,"./makePromise":17,"./scheduler":18}],5:[function(b,c){!function(a){"use strict";a(function(){function a(a){this.head=this.tail=this.length=0,this.buffer=new Array(1<f;++f)e[f]=d[f];else{for(a=d.length,b=this.tail;a>c;++f,++c)e[f]=d[c];for(c=0;b>c;++f,++c)e[f]=d[c]}this.buffer=e,this.head=0,this.tail=this.length},a})}("function"==typeof a&&a.amd?a:function(a){c.exports=a()})},{}],6:[function(b,c){!function(a){"use strict";a(function(){function a(b){Error.call(this),this.message=b,this.name=a.name,"function"==typeof Error.captureStackTrace&&Error.captureStackTrace(this,a)}return a.prototype=Object.create(Error.prototype),a.prototype.constructor=a,a})}("function"==typeof a&&a.amd?a:function(a){c.exports=a()})},{}],7:[function(b,c){(function(d){!function(a){"use strict";a(function(a){var b,c;return b="undefined"!=typeof d&&null!==d&&"function"==typeof d.nextTick?function(a){d.nextTick(a)}:(c="function"==typeof MutationObserver&&MutationObserver||"function"==typeof WebKitMutationObserver&&WebKitMutationObserver)?function(a,b){function c(){var a=d;d=void 0,a()}var d,e=a.createElement("div"),f=new b(c);return f.observe(e,{attributes:!0}),function(a){d=a,e.setAttribute("class","x")}}(document,c):function(a){try{return a("vertx").runOnLoop||a("vertx").runOnContext}catch(b){}var c=setTimeout;return function(a){c(a,0)}}(a)})}("function"==typeof a&&a.amd?a:function(a){c.exports=a(b)})}).call(this,b("FWaASH"))},{FWaASH:3}],8:[function(b,c){!function(a){"use strict";a(function(){return function(a){function b(b){return new a(function(a,c){function d(a){f.push(a),0===--e&&c(f)}var e=0,f=[];k.call(b,function(b){++e,l(b).then(a,d)}),0===e&&a()})}function c(b,c){return new a(function(a,d,e){function f(b){i>0&&(--i,j.push(b),0===i&&a(j))}function g(a){h>0&&(--h,m.push(a),0===h&&d(m))}var h,i=0,j=[],m=[];return k.call(b,function(a){++i,l(a).then(f,g,e)}),c=Math.max(c,0),h=i-c+1,i=Math.min(c,i),0===i?void a(j):void 0})}function d(a,b,c){return m(h.call(a,function(a){return l(a).then(b,c)}))}function e(a){return m(h.call(a,function(a){function b(){return a.inspect()}return a=l(a),a.then(b,b)}))}function f(a,b){function c(a,c,d){return l(a).then(function(a){return l(c).then(function(c){return b(a,c,d)})})}return arguments.length>2?i.call(a,c,arguments[2]):i.call(a,c)}function g(a,b){function c(a,c,d){return l(a).then(function(a){return l(c).then(function(c){return b(a,c,d)})})}return arguments.length>2?j.call(a,c,arguments[2]):j.call(a,c)}var h=Array.prototype.map,i=Array.prototype.reduce,j=Array.prototype.reduceRight,k=Array.prototype.forEach,l=a.resolve,m=a.all;return a.any=b,a.some=c,a.settle=e,a.map=d,a.reduce=f,a.reduceRight=g,a.prototype.spread=function(a){return this.then(m).then(function(b){return a.apply(void 0,b)})},a}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a()})},{}],9:[function(b,c){!function(a){"use strict";a(function(){function a(){throw new TypeError("catch predicate must be a function")}function b(a,b){return c(b)?a instanceof b:b(a)}function c(a){return a===Error||null!=a&&a.prototype instanceof Error}function d(a,b){return function(){return a.call(this),b}}function e(){}return function(c){function f(a,c){return function(d){return b(d,c)?a.call(this,d):g(d)}}var g=c.reject,h=c.prototype["catch"];return c.prototype.done=function(a,b){var c=this._handler;c.when({resolve:this._maybeFatal,notify:e,context:this,receiver:c.receiver,fulfilled:a,rejected:b,progress:void 0})},c.prototype["catch"]=c.prototype.otherwise=function(b){return 1===arguments.length?h.call(this,b):"function"!=typeof b?this.ensure(a):h.call(this,f(arguments[1],b))},c.prototype["finally"]=c.prototype.ensure=function(a){return"function"!=typeof a?this:(a=d(a,this),this.then(a,a))},c.prototype["else"]=c.prototype.orElse=function(a){return this.then(void 0,function(){return a})},c.prototype["yield"]=function(a){return this.then(function(){return a})},c.prototype.tap=function(a){return this.then(a)["yield"](this)},c}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a()})},{}],10:[function(b,c){!function(a){"use strict";a(function(){return function(a){return a.prototype.fold=function(a,b){var c=this._beget();return this._handler.fold(c._handler,a,b),c},a}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a()})},{}],11:[function(b,c){!function(a){"use strict";a(function(){return function(a){return a.prototype.inspect=function(){return this._handler.inspect()},a}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a()})},{}],12:[function(b,c){!function(a){"use strict";a(function(){return function(a){function b(a,b,d,e){return c(function(b){return[b,a(b)]},b,d,e)}function c(a,b,e,f){function g(f,g){return d(e(f)).then(function(){return c(a,b,e,g)})}return d(f).then(function(c){return d(b(c)).then(function(b){return b?c:d(a(c)).spread(g)})})}var d=a.resolve;return a.iterate=b,a.unfold=c,a}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a()})},{}],13:[function(b,c){!function(a){"use strict";a(function(){return function(a){return a.prototype.progress=function(a){return this.then(void 0,void 0,a)},a}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a()})},{}],14:[function(b,c){!function(a){"use strict";a(function(a){var b=a("../timer"),c=a("../TimeoutError");return function(a){return a.prototype.delay=function(a){var c=this._beget(),d=c._handler;return this._handler.map(function(c){b.set(function(){d.resolve(c)},a)},d),c},a.prototype.timeout=function(a,d){function e(){h.reject(f?d:new c("timed out after "+a+"ms"))}var f=arguments.length>1,g=this._beget(),h=g._handler,i=b.set(e,a);return this._handler.chain(h,function(a){b.clear(i),this.resolve(a)},function(a){b.clear(i),this.reject(a)},h.notify),g},a}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a(b)})},{"../TimeoutError":6,"../timer":19}],15:[function(b,c){!function(a){"use strict";a(function(a){function b(a){var b="object"==typeof a&&a.stack?a.stack:c(a);return a instanceof Error?b:b+" (WARNING: non-Error used)"}function c(a){var b=String(a);return"[object Object]"===b&&"undefined"!=typeof JSON&&(b=d(a,b)),b}function d(a,b){try{return JSON.stringify(a)}catch(a){return b}}function e(a){throw a}function f(){}var g=a("../timer");return function(a){function d(a){a.handled||(n.push(a),k("Potentially unhandled rejection ["+a.id+"] "+b(a.value)))}function h(a){var b=n.indexOf(a);b>=0&&(n.splice(b,1),l("Handled previous rejection ["+a.id+"] "+c(a.value)))}function i(a,b){m.push(a,b),o||(o=!0,o=g.set(j,0))}function j(){for(o=!1;m.length>0;)m.shift()(m.shift())}var k=f,l=f;"undefined"!=typeof console&&(k="undefined"!=typeof console.error?function(a){console.error(a)}:function(a){console.log(a)},l="undefined"!=typeof console.info?function(a){console.info(a)}:function(a){console.log(a)}),a.onPotentiallyUnhandledRejection=function(a){i(d,a)},a.onPotentiallyUnhandledRejectionHandled=function(a){i(h,a)},a.onFatalRejection=function(a){i(e,a.value)};var m=[],n=[],o=!1;return a}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a(b)})},{"../timer":19}],16:[function(b,c){!function(a){"use strict";a(function(){return function(a){return a.prototype["with"]=a.prototype.withThis=a.prototype._bindContext,a}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a()})},{}],17:[function(b,c){!function(a){"use strict";a(function(){return function(a){function b(a,b){this._handler=a===m?b:c(a)}function c(a){function b(a){e.resolve(a)}function c(a){e.reject(a)}function d(a){e.notify(a)}var e=new n;try{a(b,c,d)}catch(f){c(f)}return e}function d(a){return k(a)?a:new b(m,new p(j(a)))}function e(a){return new b(m,new p(new t(a)))}function f(){return M}function g(){return new b(m,new n)}function h(a){function c(a,b,c,d){c.map(function(a){b[d]=a,0===--i&&this.become(new s(b))},a)}var d,e,f,g,h=new n,i=a.length>>>0,j=new Array(i);for(d=0;d0)){h.become(e);break}j[d]=e.value,--i}else j[d]=f,--i;else--i;return 0===i&&h.become(new s(j)),new b(m,h)}function i(a){if(Object(a)===a&&0===a.length)return f();var c,d,e=new n;for(c=0;c0)return new b(m,d);var e=this._beget(),f=e._handler;return d.when({resolve:f.resolve,notify:f.notify,context:f,receiver:d.receiver,fulfilled:a,rejected:c,progress:arguments.length>2?arguments[2]:void 0}),e},b.prototype["catch"]=function(a){return this.then(void 0,a)},b.prototype._bindContext=function(a){return new b(m,new q(this._handler,a))},b.prototype._beget=function(){var a=this._handler,b=new n(a.receiver,a.join().context);return new this.constructor(m,b)},b.prototype._maybeFatal=function(a){if(C(a)){var b=j(a),c=this._handler.context;b.catchError(function(){this._fatal(c)},b)}},b.all=h,b.race=i,m.prototype.when=m.prototype.resolve=m.prototype.reject=m.prototype.notify=m.prototype._fatal=m.prototype._unreport=m.prototype._report=H,m.prototype.inspect=x,m.prototype._state=0,m.prototype.state=function(){return this._state},m.prototype.join=function(){for(var a=this;void 0!==a.handler;)a=a.handler;return a},m.prototype.chain=function(a,b,c,d){this.when({resolve:H,notify:H,context:void 0,receiver:a,fulfilled:b,rejected:c,progress:d})},m.prototype.map=function(a,b){this.chain(b,a,b.reject,b.notify)},m.prototype.catchError=function(a,b){this.chain(b,b.resolve,a,b.notify)},m.prototype.fold=function(a,b,c){this.join().map(function(a){j(c).map(function(c){this.resolve(E(b,c,a,this.receiver))},this)},a)},G(m,n),n.prototype._state=0,n.prototype.inspect=function(){return this.resolved?this.join().inspect():x()},n.prototype.resolve=function(a){this.resolved||this.become(j(a))},n.prototype.reject=function(a){this.resolved||this.become(new t(a))},n.prototype.join=function(){if(this.resolved){for(var a=this;void 0!==a.handler;)if(a=a.handler,a===this)return this.handler=new w;return a}return this},n.prototype.run=function(){var a=this.consumers,b=this.join();this.consumers=void 0;for(var c=0;c0;)a.shift().run();for(this._running=!1,a=this._afterQueue;a.length>0;)a.shift()(a.shift(),a.shift())},b})}("function"==typeof a&&a.amd?a:function(a){c.exports=a(b)})},{"./Queue":5}],19:[function(b,c){!function(a){"use strict";a(function(a){var b,c,d,e;b=a;try{c=b("vertx"),d=function(a,b){return c.setTimer(b,a)},e=c.cancelTimer}catch(f){d=function(a,b){return setTimeout(a,b)},e=function(a){return clearTimeout(a)}}return{set:d,clear:e}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a(b)})},{}],20:[function(b,c){!function(a){"use strict";a(function(a){function b(a,b,c){var d=z.resolve(a);return arguments.length<2?d:arguments.length>3?d.then(b,c,arguments[3]):d.then(b,c)}function c(a){return new z(a)}function d(a){return function(){return f(a,this,A.call(arguments))}}function e(a){return f(a,this,A.call(arguments,1))}function f(a,b,c){return z.all(c).then(function(c){return a.apply(b,c)})}function g(){return new h}function h(){function a(a){d._handler.resolve(a)}function b(a){d._handler.reject(a)}function c(a){d._handler.notify(a)}var d=z._defer();this.promise=d,this.resolve=a,this.reject=b,this.notify=c,this.resolver={resolve:a,reject:b,notify:c}}function i(a){return a&&"function"==typeof a.then}function j(){return z.all(arguments)}function k(a){return b(a,z.all)}function l(a){return b(a,z.settle)}function m(a,c){return b(a,function(a){return z.map(a,c)})}function n(a){var c=A.call(arguments,1);return b(a,function(a){return c.unshift(a),z.reduce.apply(z,c)})}function o(a){var c=A.call(arguments,1);return b(a,function(a){return c.unshift(a),z.reduceRight.apply(z,c)})}var p=a("./lib/decorators/timed"),q=a("./lib/decorators/array"),r=a("./lib/decorators/flow"),s=a("./lib/decorators/fold"),t=a("./lib/decorators/inspect"),u=a("./lib/decorators/iterate"),v=a("./lib/decorators/progress"),w=a("./lib/decorators/with"),x=a("./lib/decorators/unhandledRejection"),y=a("./lib/TimeoutError"),z=[q,r,s,u,v,t,w,p,x].reduce(function(a,b){return b(a)},a("./lib/Promise")),A=Array.prototype.slice;return b.promise=c,b.resolve=z.resolve,b.reject=z.reject,b.lift=d,b["try"]=e,b.attempt=e,b.iterate=z.iterate,b.unfold=z.unfold,b.join=j,b.all=k,b.settle=l,b.any=d(z.any),b.some=d(z.some),b.map=m,b.reduce=n,b.reduceRight=o,b.isPromiseLike=i,b.Promise=z,b.defer=g,b.TimeoutError=y,b})}("function"==typeof a&&a.amd?a:function(a){c.exports=a(b)})},{"./lib/Promise":4,"./lib/TimeoutError":6,"./lib/decorators/array":8,"./lib/decorators/flow":9,"./lib/decorators/fold":10,"./lib/decorators/inspect":11,"./lib/decorators/iterate":12,"./lib/decorators/progress":13,"./lib/decorators/timed":14,"./lib/decorators/unhandledRejection":15,"./lib/decorators/with":16}],21:[function(a,b){function c(a){return this instanceof c?(this._console=this._getConsole(a||{}),this._settings=this._configure(a||{}),this._backoffDelay=this._settings.backoffDelayMin,this._pendingRequests={},this._webSocket=null,d.createEventEmitter(this),this._delegateEvents(),void(this._settings.autoConnect&&this.connect())):new c(a)}var d=a("bane"),e=a("../lib/websocket/"),f=a("when");c.ConnectionError=function(a){this.name="ConnectionError",this.message=a},c.ConnectionError.prototype=new Error,c.ConnectionError.prototype.constructor=c.ConnectionError,c.ServerError=function(a){this.name="ServerError",this.message=a},c.ServerError.prototype=new Error,c.ServerError.prototype.constructor=c.ServerError,c.WebSocket=e.Client,c.prototype._getConsole=function(a){if("undefined"!=typeof a.console)return a.console;var b="undefined"!=typeof console&&console||{};return b.log=b.log||function(){},b.warn=b.warn||function(){},b.error=b.error||function(){},b},c.prototype._configure=function(a){var b="undefined"!=typeof document&&document.location.host||"localhost";return a.webSocketUrl=a.webSocketUrl||"ws://"+b+"/mopidy/ws",a.autoConnect!==!1&&(a.autoConnect=!0),a.backoffDelayMin=a.backoffDelayMin||1e3,a.backoffDelayMax=a.backoffDelayMax||64e3,"undefined"==typeof a.callingConvention&&this._console.warn("Mopidy.js is using the default calling convention. The default will change in the future. You should explicitly specify which calling convention you use."),a.callingConvention=a.callingConvention||"by-position-only",a},c.prototype._delegateEvents=function(){this.off("websocket:close"),this.off("websocket:error"),this.off("websocket:incomingMessage"),this.off("websocket:open"),this.off("state:offline"),this.on("websocket:close",this._cleanup),this.on("websocket:error",this._handleWebSocketError),this.on("websocket:incomingMessage",this._handleMessage),this.on("websocket:open",this._resetBackoffDelay),this.on("websocket:open",this._getApiSpec),this.on("state:offline",this._reconnect)},c.prototype.connect=function(){if(this._webSocket){if(this._webSocket.readyState===c.WebSocket.OPEN)return;this._webSocket.close()}this._webSocket=this._settings.webSocket||new c.WebSocket(this._settings.webSocketUrl),this._webSocket.onclose=function(a){this.emit("websocket:close",a)}.bind(this),this._webSocket.onerror=function(a){this.emit("websocket:error",a)}.bind(this),this._webSocket.onopen=function(){this.emit("websocket:open")}.bind(this),this._webSocket.onmessage=function(a){this.emit("websocket:incomingMessage",a)}.bind(this)},c.prototype._cleanup=function(a){Object.keys(this._pendingRequests).forEach(function(b){var d=this._pendingRequests[b];delete this._pendingRequests[b];var e=new c.ConnectionError("WebSocket closed");e.closeEvent=a,d.reject(e)}.bind(this)),this.emit("state:offline")},c.prototype._reconnect=function(){this.emit("reconnectionPending",{timeToAttempt:this._backoffDelay}),setTimeout(function(){this.emit("reconnecting"),this.connect()}.bind(this),this._backoffDelay),this._backoffDelay=2*this._backoffDelay,this._backoffDelay>this._settings.backoffDelayMax&&(this._backoffDelay=this._settings.backoffDelayMax)},c.prototype._resetBackoffDelay=function(){this._backoffDelay=this._settings.backoffDelayMin},c.prototype.close=function(){this.off("state:offline",this._reconnect),this._webSocket.close()},c.prototype._handleWebSocketError=function(a){this._console.warn("WebSocket error:",a.stack||a)},c.prototype._send=function(a){switch(this._webSocket.readyState){case c.WebSocket.CONNECTING:return f.reject(new c.ConnectionError("WebSocket is still connecting"));case c.WebSocket.CLOSING:return f.reject(new c.ConnectionError("WebSocket is closing"));case c.WebSocket.CLOSED:return f.reject(new c.ConnectionError("WebSocket is closed"));default:var b=f.defer();return a.jsonrpc="2.0",a.id=this._nextRequestId(),this._pendingRequests[a.id]=b.resolver,this._webSocket.send(JSON.stringify(a)),this.emit("websocket:outgoingMessage",a),b.promise}},c.prototype._nextRequestId=function(){var a=-1;return function(){return a+=1}}(),c.prototype._handleMessage=function(a){try{var b=JSON.parse(a.data);b.hasOwnProperty("id")?this._handleResponse(b):b.hasOwnProperty("event")?this._handleEvent(b):this._console.warn("Unknown message type received. Message was: "+a.data)}catch(c){if(!(c instanceof SyntaxError))throw c;this._console.warn("WebSocket message parsing failed. Message was: "+a.data)}},c.prototype._handleResponse=function(a){if(!this._pendingRequests.hasOwnProperty(a.id))return void this._console.warn("Unexpected response received. Message was:",a);var b,d=this._pendingRequests[a.id];delete this._pendingRequests[a.id],a.hasOwnProperty("result")?d.resolve(a.result):a.hasOwnProperty("error")?(b=new c.ServerError(a.error.message),b.code=a.error.code,b.data=a.error.data,d.reject(b),this._console.warn("Server returned error:",a.error)):(b=new Error("Response without 'result' or 'error' received"),b.data={response:a},d.reject(b),this._console.warn("Response without 'result' or 'error' received. Message was:",a))},c.prototype._handleEvent=function(a){var b=a.event,c=a;delete c.event,this.emit("event:"+this._snakeToCamel(b),c)},c.prototype._getApiSpec=function(){return this._send({method:"core.describe"}).then(this._createApi.bind(this)).catch(this._handleWebSocketError)},c.prototype._createApi=function(a){var b="by-position-or-by-name"===this._settings.callingConvention,c=function(a){return function(){var c={method:a};return 0===arguments.length?this._send(c):b?arguments.length>1?f.reject(new Error("Expected zero arguments, a single array, or a single object.")):Array.isArray(arguments[0])||arguments[0]===Object(arguments[0])?(c.params=arguments[0],this._send(c)):f.reject(new TypeError("Expected an array or an object.")):(c.params=Array.prototype.slice.call(arguments),this._send(c))}.bind(this)}.bind(this),d=function(a){var b=a.split(".");return b.length>=1&&"core"===b[0]&&(b=b.slice(1)),b},e=function(a){var b=this;return a.forEach(function(a){a=this._snakeToCamel(a),b[a]=b[a]||{},b=b[a]}.bind(this)),b}.bind(this),g=function(b){var f=d(b),g=this._snakeToCamel(f.slice(-1)[0]),h=e(f.slice(0,-1));h[g]=c(b),h[g].description=a[b].description,h[g].params=a[b].params}.bind(this);Object.keys(a).forEach(g),this.emit("state:online")},c.prototype._snakeToCamel=function(a){return a.replace(/(_[a-z])/g,function(a){return a.toUpperCase().replace("_","")})},b.exports=c},{"../lib/websocket/":1,bane:2,when:20}]},{},[21])(21)}); \ No newline at end of file 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/handlers.py b/mopidy/http/handlers.py new file mode 100644 index 00000000..4ea2e97d --- /dev/null +++ b/mopidy/http/handlers.py @@ -0,0 +1,186 @@ +from __future__ import unicode_literals + +import logging +import os +import socket + +import tornado.escape +import tornado.web +import tornado.websocket + +import mopidy +from mopidy import core, models +from mopidy.utils import jsonrpc + + +logger = logging.getLogger(__name__) + + +def make_mopidy_app_factory(apps, statics): + def mopidy_app_factory(config, core): + return [ + (r'/ws/?', WebSocketHandler, { + 'core': core, + }), + (r'/rpc', JsonRpcHandler, { + 'core': core, + }), + (r'/(.+)', StaticFileHandler, { + 'path': os.path.join(os.path.dirname(__file__), 'data'), + }), + (r'/', ClientListHandler, { + 'apps': apps, + 'statics': statics, + }), + ] + return mopidy_app_factory + + +def make_jsonrpc_wrapper(core_actor): + inspector = jsonrpc.JsonRpcInspector( + objects={ + 'core.get_uri_schemes': core.Core.get_uri_schemes, + 'core.get_version': core.Core.get_version, + 'core.library': core.LibraryController, + 'core.playback': core.PlaybackController, + 'core.playlists': core.PlaylistsController, + 'core.tracklist': core.TracklistController, + }) + return jsonrpc.JsonRpcWrapper( + objects={ + 'core.describe': inspector.describe, + 'core.get_uri_schemes': core_actor.get_uri_schemes, + 'core.get_version': core_actor.get_version, + 'core.library': core_actor.library, + 'core.playback': core_actor.playback, + 'core.playlists': core_actor.playlists, + 'core.tracklist': core_actor.tracklist, + }, + decoders=[models.model_json_decoder], + encoders=[models.ModelJSONEncoder] + ) + + +class WebSocketHandler(tornado.websocket.WebSocketHandler): + + # XXX This set is shared by all WebSocketHandler objects. This isn't + # optimal, but there's currently no use case for having more than one of + # these anyway. + clients = set() + + @classmethod + def broadcast(cls, msg): + for client in cls.clients: + client.write_message(msg) + + def initialize(self, core): + self.jsonrpc = make_jsonrpc_wrapper(core) + + def open(self): + if hasattr(self, 'set_nodelay'): + # New in Tornado 3.1 + self.set_nodelay(True) + else: + self.stream.socket.setsockopt( + socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + self.clients.add(self) + logger.debug( + 'New WebSocket connection from %s', self.request.remote_ip) + + def on_close(self): + self.clients.discard(self) + logger.debug( + 'Closed WebSocket connection from %s', + self.request.remote_ip) + + def on_message(self, message): + if not message: + return + + logger.debug( + 'Received WebSocket message from %s: %r', + self.request.remote_ip, message) + + try: + response = self.jsonrpc.handle_json( + tornado.escape.native_str(message)) + if response and self.write_message(response): + logger.debug( + 'Sent WebSocket message to %s: %r', + self.request.remote_ip, response) + except Exception as e: + logger.error('WebSocket request error: %s', e) + self.close() + + +def set_mopidy_headers(request_handler): + request_handler.set_header('Cache-Control', 'no-cache') + request_handler.set_header( + 'X-Mopidy-Version', mopidy.__version__.encode('utf-8')) + + +class JsonRpcHandler(tornado.web.RequestHandler): + def initialize(self, core): + self.jsonrpc = make_jsonrpc_wrapper(core) + + def head(self): + self.set_extra_headers() + self.finish() + + def post(self): + data = self.request.body + if not data: + return + + logger.debug( + 'Received RPC message from %s: %r', self.request.remote_ip, data) + + try: + self.set_extra_headers() + response = self.jsonrpc.handle_json( + tornado.escape.native_str(data)) + if response and self.write(response): + logger.debug( + 'Sent RPC message to %s: %r', + self.request.remote_ip, response) + except Exception as e: + logger.error('HTTP JSON-RPC request error:', e) + self.write_error(500) + + def set_extra_headers(self): + set_mopidy_headers(self) + self.set_header('Accept', 'application/json') + self.set_header('Content-Type', 'application/json; utf-8') + + +class ClientListHandler(tornado.web.RequestHandler): + def initialize(self, apps, statics): + self.apps = apps + self.statics = statics + + def get_template_path(self): + return os.path.dirname(__file__) + + def get(self): + set_mopidy_headers(self) + + names = set() + for app in self.apps: + names.add(app['name']) + for static in self.statics: + names.add(static['name']) + names.discard('mopidy') + + self.render('data/clients.html', apps=sorted(list(names))) + + +class StaticFileHandler(tornado.web.StaticFileHandler): + def set_extra_headers(self, path): + set_mopidy_headers(self) + + +class AddSlashHandler(tornado.web.RequestHandler): + + @tornado.web.addslash + def prepare(self): + return super(AddSlashHandler, self).prepare() diff --git a/mopidy/http/ws.py b/mopidy/http/ws.py deleted file mode 100644 index 4d7aa9a2..00000000 --- a/mopidy/http/ws.py +++ /dev/null @@ -1,73 +0,0 @@ -from __future__ import unicode_literals - -import logging - -import cherrypy -from ws4py.websocket import WebSocket - -from mopidy import core, models -from mopidy.utils import jsonrpc - - -logger = logging.getLogger(__name__) - - -class WebSocketResource(object): - def __init__(self, core_proxy): - self._core = core_proxy - inspector = jsonrpc.JsonRpcInspector( - objects={ - 'core.get_uri_schemes': core.Core.get_uri_schemes, - 'core.get_version': core.Core.get_version, - 'core.library': core.LibraryController, - 'core.playback': core.PlaybackController, - 'core.playlists': core.PlaylistsController, - 'core.tracklist': core.TracklistController, - }) - self.jsonrpc = jsonrpc.JsonRpcWrapper( - objects={ - 'core.describe': inspector.describe, - 'core.get_uri_schemes': self._core.get_uri_schemes, - 'core.get_version': self._core.get_version, - 'core.library': self._core.library, - 'core.playback': self._core.playback, - 'core.playlists': self._core.playlists, - 'core.tracklist': self._core.tracklist, - }, - decoders=[models.model_json_decoder], - encoders=[models.ModelJSONEncoder]) - - @cherrypy.expose - def index(self): - logger.debug('WebSocket handler created') - cherrypy.request.ws_handler.jsonrpc = self.jsonrpc - - -class WebSocketHandler(WebSocket): - def opened(self): - remote = cherrypy.request.remote - logger.debug( - 'New WebSocket connection from %s:%d', - remote.ip, remote.port) - - def closed(self, code, reason=None): - remote = cherrypy.request.remote - logger.debug( - 'Closed WebSocket connection from %s:%d ' - 'with code %s and reason %r', - remote.ip, remote.port, code, reason) - - def received_message(self, request): - remote = cherrypy.request.remote - request = str(request) - - logger.debug( - 'Received WebSocket message from %s:%d: %r', - remote.ip, remote.port, request) - - response = self.jsonrpc.handle_json(request) - if response: - self.send(response) - logger.debug( - 'Sent WebSocket message to %s:%d: %r', - remote.ip, remote.port, response) diff --git a/mopidy/listener.py b/mopidy/listener.py index cce5556d..c8ecfa53 100644 --- a/mopidy/listener.py +++ b/mopidy/listener.py @@ -3,6 +3,7 @@ from __future__ import unicode_literals import logging import gobject + import pykka logger = logging.getLogger(__name__) diff --git a/mopidy/local/actor.py b/mopidy/local/actor.py index 61becc72..590d7867 100644 --- a/mopidy/local/actor.py +++ b/mopidy/local/actor.py @@ -1,16 +1,15 @@ from __future__ import unicode_literals import logging -import os import pykka from mopidy import backend -from mopidy.utils import encoding, path +from mopidy.local import storage +from mopidy.local.library import LocalLibraryProvider +from mopidy.local.playback import LocalPlaybackProvider +from mopidy.local.playlists import LocalPlaylistsProvider -from .library import LocalLibraryProvider -from .playback import LocalPlaybackProvider -from .playlists import LocalPlaylistsProvider logger = logging.getLogger(__name__) @@ -24,7 +23,7 @@ class LocalBackend(pykka.ThreadingActor, backend.Backend): self.config = config - self.check_dirs_and_files() + storage.check_dirs_and_files(config) libraries = dict((l.name, l) for l in self.libraries) library_name = config['local']['library'] @@ -39,23 +38,3 @@ class LocalBackend(pykka.ThreadingActor, backend.Backend): self.playback = LocalPlaybackProvider(audio=audio, backend=self) self.playlists = LocalPlaylistsProvider(backend=self) self.library = LocalLibraryProvider(backend=self, library=library) - - def check_dirs_and_files(self): - if not os.path.isdir(self.config['local']['media_dir']): - logger.warning('Local media dir %s does not exist.' % - self.config['local']['media_dir']) - - try: - path.get_or_create_dir(self.config['local']['data_dir']) - except EnvironmentError as error: - logger.warning( - 'Could not create local data dir: %s', - encoding.locale_decode(error)) - - # TODO: replace with data dir? - try: - path.get_or_create_dir(self.config['local']['playlists_dir']) - except EnvironmentError as error: - logger.warning( - 'Could not create local playlists dir: %s', - encoding.locale_decode(error)) diff --git a/mopidy/local/commands.py b/mopidy/local/commands.py index 85939b43..1e7839a5 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 @@ -6,9 +6,9 @@ import time from mopidy import commands, exceptions from mopidy.audio import scan +from mopidy.local import translator from mopidy.utils import path -from . import translator logger = logging.getLogger(__name__) @@ -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 @@ -65,59 +65,59 @@ class ScanCommand(commands.Command): scan_timeout = config['local']['scan_timeout'] flush_threshold = config['local']['scan_flush_threshold'] excluded_file_extensions = config['local']['excluded_file_extensions'] - excluded_file_extensions = set( - file_ext.lower() for file_ext in excluded_file_extensions) + excluded_file_extensions = tuple( + bytes(file_ext.lower()) for file_ext in excluded_file_extensions) library = _get_library(args, config) - uri_path_mapping = {} uris_in_library = set() uris_to_update = set() uris_to_remove = set() + file_mtimes = path.find_mtimes(media_dir) + logger.info('Found %d files in media_dir.', len(file_mtimes)) + num_tracks = library.load() logger.info('Checking %d tracks from library.', num_tracks) for track in library.begin(): - uri_path_mapping[track.uri] = translator.local_track_uri_to_path( - track.uri, media_dir) - try: - stat = os.stat(uri_path_mapping[track.uri]) - if int(stat.st_mtime) > track.last_modified: - uris_to_update.add(track.uri) - uris_in_library.add(track.uri) - except OSError: + abspath = translator.local_track_uri_to_path(track.uri, media_dir) + mtime = file_mtimes.pop(abspath, None) + if mtime is None: logger.debug('Missing file %s', track.uri) uris_to_remove.add(track.uri) + elif mtime > track.last_modified: + uris_in_library.add(track.uri) logger.info('Removing %d missing tracks.', len(uris_to_remove)) for uri in uris_to_remove: library.remove(uri) - logger.info('Checking %s for unknown tracks.', media_dir) - for relpath in path.find_files(media_dir): + for abspath in file_mtimes: + relpath = os.path.relpath(abspath, media_dir) uri = translator.path_to_local_track_uri(relpath) - file_extension = os.path.splitext(relpath)[1] - if file_extension.lower() in excluded_file_extensions: + if relpath.lower().endswith(excluded_file_extensions): logger.debug('Skipped %s: File extension excluded.', uri) continue - if uri not in uris_in_library: - uris_to_update.add(uri) - uri_path_mapping[uri] = os.path.join(media_dir, relpath) + uris_to_update.add(uri) - logger.info('Found %d unknown tracks.', len(uris_to_update)) + logger.info( + 'Found %d tracks which need to be updated.', len(uris_to_update)) logger.info('Scanning...') - uris_to_update = sorted(uris_to_update)[:args.limit] + uris_to_update = sorted(uris_to_update, key=lambda v: v.lower()) + uris_to_update = uris_to_update[:args.limit] scanner = scan.Scanner(scan_timeout) progress = _Progress(flush_threshold, len(uris_to_update)) for uri in uris_to_update: try: - data = scanner.scan(path.path_to_uri(uri_path_mapping[uri])) + 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) library.add(track) logger.debug('Added %s', track.uri) diff --git a/mopidy/local/ext.conf b/mopidy/local/ext.conf index 8f1e860c..9a0f19f1 100644 --- a/mopidy/local/ext.conf +++ b/mopidy/local/ext.conf @@ -7,6 +7,7 @@ playlists_dir = $XDG_DATA_DIR/mopidy/local/playlists scan_timeout = 1000 scan_flush_threshold = 1000 excluded_file_extensions = + .directory .html .jpeg .jpg diff --git a/mopidy/local/json.py b/mopidy/local/json.py index 10611f6f..927f8898 100644 --- a/mopidy/local/json.py +++ b/mopidy/local/json.py @@ -12,18 +12,29 @@ import time import mopidy from mopidy import local, models -from mopidy.local import search, translator +from mopidy.local import search, storage, translator +from mopidy.utils import encoding logger = logging.getLogger(__name__) # TODO: move to load and dump in models? def load_library(json_file): + if not os.path.isfile(json_file): + logger.info( + 'No local library metadata cache found at %s. Please run ' + '`mopidy local scan` to index your local music library. ' + 'If you do not have a local music collection, you can disable the ' + 'local backend to hide this message.', + json_file) + return {} try: with gzip.open(json_file, 'rb') as fp: return json.load(fp, object_hook=models.model_json_decoder) - except (IOError, ValueError) as e: - logger.warning('Loading JSON local library failed: %s', e) + except (IOError, ValueError) as error: + logger.warning( + 'Loading JSON local library failed: %s', + encoding.locale_decode(error)) return {} @@ -121,6 +132,8 @@ class JsonLibrary(local.Library): self._json_file = os.path.join( config['local']['data_dir'], b'library.json.gz') + storage.check_dirs_and_files(config) + def browse(self, uri): if not self._browse_cache: return [] diff --git a/mopidy/local/playback.py b/mopidy/local/playback.py index bd798589..aa0e5b3a 100644 --- a/mopidy/local/playback.py +++ b/mopidy/local/playback.py @@ -3,8 +3,8 @@ from __future__ import unicode_literals import logging from mopidy import backend +from mopidy.local import translator -from . import translator logger = logging.getLogger(__name__) diff --git a/mopidy/local/storage.py b/mopidy/local/storage.py new file mode 100644 index 00000000..d83bdf77 --- /dev/null +++ b/mopidy/local/storage.py @@ -0,0 +1,30 @@ +from __future__ import unicode_literals + +import logging +import os + +from mopidy.utils import encoding, path + +logger = logging.getLogger(__name__) + + +def check_dirs_and_files(config): + if not os.path.isdir(config['local']['media_dir']): + logger.warning( + 'Local media dir %s does not exist.' % + config['local']['media_dir']) + + try: + path.get_or_create_dir(config['local']['data_dir']) + except EnvironmentError as error: + logger.warning( + 'Could not create local data dir: %s', + encoding.locale_decode(error)) + + # TODO: replace with data dir? + try: + path.get_or_create_dir(config['local']['playlists_dir']) + except EnvironmentError as error: + logger.warning( + 'Could not create local playlists dir: %s', + encoding.locale_decode(error)) diff --git a/mopidy/local/translator.py b/mopidy/local/translator.py index c3f9874b..33b67775 100644 --- a/mopidy/local/translator.py +++ b/mopidy/local/translator.py @@ -3,13 +3,14 @@ from __future__ import unicode_literals import logging import os import re -import urlparse import urllib +import urlparse from mopidy.models import Track from mopidy.utils.encoding import locale_decode from mopidy.utils.path import path_to_uri, uri_to_path + M3U_EXTINF_RE = re.compile(r'#EXTINF:(-1|\d+),(.*)') logger = logging.getLogger(__name__) diff --git a/mopidy/mixer.py b/mopidy/mixer.py new file mode 100644 index 00000000..be793a7c --- /dev/null +++ b/mopidy/mixer.py @@ -0,0 +1,149 @@ +from __future__ import unicode_literals + +import logging + +from mopidy import listener + + +logger = logging.getLogger(__name__) + + +class Mixer(object): + """ + Audio mixer API + + If the mixer has problems during initialization it should raise + :exc:`mopidy.exceptions.MixerError` with a descriptive error message. This + will make Mopidy print the error message and exit so that the user can fix + the issue. + + :param config: the entire Mopidy configuration + :type config: dict + """ + + name = None + """ + Name of the mixer. + + Used when configuring what mixer to use. Should match the + :attr:`~mopidy.ext.Extension.ext_name` of the extension providing the + mixer. + """ + + def __init__(self, config): + pass + + def get_volume(self): + """ + Get volume level of the mixer on a linear scale from 0 to 100. + + Example values: + + 0: + Minimum volume, usually silent. + 100: + Maximum volume. + :class:`None`: + Volume is unknown. + + *MAY be implemented by subclass.* + + :rtype: int in range [0..100] or :class:`None` + """ + return None + + def set_volume(self, volume): + """ + Set volume level of the mixer. + + *MAY be implemented by subclass.* + + :param volume: Volume in the range [0..100] + :type volume: int + :rtype: :class:`True` if success, :class:`False` if failure + """ + return False + + def trigger_volume_changed(self, volume): + """ + Send ``volume_changed`` event to all mixer listeners. + + This method should be called by subclasses when the volume is changed, + either because of a call to :meth:`set_volume` or because of any + external entity changing the volume. + """ + logger.debug('Mixer event: volume_changed(volume=%d)', volume) + MixerListener.send('volume_changed', volume=volume) + + def get_mute(self): + """ + Get mute state of the mixer. + + *MAY be implemented by subclass.* + + :rtype: :class:`True` if muted, :class:`False` if unmuted, + :class:`None` if unknown. + """ + return None + + def set_mute(self, mute): + """ + Mute or unmute the mixer. + + *MAY be implemented by subclass.* + + :param mute: :class:`True` to mute, :class:`False` to unmute + :type mute: bool + :rtype: :class:`True` if success, :class:`False` if failure + """ + return False + + def trigger_mute_changed(self, mute): + """ + Send ``mute_changed`` event to all mixer listeners. + + This method should be called by subclasses when the mute state is + changed, either because of a call to :meth:`set_mute` or because of + any external entity changing the mute state. + """ + logger.debug('Mixer event: mute_changed(mute=%s)', mute) + MixerListener.send('mute_changed', mute=mute) + + +class MixerListener(listener.Listener): + """ + Marker interface for recipients of events sent by the mixer actor. + + Any Pykka actor that mixes in this class will receive calls to the methods + defined here when the corresponding events happen in the mixer actor. This + interface is used both for looking up what actors to notify of the events, + and for providing default implementations for those listeners that are not + interested in all events. + """ + + @staticmethod + def send(event, **kwargs): + """Helper to allow calling of mixer listener events""" + listener.send_async(MixerListener, event, **kwargs) + + def volume_changed(self, volume): + """ + Called after the volume has changed. + + *MAY* be implemented by actor. + + :param volume: the new volume + :type volume: int in range [0..100] + """ + pass + + def mute_changed(self, mute): + """ + Called after the mute state has changed. + + *MAY* be implemented by actor. + + :param mute: :class:`True` if muted, :class:`False` if not muted + :type mute: bool + """ + pass diff --git a/mopidy/models.py b/mopidy/models.py index e1a1270f..42313922 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -418,8 +418,9 @@ class Playlist(ImmutableObject): :type name: string :param tracks: playlist's tracks :type tracks: list of :class:`Track` elements - :param last_modified: playlist's modification time in UTC - :type last_modified: :class:`datetime.datetime` + :param last_modified: + playlist's modification time in milliseconds since Unix epoch + :type last_modified: int """ #: The playlist URI. Read-only. @@ -431,9 +432,10 @@ class Playlist(ImmutableObject): #: The playlist's tracks. Read-only. tracks = tuple() - #: The playlist modification time in UTC. Read-only. + #: The playlist modification time in milliseconds since Unix epoch. + #: Read-only. #: - #: :class:`datetime.datetime`, or :class:`None` if unknown. + #: Integer, or :class:`None` if unknown. last_modified = None def __init__(self, *args, **kwargs): diff --git a/mopidy/mpd/actor.py b/mopidy/mpd/actor.py index 20417a4d..23d88bf9 100644 --- a/mopidy/mpd/actor.py +++ b/mopidy/mpd/actor.py @@ -1,11 +1,10 @@ from __future__ import unicode_literals import logging -import sys import pykka -from mopidy import zeroconf +from mopidy import exceptions, zeroconf from mopidy.core import CoreListener from mopidy.mpd import session from mopidy.utils import encoding, network, process @@ -17,9 +16,9 @@ class MpdFrontend(pykka.ThreadingActor, CoreListener): def __init__(self, config, core): super(MpdFrontend, self).__init__() - hostname = network.format_hostname(config['mpd']['hostname']) - self.hostname = hostname + self.hostname = network.format_hostname(config['mpd']['hostname']) self.port = config['mpd']['port'] + self.zeroconf_name = config['mpd']['zeroconf'] self.zeroconf_service = None @@ -34,10 +33,9 @@ class MpdFrontend(pykka.ThreadingActor, CoreListener): max_connections=config['mpd']['max_connections'], timeout=config['mpd']['connection_timeout']) except IOError as error: - logger.error( - 'MPD server startup failed: %s', + raise exceptions.FrontendError( + 'MPD server startup failed: %s' % encoding.locale_decode(error)) - sys.exit(1) logger.info('MPD server running at [%s]:%s', self.hostname, self.port) @@ -46,12 +44,7 @@ class MpdFrontend(pykka.ThreadingActor, CoreListener): self.zeroconf_service = zeroconf.Zeroconf( stype='_mpd._tcp', name=self.zeroconf_name, host=self.hostname, port=self.port) - - if self.zeroconf_service.publish(): - logger.info('Registered MPD with Zeroconf as "%s"', - self.zeroconf_service.name) - else: - logger.info('Registering MPD with Zeroconf failed.') + self.zeroconf_service.publish() def on_stop(self): if self.zeroconf_service: diff --git a/mopidy/mpd/dispatcher.py b/mopidy/mpd/dispatcher.py index 0a916408..84550698 100644 --- a/mopidy/mpd/dispatcher.py +++ b/mopidy/mpd/dispatcher.py @@ -5,7 +5,7 @@ import re import pykka -from mopidy.mpd import exceptions, protocol +from mopidy.mpd import exceptions, protocol, tokenize logger = logging.getLogger(__name__) @@ -68,7 +68,7 @@ class MpdDispatcher(object): else: return response - ### Filter: catch MPD ACK errors + # Filter: catch MPD ACK errors def _catch_mpd_ack_errors_filter(self, request, response, filter_chain): try: @@ -78,7 +78,7 @@ class MpdDispatcher(object): mpd_ack_error.index = self.command_list_index return [mpd_ack_error.get_mpd_ack()] - ### Filter: authenticate + # Filter: authenticate def _authenticate_filter(self, request, response, filter_chain): if self.authenticated: @@ -88,15 +88,13 @@ class MpdDispatcher(object): return self._call_next_filter(request, response, filter_chain) else: command_name = request.split(' ')[0] - command_names_not_requiring_auth = [ - command.name for command in protocol.mpd_commands - if not command.auth_required] - if command_name in command_names_not_requiring_auth: + command = protocol.commands.handlers.get(command_name) + if command and not command.auth_required: return self._call_next_filter(request, response, filter_chain) else: raise exceptions.MpdPermissionError(command=command_name) - ### Filter: command list + # Filter: command list def _command_list_filter(self, request, response, filter_chain): if self._is_receiving_command_list(request): @@ -119,7 +117,7 @@ class MpdDispatcher(object): self.command_list_index is not None and request != 'command_list_end') - ### Filter: idle + # Filter: idle def _idle_filter(self, request, response, filter_chain): if self._is_currently_idle() and not self._noidle.match(request): @@ -142,7 +140,7 @@ class MpdDispatcher(object): def _is_currently_idle(self): return bool(self.context.subscriptions) - ### Filter: add OK + # Filter: add OK def _add_ok_filter(self, request, response, filter_chain): response = self._call_next_filter(request, response, filter_chain) @@ -153,7 +151,7 @@ class MpdDispatcher(object): def _has_error(self, response): return response and response[-1].startswith('ACK') - ### Filter: call handler + # Filter: call handler def _call_handler_filter(self, request, response, filter_chain): try: @@ -164,26 +162,14 @@ class MpdDispatcher(object): raise exceptions.MpdSystemError(e) def _call_handler(self, request): - (handler, kwargs) = self._find_handler(request) + tokens = tokenize.split(request) try: - return handler(self.context, **kwargs) + return protocol.commands.call(tokens, context=self.context) except exceptions.MpdAckError as exc: if exc.command is None: - exc.command = handler.__name__.split('__', 1)[0] + exc.command = tokens[0] raise - def _find_handler(self, request): - for pattern in protocol.request_handlers: - matches = re.match(pattern, request) - if matches is not None: - return ( - protocol.request_handlers[pattern], matches.groupdict()) - command_name = request.split(' ')[0] - if command_name in [command.name for command in protocol.mpd_commands]: - raise exceptions.MpdArgError( - 'incorrect arguments', command=command_name) - raise exceptions.MpdUnknownCommand(command=command_name) - def _format_response(self, response): formatted_response = [] for element in self._listify_result(response): @@ -241,7 +227,8 @@ class MpdContext(object): #: The subsytems that we want to be notified about in idle mode. subscriptions = None - _invalid_playlist_chars = re.compile(r'[\n\r/]') + _invalid_browse_chars = re.compile(r'[\n\r]') + _invalid_playlist_chars = re.compile(r'[/]') def __init__(self, dispatcher, session=None, config=None, core=None): self.dispatcher = dispatcher @@ -251,66 +238,95 @@ class MpdContext(object): self.core = core self.events = set() self.subscriptions = set() - self._playlist_uri_from_name = {} - self._playlist_name_from_uri = {} + self._uri_from_name = {} + self._name_from_uri = {} self.refresh_playlists_mapping() - def create_unique_name(self, playlist_name): - stripped_name = self._invalid_playlist_chars.sub(' ', playlist_name) + def create_unique_name(self, name, uri): + stripped_name = self._invalid_browse_chars.sub(' ', name) name = stripped_name i = 2 - while name in self._playlist_uri_from_name: + while name in self._uri_from_name: + if self._uri_from_name[name] == uri: + return name name = '%s [%d]' % (stripped_name, i) i += 1 return name + def insert_name_uri_mapping(self, name, uri): + name = self.create_unique_name(name, uri) + self._uri_from_name[name] = uri + self._name_from_uri[uri] = name + return name + def refresh_playlists_mapping(self): """ Maintain map between playlists and unique playlist names to be used by MPD """ if self.core is not None: - self._playlist_uri_from_name.clear() - self._playlist_name_from_uri.clear() for playlist in self.core.playlists.playlists.get(): if not playlist.name: continue # TODO: add scheme to name perhaps 'foo (spotify)' etc. - name = self.create_unique_name(playlist.name) - self._playlist_uri_from_name[name] = playlist.uri - self._playlist_name_from_uri[playlist.uri] = name + name = self._invalid_playlist_chars.sub(' ', playlist.name) + self.insert_name_uri_mapping(name, playlist.uri) def lookup_playlist_from_name(self, name): """ Helper function to retrieve a playlist from its unique MPD name. """ - if not self._playlist_uri_from_name: + if not self._uri_from_name: self.refresh_playlists_mapping() - if name not in self._playlist_uri_from_name: + if name not in self._uri_from_name: return None - uri = self._playlist_uri_from_name[name] + uri = self._uri_from_name[name] return self.core.playlists.lookup(uri).get() def lookup_playlist_name_from_uri(self, uri): """ Helper function to retrieve the unique MPD playlist name from its uri. """ - if uri not in self._playlist_name_from_uri: + if uri not in self._name_from_uri: self.refresh_playlists_mapping() - return self._playlist_name_from_uri[uri] + return self._name_from_uri[uri] - # TODO: consider making context.browse(path) which uses this internally. - # advantage would be that all browse requests then go through the same code - # and we could prebuild/cache path->uri relationships instead of having to - # look them up all the time. - def directory_path_to_uri(self, path): - parts = re.findall(r'[^/]+', path) - uri = None - for part in parts: - for ref in self.core.library.browse(uri).get(): - if ref.type == ref.DIRECTORY and ref.name == part: - uri = ref.uri - break - else: - raise exceptions.MpdNoExistError() - return uri + def browse(self, path, recursive=True, lookup=True): + path_parts = re.findall(r'[^/]+', path or '') + root_path = '/'.join([''] + path_parts) + + if root_path not in self._uri_from_name: + uri = None + for part in path_parts: + for ref in self.core.library.browse(uri).get(): + if (ref.type in (ref.DIRECTORY, ref.ALBUM, ref.PLAYLIST) + and ref.name == part): + uri = ref.uri + break + else: + raise exceptions.MpdNoExistError('Not found') + root_path = self.insert_name_uri_mapping(root_path, uri) + + else: + uri = self._uri_from_name[root_path] + + if recursive: + yield (root_path, None) + + path_and_futures = [(root_path, self.core.library.browse(uri))] + while path_and_futures: + base_path, future = path_and_futures.pop() + for ref in future.get(): + path = '/'.join([base_path, ref.name.replace('/', '')]) + path = self.insert_name_uri_mapping(path, ref.uri) + + if ref.type in (ref.DIRECTORY, ref.ALBUM, ref.PLAYLIST): + yield (path, None) + if recursive: + path_and_futures.append( + (path, self.core.library.browse(ref.uri))) + elif ref.type == ref.TRACK: + if lookup: + yield (path, self.core.library.lookup(ref.uri)) + else: + yield (path, ref) diff --git a/mopidy/mpd/exceptions.py b/mopidy/mpd/exceptions.py index ec874553..6738b4c9 100644 --- a/mopidy/mpd/exceptions.py +++ b/mopidy/mpd/exceptions.py @@ -54,9 +54,11 @@ class MpdPermissionError(MpdAckError): self.message = 'you don\'t have permission for "%s"' % self.command -class MpdUnknownCommand(MpdAckError): +class MpdUnknownError(MpdAckError): error_code = MpdAckError.ACK_ERROR_UNKNOWN + +class MpdUnknownCommand(MpdUnknownError): def __init__(self, *args, **kwargs): super(MpdUnknownCommand, self).__init__(*args, **kwargs) assert self.command is not None, 'command must be given explicitly' diff --git a/mopidy/mpd/protocol/__init__.py b/mopidy/mpd/protocol/__init__.py index 8a0993d8..3c501bbb 100644 --- a/mopidy/mpd/protocol/__init__.py +++ b/mopidy/mpd/protocol/__init__.py @@ -12,10 +12,9 @@ implement our own MPD server which is compatible with the numerous existing from __future__ import unicode_literals -from collections import namedtuple -import re +import inspect -from mopidy.utils import formatting +from mopidy.mpd import exceptions #: The MPD protocol uses UTF-8 for encoding all data. ENCODING = 'UTF-8' @@ -26,70 +25,151 @@ LINE_TERMINATOR = '\n' #: The MPD protocol version is 0.17.0. VERSION = '0.17.0' -MpdCommand = namedtuple('MpdCommand', ['name', 'auth_required']) - -#: Set of all available commands, represented as :class:`MpdCommand` objects. -mpd_commands = set() - -#: Map between request matchers and request handler functions. -request_handlers = {} - - -def handle_request(pattern, auth_required=True): - """ - Decorator for connecting command handlers to command requests. - - If you use named groups in the pattern, the decorated method will get the - groups as keyword arguments. If the group is optional, remember to give the - argument a default value. - - For example, if the command is ``do that thing`` the ``what`` argument will - be ``this thing``:: - - @handle_request('do\ (?P.+)$') - def do(what): - ... - - Note that the patterns are compiled with the :attr:`re.VERBOSE` flag. Thus, - you must escape any space characters you want to match, but you're also - free to add non-escaped whitespace to format the pattern for easier - reading. - - :param pattern: regexp pattern for matching commands - :type pattern: string - """ - def decorator(func): - match = re.search('([a-z_]+)', pattern) - if match is not None: - mpd_commands.add( - MpdCommand(name=match.group(), auth_required=auth_required)) - compiled_pattern = re.compile(pattern, flags=(re.UNICODE | re.VERBOSE)) - if compiled_pattern in request_handlers: - raise ValueError('Tried to redefine handler for %s with %s' % ( - pattern, func)) - request_handlers[compiled_pattern] = func - func.__doc__ = """ - *Pattern:* - - .. code-block:: text - -%(pattern)s - -%(docs)s - """ % { - 'pattern': formatting.indent(pattern, places=8, singles=True), - 'docs': func.__doc__ or '', - } - return func - return decorator - def load_protocol_modules(): """ The protocol modules must be imported to get them registered in - :attr:`request_handlers` and :attr:`mpd_commands`. + :attr:`commands`. """ from . import ( # noqa audio_output, channels, command_list, connection, current_playlist, - empty, music_db, playback, reflection, status, stickers, - stored_playlists) + music_db, playback, reflection, status, stickers, stored_playlists) + + +def INT(value): + """Converts a value that matches [+-]?\d+ into and integer.""" + if value is None: + raise ValueError('None is not a valid integer') + # TODO: check for whitespace via value != value.strip()? + return int(value) + + +def UINT(value): + """Converts a value that matches \d+ into an integer.""" + if value is None: + raise ValueError('None is not a valid integer') + if not value.isdigit(): + raise ValueError('Only positive numbers are allowed') + return int(value) + + +def BOOL(value): + """Convert the values 0 and 1 into booleans.""" + if value in ('1', '0'): + return bool(int(value)) + raise ValueError('%r is not 0 or 1' % value) + + +def RANGE(value): + """Convert a single integer or range spec into a slice + + ``n`` should become ``slice(n, n+1)`` + ``n:`` should become ``slice(n, None)`` + ``n:m`` should become ``slice(n, m)`` and ``m > n`` must hold + """ + if ':' in value: + start, stop = value.split(':', 1) + start = UINT(start) + if stop.strip(): + stop = UINT(stop) + if start >= stop: + raise ValueError('End must be larger than start') + else: + stop = None + else: + start = UINT(value) + stop = start + 1 + return slice(start, stop) + + +class Commands(object): + """Collection of MPD commands to expose to users. + + Normally used through the global instance which command handlers have been + installed into. + """ + + def __init__(self): + self.handlers = {} + + # TODO: consider removing auth_required and list_command in favour of + # additional command instances to register in? + def add(self, name, auth_required=True, list_command=True, **validators): + """Create a decorator that registers a handler and validation rules. + + Additional keyword arguments are treated as converters/validators to + apply to tokens converting them to proper Python types. + + Requirements for valid handlers: + + - must accept a context argument as the first arg. + - may not use variable keyword arguments, ``**kwargs``. + - may use variable arguments ``*args`` *or* a mix of required and + optional arguments. + + Decorator returns the unwrapped function so that tests etc can use the + functions with values with correct python types instead of strings. + + :param string name: Name of the command being registered. + :param bool auth_required: If authorization is required. + :param bool list_command: If command should be listed in reflection. + """ + + def wrapper(func): + if name in self.handlers: + raise ValueError('%s already registered' % name) + + args, varargs, keywords, defaults = inspect.getargspec(func) + defaults = dict(zip(args[-len(defaults or []):], defaults or [])) + + if not args and not varargs: + raise TypeError('Handler must accept at least one argument.') + + if len(args) > 1 and varargs: + raise TypeError( + '*args may not be combined with regular arguments') + + if not set(validators.keys()).issubset(args): + raise TypeError('Validator for non-existent arg passed') + + if keywords: + raise TypeError('**kwargs are not permitted') + + def validate(*args, **kwargs): + if varargs: + return func(*args, **kwargs) + callargs = inspect.getcallargs(func, *args, **kwargs) + for key, value in callargs.items(): + default = defaults.get(key, object()) + if key in validators and value != default: + try: + callargs[key] = validators[key](value) + except ValueError: + raise exceptions.MpdArgError('incorrect arguments') + return func(**callargs) + + validate.auth_required = auth_required + validate.list_command = list_command + self.handlers[name] = validate + return func + return wrapper + + def call(self, tokens, context=None): + """Find and run the handler registered for the given command. + + If the handler was registered with any converters/validators they will + be run before calling the real handler. + + :param list tokens: List of tokens to process + :param context: MPD context. + :type context: :class:`~mopidy.mpd.dispatcher.MpdContext` + """ + if not tokens: + raise exceptions.MpdNoCommand() + if tokens[0] not in self.handlers: + raise exceptions.MpdUnknownCommand(command=tokens[0]) + return self.handlers[tokens[0]](context, *tokens[1:]) + + +#: Global instance to install commands into +commands = Commands() diff --git a/mopidy/mpd/protocol/audio_output.py b/mopidy/mpd/protocol/audio_output.py index 802be6c0..2c7aea16 100644 --- a/mopidy/mpd/protocol/audio_output.py +++ b/mopidy/mpd/protocol/audio_output.py @@ -1,10 +1,9 @@ from __future__ import unicode_literals -from mopidy.mpd.exceptions import MpdNoExistError -from mopidy.mpd.protocol import handle_request +from mopidy.mpd import exceptions, protocol -@handle_request(r'disableoutput\ "(?P\d+)"$') +@protocol.commands.add('disableoutput', outputid=protocol.UINT) def disableoutput(context, outputid): """ *musicpd.org, audio output section:* @@ -13,13 +12,13 @@ def disableoutput(context, outputid): Turns an output off. """ - if int(outputid) == 0: + if outputid == 0: context.core.playback.set_mute(False) else: - raise MpdNoExistError('No such audio output') + raise exceptions.MpdNoExistError('No such audio output') -@handle_request(r'enableoutput\ "(?P\d+)"$') +@protocol.commands.add('enableoutput', outputid=protocol.UINT) def enableoutput(context, outputid): """ *musicpd.org, audio output section:* @@ -28,13 +27,26 @@ def enableoutput(context, outputid): Turns an output on. """ - if int(outputid) == 0: + if outputid == 0: context.core.playback.set_mute(True) else: - raise MpdNoExistError('No such audio output') + raise exceptions.MpdNoExistError('No such audio output') -@handle_request(r'outputs$') +# TODO: implement and test +# @protocol.commands.add('toggleoutput', outputid=protocol.UINT) +def toggleoutput(context, outputid): + """ + *musicpd.org, audio output section:* + + ``toggleoutput {ID}`` + + Turns an output on or off, depending on the current state. + """ + pass + + +@protocol.commands.add('outputs') def outputs(context): """ *musicpd.org, audio output section:* diff --git a/mopidy/mpd/protocol/channels.py b/mopidy/mpd/protocol/channels.py index e8efd2a0..4ae00622 100644 --- a/mopidy/mpd/protocol/channels.py +++ b/mopidy/mpd/protocol/channels.py @@ -1,10 +1,9 @@ from __future__ import unicode_literals -from mopidy.mpd.protocol import handle_request -from mopidy.mpd.exceptions import MpdNotImplemented +from mopidy.mpd import exceptions, protocol -@handle_request(r'subscribe\ "(?P[A-Za-z0-9:._-]+)"$') +@protocol.commands.add('subscribe') def subscribe(context, channel): """ *musicpd.org, client to client section:* @@ -15,10 +14,11 @@ def subscribe(context, channel): already. The name may consist of alphanumeric ASCII characters plus underscore, dash, dot and colon. """ - raise MpdNotImplemented # TODO + # TODO: match channel against [A-Za-z0-9:._-]+ + raise exceptions.MpdNotImplemented # TODO -@handle_request(r'unsubscribe\ "(?P[A-Za-z0-9:._-]+)"$') +@protocol.commands.add('unsubscribe') def unsubscribe(context, channel): """ *musicpd.org, client to client section:* @@ -27,10 +27,11 @@ def unsubscribe(context, channel): Unsubscribe from a channel. """ - raise MpdNotImplemented # TODO + # TODO: match channel against [A-Za-z0-9:._-]+ + raise exceptions.MpdNotImplemented # TODO -@handle_request(r'channels$') +@protocol.commands.add('channels') def channels(context): """ *musicpd.org, client to client section:* @@ -40,10 +41,10 @@ def channels(context): Obtain a list of all channels. The response is a list of "channel:" lines. """ - raise MpdNotImplemented # TODO + raise exceptions.MpdNotImplemented # TODO -@handle_request(r'readmessages$') +@protocol.commands.add('readmessages') def readmessages(context): """ *musicpd.org, client to client section:* @@ -53,11 +54,10 @@ def readmessages(context): Reads messages for this client. The response is a list of "channel:" and "message:" lines. """ - raise MpdNotImplemented # TODO + raise exceptions.MpdNotImplemented # TODO -@handle_request( - r'sendmessage\ "(?P[A-Za-z0-9:._-]+)"\ "(?P[^"]*)"$') +@protocol.commands.add('sendmessage') def sendmessage(context, channel, text): """ *musicpd.org, client to client section:* @@ -66,4 +66,5 @@ def sendmessage(context, channel, text): Send a message to the specified channel. """ - raise MpdNotImplemented # TODO + # TODO: match channel against [A-Za-z0-9:._-]+ + raise exceptions.MpdNotImplemented # TODO diff --git a/mopidy/mpd/protocol/command_list.py b/mopidy/mpd/protocol/command_list.py index 8268c55d..d8551105 100644 --- a/mopidy/mpd/protocol/command_list.py +++ b/mopidy/mpd/protocol/command_list.py @@ -1,10 +1,9 @@ from __future__ import unicode_literals -from mopidy.mpd.protocol import handle_request -from mopidy.mpd.exceptions import MpdUnknownCommand +from mopidy.mpd import exceptions, protocol -@handle_request(r'command_list_begin$') +@protocol.commands.add('command_list_begin', list_command=False) def command_list_begin(context): """ *musicpd.org, command list section:* @@ -26,11 +25,12 @@ def command_list_begin(context): context.dispatcher.command_list = [] -@handle_request(r'command_list_end$') +@protocol.commands.add('command_list_end', list_command=False) def command_list_end(context): """See :meth:`command_list_begin()`.""" + # TODO: batch consecutive add commands if not context.dispatcher.command_list_receiving: - raise MpdUnknownCommand(command='command_list_end') + raise exceptions.MpdUnknownCommand(command='command_list_end') context.dispatcher.command_list_receiving = False (command_list, context.dispatcher.command_list) = ( context.dispatcher.command_list, []) @@ -49,7 +49,7 @@ def command_list_end(context): return command_list_response -@handle_request(r'command_list_ok_begin$') +@protocol.commands.add('command_list_ok_begin', list_command=False) def command_list_ok_begin(context): """See :meth:`command_list_begin()`.""" context.dispatcher.command_list_receiving = True diff --git a/mopidy/mpd/protocol/connection.py b/mopidy/mpd/protocol/connection.py index 41ee9e6a..41896acf 100644 --- a/mopidy/mpd/protocol/connection.py +++ b/mopidy/mpd/protocol/connection.py @@ -1,11 +1,9 @@ from __future__ import unicode_literals -from mopidy.mpd.protocol import handle_request -from mopidy.mpd.exceptions import ( - MpdPasswordError, MpdPermissionError) +from mopidy.mpd import exceptions, protocol -@handle_request(r'close$', auth_required=False) +@protocol.commands.add('close', auth_required=False) def close(context): """ *musicpd.org, connection section:* @@ -17,7 +15,7 @@ def close(context): context.session.close() -@handle_request(r'kill$') +@protocol.commands.add('kill', list_command=False) def kill(context): """ *musicpd.org, connection section:* @@ -26,10 +24,10 @@ def kill(context): Kills MPD. """ - raise MpdPermissionError(command='kill') + raise exceptions.MpdPermissionError(command='kill') -@handle_request(r'password\ "(?P[^"]+)"$', auth_required=False) +@protocol.commands.add('password', auth_required=False) def password(context, password): """ *musicpd.org, connection section:* @@ -42,10 +40,10 @@ def password(context, password): if password == context.password: context.dispatcher.authenticated = True else: - raise MpdPasswordError('incorrect password') + raise exceptions.MpdPasswordError('incorrect password') -@handle_request(r'ping$', auth_required=False) +@protocol.commands.add('ping', auth_required=False) def ping(context): """ *musicpd.org, connection section:* diff --git a/mopidy/mpd/protocol/current_playlist.py b/mopidy/mpd/protocol/current_playlist.py index de8721d3..a2d60e96 100644 --- a/mopidy/mpd/protocol/current_playlist.py +++ b/mopidy/mpd/protocol/current_playlist.py @@ -1,12 +1,11 @@ from __future__ import unicode_literals -from mopidy.mpd import translator -from mopidy.mpd.exceptions import ( - MpdArgError, MpdNoExistError, MpdNotImplemented) -from mopidy.mpd.protocol import handle_request +import warnings + +from mopidy.mpd import exceptions, protocol, translator -@handle_request(r'add\ "(?P[^"]*)"$') +@protocol.commands.add('add') def add(context, uri): """ *musicpd.org, current playlist section:* @@ -23,37 +22,24 @@ def add(context, uri): if not uri.strip('/'): return - tl_tracks = context.core.tracklist.add(uri=uri).get() - if tl_tracks: + if context.core.tracklist.add(uri=uri).get(): return try: - uri = context.directory_path_to_uri(translator.normalize_path(uri)) - except MpdNoExistError as e: - e.command = 'add' + tracks = [] + for path, lookup_future in context.browse(uri): + if lookup_future: + tracks.extend(lookup_future.get()) + except exceptions.MpdNoExistError as e: e.message = 'directory or file not found' raise - browse_futures = [context.core.library.browse(uri)] - lookup_futures = [] - while browse_futures: - for ref in browse_futures.pop().get(): - if ref.type == ref.DIRECTORY: - browse_futures.append(context.core.library.browse(ref.uri)) - else: - lookup_futures.append(context.core.library.lookup(ref.uri)) - - tracks = [] - for future in lookup_futures: - tracks.extend(future.get()) - if not tracks: - raise MpdNoExistError('directory or file not found') - + raise exceptions.MpdNoExistError('directory or file not found') context.core.tracklist.add(tracks=tracks) -@handle_request(r'addid\ "(?P[^"]*)"(\ "(?P\d+)")*$') +@protocol.commands.add('addid', songpos=protocol.UINT) def addid(context, uri, songpos=None): """ *musicpd.org, current playlist section:* @@ -73,19 +59,17 @@ def addid(context, uri, songpos=None): - ``addid ""`` should return an error. """ if not uri: - raise MpdNoExistError('No such song') - if songpos is not None: - songpos = int(songpos) - if songpos and songpos > context.core.tracklist.length.get(): - raise MpdArgError('Bad song index') + raise exceptions.MpdNoExistError('No such song') + if songpos is not None and songpos > context.core.tracklist.length.get(): + raise exceptions.MpdArgError('Bad song index') tl_tracks = context.core.tracklist.add(uri=uri, at_position=songpos).get() if not tl_tracks: - raise MpdNoExistError('No such song') + raise exceptions.MpdNoExistError('No such song') return ('Id', tl_tracks[0].tlid) -@handle_request(r'delete\ "(?P\d+):(?P\d+)*"$') -def delete_range(context, start, end=None): +@protocol.commands.add('delete', position=protocol.RANGE) +def delete(context, position): """ *musicpd.org, current playlist section:* @@ -93,31 +77,18 @@ def delete_range(context, start, end=None): Deletes a song from the playlist. """ - start = int(start) - if end is not None: - end = int(end) - else: + start = position.start + end = position.stop + if end is None: end = context.core.tracklist.length.get() tl_tracks = context.core.tracklist.slice(start, end).get() if not tl_tracks: - raise MpdArgError('Bad song index', command='delete') + raise exceptions.MpdArgError('Bad song index', command='delete') for (tlid, _) in tl_tracks: context.core.tracklist.remove(tlid=[tlid]) -@handle_request(r'delete\ "(?P\d+)"$') -def delete_songpos(context, songpos): - """See :meth:`delete_range`""" - try: - songpos = int(songpos) - (tlid, _) = context.core.tracklist.slice( - songpos, songpos + 1).get()[0] - context.core.tracklist.remove(tlid=[tlid]) - except IndexError: - raise MpdArgError('Bad song index', command='delete') - - -@handle_request(r'deleteid\ "(?P\d+)"$') +@protocol.commands.add('deleteid', tlid=protocol.UINT) def deleteid(context, tlid): """ *musicpd.org, current playlist section:* @@ -126,13 +97,12 @@ def deleteid(context, tlid): Deletes the song ``SONGID`` from the playlist """ - tlid = int(tlid) tl_tracks = context.core.tracklist.remove(tlid=[tlid]).get() if not tl_tracks: - raise MpdNoExistError('No such song') + raise exceptions.MpdNoExistError('No such song') -@handle_request(r'clear$') +@protocol.commands.add('clear') def clear(context): """ *musicpd.org, current playlist section:* @@ -144,8 +114,8 @@ def clear(context): context.core.tracklist.clear() -@handle_request(r'move\ "(?P\d+):(?P\d+)*"\ "(?P\d+)"$') -def move_range(context, start, to, end=None): +@protocol.commands.add('move', position=protocol.RANGE, to=protocol.UINT) +def move_range(context, position, to): """ *musicpd.org, current playlist section:* @@ -154,23 +124,14 @@ def move_range(context, start, to, end=None): Moves the song at ``FROM`` or range of songs at ``START:END`` to ``TO`` in the playlist. """ + start = position.start + end = position.stop if end is None: end = context.core.tracklist.length.get() - start = int(start) - end = int(end) - to = int(to) context.core.tracklist.move(start, end, to) -@handle_request(r'move\ "(?P\d+)"\ "(?P\d+)"$') -def move_songpos(context, songpos, to): - """See :meth:`move_range`.""" - songpos = int(songpos) - to = int(to) - context.core.tracklist.move(songpos, songpos + 1, to) - - -@handle_request(r'moveid\ "(?P\d+)"\ "(?P\d+)"$') +@protocol.commands.add('moveid', tlid=protocol.UINT, to=protocol.UINT) def moveid(context, tlid, to): """ *musicpd.org, current playlist section:* @@ -181,16 +142,14 @@ def moveid(context, tlid, to): the playlist. If ``TO`` is negative, it is relative to the current song in the playlist (if there is one). """ - tlid = int(tlid) - to = int(to) tl_tracks = context.core.tracklist.filter(tlid=[tlid]).get() if not tl_tracks: - raise MpdNoExistError('No such song') + raise exceptions.MpdNoExistError('No such song') position = context.core.tracklist.index(tl_tracks[0]).get() context.core.tracklist.move(position, position + 1, to) -@handle_request(r'playlist$') +@protocol.commands.add('playlist') def playlist(context): """ *musicpd.org, current playlist section:* @@ -203,10 +162,12 @@ def playlist(context): Do not use this, instead use ``playlistinfo``. """ + warnings.warn( + 'Do not use this, instead use playlistinfo', DeprecationWarning) return playlistinfo(context) -@handle_request(r'playlistfind\ ("?)(?P[^"]+)\1\ "(?P[^"]+)"$') +@protocol.commands.add('playlistfind') def playlistfind(context, tag, needle): """ *musicpd.org, current playlist section:* @@ -225,11 +186,10 @@ def playlistfind(context, tag, needle): return None position = context.core.tracklist.index(tl_tracks[0]).get() return translator.track_to_mpd_format(tl_tracks[0], position=position) - raise MpdNotImplemented # TODO + raise exceptions.MpdNotImplemented # TODO -@handle_request(r'playlistid$') -@handle_request(r'playlistid\ "(?P\d+)"$') +@protocol.commands.add('playlistid', tlid=protocol.UINT) def playlistid(context, tlid=None): """ *musicpd.org, current playlist section:* @@ -240,10 +200,9 @@ def playlistid(context, tlid=None): and specifies a single song to display info for. """ if tlid is not None: - tlid = int(tlid) tl_tracks = context.core.tracklist.filter(tlid=[tlid]).get() if not tl_tracks: - raise MpdNoExistError('No such song') + raise exceptions.MpdNoExistError('No such song') position = context.core.tracklist.index(tl_tracks[0]).get() return translator.track_to_mpd_format(tl_tracks[0], position=position) else: @@ -251,10 +210,8 @@ def playlistid(context, tlid=None): context.core.tracklist.tl_tracks.get()) -@handle_request(r'playlistinfo$') -@handle_request(r'playlistinfo\ "(?P-?\d+)"$') -@handle_request(r'playlistinfo\ "(?P\d+):(?P\d+)*"$') -def playlistinfo(context, songpos=None, start=None, end=None): +@protocol.commands.add('playlistinfo') +def playlistinfo(context, parameter=None): """ *musicpd.org, current playlist section:* @@ -269,27 +226,21 @@ def playlistinfo(context, songpos=None, start=None, end=None): - uses negative indexes, like ``playlistinfo "-1"``, to request the entire playlist """ - if songpos == '-1': - songpos = None - if songpos is not None: - songpos = int(songpos) - tl_track = context.core.tracklist.tl_tracks.get()[songpos] - return translator.track_to_mpd_format(tl_track, position=songpos) + if parameter is None or parameter == '-1': + start, end = 0, None else: - if start is None: - start = 0 - start = int(start) - if not (0 <= start <= context.core.tracklist.length.get()): - raise MpdArgError('Bad song index') - if end is not None: - end = int(end) - if end > context.core.tracklist.length.get(): - end = None - tl_tracks = context.core.tracklist.tl_tracks.get() - return translator.tracks_to_mpd_format(tl_tracks, start, end) + tracklist_slice = protocol.RANGE(parameter) + start, end = tracklist_slice.start, tracklist_slice.stop + + tl_tracks = context.core.tracklist.tl_tracks.get() + if start and start > len(tl_tracks): + raise exceptions.MpdArgError('Bad song index') + if end and end > len(tl_tracks): + end = None + return translator.tracks_to_mpd_format(tl_tracks, start, end) -@handle_request(r'playlistsearch\ ("?)(?P\w+)\1\ "(?P[^"]+)"$') +@protocol.commands.add('playlistsearch') def playlistsearch(context, tag, needle): """ *musicpd.org, current playlist section:* @@ -304,10 +255,10 @@ def playlistsearch(context, tag, needle): - does not add quotes around the tag - uses ``filename`` and ``any`` as tags """ - raise MpdNotImplemented # TODO + raise exceptions.MpdNotImplemented # TODO -@handle_request(r'plchanges\ ("?)(?P-?\d+)\1$') +@protocol.commands.add('plchanges', version=protocol.INT) def plchanges(context, version): """ *musicpd.org, current playlist section:* @@ -329,7 +280,7 @@ def plchanges(context, version): context.core.tracklist.tl_tracks.get()) -@handle_request(r'plchangesposid\ "(?P\d+)"$') +@protocol.commands.add('plchangesposid', version=protocol.INT) def plchangesposid(context, version): """ *musicpd.org, current playlist section:* @@ -353,9 +304,8 @@ def plchangesposid(context, version): return result -@handle_request(r'shuffle$') -@handle_request(r'shuffle\ "(?P\d+):(?P\d+)*"$') -def shuffle(context, start=None, end=None): +@protocol.commands.add('shuffle', position=protocol.RANGE) +def shuffle(context, position=None): """ *musicpd.org, current playlist section:* @@ -364,14 +314,14 @@ def shuffle(context, start=None, end=None): Shuffles the current playlist. ``START:END`` is optional and specifies a range of songs. """ - if start is not None: - start = int(start) - if end is not None: - end = int(end) + if position is None: + start, end = None, None + else: + start, end = position.start, position.stop context.core.tracklist.shuffle(start, end) -@handle_request(r'swap\ "(?P\d+)"\ "(?P\d+)"$') +@protocol.commands.add('swap', songpos1=protocol.UINT, songpos2=protocol.UINT) def swap(context, songpos1, songpos2): """ *musicpd.org, current playlist section:* @@ -380,8 +330,6 @@ def swap(context, songpos1, songpos2): Swaps the positions of ``SONG1`` and ``SONG2``. """ - songpos1 = int(songpos1) - songpos2 = int(songpos2) tracks = context.core.tracklist.tracks.get() song1 = tracks[songpos1] song2 = tracks[songpos2] @@ -393,7 +341,7 @@ def swap(context, songpos1, songpos2): context.core.tracklist.add(tracks) -@handle_request(r'swapid\ "(?P\d+)"\ "(?P\d+)"$') +@protocol.commands.add('swapid', tlid1=protocol.UINT, tlid2=protocol.UINT) def swapid(context, tlid1, tlid2): """ *musicpd.org, current playlist section:* @@ -402,12 +350,72 @@ def swapid(context, tlid1, tlid2): Swaps the positions of ``SONG1`` and ``SONG2`` (both song ids). """ - tlid1 = int(tlid1) - tlid2 = int(tlid2) tl_tracks1 = context.core.tracklist.filter(tlid=[tlid1]).get() tl_tracks2 = context.core.tracklist.filter(tlid=[tlid2]).get() if not tl_tracks1 or not tl_tracks2: - raise MpdNoExistError('No such song') + raise exceptions.MpdNoExistError('No such song') position1 = context.core.tracklist.index(tl_tracks1[0]).get() position2 = context.core.tracklist.index(tl_tracks2[0]).get() swap(context, position1, position2) + + +# TODO: add at least reflection tests before adding NotImplemented version +# @protocol.commands.add( +# 'prio', priority=protocol.UINT, position=protocol.RANGE) +def prio(context, priority, position): + """ + *musicpd.org, current playlist section:* + + ``prio {PRIORITY} {START:END...}`` + + Set the priority of the specified songs. A higher priority means that + it will be played first when "random" mode is enabled. + + A priority is an integer between 0 and 255. The default priority of new + songs is 0. + """ + pass + + +# TODO: add at least reflection tests before adding NotImplemented version +# @protocol.commands.add('prioid') +def prioid(context, *args): + """ + *musicpd.org, current playlist section:* + + ``prioid {PRIORITY} {ID...}`` + + Same as prio, but address the songs with their id. + """ + pass + + +# TODO: add at least reflection tests before adding NotImplemented version +# @protocol.commands.add('addtagid', tlid=protocol.UINT) +def addtagid(context, tlid, tag, value): + """ + *musicpd.org, current playlist section:* + + ``addtagid {SONGID} {TAG} {VALUE}`` + + Adds a tag to the specified song. Editing song tags is only possible + for remote songs. This change is volatile: it may be overwritten by + tags received from the server, and the data is gone when the song gets + removed from the queue. + """ + pass + + +# TODO: add at least reflection tests before adding NotImplemented version +# @protocol.commands.add('cleartagid', tlid=protocol.UINT) +def cleartagid(context, tlid, tag): + """ + *musicpd.org, current playlist section:* + + ``cleartagid {SONGID} [TAG]`` + + Removes tags from the specified song. If TAG is not specified, then all + tag values will be removed. Editing song tags is only possible for + remote songs. + """ + pass diff --git a/mopidy/mpd/protocol/empty.py b/mopidy/mpd/protocol/empty.py deleted file mode 100644 index 64cfc1fb..00000000 --- a/mopidy/mpd/protocol/empty.py +++ /dev/null @@ -1,10 +0,0 @@ -from __future__ import unicode_literals - -from mopidy.mpd.protocol import handle_request -from mopidy.mpd.exceptions import MpdNoCommand - - -@handle_request(r'[\ ]*$') -def empty(context): - """The original MPD server returns an error on an empty request.""" - raise MpdNoCommand() diff --git a/mopidy/mpd/protocol/music_db.py b/mopidy/mpd/protocol/music_db.py index e821af6b..acddf1f4 100644 --- a/mopidy/mpd/protocol/music_db.py +++ b/mopidy/mpd/protocol/music_db.py @@ -2,106 +2,48 @@ from __future__ import unicode_literals import functools import itertools -import re -from mopidy.models import Ref, Track -from mopidy.mpd import translator -from mopidy.mpd.exceptions import MpdArgError, MpdNoExistError -from mopidy.mpd.protocol import handle_request, stored_playlists +from mopidy.models import Track +from mopidy.mpd import exceptions, protocol, translator + +_SEARCH_MAPPING = { + 'album': 'album', + 'albumartist': 'albumartist', + 'any': 'any', + 'artist': 'artist', + 'comment': 'comment', + 'composer': 'composer', + 'date': 'date', + 'file': 'uri', + 'filename': 'uri', + 'genre': 'genre', + 'performer': 'performer', + 'title': 'track_name', + 'track': 'track_no'} + +_LIST_MAPPING = { + 'album': 'album', + 'albumartist': 'albumartist', + 'artist': 'artist', + 'composer': 'composer', + 'date': 'date', + 'genre': 'genre', + 'performer': 'performer'} -LIST_QUERY = r""" - ("?) # Optional quote around the field type - (?P( # Field to list in the response - [Aa]lbum - | [Aa]lbumartist - | [Aa]rtist - | [Cc]omposer - | [Dd]ate - | [Gg]enre - | [Pp]erformer - )) - \1 # End of optional quote around the field type - (?: # Non-capturing group for optional search query - \ # A single space - (?P.*) - )? - $ -""" - -SEARCH_FIELDS = r""" - [Aa]lbum - | [Aa]lbumartist - | [Aa]ny - | [Aa]rtist - | [Cc]omment - | [Cc]omposer - | [Dd]ate - | [Ff]ile - | [Ff]ilename - | [Gg]enre - | [Pp]erformer - | [Tt]itle - | [Tt]rack -""" - -# TODO Would be nice to get ("?)...\1 working for the quotes here -SEARCH_QUERY = r""" - (?P - (?: # Non-capturing group for repeating query pairs - "? # Optional quote around the field type - (?: -""" + SEARCH_FIELDS + r""" - ) - "? # End of optional quote around the field type - \ # A single space - "[^"]*" # Matching a quoted search string - \s? - )+ - ) - $ -""" - -SEARCH_PAIR_WITH_GROUPS = r""" - ("?) # Optional quote around the field type - \b # Only begin matching at word bundaries - ( # A capturing group for the field type -""" + SEARCH_FIELDS + """ - ) - \\1 # End of optional quote around the field type - \ # A single space - "([^"]+)" # Capturing a quoted search string -""" -SEARCH_PAIR_WITH_GROUPS_RE = re.compile( - SEARCH_PAIR_WITH_GROUPS, flags=(re.UNICODE | re.VERBOSE)) - - -def _query_from_mpd_search_format(mpd_query): - """ - Parses an MPD ``search`` or ``find`` query and converts it to the Mopidy - query format. - - :param mpd_query: the MPD search query - :type mpd_query: string - """ - matches = SEARCH_PAIR_WITH_GROUPS_RE.findall(mpd_query) +def _query_from_mpd_search_parameters(parameters, mapping): query = {} - # discard first field, which just captures optional quote - for _, field, what in matches: - field = field.lower() - if field == 'title': - field = 'track_name' - elif field == 'track': - field = 'track_no' - elif field in ('file', 'filename'): - field = 'uri' - - if not what: + parameters = list(parameters) + while parameters: + # TODO: does it matter that this is now case insensitive + field = mapping.get(parameters.pop(0).lower()) + if not field: + raise exceptions.MpdArgError('incorrect arguments') + if not parameters: raise ValueError - if field in query: - query[field].append(what) - else: - query[field] = [what] + value = parameters.pop(0) + if value.strip(): + query.setdefault(field, []).append(value) return query @@ -130,8 +72,8 @@ def _artist_as_track(artist): artists=[artist]) -@handle_request(r'count\ ' + SEARCH_QUERY) -def count(context, mpd_query): +@protocol.commands.add('count') +def count(context, *args): """ *musicpd.org, music database section:* @@ -146,9 +88,9 @@ def count(context, mpd_query): - use multiple tag-needle pairs to make more specific searches. """ try: - query = _query_from_mpd_search_format(mpd_query) + query = _query_from_mpd_search_parameters(args, _SEARCH_MAPPING) except ValueError: - raise MpdArgError('incorrect arguments') + raise exceptions.MpdArgError('incorrect arguments') results = context.core.library.find_exact(**query).get() result_tracks = _get_tracks(results) return [ @@ -157,8 +99,8 @@ def count(context, mpd_query): ] -@handle_request(r'find\ ' + SEARCH_QUERY) -def find(context, mpd_query): +@protocol.commands.add('find') +def find(context, *args): """ *musicpd.org, music database section:* @@ -186,9 +128,10 @@ def find(context, mpd_query): - uses "file" instead of "filename". """ try: - query = _query_from_mpd_search_format(mpd_query) + query = _query_from_mpd_search_parameters(args, _SEARCH_MAPPING) except ValueError: return + results = context.core.library.find_exact(**query).get() result_tracks = [] if ('artist' not in query and @@ -202,8 +145,8 @@ def find(context, mpd_query): return translator.tracks_to_mpd_format(result_tracks) -@handle_request(r'findadd\ ' + SEARCH_QUERY) -def findadd(context, mpd_query): +@protocol.commands.add('findadd') +def findadd(context, *args): """ *musicpd.org, music database section:* @@ -213,15 +156,15 @@ def findadd(context, mpd_query): current playlist. Parameters have the same meaning as for ``find``. """ try: - query = _query_from_mpd_search_format(mpd_query) + query = _query_from_mpd_search_parameters(args, _SEARCH_MAPPING) except ValueError: return results = context.core.library.find_exact(**query).get() context.core.tracklist.add(_get_tracks(results)) -@handle_request(r'list\ ' + LIST_QUERY) -def list_(context, field, mpd_query=None): +@protocol.commands.add('list') +def list_(context, *args): """ *musicpd.org, music database section:* @@ -303,11 +246,27 @@ def list_(context, field, mpd_query=None): - does not add quotes around the field argument. - capitalizes the field argument. """ - field = field.lower() + parameters = list(args) + if not parameters: + raise exceptions.MpdArgError('incorrect arguments') + field = parameters.pop(0).lower() + + if field not in _LIST_MAPPING: + raise exceptions.MpdArgError('incorrect arguments') + + if len(parameters) == 1: + if field != 'album': + raise exceptions.MpdArgError('should be "Album" for 3 arguments') + return _list_artist(context, {'artist': parameters}) + try: - query = translator.query_from_mpd_list_format(field, mpd_query) + query = _query_from_mpd_search_parameters(parameters, _LIST_MAPPING) + except exceptions.MpdArgError as e: + e.message = 'not able to parse args' + raise except ValueError: return + if field == 'artist': return _list_artist(context, query) if field == 'albumartist': @@ -392,8 +351,7 @@ def _list_genre(context, query): return genres -@handle_request(r'listall$') -@handle_request(r'listall\ "(?P[^"]+)"$') +@protocol.commands.add('listall') def listall(context, uri=None): """ *musicpd.org, music database section:* @@ -403,36 +361,18 @@ def listall(context, uri=None): Lists all songs and directories in ``URI``. """ result = [] - root_path = translator.normalize_path(uri) - # TODO: doesn't the dispatcher._call_handler have enough info to catch - # the error this can produce, set the command and then 'raise'? - try: - uri = context.directory_path_to_uri(root_path) - except MpdNoExistError as e: - e.command = 'listall' - e.message = 'Not found' - raise - browse_futures = [(root_path, context.core.library.browse(uri))] - - while browse_futures: - base_path, future = browse_futures.pop() - for ref in future.get(): - if ref.type == Ref.DIRECTORY: - path = '/'.join([base_path, ref.name.replace('/', '')]) - result.append(('directory', path)) - browse_futures.append( - (path, context.core.library.browse(ref.uri))) - elif ref.type == Ref.TRACK: - result.append(('file', ref.uri)) + for path, track_ref in context.browse(uri, lookup=False): + if not track_ref: + result.append(('directory', path)) + else: + result.append(('file', track_ref.uri)) if not result: - raise MpdNoExistError('Not found') - - return [('directory', root_path)] + result + raise exceptions.MpdNoExistError('Not found') + return result -@handle_request(r'listallinfo$') -@handle_request(r'listallinfo\ "(?P[^"]+)"$') +@protocol.commands.add('listallinfo') def listallinfo(context, uri=None): """ *musicpd.org, music database section:* @@ -442,45 +382,17 @@ def listallinfo(context, uri=None): Same as ``listall``, except it also returns metadata info in the same format as ``lsinfo``. """ - dirs_and_futures = [] result = [] - root_path = translator.normalize_path(uri) - try: - uri = context.directory_path_to_uri(root_path) - except MpdNoExistError as e: - e.command = 'listallinfo' - e.message = 'Not found' - raise - browse_futures = [(root_path, context.core.library.browse(uri))] - - while browse_futures: - base_path, future = browse_futures.pop() - for ref in future.get(): - if ref.type == Ref.DIRECTORY: - path = '/'.join([base_path, ref.name.replace('/', '')]) - future = context.core.library.browse(ref.uri) - browse_futures.append((path, future)) - dirs_and_futures.append(('directory', path)) - elif ref.type == Ref.TRACK: - # TODO Lookup tracks in batch for better performance - dirs_and_futures.append(context.core.library.lookup(ref.uri)) - - result = [] - for obj in dirs_and_futures: - if hasattr(obj, 'get'): - for track in obj.get(): - result.extend(translator.track_to_mpd_format(track)) + for path, lookup_future in context.browse(uri): + if not lookup_future: + result.append(('directory', path)) else: - result.append(obj) - - if not result: - raise MpdNoExistError('Not found') - - return [('directory', root_path)] + result + for track in lookup_future.get(): + result.extend(translator.track_to_mpd_format(track)) + return result -@handle_request(r'lsinfo$') -@handle_request(r'lsinfo\ "(?P[^"]*)"$') +@protocol.commands.add('lsinfo') def lsinfo(context, uri=None): """ *musicpd.org, music database section:* @@ -498,31 +410,23 @@ def lsinfo(context, uri=None): ""``, and ``lsinfo "/"``. """ result = [] - root_path = translator.normalize_path(uri, relative=True) - try: - uri = context.directory_path_to_uri(root_path) - except MpdNoExistError as e: - e.command = 'lsinfo' - e.message = 'Not found' - raise - - if uri is None: - result.extend(stored_playlists.listplaylists(context)) - - for ref in context.core.library.browse(uri).get(): - if ref.type == Ref.DIRECTORY: - path = '/'.join([root_path, ref.name.replace('/', '')]) + for path, lookup_future in context.browse(uri, recursive=False): + if not lookup_future: result.append(('directory', path.lstrip('/'))) - elif ref.type == Ref.TRACK: - # TODO Lookup tracks in batch for better performance - tracks = context.core.library.lookup(ref.uri).get() + else: + tracks = lookup_future.get() if tracks: result.extend(translator.track_to_mpd_format(tracks[0])) + + if uri in (None, '', '/'): + result.extend(protocol.stored_playlists.listplaylists(context)) + + if not result: + raise exceptions.MpdNoExistError('Not found') return result -@handle_request(r'rescan$') -@handle_request(r'rescan\ "(?P[^"]+)"$') +@protocol.commands.add('rescan') def rescan(context, uri=None): """ *musicpd.org, music database section:* @@ -531,11 +435,11 @@ def rescan(context, uri=None): Same as ``update``, but also rescans unmodified files. """ - return update(context, uri, rescan_unmodified_files=True) + return {'updating_db': 0} # TODO -@handle_request(r'search\ ' + SEARCH_QUERY) -def search(context, mpd_query): +@protocol.commands.add('search') +def search(context, *args): """ *musicpd.org, music database section:* @@ -563,7 +467,7 @@ def search(context, mpd_query): - uses "file" instead of "filename". """ try: - query = _query_from_mpd_search_format(mpd_query) + query = _query_from_mpd_search_parameters(args, _SEARCH_MAPPING) except ValueError: return results = context.core.library.search(**query).get() @@ -573,8 +477,8 @@ def search(context, mpd_query): return translator.tracks_to_mpd_format(artists + albums + tracks) -@handle_request(r'searchadd\ ' + SEARCH_QUERY) -def searchadd(context, mpd_query): +@protocol.commands.add('searchadd') +def searchadd(context, *args): """ *musicpd.org, music database section:* @@ -587,15 +491,15 @@ def searchadd(context, mpd_query): not case sensitive. """ try: - query = _query_from_mpd_search_format(mpd_query) + query = _query_from_mpd_search_parameters(args, _SEARCH_MAPPING) except ValueError: return results = context.core.library.search(**query).get() context.core.tracklist.add(_get_tracks(results)) -@handle_request(r'searchaddpl\ "(?P[^"]+)"\ ' + SEARCH_QUERY) -def searchaddpl(context, playlist_name, mpd_query): +@protocol.commands.add('searchaddpl') +def searchaddpl(context, *args): """ *musicpd.org, music database section:* @@ -609,8 +513,12 @@ def searchaddpl(context, playlist_name, mpd_query): Parameters have the same meaning as for ``find``, except that search is not case sensitive. """ + parameters = list(args) + if not parameters: + raise exceptions.MpdArgError('incorrect arguments') + playlist_name = parameters.pop(0) try: - query = _query_from_mpd_search_format(mpd_query) + query = _query_from_mpd_search_parameters(parameters, _SEARCH_MAPPING) except ValueError: return results = context.core.library.search(**query).get() @@ -623,9 +531,8 @@ def searchaddpl(context, playlist_name, mpd_query): context.core.playlists.save(playlist) -@handle_request(r'update$') -@handle_request(r'update\ "(?P[^"]+)"$') -def update(context, uri=None, rescan_unmodified_files=False): +@protocol.commands.add('update') +def update(context, uri=None): """ *musicpd.org, music database section:* @@ -642,3 +549,27 @@ def update(context, uri=None, rescan_unmodified_files=False): ``status`` response. """ return {'updating_db': 0} # TODO + + +# TODO: add at least reflection tests before adding NotImplemented version +# @protocol.commands.add('readcomments') +def readcomments(context, uri): + """ + *musicpd.org, music database section:* + + ``readcomments [URI]`` + + Read "comments" (i.e. key-value pairs) from the file specified by + "URI". This "URI" can be a path relative to the music directory or a + URL in the form "file:///foo/bar.ogg". + + This command may be used to list metadata of remote files (e.g. URI + beginning with "http://" or "smb://"). + + The response consists of lines in the form "KEY: VALUE". Comments with + suspicious characters (e.g. newlines) are ignored silently. + + The meaning of these depends on the codec, and not all decoder plugins + support it. For example, on Ogg files, this lists the Vorbis comments. + """ + pass diff --git a/mopidy/mpd/protocol/playback.py b/mopidy/mpd/protocol/playback.py index 4f8ae73a..5b63c561 100644 --- a/mopidy/mpd/protocol/playback.py +++ b/mopidy/mpd/protocol/playback.py @@ -1,12 +1,12 @@ from __future__ import unicode_literals +import warnings + from mopidy.core import PlaybackState -from mopidy.mpd.protocol import handle_request -from mopidy.mpd.exceptions import ( - MpdArgError, MpdNoExistError, MpdNotImplemented) +from mopidy.mpd import exceptions, protocol -@handle_request(r'consume\ ("?)(?P[01])\1$') +@protocol.commands.add('consume', state=protocol.BOOL) def consume(context, state): """ *musicpd.org, playback section:* @@ -17,13 +17,10 @@ def consume(context, state): 1. When consume is activated, each song played is removed from playlist. """ - if int(state): - context.core.tracklist.consume = True - else: - context.core.tracklist.consume = False + context.core.tracklist.consume = state -@handle_request(r'crossfade\ "(?P\d+)"$') +@protocol.commands.add('crossfade', seconds=protocol.UINT) def crossfade(context, seconds): """ *musicpd.org, playback section:* @@ -32,11 +29,42 @@ def crossfade(context, seconds): Sets crossfading between songs. """ - seconds = int(seconds) - raise MpdNotImplemented # TODO + raise exceptions.MpdNotImplemented # TODO -@handle_request(r'next$') +# TODO: add at least reflection tests before adding NotImplemented version +# @protocol.commands.add('mixrampdb') +def mixrampdb(context, decibels): + """ + *musicpd.org, playback section:* + + ``mixrampdb {deciBels}`` + + Sets the threshold at which songs will be overlapped. Like crossfading but + doesn't fade the track volume, just overlaps. The songs need to have + MixRamp tags added by an external tool. 0dB is the normalized maximum + volume so use negative values, I prefer -17dB. In the absence of mixramp + tags crossfading will be used. See http://sourceforge.net/projects/mixramp + """ + pass + + +# TODO: add at least reflection tests before adding NotImplemented version +# @protocol.commands.add('mixrampdelay', seconds=protocol.UINT) +def mixrampdelay(context, seconds): + """ + *musicpd.org, playback section:* + + ``mixrampdelay {SECONDS}`` + + Additional time subtracted from the overlap calculated by mixrampdb. A + value of "nan" disables MixRamp overlapping and falls back to + crossfading. + """ + pass + + +@protocol.commands.add('next') def next_(context): """ *musicpd.org, playback section:* @@ -94,8 +122,7 @@ def next_(context): return context.core.playback.next().get() -@handle_request(r'pause$') -@handle_request(r'pause\ "(?P[01])"$') +@protocol.commands.add('pause', state=protocol.BOOL) def pause(context, state=None): """ *musicpd.org, playback section:* @@ -109,54 +136,22 @@ def pause(context, state=None): - Calls ``pause`` without any arguments to toogle pause. """ if state is None: + warnings.warn( + 'The use of pause command w/o the PAUSE argument is deprecated.', + DeprecationWarning) + if (context.core.playback.state.get() == PlaybackState.PLAYING): context.core.playback.pause() elif (context.core.playback.state.get() == PlaybackState.PAUSED): context.core.playback.resume() - elif int(state): + elif state: context.core.playback.pause() else: context.core.playback.resume() -@handle_request(r'play$') -def play(context): - """ - The original MPD server resumes from the paused state on ``play`` - without arguments. - """ - return context.core.playback.play().get() - - -@handle_request(r'playid\ ("?)(?P-?\d+)\1$') -def playid(context, tlid): - """ - *musicpd.org, playback section:* - - ``playid [SONGID]`` - - Begins playing the playlist at song ``SONGID``. - - *Clarifications:* - - - ``playid "-1"`` when playing is ignored. - - ``playid "-1"`` when paused resumes playback. - - ``playid "-1"`` when stopped with a current track starts playback at the - current track. - - ``playid "-1"`` when stopped without a current track, e.g. after playlist - replacement, starts playback at the first track. - """ - tlid = int(tlid) - if tlid == -1: - return _play_minus_one(context) - tl_tracks = context.core.tracklist.filter(tlid=[tlid]).get() - if not tl_tracks: - raise MpdNoExistError('No such song') - return context.core.playback.play(tl_tracks[0]).get() - - -@handle_request(r'play\ ("?)(?P-?\d+)\1$') -def play__pos(context, songpos): +@protocol.commands.add('play', tlid=protocol.INT) +def play(context, tlid=None): """ *musicpd.org, playback section:* @@ -164,6 +159,9 @@ def play__pos(context, songpos): Begins playing the playlist at song number ``SONGPOS``. + The original MPD server resumes from the paused state on ``play`` + without arguments. + *Clarifications:* - ``play "-1"`` when playing is ignored. @@ -177,14 +175,16 @@ def play__pos(context, songpos): - issues ``play 6`` without quotes around the argument. """ - songpos = int(songpos) - if songpos == -1: + if tlid is None: + return context.core.playback.play().get() + elif tlid == -1: return _play_minus_one(context) + try: - tl_track = context.core.tracklist.slice(songpos, songpos + 1).get()[0] + tl_track = context.core.tracklist.slice(tlid, tlid + 1).get()[0] return context.core.playback.play(tl_track).get() except IndexError: - raise MpdArgError('Bad song index') + raise exceptions.MpdArgError('Bad song index') def _play_minus_one(context): @@ -202,7 +202,33 @@ def _play_minus_one(context): return # Fail silently -@handle_request(r'previous$') +@protocol.commands.add('playid', tlid=protocol.INT) +def playid(context, tlid): + """ + *musicpd.org, playback section:* + + ``playid [SONGID]`` + + Begins playing the playlist at song ``SONGID``. + + *Clarifications:* + + - ``playid "-1"`` when playing is ignored. + - ``playid "-1"`` when paused resumes playback. + - ``playid "-1"`` when stopped with a current track starts playback at the + current track. + - ``playid "-1"`` when stopped without a current track, e.g. after playlist + replacement, starts playback at the first track. + """ + if tlid == -1: + return _play_minus_one(context) + tl_tracks = context.core.tracklist.filter(tlid=[tlid]).get() + if not tl_tracks: + raise exceptions.MpdNoExistError('No such song') + return context.core.playback.play(tl_tracks[0]).get() + + +@protocol.commands.add('previous') def previous(context): """ *musicpd.org, playback section:* @@ -249,7 +275,7 @@ def previous(context): return context.core.playback.previous().get() -@handle_request(r'random\ ("?)(?P[01])\1$') +@protocol.commands.add('random', state=protocol.BOOL) def random(context, state): """ *musicpd.org, playback section:* @@ -258,13 +284,10 @@ def random(context, state): Sets random state to ``STATE``, ``STATE`` should be 0 or 1. """ - if int(state): - context.core.tracklist.random = True - else: - context.core.tracklist.random = False + context.core.tracklist.random = state -@handle_request(r'repeat\ ("?)(?P[01])\1$') +@protocol.commands.add('repeat', state=protocol.BOOL) def repeat(context, state): """ *musicpd.org, playback section:* @@ -273,13 +296,10 @@ def repeat(context, state): Sets repeat state to ``STATE``, ``STATE`` should be 0 or 1. """ - if int(state): - context.core.tracklist.repeat = True - else: - context.core.tracklist.repeat = False + context.core.tracklist.repeat = state -@handle_request(r'replay_gain_mode\ "(?P(off|track|album))"$') +@protocol.commands.add('replay_gain_mode') def replay_gain_mode(context, mode): """ *musicpd.org, playback section:* @@ -293,10 +313,10 @@ def replay_gain_mode(context, mode): This command triggers the options idle event. """ - raise MpdNotImplemented # TODO + raise exceptions.MpdNotImplemented # TODO -@handle_request(r'replay_gain_status$') +@protocol.commands.add('replay_gain_status') def replay_gain_status(context): """ *musicpd.org, playback section:* @@ -309,8 +329,8 @@ def replay_gain_status(context): return 'off' # TODO -@handle_request(r'seek\ ("?)(?P\d+)\1\ ("?)(?P\d+)\3$') -def seek(context, songpos, seconds): +@protocol.commands.add('seek', tlid=protocol.UINT, seconds=protocol.UINT) +def seek(context, tlid, seconds): """ *musicpd.org, playback section:* @@ -324,12 +344,12 @@ def seek(context, songpos, seconds): - issues ``seek 1 120`` without quotes around the arguments. """ tl_track = context.core.playback.current_tl_track.get() - if context.core.tracklist.index(tl_track).get() != int(songpos): - play__pos(context, songpos) - context.core.playback.seek(int(seconds) * 1000).get() + if context.core.tracklist.index(tl_track).get() != tlid: + play(context, tlid) + context.core.playback.seek(seconds * 1000).get() -@handle_request(r'seekid\ "(?P\d+)"\ "(?P\d+)"$') +@protocol.commands.add('seekid', tlid=protocol.UINT, seconds=protocol.UINT) def seekid(context, tlid, seconds): """ *musicpd.org, playback section:* @@ -339,14 +359,13 @@ def seekid(context, tlid, seconds): Seeks to the position ``TIME`` (in seconds) of song ``SONGID``. """ tl_track = context.core.playback.current_tl_track.get() - if not tl_track or tl_track.tlid != int(tlid): + if not tl_track or tl_track.tlid != tlid: playid(context, tlid) - context.core.playback.seek(int(seconds) * 1000).get() + context.core.playback.seek(seconds * 1000).get() -@handle_request(r'seekcur\ "(?P\d+)"$') -@handle_request(r'seekcur\ "(?P[-+]\d+)"$') -def seekcur(context, position=None, diff=None): +@protocol.commands.add('seekcur') +def seekcur(context, time): """ *musicpd.org, playback section:* @@ -355,16 +374,16 @@ def seekcur(context, position=None, diff=None): Seeks to the position ``TIME`` within the current song. If prefixed by '+' or '-', then the time is relative to the current playing position. """ - if position is not None: - position = int(position) * 1000 - context.core.playback.seek(position).get() - elif diff is not None: + if time.startswith(('+', '-')): position = context.core.playback.time_position.get() - position += int(diff) * 1000 + position += protocol.INT(time) * 1000 + context.core.playback.seek(position).get() + else: + position = protocol.UINT(time) * 1000 context.core.playback.seek(position).get() -@handle_request(r'setvol\ ("?)(?P[-+]*\d+)\1$') +@protocol.commands.add('setvol', volume=protocol.INT) def setvol(context, volume): """ *musicpd.org, playback section:* @@ -377,15 +396,11 @@ def setvol(context, volume): - issues ``setvol 50`` without quotes around the argument. """ - volume = int(volume) - if volume < 0: - volume = 0 - if volume > 100: - volume = 100 - context.core.playback.volume = volume + # NOTE: we use INT as clients can pass in +N etc. + context.core.playback.volume = min(max(0, volume), 100) -@handle_request(r'single\ ("?)(?P[01])\1$') +@protocol.commands.add('single', state=protocol.BOOL) def single(context, state): """ *musicpd.org, playback section:* @@ -396,13 +411,10 @@ def single(context, state): single is activated, playback is stopped after current song, or song is repeated if the ``repeat`` mode is enabled. """ - if int(state): - context.core.tracklist.single = True - else: - context.core.tracklist.single = False + context.core.tracklist.single = state -@handle_request(r'stop$') +@protocol.commands.add('stop') def stop(context): """ *musicpd.org, playback section:* diff --git a/mopidy/mpd/protocol/reflection.py b/mopidy/mpd/protocol/reflection.py index 79aa1247..4308c560 100644 --- a/mopidy/mpd/protocol/reflection.py +++ b/mopidy/mpd/protocol/reflection.py @@ -1,10 +1,9 @@ from __future__ import unicode_literals -from mopidy.mpd.exceptions import MpdPermissionError -from mopidy.mpd.protocol import handle_request, mpd_commands +from mopidy.mpd import exceptions, protocol -@handle_request(r'config$', auth_required=False) +@protocol.commands.add('config', list_command=False) def config(context): """ *musicpd.org, reflection section:* @@ -15,10 +14,10 @@ def config(context): command is only permitted to "local" clients (connected via UNIX domain socket). """ - raise MpdPermissionError(command='config') + raise exceptions.MpdPermissionError(command='config') -@handle_request(r'commands$', auth_required=False) +@protocol.commands.add('commands', auth_required=False) def commands(context): """ *musicpd.org, reflection section:* @@ -27,25 +26,18 @@ def commands(context): Shows which commands the current user has access to. """ - if context.dispatcher.authenticated: - command_names = set([command.name for command in mpd_commands]) - else: - command_names = set([ - command.name for command in mpd_commands - if not command.auth_required]) - - # No one is permited to use 'config' or 'kill', rest of commands are not - # listed by MPD, so we shouldn't either. - command_names = command_names - set([ - 'config', 'kill', 'command_list_begin', 'command_list_ok_begin', - 'command_list_ok_begin', 'command_list_end', 'idle', 'noidle', - 'sticker']) + command_names = set() + for name, handler in protocol.commands.handlers.items(): + if not handler.list_command: + continue + if context.dispatcher.authenticated or not handler.auth_required: + command_names.add(name) return [ ('command', command_name) for command_name in sorted(command_names)] -@handle_request(r'decoders$') +@protocol.commands.add('decoders') def decoders(context): """ *musicpd.org, reflection section:* @@ -72,7 +64,7 @@ def decoders(context): return # TODO -@handle_request(r'notcommands$', auth_required=False) +@protocol.commands.add('notcommands', auth_required=False) def notcommands(context): """ *musicpd.org, reflection section:* @@ -81,21 +73,18 @@ def notcommands(context): Shows which commands the current user does not have access to. """ - if context.dispatcher.authenticated: - command_names = [] - else: - command_names = [ - command.name for command in mpd_commands if command.auth_required] - - # No permission to use - command_names.append('config') - command_names.append('kill') + command_names = set(['config', 'kill']) # No permission to use + for name, handler in protocol.commands.handlers.items(): + if not handler.list_command: + continue + if not context.dispatcher.authenticated and handler.auth_required: + command_names.add(name) return [ ('command', command_name) for command_name in sorted(command_names)] -@handle_request(r'tagtypes$') +@protocol.commands.add('tagtypes') def tagtypes(context): """ *musicpd.org, reflection section:* @@ -107,7 +96,7 @@ def tagtypes(context): pass # TODO -@handle_request(r'urlhandlers$') +@protocol.commands.add('urlhandlers') def urlhandlers(context): """ *musicpd.org, reflection section:* diff --git a/mopidy/mpd/protocol/status.py b/mopidy/mpd/protocol/status.py index 96bca6d6..8f97c2e4 100644 --- a/mopidy/mpd/protocol/status.py +++ b/mopidy/mpd/protocol/status.py @@ -3,9 +3,7 @@ from __future__ import unicode_literals import pykka from mopidy.core import PlaybackState -from mopidy.mpd.exceptions import MpdNotImplemented -from mopidy.mpd.protocol import handle_request -from mopidy.mpd.translator import track_to_mpd_format +from mopidy.mpd import exceptions, protocol, translator #: Subsystems that can be registered with idle command. SUBSYSTEMS = [ @@ -13,7 +11,7 @@ SUBSYSTEMS = [ 'stored_playlist', 'update'] -@handle_request(r'clearerror$') +@protocol.commands.add('clearerror') def clearerror(context): """ *musicpd.org, status section:* @@ -23,10 +21,10 @@ def clearerror(context): Clears the current error message in status (this is also accomplished by any command that starts playback). """ - raise MpdNotImplemented # TODO + raise exceptions.MpdNotImplemented # TODO -@handle_request(r'currentsong$') +@protocol.commands.add('currentsong') def currentsong(context): """ *musicpd.org, status section:* @@ -39,12 +37,11 @@ def currentsong(context): tl_track = context.core.playback.current_tl_track.get() if tl_track is not None: position = context.core.tracklist.index(tl_track).get() - return track_to_mpd_format(tl_track, position=position) + return translator.track_to_mpd_format(tl_track, position=position) -@handle_request(r'idle$') -@handle_request(r'idle\ (?P.+)$') -def idle(context, subsystems=None): +@protocol.commands.add('idle', list_command=False) +def idle(context, *subsystems): """ *musicpd.org, status section:* @@ -77,10 +74,9 @@ def idle(context, subsystems=None): notifications when something changed in one of the specified subsystems. """ + # TODO: test against valid subsystems - if subsystems: - subsystems = subsystems.split() - else: + if not subsystems: subsystems = SUBSYSTEMS for subsystem in subsystems: @@ -100,7 +96,7 @@ def idle(context, subsystems=None): return response -@handle_request(r'noidle$') +@protocol.commands.add('noidle', list_command=False) def noidle(context): """See :meth:`_status_idle`.""" if not context.subscriptions: @@ -110,7 +106,7 @@ def noidle(context): context.session.prevent_timeout = False -@handle_request(r'stats$') +@protocol.commands.add('stats') def stats(context): """ *musicpd.org, status section:* @@ -137,7 +133,7 @@ def stats(context): } -@handle_request(r'status$') +@protocol.commands.add('status') def status(context): """ *musicpd.org, status section:* diff --git a/mopidy/mpd/protocol/stickers.py b/mopidy/mpd/protocol/stickers.py index 17798523..4d535423 100644 --- a/mopidy/mpd/protocol/stickers.py +++ b/mopidy/mpd/protocol/stickers.py @@ -1,76 +1,38 @@ from __future__ import unicode_literals -from mopidy.mpd.protocol import handle_request -from mopidy.mpd.exceptions import MpdNotImplemented +from mopidy.mpd import exceptions, protocol -@handle_request( - r'sticker\ delete\ "(?P[^"]+)"\ ' - r'"(?P[^"]+)"(\ "(?P[^"]+)")*$') -def sticker__delete(context, field, uri, name=None): - """ - *musicpd.org, sticker section:* - - ``sticker delete {TYPE} {URI} [NAME]`` - - Deletes a sticker value from the specified object. If you do not - specify a sticker name, all sticker values are deleted. - """ - raise MpdNotImplemented # TODO - - -@handle_request( - r'sticker\ find\ "(?P[^"]+)"\ "(?P[^"]+)"\ ' - r'"(?P[^"]+)"$') -def sticker__find(context, field, uri, name): - """ - *musicpd.org, sticker section:* - - ``sticker find {TYPE} {URI} {NAME}`` - - Searches the sticker database for stickers with the specified name, - below the specified directory (``URI``). For each matching song, it - prints the ``URI`` and that one sticker's value. - """ - raise MpdNotImplemented # TODO - - -@handle_request( - r'sticker\ get\ "(?P[^"]+)"\ "(?P[^"]+)"\ ' - r'"(?P[^"]+)"$') -def sticker__get(context, field, uri, name): - """ - *musicpd.org, sticker section:* - - ``sticker get {TYPE} {URI} {NAME}`` - - Reads a sticker value for the specified object. - """ - raise MpdNotImplemented # TODO - - -@handle_request(r'sticker\ list\ "(?P[^"]+)"\ "(?P[^"]+)"$') -def sticker__list(context, field, uri): +@protocol.commands.add('sticker', list_command=False) +def sticker(context, action, field, uri, name=None, value=None): """ *musicpd.org, sticker section:* ``sticker list {TYPE} {URI}`` Lists the stickers for the specified object. - """ - raise MpdNotImplemented # TODO + ``sticker find {TYPE} {URI} {NAME}`` -@handle_request( - r'sticker\ set\ "(?P[^"]+)"\ "(?P[^"]+)"\ ' - r'"(?P[^"]+)"\ "(?P[^"]+)"$') -def sticker__set(context, field, uri, name, value): - """ - *musicpd.org, sticker section:* + Searches the sticker database for stickers with the specified name, + below the specified directory (``URI``). For each matching song, it + prints the ``URI`` and that one sticker's value. + + ``sticker get {TYPE} {URI} {NAME}`` + + Reads a sticker value for the specified object. ``sticker set {TYPE} {URI} {NAME} {VALUE}`` Adds a sticker value to the specified object. If a sticker item with that name already exists, it is replaced. + + ``sticker delete {TYPE} {URI} [NAME]`` + + Deletes a sticker value from the specified object. If you do not + specify a sticker name, all sticker values are deleted. + """ - raise MpdNotImplemented # TODO + # TODO: check that action in ('list', 'find', 'get', 'set', 'delete') + # TODO: check name/value matches with action + raise exceptions.MpdNotImplemented # TODO diff --git a/mopidy/mpd/protocol/stored_playlists.py b/mopidy/mpd/protocol/stored_playlists.py index a852d795..f4d48ff0 100644 --- a/mopidy/mpd/protocol/stored_playlists.py +++ b/mopidy/mpd/protocol/stored_playlists.py @@ -1,13 +1,11 @@ -from __future__ import unicode_literals +from __future__ import division, unicode_literals -import datetime as dt +import datetime -from mopidy.mpd.exceptions import MpdNoExistError, MpdNotImplemented -from mopidy.mpd.protocol import handle_request -from mopidy.mpd.translator import playlist_to_mpd_format +from mopidy.mpd import exceptions, protocol, translator -@handle_request(r'listplaylist\ ("?)(?P[^"]+)\1$') +@protocol.commands.add('listplaylist') def listplaylist(context, name): """ *musicpd.org, stored playlists section:* @@ -24,11 +22,11 @@ def listplaylist(context, name): """ playlist = context.lookup_playlist_from_name(name) if not playlist: - raise MpdNoExistError('No such playlist') + raise exceptions.MpdNoExistError('No such playlist') return ['file: %s' % t.uri for t in playlist.tracks] -@handle_request(r'listplaylistinfo\ ("?)(?P[^"]+)\1$') +@protocol.commands.add('listplaylistinfo') def listplaylistinfo(context, name): """ *musicpd.org, stored playlists section:* @@ -44,11 +42,11 @@ def listplaylistinfo(context, name): """ playlist = context.lookup_playlist_from_name(name) if not playlist: - raise MpdNoExistError('No such playlist') - return playlist_to_mpd_format(playlist) + raise exceptions.MpdNoExistError('No such playlist') + return translator.playlist_to_mpd_format(playlist) -@handle_request(r'listplaylists$') +@protocol.commands.add('listplaylists') def listplaylists(context): """ *musicpd.org, stored playlists section:* @@ -80,19 +78,29 @@ def listplaylists(context): continue name = context.lookup_playlist_name_from_uri(playlist.uri) result.append(('playlist', name)) - last_modified = ( - playlist.last_modified or dt.datetime.utcnow()).isoformat() - # Remove microseconds - last_modified = last_modified.split('.')[0] - # Add time zone information - last_modified = last_modified + 'Z' - result.append(('Last-Modified', last_modified)) + result.append(('Last-Modified', _get_last_modified(playlist))) return result -@handle_request( - r'load\ "(?P[^"]+)"(\ "(?P\d+):(?P\d+)*")*$') -def load(context, name, start=None, end=None): +# TODO: move to translators? +def _get_last_modified(playlist): + """Formats last modified timestamp of a playlist for MPD. + + Time in UTC with second precision, formatted in the ISO 8601 format, with + the "Z" time zone marker for UTC. For example, "1970-01-01T00:00:00Z". + """ + if playlist.last_modified is None: + # If unknown, assume the playlist is modified + dt = datetime.datetime.utcnow() + else: + dt = datetime.datetime.utcfromtimestamp( + playlist.last_modified / 1000.0) + dt = dt.replace(microsecond=0) + return '%sZ' % dt.isoformat() + + +@protocol.commands.add('load', playlist_slice=protocol.RANGE) +def load(context, name, playlist_slice=slice(0, None)): """ *musicpd.org, stored playlists section:* @@ -115,15 +123,11 @@ def load(context, name, start=None, end=None): """ playlist = context.lookup_playlist_from_name(name) if not playlist: - raise MpdNoExistError('No such playlist') - if start is not None: - start = int(start) - if end is not None: - end = int(end) - context.core.tracklist.add(playlist.tracks[start:end]) + raise exceptions.MpdNoExistError('No such playlist') + context.core.tracklist.add(playlist.tracks[playlist_slice]) -@handle_request(r'playlistadd\ "(?P[^"]+)"\ "(?P[^"]+)"$') +@protocol.commands.add('playlistadd') def playlistadd(context, name, uri): """ *musicpd.org, stored playlists section:* @@ -134,10 +138,10 @@ def playlistadd(context, name, uri): ``NAME.m3u`` will be created if it does not exist. """ - raise MpdNotImplemented # TODO + raise exceptions.MpdNotImplemented # TODO -@handle_request(r'playlistclear\ "(?P[^"]+)"$') +@protocol.commands.add('playlistclear') def playlistclear(context, name): """ *musicpd.org, stored playlists section:* @@ -146,10 +150,10 @@ def playlistclear(context, name): Clears the playlist ``NAME.m3u``. """ - raise MpdNotImplemented # TODO + raise exceptions.MpdNotImplemented # TODO -@handle_request(r'playlistdelete\ "(?P[^"]+)"\ "(?P\d+)"$') +@protocol.commands.add('playlistdelete', songpos=protocol.UINT) def playlistdelete(context, name, songpos): """ *musicpd.org, stored playlists section:* @@ -158,12 +162,11 @@ def playlistdelete(context, name, songpos): Deletes ``SONGPOS`` from the playlist ``NAME.m3u``. """ - raise MpdNotImplemented # TODO + raise exceptions.MpdNotImplemented # TODO -@handle_request( - r'playlistmove\ "(?P[^"]+)"\ ' - r'"(?P\d+)"\ "(?P\d+)"$') +@protocol.commands.add( + 'playlistmove', from_pos=protocol.UINT, to_pos=protocol.UINT) def playlistmove(context, name, from_pos, to_pos): """ *musicpd.org, stored playlists section:* @@ -179,10 +182,10 @@ def playlistmove(context, name, from_pos, to_pos): documentation, but just the ``SONGPOS`` to move *from*, i.e. ``playlistmove {NAME} {FROM_SONGPOS} {TO_SONGPOS}``. """ - raise MpdNotImplemented # TODO + raise exceptions.MpdNotImplemented # TODO -@handle_request(r'rename\ "(?P[^"]+)"\ "(?P[^"]+)"$') +@protocol.commands.add('rename') def rename(context, old_name, new_name): """ *musicpd.org, stored playlists section:* @@ -191,10 +194,10 @@ def rename(context, old_name, new_name): Renames the playlist ``NAME.m3u`` to ``NEW_NAME.m3u``. """ - raise MpdNotImplemented # TODO + raise exceptions.MpdNotImplemented # TODO -@handle_request(r'rm\ "(?P[^"]+)"$') +@protocol.commands.add('rm') def rm(context, name): """ *musicpd.org, stored playlists section:* @@ -203,10 +206,10 @@ def rm(context, name): Removes the playlist ``NAME.m3u`` from the playlist directory. """ - raise MpdNotImplemented # TODO + raise exceptions.MpdNotImplemented # TODO -@handle_request(r'save\ "(?P[^"]+)"$') +@protocol.commands.add('save') def save(context, name): """ *musicpd.org, stored playlists section:* @@ -216,4 +219,4 @@ def save(context, name): Saves the current playlist to ``NAME.m3u`` in the playlist directory. """ - raise MpdNotImplemented # TODO + raise exceptions.MpdNotImplemented # TODO diff --git a/mopidy/mpd/session.py b/mopidy/mpd/session.py index 2c0bd840..f0317ede 100644 --- a/mopidy/mpd/session.py +++ b/mopidy/mpd/session.py @@ -45,7 +45,7 @@ class MpdSession(network.LineProtocol): def decode(self, line): try: - return super(MpdSession, self).decode(line.decode('string_escape')) + return super(MpdSession, self).decode(line) except ValueError: logger.warning( 'Stopping actor due to unescaping error, data ' diff --git a/mopidy/mpd/tokenize.py b/mopidy/mpd/tokenize.py new file mode 100644 index 00000000..bc0d6b3f --- /dev/null +++ b/mopidy/mpd/tokenize.py @@ -0,0 +1,88 @@ +from __future__ import unicode_literals + +import re + +from mopidy.mpd import exceptions + + +WORD_RE = re.compile(r""" + ^ + (\s*) # Leading whitespace not allowed, capture it to report. + ([a-z][a-z0-9_]*) # A command name + (?:\s+|$) # trailing whitespace or EOS + (.*) # Possibly a remainder to be parsed + """, re.VERBOSE) + +# Quotes matching is an unrolled version of "(?:[^"\\]|\\.)*" +PARAM_RE = re.compile(r""" + ^ # Leading whitespace is not allowed + (?: + ([^%(unprintable)s"']+) # ord(char) < 0x20, not ", not ' + | # or + "([^"\\]*(?:\\.[^"\\]*)*)" # anything surrounded by quotes + ) + (?:\s+|$) # trailing whitespace or EOS + (.*) # Possibly a remainder to be parsed + """ % {'unprintable': ''.join(map(chr, range(0x21)))}, re.VERBOSE) + +BAD_QUOTED_PARAM_RE = re.compile(r""" + ^ + "[^"\\]*(?:\\.[^"\\]*)* # start of a quoted value + (?: # followed by: + ("[^\s]) # non-escaped quote, followed by non-whitespace + | # or + ([^"]) # anything that is not a quote + ) + """, re.VERBOSE) + +UNESCAPE_RE = re.compile(r'\\(.)') # Backslash escapes any following char. + + +def split(line): + """Splits a line into tokens using same rules as MPD. + + - Lines may not start with whitespace + - Tokens are split by arbitrary amount of spaces or tabs + - First token must match `[a-z][a-z0-9_]*` + - Remaining tokens can be unquoted or quoted tokens. + - Unquoted tokens consist of all printable characters except double quotes, + single quotes, spaces and tabs. + - Quoted tokens are surrounded by a matching pair of double quotes. + - The closing quote must be followed by space, tab or end of line. + - Any value is allowed inside a quoted token. Including double quotes, + assuming it is correctly escaped. + - Backslash inside a quoted token is used to escape the following + character. + + For examples see the tests for this function. + """ + if not line.strip(): + raise exceptions.MpdNoCommand('No command given') + match = WORD_RE.match(line) + if not match: + raise exceptions.MpdUnknownError('Invalid word character') + whitespace, command, remainder = match.groups() + if whitespace: + raise exceptions.MpdUnknownError('Letter expected') + + result = [command] + while remainder: + match = PARAM_RE.match(remainder) + if not match: + msg = _determine_error_message(remainder) + raise exceptions.MpdArgError(msg, command=command) + unquoted, quoted, remainder = match.groups() + result.append(unquoted or UNESCAPE_RE.sub(r'\g<1>', quoted)) + return result + + +def _determine_error_message(remainder): + """Helper to emulate MPD errors.""" + # Following checks are simply to match MPD error messages: + match = BAD_QUOTED_PARAM_RE.match(remainder) + if match: + if match.group(1): + return 'Space expected after closing \'"\'' + else: + return 'Missing closing \'"\'' + return 'Invalid unquoted character' diff --git a/mopidy/mpd/translator.py b/mopidy/mpd/translator.py index 520e9ac8..252725ee 100644 --- a/mopidy/mpd/translator.py +++ b/mopidy/mpd/translator.py @@ -1,9 +1,7 @@ from __future__ import unicode_literals import re -import shlex -from mopidy.mpd.exceptions import MpdArgError from mopidy.models import TlTrack # TODO: special handling of local:// uri scheme @@ -137,46 +135,3 @@ def playlist_to_mpd_format(playlist, *args, **kwargs): Arguments as for :func:`tracks_to_mpd_format`, except the first one. """ return tracks_to_mpd_format(playlist.tracks, *args, **kwargs) - - -def query_from_mpd_list_format(field, mpd_query): - """ - Converts an MPD ``list`` query to a Mopidy query. - """ - if mpd_query is None: - return {} - try: - # shlex does not seem to be friends with unicode objects - tokens = shlex.split(mpd_query.encode('utf-8')) - except ValueError as error: - if str(error) == 'No closing quotation': - raise MpdArgError('Invalid unquoted character', command='list') - else: - raise - tokens = [t.decode('utf-8') for t in tokens] - if len(tokens) == 1: - if field == 'album': - if not tokens[0]: - raise ValueError - return {'artist': [tokens[0]]} - else: - raise MpdArgError( - 'should be "Album" for 3 arguments', command='list') - elif len(tokens) % 2 == 0: - query = {} - while tokens: - key = tokens[0].lower() - value = tokens[1] - tokens = tokens[2:] - if key not in ('artist', 'album', 'albumartist', 'composer', - 'date', 'genre', 'performer'): - raise MpdArgError('not able to parse args', command='list') - if not value: - raise ValueError - if key in query: - query[key].append(value) - else: - query[key] = [value] - return query - else: - raise MpdArgError('not able to parse args', command='list') diff --git a/mopidy/softwaremixer/__init__.py b/mopidy/softwaremixer/__init__.py new file mode 100644 index 00000000..242069eb --- /dev/null +++ b/mopidy/softwaremixer/__init__.py @@ -0,0 +1,25 @@ +from __future__ import unicode_literals + +import os + +import mopidy +from mopidy import config, ext + + +class Extension(ext.Extension): + + dist_name = 'Mopidy-SoftwareMixer' + ext_name = 'softwaremixer' + version = mopidy.__version__ + + def get_default_config(self): + conf_file = os.path.join(os.path.dirname(__file__), 'ext.conf') + return config.read(conf_file) + + def get_config_schema(self): + schema = super(Extension, self).get_config_schema() + return schema + + def setup(self, registry): + from .mixer import SoftwareMixer + registry.add('mixer', SoftwareMixer) diff --git a/mopidy/softwaremixer/ext.conf b/mopidy/softwaremixer/ext.conf new file mode 100644 index 00000000..47a98ba7 --- /dev/null +++ b/mopidy/softwaremixer/ext.conf @@ -0,0 +1,2 @@ +[softwaremixer] +enabled = true diff --git a/mopidy/softwaremixer/mixer.py b/mopidy/softwaremixer/mixer.py new file mode 100644 index 00000000..0ebbfeb7 --- /dev/null +++ b/mopidy/softwaremixer/mixer.py @@ -0,0 +1,56 @@ +from __future__ import unicode_literals + +import logging + +import pykka + +from mopidy import mixer + + +logger = logging.getLogger(__name__) + + +class SoftwareMixer(pykka.ThreadingActor, mixer.Mixer): + + name = 'software' + + def __init__(self, config): + super(SoftwareMixer, self).__init__(config) + + self.audio = None + self._last_volume = None + self._last_mute = None + + logger.info('Mixing using GStreamer software mixing') + + def get_volume(self): + if self.audio is None: + return None + return self.audio.get_volume().get() + + def set_volume(self, volume): + if self.audio is None: + return False + self.audio.set_volume(volume) + return True + + def get_mute(self): + if self.audio is None: + return None + return self.audio.get_mute().get() + + def set_mute(self, mute): + if self.audio is None: + return False + self.audio.set_mute(mute) + return True + + def trigger_events_for_changed_values(self): + old_volume, self._last_volume = self._last_volume, self.get_volume() + old_mute, self._last_mute = self._last_mute, self.get_mute() + + if old_volume != self._last_volume: + self.trigger_volume_changed(self._last_volume) + + if old_mute != self._last_mute: + self.trigger_mute_changed(self._last_mute) diff --git a/mopidy/stream/__init__.py b/mopidy/stream/__init__.py index e4c2bad7..2cb77365 100644 --- a/mopidy/stream/__init__.py +++ b/mopidy/stream/__init__.py @@ -19,6 +19,7 @@ class Extension(ext.Extension): def get_config_schema(self): schema = super(Extension, self).get_config_schema() schema['protocols'] = config.List() + schema['metadata_blacklist'] = config.List(optional=True) schema['timeout'] = config.Integer( minimum=1000, maximum=1000 * 60 * 60) return schema diff --git a/mopidy/stream/actor.py b/mopidy/stream/actor.py index aecc4e42..b17dfcea 100644 --- a/mopidy/stream/actor.py +++ b/mopidy/stream/actor.py @@ -1,6 +1,8 @@ from __future__ import unicode_literals +import fnmatch import logging +import re import urlparse import pykka @@ -17,7 +19,8 @@ class StreamBackend(pykka.ThreadingActor, backend.Backend): super(StreamBackend, self).__init__() self.library = StreamLibraryProvider( - backend=self, timeout=config['stream']['timeout']) + backend=self, timeout=config['stream']['timeout'], + blacklist=config['stream']['metadata_blacklist']) self.playback = backend.PlaybackProvider(audio=audio, backend=self) self.playlists = None @@ -26,19 +29,25 @@ class StreamBackend(pykka.ThreadingActor, backend.Backend): class StreamLibraryProvider(backend.LibraryProvider): - def __init__(self, backend, timeout): + def __init__(self, backend, timeout, blacklist): super(StreamLibraryProvider, self).__init__(backend) self._scanner = scan.Scanner(min_duration=None, timeout=timeout) + self._blacklist_re = re.compile( + r'^(%s)$' % '|'.join(fnmatch.translate(u) for u in blacklist)) def lookup(self, uri): if urlparse.urlsplit(uri).scheme not in self.backend.uri_schemes: return [] + if self._blacklist_re.match(uri): + logger.debug('URI matched metadata lookup blacklist: %s', uri) + return [Track(uri=uri)] + try: data = self._scanner.scan(uri) track = scan.audio_data_to_track(data) except exceptions.ScannerError as e: logger.warning('Problem looking up %s: %s', uri, e) - track = Track(uri=uri, name=uri) + track = Track(uri=uri) return [track] diff --git a/mopidy/stream/ext.conf b/mopidy/stream/ext.conf index 811dec88..cedb3085 100644 --- a/mopidy/stream/ext.conf +++ b/mopidy/stream/ext.conf @@ -9,3 +9,4 @@ protocols = rtmps rtsp timeout = 5000 +metadata_blacklist = diff --git a/mopidy/utils/deps.py b/mopidy/utils/deps.py index a1adf0a7..46feb711 100644 --- a/mopidy/utils/deps.py +++ b/mopidy/utils/deps.py @@ -6,11 +6,11 @@ import platform import pygst pygst.require('0.10') -import gst +import gst # noqa import pkg_resources -from . import formatting +from mopidy.utils import formatting def format_dependency_list(adapters=None): @@ -155,7 +155,6 @@ def _gstreamer_check_elements(): # MP3 encoding and decoding 'mp3parse', - 'mad', 'id3demux', 'id3v2mux', 'lame', diff --git a/mopidy/utils/jsonrpc.py b/mopidy/utils/jsonrpc.py index 503921dc..85565262 100644 --- a/mopidy/utils/jsonrpc.py +++ b/mopidy/utils/jsonrpc.py @@ -155,13 +155,13 @@ class JsonRpcWrapper(object): if not isinstance(request, dict): raise JsonRpcInvalidRequestError( data='Request must be an object') - if not 'jsonrpc' in request: + if 'jsonrpc' not in request: raise JsonRpcInvalidRequestError( data='"jsonrpc" member must be included') if request['jsonrpc'] != '2.0': raise JsonRpcInvalidRequestError( data='"jsonrpc" value must be "2.0"') - if not 'method' in request: + if 'method' not in request: raise JsonRpcInvalidRequestError( data='"method" member must be included') if not isinstance(request['method'], unicode): @@ -169,7 +169,7 @@ class JsonRpcWrapper(object): data='"method" must be a string') def _get_params(self, request): - if not 'params' in request: + if 'params' not in request: return [], {} params = request['params'] if isinstance(params, list): diff --git a/mopidy/utils/log.py b/mopidy/utils/log.py index 6004b9f9..5d6d3635 100644 --- a/mopidy/utils/log.py +++ b/mopidy/utils/log.py @@ -3,6 +3,16 @@ from __future__ import unicode_literals import logging import logging.config import logging.handlers +import platform + + +LOG_LEVELS = { + -1: dict(root=logging.ERROR, mopidy=logging.WARNING), + 0: dict(root=logging.ERROR, mopidy=logging.INFO), + 1: dict(root=logging.WARNING, mopidy=logging.DEBUG), + 2: dict(root=logging.INFO, mopidy=logging.DEBUG), + 3: dict(root=logging.DEBUG, mopidy=logging.DEBUG), +} class DelayedHandler(logging.Handler): @@ -39,7 +49,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,47 +56,28 @@ 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), - 1: dict(root=logging.WARNING, mopidy=logging.DEBUG), - 2: dict(root=logging.INFO, mopidy=logging.DEBUG), - 3: dict(root=logging.DEBUG, mopidy=logging.DEBUG), -} - - -class VerbosityFilter(logging.Filter): - def __init__(self, verbosity_level): - self.verbosity_level = verbosity_level - - def filter(self, record): - if record.name.startswith('mopidy'): - required_log_level = LOG_LEVELS[self.verbosity_level]['mopidy'] - else: - required_log_level = LOG_LEVELS[self.verbosity_level]['root'] - return record.levelno >= required_log_level - - def setup_console_logging(config, verbosity_level): if verbosity_level < min(LOG_LEVELS.keys()): verbosity_level = min(LOG_LEVELS.keys()) 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'] formatter = logging.Formatter(log_format) - handler = logging.StreamHandler() + if config['logging']['color']: + handler = ColorizingStreamHandler() + else: + handler = logging.StreamHandler() handler.addFilter(verbosity_filter) handler.setFormatter(formatter) @@ -101,3 +91,93 @@ def setup_debug_logging_to_file(config): handler.setFormatter(formatter) logging.getLogger('').addHandler(handler) + + +class VerbosityFilter(logging.Filter): + 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: + required_log_level = LOG_LEVELS[self.verbosity_level]['root'] + return record.levelno >= required_log_level + + +class ColorizingStreamHandler(logging.StreamHandler): + """ + Stream handler which colorizes the log using ANSI escape sequences. + + Does nothing on Windows, which doesn't support ANSI escape sequences. + + This implementation is based upon https://gist.github.com/vsajip/758430, + which is: + + Copyright (C) 2010-2012 Vinay Sajip. All rights reserved. + Licensed under the new BSD license. + """ + + color_map = { + 'black': 0, + 'red': 1, + 'green': 2, + 'yellow': 3, + 'blue': 4, + 'magenta': 5, + 'cyan': 6, + 'white': 7, + } + + # Map logging levels to (background, foreground, bold/intense) + level_map = { + logging.DEBUG: (None, 'blue', False), + logging.INFO: (None, 'white', False), + logging.WARNING: (None, 'yellow', False), + logging.ERROR: (None, 'red', False), + logging.CRITICAL: ('red', 'white', True), + } + csi = '\x1b[' + reset = '\x1b[0m' + + is_windows = platform.system() == 'Windows' + + @property + def is_tty(self): + isatty = getattr(self.stream, 'isatty', None) + return isatty and isatty() + + def emit(self, record): + try: + message = self.format(record) + self.stream.write(message) + self.stream.write(getattr(self, 'terminator', '\n')) + self.flush() + except Exception: + self.handleError(record) + + def format(self, record): + message = logging.StreamHandler.format(self, record) + if not self.is_tty or self.is_windows: + return message + return self.colorize(message, record) + + def colorize(self, message, record): + if record.levelno in self.level_map: + bg, fg, bold = self.level_map[record.levelno] + params = [] + if bg in self.color_map: + params.append(str(self.color_map[bg] + 40)) + if fg in self.color_map: + params.append(str(self.color_map[fg] + 30)) + if bold: + params.append('1') + if params: + message = ''.join(( + self.csi, ';'.join(params), 'm', message, self.reset)) + return message diff --git a/mopidy/utils/network.py b/mopidy/utils/network.py index 20587eac..11469b47 100644 --- a/mopidy/utils/network.py +++ b/mopidy/utils/network.py @@ -1,13 +1,14 @@ from __future__ import unicode_literals import errno -import gobject import logging import re import socket import sys import threading +import gobject + import pykka from mopidy.utils import encoding @@ -267,7 +268,8 @@ class Connection(object): return True if not data: - self.stop('Client most likely disconnected.') + self.actor_ref.tell({'close': True}) + self.disable_recv() return True try: @@ -348,6 +350,10 @@ class LineProtocol(pykka.ThreadingActor): def on_receive(self, message): """Handle messages with new data from server.""" + if 'close' in message: + self.connection.stop('Client most likely disconnected.') + return + if 'received' not in message: return diff --git a/mopidy/utils/path.py b/mopidy/utils/path.py index 29e8077e..5870fc6e 100644 --- a/mopidy/utils/path.py +++ b/mopidy/utils/path.py @@ -1,8 +1,11 @@ from __future__ import unicode_literals +import Queue as queue import logging import os +import stat import string +import threading import urllib import urlparse @@ -107,32 +110,81 @@ def expand_path(path): return path -def find_files(path): +def _find_worker(relative, hidden, done, work, results, errors): + """Worker thread for collecting stat() results. + + :param str relative: directory to make results relative to + :param bool hidden: whether to include files and dirs starting with '.' + :param threading.Event done: event indicating that all work has been done + :param queue.Queue work: queue of paths to process + :param dict results: shared dictionary for storing all the stat() results + :param dict errors: shared dictionary for storing any per path errors """ - Finds all files within a path. + while not done.is_set(): + try: + entry = work.get(block=False) + except queue.Empty: + continue - Directories and files with names starting with ``.`` is ignored. + if relative: + path = os.path.relpath(entry, relative) + else: + path = entry - :returns: yields the full path to files as bytestrings + try: + st = os.lstat(entry) + if stat.S_ISDIR(st.st_mode): + for e in os.listdir(entry): + if hidden or not e.startswith(b'.'): + work.put(os.path.join(entry, e)) + elif stat.S_ISREG(st.st_mode): + results[path] = st + else: + errors[path] = 'Not a file or directory' + except os.error as e: + errors[path] = str(e) + finally: + work.task_done() + + +def _find(root, thread_count=10, hidden=True, relative=False): + """Threaded find implementation that provides stat results for files. + + Note that we do _not_ handle loops from bad sym/hardlinks in any way. + + :param str root: root directory to search from, may not be a file + :param int thread_count: number of workers to use, mainly useful to + mitigate network lag when scanning on NFS etc. + :param bool hidden: whether to include files and dirs starting with '.' + :param bool relative: if results should be relative to root or absolute """ - if isinstance(path, unicode): - path = path.encode('utf-8') + threads = [] + results = {} + errors = {} + done = threading.Event() + work = queue.Queue() + work.put(os.path.abspath(root)) - if os.path.isfile(path): - return + if not relative: + root = None - for dirpath, dirnames, filenames in os.walk(path, followlinks=True): - for dirname in dirnames: - if dirname.startswith(b'.'): - # Skip hidden dirs by modifying dirnames inplace - dirnames.remove(dirname) + for i in range(thread_count): + t = threading.Thread(target=_find_worker, + args=(root, hidden, done, work, results, errors)) + t.daemon = True + t.start() + threads.append(t) - for filename in filenames: - if filename.startswith(b'.'): - # Skip hidden files - continue + work.join() + done.set() + for t in threads: + t.join() + return results, errors - yield os.path.relpath(os.path.join(dirpath, filename), path) + +def find_mtimes(root): + results, errors = _find(root, hidden=False, relative=False) + return dict((f, int(st.st_mtime)) for f, st in results.iteritems()) def check_file_path_is_inside_base_dir(file_path, base_path): diff --git a/mopidy/utils/versioning.py b/mopidy/utils/versioning.py index e8856473..94578121 100644 --- a/mopidy/utils/versioning.py +++ b/mopidy/utils/versioning.py @@ -1,19 +1,24 @@ from __future__ import unicode_literals -from subprocess import PIPE, Popen +import os +import subprocess -from mopidy import __version__ +import mopidy def get_version(): try: return get_git_version() except EnvironmentError: - return __version__ + return mopidy.__version__ def get_git_version(): - process = Popen(['git', 'describe'], stdout=PIPE, stderr=PIPE) + project_dir = os.path.abspath( + os.path.join(os.path.dirname(mopidy.__file__), '..')) + process = subprocess.Popen( + ['git', 'describe'], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=project_dir) if process.wait() != 0: raise EnvironmentError('Execution of "git describe" failed') version = process.stdout.read().strip() diff --git a/mopidy/zeroconf.py b/mopidy/zeroconf.py index e95b1792..cdd84792 100644 --- a/mopidy/zeroconf.py +++ b/mopidy/zeroconf.py @@ -17,7 +17,10 @@ _AVAHI_PUBLISHFLAGS_NONE = 0 def _is_loopback_address(host): - return host.startswith('127.') or host == '::1' + return ( + host.startswith('127.') or + host.startswith('::ffff:127.') or + host == '::1') def _convert_text_to_dbus_bytes(text): @@ -56,6 +59,10 @@ class Zeroconf(object): self.name = template.safe_substitute( hostname=self.host or socket.getfqdn(), port=self.port) + def __str__(self): + return 'Zeroconf service %s at [%s]:%d' % ( + self.stype, self.host, self.port) + def publish(self): """Publish the service. @@ -63,12 +70,12 @@ class Zeroconf(object): """ if _is_loopback_address(self.host): - logger.info( - 'Zeroconf publish on loopback interface is not supported.') + logger.debug( + '%s: Publish on loopback interface is not supported.', self) return False if not dbus: - logger.debug('Zeroconf publish failed: dbus not installed.') + logger.debug('%s: dbus not installed; publish failed.', self) return False try: @@ -76,7 +83,7 @@ class Zeroconf(object): if not bus.name_has_owner('org.freedesktop.Avahi'): logger.debug( - 'Zeroconf publish failed: Avahi service not running.') + '%s: Avahi service not running; publish failed.', self) return False server = dbus.Interface( @@ -95,9 +102,10 @@ class Zeroconf(object): self.domain, self.host, dbus.UInt16(self.port), text) self.group.Commit() + logger.debug('%s: Published', self) return True except dbus.exceptions.DBusException as e: - logger.debug('Zeroconf publish failed: %s', e) + logger.debug('%s: Publish failed: %s', self, e) return False def unpublish(self): @@ -109,7 +117,8 @@ class Zeroconf(object): if self.group: try: self.group.Reset() + logger.debug('%s: Unpublished', self) except dbus.exceptions.DBusException as e: - logger.debug('Zeroconf unpublish failed: %s', e) + logger.debug('%s: Unpublish failed: %s', self, e) finally: self.group = None diff --git a/setup.cfg b/setup.cfg index 5e409001..80ab9645 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,2 +1,6 @@ +[flake8] +application-import-names = mopidy,tests +exclude = .git,.tox,build,js + [wheel] universal = 1 diff --git a/setup.py b/setup.py index f3e20f4a..900fcf38 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from __future__ import unicode_literals import re -from setuptools import setup, find_packages +from setuptools import find_packages, setup def get_version(filename): @@ -26,10 +26,9 @@ setup( install_requires=[ 'setuptools', 'Pykka >= 1.1', + 'tornado >= 2.3', ], - extras_require={ - 'http': ['CherryPy >= 3.2.2', 'ws4py >= 0.2.3'], - }, + extras_require={'http': []}, test_suite='nose.collector', tests_require=[ 'nose', @@ -38,12 +37,12 @@ setup( entry_points={ 'console_scripts': [ 'mopidy = mopidy.__main__:main', - 'mopidy-convert-config = mopidy.config.convert:main', ], 'mopidy.ext': [ - 'http = mopidy.http:Extension [http]', + 'http = mopidy.http:Extension', 'local = mopidy.local:Extension', 'mpd = mopidy.mpd:Extension', + 'softwaremixer = mopidy.softwaremixer:Extension', 'stream = mopidy.stream:Extension', ], }, diff --git a/tests/audio/test_actor.py b/tests/audio/test_actor.py index bda38164..ae1a7991 100644 --- a/tests/audio/test_actor.py +++ b/tests/audio/test_actor.py @@ -4,13 +4,15 @@ import mock import threading import unittest -import pygst -pygst.require('0.10') -import gst - import gobject gobject.threads_init() +import pygst +pygst.require('0.10') +import gst # noqa + +import mock + import pykka from mopidy import audio @@ -44,7 +46,19 @@ class BaseTest(unittest.TestCase): audio_class = audio.Audio def setUp(self): - self.audio = self.audio_class.start(config=self.config).proxy() + config = { + 'audio': { + 'mixer': 'foomixer', + 'mixer_volume': None, + 'output': 'fakesink', + 'visualizer': None, + }, + 'proxy': { + 'hostname': '', + }, + } + self.song_uri = path_to_uri(path_to_data_dir('song1.wav')) + self.audio = self.audio_class.start(config=config, mixer=None).proxy() def tearDown(self): pykka.ActorRegistry.stop_all() @@ -140,7 +154,7 @@ class AudioEventTest(BaseTest): self.audio.wait_for_state_change().get() call = mock.call('state_changed', old_state=PlaybackState.STOPPED, - new_state=PlaybackState.PLAYING) + new_state=PlaybackState.PLAYING, target_state=None) self.assertIn(call, send_mock.call_args_list) def test_state_change_stopped_to_paused_event(self, send_mock): @@ -150,7 +164,7 @@ class AudioEventTest(BaseTest): self.audio.wait_for_state_change().get() call = mock.call('state_changed', old_state=PlaybackState.STOPPED, - new_state=PlaybackState.PAUSED) + new_state=PlaybackState.PAUSED, target_state=None) self.assertIn(call, send_mock.call_args_list) def test_state_change_paused_to_playing_event(self, send_mock): @@ -162,7 +176,7 @@ class AudioEventTest(BaseTest): self.audio.wait_for_state_change().get() call = mock.call('state_changed', old_state=PlaybackState.PAUSED, - new_state=PlaybackState.PLAYING) + new_state=PlaybackState.PLAYING, target_state=None) self.assertIn(call, send_mock.call_args_list) def test_state_change_paused_to_stopped_event(self, send_mock): @@ -174,7 +188,7 @@ class AudioEventTest(BaseTest): self.audio.wait_for_state_change().get() call = mock.call('state_changed', old_state=PlaybackState.PAUSED, - new_state=PlaybackState.STOPPED) + new_state=PlaybackState.STOPPED, target_state=None) self.assertIn(call, send_mock.call_args_list) def test_state_change_playing_to_paused_event(self, send_mock): @@ -186,7 +200,7 @@ class AudioEventTest(BaseTest): self.audio.wait_for_state_change().get() call = mock.call('state_changed', old_state=PlaybackState.PLAYING, - new_state=PlaybackState.PAUSED) + new_state=PlaybackState.PAUSED, target_state=None) self.assertIn(call, send_mock.call_args_list) def test_state_change_playing_to_stopped_event(self, send_mock): @@ -198,7 +212,7 @@ class AudioEventTest(BaseTest): self.audio.wait_for_state_change().get() call = mock.call('state_changed', old_state=PlaybackState.PLAYING, - new_state=PlaybackState.STOPPED) + new_state=PlaybackState.STOPPED, target_state=None) self.assertIn(call, send_mock.call_args_list) def test_stream_changed_event_on_playing(self, send_mock): @@ -353,7 +367,8 @@ class AudioEventTest(BaseTest): ('position_changed', {'position': 0}), ('stream_changed', {'uri': self.uris[0]}), ('state_changed', {'old_state': PlaybackState.STOPPED, - 'new_state': PlaybackState.PLAYING}), + 'new_state': PlaybackState.PLAYING, + 'target_state': None}), ('position_changed', {'position': 0}), ('stream_changed', {'uri': self.uris[1]}), ('reached_end_of_stream', {})] @@ -364,46 +379,30 @@ class AudioDummyEventTest(DummyMixin, AudioEventTest): pass -# TODO: this is really a mixer scaling test, has nothing to do with audio +# TODO: move to mixer tests... class MixerTest(BaseTest): - def test_set_volume(self): - for value in range(0, 101): - self.assertTrue(self.audio.set_volume(value).get()) - self.assertEqual(value, self.audio.get_volume().get()) + @unittest.SkipTest + def test_set_mute(self): + for value in (True, False): + self.assertTrue(self.audio.set_mute(value).get()) + self.assertEqual(value, self.audio.get_mute().get()) - def test_set_volume_with_mixer_max_below_100(self): - config = { - 'audio': { - 'mixer': 'fakemixer track_max_volume=40', - 'mixer_track': None, - 'mixer_volume': None, - 'output': 'fakesink', - 'visualizer': None, - } - } - self.audio = self.audio_class.start(config=config).proxy() + @unittest.SkipTest + def test_set_state_encapsulation(self): + pass # TODO - for value in range(0, 101): - self.assertTrue(self.audio.set_volume(value).get()) - self.assertEqual(value, self.audio.get_volume().get()) + @unittest.SkipTest + def test_set_position(self): + pass # TODO - def test_set_volume_with_mixer_min_equal_max(self): - config = { - 'audio': { - 'mixer': 'fakemixer track_max_volume=0', - 'mixer_track': None, - 'mixer_volume': None, - 'output': 'fakesink', - 'visualizer': None, - } - } - self.audio = self.audio_class.start(config=config).proxy() - self.assertEqual(0, self.audio.get_volume().get()) + @unittest.SkipTest + def test_invalid_output_raises_error(self): + pass # TODO class AudioStateTest(unittest.TestCase): def setUp(self): - self.audio = audio.Audio(config=None) + self.audio = audio.Audio(config=None, mixer=None) def test_state_starts_as_stopped(self): self.assertEqual(audio.PlaybackState.STOPPED, self.audio.state) @@ -440,7 +439,62 @@ class AudioStateTest(unittest.TestCase): self.audio._on_playbin_state_changed( gst.STATE_PAUSED, gst.STATE_READY, gst.STATE_NULL) # We never get the following call, so the logic must work without it - #self.audio._on_playbin_state_changed( - # gst.STATE_READY, gst.STATE_NULL, gst.STATE_VOID_PENDING) + # self.audio._on_playbin_state_changed( + # gst.STATE_READY, gst.STATE_NULL, gst.STATE_VOID_PENDING) self.assertEqual(audio.PlaybackState.STOPPED, self.audio.state) + + +class AudioBufferingTest(unittest.TestCase): + def setUp(self): + self.audio = audio.Audio(config=None, mixer=None) + self.audio._playbin = mock.Mock(spec=['set_state']) + + def test_pause_when_buffer_empty(self): + playbin = self.audio._playbin + self.audio.start_playback() + playbin.set_state.assert_called_with(gst.STATE_PLAYING) + playbin.set_state.reset_mock() + + self.audio._on_buffering(0) + playbin.set_state.assert_called_with(gst.STATE_PAUSED) + self.assertTrue(self.audio._buffering) + + def test_stay_paused_when_buffering_finished(self): + playbin = self.audio._playbin + self.audio.pause_playback() + playbin.set_state.assert_called_with(gst.STATE_PAUSED) + playbin.set_state.reset_mock() + + self.audio._on_buffering(100) + self.assertEqual(playbin.set_state.call_count, 0) + self.assertFalse(self.audio._buffering) + + def test_change_to_paused_while_buffering(self): + playbin = self.audio._playbin + self.audio.start_playback() + playbin.set_state.assert_called_with(gst.STATE_PLAYING) + playbin.set_state.reset_mock() + + self.audio._on_buffering(0) + playbin.set_state.assert_called_with(gst.STATE_PAUSED) + self.audio.pause_playback() + playbin.set_state.reset_mock() + + self.audio._on_buffering(100) + self.assertEqual(playbin.set_state.call_count, 0) + self.assertFalse(self.audio._buffering) + + def test_change_to_stopped_while_buffering(self): + playbin = self.audio._playbin + self.audio.start_playback() + playbin.set_state.assert_called_with(gst.STATE_PLAYING) + playbin.set_state.reset_mock() + + self.audio._on_buffering(0) + playbin.set_state.assert_called_with(gst.STATE_PAUSED) + playbin.set_state.reset_mock() + + self.audio.stop_playback() + playbin.set_state.assert_called_with(gst.STATE_NULL) + self.assertFalse(self.audio._buffering) diff --git a/tests/audio/test_listener.py b/tests/audio/test_listener.py index 84f5b59e..56574411 100644 --- a/tests/audio/test_listener.py +++ b/tests/audio/test_listener.py @@ -1,8 +1,9 @@ from __future__ import unicode_literals -import mock import unittest +import mock + from mopidy import audio @@ -14,16 +15,17 @@ class AudioListenerTest(unittest.TestCase): self.listener.state_changed = mock.Mock() self.listener.on_event( - 'state_changed', old_state='stopped', new_state='playing') + 'state_changed', old_state='stopped', new_state='playing', + target_state=None) self.listener.state_changed.assert_called_with( - old_state='stopped', new_state='playing') + old_state='stopped', new_state='playing', target_state=None) def test_listener_has_default_impl_for_reached_end_of_stream(self): self.listener.reached_end_of_stream() def test_listener_has_default_impl_for_state_changed(self): - self.listener.state_changed(None, None) + self.listener.state_changed(None, None, None) def test_listener_has_default_impl_for_stream_changed(self): self.listener.stream_changed(None) diff --git a/tests/audio/test_playlists.py b/tests/audio/test_playlists.py index 0f031736..51c36eac 100644 --- a/tests/audio/test_playlists.py +++ b/tests/audio/test_playlists.py @@ -1,4 +1,4 @@ -#encoding: utf-8 +# encoding: utf-8 from __future__ import unicode_literals @@ -33,21 +33,28 @@ Length3=213 Version=2 """ -ASX = b""" - Example - - Sample Title - - - - Example title - - - - Other title - - - +ASX = b""" + Example + + Sample Title + + + + Example title + + + + Other title + + + +""" + +SIMPLE_ASX = b""" + + + + """ XSPF = b""" @@ -121,6 +128,13 @@ class AsxPlsPlaylistTest(BasePlaylistTest, unittest.TestCase): parse = staticmethod(playlists.parse_asx) +class SimpleAsxPlsPlaylistTest(BasePlaylistTest, unittest.TestCase): + valid = SIMPLE_ASX + invalid = BAD + detect = staticmethod(playlists.detect_asx_header) + parse = staticmethod(playlists.parse_asx) + + class XspfPlaylistTest(BasePlaylistTest, unittest.TestCase): valid = XSPF invalid = BAD diff --git a/tests/audio/test_scan.py b/tests/audio/test_scan.py index cc44ecd6..26caa422 100644 --- a/tests/audio/test_scan.py +++ b/tests/audio/test_scan.py @@ -8,7 +8,7 @@ gobject.threads_init() from mopidy import exceptions from mopidy.audio import scan -from mopidy.models import Track, Artist, Album +from mopidy.models import Album, Artist, Track from mopidy.utils import path as path_lib from tests import path_to_data_dir @@ -283,7 +283,7 @@ class ScannerTest(unittest.TestCase): def find(self, path): media_dir = path_to_data_dir(path) - for path in path_lib.find_files(media_dir): + for path in path_lib.find_mtimes(media_dir): yield os.path.join(media_dir, path) def scan(self, paths): diff --git a/tests/backend/test_listener.py b/tests/backend/test_listener.py index fd861e4f..9e080d31 100644 --- a/tests/backend/test_listener.py +++ b/tests/backend/test_listener.py @@ -1,8 +1,9 @@ from __future__ import unicode_literals -import mock import unittest +import mock + from mopidy import backend diff --git a/tests/config/test_config.py b/tests/config/test_config.py index fceb293d..52035825 100644 --- a/tests/config/test_config.py +++ b/tests/config/test_config.py @@ -2,9 +2,10 @@ from __future__ import unicode_literals -import mock import unittest +import mock + from mopidy import config from tests import path_to_data_dir diff --git a/tests/config/test_schemas.py b/tests/config/test_schemas.py index 6eb35ed3..86cd69f1 100644 --- a/tests/config/test_schemas.py +++ b/tests/config/test_schemas.py @@ -1,9 +1,10 @@ from __future__ import unicode_literals import logging -import mock import unittest +import mock + from mopidy.config import schemas, types from tests import any_unicode diff --git a/tests/config/test_types.py b/tests/config/test_types.py index c4b9ec88..dfb439be 100644 --- a/tests/config/test_types.py +++ b/tests/config/test_types.py @@ -3,10 +3,11 @@ from __future__ import unicode_literals import logging -import mock import socket import unittest +import mock + from mopidy.config import types # TODO: DecodeTest and EncodeTest @@ -213,6 +214,10 @@ class BooleanTest(unittest.TestCase): self.assertEqual(b'false', result) self.assertIsInstance(result, bytes) + def test_deserialize_respects_optional(self): + value = types.Boolean(optional=True) + self.assertEqual(None, value.deserialize('')) + # TODO: test None or other invalid values into serialize? @@ -267,6 +272,12 @@ class ListTest(unittest.TestCase): self.assertIsInstance(result, bytes) self.assertRegexpMatches(result, r'foo\n\s*bar\n\s*baz') + def test_serialize_none(self): + value = types.List() + result = value.serialize(None) + self.assertIsInstance(result, bytes) + self.assertEqual(result, '') + class LogLevelTest(unittest.TestCase): levels = {'critical': logging.CRITICAL, diff --git a/tests/core/test_actor.py b/tests/core/test_actor.py index 4a808cad..79d778af 100644 --- a/tests/core/test_actor.py +++ b/tests/core/test_actor.py @@ -1,8 +1,9 @@ from __future__ import unicode_literals -import mock import unittest +import mock + import pykka from mopidy.core import Core @@ -19,7 +20,7 @@ class CoreActorTest(unittest.TestCase): self.backend2.uri_schemes.get.return_value = ['dummy2'] self.backend2.actor_ref.actor_class.__name__ = b'B2' - self.core = Core(audio=None, backends=[self.backend1, self.backend2]) + self.core = Core(mixer=None, backends=[self.backend1, self.backend2]) def tearDown(self): pykka.ActorRegistry.stop_all() @@ -36,7 +37,7 @@ class CoreActorTest(unittest.TestCase): self.assertRaisesRegexp( AssertionError, 'Cannot add URI scheme dummy1 for B2, it is already handled by B1', - Core, audio=None, backends=[self.backend1, self.backend2]) + Core, mixer=None, backends=[self.backend1, self.backend2]) def test_version(self): self.assertEqual(self.core.version, versioning.get_version()) diff --git a/tests/core/test_events.py b/tests/core/test_events.py index ffa84e6e..ab7906a8 100644 --- a/tests/core/test_events.py +++ b/tests/core/test_events.py @@ -1,8 +1,9 @@ from __future__ import unicode_literals -import mock import unittest +import mock + import pykka from mopidy import core @@ -19,11 +20,23 @@ class BackendEventsTest(unittest.TestCase): def tearDown(self): pykka.ActorRegistry.stop_all() - def test_backends_playlists_loaded_forwards_event_to_frontends(self, send): + def test_forwards_backend_playlists_loaded_event_to_frontends(self, send): self.core.playlists_loaded().get() self.assertEqual(send.call_args[0][0], 'playlists_loaded') + def test_forwards_mixer_volume_changed_event_to_frontends(self, send): + self.core.volume_changed(volume=60).get() + + self.assertEqual(send.call_args[0][0], 'volume_changed') + self.assertEqual(send.call_args[1]['volume'], 60) + + def test_forwards_mixer_mute_changed_event_to_frontends(self, send): + self.core.mute_changed(mute=True).get() + + self.assertEqual(send.call_args[0][0], 'mute_changed') + self.assertEqual(send.call_args[1]['mute'], True) + def test_tracklist_add_sends_tracklist_changed_event(self, send): send.reset_mock() diff --git a/tests/core/test_library.py b/tests/core/test_library.py index 7a40194d..9eac3ebd 100644 --- a/tests/core/test_library.py +++ b/tests/core/test_library.py @@ -1,8 +1,9 @@ from __future__ import unicode_literals -import mock import unittest +import mock + from mopidy import backend, core from mopidy.models import Ref, SearchResult, Track @@ -18,7 +19,7 @@ class CoreLibraryTest(unittest.TestCase): dummy2_root = Ref.directory(uri='dummy2:directory', name='dummy2') self.backend2 = mock.Mock() - self.backend2.uri_schemes.get.return_value = ['dummy2'] + self.backend2.uri_schemes.get.return_value = ['dummy2', 'du2'] self.library2 = mock.Mock(spec=backend.LibraryProvider) self.library2.root_directory.get.return_value = dummy2_root self.backend2.library = self.library2 @@ -29,7 +30,7 @@ class CoreLibraryTest(unittest.TestCase): self.backend3.has_library().get.return_value = False self.backend3.has_library_browse().get.return_value = False - self.core = core.Core(audio=None, backends=[ + self.core = core.Core(mixer=None, backends=[ self.backend1, self.backend2, self.backend3]) def test_browse_root_returns_dir_ref_for_each_lib_with_root_dir_name(self): @@ -137,7 +138,7 @@ class CoreLibraryTest(unittest.TestCase): self.core.library.refresh() self.library1.refresh.assert_called_once_with(None) - self.library2.refresh.assert_called_once_with(None) + self.library2.refresh.assert_called_twice_with(None) def test_find_exact_combines_results_from_all_backends(self): track1 = Track(uri='dummy1:a') diff --git a/tests/core/test_listener.py b/tests/core/test_listener.py index 3678451d..c0075450 100644 --- a/tests/core/test_listener.py +++ b/tests/core/test_listener.py @@ -1,8 +1,9 @@ from __future__ import unicode_literals -import mock import unittest +import mock + from mopidy.core import CoreListener, PlaybackState from mopidy.models import Playlist, TlTrack diff --git a/tests/core/test_playback.py b/tests/core/test_playback.py index 6796cfe7..ce6c8571 100644 --- a/tests/core/test_playback.py +++ b/tests/core/test_playback.py @@ -1,8 +1,9 @@ from __future__ import unicode_literals -import mock import unittest +import mock + from mopidy import backend, core from mopidy.models import Track @@ -35,7 +36,7 @@ class CorePlaybackTest(unittest.TestCase): Track(uri='dummy1:b', length=40000), ] - self.core = core.Core(audio=None, backends=[ + self.core = core.Core(mixer=None, backends=[ self.backend1, self.backend2, self.backend3]) self.core.tracklist.add(self.tracks) @@ -375,17 +376,16 @@ class CorePlaybackTest(unittest.TestCase): # TODO Test on_tracklist_change - # TODO Test volume + def test_volume(self): + self.assertEqual(self.core.playback.volume, None) - @mock.patch( - 'mopidy.core.playback.listener.CoreListener', spec=core.CoreListener) - def test_set_volume_emits_volume_changed_event(self, listener_mock): - self.core.playback.set_volume(10) - listener_mock.reset_mock() + self.core.playback.volume = 30 - self.core.playback.set_volume(20) + self.assertEqual(self.core.playback.volume, 30) - listener_mock.send.assert_called_once_with('volume_changed', volume=20) + self.core.playback.volume = 70 + + self.assertEqual(self.core.playback.volume, 70) def test_mute(self): self.assertEqual(self.core.playback.mute, False) diff --git a/tests/core/test_playlists.py b/tests/core/test_playlists.py index ac1787fa..49f617b5 100644 --- a/tests/core/test_playlists.py +++ b/tests/core/test_playlists.py @@ -1,8 +1,9 @@ from __future__ import unicode_literals -import mock import unittest +import mock + from mopidy import backend, core from mopidy.models import Playlist, Track @@ -33,7 +34,7 @@ class PlaylistsTest(unittest.TestCase): self.pl2b = Playlist(name='B', tracks=[Track(uri='dummy2:b')]) self.sp2.playlists.get.return_value = [self.pl2a, self.pl2b] - self.core = core.Core(audio=None, backends=[ + self.core = core.Core(mixer=None, backends=[ self.backend3, self.backend1, self.backend2]) def test_get_playlists_combines_result_from_backends(self): diff --git a/tests/core/test_tracklist.py b/tests/core/test_tracklist.py index 80b4dd23..963a4bb7 100644 --- a/tests/core/test_tracklist.py +++ b/tests/core/test_tracklist.py @@ -1,8 +1,9 @@ from __future__ import unicode_literals -import mock import unittest +import mock + from mopidy import backend, core from mopidy.models import Track @@ -20,7 +21,7 @@ class TracklistTest(unittest.TestCase): self.library = mock.Mock(spec=backend.LibraryProvider) self.backend.library = self.library - self.core = core.Core(audio=None, backends=[self.backend]) + self.core = core.Core(mixer=None, backends=[self.backend]) self.tl_tracks = self.core.tracklist.add(self.tracks) def test_add_by_uri_looks_up_uri_in_library(self): diff --git a/tests/http/test_events.py b/tests/http/test_events.py index dbfa8413..d03778a6 100644 --- a/tests/http/test_events.py +++ b/tests/http/test_events.py @@ -1,54 +1,34 @@ from __future__ import unicode_literals import json -import mock import unittest -try: - import cherrypy -except ImportError: - cherrypy = False +import mock -try: - import ws4py -except ImportError: - ws4py = False - -if cherrypy and ws4py: - from mopidy.http import actor +from mopidy.http import actor -@unittest.skipUnless(cherrypy, 'cherrypy not found') -@unittest.skipUnless(ws4py, 'ws4py not found') -@mock.patch('cherrypy.engine.publish') +@mock.patch('mopidy.http.handlers.WebSocketHandler.broadcast') class HttpEventsTest(unittest.TestCase): - def setUp(self): - config = { - 'http': { - 'hostname': '127.0.0.1', - 'port': 6680, - 'static_dir': None, - 'zeroconf': '', - } - } - self.http = actor.HttpFrontend(config=config, core=mock.Mock()) - def test_track_playback_paused_is_broadcasted(self, publish): - publish.reset_mock() - self.http.on_event('track_playback_paused', foo='bar') - self.assertEqual(publish.call_args[0][0], 'websocket-broadcast') + def test_track_playback_paused_is_broadcasted(self, broadcast): + broadcast.reset_mock() + + actor.on_event('track_playback_paused', foo='bar') + self.assertDictEqual( - json.loads(str(publish.call_args[0][1])), { + json.loads(str(broadcast.call_args[0][0])), { 'event': 'track_playback_paused', 'foo': 'bar', }) - def test_track_playback_resumed_is_broadcasted(self, publish): - publish.reset_mock() - self.http.on_event('track_playback_resumed', foo='bar') - self.assertEqual(publish.call_args[0][0], 'websocket-broadcast') + def test_track_playback_resumed_is_broadcasted(self, broadcast): + broadcast.reset_mock() + + actor.on_event('track_playback_resumed', foo='bar') + self.assertDictEqual( - json.loads(str(publish.call_args[0][1])), { + json.loads(str(broadcast.call_args[0][0])), { 'event': 'track_playback_resumed', 'foo': 'bar', }) diff --git a/tests/http/test_handlers.py b/tests/http/test_handlers.py new file mode 100644 index 00000000..28e53855 --- /dev/null +++ b/tests/http/test_handlers.py @@ -0,0 +1,37 @@ +from __future__ import unicode_literals + +import os + +import tornado.testing +import tornado.web + +import mopidy +from mopidy.http import handlers + + +class StaticFileHandlerTest(tornado.testing.AsyncHTTPTestCase): + def get_app(self): + return tornado.web.Application([ + (r'/(.*)', handlers.StaticFileHandler, { + 'path': os.path.dirname(__file__), + 'default_filename': 'test_handlers.py' + }) + ]) + + def test_static_handler(self): + response = self.fetch('/test_handlers.py', method='GET') + + self.assertEqual(200, response.code) + self.assertEqual( + response.headers['X-Mopidy-Version'], mopidy.__version__) + self.assertEqual( + response.headers['Cache-Control'], 'no-cache') + + def test_static_default_filename(self): + response = self.fetch('/', method='GET') + + self.assertEqual(200, response.code) + self.assertEqual( + response.headers['X-Mopidy-Version'], mopidy.__version__) + self.assertEqual( + response.headers['Cache-Control'], 'no-cache') diff --git a/tests/http/test_server.py b/tests/http/test_server.py new file mode 100644 index 00000000..b3cfa92c --- /dev/null +++ b/tests/http/test_server.py @@ -0,0 +1,249 @@ +from __future__ import unicode_literals + +import os + +import mock + +import tornado.testing +import tornado.wsgi + +import mopidy +from mopidy.http import actor, handlers + + +class HttpServerTest(tornado.testing.AsyncHTTPTestCase): + def get_config(self): + return { + 'http': { + 'hostname': '127.0.0.1', + 'port': 6680, + 'static_dir': None, + 'zeroconf': '', + } + } + + def get_app(self): + core = mock.Mock() + core.get_version = mock.MagicMock(name='get_version') + core.get_version.return_value = mopidy.__version__ + + testapps = [dict(name='testapp')] + teststatics = [dict(name='teststatic')] + + apps = [{ + 'name': 'mopidy', + 'factory': handlers.make_mopidy_app_factory(testapps, teststatics), + }] + + http_server = actor.HttpServer( + config=self.get_config(), core=core, sockets=[], + apps=apps, statics=[]) + + return tornado.web.Application(http_server._get_request_handlers()) + + +class RootRedirectTest(HttpServerTest): + def test_should_redirect_to_mopidy_app(self): + response = self.fetch('/', method='GET', follow_redirects=False) + + self.assertEqual(response.code, 302) + self.assertEqual(response.headers['Location'], '/mopidy/') + + +class LegacyStaticDirAppTest(HttpServerTest): + def get_config(self): + config = super(LegacyStaticDirAppTest, self).get_config() + config['http']['static_dir'] = os.path.dirname(__file__) + return config + + def test_should_return_index(self): + response = self.fetch('/', method='GET', follow_redirects=False) + + self.assertEqual(response.code, 404, 'No index.html in this dir') + + def test_should_return_static_files(self): + response = self.fetch('/test_server.py', method='GET') + + self.assertIn( + 'test_should_return_static_files', + tornado.escape.to_unicode(response.body)) + self.assertEqual( + response.headers['X-Mopidy-Version'], mopidy.__version__) + self.assertEqual(response.headers['Cache-Control'], 'no-cache') + + +class MopidyAppTest(HttpServerTest): + def test_should_return_index(self): + response = self.fetch('/mopidy/', method='GET') + body = tornado.escape.to_unicode(response.body) + + self.assertIn( + 'This web server is a part of the Mopidy music server.', body) + self.assertIn('testapp', body) + self.assertIn('teststatic', body) + self.assertEqual( + response.headers['X-Mopidy-Version'], mopidy.__version__) + self.assertEqual(response.headers['Cache-Control'], 'no-cache') + + def test_without_slash_should_redirect(self): + response = self.fetch('/mopidy', method='GET', follow_redirects=False) + + self.assertEqual(response.code, 301) + self.assertEqual(response.headers['Location'], '/mopidy/') + + def test_should_return_static_files(self): + response = self.fetch('/mopidy/mopidy.js', method='GET') + + self.assertIn( + 'function Mopidy', + tornado.escape.to_unicode(response.body)) + self.assertEqual( + response.headers['X-Mopidy-Version'], mopidy.__version__) + self.assertEqual(response.headers['Cache-Control'], 'no-cache') + + +class MopidyWebSocketHandlerTest(HttpServerTest): + def test_should_return_ws(self): + response = self.fetch('/mopidy/ws', method='GET') + + self.assertEqual( + 'Can "Upgrade" only to "WebSocket".', + tornado.escape.to_unicode(response.body)) + + def test_should_return_ws_old(self): + response = self.fetch('/mopidy/ws/', method='GET') + + self.assertEqual( + 'Can "Upgrade" only to "WebSocket".', + tornado.escape.to_unicode(response.body)) + + +class MopidyRPCHandlerTest(HttpServerTest): + def test_should_return_rpc_error(self): + cmd = tornado.escape.json_encode({'action': 'get_version'}) + + response = self.fetch('/mopidy/rpc', method='POST', body=cmd) + + self.assertEqual( + {'jsonrpc': '2.0', 'id': None, 'error': + {'message': 'Invalid Request', 'code': -32600, + 'data': '"jsonrpc" member must be included'}}, + tornado.escape.json_decode(response.body)) + + def test_should_return_parse_error(self): + cmd = '{[[[]}' + + response = self.fetch('/mopidy/rpc', method='POST', body=cmd) + + self.assertEqual( + {'jsonrpc': '2.0', 'id': None, 'error': + {'message': 'Parse error', 'code': -32700}}, + tornado.escape.json_decode(response.body)) + + def test_should_return_mopidy_version(self): + cmd = tornado.escape.json_encode({ + 'method': 'core.get_version', + 'params': [], + 'jsonrpc': '2.0', + 'id': 1, + }) + + response = self.fetch('/mopidy/rpc', method='POST', body=cmd) + + self.assertEqual( + {'jsonrpc': '2.0', 'id': 1, 'result': mopidy.__version__}, + tornado.escape.json_decode(response.body)) + + def test_should_return_extra_headers(self): + response = self.fetch('/mopidy/rpc', method='HEAD') + + self.assertIn('Accept', response.headers) + self.assertIn('X-Mopidy-Version', response.headers) + self.assertIn('Cache-Control', response.headers) + self.assertIn('Content-Type', response.headers) + + +class HttpServerWithStaticFilesTest(tornado.testing.AsyncHTTPTestCase): + def get_app(self): + config = { + 'http': { + 'hostname': '127.0.0.1', + 'port': 6680, + 'static_dir': None, + 'zeroconf': '', + } + } + core = mock.Mock() + + statics = [dict(name='static', path=os.path.dirname(__file__))] + + http_server = actor.HttpServer( + config=config, core=core, sockets=[], apps=[], statics=statics) + + return tornado.web.Application(http_server._get_request_handlers()) + + def test_without_slash_should_redirect(self): + response = self.fetch('/static', method='GET', follow_redirects=False) + + self.assertEqual(response.code, 301) + self.assertEqual(response.headers['Location'], '/static/') + + def test_can_serve_static_files(self): + response = self.fetch('/static/test_server.py', method='GET') + + self.assertEqual(200, response.code) + self.assertEqual( + response.headers['X-Mopidy-Version'], mopidy.__version__) + self.assertEqual( + response.headers['Cache-Control'], 'no-cache') + + +def wsgi_app_factory(config, core): + + def wsgi_app(environ, start_response): + status = '200 OK' + response_headers = [('Content-type', 'text/plain')] + start_response(status, response_headers) + return ['Hello, world!\n'] + + return [ + ('(.*)', tornado.web.FallbackHandler, { + 'fallback': tornado.wsgi.WSGIContainer(wsgi_app), + }), + ] + + +class HttpServerWithWsgiAppTest(tornado.testing.AsyncHTTPTestCase): + def get_app(self): + config = { + 'http': { + 'hostname': '127.0.0.1', + 'port': 6680, + 'static_dir': None, + 'zeroconf': '', + } + } + core = mock.Mock() + + apps = [{ + 'name': 'wsgi', + 'factory': wsgi_app_factory, + }] + + http_server = actor.HttpServer( + config=config, core=core, sockets=[], apps=apps, statics=[]) + + return tornado.web.Application(http_server._get_request_handlers()) + + def test_without_slash_should_redirect(self): + response = self.fetch('/wsgi', method='GET', follow_redirects=False) + + self.assertEqual(response.code, 301) + self.assertEqual(response.headers['Location'], '/wsgi/') + + def test_can_wrap_wsgi_apps(self): + response = self.fetch('/wsgi/', method='GET') + + self.assertEqual(200, response.code) + self.assertIn( + 'Hello, world!', tornado.escape.to_unicode(response.body)) diff --git a/tests/local/test_events.py b/tests/local/test_events.py index f0fd0959..f6ae5360 100644 --- a/tests/local/test_events.py +++ b/tests/local/test_events.py @@ -3,6 +3,7 @@ from __future__ import unicode_literals import unittest import mock + import pykka from mopidy import audio, backend, core diff --git a/tests/local/test_library.py b/tests/local/test_library.py index 575f1fb8..fcc6d4df 100644 --- a/tests/local/test_library.py +++ b/tests/local/test_library.py @@ -9,7 +9,7 @@ import pykka from mopidy import core from mopidy.local import actor, json -from mopidy.models import Track, Album, Artist +from mopidy.models import Album, Artist, Track from tests import path_to_data_dir @@ -284,8 +284,9 @@ class LocalLibraryProviderTest(unittest.TestCase): # Matches on track album artists result = self.library.find_exact(any=['artist3']) - self.assertEqual( - list(result[0].tracks), [self.tracks[3], self.tracks[2]]) + self.assertEqual(len(result[0].tracks), 2) + self.assertIn(self.tracks[2], result[0].tracks) + self.assertIn(self.tracks[3], result[0].tracks) # Matches on track composer result = self.library.find_exact(any=['artist5']) @@ -502,8 +503,9 @@ class LocalLibraryProviderTest(unittest.TestCase): # Matches on track album artists result = self.library.search(any=['Tist3']) - self.assertEqual( - list(result[0].tracks), [self.tracks[3], self.tracks[2]]) + self.assertEqual(len(result[0].tracks), 2) + self.assertIn(self.tracks[2], result[0].tracks) + self.assertIn(self.tracks[3], result[0].tracks) # Matches on track genre result = self.library.search(any=['Enre1']) diff --git a/tests/local/test_playback.py b/tests/local/test_playback.py index 4aae8b04..7b64e495 100644 --- a/tests/local/test_playback.py +++ b/tests/local/test_playback.py @@ -1,9 +1,10 @@ from __future__ import unicode_literals -import mock import time import unittest +import mock + import pykka from mopidy import audio, core diff --git a/tests/local/test_tracklist.py b/tests/local/test_tracklist.py index 7717f1a5..af07a4e6 100644 --- a/tests/local/test_tracklist.py +++ b/tests/local/test_tracklist.py @@ -30,7 +30,7 @@ class LocalTracklistProviderTest(unittest.TestCase): self.audio = audio.DummyAudio.start().proxy() self.backend = actor.LocalBackend.start( config=self.config, audio=self.audio).proxy() - self.core = core.Core(audio=self.audio, backends=[self.backend]) + self.core = core.Core(mixer=None, backends=[self.backend]) self.controller = self.core.tracklist self.playback = self.core.playback diff --git a/tests/mpd/protocol/__init__.py b/tests/mpd/protocol/__init__.py index 97b73b7a..f4776f4f 100644 --- a/tests/mpd/protocol/__init__.py +++ b/tests/mpd/protocol/__init__.py @@ -1,8 +1,9 @@ from __future__ import unicode_literals -import mock import unittest +import mock + import pykka from mopidy import core diff --git a/tests/mpd/protocol/test_current_playlist.py b/tests/mpd/protocol/test_current_playlist.py index e2db8b05..e9898dd9 100644 --- a/tests/mpd/protocol/test_current_playlist.py +++ b/tests/mpd/protocol/test_current_playlist.py @@ -389,6 +389,10 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.sendRequest('playlistinfo "0:20"') self.assertInResponse('OK') + def test_playlistinfo_with_zero_returns_ok(self): + self.sendRequest('playlistinfo "0"') + self.assertInResponse('OK') + def test_playlistsearch(self): self.sendRequest('playlistsearch "any" "needle"') self.assertEqualResponse('ACK [0@0] {playlistsearch} Not implemented') diff --git a/tests/mpd/protocol/test_music_db.py b/tests/mpd/protocol/test_music_db.py index bb36f3e2..55ab75c5 100644 --- a/tests/mpd/protocol/test_music_db.py +++ b/tests/mpd/protocol/test_music_db.py @@ -1,21 +1,30 @@ from __future__ import unicode_literals -import datetime import unittest -from mopidy.mpd.protocol import music_db from mopidy.models import Album, Artist, Playlist, Ref, SearchResult, Track +from mopidy.mpd.protocol import music_db from tests.mpd import protocol class QueryFromMpdSearchFormatTest(unittest.TestCase): def test_dates_are_extracted(self): - result = music_db._query_from_mpd_search_format( - 'Date "1974-01-02" "Date" "1975"') + result = music_db._query_from_mpd_search_parameters( + ['Date', '1974-01-02', 'Date', '1975'], music_db._SEARCH_MAPPING) self.assertEqual(result['date'][0], '1974-01-02') self.assertEqual(result['date'][1], '1975') + def test_empty_value_is_ignored(self): + result = music_db._query_from_mpd_search_parameters( + ['Date', ''], music_db._SEARCH_MAPPING) + self.assertEqual(result, {}) + + def test_whitespace_value_is_ignored(self): + result = music_db._query_from_mpd_search_parameters( + ['Date', ' '], music_db._SEARCH_MAPPING) + self.assertEqual(result, {}) + # TODO Test more mappings @@ -128,13 +137,17 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): self.backend.library.dummy_library = tracks self.backend.library.dummy_browse_result = { 'dummy:/': [Ref.track(uri='dummy:/a', name='a'), - Ref.directory(uri='dummy:/foo', name='foo')], + Ref.directory(uri='dummy:/foo', name='foo'), + Ref.album(uri='dummy:/album', name='album'), + Ref.playlist(uri='dummy:/pl', name='pl')], 'dummy:/foo': [Ref.track(uri='dummy:/foo/b', name='b')]} self.sendRequest('listall') self.assertInResponse('file: dummy:/a') self.assertInResponse('directory: /dummy/foo') + self.assertInResponse('directory: /dummy/album') + self.assertInResponse('directory: /dummy/pl') self.assertInResponse('file: dummy:/foo/b') self.assertInResponse('OK') @@ -177,13 +190,24 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): response2 = self.sendRequest('listall "dummy/"') self.assertEqual(response1, response2) + def test_listall_duplicate(self): + self.backend.library.dummy_browse_result = { + 'dummy:/': [Ref.directory(uri='dummy:/a1', name='a'), + Ref.directory(uri='dummy:/a2', name='a')]} + + self.sendRequest('listall') + self.assertInResponse('directory: /dummy/a') + self.assertInResponse('directory: /dummy/a [2]') + def test_listallinfo_without_uri(self): tracks = [Track(uri='dummy:/a', name='a'), Track(uri='dummy:/foo/b', name='b')] self.backend.library.dummy_library = tracks self.backend.library.dummy_browse_result = { 'dummy:/': [Ref.track(uri='dummy:/a', name='a'), - Ref.directory(uri='dummy:/foo', name='foo')], + Ref.directory(uri='dummy:/foo', name='foo'), + Ref.album(uri='dummy:/album', name='album'), + Ref.playlist(uri='dummy:/pl', name='pl')], 'dummy:/foo': [Ref.track(uri='dummy:/foo/b', name='b')]} self.sendRequest('listallinfo') @@ -191,6 +215,8 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): self.assertInResponse('file: dummy:/a') self.assertInResponse('Title: a') self.assertInResponse('directory: /dummy/foo') + self.assertInResponse('directory: /dummy/album') + self.assertInResponse('directory: /dummy/pl') self.assertInResponse('file: dummy:/foo/b') self.assertInResponse('Title: b') self.assertInResponse('OK') @@ -236,8 +262,17 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): response2 = self.sendRequest('listallinfo "dummy/"') self.assertEqual(response1, response2) + def test_listallinfo_duplicate(self): + self.backend.library.dummy_browse_result = { + 'dummy:/': [Ref.directory(uri='dummy:/a1', name='a'), + Ref.directory(uri='dummy:/a2', name='a')]} + + self.sendRequest('listallinfo') + self.assertInResponse('directory: /dummy/a') + self.assertInResponse('directory: /dummy/a [2]') + def test_lsinfo_without_path_returns_same_as_for_root(self): - last_modified = datetime.datetime(2001, 3, 17, 13, 41, 17, 12345) + last_modified = 1390942873222 self.backend.playlists.playlists = [ Playlist(name='a', uri='dummy:/a', last_modified=last_modified)] @@ -246,7 +281,7 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): self.assertEqual(response1, response2) def test_lsinfo_with_empty_path_returns_same_as_for_root(self): - last_modified = datetime.datetime(2001, 3, 17, 13, 41, 17, 12345) + last_modified = 1390942873222 self.backend.playlists.playlists = [ Playlist(name='a', uri='dummy:/a', last_modified=last_modified)] @@ -255,14 +290,14 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): self.assertEqual(response1, response2) def test_lsinfo_for_root_includes_playlists(self): - last_modified = datetime.datetime(2001, 3, 17, 13, 41, 17, 12345) + last_modified = 1390942873222 self.backend.playlists.playlists = [ Playlist(name='a', uri='dummy:/a', last_modified=last_modified)] self.sendRequest('lsinfo "/"') self.assertInResponse('playlist: a') - # Date without microseconds and with time zone information - self.assertInResponse('Last-Modified: 2001-03-17T13:41:17Z') + # Date without milliseconds and with time zone information + self.assertInResponse('Last-Modified: 2014-01-28T21:01:13Z') self.assertInResponse('OK') def test_lsinfo_for_root_includes_dirs_for_each_lib_with_content(self): @@ -306,12 +341,54 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): def test_lsinfo_for_dir_includes_subdirs(self): self.backend.library.dummy_browse_result = { - 'dummy:/': [Ref.directory(uri='/foo', name='foo')]} + 'dummy:/': [Ref.directory(uri='dummy:/foo', name='foo')]} self.sendRequest('lsinfo "/dummy"') self.assertInResponse('directory: dummy/foo') self.assertInResponse('OK') + def test_lsinfo_for_dir_does_not_recurse(self): + self.backend.library.dummy_library = [ + Track(uri='dummy:/a', name='a'), + ] + self.backend.library.dummy_browse_result = { + 'dummy:/': [Ref.directory(uri='dummy:/foo', name='foo')], + 'dummy:/foo': [Ref.track(uri='dummy:/a', name='a')]} + + self.sendRequest('lsinfo "/dummy"') + self.assertNotInResponse('file: dummy:/a') + self.assertInResponse('OK') + + def test_lsinfo_for_dir_does_not_include_self(self): + self.backend.library.dummy_browse_result = { + 'dummy:/': [Ref.directory(uri='dummy:/foo', name='foo')], + 'dummy:/foo': [Ref.track(uri='dummy:/a', name='a')]} + + self.sendRequest('lsinfo "/dummy"') + self.assertNotInResponse('directory: dummy') + self.assertInResponse('OK') + + def test_lsinfo_for_root_returns_browse_result_before_playlists(self): + last_modified = 1390942873222 + self.backend.library.dummy_browse_result = { + 'dummy:/': [Ref.track(uri='dummy:/a', name='a'), + Ref.directory(uri='dummy:/foo', name='foo')]} + self.backend.playlists.playlists = [ + Playlist(name='a', uri='dummy:/a', last_modified=last_modified)] + + response = self.sendRequest('lsinfo "/"') + self.assertLess(response.index('directory: dummy'), + response.index('playlist: a')) + + def test_lsinfo_duplicate(self): + self.backend.library.dummy_browse_result = { + 'dummy:/': [Ref.directory(uri='dummy:/a1', name='a'), + Ref.directory(uri='dummy:/a2', name='a')]} + + self.sendRequest('lsinfo "/dummy"') + self.assertInResponse('directory: dummy/a') + self.assertInResponse('directory: dummy/a [2]') + def test_update_without_uri(self): self.sendRequest('update') self.assertInResponse('updating_db: 0') @@ -539,7 +616,7 @@ class MusicDatabaseListTest(protocol.BaseTestCase): self.sendRequest('list "foo"') self.assertEqualResponse('ACK [2@0] {list} incorrect arguments') - ### Artist + # Artist def test_list_artist_with_quotes(self): self.sendRequest('list "artist"') @@ -599,7 +676,7 @@ class MusicDatabaseListTest(protocol.BaseTestCase): self.assertNotInResponse('Artist: ') self.assertInResponse('OK') - ### Albumartist + # Albumartist def test_list_albumartist_with_quotes(self): self.sendRequest('list "albumartist"') @@ -662,7 +739,7 @@ class MusicDatabaseListTest(protocol.BaseTestCase): self.assertNotInResponse('Performer: ') self.assertInResponse('OK') - ### Composer + # Composer def test_list_composer_with_quotes(self): self.sendRequest('list "composer"') @@ -725,7 +802,7 @@ class MusicDatabaseListTest(protocol.BaseTestCase): self.assertNotInResponse('Performer: ') self.assertInResponse('OK') - ### Performer + # Performer def test_list_performer_with_quotes(self): self.sendRequest('list "performer"') @@ -788,7 +865,7 @@ class MusicDatabaseListTest(protocol.BaseTestCase): self.assertNotInResponse('Performer: ') self.assertInResponse('OK') - ### Album + # Album def test_list_album_with_quotes(self): self.sendRequest('list "album"') @@ -859,7 +936,7 @@ class MusicDatabaseListTest(protocol.BaseTestCase): self.assertNotInResponse('Album: ') self.assertInResponse('OK') - ### Date + # Date def test_list_date_with_quotes(self): self.sendRequest('list "date"') @@ -914,7 +991,7 @@ class MusicDatabaseListTest(protocol.BaseTestCase): self.assertNotInResponse('Date: ') self.assertInResponse('OK') - ### Genre + # Genre def test_list_genre_with_quotes(self): self.sendRequest('list "genre"') diff --git a/tests/mpd/protocol/test_stored_playlists.py b/tests/mpd/protocol/test_stored_playlists.py index 636c5c2c..56011435 100644 --- a/tests/mpd/protocol/test_stored_playlists.py +++ b/tests/mpd/protocol/test_stored_playlists.py @@ -1,8 +1,6 @@ from __future__ import unicode_literals -import datetime - -from mopidy.models import Track, Playlist +from mopidy.models import Playlist, Track from tests.mpd import protocol @@ -78,14 +76,14 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_listplaylists(self): - last_modified = datetime.datetime(2001, 3, 17, 13, 41, 17, 12345) + last_modified = 1390942873222 self.backend.playlists.playlists = [ Playlist(name='a', uri='dummy:a', last_modified=last_modified)] self.sendRequest('listplaylists') self.assertInResponse('playlist: a') - # Date without microseconds and with time zone information - self.assertInResponse('Last-Modified: 2001-03-17T13:41:17Z') + # Date without milliseconds and with time zone information + self.assertInResponse('Last-Modified: 2014-01-28T21:01:13Z') self.assertInResponse('OK') def test_listplaylists_duplicate(self): @@ -99,7 +97,7 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_listplaylists_ignores_playlists_without_name(self): - last_modified = datetime.datetime(2001, 3, 17, 13, 41, 17, 12345) + last_modified = 1390942873222 self.backend.playlists.playlists = [ Playlist(name='', uri='dummy:', last_modified=last_modified)] diff --git a/tests/mpd/test_commands.py b/tests/mpd/test_commands.py new file mode 100644 index 00000000..bb4effb8 --- /dev/null +++ b/tests/mpd/test_commands.py @@ -0,0 +1,241 @@ +# encoding: utf-8 + +from __future__ import unicode_literals + +import unittest + +from mopidy.mpd import exceptions, protocol + + +class TestConverts(unittest.TestCase): + def test_integer(self): + self.assertEqual(123, protocol.INT('123')) + self.assertEqual(-123, protocol.INT('-123')) + self.assertEqual(123, protocol.INT('+123')) + self.assertRaises(ValueError, protocol.INT, '3.14') + self.assertRaises(ValueError, protocol.INT, '') + self.assertRaises(ValueError, protocol.INT, 'abc') + self.assertRaises(ValueError, protocol.INT, '12 34') + + def test_unsigned_integer(self): + self.assertEqual(123, protocol.UINT('123')) + self.assertRaises(ValueError, protocol.UINT, '-123') + self.assertRaises(ValueError, protocol.UINT, '+123') + self.assertRaises(ValueError, protocol.UINT, '3.14') + self.assertRaises(ValueError, protocol.UINT, '') + self.assertRaises(ValueError, protocol.UINT, 'abc') + self.assertRaises(ValueError, protocol.UINT, '12 34') + + def test_boolean(self): + self.assertEqual(True, protocol.BOOL('1')) + self.assertEqual(False, protocol.BOOL('0')) + self.assertRaises(ValueError, protocol.BOOL, '3.14') + self.assertRaises(ValueError, protocol.BOOL, '') + self.assertRaises(ValueError, protocol.BOOL, 'true') + self.assertRaises(ValueError, protocol.BOOL, 'false') + self.assertRaises(ValueError, protocol.BOOL, 'abc') + self.assertRaises(ValueError, protocol.BOOL, '12 34') + + def test_range(self): + self.assertEqual(slice(1, 2), protocol.RANGE('1')) + self.assertEqual(slice(0, 1), protocol.RANGE('0')) + self.assertEqual(slice(0, None), protocol.RANGE('0:')) + self.assertEqual(slice(1, 3), protocol.RANGE('1:3')) + self.assertRaises(ValueError, protocol.RANGE, '3.14') + self.assertRaises(ValueError, protocol.RANGE, '1:abc') + self.assertRaises(ValueError, protocol.RANGE, 'abc:1') + self.assertRaises(ValueError, protocol.RANGE, '2:1') + self.assertRaises(ValueError, protocol.RANGE, '-1:2') + self.assertRaises(ValueError, protocol.RANGE, '1 : 2') + self.assertRaises(ValueError, protocol.RANGE, '') + self.assertRaises(ValueError, protocol.RANGE, 'true') + self.assertRaises(ValueError, protocol.RANGE, 'false') + self.assertRaises(ValueError, protocol.RANGE, 'abc') + self.assertRaises(ValueError, protocol.RANGE, '12 34') + + +class TestCommands(unittest.TestCase): + def setUp(self): + self.commands = protocol.Commands() + + def test_add_as_a_decorator(self): + @self.commands.add('test') + def test(context): + pass + + def test_register_second_command_to_same_name_fails(self): + func = lambda context: True + + self.commands.add('foo')(func) + with self.assertRaises(Exception): + self.commands.add('foo')(func) + + def test_function_only_takes_context_succeeds(self): + sentinel = object() + self.commands.add('bar')(lambda context: sentinel) + self.assertEqual(sentinel, self.commands.call(['bar'])) + + def test_function_has_required_arg_succeeds(self): + sentinel = object() + self.commands.add('bar')(lambda context, required: sentinel) + self.assertEqual(sentinel, self.commands.call(['bar', 'arg'])) + + def test_function_has_optional_args_succeeds(self): + sentinel = object() + self.commands.add('bar')(lambda context, optional=None: sentinel) + self.assertEqual(sentinel, self.commands.call(['bar'])) + self.assertEqual(sentinel, self.commands.call(['bar', 'arg'])) + + def test_function_has_required_and_optional_args_succeeds(self): + sentinel = object() + func = lambda context, required, optional=None: sentinel + self.commands.add('bar')(func) + self.assertEqual(sentinel, self.commands.call(['bar', 'arg'])) + self.assertEqual(sentinel, self.commands.call(['bar', 'arg', 'arg'])) + + def test_function_has_varargs_succeeds(self): + sentinel, args = object(), [] + self.commands.add('bar')(lambda context, *args: sentinel) + for i in range(10): + self.assertEqual(sentinel, self.commands.call(['bar'] + args)) + args.append('test') + + def test_function_has_only_varags_succeeds(self): + sentinel = object() + self.commands.add('baz')(lambda *args: sentinel) + self.assertEqual(sentinel, self.commands.call(['baz'])) + + def test_function_has_no_arguments_fails(self): + with self.assertRaises(TypeError): + self.commands.add('test')(lambda: True) + + def test_function_has_required_and_varargs_fails(self): + with self.assertRaises(TypeError): + func = lambda context, required, *args: True + self.commands.add('test')(func) + + def test_function_has_optional_and_varargs_fails(self): + with self.assertRaises(TypeError): + func = lambda context, optional=None, *args: True + self.commands.add('test')(func) + + def test_function_hash_keywordargs_fails(self): + with self.assertRaises(TypeError): + self.commands.add('test')(lambda context, **kwargs: True) + + def test_call_chooses_correct_handler(self): + sentinel1, sentinel2, sentinel3 = object(), object(), object() + self.commands.add('foo')(lambda context: sentinel1) + self.commands.add('bar')(lambda context: sentinel2) + self.commands.add('baz')(lambda context: sentinel3) + + self.assertEqual(sentinel1, self.commands.call(['foo'])) + self.assertEqual(sentinel2, self.commands.call(['bar'])) + self.assertEqual(sentinel3, self.commands.call(['baz'])) + + def test_call_with_nonexistent_handler(self): + with self.assertRaises(exceptions.MpdUnknownCommand): + self.commands.call(['bar']) + + def test_call_passes_context(self): + sentinel = object() + self.commands.add('foo')(lambda context: context) + self.assertEqual( + sentinel, self.commands.call(['foo'], context=sentinel)) + + def test_call_without_args_fails(self): + with self.assertRaises(exceptions.MpdNoCommand): + self.commands.call([]) + + def test_call_passes_required_argument(self): + self.commands.add('foo')(lambda context, required: required) + self.assertEqual('test123', self.commands.call(['foo', 'test123'])) + + def test_call_passes_optional_argument(self): + sentinel = object() + self.commands.add('foo')(lambda context, optional=sentinel: optional) + self.assertEqual(sentinel, self.commands.call(['foo'])) + self.assertEqual('test', self.commands.call(['foo', 'test'])) + + def test_call_passes_required_and_optional_argument(self): + func = lambda context, required, optional=None: (required, optional) + self.commands.add('foo')(func) + self.assertEqual(('arg', None), self.commands.call(['foo', 'arg'])) + self.assertEqual( + ('arg', 'kwarg'), self.commands.call(['foo', 'arg', 'kwarg'])) + + def test_call_passes_varargs(self): + self.commands.add('foo')(lambda context, *args: args) + + def test_call_incorrect_args(self): + self.commands.add('foo')(lambda context: context) + with self.assertRaises(TypeError): + self.commands.call(['foo', 'bar']) + + self.commands.add('bar')(lambda context, required: context) + with self.assertRaises(TypeError): + self.commands.call(['bar', 'bar', 'baz']) + + self.commands.add('baz')(lambda context, optional=None: context) + with self.assertRaises(TypeError): + self.commands.call(['baz', 'bar', 'baz']) + + def test_validator_gets_applied_to_required_arg(self): + sentinel = object() + func = lambda context, required: required + self.commands.add('test', required=lambda v: sentinel)(func) + self.assertEqual(sentinel, self.commands.call(['test', 'foo'])) + + def test_validator_gets_applied_to_optional_arg(self): + sentinel = object() + func = lambda context, optional=None: optional + self.commands.add('foo', optional=lambda v: sentinel)(func) + + self.assertEqual(sentinel, self.commands.call(['foo', '123'])) + + def test_validator_skips_optional_default(self): + sentinel = object() + func = lambda context, optional=sentinel: optional + self.commands.add('foo', optional=lambda v: None)(func) + + self.assertEqual(sentinel, self.commands.call(['foo'])) + + def test_validator_applied_to_non_existent_arg_fails(self): + self.commands.add('foo')(lambda context, arg: arg) + with self.assertRaises(TypeError): + func = lambda context, wrong_arg: wrong_arg + self.commands.add('bar', arg=lambda v: v)(func) + + def test_validator_called_context_fails(self): + return # TODO: how to handle this + with self.assertRaises(TypeError): + func = lambda context: True + self.commands.add('bar', context=lambda v: v)(func) + + def test_validator_value_error_is_converted(self): + def validdate(value): + raise ValueError + + func = lambda context, arg: True + self.commands.add('bar', arg=validdate)(func) + + with self.assertRaises(exceptions.MpdArgError): + self.commands.call(['bar', 'test']) + + def test_auth_required_gets_stored(self): + func1 = lambda context: context + func2 = lambda context: context + self.commands.add('foo')(func1) + self.commands.add('bar', auth_required=False)(func2) + + self.assertTrue(self.commands.handlers['foo'].auth_required) + self.assertFalse(self.commands.handlers['bar'].auth_required) + + def test_list_command_gets_stored(self): + func1 = lambda context: context + func2 = lambda context: context + self.commands.add('foo')(func1) + self.commands.add('bar', list_command=False)(func2) + + self.assertTrue(self.commands.handlers['foo'].list_command) + self.assertFalse(self.commands.handlers['bar'].list_command) diff --git a/tests/mpd/test_dispatcher.py b/tests/mpd/test_dispatcher.py index c4da1714..cee4531a 100644 --- a/tests/mpd/test_dispatcher.py +++ b/tests/mpd/test_dispatcher.py @@ -8,7 +8,6 @@ from mopidy import core from mopidy.backend import dummy from mopidy.mpd.dispatcher import MpdDispatcher from mopidy.mpd.exceptions import MpdAckError -from mopidy.mpd.protocol import request_handlers, handle_request class MpdDispatcherTest(unittest.TestCase): @@ -25,41 +24,15 @@ class MpdDispatcherTest(unittest.TestCase): def tearDown(self): pykka.ActorRegistry.stop_all() - def test_register_same_pattern_twice_fails(self): - func = lambda: None + def test_call_handler_for_unknown_command_raises_exception(self): try: - handle_request('a pattern')(func) - handle_request('a pattern')(func) - self.fail('Registering a pattern twice shoulde raise ValueError') - except ValueError: - pass - - def test_finding_handler_for_unknown_command_raises_exception(self): - try: - self.dispatcher._find_handler('an_unknown_command with args') + self.dispatcher._call_handler('an_unknown_command with args') self.fail('Should raise exception') except MpdAckError as e: self.assertEqual( e.get_mpd_ack(), 'ACK [5@0] {} unknown command "an_unknown_command"') - def test_find_handler_for_known_command_returns_handler_and_kwargs(self): - expected_handler = lambda x: None - request_handlers['known_command (?P.+)'] = \ - expected_handler - (handler, kwargs) = self.dispatcher._find_handler( - 'known_command an_arg') - self.assertEqual(handler, expected_handler) - self.assertIn('arg1', kwargs) - self.assertEqual(kwargs['arg1'], 'an_arg') - def test_handling_unknown_request_yields_error(self): result = self.dispatcher.handle_request('an unhandled request') self.assertEqual(result[0], 'ACK [5@0] {} unknown command "an"') - - def test_handling_known_request(self): - expected = 'magic' - request_handlers['known request'] = lambda x: expected - result = self.dispatcher.handle_request('known request') - self.assertIn('OK', result) - self.assertIn(expected, result) diff --git a/tests/mpd/test_exceptions.py b/tests/mpd/test_exceptions.py index ef84a5f9..7f50c41b 100644 --- a/tests/mpd/test_exceptions.py +++ b/tests/mpd/test_exceptions.py @@ -3,8 +3,8 @@ from __future__ import unicode_literals import unittest from mopidy.mpd.exceptions import ( - MpdAckError, MpdPermissionError, MpdUnknownCommand, MpdNoCommand, - MpdSystemError, MpdNotImplemented) + MpdAckError, MpdNoCommand, MpdNotImplemented, MpdPermissionError, + MpdSystemError, MpdUnknownCommand) class MpdExceptionsTest(unittest.TestCase): diff --git a/tests/mpd/test_tokenizer.py b/tests/mpd/test_tokenizer.py new file mode 100644 index 00000000..01ecd17d --- /dev/null +++ b/tests/mpd/test_tokenizer.py @@ -0,0 +1,149 @@ +# encoding: utf-8 + +from __future__ import unicode_literals + +import unittest + +from mopidy.mpd import exceptions, tokenize + + +class TestTokenizer(unittest.TestCase): + def assertTokenizeEquals(self, expected, line): + self.assertEqual(expected, tokenize.split(line)) + + def assertTokenizeRaises(self, exception, message, line): + with self.assertRaises(exception) as cm: + tokenize.split(line) + self.assertEqual(cm.exception.message, message) + + def test_empty_string(self): + ex = exceptions.MpdNoCommand + msg = 'No command given' + self.assertTokenizeRaises(ex, msg, '') + self.assertTokenizeRaises(ex, msg, ' ') + self.assertTokenizeRaises(ex, msg, '\t\t\t') + + def test_command(self): + self.assertTokenizeEquals(['test'], 'test') + self.assertTokenizeEquals(['test123'], 'test123') + self.assertTokenizeEquals(['foo_bar'], 'foo_bar') + + def test_command_trailing_whitespace(self): + self.assertTokenizeEquals(['test'], 'test ') + self.assertTokenizeEquals(['test'], 'test\t\t\t') + + def test_command_leading_whitespace(self): + ex = exceptions.MpdUnknownError + msg = 'Letter expected' + self.assertTokenizeRaises(ex, msg, ' test') + self.assertTokenizeRaises(ex, msg, '\ttest') + + def test_invalid_command(self): + ex = exceptions.MpdUnknownError + msg = 'Invalid word character' + self.assertTokenizeRaises(ex, msg, 'foo/bar') + self.assertTokenizeRaises(ex, msg, 'æøå') + self.assertTokenizeRaises(ex, msg, 'test?') + self.assertTokenizeRaises(ex, msg, 'te"st') + + def test_unquoted_param(self): + self.assertTokenizeEquals(['test', 'param'], 'test param') + self.assertTokenizeEquals(['test', 'param'], 'test\tparam') + + def test_unquoted_param_leading_whitespace(self): + self.assertTokenizeEquals(['test', 'param'], 'test param') + self.assertTokenizeEquals(['test', 'param'], 'test\t\tparam') + + def test_unquoted_param_trailing_whitespace(self): + self.assertTokenizeEquals(['test', 'param'], 'test param ') + self.assertTokenizeEquals(['test', 'param'], 'test param\t\t') + + def test_unquoted_param_invalid_chars(self): + ex = exceptions.MpdArgError + msg = 'Invalid unquoted character' + self.assertTokenizeRaises(ex, msg, 'test par"m') + self.assertTokenizeRaises(ex, msg, 'test foo\bbar') + self.assertTokenizeRaises(ex, msg, 'test foo"bar"baz') + self.assertTokenizeRaises(ex, msg, 'test foo\'bar') + + def test_unquoted_param_numbers(self): + self.assertTokenizeEquals(['test', '123'], 'test 123') + self.assertTokenizeEquals(['test', '+123'], 'test +123') + self.assertTokenizeEquals(['test', '-123'], 'test -123') + self.assertTokenizeEquals(['test', '3.14'], 'test 3.14') + + def test_unquoted_param_extended_chars(self): + self.assertTokenizeEquals(['test', 'æøå'], 'test æøå') + self.assertTokenizeEquals(['test', '?#$'], 'test ?#$') + self.assertTokenizeEquals(['test', '/foo/bar/'], 'test /foo/bar/') + self.assertTokenizeEquals(['test', 'foo\\bar'], 'test foo\\bar') + + def test_unquoted_params(self): + self.assertTokenizeEquals(['test', 'foo', 'bar'], 'test foo bar') + + def test_quoted_param(self): + self.assertTokenizeEquals(['test', 'param'], 'test "param"') + self.assertTokenizeEquals(['test', 'param'], 'test\t"param"') + + def test_quoted_param_leading_whitespace(self): + self.assertTokenizeEquals(['test', 'param'], 'test "param"') + self.assertTokenizeEquals(['test', 'param'], 'test\t\t"param"') + + def test_quoted_param_trailing_whitespace(self): + self.assertTokenizeEquals(['test', 'param'], 'test "param" ') + self.assertTokenizeEquals(['test', 'param'], 'test "param"\t\t') + + def test_quoted_param_invalid_chars(self): + ex = exceptions.MpdArgError + msg = 'Space expected after closing \'"\'' + self.assertTokenizeRaises(ex, msg, 'test "foo"bar"') + self.assertTokenizeRaises(ex, msg, 'test "foo"bar" ') + self.assertTokenizeRaises(ex, msg, 'test "foo"bar') + self.assertTokenizeRaises(ex, msg, 'test "foo"bar ') + + def test_quoted_param_numbers(self): + self.assertTokenizeEquals(['test', '123'], 'test "123"') + self.assertTokenizeEquals(['test', '+123'], 'test "+123"') + self.assertTokenizeEquals(['test', '-123'], 'test "-123"') + self.assertTokenizeEquals(['test', '3.14'], 'test "3.14"') + + def test_quoted_param_spaces(self): + self.assertTokenizeEquals(['test', 'foo bar'], 'test "foo bar"') + self.assertTokenizeEquals(['test', 'foo bar'], 'test "foo bar"') + self.assertTokenizeEquals(['test', ' param\t'], 'test " param\t"') + + def test_quoted_param_extended_chars(self): + self.assertTokenizeEquals(['test', 'æøå'], 'test "æøå"') + self.assertTokenizeEquals(['test', '?#$'], 'test "?#$"') + self.assertTokenizeEquals(['test', '/foo/bar/'], 'test "/foo/bar/"') + + def test_quoted_param_escaping(self): + self.assertTokenizeEquals(['test', '\\'], r'test "\\"') + self.assertTokenizeEquals(['test', '"'], r'test "\""') + self.assertTokenizeEquals(['test', ' '], r'test "\ "') + self.assertTokenizeEquals(['test', '\\n'], r'test "\\\n"') + + def test_quoted_params(self): + self.assertTokenizeEquals(['test', 'foo', 'bar'], 'test "foo" "bar"') + + def test_mixed_params(self): + self.assertTokenizeEquals(['test', 'foo', 'bar'], 'test foo "bar"') + self.assertTokenizeEquals(['test', 'foo', 'bar'], 'test "foo" bar') + self.assertTokenizeEquals(['test', '1', '2'], 'test 1 "2"') + self.assertTokenizeEquals(['test', '1', '2'], 'test "1" 2') + + self.assertTokenizeEquals(['test', 'foo bar', 'baz', '123'], + 'test "foo bar" baz 123') + self.assertTokenizeEquals(['test', 'foo"bar', 'baz', '123'], + r'test "foo\"bar" baz 123') + + def test_unbalanced_quotes(self): + ex = exceptions.MpdArgError + msg = 'Invalid unquoted character' + self.assertTokenizeRaises(ex, msg, 'test "foo bar" baz"') + + def test_missing_closing_quote(self): + ex = exceptions.MpdArgError + msg = 'Missing closing \'"\'' + self.assertTokenizeRaises(ex, msg, 'test "foo') + self.assertTokenizeRaises(ex, msg, 'test "foo a ') diff --git a/tests/mpd/test_translator.py b/tests/mpd/test_translator.py index c2648311..2bd6cff6 100644 --- a/tests/mpd/test_translator.py +++ b/tests/mpd/test_translator.py @@ -3,9 +3,9 @@ from __future__ import unicode_literals import datetime import unittest -from mopidy.utils.path import mtime +from mopidy.models import Album, Artist, Playlist, TlTrack, Track from mopidy.mpd import translator -from mopidy.models import Album, Artist, TlTrack, Playlist, Track +from mopidy.utils.path import mtime class TrackMpdFormatTest(unittest.TestCase): @@ -13,7 +13,8 @@ class TrackMpdFormatTest(unittest.TestCase): uri='a uri', artists=[Artist(name='an artist')], name='a name', - album=Album(name='an album', num_tracks=13, + album=Album( + name='an album', num_tracks=13, artists=[Artist(name='an other artist')]), track_no=7, composers=[Artist(name='a composer')], diff --git a/tests/test_commands.py b/tests/test_commands.py index a651a56e..9d3d0bda 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1,9 +1,10 @@ from __future__ import unicode_literals import argparse -import mock import unittest +import mock + from mopidy import commands diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index da8fed90..47b3080d 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -12,6 +12,22 @@ class ExceptionsTest(unittest.TestCase): self.assertEqual(exc.message, 'foo') self.assertEqual(str(exc), 'foo') + def test_backend_error_is_a_mopidy_exception(self): + self.assert_(issubclass( + exceptions.BackendError, exceptions.MopidyException)) + def test_extension_error_is_a_mopidy_exception(self): self.assert_(issubclass( exceptions.ExtensionError, exceptions.MopidyException)) + + def test_frontend_error_is_a_mopidy_exception(self): + self.assert_(issubclass( + exceptions.FrontendError, exceptions.MopidyException)) + + def test_mixer_error_is_a_mopidy_exception(self): + self.assert_(issubclass( + exceptions.MixerError, exceptions.MopidyException)) + + def test_scanner_error_is_a_mopidy_exception(self): + self.assert_(issubclass( + exceptions.ScannerError, exceptions.MopidyException)) diff --git a/tests/test_ext.py b/tests/test_ext.py index f6b0ccfe..5338c91d 100644 --- a/tests/test_ext.py +++ b/tests/test_ext.py @@ -27,12 +27,3 @@ class ExtensionTest(unittest.TestCase): def test_validate_environment_does_nothing_by_default(self): self.assertIsNone(self.ext.validate_environment()) - - def test_get_frontend_classes_returns_an_empty_list(self): - self.assertListEqual(self.ext.get_frontend_classes(), []) - - def test_get_backend_classes_returns_an_empty_list(self): - self.assertListEqual(self.ext.get_backend_classes(), []) - - def test_register_gstreamer_elements_does_nothing_by_default(self): - self.assertIsNone(self.ext.register_gstreamer_elements()) diff --git a/tests/test_mixer.py b/tests/test_mixer.py new file mode 100644 index 00000000..53c10292 --- /dev/null +++ b/tests/test_mixer.py @@ -0,0 +1,26 @@ +from __future__ import unicode_literals + +import unittest + +import mock + +from mopidy import mixer + + +class MixerListenerTest(unittest.TestCase): + def setUp(self): + self.listener = mixer.MixerListener() + + def test_on_event_forwards_to_specific_handler(self): + self.listener.volume_changed = mock.Mock() + + self.listener.on_event( + 'volume_changed', volume=60) + + self.listener.volume_changed.assert_called_with(volume=60) + + def test_listener_has_default_impl_for_volume_changed(self): + self.listener.volume_changed(volume=60) + + def test_listener_has_default_impl_for_mute_changed(self): + self.listener.mute_changed(mute=True) diff --git a/tests/test_models.py b/tests/test_models.py index 9a4f97b7..43343ce7 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,12 +1,11 @@ from __future__ import unicode_literals -import datetime import json import unittest from mopidy.models import ( - Ref, Artist, Album, TlTrack, Track, Playlist, SearchResult, - ModelJSONEncoder, model_json_decoder) + Album, Artist, ModelJSONEncoder, Playlist, Ref, SearchResult, TlTrack, + Track, model_json_decoder) class GenericCopyTest(unittest.TestCase): @@ -842,7 +841,7 @@ class PlaylistTest(unittest.TestCase): self.assertEqual(playlist.length, 3) def test_last_modified(self): - last_modified = datetime.datetime.utcnow() + last_modified = 1390942873000 playlist = Playlist(last_modified=last_modified) self.assertEqual(playlist.last_modified, last_modified) self.assertRaises( @@ -850,7 +849,7 @@ class PlaylistTest(unittest.TestCase): def test_with_new_uri(self): tracks = [Track()] - last_modified = datetime.datetime.utcnow() + last_modified = 1390942873000 playlist = Playlist( uri='an uri', name='a name', tracks=tracks, last_modified=last_modified) @@ -862,7 +861,7 @@ class PlaylistTest(unittest.TestCase): def test_with_new_name(self): tracks = [Track()] - last_modified = datetime.datetime.utcnow() + last_modified = 1390942873000 playlist = Playlist( uri='an uri', name='a name', tracks=tracks, last_modified=last_modified) @@ -874,7 +873,7 @@ class PlaylistTest(unittest.TestCase): def test_with_new_tracks(self): tracks = [Track()] - last_modified = datetime.datetime.utcnow() + last_modified = 1390942873000 playlist = Playlist( uri='an uri', name='a name', tracks=tracks, last_modified=last_modified) @@ -887,8 +886,8 @@ class PlaylistTest(unittest.TestCase): def test_with_new_last_modified(self): tracks = [Track()] - last_modified = datetime.datetime.utcnow() - new_last_modified = last_modified + datetime.timedelta(1) + last_modified = 1390942873000 + new_last_modified = last_modified + 1000 playlist = Playlist( uri='an uri', name='a name', tracks=tracks, last_modified=last_modified) diff --git a/tests/test_version.py b/tests/test_version.py index 23c93f01..c166922c 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -1,7 +1,7 @@ from __future__ import unicode_literals -from distutils.version import StrictVersion as SV import unittest +from distutils.version import StrictVersion as SV from mopidy import __version__ @@ -43,5 +43,8 @@ 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('0.18.3')) + self.assertLess(SV('0.18.3'), SV(__version__)) + self.assertLess(SV(__version__), SV('0.19.1')) diff --git a/tests/utils/network/test_connection.py b/tests/utils/network/test_connection.py index c0a74542..7a25643f 100644 --- a/tests/utils/network/test_connection.py +++ b/tests/utils/network/test_connection.py @@ -2,11 +2,13 @@ from __future__ import unicode_literals import errno import logging -from mock import patch, sentinel, Mock import socket import unittest import gobject + +from mock import Mock, patch, sentinel + import pykka from mopidy.utils import network @@ -416,7 +418,8 @@ class ConnectionTest(unittest.TestCase): self.assertTrue(network.Connection.recv_callback( self.mock, sentinel.fd, gobject.IO_IN)) - self.mock.stop.assert_called_once_with(any_unicode) + self.mock.actor_ref.tell.assert_called_once_with({'close': True}) + self.mock.disable_recv.assert_called_once_with() def test_recv_callback_recoverable_error(self): self.mock.sock = Mock(spec=socket.SocketType) diff --git a/tests/utils/network/test_lineprotocol.py b/tests/utils/network/test_lineprotocol.py index 4919754d..d7db67f6 100644 --- a/tests/utils/network/test_lineprotocol.py +++ b/tests/utils/network/test_lineprotocol.py @@ -2,12 +2,15 @@ from __future__ import unicode_literals -from mock import sentinel, Mock import re import unittest +from mock import Mock, sentinel + from mopidy.utils import network +from tests import any_unicode + class LineProtocolTest(unittest.TestCase): def setUp(self): @@ -33,6 +36,14 @@ class LineProtocolTest(unittest.TestCase): network.LineProtocol.__init__(self.mock, sentinel.connection) self.assertEqual(delimiter, self.mock.delimiter) + def test_on_receive_close_calls_stop(self): + self.mock.connection = Mock(spec=network.Connection) + self.mock.recv_buffer = '' + self.mock.parse_lines.return_value = [] + + network.LineProtocol.on_receive(self.mock, {'close': True}) + self.mock.connection.stop.assert_called_once_with(any_unicode) + def test_on_receive_no_new_lines_adds_to_recv_buffer(self): self.mock.connection = Mock(spec=network.Connection) self.mock.recv_buffer = '' diff --git a/tests/utils/network/test_server.py b/tests/utils/network/test_server.py index 123f535c..c5b8c41a 100644 --- a/tests/utils/network/test_server.py +++ b/tests/utils/network/test_server.py @@ -1,12 +1,13 @@ from __future__ import unicode_literals import errno -from mock import patch, sentinel, Mock import socket import unittest import gobject +from mock import Mock, patch, sentinel + from mopidy.utils import network from tests import any_int diff --git a/tests/utils/network/test_utils.py b/tests/utils/network/test_utils.py index 527e9196..d0886cfc 100644 --- a/tests/utils/network/test_utils.py +++ b/tests/utils/network/test_utils.py @@ -1,9 +1,10 @@ from __future__ import unicode_literals -from mock import patch, Mock import socket import unittest +from mock import Mock, patch + from mopidy.utils import network diff --git a/tests/utils/test_deps.py b/tests/utils/test_deps.py index 2a25c095..103f478c 100644 --- a/tests/utils/test_deps.py +++ b/tests/utils/test_deps.py @@ -1,12 +1,13 @@ from __future__ import unicode_literals import platform -import mock import unittest +import mock + import pygst pygst.require('0.10') -import gst +import gst # noqa import pkg_resources diff --git a/tests/utils/test_encoding.py b/tests/utils/test_encoding.py index 88ad5899..912f38c0 100644 --- a/tests/utils/test_encoding.py +++ b/tests/utils/test_encoding.py @@ -1,8 +1,9 @@ from __future__ import unicode_literals -import mock import unittest +import mock + from mopidy.utils.encoding import locale_decode diff --git a/tests/utils/test_jsonrpc.py b/tests/utils/test_jsonrpc.py index 6bd6a32b..e6f94fb3 100644 --- a/tests/utils/test_jsonrpc.py +++ b/tests/utils/test_jsonrpc.py @@ -1,9 +1,10 @@ from __future__ import unicode_literals import json -import mock import unittest +import mock + import pykka from mopidy import core, models @@ -91,10 +92,13 @@ class JsonRpcSerializationTest(JsonRpcTestBase): self.jrw.handle_data.return_value = {'foo': models.Artist(name='bar')} request = '[]' - response = self.jrw.handle_json(request) + response = json.loads(self.jrw.handle_json(request)) - self.assertEqual( - response, '{"foo": {"__model__": "Artist", "name": "bar"}}') + self.assertIn('foo', response) + self.assertIn('__model__', response['foo']) + self.assertEqual(response['foo']['__model__'], 'Artist') + self.assertIn('name', response['foo']) + self.assertEqual(response['foo']['name'], 'bar') def test_handle_json_returns_nothing_for_notices(self): request = '{"jsonrpc": "2.0", "method": "core.get_uri_schemes"}' diff --git a/tests/utils/test_path.py b/tests/utils/test_path.py index 3accab39..078cdb20 100644 --- a/tests/utils/test_path.py +++ b/tests/utils/test_path.py @@ -11,7 +11,7 @@ import glib from mopidy.utils import path -from tests import path_to_data_dir +from tests import any_int, path_to_data_dir class GetOrCreateDirTest(unittest.TestCase): @@ -210,23 +210,28 @@ class ExpandPathTest(unittest.TestCase): path.expand_path(b'/tmp/$XDG_INVALID_DIR/foo')) -class FindFilesTest(unittest.TestCase): +class FindMTimesTest(unittest.TestCase): + maxDiff = None + def find(self, value): - return list(path.find_files(path_to_data_dir(value))) + return path.find_mtimes(path_to_data_dir(value)) def test_basic_dir(self): self.assert_(self.find('')) def test_nonexistant_dir(self): - self.assertEqual(self.find('does-not-exist'), []) + self.assertEqual(self.find('does-not-exist'), {}) def test_file(self): - self.assertEqual([], self.find('blank.mp3')) + self.assertEqual({path_to_data_dir('blank.mp3'): any_int}, + self.find('blank.mp3')) def test_files(self): - files = self.find('find') - expected = [b'foo/bar/file', b'foo/file', b'baz/file'] - self.assertItemsEqual(expected, files) + mtimes = self.find('find') + expected_files = [ + b'find/foo/bar/file', b'find/foo/file', b'find/baz/file'] + expected = {path_to_data_dir(p): any_int for p in expected_files} + self.assertEqual(expected, mtimes) def test_names_are_bytestrings(self): is_bytes = lambda f: isinstance(f, bytes) diff --git a/tox.ini b/tox.ini new file mode 100644 index 00000000..e8946523 --- /dev/null +++ b/tox.ini @@ -0,0 +1,21 @@ +[tox] +envlist = py27, docs, flake8 + +[testenv] +sitepackages = true +deps = + coverage + mock + nose +commands = nosetests -v --with-xunit --xunit-file=xunit-{envname}.xml --with-coverage --cover-package=mopidy + +[testenv:docs] +deps = -r{toxinidir}/docs/requirements.txt +changedir = docs +commands = sphinx-build -b html -d {envtmpdir}/doctrees . {envtmpdir}/html + +[testenv:flake8] +deps = + flake8 + flake8-import-order +commands = flake8