Release v0.13.0

This commit is contained in:
Stein Magnus Jodal 2013-03-31 16:59:30 +02:00
commit 5d83e3e97a
64 changed files with 2782 additions and 1654 deletions

View File

@ -13,3 +13,8 @@
- Matt Bray <mattjbray@gmail.com>
- Trygve Aaberge <trygveaa@gmail.com>
- Wouter van Wijk <woutervanwijk@gmail.com>
- Jeremy B. Merrill <jeremybmerrill@gmail.com>
- 0xadam <radx@live.com.au>
- herrernst <herr.ernst@gmail.com>
- Nick Steel <kingosticks@gmail.com>
- Zan Dobersek <zandobersek@gmail.com>

View File

@ -22,4 +22,5 @@ To get started with Mopidy, check out `the docs <http://docs.mopidy.com/>`_.
- `Issue tracker <http://github.com/mopidy/mopidy/issues>`_
- `CI server <http://travis-ci.org/mopidy/mopidy>`_
- IRC: ``#mopidy`` at `irc.freenode.net <http://freenode.net/>`_
- Mailing list: `mopidy@googlegroups.com <https://groups.google.com/forum/?fromgroups=#!forum/mopidy>`_
- `Download development snapshot <http://github.com/mopidy/mopidy/tarball/develop#egg=mopidy-dev>`_

BIN
docs/_static/dz0ny-mopidy-lux.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 519 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 47 KiB

View File

@ -18,6 +18,10 @@ Data model relations
Track -> Artist [ label="has 0..n" ]
Album -> Artist [ label="has 0..n" ]
SearchResult -> Artist [ label="has 0..n" ]
SearchResult -> Album [ label="has 0..n" ]
SearchResult -> Track [ label="has 0..n" ]
Data model API
==============

View File

@ -5,6 +5,86 @@ Changes
This change log is used to track all major changes to Mopidy.
v0.13.0 (2013-03-31)
====================
The 0.13 release brings small improvements and bugfixes throughout Mopidy.
There are no major new features, just incremental improvement of what we
already have.
**Dependencies**
- Pykka >= 1.1 is now required.
**Core**
- Removed the :attr:`mopidy.settings.DEBUG_THREAD` setting and the
:option:`--debug-thread` command line option. Sending SIGUSR1 to
the Mopidy process will now always make it log tracebacks for all alive
threads.
- Log a warning if a track isn't playable to make it more obvious that backend
X needs backend Y to be present for playback to work.
- :meth:`mopidy.core.TracklistController.add` now accepts an ``uri`` which it
will lookup in the library and then add to the tracklist. This is helpful
for e.g. web clients that doesn't want to transfer all track meta data back
to the server just to add it to the tracklist when the server already got all
the needed information easily available. (Fixes: :issue:`325`)
- Change the following methods to accept an ``uris`` keyword argument:
- :meth:`mopidy.core.LibraryController.find_exact`
- :meth:`mopidy.core.LibraryController.search`
Search queries will only be forwarded to backends handling the given URI
roots, and the backends may use the URI roots to further limit what results
are returned. For example, a search with ``uris=['file:']`` will only be
processed by the local backend. A search with
``uris=['file:///media/music']`` will only be processed by the local backend,
and, if such filtering is supported by the backend, will only return results
with URIs within the given URI root.
**Audio sub-system**
- Make audio error logging handle log messages with non-ASCII chars. (Fixes:
:issue:`347`)
**Local backend**
- Make ``mopidy-scan`` work with Ogg Vorbis files. (Fixes: :issue:`275`)
- Fix playback of files with non-ASCII chars in their file path. (Fixes:
:issue:`353`)
**Spotify backend**
- Let GStreamer handle time position tracking and seeks. (Fixes: :issue:`191`)
- For all playlists owned by other Spotify users, we now append the owner's
username to the playlist name. (Partly fixes: :issue:`114`)
**HTTP frontend**
- Mopidy.js now works both from browsers and from Node.js environments. This
means that you now can make Mopidy clients in Node.js. Mopidy.js has been
published to the `npm registry <https://npmjs.org/package/mopidy>`_ for easy
installation in Node.js projects.
- Upgrade Mopidy.js' build system Grunt from 0.3 to 0.4.
- Upgrade Mopidy.js' dependencies when.js from 1.6.1 to 2.0.0.
- Expose :meth:`mopidy.core.Core.get_uri_schemes` to HTTP clients. It is
available through Mopidy.js as ``mopidy.getUriSchemes()``.
**MPRIS frontend**
- Publish album art URIs if available.
- Publish disc number of track if available.
v0.12.0 (2013-03-12)
====================

View File

@ -18,8 +18,8 @@ woutervanwijk/Mopidy-Webclient
==============================
.. image:: /_static/woutervanwijk-mopidy-webclient.png
:width: 410
:height: 511
:width: 382
:height: 621
The first web client for Mopidy is still under development, but is already very
usable. It targets both desktop and mobile browsers.
@ -29,6 +29,24 @@ and point the :attr:`mopidy.settings.HTTP_SERVER_STATIC_DIR` setting towards
your copy of the web client.
Mopidy Lux
==========
.. image:: /_static/dz0ny-mopidy-lux.png
:width: 1000
:height: 645
New web client developed by Janez Troha. See
https://github.com/dz0ny/mopidy-lux for details.
JukePi
======
New web client developed by Meantime IT in the UK for their office jukebox. See
https://github.com/meantimeit/jukepi for details.
Rompr
=====

View File

@ -305,10 +305,16 @@ Debugging deadlocks
Between the numerous Pykka threads and GStreamer interactions there can
sometimes be a potential for deadlocks. In an effort to make these slightly
simpler to debug the setting :attr:`mopidy.settings.DEBUG_THREAD` or the option
``--debug-thread`` can be used to turn on an extra debug thread. This thread is
not linked to the regular program flow, and it's only task is to dump traceback
showing the other threads state when we get a ``SIGUSR1``.
simpler to debug Mopidy registers a ``SIGUSR1`` signal handler which logs the
traceback of all alive threads.
To trigger the signal handler, you can use the ``pkill`` command to
send the ``SIGUSR1`` signal to any Mopidy processes::
pkill -SIGUSR1 mopidy
If you check the log, you should now find one log record with a full traceback
for each of the currently alive threads in Mopidy.
Writing documentation

410
docs/extensiondev.rst Normal file
View File

@ -0,0 +1,410 @@
*********************
Extension development
*********************
.. warning:: Draft
This document is a draft open for discussion. It shows how we imagine that
development of Mopidy extensions should become in the future, not how to
currently develop an extension for Mopidy.
Mopidy started as simply an MPD server that could play music from Spotify.
Early on Mopidy got multiple "frontends" to expose Mopidy to more than just MPD
clients: for example the Last.fm frontend what scrobbles what you've listened
to to your Last.fm account, the MPRIS frontend that integrates Mopidy into the
Ubuntu Sound Menu, and the HTTP server and JavaScript player API making web
based Mopidy clients possible. In Mopidy 0.9 we added support for multiple
music sources without stopping and reconfiguring Mopidy: for example the local
backend for playing music from your disk, the stream backend for playing
Internet radio streams, and the Spotify and SoundCloud backends, for playing
music directly from those services.
All of these are examples of what you can accomplish by creating a Mopidy
extension. If you want to create your own Mopidy extension for something that
does not exist yet, this guide to extension development will help you get your
extension running in no time, and make it feel the way users would expect your
extension to behave.
Anatomy of an extension
=======================
Extensions are all located in a Python package called ``mopidy_something``
where "something" is the name of the application, library or web service you
want to integrated with Mopidy. So for example if you plan to add support for a
service named Soundspot to Mopidy, you would name your extension's Python
package ``mopidy_soundspot``.
The name of the actual extension (the human readable name) however would be
something like "Mopidy-Soundspot". Make sure to include the name "Mopidy"
somewhere in that name and that you check the capitalization. This is the name
users will use when they install your extension from PyPI.
The extension must be shipped with a ``setup.py`` file and be registered on
`PyPI <https://pypi.python.org/>`_. Also make sure the development version link
in your package details work so that people can easily install the development
version into their virtualenv simply by running e.g. ``pip install
Mopidy-Soundspot==dev``.
Mopidy extensions must be licensed under an Apache 2.0 (like Mopidy itself),
BSD, MIT or more liberal license to be able to be enlisted in the Mopidy
documentation. The license text should be included in the ``LICENSE`` file in
the root of the extension's Git repo.
Combining this together, we get the following folder structure for our
extension, Mopidy-Soundspot::
mopidy-soundspot/ # The Git repo root
LICENSE # The license text
README.rst # Document what it is and how to use it
mopidy_soundspot/ # Your code
__init__.py
config.ini # Default configuration for the extension
...
setup.py # Installation script
Example content for the most important files follows below.
Example README.rst
==================
The README file should quickly tell what the extension does, how to install it,
and how to configure it. The README should contain a development snapshot link
to a tarball of the latest development version of the extension. It's important
that the development snapshot link ends with ``#egg=mopidy-something-dev`` for
installation using ``pip install mopidy-something==dev`` to work.
.. code-block:: rst
Mopidy-Soundspot
================
`Mopidy <http://www.mopidy.com/>`_ extension for playing music from
`Soundspot <http://soundspot.example.com/>`_.
Usage
-----
Requires a Soundspot Platina subscription and the pysoundspot library.
Install by running::
sudo pip install Mopidy-Soundspot
Or install the Debian/Ubuntu package from `apt.mopidy.com
<http://apt.mopidy.com/>`_.
Before starting Mopidy, you must add your Soundspot username and password
to the Mopidy configuration file::
[soundspot]
username = alice
password = secret
Project resources
-----------------
- `Source code <https://github.com/mopidy/mopidy-soundspot>`_
- `Issue tracker <https://github.com/mopidy/mopidy-soundspot/issues>`_
- `Download development snapshot <https://github.com/mopidy/mopidy-soundspot/tarball/develop#egg=mopidy-soundspot-dev>`_
Example setup.py
================
The ``setup.py`` file must use setuptools/distribute, and not distutils. This
is because Mopidy extensions use setuptools' entry point functionality to
register themselves as available Mopidy extensions when they are installed on
your system.
The example below also includes a couple of convenient tricks for reading the
package version from the source code so that it it's just defined in a single
place, and to reuse the README file as the long description of the package for
the PyPI registration.
The package must have ``install_requires`` on ``setuptools`` and ``Mopidy``, in
addition to any other dependencies required by your extension. The
``entry_points`` part must be included. The ``mopidy.extension`` part cannot be
changed, but the innermost string should be changed. It's format is
``my_ext_name = my_py_module:MyExtClass``. ``my_ext_name`` should be a short
name for your extension, typically the part after "Mopidy-" in lowercase. This
name is used e.g. to name the config section for your extension. The
``my_py_module:MyExtClass`` part is simply the Python path to the extension
class that will connect the rest of the dots.
::
from __future__ import unicode_literals
import re
from setuptools import setup
def get_version(filename):
content = open(filename).read()
metadata = dict(re.findall("__([a-z]+)__ = '([^']+)'", content))
return metadata['version']
setup(
name='Mopidy-Soundspot',
version=get_version('mopidy_soundspot/__init__.py'),
url='http://example.com/mopidy-soundspot/',
license='Apache License, Version 2.0',
author='Your Name',
author_email='your-email@example.com',
description='Very short description',
long_description=open('README.rst').read(),
packages=['mopidy_soundspot'],
# If you ship package instead of a single module instead, use
# 'py_modules' instead of 'packages':
#py_modules=['mopidy_soundspot'],
zip_safe=False,
include_package_data=True,
platforms='any',
install_requires=[
'setuptools',
'Mopidy',
'pysoundspot',
],
entry_points={
'mopidy.extension': [
'soundspot = mopidy_soundspot:Extension',
],
},
classifiers=[
'Environment :: No Input/Output (Daemon)',
'Intended Audience :: End Users/Desktop',
'License :: OSI Approved :: Apache Software License',
'Operating System :: OS Independent',
'Programming Language :: Python :: 2',
'Topic :: Multimedia :: Sound/Audio :: Players',
],
)
Example __init__.py
===================
The ``__init__.py`` file should be placed inside the ``mopidy_soundspot``
Python package. The root of your Python package should have an ``__version__``
attribute with a :pep:`386` compliant version number, for example "0.1". Next,
it should have a class named ``Extension`` which inherits from Mopidy's
extension base class. This is the class referred to in the ``entry_points``
part of ``setup.py``. Any imports of other files in your extension should be
kept inside methods. This ensures that this file can be imported without
raising :exc:`ImportError` exceptions for missing dependencies, etc.
::
from __future__ import unicode_literals
import os
import pygst
pygst.require('0.10')
import gst
import gobject
from mopidy.exceptions import ExtensionError
from mopidy.utils import ext
__version__ = '0.1'
class Extension(ext.Extension):
name = 'Mopidy-Soundspot'
version = __version__
@classmethod
def get_default_config(cls):
config_file = os.path.join(
os.path.dirname(__file__), 'config.ini')
return open(config_file).read()
@classmethod
def validate_config(cls, config):
# ``config`` is the complete config document for the Mopidy
# instance. The extension is free to check any config value it is
# interested in, not just its own config values.
if not config.getboolean('soundspot', 'enabled'):
return
if not config.get('soundspot', 'username'):
raise ExtensionError('Config soundspot.username not set')
if not config.get('soundspot', 'password'):
raise ExtensionError('Config soundspot.password not set')
@classmethod
def validate_environment(cls):
# This method can validate anything it wants about the environment
# the extension is running in. Examples include checking if all
# dependencies are installed.
try:
import pysoundspot
except ImportError as e:
raise ExtensionError('pysoundspot library not found', e)
# You will typically only implement one of the next three methods
# in a single extension.
@classmethod
def get_frontend_class(cls):
from .frontend import SoundspotFrontend
return SoundspotFrontend
@classmethod
def get_backend_class(cls):
from .backend import SoundspotBackend
return SoundspotBackend
@classmethod
def register_gstreamer_elements(cls):
from .mixer import SoundspotMixer
gobject.type_register(SoundspotMixer)
gst.element_register(
SoundspotMixer, 'soundspotmixer', gst.RANK_MARGINAL)
Example config.ini
==================
The default configuration for the extension is located in a ``config.ini`` file
inside the Python package. It contains a single config section, with a name
matching the short name used for the extension in the ``entry_points`` part of
``setup.py``.
All extensions should include an ``enabled`` config which should default to
``true``. Leave any configurations that doesn't have meaningful defaults blank,
like ``username`` and ``password``.
.. code-block:: ini
[soundspot]
enabled = true
username =
password =
Example frontend
================
If you want to *use* Mopidy's core API from your extension, then you want to
implement a frontend.
The skeleton of a frontend would look like this. Notice that the frontend gets
passed a reference to the core API when it's created. See the
:ref:`frontend-api` for more details.
::
import pykka
from mopidy.core import CoreListener
class SoundspotFrontend(pykka.ThreadingActor, CoreListener):
def __init__(self, core):
super(SoundspotFrontend, self).__init__()
self.core = core
# Your frontend implementation
Example backend
===============
If you want to extend Mopidy to support new music and playlist sources, you
want to implement a backend. A backend does not have access to Mopidy's core
API at all and got a bunch of interfaces to implement.
The skeleton of a backend would look like this. See :ref:`backend-api` for more
details.
::
import pykka
from mopidy.backends import base
class SoundspotBackend(pykka.ThreadingActor, base.BaseBackend):
def __init__(self, audio):
super(SoundspotBackend, self).__init__()
self.audio = audio
# Your backend implementation
Example GStreamer element
=========================
If you want to extend Mopidy's GStreamer pipeline with new custom GStreamer
elements, you'll need to register them in GStreamer before they can be used.
Basically, you just implement your GStreamer element in Python and then make
your :meth:`Extension.register_gstreamer_elements` method register all your
custom GStreamer elements.
For examples of custom GStreamer elements implemented in Python, see
:mod:`mopidy.audio.mixers`.
Implementation steps
====================
A rough plan of how to make the above document the reality of how Mopidy
extensions work.
1. Implement :class:`mopidy.utils.ext.Extension` base class and the
:exc:`mopidy.exceptions.ExtensionError` exception class.
2. Switch from using distutils to setuptools to package and install Mopidy so
that we can register entry points for the bundled extensions and get
information about all extensions available on the system from
:mod:`pkg_resources`.
3. Add :class:`Extension` classes for all existing frontends and backends. Make
sure to add default config files and config validation, even though this
will not be used at this implementation stage.
4. Add entry points for the existing extensions in the ``setup.py`` file.
5. Rewrite the startup procedure to find extensions and thus frontends and
backends via :mod:`pkg_resouces` instead of the ``FRONTENDS`` and
``BACKENDS`` settings.
6. Remove the ``FRONTENDS`` and ``BACKENDS`` settings.
7. Switch to ini file based configuration, using :mod:`ConfigParser`. The
default config is the combination of a core config file plus the config from
each installed extension. To find the effective config for the system, the
following config sources are added together, with the later ones overriding
the earlier ones:
- the default config built from Mopidy core and all installed extensions,
- ``/etc/mopidy.conf``,
- ``~/.config/mopidy.conf``,
- any config file provided via command line arguments, and
- any config values provided via command line arguments.
8. Add command line options for:
- loading an additional config file for this execution of Mopidy,
- setting a config value for this execution of Mopidy,
- printing the effective config and exit, and
- write a config value permanently to ``~/.config/mopidy.conf`` and exit.

View File

@ -17,9 +17,10 @@ including Windows, Mac OS X, Linux, Android, and iOS.
To install Mopidy, start by reading :ref:`installation`.
If you get stuck, we usually hang around at ``#mopidy`` at `irc.freenode.net
<http://freenode.net/>`_. If you stumble into a bug or got a feature request,
please create an issue in the `issue tracker
<https://github.com/mopidy/mopidy/issues>`_.
<http://freenode.net/>`_ and also got a `mailing list at Google Groups
<https://groups.google.com/forum/?fromgroups=#!forum/mopidy>`_. If you stumble
into a bug or got a feature request, please create an issue in the `issue
tracker <https://github.com/mopidy/mopidy/issues>`_.
Project resources
@ -30,13 +31,14 @@ Project resources
- `Issue tracker <https://github.com/mopidy/mopidy/issues>`_
- `CI server <https://travis-ci.org/mopidy/mopidy>`_
- IRC: ``#mopidy`` at `irc.freenode.net <http://freenode.net/>`_
- Mailing list: `mopidy@googlegroups.com <https://groups.google.com/forum/?fromgroups=#!forum/mopidy>`_
User documentation
==================
.. toctree::
:maxdepth: 3
:maxdepth: 2
installation/index
installation/raspberrypi
@ -52,7 +54,7 @@ Reference documentation
=======================
.. toctree::
:maxdepth: 3
:maxdepth: 2
api/index
modules/index
@ -62,9 +64,10 @@ Development documentation
=========================
.. toctree::
:maxdepth: 3
:maxdepth: 2
development
extensiondev
Indices and tables

View File

@ -209,8 +209,12 @@ software packages, as Wheezy is going to be the next release of Debian.
aplay /usr/share/sounds/alsa/Front_Center.wav
To make the change to analog output stick, you can add the ``amixer`` command
to e.g. ``/etc/rc.local``, which will be executed when the system is
If you hear a voice saying "Front Center", then your sound is working. Don't
be concerned if this test sound includes static. Test your sound with
GStreamer to determine the sound quality of Mopidy.
To make the change to analog output stick, you can add the ``amixer``
command to e.g. ``/etc/rc.local``, which will be executed when the system is
booting.

79
js/Gruntfile.js Normal file
View File

@ -0,0 +1,79 @@
/*global module:false*/
module.exports = function (grunt) {
grunt.initConfig({
meta: {
banner: "/*! Mopidy.js - built " +
"<%= grunt.template.today('yyyy-mm-dd') %>\n" +
" * http://www.mopidy.com/\n" +
" * Copyright (c) <%= grunt.template.today('yyyy') %> " +
"Stein Magnus Jodal and contributors\n" +
" * Licensed under the Apache License, Version 2.0 */\n",
files: {
own: ["Gruntfile.js", "src/**/*.js", "test/**/*-test.js"],
concat: "../mopidy/frontends/http/data/mopidy.js",
minified: "../mopidy/frontends/http/data/mopidy.min.js"
}
},
concat: {
options: {
banner: "<%= meta.banner %>",
stripBanners: true
},
all: {
files: {
"<%= meta.files.concat %>": [
"lib/bane-*.js",
"lib/when-define-shim.js",
"lib/when-*.js",
"src/mopidy.js"
]
}
}
},
jshint: {
options: {
curly: true,
eqeqeq: true,
immed: true,
indent: 4,
latedef: true,
newcap: true,
noarg: true,
sub: true,
quotmark: "double",
undef: true,
unused: true,
eqnull: true,
browser: true,
devel: true,
globals: {}
},
files: "<%= meta.files.own %>"
},
uglify: {
options: {
banner: "<%= meta.banner %>"
},
all: {
files: {
"<%= meta.files.minified %>": ["<%= meta.files.concat %>"]
}
}
},
watch: {
files: "<%= meta.files.own %>",
tasks: ["default"]
}
});
grunt.registerTask("test", ["jshint", "buster"]);
grunt.registerTask("build", ["test", "concat", "uglify"]);
grunt.registerTask("default", ["build"]);
grunt.loadNpmTasks("grunt-buster");
grunt.loadNpmTasks("grunt-contrib-concat");
grunt.loadNpmTasks("grunt-contrib-jshint");
grunt.loadNpmTasks("grunt-contrib-uglify");
grunt.loadNpmTasks("grunt-contrib-watch");
};

82
js/README.md Normal file
View File

@ -0,0 +1,82 @@
Mopidy.js
=========
Mopidy.js is a JavaScript library that is installed as a part of Mopidy's HTTP
frontend or from npm. The library makes Mopidy's core API available from the
browser or a Node.js environment, using JSON-RPC messages over a WebSocket to
communicate with Mopidy.
Getting it for browser use
--------------------------
Regular and minified versions of Mopidy.js, ready for use, is installed
together with Mopidy. When the HTTP frontend is running, 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.
In the source repo, you can find the files at:
- `mopidy/frontends/http/data/mopidy.js`
- `mopidy/frontends/http/data/mopidy.min.js`
Getting it 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()``:
var Mopidy = require("mopidy").Mopidy;
Using the library
-----------------
See Mopidy's [HTTP frontend
documentation](http://docs.mopidy.com/en/latest/modules/frontends/http/).
Building from source
--------------------
1. Install [Node.js](http://nodejs.org/) and npm. There is a PPA 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 npm
2. Enter the `js/` in Mopidy's Git repo dir and install all dependencies:
cd js/
npm install
That's it.
You can now run the tests:
npm test
To run tests automatically when you save a file:
npm run-script watch
To run tests, concatenate, minify the source, and update the JavaScript files
in `mopidy/frontends/http/data/`:
npm run-script build
To run other [grunt](http://gruntjs.com/) targets which isn't predefined in
`package.json` and thus isn't available through `npm run-script`:
PATH=./node_modules/.bin:$PATH grunt foo

View File

@ -1,62 +0,0 @@
*********
Mopidy.js
*********
This is the source for the JavaScript library that is installed as a part of
Mopidy's HTTP frontend. The library makes Mopidy's core API available from the
browser, using JSON-RPC messages over a WebSocket to communicate with Mopidy.
Getting it
==========
Regular and minified versions of Mopidy.js, ready for use, is installed
together with Mopidy. When the HTTP frontend is running, 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.
In the source repo, you can find the files at:
- ``mopidy/frontends/http/data/mopidy.js``
- ``mopidy/frontends/http/data/mopidy.min.js``
Building from source
====================
1. Install `Node.js <http://nodejs.org/>`_ and npm. There is a PPA 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 npm
2. Enter the ``js/`` dir and install development dependencies::
cd js/
npm install
That's it.
You can now run the tests::
npm test
To run tests automatically when you save a file::
npm run-script watch
To run tests, concatenate, minify the source, and update the JavaScript files
in ``mopidy/frontends/http/data/``::
npm run-script build
To run other `grunt <http://gruntjs.com/>`_ targets which isn't predefined in
``package.json`` and thus isn't available through ``npm run-script``::
PATH=./node_modules/.bin:$PATH grunt foo

View File

@ -1,8 +1,24 @@
var config = module.exports;
config["tests"] = {
config.browser_tests = {
environment: "browser",
libs: ["lib/**/*.js"],
libs: [
"lib/bane-*.js",
"lib/when-define-shim.js",
"lib/when-*.js"
],
sources: ["src/**/*.js"],
testHelpers: ["test/**/*-helper.js"],
tests: ["test/**/*-test.js"]
};
config.node_tests = {
environment: "node",
libs: [
"lib/bane-*.js",
"lib/when-define-shim.js",
"lib/when-*.js"
],
sources: ["src/**/*.js"],
testHelpers: ["test/**/*-helper.js"],
tests: ["test/**/*-test.js"]

View File

@ -1,72 +0,0 @@
/*global module:false*/
module.exports = function (grunt) {
grunt.initConfig({
meta: {
banner: "/*! Mopidy.js - built " +
"<%= grunt.template.today('yyyy-mm-dd') %>\n" +
" * http://www.mopidy.com/\n" +
" * Copyright (c) <%= grunt.template.today('yyyy') %> " +
"Stein Magnus Jodal and contributors\n" +
" * Licensed under the Apache License, Version 2.0 */"
},
dirs: {
dest: "../mopidy/frontends/http/data"
},
lint: {
files: ["grunt.js", "src/**/*.js", "test/**/*-test.js"]
},
buster: {
test: {
config: "buster.js"
}
},
concat: {
dist: {
src: [
"<banner:meta.banner>",
"lib/bane-*.js",
"lib/when-*.js",
"src/mopidy.js"
],
dest: "<%= dirs.dest %>/mopidy.js"
}
},
min: {
dist: {
src: ["<banner:meta.banner>", "<config:concat.dist.dest>"],
dest: "<%= dirs.dest %>/mopidy.min.js"
}
},
watch: {
files: "<config:lint.files>",
tasks: "lint buster concat min"
},
jshint: {
options: {
curly: true,
eqeqeq: true,
immed: true,
indent: 4,
latedef: true,
newcap: true,
noarg: true,
sub: true,
quotmark: "double",
undef: true,
unused: true,
eqnull: true,
browser: true,
devel: true
},
globals: {}
},
uglify: {}
});
grunt.registerTask("test", "lint buster");
grunt.registerTask("build", "lint buster concat min");
grunt.registerTask("default", "build");
grunt.loadNpmTasks("grunt-buster");
};

View File

@ -1,731 +0,0 @@
/** @license MIT License (c) copyright B Cavalier & J Hann */
/**
* 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
*
* @version 1.6.1
*/
(function(define) { 'use strict';
define(['module'], function () {
var reduceArray, slice, undef;
//
// Public API
//
when.defer = defer; // Create a deferred
when.resolve = resolve; // Create a resolved promise
when.reject = reject; // Create a rejected promise
when.join = join; // Join 2 or more promises
when.all = all; // Resolve a list of promises
when.some = some; // Resolve a sub-set of promises
when.any = any; // Resolve one promise in a list
when.map = map; // Array.map() for promises
when.reduce = reduce; // Array.reduce() for promises
when.chain = chain; // Make a promise trigger another resolver
when.isPromise = isPromise; // Determine if a thing is a promise
/**
* Register an observer for a promise or immediate value.
* @function
* @name when
* @namespace
*
* @param promiseOrValue {*}
* @param {Function} [callback] callback to be called when promiseOrValue is
* successfully fulfilled. If promiseOrValue is an immediate value, callback
* will be invoked immediately.
* @param {Function} [errback] callback to be called when promiseOrValue is
* rejected.
* @param {Function} [progressHandler] callback to be called when progress updates
* are issued for promiseOrValue.
* @returns {Promise} a new {@link Promise} that will complete 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, callback, errback, progressHandler) {
// Get a trusted promise for the input promiseOrValue, and then
// register promise handlers
return resolve(promiseOrValue).then(callback, errback, progressHandler);
}
/**
* Returns promiseOrValue if promiseOrValue is a {@link Promise}, a new Promise if
* promiseOrValue is a foreign promise, or a new, already-fulfilled {@link Promise}
* whose value is promiseOrValue if promiseOrValue is an immediate value.
* @memberOf when
*
* @param promiseOrValue {*}
* @returns Guaranteed to return a trusted Promise. If promiseOrValue is a when.js {@link Promise}
* returns promiseOrValue, otherwise, returns a new, already-resolved, when.js {@link Promise}
* whose resolution value is:
* * the resolution value of promiseOrValue if it's a foreign promise, or
* * promiseOrValue if it's a value
*/
function resolve(promiseOrValue) {
var promise, deferred;
if(promiseOrValue instanceof Promise) {
// It's a when.js promise, so we trust it
promise = promiseOrValue;
} else {
// It's not a when.js promise. See if it's a foreign promise or a value.
// Some promises, particularly Q promises, provide a valueOf method that
// attempts to synchronously return the fulfilled value of the promise, or
// returns the unresolved promise itself. Attempting to break a fulfillment
// value out of a promise appears to be necessary to break cycles between
// Q and When attempting to coerce each-other's promises in an infinite loop.
// For promises that do not implement "valueOf", the Object#valueOf is harmless.
// See: https://github.com/kriskowal/q/issues/106
// IMPORTANT: Must check for a promise here, since valueOf breaks other things
// like Date.
if (isPromise(promiseOrValue) && typeof promiseOrValue.valueOf === 'function') {
promiseOrValue = promiseOrValue.valueOf();
}
if(isPromise(promiseOrValue)) {
// It looks like a thenable, but we don't know where it came from,
// so we don't trust its implementation entirely. Introduce a trusted
// middleman when.js promise
deferred = defer();
// IMPORTANT: This is the only place when.js should ever call .then() on
// an untrusted promise.
promiseOrValue.then(deferred.resolve, deferred.reject, deferred.progress);
promise = deferred.promise;
} else {
// It's a value, not a promise. Create a resolved promise for it.
promise = fulfilled(promiseOrValue);
}
}
return promise;
}
/**
* Returns a rejected promise for the supplied promiseOrValue. If
* promiseOrValue is a value, it will be the rejection value of the
* returned promise. If promiseOrValue is a promise, its
* completion value will be the rejected value of the returned promise
* @memberOf when
*
* @param promiseOrValue {*} the rejected value of the returned {@link Promise}
* @return {Promise} rejected {@link Promise}
*/
function reject(promiseOrValue) {
return when(promiseOrValue, function(value) {
return rejected(value);
});
}
/**
* Trusted Promise constructor. A Promise created from this constructor is
* a trusted when.js promise. Any other duck-typed promise is considered
* untrusted.
* @constructor
* @name Promise
*/
function Promise(then) {
this.then = then;
}
Promise.prototype = {
/**
* Register a callback that will be called when a promise is
* resolved or rejected. Optionally also register a progress handler.
* Shortcut for .then(alwaysback, alwaysback, progback)
* @memberOf Promise
* @param alwaysback {Function}
* @param progback {Function}
* @return {Promise}
*/
always: function(alwaysback, progback) {
return this.then(alwaysback, alwaysback, progback);
},
/**
* Register a rejection handler. Shortcut for .then(null, errback)
* @memberOf Promise
* @param errback {Function}
* @return {Promise}
*/
otherwise: function(errback) {
return this.then(undef, errback);
}
};
/**
* Create an already-resolved promise for the supplied value
* @private
*
* @param value anything
* @return {Promise}
*/
function fulfilled(value) {
var p = new Promise(function(callback) {
try {
return resolve(callback ? callback(value) : value);
} catch(e) {
return rejected(e);
}
});
return p;
}
/**
* Create an already-rejected {@link Promise} with the supplied
* rejection reason.
* @private
*
* @param reason rejection reason
* @return {Promise}
*/
function rejected(reason) {
var p = new Promise(function(callback, errback) {
try {
return errback ? resolve(errback(reason)) : rejected(reason);
} catch(e) {
return rejected(e);
}
});
return p;
}
/**
* Creates a new, Deferred with fully isolated resolver and promise parts,
* either or both of which may be given out safely to consumers.
* The Deferred itself has the full API: resolve, reject, progress, and
* then. The resolver has resolve, reject, and progress. The promise
* only has then.
* @memberOf when
* @function
*
* @return {Deferred}
*/
function defer() {
var deferred, promise, handlers, progressHandlers,
_then, _progress, _resolve;
/**
* The promise for the new deferred
* @type {Promise}
*/
promise = new Promise(then);
/**
* The full Deferred object, with {@link Promise} and {@link Resolver} parts
* @class Deferred
* @name Deferred
*/
deferred = {
then: then,
resolve: promiseResolve,
reject: promiseReject,
// TODO: Consider renaming progress() to notify()
progress: promiseProgress,
promise: promise,
resolver: {
resolve: promiseResolve,
reject: promiseReject,
progress: promiseProgress
}
};
handlers = [];
progressHandlers = [];
/**
* Pre-resolution then() that adds the supplied callback, errback, and progback
* functions to the registered listeners
* @private
*
* @param [callback] {Function} resolution handler
* @param [errback] {Function} rejection handler
* @param [progback] {Function} progress handler
* @throws {Error} if any argument is not null, undefined, or a Function
*/
_then = function(callback, errback, progback) {
var deferred, progressHandler;
deferred = defer();
progressHandler = progback
? function(update) {
try {
// Allow progress handler to transform progress event
deferred.progress(progback(update));
} catch(e) {
// Use caught value as progress
deferred.progress(e);
}
}
: deferred.progress;
handlers.push(function(promise) {
promise.then(callback, errback)
.then(deferred.resolve, deferred.reject, progressHandler);
});
progressHandlers.push(progressHandler);
return deferred.promise;
};
/**
* Issue a progress event, notifying all progress listeners
* @private
* @param update {*} progress event payload to pass to all listeners
*/
_progress = function(update) {
processQueue(progressHandlers, update);
return update;
};
/**
* Transition from pre-resolution state to post-resolution state, notifying
* all listeners of the resolution or rejection
* @private
* @param completed {Promise} the completed value of this deferred
*/
_resolve = function(completed) {
completed = resolve(completed);
// Replace _then with one that directly notifies with the result.
_then = completed.then;
// Replace _resolve so that this Deferred can only be completed once
_resolve = resolve;
// Make _progress a noop, to disallow progress for the resolved promise.
_progress = noop;
// Notify handlers
processQueue(handlers, completed);
// Free progressHandlers array since we'll never issue progress events
progressHandlers = handlers = undef;
return completed;
};
return deferred;
/**
* Wrapper to allow _then to be replaced safely
* @param [callback] {Function} resolution handler
* @param [errback] {Function} rejection handler
* @param [progback] {Function} progress handler
* @return {Promise} new Promise
* @throws {Error} if any argument is not null, undefined, or a Function
*/
function then(callback, errback, progback) {
return _then(callback, errback, progback);
}
/**
* Wrapper to allow _resolve to be replaced
*/
function promiseResolve(val) {
return _resolve(val);
}
/**
* Wrapper to allow _resolve to be replaced
*/
function promiseReject(err) {
return _resolve(rejected(err));
}
/**
* Wrapper to allow _progress to be replaced
* @param {*} update progress update
*/
function promiseProgress(update) {
return _progress(update);
}
}
/**
* Determines if promiseOrValue is a promise or not. Uses the feature
* test from http://wiki.commonjs.org/wiki/Promises/A to determine if
* promiseOrValue is a promise.
*
* @param {*} promiseOrValue anything
* @returns {Boolean} true if promiseOrValue is a {@link Promise}
*/
function isPromise(promiseOrValue) {
return promiseOrValue && typeof promiseOrValue.then === 'function';
}
/**
* 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.
* @memberOf when
*
* @param promisesOrValues {Array} array of anything, may contain a mix
* of {@link Promise}s and values
* @param howMany {Number} number of promisesOrValues to resolve
* @param [callback] {Function} resolution handler
* @param [errback] {Function} rejection handler
* @param [progback] {Function} progress handler
* @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, callback, errback, progback) {
checkCallbacks(2, arguments);
return when(promisesOrValues, function(promisesOrValues) {
var toResolve, toReject, values, reasons, deferred, fulfillOne, rejectOne, progress, len, i;
len = promisesOrValues.length >>> 0;
toResolve = Math.max(0, Math.min(howMany, len));
values = [];
toReject = (len - toResolve) + 1;
reasons = [];
deferred = defer();
// No items in the input, resolve immediately
if (!toResolve) {
deferred.resolve(values);
} else {
progress = deferred.progress;
rejectOne = function(reason) {
reasons.push(reason);
if(!--toReject) {
fulfillOne = rejectOne = noop;
deferred.reject(reasons);
}
};
fulfillOne = function(val) {
// This orders the values based on promise resolution order
// Another strategy would be to use the original position of
// the corresponding promise.
values.push(val);
if (!--toResolve) {
fulfillOne = rejectOne = noop;
deferred.resolve(values);
}
};
for(i = 0; i < len; ++i) {
if(i in promisesOrValues) {
when(promisesOrValues[i], fulfiller, rejecter, progress);
}
}
}
return deferred.then(callback, errback, progback);
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.
* @memberOf when
*
* @param promisesOrValues {Array|Promise} array of anything, may contain a mix
* of {@link Promise}s and values
* @param [callback] {Function} resolution handler
* @param [errback] {Function} rejection handler
* @param [progback] {Function} progress handler
* @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, callback, errback, progback) {
function unwrapSingleResult(val) {
return callback ? callback(val[0]) : val[0];
}
return some(promisesOrValues, 1, unwrapSingleResult, errback, progback);
}
/**
* Return a promise that will resolve only once all the supplied promisesOrValues
* 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 promisesOrValues {Array|Promise} array of anything, may contain a mix
* of {@link Promise}s and values
* @param [callback] {Function}
* @param [errback] {Function}
* @param [progressHandler] {Function}
* @returns {Promise}
*/
function all(promisesOrValues, callback, errback, progressHandler) {
checkCallbacks(1, arguments);
return map(promisesOrValues, identity).then(callback, errback, progressHandler);
}
/**
* Joins multiple promises into a single returned promise.
* @memberOf when
* @param {Promise|*} [...promises] two or more promises to join
* @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);
}
/**
* Traditional map function, similar to `Array.prototype.map()`, but allows
* input to contain {@link Promise}s and/or values, and mapFunc may return
* either a value or a {@link Promise}
*
* @memberOf when
*
* @param promise {Array|Promise} array of anything, may contain a mix
* of {@link Promise}s and values
* @param mapFunc {Function} mapping function mapFunc(value) which may return
* either a {@link Promise} or value
* @returns {Promise} a {@link Promise} that will resolve to an array containing
* the mapped output values.
*/
function map(promise, mapFunc) {
return when(promise, function(array) {
var results, len, toResolve, resolve, reject, i, d;
// Since we know the resulting length, we can preallocate the results
// array to avoid array expansions.
toResolve = len = array.length >>> 0;
results = [];
d = defer();
if(!toResolve) {
d.resolve(results);
} else {
reject = d.reject;
resolve = function resolveOne(item, i) {
when(item, mapFunc).then(function(mapped) {
results[i] = mapped;
if(!--toResolve) {
d.resolve(results);
}
}, reject);
};
// Since mapFunc may be async, get all invocations of it into flight
for(i = 0; i < len; i++) {
if(i in array) {
resolve(array[i], i);
} else {
--toResolve;
}
}
}
return d.promise;
});
}
/**
* Traditional reduce function, similar to `Array.prototype.reduce()`, but
* input may contain {@link Promise}s and/or values, and reduceFunc
* may return either a value or a {@link Promise}, *and* initialValue may
* be a {@link Promise} for the starting value.
* @memberOf when
*
* @param promise {Array|Promise} array of anything, may contain a mix
* of {@link Promise}s and values. May also be a {@link Promise} for
* an array.
* @param reduceFunc {Function} 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 [initialValue] {*} starting value, or a {@link Promise} for the starting value
* @returns {Promise} that will resolve to the final reduced value
*/
function reduce(promise, reduceFunc /*, initialValue */) {
var args = slice.call(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);
});
}
/**
* Ensure that resolution of promiseOrValue will complete resolver with the completion
* value of promiseOrValue, or instead with resolveValue if it is provided.
* @memberOf when
*
* @param promiseOrValue
* @param resolver {Resolver}
* @param [resolveValue] anything
* @returns {Promise}
*/
function chain(promiseOrValue, resolver, resolveValue) {
var useResolveValue = arguments.length > 2;
return when(promiseOrValue,
function(val) {
return resolver.resolve(useResolveValue ? resolveValue : val);
},
resolver.reject,
resolver.progress
);
}
//
// Utility functions
//
function processQueue(queue, value) {
var handler, i = 0;
while (handler = queue[i++]) {
handler(value);
}
}
/**
* Helper that checks arrayOfCallbacks to ensure that each element is either
* a function, or null or undefined.
* @private
*
* @param arrayOfCallbacks {Array} array to check
* @throws {Error} if any element of arrayOfCallbacks is something other than
* a Functions, null, or undefined.
*/
function checkCallbacks(start, arrayOfCallbacks) {
var arg, i = arrayOfCallbacks.length;
while(i > start) {
arg = arrayOfCallbacks[--i];
if (arg != null && typeof arg != 'function') {
throw new Error('arg '+i+' must be a function');
}
}
}
/**
* No-Op function used in method replacement
* @private
*/
function noop() {}
slice = [].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.
reduceArray = [].reduce ||
function(reduceFunc /*, initialValue */) {
/*jshint maxcomplexity: 7*/
// 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
var arr, args, reduced, len, i;
i = 0;
// This generates a jshint warning, despite being valid
// "Missing 'new' prefix when invoking a constructor."
// See https://github.com/jshint/jshint/issues/392
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) {
// Skip holes
if(i in arr) {
reduced = reduceFunc(reduced, arr[i], i, arr);
}
}
return reduced;
};
function identity(x) {
return x;
}
return when;
});
})(typeof define == 'function' && define.amd
? define
: function (deps, factory) { typeof exports === 'object'
? (module.exports = factory())
: (this.when = factory());
}
// Boilerplate for AMD, Node, and browser global
);

787
js/lib/when-2.0.0.js Normal file
View File

@ -0,0 +1,787 @@
/** @license MIT License (c) copyright 2011-2013 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
*
* @author Brian Cavalier
* @author John Hann
* @version 2.0.0
*/
(function(define) { 'use strict';
define(function () {
// Public API
when.defer = defer; // Create a deferred
when.resolve = resolve; // Create a resolved promise
when.reject = reject; // Create a rejected promise
when.join = join; // Join 2 or more promises
when.all = all; // Resolve a list of promises
when.map = map; // Array.map() for promises
when.reduce = reduce; // Array.reduce() for promises
when.any = any; // One-winner race
when.some = some; // Multi-winner race
when.isPromise = isPromise; // Determine if a thing is a promise
/**
* Register an observer for a promise or immediate value.
*
* @param {*} promiseOrValue
* @param {function?} [onFulfilled] callback to be called when promiseOrValue is
* successfully fulfilled. If promiseOrValue is an immediate value, callback
* will be invoked immediately.
* @param {function?} [onRejected] callback to be called when promiseOrValue 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
* 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 resolve(promiseOrValue).then(onFulfilled, onRejected, onProgress);
}
/**
* Trusted Promise constructor. A Promise created from this constructor is
* a trusted when.js promise. Any other duck-typed promise is considered
* untrusted.
* @constructor
* @name Promise
*/
function Promise(then) {
this.then = then;
}
Promise.prototype = {
/**
* Register a rejection handler. Shortcut for .then(undefined, onRejected)
* @param {function?} onRejected
* @return {Promise}
*/
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
* @returns {Promise}
*/
ensure: function(onFulfilledOrRejected) {
var self = this;
return this.then(injectHandler, injectHandler).yield(self);
function injectHandler() {
return resolve(onFulfilledOrRejected());
}
},
/**
* 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.
*/
'yield': function(value) {
return this.then(function() {
return value;
});
},
/**
* 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}
*/
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
*/
always: function(onFulfilledOrRejected, onProgress) {
return this.then(onFulfilledOrRejected, onFulfilledOrRejected, onProgress);
}
};
/**
* 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
* @param {*} value
* @return {Promise}
*/
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, rejected);
}
/**
* Creates a new Deferred with fully isolated resolver and promise parts,
* either or both of which may be given out safely to consumers.
* The resolver has resolve, reject, and progress. The promise
* only has then.
*
* @return {{
* promise: Promise,
* resolver: {
* resolve: function:Promise,
* reject: function:Promise,
* notify: function:Promise
* }}}
*/
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(rejected(reason));
}
resolved = true;
rejectPending(reason);
return pending;
};
deferred.notify = deferred.resolver.notify = function(update) {
notifyPending(update);
return update;
};
}
}
/**
* Creates a new promise whose fate is determined by resolver.
* @private (for now)
* @param {function} resolver function(resolve, reject, notify)
* @returns {Promise} promise whose fate is determine by resolver
*/
function promise(resolver) {
var value, handlers = [];
// Call the provider resolver to seal the promise's fate
try {
resolver(promiseResolve, promiseReject, promiseNotify);
} catch(e) {
promiseReject(e);
}
// Return the promise
return new Promise(then);
/**
* Register handlers for this promise.
* @param [onFulfilled] {Function} fulfillment handler
* @param [onRejected] {Function} rejection handler
* @param [onProgress] {Function} progress handler
* @return {Promise} new Promise
*/
function then(onFulfilled, onRejected, onProgress) {
return promise(function(resolve, reject, notify) {
handlers
// Call handlers later, after resolution
? handlers.push(function(value) {
value.then(onFulfilled, onRejected, onProgress)
.then(resolve, reject, notify);
})
// Call handlers soon, but not in the current stack
: enqueue(function() {
value.then(onFulfilled, onRejected, onProgress)
.then(resolve, reject, notify);
});
});
}
/**
* Transition from pre-resolution state to post-resolution state, notifying
* all listeners of the ultimate fulfillment or rejection
* @param {*|Promise} val resolution value
*/
function promiseResolve(val) {
if(!handlers) {
return;
}
value = coerce(val);
scheduleHandlers(handlers, value);
handlers = undef;
}
/**
* Reject this promise with the supplied reason, which will be used verbatim.
* @param {*} reason reason for the rejection
*/
function promiseReject(reason) {
promiseResolve(rejected(reason));
}
/**
* Issue a progress event, notifying all progress listeners
* @param {*} update progress event payload to pass to all listeners
*/
function promiseNotify(update) {
if(handlers) {
scheduleHandlers(handlers, progressing(update));
}
}
}
/**
* Coerces x to a trusted Promise
*
* @private
* @param {*} x thing to coerce
* @returns {Promise} 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(x) {
if(x instanceof Promise) {
return x;
} else if (x !== Object(x)) {
return fulfilled(x);
}
return promise(function(resolve, reject, notify) {
enqueue(function() {
try {
// We must check and assimilate in the same tick, but not the
// current tick, careful only to access promiseOrValue.then once.
var untrustedThen = x.then;
if(typeof untrustedThen === 'function') {
fcall(untrustedThen, x, resolve, reject, notify);
} else {
// It's a value, create a fulfilled wrapper
resolve(fulfilled(x));
}
} catch(e) {
// Something went wrong, reject
reject(e);
}
});
});
}
/**
* Create an already-fulfilled promise for the supplied value
* @private
* @param {*} value
* @return {Promise} fulfilled promise
*/
function fulfilled(value) {
var self = new Promise(function (onFulfilled) {
try {
return typeof onFulfilled == 'function'
? coerce(onFulfilled(value)) : self;
} catch (e) {
return rejected(e);
}
});
return self;
}
/**
* Create an already-rejected promise with the supplied rejection reason.
* @private
* @param {*} reason
* @return {Promise} rejected promise
*/
function rejected(reason) {
var self = new Promise(function (_, onRejected) {
try {
return typeof onRejected == 'function'
? coerce(onRejected(reason)) : self;
} catch (e) {
return rejected(e);
}
});
return self;
}
/**
* Create a progress promise with the supplied update.
* @private
* @param {*} update
* @return {Promise} progress promise
*/
function progressing(update) {
var self = new Promise(function (_, __, onProgress) {
try {
return typeof onProgress == 'function'
? progressing(onProgress(update)) : self;
} catch (e) {
return progressing(e);
}
});
return self;
}
/**
* Schedule a task that will process a list of handlers
* in the next queue drain run.
* @private
* @param {Array} handlers queue of handlers to execute
* @param {*} value passed as the only arg to each handler
*/
function scheduleHandlers(handlers, value) {
enqueue(function() {
var handler, i = 0;
while (handler = handlers[i++]) {
handler(value);
}
});
}
/**
* Determines if promiseOrValue is a promise or not
*
* @param {*} promiseOrValue anything
* @returns {boolean} true if promiseOrValue is a {@link Promise}
*/
function isPromise(promiseOrValue) {
return promiseOrValue && typeof promiseOrValue.then === 'function';
}
/**
* 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] resolution handler
* @param {function?} [onRejected] rejection handler
* @param {function?} [onProgress] progress handler
* @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) {
checkCallbacks(2, arguments);
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 = noop;
reject(reasons);
}
};
fulfillOne = function(val) {
// This orders the values based on promise resolution order
values.push(val);
if (!--toResolve) {
fulfillOne = rejectOne = noop;
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] resolution handler
* @param {function?} [onRejected] rejection handler
* @param {function?} [onProgress] progress handler
* @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
* 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] resolution handler
* @param {function?} [onRejected] rejection handler
* @param {function?} [onProgress] progress handler
* @returns {Promise}
*/
function all(promisesOrValues, onFulfilled, onRejected, onProgress) {
checkCallbacks(1, arguments);
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);
}
/**
* Traditional map function, similar to `Array.prototype.map()`, but allows
* input to contain {@link Promise}s and/or values, and mapFunc may return
* either a value or a {@link Promise}
*
* @param {Array|Promise} array array of anything, may contain a mix
* of {@link Promise}s and values
* @param {function} mapFunc mapping function mapFunc(value) which may return
* either a {@link Promise} or value
* @returns {Promise} a {@link Promise} that will resolve to an array containing
* the mapped output values.
*/
function map(array, mapFunc) {
return when(array, function(array) {
return promise(resolveMap);
function resolveMap(resolve, reject, notify) {
var results, len, toResolve, resolveOne, 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);
} else {
resolveOne = function(item, i) {
when(item, mapFunc).then(function(mapped) {
results[i] = mapped;
if(!--toResolve) {
resolve(results);
}
}, reject, notify);
};
// 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;
}
}
}
}
});
}
/**
* Traditional reduce function, similar to `Array.prototype.reduce()`, 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} promise 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.
* @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);
});
}
//
// Utilities, etc.
//
var reduceArray, slice, fcall, nextTick, handlerQueue,
timeout, funcProto, call, arrayProto, undef;
//
// 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) {
scheduleDrainQueue();
}
}
/**
* Schedule the queue to be drained in the next tick.
*/
function scheduleDrainQueue() {
nextTick(drainQueue);
}
/**
* Drain the handler queue entirely or partially, 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() {
var task, i = 0;
while(task = handlerQueue[i++]) {
task();
}
handlerQueue = [];
}
//
// Capture function and array utils
//
/*global setImmediate:true*/
// capture setTimeout to avoid being caught by fake timers used in time based tests
timeout = setTimeout;
nextTick = typeof setImmediate === 'function'
? typeof window === 'undefined'
? setImmediate
: setImmediate.bind(window)
: typeof process === 'object'
? process.nextTick
: function(task) { timeout(task, 0); };
// 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;
};
//
// Utility functions
//
/**
* Helper that checks arrayOfCallbacks to ensure that each element is either
* a function, or null or undefined.
* @private
* @param {number} start index at which to start checking items in arrayOfCallbacks
* @param {Array} arrayOfCallbacks array to check
* @throws {Error} if any element of arrayOfCallbacks is something other than
* a functions, null, or undefined.
*/
function checkCallbacks(start, arrayOfCallbacks) {
// TODO: Promises/A+ update type checking and docs
var arg, i = arrayOfCallbacks.length;
while(i > start) {
arg = arrayOfCallbacks[--i];
if (arg != null && typeof arg != 'function') {
throw new Error('arg '+i+' must be a function');
}
}
}
function noop() {}
function identity(x) {
return x;
}
return when;
});
})(
typeof define === 'function' && define.amd ? define : function (factory) { module.exports = factory(); }
);

View File

@ -0,0 +1,11 @@
if (typeof window !== "undefined") {
window.define = function (factory) {
try {
delete window.define;
} catch (e) {
window.define = void 0; // IE
}
window.when = factory();
};
window.define.amd = {};
}

View File

@ -1,15 +1,36 @@
{
"name": "mopidy",
"version": "0.0.0",
"devDependencies": {
"buster": "*",
"grunt": "*",
"grunt-buster": "*",
"phantomjs": "*"
},
"scripts": {
"test": "grunt test",
"build": "grunt build",
"watch": "grunt watch"
}
"name": "mopidy",
"version": "0.1.0",
"description": "Client lib for controlling a Mopidy music server over a WebSocket",
"homepage": "http://www.mopidy.com/",
"author": {
"name": "Stein Magnus Jodal",
"email": "stein.magnus@jodal.no",
"url": "http://www.jodal.no"
},
"repository": {
"type": "git",
"url": "git://github.com/mopidy/mopidy.git"
},
"main": "src/mopidy.js",
"dependencies": {
"bane": "~0.4.0",
"faye-websocket": "~0.4.4",
"when": "~2.0.0"
},
"devDependencies": {
"buster": "~0.6.12",
"grunt": "~0.4.0",
"grunt-buster": "~0.1.2",
"grunt-contrib-concat": "~0.1.3",
"grunt-contrib-jshint": "~0.2.0",
"grunt-contrib-uglify": "~0.1.2",
"grunt-contrib-watch": "~0.3.1",
"phantomjs": "~1.8.2"
},
"scripts": {
"test": "grunt test",
"build": "grunt build",
"watch": "grunt watch"
}
}

View File

@ -1,4 +1,10 @@
/*global bane:false, when:false*/
/*global exports:false, require:false*/
if (typeof module === "object" && typeof require === "function") {
var bane = require("bane");
var websocket = require("faye-websocket");
var when = require("when");
}
function Mopidy(settings) {
if (!(this instanceof Mopidy)) {
@ -20,9 +26,17 @@ function Mopidy(settings) {
}
}
if (typeof module === "object" && typeof require === "function") {
Mopidy.WebSocket = websocket.Client;
} else {
Mopidy.WebSocket = window.WebSocket;
}
Mopidy.prototype._configure = function (settings) {
var currentHost = (typeof document !== "undefined" &&
document.location.host) || "localhost";
settings.webSocketUrl = settings.webSocketUrl ||
"ws://" + document.location.host + "/mopidy/ws/";
"ws://" + currentHost + "/mopidy/ws/";
if (settings.autoConnect !== false) {
settings.autoConnect = true;
@ -35,7 +49,7 @@ Mopidy.prototype._configure = function (settings) {
};
Mopidy.prototype._getConsole = function () {
var console = window.console || {};
var console = typeof console !== "undefined" && console || {};
console.log = console.log || function () {};
console.warn = console.warn || function () {};
@ -63,7 +77,7 @@ Mopidy.prototype._delegateEvents = function () {
Mopidy.prototype.connect = function () {
if (this._webSocket) {
if (this._webSocket.readyState === WebSocket.OPEN) {
if (this._webSocket.readyState === Mopidy.WebSocket.OPEN) {
return;
} else {
this._webSocket.close();
@ -71,7 +85,7 @@ Mopidy.prototype.connect = function () {
}
this._webSocket = this._settings.webSocket ||
new WebSocket(this._settings.webSocketUrl);
new Mopidy.WebSocket(this._settings.webSocketUrl);
this._webSocket.onclose = function (close) {
this.emit("websocket:close", close);
@ -136,17 +150,17 @@ Mopidy.prototype._send = function (message) {
var deferred = when.defer();
switch (this._webSocket.readyState) {
case WebSocket.CONNECTING:
case Mopidy.WebSocket.CONNECTING:
deferred.resolver.reject({
message: "WebSocket is still connecting"
});
break;
case WebSocket.CLOSING:
case Mopidy.WebSocket.CLOSING:
deferred.resolver.reject({
message: "WebSocket is closing"
});
break;
case WebSocket.CLOSED:
case Mopidy.WebSocket.CLOSED:
deferred.resolver.reject({
message: "WebSocket is closed"
});
@ -228,7 +242,7 @@ Mopidy.prototype._handleEvent = function (eventMessage) {
};
Mopidy.prototype._getApiSpec = function () {
this._send({method: "core.describe"})
return this._send({method: "core.describe"})
.then(this._createApi.bind(this), this._handleWebSocketError)
.then(null, this._handleWebSocketError);
};
@ -280,3 +294,7 @@ Mopidy.prototype._snakeToCamel = function (name) {
return match.toUpperCase().replace("_", "");
});
};
if (typeof exports === "object") {
exports.Mopidy = Mopidy;
}

View File

@ -1,4 +1,10 @@
/*global buster:false, assert:false, refute:false, when:false, Mopidy:false*/
/*global require:false, assert:false, refute:false*/
if (typeof module === "object" && typeof require === "function") {
var buster = require("buster");
var Mopidy = require("../src/mopidy").Mopidy;
var when = require("when");
}
buster.testCase("Mopidy", {
setUp: function () {
@ -14,10 +20,11 @@ buster.testCase("Mopidy", {
fakeWebSocket.OPEN = 1;
fakeWebSocket.CLOSING = 2;
fakeWebSocket.CLOSED = 3;
this.realWebSocket = WebSocket;
window.WebSocket = fakeWebSocket;
this.webSocketConstructorStub = this.stub(window, "WebSocket");
this.realWebSocket = Mopidy.WebSocket;
Mopidy.WebSocket = fakeWebSocket;
this.webSocketConstructorStub = this.stub(Mopidy, "WebSocket");
this.webSocket = {
close: this.stub(),
@ -27,15 +34,18 @@ buster.testCase("Mopidy", {
},
tearDown: function () {
window.WebSocket = this.realWebSocket;
Mopidy.WebSocket = this.realWebSocket;
},
"constructor": {
"connects when autoConnect is true": function () {
new Mopidy({autoConnect: true});
var currentHost = typeof document !== "undefined" &&
document.location.host || "localhost";
assert.calledOnceWith(this.webSocketConstructorStub,
"ws://" + document.location.host + "/mopidy/ws/");
"ws://" + currentHost + "/mopidy/ws/");
},
"does not connect when autoConnect is false": function () {
@ -67,12 +77,15 @@ buster.testCase("Mopidy", {
mopidy.connect();
var currentHost = typeof document !== "undefined" &&
document.location.host || "localhost";
assert.calledOnceWith(this.webSocketConstructorStub,
"ws://" + document.location.host + "/mopidy/ws/");
"ws://" + currentHost + "/mopidy/ws/");
},
"does nothing when the WebSocket is open": function () {
this.webSocket.readyState = WebSocket.OPEN;
this.webSocket.readyState = Mopidy.WebSocket.OPEN;
var mopidy = new Mopidy({webSocket: this.webSocket});
mopidy.connect();
@ -367,7 +380,7 @@ buster.testCase("Mopidy", {
},
"immediately rejects request if CONNECTING": function (done) {
this.mopidy._webSocket.readyState = WebSocket.CONNECTING;
this.mopidy._webSocket.readyState = Mopidy.WebSocket.CONNECTING;
var promise = this.mopidy._send({method: "foo"});
@ -381,7 +394,7 @@ buster.testCase("Mopidy", {
},
"immediately rejects request if CLOSING": function (done) {
this.mopidy._webSocket.readyState = WebSocket.CLOSING;
this.mopidy._webSocket.readyState = Mopidy.WebSocket.CLOSING;
var promise = this.mopidy._send({method: "foo"});
@ -395,7 +408,7 @@ buster.testCase("Mopidy", {
},
"immediately rejects request if CLOSED": function (done) {
this.mopidy._webSocket.readyState = WebSocket.CLOSED;
this.mopidy._webSocket.readyState = Mopidy.WebSocket.CLOSED;
var promise = this.mopidy._send({method: "foo"});
@ -597,16 +610,16 @@ buster.testCase("Mopidy", {
assert.calledOnceWith(stub);
},
"gets Api description from server and calls _createApi": function () {
"gets Api description from server and calls _createApi": function (done) {
var methods = {};
var sendStub = this.stub(this.mopidy, "_send");
sendStub.returns(when.resolve(methods));
var _createApiStub = this.stub(this.mopidy, "_createApi");
this.mopidy._getApiSpec();
assert.calledOnceWith(sendStub, {method: "core.describe"});
assert.calledOnceWith(_createApiStub, methods);
this.mopidy._getApiSpec().then(done(function () {
assert.calledOnceWith(sendStub, {method: "core.describe"});
assert.calledOnceWith(_createApiStub, methods);
}));
}
},

View File

@ -23,7 +23,7 @@ if (isinstance(pykka.__version__, basestring)
warnings.filterwarnings('ignore', 'could not open display')
__version__ = '0.12.0'
__version__ = '0.13.0'
from mopidy import settings as default_settings_module

View File

@ -9,6 +9,8 @@ import sys
import gobject
gobject.threads_init()
import pykka.debug
# Extract any non-GStreamer arguments, and leave the GStreamer arguments for
# processing by GStreamer. This needs to be done before GStreamer is imported,
@ -43,15 +45,11 @@ logger = logging.getLogger('mopidy.main')
def main():
signal.signal(signal.SIGTERM, process.exit_handler)
signal.signal(signal.SIGUSR1, pykka.debug.log_thread_tracebacks)
loop = gobject.MainLoop()
options = parse_options()
if options.debug_thread or settings.DEBUG_THREAD:
debug_thread = process.DebugThread()
debug_thread.start()
signal.signal(signal.SIGUSR1, debug_thread.handler)
try:
log.setup_logging(options.verbosity_level, options.save_debug_log)
check_old_folders()

View File

@ -2,5 +2,8 @@ from __future__ import unicode_literals
# flake8: noqa
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)

View File

@ -12,7 +12,7 @@ import pykka
from mopidy import settings
from mopidy.utils import process
from . import mixers
from . import mixers, utils
from .constants import PlaybackState
from .listener import AudioListener
@ -21,6 +21,9 @@ logger = logging.getLogger('mopidy.audio')
mixers.register_mixers()
MB = 1 << 20
class Audio(pykka.ThreadingActor):
"""
Audio output through `GStreamer <http://gstreamer.freedesktop.org/>`_.
@ -39,16 +42,19 @@ class Audio(pykka.ThreadingActor):
super(Audio, self).__init__()
self._playbin = None
self._signal_ids = {} # {(element, event): signal_id}
self._mixer = None
self._mixer_track = None
self._mixer_scale = None
self._software_mixing = False
self._appsrc = None
self._volume_set = None
self._notify_source_signal_id = None
self._about_to_finish_id = None
self._message_signal_id = None
self._appsrc = None
self._appsrc_caps = None
self._appsrc_need_data_callback = None
self._appsrc_enough_data_callback = None
self._appsrc_seek_data_callback = None
def on_start(self):
try:
@ -65,42 +71,75 @@ class Audio(pykka.ThreadingActor):
self._teardown_mixer()
self._teardown_playbin()
def _connect(self, element, event, *args):
"""Helper to keep track of signal ids based on element+event"""
self._signal_ids[(element, event)] = element.connect(event, *args)
def _disconnect(self, element, event):
"""Helper to disconnect signals created with _connect helper."""
signal_id = self._signal_ids.pop((element, event), None)
if signal_id is not None:
element.disconnect(signal_id)
def _setup_playbin(self):
self._playbin = gst.element_factory_make('playbin2')
playbin = gst.element_factory_make('playbin2')
fakesink = gst.element_factory_make('fakesink')
self._playbin.set_property('video-sink', fakesink)
playbin.set_property('video-sink', fakesink)
self._about_to_finish_id = self._playbin.connect(
'about-to-finish', self._on_about_to_finish)
self._notify_source_signal_id = self._playbin.connect(
'notify::source', self._on_new_source)
self._connect(playbin, 'about-to-finish', self._on_about_to_finish)
self._connect(playbin, 'notify::source', self._on_new_source)
self._playbin = playbin
def _on_about_to_finish(self, element):
self._appsrc = None
source, self._appsrc = self._appsrc, None
if source is None:
return
self._appsrc_caps = None
self._disconnect(source, 'need-data')
self._disconnect(source, 'enough-data')
self._disconnect(source, 'seek-data')
def _on_new_source(self, element, pad):
uri = element.get_property('uri')
if not uri or not uri.startswith('appsrc://'):
return
# These caps matches the audio data provided by libspotify
default_caps = gst.Caps(
b'audio/x-raw-int, endianness=(int)1234, channels=(int)2, '
b'width=(int)16, depth=(int)16, signed=(boolean)true, '
b'rate=(int)44100')
source = element.get_property('source')
source.set_property('caps', default_caps)
# GStreamer does not like unicode
source.set_property('caps', self._appsrc_caps)
source.set_property('format', b'time')
source.set_property('stream-type', b'seekable')
source.set_property('max-bytes', 1 * MB)
source.set_property('min-percent', 50)
self._connect(source, 'need-data', self._appsrc_on_need_data)
self._connect(source, 'enough-data', self._appsrc_on_enough_data)
self._connect(source, 'seek-data', self._appsrc_on_seek_data)
self._appsrc = source
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:
self._appsrc_need_data_callback(length_hint)
return True
def _appsrc_on_enough_data(self, appsrc):
if self._appsrc_enough_data_callback is not None:
self._appsrc_enough_data_callback()
return True
def _appsrc_on_seek_data(self, appsrc, gst_position):
position = utils.clocktime_to_millisecond(gst_position)
if self._appsrc_seek_data_callback is not None:
self._appsrc_seek_data_callback(position)
return True
def _teardown_playbin(self):
if self._about_to_finish_id:
self._playbin.disconnect(self._about_to_finish_id)
if self._notify_source_signal_id:
self._playbin.disconnect(self._notify_source_signal_id)
self._disconnect(self._playbin, 'about-to-finish')
self._disconnect(self._playbin, 'notify::source')
self._playbin.set_state(gst.STATE_NULL)
def _setup_output(self):
@ -183,28 +222,34 @@ class Audio(pykka.ThreadingActor):
def _setup_message_processor(self):
bus = self._playbin.get_bus()
bus.add_signal_watch()
self._message_signal_id = bus.connect('message', self._on_message)
self._connect(bus, 'message', self._on_message)
def _teardown_message_processor(self):
if self._message_signal_id:
bus = self._playbin.get_bus()
bus.disconnect(self._message_signal_id)
bus.remove_signal_watch()
bus = self._playbin.get_bus()
self._disconnect(bus, 'message')
bus.remove_signal_watch()
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:
self._on_end_of_stream()
elif message.type == gst.MESSAGE_ERROR:
error, debug = message.parse_error()
logger.error('%s %s', error, debug)
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 %s', error, debug)
logger.warning(
'%s Debug message: %s',
str(error).decode('utf-8'), debug.decode('utf-8') or 'None')
def _on_playbin_state_changed(self, old_state, new_state, pending_state):
if new_state == gst.STATE_READY and pending_state == gst.STATE_NULL:
@ -250,6 +295,32 @@ class Audio(pykka.ThreadingActor):
"""
self._playbin.set_property('uri', uri)
def set_appsrc(
self, caps, need_data=None, enough_data=None, seek_data=None):
"""
Switch to using appsrc for getting audio to be played.
You *MUST* call :meth:`prepare_change` before calling this method.
:param caps: GStreamer caps string describing the audio format to
expect
:type caps: string
:param need_data: callback for when appsrc needs data
:type need_data: callable which takes data length hint in ms
:param enough_data: callback for when appsrc has enough data
:type enough_data: callable
:param seek_data: callback for when data from a new position is needed
to continue playback
:type seek_data: callable which takes time position in ms
"""
if isinstance(caps, unicode):
caps = caps.encode('utf-8')
self._appsrc_caps = gst.Caps(caps)
self._appsrc_need_data_callback = need_data
self._appsrc_enough_data_callback = enough_data
self._appsrc_seek_data_callback = seek_data
self._playbin.set_property('uri', 'appsrc://')
def emit_data(self, buffer_):
"""
Call this to deliver raw audio data to be played.
@ -282,13 +353,11 @@ class Audio(pykka.ThreadingActor):
:rtype: int
"""
if self._playbin.get_state()[1] == gst.STATE_NULL:
return 0
try:
position = self._playbin.query_position(gst.FORMAT_TIME)[0]
return position // gst.MSECOND
except gst.QueryError, e:
logger.error('time_position failed: %s', e)
gst_position = self._playbin.query_position(gst.FORMAT_TIME)[0]
return utils.clocktime_to_millisecond(gst_position)
except gst.QueryError:
logger.debug('Position query failed')
return 0
def set_position(self, position):
@ -299,12 +368,9 @@ class Audio(pykka.ThreadingActor):
:type position: int
:rtype: :class:`True` if successful, else :class:`False`
"""
self._playbin.get_state() # block until state changes are done
handeled = self._playbin.seek_simple(
gst.Format(gst.FORMAT_TIME), gst.SEEK_FLAG_FLUSH,
position * gst.MSECOND)
self._playbin.get_state() # block until seek is done
return handeled
gst_position = utils.millisecond_to_clocktime(position)
return self._playbin.seek_simple(
gst.Format(gst.FORMAT_TIME), gst.SEEK_FLAG_FLUSH, gst_position)
def start_playback(self):
"""

68
mopidy/audio/dummy.py Normal file
View File

@ -0,0 +1,68 @@
"""A dummy audio actor for use in tests.
This class implements the audio API in the simplest way possible. It is used in
tests of the core and backends.
"""
from __future__ import unicode_literals
import pykka
from .constants import PlaybackState
from .listener import AudioListener
class DummyAudio(pykka.ThreadingActor):
def __init__(self):
super(DummyAudio, self).__init__()
self.state = PlaybackState.STOPPED
self._position = 0
def set_on_end_of_track(self, callback):
pass
def set_uri(self, uri):
pass
def set_appsrc(self, *args, **kwargs):
pass
def emit_data(self, buffer_):
pass
def emit_end_of_stream(self):
pass
def get_position(self):
return self._position
def set_position(self, position):
self._position = position
return True
def start_playback(self):
return self._change_state(PlaybackState.PLAYING)
def pause_playback(self):
return self._change_state(PlaybackState.PAUSED)
def prepare_change(self):
return True
def stop_playback(self):
return self._change_state(PlaybackState.STOPPED)
def get_volume(self):
return 0
def set_volume(self, volume):
pass
def set_metadata(self, track):
pass
def _change_state(self, new_state):
old_state, self.state = self.state, new_state
AudioListener.send(
'state_changed', old_state=old_state, new_state=new_state)
return True

View File

@ -5,6 +5,39 @@ pygst.require('0.10')
import gst
def calculate_duration(num_samples, sample_rate):
"""Determine duration of samples using GStreamer helper for precise
math."""
return gst.util_uint64_scale(num_samples, gst.SECOND, sample_rate)
def create_buffer(data, capabilites=None, timestamp=None, duration=None):
"""Create a new GStreamer buffer based on provided data.
Mainly intended to keep gst imports out of non-audio modules.
"""
buffer_ = gst.Buffer(data)
if capabilites:
if isinstance(capabilites, basestring):
capabilites = gst.caps_from_string(capabilites)
buffer_.set_caps(capabilites)
if timestamp:
buffer_.timestamp = timestamp
if duration:
buffer_.duration = duration
return buffer_
def millisecond_to_clocktime(value):
"""Convert a millisecond time to internal GStreamer time."""
return value * gst.MSECOND
def clocktime_to_millisecond(value):
"""Convert an internal GStreamer time to millisecond time."""
return value // gst.MSECOND
def supported_uri_schemes(uri_schemes):
"""Determine which URIs we can actually support from provided whitelist.
@ -12,7 +45,7 @@ def supported_uri_schemes(uri_schemes):
:type uri_schemes: list or set or URI schemes as strings.
:rtype: set of URI schemes we can support via this GStreamer install.
"""
supported_schemes= set()
supported_schemes = set()
registry = gst.registry_get_default()
for factory in registry.get_feature_list(gst.TYPE_ELEMENT_FACTORY):

View File

@ -53,7 +53,7 @@ class BaseLibraryProvider(object):
def __init__(self, backend):
self.backend = backend
def find_exact(self, **query):
def find_exact(self, query=None, uris=None):
"""
See :meth:`mopidy.core.LibraryController.find_exact`.
@ -77,7 +77,7 @@ class BaseLibraryProvider(object):
"""
pass
def search(self, **query):
def search(self, query=None, uris=None):
"""
See :meth:`mopidy.core.LibraryController.search`.

View File

@ -35,7 +35,11 @@ class LocalLibraryProvider(base.BaseLibraryProvider):
logger.debug('Failed to lookup %r', uri)
return []
def find_exact(self, **query):
def find_exact(self, query=None, uris=None):
# TODO Only return results within URI roots given by ``uris``
if query is None:
query = {}
self._validate_query(query)
result_tracks = self._uri_mapping.values()
@ -72,7 +76,11 @@ class LocalLibraryProvider(base.BaseLibraryProvider):
raise LookupError('Invalid lookup field: %s' % field)
return SearchResult(uri='file:search', tracks=result_tracks)
def search(self, **query):
def search(self, query=None, uris=None):
# TODO Only return results within URI roots given by ``uris``
if query is None:
query = {}
self._validate_query(query)
result_tracks = self._uri_mapping.values()

View File

@ -142,12 +142,6 @@ def _convert_mpd_data(data, tracks, music_dir):
albumartist_kwargs[b'musicbrainz_id'] = (
data['musicbrainz_albumartistid'])
if data['file'][0] == '/':
path = data['file'][1:]
else:
path = data['file']
path = urllib.unquote(path)
if artist_kwargs:
artist = Artist(**artist_kwargs)
track_kwargs[b'artists'] = [artist]
@ -160,7 +154,19 @@ def _convert_mpd_data(data, tracks, music_dir):
album = Album(**album_kwargs)
track_kwargs[b'album'] = album
if data['file'][0] == '/':
path = data['file'][1:]
else:
path = data['file']
path = urllib.unquote(path.encode('utf-8'))
if isinstance(music_dir, unicode):
music_dir = music_dir.encode('utf-8')
# Make sure we only pass bytestrings to path_to_uri to avoid implicit
# decoding of bytestrings to unicode strings
track_kwargs[b'uri'] = path_to_uri(music_dir, path)
track_kwargs[b'length'] = int(data.get('time', 0)) * 1000
track = Track(**track_kwargs)

View File

@ -62,8 +62,8 @@ class SpotifyTrack(Track):
class SpotifyLibraryProvider(base.BaseLibraryProvider):
def find_exact(self, **query):
return self.search(**query)
def find_exact(self, query=None, uris=None):
return self.search(query=query, uris=uris)
def lookup(self, uri):
try:
@ -131,7 +131,9 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider):
def refresh(self, uri=None):
pass # TODO
def search(self, **query):
def search(self, query=None, uris=None):
# TODO Only return results within URI roots given by ``uris``
if not query:
return self._get_all_tracks()

View File

@ -1,113 +1,94 @@
from __future__ import unicode_literals
import logging
import time
import functools
from spotify import Link, SpotifyError
from mopidy import audio
from mopidy.backends import base
from mopidy.core import PlaybackState
logger = logging.getLogger('mopidy.backends.spotify')
def need_data_callback(spotify_backend, length_hint):
spotify_backend.playback.on_need_data(length_hint)
def enough_data_callback(spotify_backend):
spotify_backend.playback.on_enough_data()
def seek_data_callback(spotify_backend, time_position):
spotify_backend.playback.on_seek_data(time_position)
class SpotifyPlaybackProvider(base.BasePlaybackProvider):
# These GStreamer caps matches the audio data provided by libspotify
_caps = (
'audio/x-raw-int, endianness=(int)1234, channels=(int)2, '
'width=(int)16, depth=(int)16, signed=(boolean)true, '
'rate=(int)44100')
def __init__(self, *args, **kwargs):
super(SpotifyPlaybackProvider, self).__init__(*args, **kwargs)
self._timer = TrackPositionTimer()
def pause(self):
self._timer.pause()
return super(SpotifyPlaybackProvider, self).pause()
self._first_seek = False
def play(self, track):
if track.uri is None:
return False
spotify_backend = self.backend.actor_ref.proxy()
need_data_callback_bound = functools.partial(
need_data_callback, spotify_backend)
enough_data_callback_bound = functools.partial(
enough_data_callback, spotify_backend)
seek_data_callback_bound = functools.partial(
seek_data_callback, spotify_backend)
self._first_seek = True
try:
self.backend.spotify.session.load(
Link.from_string(track.uri).as_track())
self.backend.spotify.session.play(1)
self.backend.spotify.buffer_timestamp = 0
self.audio.prepare_change()
self.audio.set_uri('appsrc://')
self.audio.set_appsrc(
self._caps,
need_data=need_data_callback_bound,
enough_data=enough_data_callback_bound,
seek_data=seek_data_callback_bound)
self.audio.start_playback()
self.audio.set_metadata(track)
self._timer.play()
return True
except SpotifyError as e:
logger.info('Playback of %s failed: %s', track.uri, e)
return False
def resume(self):
time_position = self.get_time_position()
self._timer.resume()
self.audio.prepare_change()
result = self.seek(time_position)
self.audio.start_playback()
return result
def seek(self, time_position):
self.backend.spotify.session.seek(time_position)
self._timer.seek(time_position)
return True
def stop(self):
self.backend.spotify.session.play(0)
return super(SpotifyPlaybackProvider, self).stop()
def get_time_position(self):
# XXX: The default implementation of get_time_position hangs/times out
# when used with the Spotify backend and GStreamer appsrc. If this can
# be resolved, we no longer need to use a wall clock based time
# position for Spotify playback.
return self._timer.get_time_position()
def on_need_data(self, length_hint):
logger.debug('playback.on_need_data(%d) called', length_hint)
self.backend.spotify.push_audio_data = True
def on_enough_data(self):
logger.debug('playback.on_enough_data() called')
self.backend.spotify.push_audio_data = False
class TrackPositionTimer(object):
"""
Keeps track of time position in a track using the wall clock and playback
events.
def on_seek_data(self, time_position):
logger.debug('playback.on_seek_data(%d) called', time_position)
To not introduce a reverse dependency on the playback controller, this
class keeps track of playback state itself.
"""
if time_position == 0 and self._first_seek:
self._first_seek = False
logger.debug('Skipping seek due to issue #300')
return
def __init__(self):
self._state = PlaybackState.STOPPED
self._accumulated = 0
self._started = 0
def play(self):
self._state = PlaybackState.PLAYING
self._accumulated = 0
self._started = self._wall_time()
def pause(self):
self._state = PlaybackState.PAUSED
self._accumulated += self._wall_time() - self._started
def resume(self):
self._state = PlaybackState.PLAYING
def seek(self, time_position):
self._started = self._wall_time()
self._accumulated = time_position
def get_time_position(self):
if self._state == PlaybackState.PLAYING:
time_since_started = self._wall_time() - self._started
return self._accumulated + time_since_started
elif self._state == PlaybackState.PAUSED:
return self._accumulated
elif self._state == PlaybackState.STOPPED:
return 0
def _wall_time(self):
return int(time.time() * 1000)
self.backend.spotify.buffer_timestamp = audio.millisecond_to_clocktime(
time_position)
self.backend.spotify.session.seek(time_position)

View File

@ -1,9 +1,5 @@
from __future__ import unicode_literals
import pygst
pygst.require('0.10')
import gst
import logging
import os
import threading
@ -46,6 +42,8 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager):
self.backend_ref = backend_ref
self.connected = threading.Event()
self.push_audio_data = True
self.buffer_timestamp = 0
self.container_manager = None
self.playlist_manager = None
@ -107,6 +105,10 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager):
"""Callback used by pyspotify"""
# pylint: disable = R0913
# Too many arguments (8/5)
if not self.push_audio_data:
return 0
assert sample_type == 0, 'Expects 16-bit signed integer samples'
capabilites = """
audio/x-raw-int,
@ -120,8 +122,14 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager):
'sample_rate': sample_rate,
'channels': channels,
}
buffer_ = gst.Buffer(bytes(frames))
buffer_.set_caps(gst.caps_from_string(capabilites))
duration = audio.calculate_duration(num_frames, sample_rate)
buffer_ = audio.create_buffer(bytes(frames),
capabilites=capabilites,
timestamp=self.buffer_timestamp,
duration=duration)
self.buffer_timestamp += duration
if self.audio.emit_data(buffer_).get():
return num_frames

View File

@ -70,13 +70,16 @@ def to_mopidy_playlist(spotify_playlist):
uri = str(Link.from_playlist(spotify_playlist))
if not spotify_playlist.is_loaded():
return Playlist(uri=uri, name='[loading...]')
if not spotify_playlist.name():
name = spotify_playlist.name()
if not name:
# Other user's "starred" playlists isn't handled properly by pyspotify
# See https://github.com/mopidy/pyspotify/issues/81
return
if spotify_playlist.owner().canonical_name() != settings.SPOTIFY_USERNAME:
name += ' by ' + spotify_playlist.owner().canonical_name()
return Playlist(
uri=uri,
name=spotify_playlist.name(),
name=name,
tracks=[
to_mopidy_track(spotify_track)
for spotify_track in spotify_playlist

View File

@ -5,10 +5,9 @@ import urlparse
import pykka
from mopidy import settings
from mopidy.audio import utils
from mopidy import audio as audio_lib, settings
from mopidy.backends import base
from mopidy.models import SearchResult, Track
from mopidy.models import Track
logger = logging.getLogger('mopidy.backends.stream')
@ -21,7 +20,7 @@ class StreamBackend(pykka.ThreadingActor, base.Backend):
self.playback = base.BasePlaybackProvider(audio=audio, backend=self)
self.playlists = None
self.uri_schemes = utils.supported_uri_schemes(
self.uri_schemes = audio_lib.supported_uri_schemes(
settings.STREAM_PROTOCOLS)

View File

@ -1,5 +1,6 @@
from __future__ import unicode_literals
from collections import defaultdict
import urlparse
import pykka
@ -16,31 +17,60 @@ class LibraryController(object):
uri_scheme = urlparse.urlparse(uri).scheme
return self.backends.with_library_by_uri_scheme.get(uri_scheme, None)
def find_exact(self, query=None, **kwargs):
def _get_backends_to_uris(self, uris):
if uris:
backends_to_uris = defaultdict(list)
for uri in uris:
backend = self._get_backend(uri)
if backend is not None:
backends_to_uris[backend].append(uri)
else:
backends_to_uris = dict([
(b, None) for b in self.backends.with_library])
return backends_to_uris
def find_exact(self, query=None, uris=None, **kwargs):
"""
Search the library for tracks where ``field`` is ``values``.
If the query is empty, and the backend can support it, all available
tracks are returned.
If ``uris`` is given, the search is limited to results from within the
URI roots. For example passing ``uris=['file:']`` will limit the search
to the local backend.
Examples::
# Returns results matching 'a'
# Returns results matching 'a' from any backend
find_exact({'any': ['a']})
find_exact(any=['a'])
# Returns results matching artist 'xyz'
# Returns results matching artist 'xyz' from any backend
find_exact({'artist': ['xyz']})
find_exact(artist=['xyz'])
# Returns results matching 'a' and 'b' and artist 'xyz'
# Returns results matching 'a' and 'b' and artist 'xyz' from any
# backend
find_exact({'any': ['a', 'b'], 'artist': ['xyz']})
find_exact(any=['a', 'b'], artist=['xyz'])
# Returns results matching 'a' if within the given URI roots
# "file:///media/music" and "spotify:"
find_exact(
{'any': ['a']}, uris=['file:///media/music', 'spotify:'])
find_exact(any=['a'], uris=['file:///media/music', 'spotify:'])
:param query: one or more queries to search for
:type query: dict
:param uris: zero or more URI roots to limit the search to
:type uris: list of strings or :class:`None`
:rtype: list of :class:`mopidy.models.SearchResult`
"""
query = query or kwargs
futures = [
b.library.find_exact(**query) for b in self.backends.with_library]
backend.library.find_exact(query=query, uris=uris)
for (backend, uris) in self._get_backends_to_uris(uris).items()]
return [result for result in pykka.get_all(futures) if result]
def lookup(self, uri):
@ -76,29 +106,45 @@ class LibraryController(object):
b.library.refresh(uri) for b in self.backends.with_library]
pykka.get_all(futures)
def search(self, query=None, **kwargs):
def search(self, query=None, uris=None, **kwargs):
"""
Search the library for tracks where ``field`` contains ``values``.
If the query is empty, and the backend can support it, all available
tracks are returned.
If ``uris`` is given, the search is limited to results from within the
URI roots. For example passing ``uris=['file:']`` will limit the search
to the local backend.
Examples::
# Returns results matching 'a'
# Returns results matching 'a' in any backend
search({'any': ['a']})
search(any=['a'])
# Returns results matching artist 'xyz'
# Returns results matching artist 'xyz' in any backend
search({'artist': ['xyz']})
search(artist=['xyz'])
# Returns results matching 'a' and 'b' and artist 'xyz'
# Returns results matching 'a' and 'b' and artist 'xyz' in any
# backend
search({'any': ['a', 'b'], 'artist': ['xyz']})
search(any=['a', 'b'], artist=['xyz'])
# Returns results matching 'a' if within the given URI roots
# "file:///media/music" and "spotify:"
search({'any': ['a']}, uris=['file:///media/music', 'spotify:'])
search(any=['a'], uris=['file:///media/music', 'spotify:'])
:param query: one or more queries to search for
:type query: dict
:param uris: zero or more URI roots to limit the search to
:type uris: list of strings or :class:`None`
:rtype: list of :class:`mopidy.models.SearchResult`
"""
query = query or kwargs
futures = [
b.library.search(**query) for b in self.backends.with_library]
backend.library.search(query=query, uris=uris)
for (backend, uris) in self._get_backends_to_uris(uris).items()]
return [result for result in pykka.get_all(futures) if result]

View File

@ -397,13 +397,14 @@ class PlaybackController(object):
self.state = PlaybackState.PLAYING
backend = self._get_backend()
if not backend or not backend.playback.play(tl_track.track).get():
# Track is not playable
logger.warning('Track is not playable: %s', tl_track.track.uri)
if self.random and self._shuffled:
self._shuffled.remove(tl_track)
if on_error_step == 1:
self.next()
elif on_error_step == -1:
self.previous()
return
if self.random and self.current_tl_track in self._shuffled:
self._shuffled.remove(self.current_tl_track)

View File

@ -62,10 +62,13 @@ class TracklistController(object):
Is not reset before Mopidy is restarted.
"""
def add(self, tracks, at_position=None):
def add(self, tracks=None, at_position=None, uri=None):
"""
Add the track or list of tracks to the tracklist.
If ``uri`` is given instead of ``tracks``, the URI is looked up in the
library and the resulting tracks are added to the tracklist.
If ``at_position`` is given, the tracks placed at the given position in
the tracklist. If ``at_position`` is not given, the tracks are appended
to the end of the tracklist.
@ -76,9 +79,18 @@ class TracklistController(object):
:type tracks: list of :class:`mopidy.models.Track`
:param at_position: position in tracklist to add track
:type at_position: int or :class:`None`
:param uri: URI for tracks to add
:type uri: string
:rtype: list of :class:`mopidy.models.TlTrack`
"""
assert tracks is not None or uri is not None, \
'tracks or uri must be provided'
if tracks is None and uri is not None:
tracks = self._core.library.lookup(uri)
tl_tracks = []
for track in tracks:
tl_track = TlTrack(self._next_tlid, track)
self._next_tlid += 1

View File

@ -128,8 +128,8 @@ you quickly started with working on your client instead of figuring out how to
communicate with Mopidy.
Getting the library
-------------------
Getting the library for browser use
-----------------------------------
Regular and minified versions of Mopidy.js, ready for use, is installed
together with Mopidy. When the HTTP frontend is running, the files are
@ -154,9 +154,28 @@ the Git repo at:
- ``mopidy/frontends/http/data/mopidy.js``
- ``mopidy/frontends/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").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.rst`` will guide you on your way.
``js/README.md`` will guide you on your way.
Creating an instance
@ -170,8 +189,8 @@ Once you got Mopidy.js loaded, you need to create an instance of the wrapper:
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, you'll need to pass the URL to the
WebSocket end point:
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

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -20,6 +20,7 @@ class WebSocketResource(object):
self._core = core_proxy
inspector = jsonrpc.JsonRpcInspector(
objects={
'core.get_uri_schemes': core.Core.get_uri_schemes,
'core.library': core.LibraryController,
'core.playback': core.PlaybackController,
'core.playlists': core.PlaylistsController,
@ -28,6 +29,7 @@ class WebSocketResource(object):
self.jsonrpc = jsonrpc.JsonRpcWrapper(
objects={
'core.describe': inspector.describe,
'core.get_uri_schemes': self._core.get_uri_schemes,
'core.library': self._core.library,
'core.playback': self._core.playback,
'core.playlists': self._core.playlists,

View File

@ -22,11 +22,9 @@ def add(context, uri):
"""
if not uri:
return
tracks = context.core.library.lookup(uri).get()
if tracks:
context.core.tracklist.add(tracks)
return
raise MpdNoExistError('directory or file not found', command='add')
tl_tracks = context.core.tracklist.add(uri=uri).get()
if not tl_tracks:
raise MpdNoExistError('directory or file not found', command='add')
@handle_request(r'^addid "(?P<uri>[^"]*)"( "(?P<songpos>\d+)")*$')
@ -52,12 +50,11 @@ def addid(context, uri, songpos=None):
raise MpdNoExistError('No such song', command='addid')
if songpos is not None:
songpos = int(songpos)
tracks = context.core.library.lookup(uri).get()
if not tracks:
raise MpdNoExistError('No such song', command='addid')
if songpos and songpos > context.core.tracklist.length.get():
raise MpdArgError('Bad song index', command='addid')
tl_tracks = context.core.tracklist.add(tracks, at_position=songpos).get()
tl_tracks = context.core.tracklist.add(uri=uri, at_position=songpos).get()
if not tl_tracks:
raise MpdNoExistError('No such song', command='addid')
return ('Id', tl_tracks[0].tlid)

View File

@ -279,9 +279,8 @@ class MprisObject(dbus.service.Object):
return
# NOTE Check if URI has MIME type known to the backend, if MIME support
# is added to the backend.
tracks = self.core.library.lookup(uri).get()
if tracks:
tl_tracks = self.core.tracklist.add(tracks).get()
tl_tracks = self.core.tracklist.add(uri=uri).get()
if tl_tracks:
self.core.playback.play(tl_tracks[0])
else:
logger.debug('Track with URI "%s" not found in library.', uri)
@ -375,6 +374,12 @@ class MprisObject(dbus.service.Object):
artists.sort(key=lambda a: a.name)
metadata['xesam:albumArtist'] = dbus.Array(
[a.name for a in artists if a.name], signature='s')
if track.album and track.album.images:
url = list(track.album.images)[0]
if url:
metadata['mpris:artUrl'] = url
if track.disc_no:
metadata['xesam:discNumber'] = track.disc_no
if track.track_no:
metadata['xesam:trackNumber'] = track.track_no
return dbus.Dictionary(metadata, signature='sv')

View File

@ -203,6 +203,9 @@ class Album(ImmutableObject):
#: The album image URIs. Read-only.
images = frozenset()
# XXX If we want to keep the order of images we shouldn't use frozenset()
# as it doesn't preserve order. I'm deferring this issue until we got
# actual usage of this field with more than one image.
def __init__(self, *args, **kwargs):
# NOTE kwargs dict keys must be bytestrings to work on Python < 2.6.5

View File

@ -153,7 +153,8 @@ class Scanner(object):
self.fakesink.connect('handoff', self.process_handoff)
self.uribin = gst.element_factory_make('uridecodebin')
self.uribin.set_property('caps', gst.Caps(b'audio/x-raw-int'))
self.uribin.set_property('caps',
gst.Caps(b'audio/x-raw-int; audio/x-raw-float'))
self.uribin.connect('pad-added', self.process_new_pad)
self.pipe = gst.element_factory_make('pipeline')

View File

@ -20,7 +20,7 @@ from __future__ import unicode_literals
#: BACKENDS = (
#: u'mopidy.backends.local.LocalBackend',
#: u'mopidy.backends.spotify.SpotifyBackend',
#: u'mopidy.backends.spotify.StreamBackend',
#: u'mopidy.backends.stream.StreamBackend',
#: )
BACKENDS = (
'mopidy.backends.local.LocalBackend',
@ -49,14 +49,6 @@ DEBUG_LOG_FORMAT = '%(levelname)-8s %(asctime)s' + \
#: DEBUG_LOG_FILENAME = u'mopidy.log'
DEBUG_LOG_FILENAME = 'mopidy.log'
#: If we should start a background thread that dumps thread's traceback when we
#: get a SIGUSR1. Mainly a debug tool for figuring out deadlocks.
#:
#: Default::
#:
#: DEBUG_THREAD = False
DEBUG_THREAD = False
#: Location of the Mopidy .desktop file.
#:
#: Used by :mod:`mopidy.frontends.mpris`.

View File

@ -181,7 +181,7 @@ class JsonRpcWrapper(object):
data='"params", if given, must be an array or an object')
def _get_method(self, method_path):
if inspect.isroutine(self.objects.get(method_path, None)):
if callable(self.objects.get(method_path, None)):
# The mounted object is the callable
return self.objects[method_path]

View File

@ -2,10 +2,8 @@ from __future__ import unicode_literals
import logging
import signal
import sys
import thread
import threading
import traceback
from pykka import ActorDeadError
from pykka.registry import ActorRegistry
@ -79,29 +77,3 @@ class BaseThread(threading.Thread):
def run_inside_try(self):
raise NotImplementedError
class DebugThread(threading.Thread):
daemon = True
name = 'DebugThread'
event = threading.Event()
def handler(self, signum, frame):
logger.info('Got %s signal', SIGNALS[signum])
self.event.set()
def run(self):
while True:
self.event.wait()
threads = dict((t.ident, t.name) for t in threading.enumerate())
for ident, frame in sys._current_frames().items():
if self.ident != ident:
stack = ''.join(traceback.format_stack(frame))
logger.debug(
'Current state of %s (%s):\n%s',
threads.get(ident, '?'), ident, stack)
del frame
self.event.clear()

View File

@ -142,7 +142,13 @@ def validate_settings(defaults, settings):
'SPOTIFY_LIB_CACHE': 'SPOTIFY_CACHE_PATH',
}
list_of_one_or_more = [
must_be_iterable = [
'BACKENDS',
'FRONTENDS',
'STREAM_PROTOCOLS',
]
must_have_value_set = [
'BACKENDS',
'FRONTENDS',
]
@ -171,13 +177,13 @@ def validate_settings(defaults, settings):
'Deprecated setting, please set the value via the GStreamer '
'bin in OUTPUT.')
elif setting in list_of_one_or_more:
if not hasattr(value, '__iter__'):
errors[setting] = (
'Must be a tuple. '
"Remember the comma after single values: (u'value',)")
if not value:
errors[setting] = 'Must contain at least one value.'
elif setting in must_be_iterable and not hasattr(value, '__iter__'):
errors[setting] = (
'Must be a tuple. '
"Remember the comma after single values: (u'value',)")
elif setting in must_have_value_set and not value:
errors[setting] = 'Must be set.'
elif setting not in defaults and not setting.startswith('CUSTOM_'):
errors[setting] = 'Unknown setting.'

View File

@ -1,2 +1,2 @@
Pykka >= 1.0
Pykka >= 1.1
# Available as python-pykka from apt.mopidy.com

View File

@ -10,7 +10,7 @@ from mopidy.backends import listener
@mock.patch.object(listener.BackendListener, 'send')
class BackendEventsTest(object):
def setUp(self):
self.audio = mock.Mock(spec=audio.Audio)
self.audio = audio.DummyAudio.start().proxy()
self.backend = self.backend_class.start(audio=self.audio).proxy()
self.core = core.Core.start(backends=[self.backend]).proxy()

View File

@ -4,6 +4,8 @@ import mock
import random
import time
import pykka
from mopidy import audio, core
from mopidy.core import PlaybackState
from mopidy.models import Track
@ -18,7 +20,7 @@ class PlaybackControllerTest(object):
tracks = []
def setUp(self):
self.audio = mock.Mock(spec=audio.Audio)
self.audio = audio.DummyAudio.start().proxy()
self.backend = self.backend_class.start(audio=self.audio).proxy()
self.core = core.Core(backends=[self.backend])
self.playback = self.core.playback
@ -29,6 +31,9 @@ class PlaybackControllerTest(object):
assert self.tracks[0].length >= 2000, \
'First song needs to be at least 2000 miliseconds'
def tearDown(self):
pykka.ActorRegistry.stop_all()
def test_initial_state_is_stopped(self):
self.assertEqual(self.playback.state, PlaybackState.STOPPED)

View File

@ -4,7 +4,6 @@ import os
import shutil
import tempfile
import mock
import pykka
from mopidy import audio, core, settings
@ -19,7 +18,7 @@ class PlaylistsControllerTest(object):
settings.LOCAL_TAG_CACHE_FILE = path_to_data_dir('library_tag_cache')
settings.LOCAL_MUSIC_PATH = path_to_data_dir('')
self.audio = mock.Mock(spec=audio.Audio)
self.audio = audio.DummyAudio.start().proxy()
self.backend = self.backend_class.start(audio=self.audio).proxy()
self.core = core.Core(backends=[self.backend])

View File

@ -1,6 +1,5 @@
from __future__ import unicode_literals
import mock
import random
import pykka
@ -16,9 +15,9 @@ class TracklistControllerTest(object):
tracks = []
def setUp(self):
self.audio = mock.Mock(spec=audio.Audio)
self.audio = audio.DummyAudio.start().proxy()
self.backend = self.backend_class.start(audio=self.audio).proxy()
self.core = core.Core(audio=audio, backends=[self.backend])
self.core = core.Core(audio=self.audio, backends=[self.backend])
self.controller = self.core.tracklist
self.playback = self.core.playback

View File

@ -87,8 +87,27 @@ class CoreLibraryTest(unittest.TestCase):
self.assertIn(result1, result)
self.assertIn(result2, result)
self.library1.find_exact.assert_called_once_with(any=['a'])
self.library2.find_exact.assert_called_once_with(any=['a'])
self.library1.find_exact.assert_called_once_with(
query=dict(any=['a']), uris=None)
self.library2.find_exact.assert_called_once_with(
query=dict(any=['a']), uris=None)
def test_find_exact_with_uris_selects_dummy1_backend(self):
self.core.library.find_exact(
any=['a'], uris=['dummy1:', 'dummy1:foo', 'dummy3:'])
self.library1.find_exact.assert_called_once_with(
query=dict(any=['a']), uris=['dummy1:', 'dummy1:foo'])
self.assertFalse(self.library2.find_exact.called)
def test_find_exact_with_uris_selects_both_backends(self):
self.core.library.find_exact(
any=['a'], uris=['dummy1:', 'dummy1:foo', 'dummy2:'])
self.library1.find_exact.assert_called_once_with(
query=dict(any=['a']), uris=['dummy1:', 'dummy1:foo'])
self.library2.find_exact.assert_called_once_with(
query=dict(any=['a']), uris=['dummy2:'])
def test_find_exact_filters_out_none(self):
track1 = Track(uri='dummy1:a')
@ -103,8 +122,10 @@ class CoreLibraryTest(unittest.TestCase):
self.assertIn(result1, result)
self.assertNotIn(None, result)
self.library1.find_exact.assert_called_once_with(any=['a'])
self.library2.find_exact.assert_called_once_with(any=['a'])
self.library1.find_exact.assert_called_once_with(
query=dict(any=['a']), uris=None)
self.library2.find_exact.assert_called_once_with(
query=dict(any=['a']), uris=None)
def test_find_accepts_query_dict_instead_of_kwargs(self):
track1 = Track(uri='dummy1:a')
@ -121,8 +142,10 @@ class CoreLibraryTest(unittest.TestCase):
self.assertIn(result1, result)
self.assertIn(result2, result)
self.library1.find_exact.assert_called_once_with(any=['a'])
self.library2.find_exact.assert_called_once_with(any=['a'])
self.library1.find_exact.assert_called_once_with(
query=dict(any=['a']), uris=None)
self.library2.find_exact.assert_called_once_with(
query=dict(any=['a']), uris=None)
def test_search_combines_results_from_all_backends(self):
track1 = Track(uri='dummy1:a')
@ -139,8 +162,27 @@ class CoreLibraryTest(unittest.TestCase):
self.assertIn(result1, result)
self.assertIn(result2, result)
self.library1.search.assert_called_once_with(any=['a'])
self.library2.search.assert_called_once_with(any=['a'])
self.library1.search.assert_called_once_with(
query=dict(any=['a']), uris=None)
self.library2.search.assert_called_once_with(
query=dict(any=['a']), uris=None)
def test_search_with_uris_selects_dummy1_backend(self):
self.core.library.search(
query=dict(any=['a']), uris=['dummy1:', 'dummy1:foo', 'dummy3:'])
self.library1.search.assert_called_once_with(
query=dict(any=['a']), uris=['dummy1:', 'dummy1:foo'])
self.assertFalse(self.library2.search.called)
def test_search_with_uris_selects_both_backends(self):
self.core.library.search(
query=dict(any=['a']), uris=['dummy1:', 'dummy1:foo', 'dummy2:'])
self.library1.search.assert_called_once_with(
query=dict(any=['a']), uris=['dummy1:', 'dummy1:foo'])
self.library2.search.assert_called_once_with(
query=dict(any=['a']), uris=['dummy2:'])
def test_search_filters_out_none(self):
track1 = Track(uri='dummy1:a')
@ -155,8 +197,10 @@ class CoreLibraryTest(unittest.TestCase):
self.assertIn(result1, result)
self.assertNotIn(None, result)
self.library1.search.assert_called_once_with(any=['a'])
self.library2.search.assert_called_once_with(any=['a'])
self.library1.search.assert_called_once_with(
query=dict(any=['a']), uris=None)
self.library2.search.assert_called_once_with(
query=dict(any=['a']), uris=None)
def test_search_accepts_query_dict_instead_of_kwargs(self):
track1 = Track(uri='dummy1:a')
@ -173,5 +217,7 @@ class CoreLibraryTest(unittest.TestCase):
self.assertIn(result1, result)
self.assertIn(result2, result)
self.library1.search.assert_called_once_with(any=['a'])
self.library2.search.assert_called_once_with(any=['a'])
self.library1.search.assert_called_once_with(
query=dict(any=['a']), uris=None)
self.library2.search.assert_called_once_with(
query=dict(any=['a']), uris=None)

View File

@ -1,5 +1,8 @@
from __future__ import unicode_literals
import mock
from mopidy.backends import base
from mopidy.core import Core
from mopidy.models import Track
@ -9,13 +12,31 @@ from tests import unittest
class TracklistTest(unittest.TestCase):
def setUp(self):
self.tracks = [
Track(uri='a', name='foo'),
Track(uri='b', name='foo'),
Track(uri='c', name='bar')
Track(uri='dummy1:a', name='foo'),
Track(uri='dummy1:b', name='foo'),
Track(uri='dummy1:c', name='bar'),
]
self.core = Core(audio=None, backends=[])
self.backend = mock.Mock()
self.backend.uri_schemes.get.return_value = ['dummy1']
self.library = mock.Mock(spec=base.BaseLibraryProvider)
self.backend.library = self.library
self.core = Core(audio=None, backends=[self.backend])
self.tl_tracks = self.core.tracklist.add(self.tracks)
def test_add_by_uri_looks_up_uri_in_library(self):
track = Track(uri='dummy1:x', name='x')
self.library.lookup().get.return_value = [track]
self.library.lookup.reset_mock()
tl_tracks = self.core.tracklist.add(uri='dummy1:x')
self.library.lookup.assert_called_once_with('dummy1:x')
self.assertEqual(1, len(tl_tracks))
self.assertEqual(track, tl_tracks[0].track)
self.assertEqual(tl_tracks, self.core.tracklist.tl_tracks[-1:])
def test_remove_removes_tl_tracks_matching_query(self):
tl_tracks = self.core.tracklist.remove(name='foo')

Binary file not shown.

View File

@ -200,6 +200,37 @@ class PlayerInterfaceTest(unittest.TestCase):
self.assertIn('xesam:albumArtist', result.keys())
self.assertEqual(result['xesam:albumArtist'], ['a', 'b'])
def test_get_metadata_use_first_album_image_as_art_url(self):
# XXX Currently, the album image order isn't preserved because they
# are stored as a frozenset(). We pick the first in the set, which is
# sorted alphabetically, thus we get 'bar.jpg', not 'foo.jpg', which
# would probably make more sense.
self.core.tracklist.add([Track(album=Album(images=[
'http://example.com/foo.jpg', 'http://example.com/bar.jpg']))])
self.core.playback.play()
result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata')
self.assertIn('mpris:artUrl', result.keys())
self.assertEqual(result['mpris:artUrl'], 'http://example.com/bar.jpg')
def test_get_metadata_has_no_art_url_if_no_album(self):
self.core.tracklist.add([Track()])
self.core.playback.play()
result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata')
self.assertNotIn('mpris:artUrl', result.keys())
def test_get_metadata_has_no_art_url_if_no_album_images(self):
self.core.tracklist.add([Track(Album(images=[]))])
self.core.playback.play()
result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata')
self.assertNotIn('mpris:artUrl', result.keys())
def test_get_metadata_has_disc_number_in_album(self):
self.core.tracklist.add([Track(disc_no=2)])
self.core.playback.play()
result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata')
self.assertIn('xesam:discNumber', result.keys())
self.assertEqual(result['xesam:discNumber'], 2)
def test_get_metadata_has_track_number_in_album(self):
self.core.tracklist.add([Track(track_no=7)])
self.core.playback.play()

View File

@ -172,22 +172,29 @@ class ScannerTest(unittest.TestCase):
self.check(
'scanner/simple/song1.mp3', 'uri',
'file://%s' % path_to_data_dir('scanner/simple/song1.mp3'))
self.check(
'scanner/simple/song1.ogg', 'uri',
'file://%s' % path_to_data_dir('scanner/simple/song1.ogg'))
def test_duration_is_set(self):
self.scan('scanner/simple')
self.check('scanner/simple/song1.mp3', 'duration', 4680)
self.check('scanner/simple/song1.ogg', 'duration', 4680)
def test_artist_is_set(self):
self.scan('scanner/simple')
self.check('scanner/simple/song1.mp3', 'artist', 'name')
self.check('scanner/simple/song1.ogg', 'artist', 'name')
def test_album_is_set(self):
self.scan('scanner/simple')
self.check('scanner/simple/song1.mp3', 'album', 'albumname')
self.check('scanner/simple/song1.ogg', 'album', 'albumname')
def test_track_is_set(self):
self.scan('scanner/simple')
self.check('scanner/simple/song1.mp3', 'title', 'trackname')
self.check('scanner/simple/song1.ogg', 'title', 'trackname')
def test_nonexistant_folder_does_not_fail(self):
self.scan('scanner/does-not-exist')

View File

@ -48,6 +48,7 @@ class JsonRpcTestBase(unittest.TestCase):
'core': self.core,
'core.playback': self.core.playback,
'core.tracklist': self.core.tracklist,
'get_uri_schemes': self.core.get_uri_schemes,
},
encoders=[models.ModelJSONEncoder],
decoders=[models.model_json_decoder])
@ -188,6 +189,23 @@ class JsonRpcSingleCommandTest(JsonRpcTestBase):
self.assertEqual(response['result'], None)
def test_call_method_which_is_a_directly_mounted_actor_member(self):
# 'get_uri_schemes' isn't a regular callable, but a Pykka
# CallableProxy. This test checks that CallableProxy objects are
# threated by JsonRpcWrapper like any other callable.
request = {
'jsonrpc': '2.0',
'method': 'get_uri_schemes',
'id': 1,
}
response = self.jrw.handle_data(request)
self.assertEqual(response['jsonrpc'], '2.0')
self.assertEqual(response['id'], 1)
self.assertNotIn('error', response)
self.assertEqual(response['result'], ['dummy'])
def test_call_method_with_positional_params(self):
request = {
'jsonrpc': '2.0',
@ -588,6 +606,7 @@ class JsonRpcInspectorTest(JsonRpcTestBase):
def test_inspector_can_describe_a_bunch_of_large_classes(self):
inspector = jsonrpc.JsonRpcInspector({
'core.get_uri_schemes': core.Core.get_uri_schemes,
'core.library': core.LibraryController,
'core.playback': core.PlaybackController,
'core.playlists': core.PlaylistsController,
@ -596,6 +615,9 @@ class JsonRpcInspectorTest(JsonRpcTestBase):
methods = inspector.describe()
self.assertIn('core.get_uri_schemes', methods)
self.assertEquals(len(methods['core.get_uri_schemes']['params']), 0)
self.assertIn('core.library.lookup', methods.keys())
self.assertEquals(
methods['core.library.lookup']['params'][0]['name'], 'uri')

View File

@ -79,13 +79,13 @@ class ValidateSettingsTest(unittest.TestCase):
result = setting_utils.validate_settings(
self.defaults, {'FRONTENDS': []})
self.assertEqual(
result['FRONTENDS'], 'Must contain at least one value.')
result['FRONTENDS'], 'Must be set.')
def test_empty_backends_list_returns_error(self):
result = setting_utils.validate_settings(
self.defaults, {'BACKENDS': []})
self.assertEqual(
result['BACKENDS'], 'Must contain at least one value.')
result['BACKENDS'], 'Must be set.')
def test_noniterable_multivalue_setting_returns_error(self):
result = setting_utils.validate_settings(

View File

@ -35,5 +35,6 @@ class VersionTest(unittest.TestCase):
self.assertLess(SV('0.9.0'), SV('0.10.0'))
self.assertLess(SV('0.10.0'), SV('0.11.0'))
self.assertLess(SV('0.11.0'), SV('0.11.1'))
self.assertLess(SV('0.11.1'), SV(__version__))
self.assertLess(SV(__version__), SV('0.12.1'))
self.assertLess(SV('0.11.1'), SV('0.12.0'))
self.assertLess(SV('0.12.0'), SV(__version__))
self.assertLess(SV(__version__), SV('0.13.1'))