Release v0.10.0
This commit is contained in:
commit
f94716d3b7
1
.gitignore
vendored
1
.gitignore
vendored
@ -11,4 +11,5 @@ coverage.xml
|
|||||||
dist/
|
dist/
|
||||||
docs/_build/
|
docs/_build/
|
||||||
mopidy.log*
|
mopidy.log*
|
||||||
|
node_modules/
|
||||||
nosetests.xml
|
nosetests.xml
|
||||||
|
|||||||
@ -5,6 +5,7 @@ install:
|
|||||||
- "sudo wget -q -O /etc/apt/sources.list.d/mopidy.list http://apt.mopidy.com/mopidy.list"
|
- "sudo wget -q -O /etc/apt/sources.list.d/mopidy.list http://apt.mopidy.com/mopidy.list"
|
||||||
- "sudo apt-get update || true"
|
- "sudo apt-get update || true"
|
||||||
- "sudo apt-get install $(apt-cache depends mopidy | awk '$2 !~ /mopidy/ {print $2}')"
|
- "sudo apt-get install $(apt-cache depends mopidy | awk '$2 !~ /mopidy/ {print $2}')"
|
||||||
|
- "pip install -r requirements/http.txt" # Until ws4py is packaged as a .deb
|
||||||
|
|
||||||
before_script:
|
before_script:
|
||||||
- "rm $VIRTUAL_ENV/lib/python$TRAVIS_PYTHON_VERSION/no-global-site-packages.txt"
|
- "rm $VIRTUAL_ENV/lib/python$TRAVIS_PYTHON_VERSION/no-global-site-packages.txt"
|
||||||
|
|||||||
@ -7,6 +7,7 @@ include mopidy/backends/spotify/spotify_appkey.key
|
|||||||
include pylintrc
|
include pylintrc
|
||||||
recursive-include docs *
|
recursive-include docs *
|
||||||
prune docs/_build
|
prune docs/_build
|
||||||
|
recursive-include mopidy/frontends/http/data/
|
||||||
recursive-include requirements *
|
recursive-include requirements *
|
||||||
recursive-include tests *.py
|
recursive-include tests *.py
|
||||||
recursive-include tests/data *
|
recursive-include tests/data *
|
||||||
|
|||||||
@ -4,6 +4,57 @@ Changes
|
|||||||
|
|
||||||
This change log is used to track all major changes to Mopidy.
|
This change log is used to track all major changes to Mopidy.
|
||||||
|
|
||||||
|
v0.10.0 (2012-12-12)
|
||||||
|
====================
|
||||||
|
|
||||||
|
We've added an HTTP frontend for those wanting to build web clients for Mopidy!
|
||||||
|
|
||||||
|
**Dependencies**
|
||||||
|
|
||||||
|
- pyspotify >= 1.9, < 1.11 is now required for Spotify support. In other words,
|
||||||
|
you're free to upgrade to pyspotify 1.10, but it isn't a requirement.
|
||||||
|
|
||||||
|
**Documentation**
|
||||||
|
|
||||||
|
- Added installation instructions for Fedora.
|
||||||
|
|
||||||
|
**Spotify backend**
|
||||||
|
|
||||||
|
- Save a lot of memory by reusing artist, album, and track models.
|
||||||
|
|
||||||
|
- Make sure the playlist loading hack only runs once.
|
||||||
|
|
||||||
|
**Local backend**
|
||||||
|
|
||||||
|
- Change log level from error to warning on messages emitted when the tag cache
|
||||||
|
isn't found and a couple of similar cases.
|
||||||
|
|
||||||
|
- Make ``mopidy-scan`` ignore invalid dates, e.g. dates in years outside the
|
||||||
|
range 1-9999.
|
||||||
|
|
||||||
|
- Make ``mopidy-scan`` accept :option:`-q`/:option:`--quiet` and
|
||||||
|
:option:`-v`/:option:`--verbose` options to control the amount of logging
|
||||||
|
output when scanning.
|
||||||
|
|
||||||
|
- The scanner can now handle files with other encodings than UTF-8. Rebuild
|
||||||
|
your tag cache with ``mopidy-scan`` to include tracks that may have been
|
||||||
|
ignored previously.
|
||||||
|
|
||||||
|
**HTTP frontend**
|
||||||
|
|
||||||
|
- Added new optional HTTP frontend which exposes Mopidy's core API through
|
||||||
|
JSON-RPC 2.0 messages over a WebSocket. See :ref:`http-frontend` for further
|
||||||
|
details.
|
||||||
|
|
||||||
|
- Added a JavaScript library, Mopidy.js, to make it easier to develop web based
|
||||||
|
Mopidy clients using the new HTTP frontend.
|
||||||
|
|
||||||
|
**Bug fixes**
|
||||||
|
|
||||||
|
- :issue:`256`: Fix crash caused by non-ASCII characters in paths returned from
|
||||||
|
``glib``. The bug can be worked around by overriding the settings that
|
||||||
|
includes offending ``$XDG_`` variables.
|
||||||
|
|
||||||
|
|
||||||
v0.9.0 (2012-11-21)
|
v0.9.0 (2012-11-21)
|
||||||
===================
|
===================
|
||||||
|
|||||||
14
docs/clients/http.rst
Normal file
14
docs/clients/http.rst
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
.. _http-clients:
|
||||||
|
|
||||||
|
************
|
||||||
|
HTTP clients
|
||||||
|
************
|
||||||
|
|
||||||
|
Mopidy added an :ref:`http-frontend` in 0.10 which provides the building blocks
|
||||||
|
needed for creating web clients for Mopidy with the help of a WebSocket and a
|
||||||
|
JavaScript library provided by Mopidy.
|
||||||
|
|
||||||
|
This page will list any HTTP/web Mopidy clients. If you've created one, please
|
||||||
|
notify us so we can include your client on this page.
|
||||||
|
|
||||||
|
See :ref:`http-frontend` for details on how to build your own web client.
|
||||||
@ -39,6 +39,7 @@ class Mock(object):
|
|||||||
|
|
||||||
|
|
||||||
MOCK_MODULES = [
|
MOCK_MODULES = [
|
||||||
|
'cherrypy',
|
||||||
'dbus',
|
'dbus',
|
||||||
'dbus.mainloop',
|
'dbus.mainloop',
|
||||||
'dbus.mainloop.glib',
|
'dbus.mainloop.glib',
|
||||||
@ -53,6 +54,11 @@ MOCK_MODULES = [
|
|||||||
'pykka.registry',
|
'pykka.registry',
|
||||||
'pylast',
|
'pylast',
|
||||||
'serial',
|
'serial',
|
||||||
|
'ws4py',
|
||||||
|
'ws4py.messaging',
|
||||||
|
'ws4py.server',
|
||||||
|
'ws4py.server.cherrypyserver',
|
||||||
|
'ws4py.websocket',
|
||||||
]
|
]
|
||||||
for mod_name in MOCK_MODULES:
|
for mod_name in MOCK_MODULES:
|
||||||
sys.modules[mod_name] = Mock()
|
sys.modules[mod_name] = Mock()
|
||||||
|
|||||||
@ -167,12 +167,19 @@ can install Mopidy from PyPI using Pip.
|
|||||||
|
|
||||||
sudo pacman -S base-devel python2-pip
|
sudo pacman -S base-devel python2-pip
|
||||||
|
|
||||||
|
And on Fedora Linux from the official repositories::
|
||||||
|
|
||||||
|
sudo yum install -y gcc python-devel python-pip
|
||||||
|
|
||||||
#. Then you'll need to install all of Mopidy's hard dependencies:
|
#. Then you'll need to install all of Mopidy's hard dependencies:
|
||||||
|
|
||||||
- Pykka >= 1.0::
|
- Pykka >= 1.0::
|
||||||
|
|
||||||
sudo pip install -U pykka
|
sudo pip install -U pykka
|
||||||
|
|
||||||
|
# On Fedora the binary is called pip-python:
|
||||||
|
sudo pip-python install -U pykka
|
||||||
|
|
||||||
- GStreamer 0.10.x, with Python bindings. GStreamer is packaged for most
|
- GStreamer 0.10.x, with Python bindings. GStreamer is packaged for most
|
||||||
popular Linux distributions. Search for GStreamer in your package manager,
|
popular Linux distributions. Search for GStreamer in your package manager,
|
||||||
and make sure to install the Python bindings, and the "good" and "ugly"
|
and make sure to install the Python bindings, and the "good" and "ugly"
|
||||||
@ -189,6 +196,11 @@ can install Mopidy from PyPI using Pip.
|
|||||||
sudo pacman -S gstreamer0.10-python gstreamer0.10-good-plugins \
|
sudo pacman -S gstreamer0.10-python gstreamer0.10-good-plugins \
|
||||||
gstreamer0.10-ugly-plugins
|
gstreamer0.10-ugly-plugins
|
||||||
|
|
||||||
|
If you use Fedora you can install GStreamer like this::
|
||||||
|
|
||||||
|
sudo yum install -y python-gst0.10 gstreamer0.10-plugins-good \
|
||||||
|
gstreamer0.10-plugins-ugly gstreamer0.10-tools
|
||||||
|
|
||||||
#. Optional: If you want Spotify support in Mopidy, you'll need to install
|
#. Optional: If you want Spotify support in Mopidy, you'll need to install
|
||||||
libspotify and the Python bindings, pyspotify.
|
libspotify and the Python bindings, pyspotify.
|
||||||
|
|
||||||
@ -212,15 +224,27 @@ can install Mopidy from PyPI using Pip.
|
|||||||
Remember to adjust the above example for the latest libspotify version
|
Remember to adjust the above example for the latest libspotify version
|
||||||
supported by pyspotify, your OS, and your CPU architecture.
|
supported by pyspotify, your OS, and your CPU architecture.
|
||||||
|
|
||||||
|
#. If you're on Fedora, you must add a configuration file so libspotify.so
|
||||||
|
can be found:
|
||||||
|
|
||||||
|
su -c 'echo "/usr/local/lib" > /etc/ld.so.conf.d/libspotify.conf'
|
||||||
|
sudo ldconfig
|
||||||
|
|
||||||
#. Then get, build, and install the latest release of pyspotify using Pip::
|
#. Then get, build, and install the latest release of pyspotify using Pip::
|
||||||
|
|
||||||
sudo pip install -U pyspotify
|
sudo pip install -U pyspotify
|
||||||
|
|
||||||
|
# Fedora:
|
||||||
|
sudo pip-python install -U pyspotify
|
||||||
|
|
||||||
#. Optional: If you want to scrobble your played tracks to Last.fm, you need
|
#. Optional: If you want to scrobble your played tracks to Last.fm, you need
|
||||||
pylast::
|
pylast::
|
||||||
|
|
||||||
sudo pip install -U pylast
|
sudo pip install -U pylast
|
||||||
|
|
||||||
|
# Fedora:
|
||||||
|
sudo pip-python install -U pylast
|
||||||
|
|
||||||
#. Optional: To use MPRIS, e.g. for controlling Mopidy from the Ubuntu Sound
|
#. Optional: To use MPRIS, e.g. for controlling Mopidy from the Ubuntu Sound
|
||||||
Menu or from an UPnP client via Rygel, you need some additional
|
Menu or from an UPnP client via Rygel, you need some additional
|
||||||
dependencies: the Python bindings for libindicate, and the Python bindings
|
dependencies: the Python bindings for libindicate, and the Python bindings
|
||||||
@ -234,6 +258,9 @@ can install Mopidy from PyPI using Pip.
|
|||||||
|
|
||||||
sudo pip install -U mopidy
|
sudo pip install -U mopidy
|
||||||
|
|
||||||
|
# Fedora:
|
||||||
|
sudo pip-python install -U mopidy
|
||||||
|
|
||||||
To upgrade Mopidy to future releases, just rerun this command.
|
To upgrade Mopidy to future releases, just rerun this command.
|
||||||
|
|
||||||
Alternatively, if you want to track Mopidy development closer, you may
|
Alternatively, if you want to track Mopidy development closer, you may
|
||||||
|
|||||||
8
docs/modules/frontends/http.rst
Normal file
8
docs/modules/frontends/http.rst
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
.. _http-frontend:
|
||||||
|
|
||||||
|
*********************************************
|
||||||
|
:mod:`mopidy.frontends.http` -- HTTP frontend
|
||||||
|
*********************************************
|
||||||
|
|
||||||
|
.. automodule:: mopidy.frontends.http
|
||||||
|
:synopsis: HTTP and WebSockets frontend
|
||||||
75
js/README.rst
Normal file
75
js/README.rst
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
*********
|
||||||
|
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. Assuming you install from PPA, setup your ``NODE_PATH`` environment variable
|
||||||
|
to include ``/usr/lib/node_modules``. Add the following to your
|
||||||
|
``~/.bashrc`` or equivalent::
|
||||||
|
|
||||||
|
export NODE_PATH=/usr/lib/node_modules:$NODE_PATH
|
||||||
|
|
||||||
|
3. Install `Buster.js <http://busterjs.org/>`_ and `Grunt
|
||||||
|
<http://gruntjs.com/>`_ globally (or locally, and make sure you get their
|
||||||
|
binaries on your ``PATH``)::
|
||||||
|
|
||||||
|
sudo npm -g install buster grunt
|
||||||
|
|
||||||
|
4. Install the grunt-buster Grunt plugin locally, when in the ``js/`` dir::
|
||||||
|
|
||||||
|
cd js/
|
||||||
|
npm install grunt-buster
|
||||||
|
|
||||||
|
5. Install `PhantomJS <http://phantomjs.org/>`_ so that we can run the tests
|
||||||
|
without a browser::
|
||||||
|
|
||||||
|
sudo apt-get install phantomjs
|
||||||
|
|
||||||
|
It is packaged in Ubuntu since 12.04, but I haven't tested with versions
|
||||||
|
older than 1.6 which is the one packaged in Ubuntu 12.10.
|
||||||
|
|
||||||
|
6. Run Grunt to lint, test, concatenate, and minify the source::
|
||||||
|
|
||||||
|
grunt
|
||||||
|
|
||||||
|
The files in ``../mopidy/frontends/http/data/`` should now be up to date.
|
||||||
|
|
||||||
|
|
||||||
|
Development tips
|
||||||
|
================
|
||||||
|
|
||||||
|
If you're coding on the JavaScript library, you should know about ``grunt
|
||||||
|
watch``. It lints and tests the code every time you save a file.
|
||||||
9
js/buster.js
Normal file
9
js/buster.js
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
var config = module.exports;
|
||||||
|
|
||||||
|
config["tests"] = {
|
||||||
|
environment: "browser",
|
||||||
|
libs: ["lib/**/*.js"],
|
||||||
|
sources: ["src/**/*.js"],
|
||||||
|
testHelpers: ["test/**/*-helper.js"],
|
||||||
|
tests: ["test/**/*-test.js"]
|
||||||
|
};
|
||||||
70
js/grunt.js
Normal file
70
js/grunt.js
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
/*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("default", "lint buster concat min");
|
||||||
|
|
||||||
|
grunt.loadNpmTasks("grunt-buster");
|
||||||
|
};
|
||||||
171
js/lib/bane-0.4.0.js
Normal file
171
js/lib/bane-0.4.0.js
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
/**
|
||||||
|
* BANE - Browser globals, AMD and Node Events
|
||||||
|
*
|
||||||
|
* https://github.com/busterjs/bane
|
||||||
|
*
|
||||||
|
* @version 0.4.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
((typeof define === "function" && define.amd && function (m) { define(m); }) ||
|
||||||
|
(typeof module === "object" && function (m) { module.exports = m(); }) ||
|
||||||
|
function (m) { this.bane = m(); }
|
||||||
|
)(function () {
|
||||||
|
"use strict";
|
||||||
|
var slice = Array.prototype.slice;
|
||||||
|
|
||||||
|
function handleError(event, error, errbacks) {
|
||||||
|
var i, l = errbacks.length;
|
||||||
|
if (l > 0) {
|
||||||
|
for (i = 0; i < l; ++i) { errbacks[i](event, error); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTimeout(function () {
|
||||||
|
error.message = event + " listener threw error: " + error.message;
|
||||||
|
throw error;
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertFunction(fn) {
|
||||||
|
if (typeof fn !== "function") {
|
||||||
|
throw new TypeError("Listener is not function");
|
||||||
|
}
|
||||||
|
return fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
function supervisors(object) {
|
||||||
|
if (!object.supervisors) { object.supervisors = []; }
|
||||||
|
return object.supervisors;
|
||||||
|
}
|
||||||
|
|
||||||
|
function listeners(object, event) {
|
||||||
|
if (!object.listeners) { object.listeners = {}; }
|
||||||
|
if (event && !object.listeners[event]) { object.listeners[event] = []; }
|
||||||
|
return event ? object.listeners[event] : object.listeners;
|
||||||
|
}
|
||||||
|
|
||||||
|
function errbacks(object) {
|
||||||
|
if (!object.errbacks) { object.errbacks = []; }
|
||||||
|
return object.errbacks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @signature var emitter = bane.createEmitter([object]);
|
||||||
|
*
|
||||||
|
* Create a new event emitter. If an object is passed, it will be modified
|
||||||
|
* by adding the event emitter methods (see below).
|
||||||
|
*/
|
||||||
|
function createEventEmitter(object) {
|
||||||
|
object = object || {};
|
||||||
|
|
||||||
|
function notifyListener(event, listener, args) {
|
||||||
|
try {
|
||||||
|
listener.listener.apply(listener.thisp || object, args);
|
||||||
|
} catch (e) {
|
||||||
|
handleError(event, e, errbacks(object));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object.on = function (event, listener, thisp) {
|
||||||
|
if (typeof event === "function") {
|
||||||
|
return supervisors(this).push({
|
||||||
|
listener: event,
|
||||||
|
thisp: listener
|
||||||
|
});
|
||||||
|
}
|
||||||
|
listeners(this, event).push({
|
||||||
|
listener: assertFunction(listener),
|
||||||
|
thisp: thisp
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
object.off = function (event, listener) {
|
||||||
|
var fns, events, i, l;
|
||||||
|
if (!event) {
|
||||||
|
fns = supervisors(this);
|
||||||
|
fns.splice(0, fns.length);
|
||||||
|
|
||||||
|
events = listeners(this);
|
||||||
|
for (i in events) {
|
||||||
|
if (events.hasOwnProperty(i)) {
|
||||||
|
fns = listeners(this, i);
|
||||||
|
fns.splice(0, fns.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fns = errbacks(this);
|
||||||
|
fns.splice(0, fns.length);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof event === "function") {
|
||||||
|
fns = supervisors(this);
|
||||||
|
listener = event;
|
||||||
|
} else {
|
||||||
|
fns = listeners(this, event);
|
||||||
|
}
|
||||||
|
if (!listener) {
|
||||||
|
fns.splice(0, fns.length);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (i = 0, l = fns.length; i < l; ++i) {
|
||||||
|
if (fns[i].listener === listener) {
|
||||||
|
fns.splice(i, 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
object.once = function (event, listener, thisp) {
|
||||||
|
var wrapper = function () {
|
||||||
|
object.off(event, wrapper);
|
||||||
|
listener.apply(this, arguments);
|
||||||
|
};
|
||||||
|
|
||||||
|
object.on(event, wrapper, thisp);
|
||||||
|
};
|
||||||
|
|
||||||
|
object.bind = function (object, events) {
|
||||||
|
var prop, i, l;
|
||||||
|
if (!events) {
|
||||||
|
for (prop in object) {
|
||||||
|
if (typeof object[prop] === "function") {
|
||||||
|
this.on(prop, object[prop], object);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (i = 0, l = events.length; i < l; ++i) {
|
||||||
|
if (typeof object[events[i]] === "function") {
|
||||||
|
this.on(events[i], object[events[i]], object);
|
||||||
|
} else {
|
||||||
|
throw new Error("No such method " + events[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return object;
|
||||||
|
};
|
||||||
|
|
||||||
|
object.emit = function (event) {
|
||||||
|
var toNotify = supervisors(this);
|
||||||
|
var args = slice.call(arguments), i, l;
|
||||||
|
|
||||||
|
for (i = 0, l = toNotify.length; i < l; ++i) {
|
||||||
|
notifyListener(event, toNotify[i], args);
|
||||||
|
}
|
||||||
|
|
||||||
|
toNotify = listeners(this, event).slice()
|
||||||
|
args = slice.call(arguments, 1);
|
||||||
|
for (i = 0, l = toNotify.length; i < l; ++i) {
|
||||||
|
notifyListener(event, toNotify[i], args);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
object.errback = function (listener) {
|
||||||
|
if (!this.errbacks) { this.errbacks = []; }
|
||||||
|
this.errbacks.push(assertFunction(listener));
|
||||||
|
};
|
||||||
|
|
||||||
|
return object;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { createEventEmitter: createEventEmitter };
|
||||||
|
});
|
||||||
731
js/lib/when-1.6.1.js
Normal file
731
js/lib/when-1.6.1.js
Normal file
@ -0,0 +1,731 @@
|
|||||||
|
/** @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
|
||||||
|
);
|
||||||
278
js/src/mopidy.js
Normal file
278
js/src/mopidy.js
Normal file
@ -0,0 +1,278 @@
|
|||||||
|
/*global bane:false, when:false*/
|
||||||
|
|
||||||
|
function Mopidy(settings) {
|
||||||
|
this._settings = this._configure(settings || {});
|
||||||
|
this._console = this._getConsole();
|
||||||
|
|
||||||
|
this._backoffDelay = this._settings.backoffDelayMin;
|
||||||
|
this._pendingRequests = {};
|
||||||
|
this._webSocket = null;
|
||||||
|
|
||||||
|
bane.createEventEmitter(this);
|
||||||
|
this._delegateEvents();
|
||||||
|
|
||||||
|
if (this._settings.autoConnect) {
|
||||||
|
this.connect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Mopidy.prototype._configure = function (settings) {
|
||||||
|
settings.webSocketUrl = settings.webSocketUrl ||
|
||||||
|
"ws://" + document.location.host + "/mopidy/ws/";
|
||||||
|
|
||||||
|
if (settings.autoConnect !== false) {
|
||||||
|
settings.autoConnect = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
settings.backoffDelayMin = settings.backoffDelayMin || 1000;
|
||||||
|
settings.backoffDelayMax = settings.backoffDelayMax || 64000;
|
||||||
|
|
||||||
|
return settings;
|
||||||
|
};
|
||||||
|
|
||||||
|
Mopidy.prototype._getConsole = function () {
|
||||||
|
var console = window.console || {};
|
||||||
|
|
||||||
|
console.log = console.log || function () {};
|
||||||
|
console.warn = console.warn || function () {};
|
||||||
|
console.error = console.error || function () {};
|
||||||
|
|
||||||
|
return console;
|
||||||
|
};
|
||||||
|
|
||||||
|
Mopidy.prototype._delegateEvents = function () {
|
||||||
|
// Remove existing event handlers
|
||||||
|
this.off("websocket:close");
|
||||||
|
this.off("websocket:error");
|
||||||
|
this.off("websocket:incomingMessage");
|
||||||
|
this.off("websocket:open");
|
||||||
|
this.off("state:offline");
|
||||||
|
|
||||||
|
// Register basic set of event handlers
|
||||||
|
this.on("websocket:close", this._cleanup);
|
||||||
|
this.on("websocket:error", this._handleWebSocketError);
|
||||||
|
this.on("websocket:incomingMessage", this._handleMessage);
|
||||||
|
this.on("websocket:open", this._resetBackoffDelay);
|
||||||
|
this.on("websocket:open", this._getApiSpec);
|
||||||
|
this.on("state:offline", this._reconnect);
|
||||||
|
};
|
||||||
|
|
||||||
|
Mopidy.prototype.connect = function () {
|
||||||
|
if (this._webSocket) {
|
||||||
|
if (this._webSocket.readyState === WebSocket.OPEN) {
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
this._webSocket.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this._webSocket = this._settings.webSocket ||
|
||||||
|
new WebSocket(this._settings.webSocketUrl);
|
||||||
|
|
||||||
|
this._webSocket.onclose = function (close) {
|
||||||
|
this.emit("websocket:close", close);
|
||||||
|
}.bind(this);
|
||||||
|
|
||||||
|
this._webSocket.onerror = function (error) {
|
||||||
|
this.emit("websocket:error", error);
|
||||||
|
}.bind(this);
|
||||||
|
|
||||||
|
this._webSocket.onopen = function () {
|
||||||
|
this.emit("websocket:open");
|
||||||
|
}.bind(this);
|
||||||
|
|
||||||
|
this._webSocket.onmessage = function (message) {
|
||||||
|
this.emit("websocket:incomingMessage", message);
|
||||||
|
}.bind(this);
|
||||||
|
};
|
||||||
|
|
||||||
|
Mopidy.prototype._cleanup = function (closeEvent) {
|
||||||
|
Object.keys(this._pendingRequests).forEach(function (requestId) {
|
||||||
|
var resolver = this._pendingRequests[requestId];
|
||||||
|
delete this._pendingRequests[requestId];
|
||||||
|
resolver.reject({
|
||||||
|
message: "WebSocket closed",
|
||||||
|
closeEvent: closeEvent
|
||||||
|
});
|
||||||
|
}.bind(this));
|
||||||
|
|
||||||
|
this.emit("state:offline");
|
||||||
|
};
|
||||||
|
|
||||||
|
Mopidy.prototype._reconnect = function () {
|
||||||
|
this.emit("reconnectionPending", {
|
||||||
|
timeToAttempt: this._backoffDelay
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(function () {
|
||||||
|
this.emit("reconnecting");
|
||||||
|
this.connect();
|
||||||
|
}.bind(this), this._backoffDelay);
|
||||||
|
|
||||||
|
this._backoffDelay = this._backoffDelay * 2;
|
||||||
|
if (this._backoffDelay > this._settings.backoffDelayMax) {
|
||||||
|
this._backoffDelay = this._settings.backoffDelayMax;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Mopidy.prototype._resetBackoffDelay = function () {
|
||||||
|
this._backoffDelay = this._settings.backoffDelayMin;
|
||||||
|
};
|
||||||
|
|
||||||
|
Mopidy.prototype.close = function () {
|
||||||
|
this.off("state:offline", this._reconnect);
|
||||||
|
this._webSocket.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
Mopidy.prototype._handleWebSocketError = function (error) {
|
||||||
|
this._console.warn("WebSocket error:", error.stack || error);
|
||||||
|
};
|
||||||
|
|
||||||
|
Mopidy.prototype._send = function (message) {
|
||||||
|
var deferred = when.defer();
|
||||||
|
|
||||||
|
switch (this._webSocket.readyState) {
|
||||||
|
case WebSocket.CONNECTING:
|
||||||
|
deferred.resolver.reject({
|
||||||
|
message: "WebSocket is still connecting"
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case WebSocket.CLOSING:
|
||||||
|
deferred.resolver.reject({
|
||||||
|
message: "WebSocket is closing"
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case WebSocket.CLOSED:
|
||||||
|
deferred.resolver.reject({
|
||||||
|
message: "WebSocket is closed"
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
message.jsonrpc = "2.0";
|
||||||
|
message.id = this._nextRequestId();
|
||||||
|
this._pendingRequests[message.id] = deferred.resolver;
|
||||||
|
this._webSocket.send(JSON.stringify(message));
|
||||||
|
this.emit("websocket:outgoingMessage", message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return deferred.promise;
|
||||||
|
};
|
||||||
|
|
||||||
|
Mopidy.prototype._nextRequestId = (function () {
|
||||||
|
var lastUsed = -1;
|
||||||
|
return function () {
|
||||||
|
lastUsed += 1;
|
||||||
|
return lastUsed;
|
||||||
|
};
|
||||||
|
}());
|
||||||
|
|
||||||
|
Mopidy.prototype._handleMessage = function (message) {
|
||||||
|
try {
|
||||||
|
var data = JSON.parse(message.data);
|
||||||
|
if (data.hasOwnProperty("id")) {
|
||||||
|
this._handleResponse(data);
|
||||||
|
} else if (data.hasOwnProperty("event")) {
|
||||||
|
this._handleEvent(data);
|
||||||
|
} else {
|
||||||
|
this._console.warn(
|
||||||
|
"Unknown message type received. Message was: " +
|
||||||
|
message.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof SyntaxError) {
|
||||||
|
this._console.warn(
|
||||||
|
"WebSocket message parsing failed. Message was: " +
|
||||||
|
message.data);
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Mopidy.prototype._handleResponse = function (responseMessage) {
|
||||||
|
if (!this._pendingRequests.hasOwnProperty(responseMessage.id)) {
|
||||||
|
this._console.warn(
|
||||||
|
"Unexpected response received. Message was:", responseMessage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var resolver = this._pendingRequests[responseMessage.id];
|
||||||
|
delete this._pendingRequests[responseMessage.id];
|
||||||
|
|
||||||
|
if (responseMessage.hasOwnProperty("result")) {
|
||||||
|
resolver.resolve(responseMessage.result);
|
||||||
|
} else if (responseMessage.hasOwnProperty("error")) {
|
||||||
|
resolver.reject(responseMessage.error);
|
||||||
|
this._console.warn("Server returned error:", responseMessage.error);
|
||||||
|
} else {
|
||||||
|
resolver.reject({
|
||||||
|
message: "Response without 'result' or 'error' received",
|
||||||
|
data: {response: responseMessage}
|
||||||
|
});
|
||||||
|
this._console.warn(
|
||||||
|
"Response without 'result' or 'error' received. Message was:",
|
||||||
|
responseMessage);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Mopidy.prototype._handleEvent = function (eventMessage) {
|
||||||
|
var type = eventMessage.event;
|
||||||
|
var data = eventMessage;
|
||||||
|
delete data.event;
|
||||||
|
|
||||||
|
this.emit("event:" + this._snakeToCamel(type), data);
|
||||||
|
};
|
||||||
|
|
||||||
|
Mopidy.prototype._getApiSpec = function () {
|
||||||
|
this._send({method: "core.describe"})
|
||||||
|
.then(this._createApi.bind(this), this._handleWebSocketError)
|
||||||
|
.then(null, this._handleWebSocketError);
|
||||||
|
};
|
||||||
|
|
||||||
|
Mopidy.prototype._createApi = function (methods) {
|
||||||
|
var caller = function (method) {
|
||||||
|
return function () {
|
||||||
|
var params = Array.prototype.slice.call(arguments);
|
||||||
|
return this._send({
|
||||||
|
method: method,
|
||||||
|
params: params
|
||||||
|
});
|
||||||
|
}.bind(this);
|
||||||
|
}.bind(this);
|
||||||
|
|
||||||
|
var getPath = function (fullName) {
|
||||||
|
var path = fullName.split(".");
|
||||||
|
if (path.length >= 1 && path[0] === "core") {
|
||||||
|
path = path.slice(1);
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
};
|
||||||
|
|
||||||
|
var createObjects = function (objPath) {
|
||||||
|
var parentObj = this;
|
||||||
|
objPath.forEach(function (objName) {
|
||||||
|
objName = this._snakeToCamel(objName);
|
||||||
|
parentObj[objName] = parentObj[objName] || {};
|
||||||
|
parentObj = parentObj[objName];
|
||||||
|
}.bind(this));
|
||||||
|
return parentObj;
|
||||||
|
}.bind(this);
|
||||||
|
|
||||||
|
var createMethod = function (fullMethodName) {
|
||||||
|
var methodPath = getPath(fullMethodName);
|
||||||
|
var methodName = this._snakeToCamel(methodPath.slice(-1)[0]);
|
||||||
|
var object = createObjects(methodPath.slice(0, -1));
|
||||||
|
object[methodName] = caller(fullMethodName);
|
||||||
|
object[methodName].description = methods[fullMethodName].description;
|
||||||
|
object[methodName].params = methods[fullMethodName].params;
|
||||||
|
}.bind(this);
|
||||||
|
|
||||||
|
Object.keys(methods).forEach(createMethod);
|
||||||
|
this.emit("state:online");
|
||||||
|
};
|
||||||
|
|
||||||
|
Mopidy.prototype._snakeToCamel = function (name) {
|
||||||
|
return name.replace(/(_[a-z])/g, function (match) {
|
||||||
|
return match.toUpperCase().replace("_", "");
|
||||||
|
});
|
||||||
|
};
|
||||||
29
js/test/bind-helper.js
Normal file
29
js/test/bind-helper.js
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
/*
|
||||||
|
* PhantomJS 1.6 does not support Function.prototype.bind, so we polyfill it.
|
||||||
|
*
|
||||||
|
* Implementation from:
|
||||||
|
* https://developer.mozilla.org/en-US/docs/JavaScript/Reference/Global_Objects/Function/bind
|
||||||
|
*/
|
||||||
|
if (!Function.prototype.bind) {
|
||||||
|
Function.prototype.bind = function (oThis) {
|
||||||
|
if (typeof this !== "function") {
|
||||||
|
// closest thing possible to the ECMAScript 5 internal IsCallable function
|
||||||
|
throw new TypeError("Function.prototype.bind - what is trying to be bound is not callable");
|
||||||
|
}
|
||||||
|
|
||||||
|
var aArgs = Array.prototype.slice.call(arguments, 1),
|
||||||
|
fToBind = this,
|
||||||
|
fNOP = function () {},
|
||||||
|
fBound = function () {
|
||||||
|
return fToBind.apply(this instanceof fNOP && oThis
|
||||||
|
? this
|
||||||
|
: oThis,
|
||||||
|
aArgs.concat(Array.prototype.slice.call(arguments)));
|
||||||
|
};
|
||||||
|
|
||||||
|
fNOP.prototype = this.prototype;
|
||||||
|
fBound.prototype = new fNOP();
|
||||||
|
|
||||||
|
return fBound;
|
||||||
|
};
|
||||||
|
}
|
||||||
679
js/test/mopidy-test.js
Normal file
679
js/test/mopidy-test.js
Normal file
@ -0,0 +1,679 @@
|
|||||||
|
/*global buster:false, assert:false, refute:false, when:false, Mopidy:false*/
|
||||||
|
|
||||||
|
buster.testCase("Mopidy", {
|
||||||
|
setUp: function () {
|
||||||
|
// Sinon.JS doesn't manage to stub PhantomJS' WebSocket implementation,
|
||||||
|
// so we replace it with a dummy temporarily.
|
||||||
|
var fakeWebSocket = function () {
|
||||||
|
return {
|
||||||
|
send: function () {},
|
||||||
|
close: function () {}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
fakeWebSocket.CONNECTING = 0;
|
||||||
|
fakeWebSocket.OPEN = 1;
|
||||||
|
fakeWebSocket.CLOSING = 2;
|
||||||
|
fakeWebSocket.CLOSED = 3;
|
||||||
|
this.realWebSocket = WebSocket;
|
||||||
|
window.WebSocket = fakeWebSocket;
|
||||||
|
|
||||||
|
this.webSocketConstructorStub = this.stub(window, "WebSocket");
|
||||||
|
|
||||||
|
this.webSocket = {
|
||||||
|
close: this.stub(),
|
||||||
|
send: this.stub()
|
||||||
|
};
|
||||||
|
this.mopidy = new Mopidy({webSocket: this.webSocket});
|
||||||
|
},
|
||||||
|
|
||||||
|
tearDown: function () {
|
||||||
|
window.WebSocket = this.realWebSocket;
|
||||||
|
},
|
||||||
|
|
||||||
|
"constructor": {
|
||||||
|
"connects when autoConnect is true": function () {
|
||||||
|
new Mopidy({autoConnect: true});
|
||||||
|
|
||||||
|
assert.calledOnceWith(this.webSocketConstructorStub,
|
||||||
|
"ws://" + document.location.host + "/mopidy/ws/");
|
||||||
|
},
|
||||||
|
|
||||||
|
"does not connect when autoConnect is false": function () {
|
||||||
|
new Mopidy({autoConnect: false});
|
||||||
|
|
||||||
|
refute.called(this.webSocketConstructorStub);
|
||||||
|
},
|
||||||
|
|
||||||
|
"does not connect when passed a WebSocket": function () {
|
||||||
|
new Mopidy({webSocket: {}});
|
||||||
|
|
||||||
|
refute.called(this.webSocketConstructorStub);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
".connect": {
|
||||||
|
"connects when autoConnect is false": function () {
|
||||||
|
var mopidy = new Mopidy({autoConnect: false});
|
||||||
|
refute.called(this.webSocketConstructorStub);
|
||||||
|
|
||||||
|
mopidy.connect();
|
||||||
|
|
||||||
|
assert.calledOnceWith(this.webSocketConstructorStub,
|
||||||
|
"ws://" + document.location.host + "/mopidy/ws/");
|
||||||
|
},
|
||||||
|
|
||||||
|
"does nothing when the WebSocket is open": function () {
|
||||||
|
this.webSocket.readyState = WebSocket.OPEN;
|
||||||
|
var mopidy = new Mopidy({webSocket: this.webSocket});
|
||||||
|
|
||||||
|
mopidy.connect();
|
||||||
|
|
||||||
|
refute.called(this.webSocket.close);
|
||||||
|
refute.called(this.webSocketConstructorStub);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"WebSocket events": {
|
||||||
|
"emits 'websocket:close' when connection is closed": function () {
|
||||||
|
var spy = this.spy();
|
||||||
|
this.mopidy.off("websocket:close");
|
||||||
|
this.mopidy.on("websocket:close", spy);
|
||||||
|
|
||||||
|
var closeEvent = {};
|
||||||
|
this.webSocket.onclose(closeEvent);
|
||||||
|
|
||||||
|
assert.calledOnceWith(spy, closeEvent);
|
||||||
|
},
|
||||||
|
|
||||||
|
"emits 'websocket:error' when errors occurs": function () {
|
||||||
|
var spy = this.spy();
|
||||||
|
this.mopidy.off("websocket:error");
|
||||||
|
this.mopidy.on("websocket:error", spy);
|
||||||
|
|
||||||
|
var errorEvent = {};
|
||||||
|
this.webSocket.onerror(errorEvent);
|
||||||
|
|
||||||
|
assert.calledOnceWith(spy, errorEvent);
|
||||||
|
},
|
||||||
|
|
||||||
|
"emits 'websocket:incomingMessage' when a message arrives": function () {
|
||||||
|
var spy = this.spy();
|
||||||
|
this.mopidy.off("websocket:incomingMessage");
|
||||||
|
this.mopidy.on("websocket:incomingMessage", spy);
|
||||||
|
|
||||||
|
var messageEvent = {data: "this is a message"};
|
||||||
|
this.webSocket.onmessage(messageEvent);
|
||||||
|
|
||||||
|
assert.calledOnceWith(spy, messageEvent);
|
||||||
|
},
|
||||||
|
|
||||||
|
"emits 'websocket:open' when connection is opened": function () {
|
||||||
|
var spy = this.spy();
|
||||||
|
this.mopidy.off("websocket:open");
|
||||||
|
this.mopidy.on("websocket:open", spy);
|
||||||
|
|
||||||
|
this.webSocket.onopen();
|
||||||
|
|
||||||
|
assert.calledOnceWith(spy);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"._cleanup": {
|
||||||
|
setUp: function () {
|
||||||
|
this.mopidy.off("state:offline");
|
||||||
|
},
|
||||||
|
|
||||||
|
"is called on 'websocket:close' event": function () {
|
||||||
|
var closeEvent = {};
|
||||||
|
var stub = this.stub(this.mopidy, "_cleanup");
|
||||||
|
this.mopidy._delegateEvents();
|
||||||
|
|
||||||
|
this.mopidy.emit("websocket:close", closeEvent);
|
||||||
|
|
||||||
|
assert.calledOnceWith(stub, closeEvent);
|
||||||
|
},
|
||||||
|
|
||||||
|
"rejects all pending requests": function (done) {
|
||||||
|
var closeEvent = {};
|
||||||
|
assert.equals(Object.keys(this.mopidy._pendingRequests).length, 0);
|
||||||
|
|
||||||
|
var promise1 = this.mopidy._send({method: "foo"});
|
||||||
|
var promise2 = this.mopidy._send({method: "bar"});
|
||||||
|
assert.equals(Object.keys(this.mopidy._pendingRequests).length, 2);
|
||||||
|
|
||||||
|
this.mopidy._cleanup(closeEvent);
|
||||||
|
|
||||||
|
assert.equals(Object.keys(this.mopidy._pendingRequests).length, 0);
|
||||||
|
when.join(promise1, promise2).then(done(function () {
|
||||||
|
assert(false, "Promises should be rejected");
|
||||||
|
}), done(function (error) {
|
||||||
|
assert.equals(error.message, "WebSocket closed");
|
||||||
|
assert.same(error.closeEvent, closeEvent);
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
"emits 'state:offline' event when done": function () {
|
||||||
|
var spy = this.spy();
|
||||||
|
this.mopidy.on("state:offline", spy);
|
||||||
|
|
||||||
|
this.mopidy._cleanup({});
|
||||||
|
|
||||||
|
assert.calledOnceWith(spy);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"._reconnect": {
|
||||||
|
"is called when the state changes to offline": function () {
|
||||||
|
var stub = this.stub(this.mopidy, "_reconnect");
|
||||||
|
this.mopidy._delegateEvents();
|
||||||
|
|
||||||
|
this.mopidy.emit("state:offline");
|
||||||
|
|
||||||
|
assert.calledOnceWith(stub);
|
||||||
|
},
|
||||||
|
|
||||||
|
"tries to connect after an increasing backoff delay": function () {
|
||||||
|
var clock = this.useFakeTimers();
|
||||||
|
var connectStub = this.stub(this.mopidy, "connect");
|
||||||
|
var pendingSpy = this.spy();
|
||||||
|
this.mopidy.on("reconnectionPending", pendingSpy);
|
||||||
|
var reconnectingSpy = this.spy();
|
||||||
|
this.mopidy.on("reconnecting", reconnectingSpy);
|
||||||
|
|
||||||
|
refute.called(connectStub);
|
||||||
|
|
||||||
|
this.mopidy._reconnect();
|
||||||
|
assert.calledOnceWith(pendingSpy, {timeToAttempt: 1000});
|
||||||
|
clock.tick(0);
|
||||||
|
refute.called(connectStub);
|
||||||
|
clock.tick(1000);
|
||||||
|
assert.calledOnceWith(reconnectingSpy);
|
||||||
|
assert.calledOnce(connectStub);
|
||||||
|
|
||||||
|
pendingSpy.reset();
|
||||||
|
reconnectingSpy.reset();
|
||||||
|
this.mopidy._reconnect();
|
||||||
|
assert.calledOnceWith(pendingSpy, {timeToAttempt: 2000});
|
||||||
|
assert.calledOnce(connectStub);
|
||||||
|
clock.tick(0);
|
||||||
|
assert.calledOnce(connectStub);
|
||||||
|
clock.tick(1000);
|
||||||
|
assert.calledOnce(connectStub);
|
||||||
|
clock.tick(1000);
|
||||||
|
assert.calledOnceWith(reconnectingSpy);
|
||||||
|
assert.calledTwice(connectStub);
|
||||||
|
|
||||||
|
pendingSpy.reset();
|
||||||
|
reconnectingSpy.reset();
|
||||||
|
this.mopidy._reconnect();
|
||||||
|
assert.calledOnceWith(pendingSpy, {timeToAttempt: 4000});
|
||||||
|
assert.calledTwice(connectStub);
|
||||||
|
clock.tick(0);
|
||||||
|
assert.calledTwice(connectStub);
|
||||||
|
clock.tick(2000);
|
||||||
|
assert.calledTwice(connectStub);
|
||||||
|
clock.tick(2000);
|
||||||
|
assert.calledOnceWith(reconnectingSpy);
|
||||||
|
assert.calledThrice(connectStub);
|
||||||
|
},
|
||||||
|
|
||||||
|
"tries to connect at least about once per minute": function () {
|
||||||
|
var clock = this.useFakeTimers();
|
||||||
|
var connectStub = this.stub(this.mopidy, "connect");
|
||||||
|
var pendingSpy = this.spy();
|
||||||
|
this.mopidy.on("reconnectionPending", pendingSpy);
|
||||||
|
this.mopidy._backoffDelay = this.mopidy._settings.backoffDelayMax;
|
||||||
|
|
||||||
|
refute.called(connectStub);
|
||||||
|
|
||||||
|
this.mopidy._reconnect();
|
||||||
|
assert.calledOnceWith(pendingSpy, {timeToAttempt: 64000});
|
||||||
|
clock.tick(0);
|
||||||
|
refute.called(connectStub);
|
||||||
|
clock.tick(64000);
|
||||||
|
assert.calledOnce(connectStub);
|
||||||
|
|
||||||
|
pendingSpy.reset();
|
||||||
|
this.mopidy._reconnect();
|
||||||
|
assert.calledOnceWith(pendingSpy, {timeToAttempt: 64000});
|
||||||
|
assert.calledOnce(connectStub);
|
||||||
|
clock.tick(0);
|
||||||
|
assert.calledOnce(connectStub);
|
||||||
|
clock.tick(64000);
|
||||||
|
assert.calledTwice(connectStub);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"._resetBackoffDelay": {
|
||||||
|
"is called on 'websocket:open' event": function () {
|
||||||
|
var stub = this.stub(this.mopidy, "_resetBackoffDelay");
|
||||||
|
this.mopidy._delegateEvents();
|
||||||
|
|
||||||
|
this.mopidy.emit("websocket:open");
|
||||||
|
|
||||||
|
assert.calledOnceWith(stub);
|
||||||
|
},
|
||||||
|
|
||||||
|
"resets the backoff delay to the minimum value": function () {
|
||||||
|
this.mopidy._backoffDelay = this.mopidy._backoffDelayMax;
|
||||||
|
|
||||||
|
this.mopidy._resetBackoffDelay();
|
||||||
|
|
||||||
|
assert.equals(this.mopidy._backoffDelay,
|
||||||
|
this.mopidy._settings.backoffDelayMin);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"close": {
|
||||||
|
"unregisters reconnection hooks": function () {
|
||||||
|
this.stub(this.mopidy, "off");
|
||||||
|
|
||||||
|
this.mopidy.close();
|
||||||
|
|
||||||
|
assert.calledOnceWith(
|
||||||
|
this.mopidy.off, "state:offline", this.mopidy._reconnect);
|
||||||
|
},
|
||||||
|
|
||||||
|
"closes the WebSocket": function () {
|
||||||
|
this.mopidy.close();
|
||||||
|
|
||||||
|
assert.calledOnceWith(this.mopidy._webSocket.close);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"._handleWebSocketError": {
|
||||||
|
"is called on 'websocket:error' event": function () {
|
||||||
|
var error = {};
|
||||||
|
var stub = this.stub(this.mopidy, "_handleWebSocketError");
|
||||||
|
this.mopidy._delegateEvents();
|
||||||
|
|
||||||
|
this.mopidy.emit("websocket:error", error);
|
||||||
|
|
||||||
|
assert.calledOnceWith(stub, error);
|
||||||
|
},
|
||||||
|
|
||||||
|
"without stack logs the error to the console": function () {
|
||||||
|
var stub = this.stub(this.mopidy._console, "warn");
|
||||||
|
var error = {};
|
||||||
|
|
||||||
|
this.mopidy._handleWebSocketError(error);
|
||||||
|
|
||||||
|
assert.calledOnceWith(stub, "WebSocket error:", error);
|
||||||
|
},
|
||||||
|
|
||||||
|
"with stack logs the error to the console": function () {
|
||||||
|
var stub = this.stub(this.mopidy._console, "warn");
|
||||||
|
var error = {stack: "foo"};
|
||||||
|
|
||||||
|
this.mopidy._handleWebSocketError(error);
|
||||||
|
|
||||||
|
assert.calledOnceWith(stub, "WebSocket error:", error.stack);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"._send": {
|
||||||
|
"adds JSON-RPC fields to the message": function () {
|
||||||
|
this.stub(this.mopidy, "_nextRequestId").returns(1);
|
||||||
|
var stub = this.stub(JSON, "stringify");
|
||||||
|
|
||||||
|
this.mopidy._send({method: "foo"});
|
||||||
|
|
||||||
|
assert.calledOnceWith(stub, {
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 1,
|
||||||
|
method: "foo"
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
"adds a resolver to the pending requests queue": function () {
|
||||||
|
this.stub(this.mopidy, "_nextRequestId").returns(1);
|
||||||
|
assert.equals(Object.keys(this.mopidy._pendingRequests).length, 0);
|
||||||
|
|
||||||
|
this.mopidy._send({method: "foo"});
|
||||||
|
|
||||||
|
assert.equals(Object.keys(this.mopidy._pendingRequests).length, 1);
|
||||||
|
assert.isFunction(this.mopidy._pendingRequests[1].resolve);
|
||||||
|
},
|
||||||
|
|
||||||
|
"sends message on the WebSocket": function () {
|
||||||
|
refute.called(this.mopidy._webSocket.send);
|
||||||
|
|
||||||
|
this.mopidy._send({method: "foo"});
|
||||||
|
|
||||||
|
assert.calledOnce(this.mopidy._webSocket.send);
|
||||||
|
},
|
||||||
|
|
||||||
|
"emits a 'websocket:outgoingMessage' event": function () {
|
||||||
|
var spy = this.spy();
|
||||||
|
this.mopidy.on("websocket:outgoingMessage", spy);
|
||||||
|
this.stub(this.mopidy, "_nextRequestId").returns(1);
|
||||||
|
|
||||||
|
this.mopidy._send({method: "foo"});
|
||||||
|
|
||||||
|
assert.calledOnceWith(spy, {
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 1,
|
||||||
|
method: "foo"
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
"immediately rejects request if CONNECTING": function (done) {
|
||||||
|
this.mopidy._webSocket.readyState = WebSocket.CONNECTING;
|
||||||
|
|
||||||
|
var promise = this.mopidy._send({method: "foo"});
|
||||||
|
|
||||||
|
refute.called(this.mopidy._webSocket.send);
|
||||||
|
promise.then(done(function () {
|
||||||
|
assert(false);
|
||||||
|
}), done(function (error) {
|
||||||
|
assert.equals(
|
||||||
|
error.message, "WebSocket is still connecting");
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
"immediately rejects request if CLOSING": function (done) {
|
||||||
|
this.mopidy._webSocket.readyState = WebSocket.CLOSING;
|
||||||
|
|
||||||
|
var promise = this.mopidy._send({method: "foo"});
|
||||||
|
|
||||||
|
refute.called(this.mopidy._webSocket.send);
|
||||||
|
promise.then(done(function () {
|
||||||
|
assert(false);
|
||||||
|
}), done(function (error) {
|
||||||
|
assert.equals(
|
||||||
|
error.message, "WebSocket is closing");
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
"immediately rejects request if CLOSED": function (done) {
|
||||||
|
this.mopidy._webSocket.readyState = WebSocket.CLOSED;
|
||||||
|
|
||||||
|
var promise = this.mopidy._send({method: "foo"});
|
||||||
|
|
||||||
|
refute.called(this.mopidy._webSocket.send);
|
||||||
|
promise.then(done(function () {
|
||||||
|
assert(false);
|
||||||
|
}), done(function (error) {
|
||||||
|
assert.equals(
|
||||||
|
error.message, "WebSocket is closed");
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"._nextRequestId": {
|
||||||
|
"returns an ever increasing ID": function () {
|
||||||
|
var base = this.mopidy._nextRequestId();
|
||||||
|
assert.equals(this.mopidy._nextRequestId(), base + 1);
|
||||||
|
assert.equals(this.mopidy._nextRequestId(), base + 2);
|
||||||
|
assert.equals(this.mopidy._nextRequestId(), base + 3);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"._handleMessage": {
|
||||||
|
"is called on 'websocket:incomingMessage' event": function () {
|
||||||
|
var messageEvent = {};
|
||||||
|
var stub = this.stub(this.mopidy, "_handleMessage");
|
||||||
|
this.mopidy._delegateEvents();
|
||||||
|
|
||||||
|
this.mopidy.emit("websocket:incomingMessage", messageEvent);
|
||||||
|
|
||||||
|
assert.calledOnceWith(stub, messageEvent);
|
||||||
|
},
|
||||||
|
|
||||||
|
"passes JSON-RPC responses on to _handleResponse": function () {
|
||||||
|
var stub = this.stub(this.mopidy, "_handleResponse");
|
||||||
|
var message = {
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 1,
|
||||||
|
result: null
|
||||||
|
};
|
||||||
|
var messageEvent = {data: JSON.stringify(message)};
|
||||||
|
|
||||||
|
this.mopidy._handleMessage(messageEvent);
|
||||||
|
|
||||||
|
assert.calledOnceWith(stub, message);
|
||||||
|
},
|
||||||
|
|
||||||
|
"passes events on to _handleEvent": function () {
|
||||||
|
var stub = this.stub(this.mopidy, "_handleEvent");
|
||||||
|
var message = {
|
||||||
|
event: "track_playback_started",
|
||||||
|
track: {}
|
||||||
|
};
|
||||||
|
var messageEvent = {data: JSON.stringify(message)};
|
||||||
|
|
||||||
|
this.mopidy._handleMessage(messageEvent);
|
||||||
|
|
||||||
|
assert.calledOnceWith(stub, message);
|
||||||
|
},
|
||||||
|
|
||||||
|
"logs unknown messages": function () {
|
||||||
|
var stub = this.stub(this.mopidy._console, "warn");
|
||||||
|
var messageEvent = {data: JSON.stringify({foo: "bar"})};
|
||||||
|
|
||||||
|
this.mopidy._handleMessage(messageEvent);
|
||||||
|
|
||||||
|
assert.calledOnceWith(stub,
|
||||||
|
"Unknown message type received. Message was: " +
|
||||||
|
messageEvent.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
"logs JSON parsing errors": function () {
|
||||||
|
var stub = this.stub(this.mopidy._console, "warn");
|
||||||
|
var messageEvent = {data: "foobarbaz"};
|
||||||
|
|
||||||
|
this.mopidy._handleMessage(messageEvent);
|
||||||
|
|
||||||
|
assert.calledOnceWith(stub,
|
||||||
|
"WebSocket message parsing failed. Message was: " +
|
||||||
|
messageEvent.data);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"._handleResponse": {
|
||||||
|
"logs unexpected responses": function () {
|
||||||
|
var stub = this.stub(this.mopidy._console, "warn");
|
||||||
|
var responseMessage = {
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 1337,
|
||||||
|
result: null
|
||||||
|
};
|
||||||
|
|
||||||
|
this.mopidy._handleResponse(responseMessage);
|
||||||
|
|
||||||
|
assert.calledOnceWith(stub,
|
||||||
|
"Unexpected response received. Message was:", responseMessage);
|
||||||
|
},
|
||||||
|
|
||||||
|
"removes the matching request from the pending queue": function () {
|
||||||
|
assert.equals(Object.keys(this.mopidy._pendingRequests).length, 0);
|
||||||
|
this.mopidy._send({method: "bar"});
|
||||||
|
assert.equals(Object.keys(this.mopidy._pendingRequests).length, 1);
|
||||||
|
|
||||||
|
this.mopidy._handleResponse({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: Object.keys(this.mopidy._pendingRequests)[0],
|
||||||
|
result: "baz"
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equals(Object.keys(this.mopidy._pendingRequests).length, 0);
|
||||||
|
},
|
||||||
|
|
||||||
|
"resolves requests which get results back": function (done) {
|
||||||
|
var promise = this.mopidy._send({method: "bar"});
|
||||||
|
var responseResult = {};
|
||||||
|
var responseMessage = {
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: Object.keys(this.mopidy._pendingRequests)[0],
|
||||||
|
result: responseResult
|
||||||
|
};
|
||||||
|
|
||||||
|
this.mopidy._handleResponse(responseMessage);
|
||||||
|
promise.then(done(function (result) {
|
||||||
|
assert.equals(result, responseResult);
|
||||||
|
}), done(function () {
|
||||||
|
assert(false);
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
"rejects and logs requests which get errors back": function (done) {
|
||||||
|
var stub = this.stub(this.mopidy._console, "warn");
|
||||||
|
var promise = this.mopidy._send({method: "bar"});
|
||||||
|
var responseError = {message: "Error", data: {}};
|
||||||
|
var responseMessage = {
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: Object.keys(this.mopidy._pendingRequests)[0],
|
||||||
|
error: responseError
|
||||||
|
};
|
||||||
|
|
||||||
|
this.mopidy._handleResponse(responseMessage);
|
||||||
|
|
||||||
|
assert.calledOnceWith(stub,
|
||||||
|
"Server returned error:", responseError);
|
||||||
|
promise.then(done(function () {
|
||||||
|
assert(false);
|
||||||
|
}), done(function (error) {
|
||||||
|
assert.equals(error, responseError);
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
"rejects and logs responses without result or error": function (done) {
|
||||||
|
var stub = this.stub(this.mopidy._console, "warn");
|
||||||
|
var promise = this.mopidy._send({method: "bar"});
|
||||||
|
var responseMessage = {
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: Object.keys(this.mopidy._pendingRequests)[0]
|
||||||
|
};
|
||||||
|
|
||||||
|
this.mopidy._handleResponse(responseMessage);
|
||||||
|
|
||||||
|
assert.calledOnceWith(stub,
|
||||||
|
"Response without 'result' or 'error' received. Message was:",
|
||||||
|
responseMessage);
|
||||||
|
promise.then(done(function () {
|
||||||
|
assert(false);
|
||||||
|
}), done(function (error) {
|
||||||
|
assert.equals(
|
||||||
|
error.message,
|
||||||
|
"Response without 'result' or 'error' received");
|
||||||
|
assert.equals(error.data.response, responseMessage);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"._handleEvent": {
|
||||||
|
"emits server side even on Mopidy object": function () {
|
||||||
|
var spy = this.spy();
|
||||||
|
this.mopidy.on(spy);
|
||||||
|
var track = {};
|
||||||
|
var message = {
|
||||||
|
event: "track_playback_started",
|
||||||
|
track: track
|
||||||
|
};
|
||||||
|
|
||||||
|
this.mopidy._handleEvent(message);
|
||||||
|
|
||||||
|
assert.calledOnceWith(spy,
|
||||||
|
"event:trackPlaybackStarted", {track: track});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"._getApiSpec": {
|
||||||
|
"is called on 'websocket:open' event": function () {
|
||||||
|
var stub = this.stub(this.mopidy, "_getApiSpec");
|
||||||
|
this.mopidy._delegateEvents();
|
||||||
|
|
||||||
|
this.mopidy.emit("websocket:open");
|
||||||
|
|
||||||
|
assert.calledOnceWith(stub);
|
||||||
|
},
|
||||||
|
|
||||||
|
"gets Api description from server and calls _createApi": function () {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"._createApi": {
|
||||||
|
"can create an API with methods on the root object": function () {
|
||||||
|
refute.defined(this.mopidy.hello);
|
||||||
|
refute.defined(this.mopidy.hi);
|
||||||
|
|
||||||
|
this.mopidy._createApi({
|
||||||
|
hello: {
|
||||||
|
description: "Says hello",
|
||||||
|
params: []
|
||||||
|
},
|
||||||
|
hi: {
|
||||||
|
description: "Says hi",
|
||||||
|
params: []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.isFunction(this.mopidy.hello);
|
||||||
|
assert.equals(this.mopidy.hello.description, "Says hello");
|
||||||
|
assert.equals(this.mopidy.hello.params, []);
|
||||||
|
assert.isFunction(this.mopidy.hi);
|
||||||
|
assert.equals(this.mopidy.hi.description, "Says hi");
|
||||||
|
assert.equals(this.mopidy.hi.params, []);
|
||||||
|
},
|
||||||
|
|
||||||
|
"can create an API with methods on a sub-object": function () {
|
||||||
|
refute.defined(this.mopidy.hello);
|
||||||
|
|
||||||
|
this.mopidy._createApi({
|
||||||
|
"hello.world": {
|
||||||
|
description: "Says hello to the world",
|
||||||
|
params: []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.defined(this.mopidy.hello);
|
||||||
|
assert.isFunction(this.mopidy.hello.world);
|
||||||
|
},
|
||||||
|
|
||||||
|
"strips off 'core' from method paths": function () {
|
||||||
|
refute.defined(this.mopidy.hello);
|
||||||
|
|
||||||
|
this.mopidy._createApi({
|
||||||
|
"core.hello.world": {
|
||||||
|
description: "Says hello to the world",
|
||||||
|
params: []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.defined(this.mopidy.hello);
|
||||||
|
assert.isFunction(this.mopidy.hello.world);
|
||||||
|
},
|
||||||
|
|
||||||
|
"converts snake_case to camelCase": function () {
|
||||||
|
refute.defined(this.mopidy.mightyGreetings);
|
||||||
|
|
||||||
|
this.mopidy._createApi({
|
||||||
|
"mighty_greetings.hello_world": {
|
||||||
|
description: "Says hello to the world",
|
||||||
|
params: []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.defined(this.mopidy.mightyGreetings);
|
||||||
|
assert.isFunction(this.mopidy.mightyGreetings.helloWorld);
|
||||||
|
},
|
||||||
|
|
||||||
|
"triggers 'state:online' event when API is ready for use": function () {
|
||||||
|
var spy = this.spy();
|
||||||
|
this.mopidy.on("state:online", spy);
|
||||||
|
|
||||||
|
this.mopidy._createApi({});
|
||||||
|
|
||||||
|
assert.calledOnceWith(spy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -23,7 +23,7 @@ if (isinstance(pykka.__version__, basestring)
|
|||||||
warnings.filterwarnings('ignore', 'could not open display')
|
warnings.filterwarnings('ignore', 'could not open display')
|
||||||
|
|
||||||
|
|
||||||
__version__ = '0.9.0'
|
__version__ = '0.10.0'
|
||||||
|
|
||||||
|
|
||||||
from mopidy import settings as default_settings_module
|
from mopidy import settings as default_settings_module
|
||||||
|
|||||||
@ -19,7 +19,20 @@ class AudioListener(object):
|
|||||||
"""Helper to allow calling of audio listener events"""
|
"""Helper to allow calling of audio listener events"""
|
||||||
listeners = pykka.ActorRegistry.get_by_class(AudioListener)
|
listeners = pykka.ActorRegistry.get_by_class(AudioListener)
|
||||||
for listener in listeners:
|
for listener in listeners:
|
||||||
getattr(listener.proxy(), event)(**kwargs)
|
listener.proxy().on_event(event, **kwargs)
|
||||||
|
|
||||||
|
def on_event(self, event, **kwargs):
|
||||||
|
"""
|
||||||
|
Called on all events.
|
||||||
|
|
||||||
|
*MAY* be implemented by actor. By default, this method forwards the
|
||||||
|
event to the specific event methods.
|
||||||
|
|
||||||
|
:param event: the event name
|
||||||
|
:type event: string
|
||||||
|
:param kwargs: any other arguments to the specific event handlers
|
||||||
|
"""
|
||||||
|
getattr(self, event)(**kwargs)
|
||||||
|
|
||||||
def reached_end_of_stream(self):
|
def reached_end_of_stream(self):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -21,7 +21,20 @@ class BackendListener(object):
|
|||||||
"""Helper to allow calling of backend listener events"""
|
"""Helper to allow calling of backend listener events"""
|
||||||
listeners = pykka.ActorRegistry.get_by_class(BackendListener)
|
listeners = pykka.ActorRegistry.get_by_class(BackendListener)
|
||||||
for listener in listeners:
|
for listener in listeners:
|
||||||
getattr(listener.proxy(), event)(**kwargs)
|
listener.proxy().on_event(event, **kwargs)
|
||||||
|
|
||||||
|
def on_event(self, event, **kwargs):
|
||||||
|
"""
|
||||||
|
Called on all events.
|
||||||
|
|
||||||
|
*MAY* be implemented by actor. By default, this method forwards the
|
||||||
|
event to the specific event methods.
|
||||||
|
|
||||||
|
:param event: the event name
|
||||||
|
:type event: string
|
||||||
|
:param kwargs: any other arguments to the specific event handlers
|
||||||
|
"""
|
||||||
|
getattr(self, event)(**kwargs)
|
||||||
|
|
||||||
def playlists_loaded(self):
|
def playlists_loaded(self):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -57,7 +57,7 @@ class LocalPlaylistsProvider(base.BasePlaylistsProvider):
|
|||||||
# from other backends
|
# from other backends
|
||||||
tracks += self.backend.library.lookup(track_uri)
|
tracks += self.backend.library.lookup(track_uri)
|
||||||
except LookupError as ex:
|
except LookupError as ex:
|
||||||
logger.error('Playlist item could not be added: %s', ex)
|
logger.warning('Playlist item could not be added: %s', ex)
|
||||||
|
|
||||||
playlist = Playlist(uri=uri, name=name, tracks=tracks)
|
playlist = Playlist(uri=uri, name=name, tracks=tracks)
|
||||||
playlists.append(playlist)
|
playlists.append(playlist)
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import urllib
|
||||||
|
|
||||||
from mopidy.models import Track, Artist, Album
|
from mopidy.models import Track, Artist, Album
|
||||||
from mopidy.utils.encoding import locale_decode
|
from mopidy.utils.encoding import locale_decode
|
||||||
@ -35,7 +36,7 @@ def parse_m3u(file_path, music_folder):
|
|||||||
with open(file_path) as m3u:
|
with open(file_path) as m3u:
|
||||||
contents = m3u.readlines()
|
contents = m3u.readlines()
|
||||||
except IOError as error:
|
except IOError as error:
|
||||||
logger.error('Couldn\'t open m3u: %s', locale_decode(error))
|
logger.warning('Couldn\'t open m3u: %s', locale_decode(error))
|
||||||
return uris
|
return uris
|
||||||
|
|
||||||
for line in contents:
|
for line in contents:
|
||||||
@ -64,7 +65,7 @@ def parse_mpd_tag_cache(tag_cache, music_dir=''):
|
|||||||
with open(tag_cache) as library:
|
with open(tag_cache) as library:
|
||||||
contents = library.read()
|
contents = library.read()
|
||||||
except IOError as error:
|
except IOError as error:
|
||||||
logger.error('Could not open tag cache: %s', locale_decode(error))
|
logger.warning('Could not open tag cache: %s', locale_decode(error))
|
||||||
return tracks
|
return tracks
|
||||||
|
|
||||||
current = {}
|
current = {}
|
||||||
@ -139,6 +140,7 @@ def _convert_mpd_data(data, tracks, music_dir):
|
|||||||
path = data['file'][1:]
|
path = data['file'][1:]
|
||||||
else:
|
else:
|
||||||
path = data['file']
|
path = data['file']
|
||||||
|
path = urllib.unquote(path)
|
||||||
|
|
||||||
if artist_kwargs:
|
if artist_kwargs:
|
||||||
artist = Artist(**artist_kwargs)
|
artist = Artist(**artist_kwargs)
|
||||||
|
|||||||
@ -21,7 +21,7 @@ https://github.com/mopidy/mopidy/issues?labels=Spotify+backend
|
|||||||
**Dependencies:**
|
**Dependencies:**
|
||||||
|
|
||||||
- libspotify >= 12, < 13 (libspotify12 package from apt.mopidy.com)
|
- libspotify >= 12, < 13 (libspotify12 package from apt.mopidy.com)
|
||||||
- pyspotify >= 1.9, < 1.10 (python-spotify package from apt.mopidy.com)
|
- pyspotify >= 1.9, < 1.11 (python-spotify package from apt.mopidy.com)
|
||||||
|
|
||||||
**Settings:**
|
**Settings:**
|
||||||
|
|
||||||
|
|||||||
@ -46,7 +46,6 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager):
|
|||||||
self.backend_ref = backend_ref
|
self.backend_ref = backend_ref
|
||||||
|
|
||||||
self.connected = threading.Event()
|
self.connected = threading.Event()
|
||||||
self.session = None
|
|
||||||
|
|
||||||
self.container_manager = None
|
self.container_manager = None
|
||||||
self.playlist_manager = None
|
self.playlist_manager = None
|
||||||
@ -64,17 +63,20 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager):
|
|||||||
return
|
return
|
||||||
|
|
||||||
logger.info('Connected to Spotify')
|
logger.info('Connected to Spotify')
|
||||||
self.session = session
|
|
||||||
|
# To work with both pyspotify 1.9 and 1.10
|
||||||
|
if not hasattr(self, 'session'):
|
||||||
|
self.session = session
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
'Preferred Spotify bitrate is %s kbps',
|
'Preferred Spotify bitrate is %s kbps',
|
||||||
settings.SPOTIFY_BITRATE)
|
settings.SPOTIFY_BITRATE)
|
||||||
self.session.set_preferred_bitrate(BITRATES[settings.SPOTIFY_BITRATE])
|
session.set_preferred_bitrate(BITRATES[settings.SPOTIFY_BITRATE])
|
||||||
|
|
||||||
self.container_manager = SpotifyContainerManager(self)
|
self.container_manager = SpotifyContainerManager(self)
|
||||||
self.playlist_manager = SpotifyPlaylistManager(self)
|
self.playlist_manager = SpotifyPlaylistManager(self)
|
||||||
|
|
||||||
self.container_manager.watch(self.session.playlist_container())
|
self.container_manager.watch(session.playlist_container())
|
||||||
|
|
||||||
self.connected.set()
|
self.connected.set()
|
||||||
|
|
||||||
@ -142,8 +144,9 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager):
|
|||||||
# startup until the Spotify backend is ready from 35s to 12s in one
|
# startup until the Spotify backend is ready from 35s to 12s in one
|
||||||
# test with clean Spotify cache. In cases with an outdated cache
|
# test with clean Spotify cache. In cases with an outdated cache
|
||||||
# the time improvements should be a lot greater.
|
# the time improvements should be a lot greater.
|
||||||
self._initial_data_receive_completed = True
|
if not self._initial_data_receive_completed:
|
||||||
self.refresh_playlists()
|
self._initial_data_receive_completed = True
|
||||||
|
self.refresh_playlists()
|
||||||
|
|
||||||
def end_of_track(self, session):
|
def end_of_track(self, session):
|
||||||
"""Callback used by pyspotify"""
|
"""Callback used by pyspotify"""
|
||||||
@ -178,5 +181,7 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager):
|
|||||||
def logout(self):
|
def logout(self):
|
||||||
"""Log out from spotify"""
|
"""Log out from spotify"""
|
||||||
logger.debug('Logging out from Spotify')
|
logger.debug('Logging out from Spotify')
|
||||||
if self.session:
|
|
||||||
|
# To work with both pyspotify 1.9 and 1.10
|
||||||
|
if getattr(self, 'session', None):
|
||||||
self.session.logout()
|
self.session.logout()
|
||||||
|
|||||||
@ -6,32 +6,45 @@ from mopidy import settings
|
|||||||
from mopidy.models import Artist, Album, Track, Playlist
|
from mopidy.models import Artist, Album, Track, Playlist
|
||||||
|
|
||||||
|
|
||||||
|
artist_cache = {}
|
||||||
|
album_cache = {}
|
||||||
|
track_cache = {}
|
||||||
|
|
||||||
|
|
||||||
def to_mopidy_artist(spotify_artist):
|
def to_mopidy_artist(spotify_artist):
|
||||||
if spotify_artist is None:
|
if spotify_artist is None:
|
||||||
return
|
return
|
||||||
uri = str(Link.from_artist(spotify_artist))
|
uri = str(Link.from_artist(spotify_artist))
|
||||||
|
if uri in artist_cache:
|
||||||
|
return artist_cache[uri]
|
||||||
if not spotify_artist.is_loaded():
|
if not spotify_artist.is_loaded():
|
||||||
return Artist(uri=uri, name='[loading...]')
|
return Artist(uri=uri, name='[loading...]')
|
||||||
return Artist(uri=uri, name=spotify_artist.name())
|
artist_cache[uri] = Artist(uri=uri, name=spotify_artist.name())
|
||||||
|
return artist_cache[uri]
|
||||||
|
|
||||||
|
|
||||||
def to_mopidy_album(spotify_album):
|
def to_mopidy_album(spotify_album):
|
||||||
if spotify_album is None:
|
if spotify_album is None:
|
||||||
return
|
return
|
||||||
uri = str(Link.from_album(spotify_album))
|
uri = str(Link.from_album(spotify_album))
|
||||||
|
if uri in album_cache:
|
||||||
|
return album_cache[uri]
|
||||||
if not spotify_album.is_loaded():
|
if not spotify_album.is_loaded():
|
||||||
return Album(uri=uri, name='[loading...]')
|
return Album(uri=uri, name='[loading...]')
|
||||||
return Album(
|
album_cache[uri] = Album(
|
||||||
uri=uri,
|
uri=uri,
|
||||||
name=spotify_album.name(),
|
name=spotify_album.name(),
|
||||||
artists=[to_mopidy_artist(spotify_album.artist())],
|
artists=[to_mopidy_artist(spotify_album.artist())],
|
||||||
date=spotify_album.year())
|
date=spotify_album.year())
|
||||||
|
return album_cache[uri]
|
||||||
|
|
||||||
|
|
||||||
def to_mopidy_track(spotify_track):
|
def to_mopidy_track(spotify_track):
|
||||||
if spotify_track is None:
|
if spotify_track is None:
|
||||||
return
|
return
|
||||||
uri = str(Link.from_track(spotify_track, 0))
|
uri = str(Link.from_track(spotify_track, 0))
|
||||||
|
if uri in track_cache:
|
||||||
|
return track_cache[uri]
|
||||||
if not spotify_track.is_loaded():
|
if not spotify_track.is_loaded():
|
||||||
return Track(uri=uri, name='[loading...]')
|
return Track(uri=uri, name='[loading...]')
|
||||||
spotify_album = spotify_track.album()
|
spotify_album = spotify_track.album()
|
||||||
@ -39,7 +52,7 @@ def to_mopidy_track(spotify_track):
|
|||||||
date = spotify_album.year()
|
date = spotify_album.year()
|
||||||
else:
|
else:
|
||||||
date = None
|
date = None
|
||||||
return Track(
|
track_cache[uri] = Track(
|
||||||
uri=uri,
|
uri=uri,
|
||||||
name=spotify_track.name(),
|
name=spotify_track.name(),
|
||||||
artists=[to_mopidy_artist(a) for a in spotify_track.artists()],
|
artists=[to_mopidy_artist(a) for a in spotify_track.artists()],
|
||||||
@ -48,6 +61,7 @@ def to_mopidy_track(spotify_track):
|
|||||||
date=date,
|
date=date,
|
||||||
length=spotify_track.duration(),
|
length=spotify_track.duration(),
|
||||||
bitrate=settings.SPOTIFY_BITRATE)
|
bitrate=settings.SPOTIFY_BITRATE)
|
||||||
|
return track_cache[uri]
|
||||||
|
|
||||||
|
|
||||||
def to_mopidy_playlist(spotify_playlist):
|
def to_mopidy_playlist(spotify_playlist):
|
||||||
|
|||||||
@ -19,7 +19,20 @@ class CoreListener(object):
|
|||||||
"""Helper to allow calling of core listener events"""
|
"""Helper to allow calling of core listener events"""
|
||||||
listeners = pykka.ActorRegistry.get_by_class(CoreListener)
|
listeners = pykka.ActorRegistry.get_by_class(CoreListener)
|
||||||
for listener in listeners:
|
for listener in listeners:
|
||||||
getattr(listener.proxy(), event)(**kwargs)
|
listener.proxy().on_event(event, **kwargs)
|
||||||
|
|
||||||
|
def on_event(self, event, **kwargs):
|
||||||
|
"""
|
||||||
|
Called on all events.
|
||||||
|
|
||||||
|
*MAY* be implemented by actor. By default, this method forwards the
|
||||||
|
event to the specific event methods.
|
||||||
|
|
||||||
|
:param event: the event name
|
||||||
|
:type event: string
|
||||||
|
:param kwargs: any other arguments to the specific event handlers
|
||||||
|
"""
|
||||||
|
getattr(self, event)(**kwargs)
|
||||||
|
|
||||||
def track_playback_paused(self, track, time_position):
|
def track_playback_paused(self, track, time_position):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -53,6 +53,9 @@ class PlaybackController(object):
|
|||||||
Tracks are not removed from the playlist.
|
Tracks are not removed from the playlist.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
def get_current_tl_track(self):
|
||||||
|
return self.current_tl_track
|
||||||
|
|
||||||
current_tl_track = None
|
current_tl_track = None
|
||||||
"""
|
"""
|
||||||
The currently playing or selected :class:`mopidy.models.TlTrack`, or
|
The currently playing or selected :class:`mopidy.models.TlTrack`, or
|
||||||
|
|||||||
459
mopidy/frontends/http/__init__.py
Normal file
459
mopidy/frontends/http/__init__.py
Normal file
@ -0,0 +1,459 @@
|
|||||||
|
"""
|
||||||
|
The HTTP frontends lets you control Mopidy through HTTP and WebSockets, e.g.
|
||||||
|
from a web based client.
|
||||||
|
|
||||||
|
**Dependencies**
|
||||||
|
|
||||||
|
- ``cherrypy``
|
||||||
|
|
||||||
|
- ``ws4py``
|
||||||
|
|
||||||
|
**Settings**
|
||||||
|
|
||||||
|
- :attr:`mopidy.settings.HTTP_SERVER_HOSTNAME`
|
||||||
|
|
||||||
|
- :attr:`mopidy.settings.HTTP_SERVER_PORT`
|
||||||
|
|
||||||
|
- :attr:`mopidy.settings.HTTP_SERVER_STATIC_DIR`
|
||||||
|
|
||||||
|
|
||||||
|
Setup
|
||||||
|
=====
|
||||||
|
|
||||||
|
When this frontend is included in :attr:`mopidy.settings.FRONTENDS`, it starts
|
||||||
|
a web server at the port specified by :attr:`mopidy.settings.HTTP_SERVER_PORT`.
|
||||||
|
|
||||||
|
.. warning:: Security
|
||||||
|
|
||||||
|
As a simple security measure, the web server is by default only available
|
||||||
|
from localhost. To make it available from other computers, change
|
||||||
|
:attr:`mopidy.settings.HTTP_SERVER_HOSTNAME`. Before you do so, note that
|
||||||
|
the HTTP frontend does not feature any form of user authentication or
|
||||||
|
authorization. Anyone able to access the web server can use the full core
|
||||||
|
API of Mopidy. Thus, you probably only want to make the web server
|
||||||
|
available from your local network or place it behind a web proxy which
|
||||||
|
takes care or user authentication. You have been warned.
|
||||||
|
|
||||||
|
|
||||||
|
Using a web based Mopidy client
|
||||||
|
===============================
|
||||||
|
|
||||||
|
The web server can also host any static files, for example the HTML, CSS,
|
||||||
|
JavaScript, and images needed for a web based Mopidy client. To host static
|
||||||
|
files, change :attr:`mopidy.settings.HTTP_SERVER_STATIC_DIR` to point to the
|
||||||
|
root directory of your web client, e.g.::
|
||||||
|
|
||||||
|
HTTP_SERVER_STATIC_DIR = u'/home/alice/dev/the-client'
|
||||||
|
|
||||||
|
If the directory includes a file named ``index.html``, it will be served on the
|
||||||
|
root of Mopidy's web server.
|
||||||
|
|
||||||
|
If you're making a web based client and wants to do server side development as
|
||||||
|
well, you are of course free to run your own web server and just use Mopidy's
|
||||||
|
web server for the APIs. But, for clients implemented purely in JavaScript,
|
||||||
|
letting Mopidy host the files is a simpler solution.
|
||||||
|
|
||||||
|
|
||||||
|
WebSocket API
|
||||||
|
=============
|
||||||
|
|
||||||
|
.. warning:: API stability
|
||||||
|
|
||||||
|
Since this frontend exposes our internal core API directly it is to be
|
||||||
|
regarded as **experimental**. We cannot promise to keep any form of
|
||||||
|
backwards compatibility between releases as we will need to change the core
|
||||||
|
API while working out how to support new use cases. Thus, if you use this
|
||||||
|
API, you must expect to do small adjustments to your client for every
|
||||||
|
release of Mopidy.
|
||||||
|
|
||||||
|
From Mopidy 1.0 and onwards, we intend to keep the core API far more
|
||||||
|
stable.
|
||||||
|
|
||||||
|
The web server exposes a WebSocket at ``/mopidy/ws/``. The WebSocket gives you
|
||||||
|
access to Mopidy's full API and enables Mopidy to instantly push events to the
|
||||||
|
client, as they happen.
|
||||||
|
|
||||||
|
On the WebSocket we send two different kind of messages: The client can send
|
||||||
|
JSON-RPC 2.0 requests, and the server will respond with JSON-RPC 2.0 responses.
|
||||||
|
In addition, the server will send event messages when something happens on the
|
||||||
|
server. Both message types are encoded as JSON objects.
|
||||||
|
|
||||||
|
|
||||||
|
Event messages
|
||||||
|
--------------
|
||||||
|
|
||||||
|
Event objects will always have a key named ``event`` whose value is the event
|
||||||
|
type. Depending on the event type, the event may include additional fields for
|
||||||
|
related data. The events maps directly to the :class:`mopidy.core.CoreListener`
|
||||||
|
API. Refer to the ``CoreListener`` method names is the available event types.
|
||||||
|
The ``CoreListener`` method's keyword arguments are all included as extra
|
||||||
|
fields on the event objects. Example event message::
|
||||||
|
|
||||||
|
{"event": "track_playback_started", "track": {...}}
|
||||||
|
|
||||||
|
|
||||||
|
JSON-RPC 2.0 messaging
|
||||||
|
----------------------
|
||||||
|
|
||||||
|
JSON-RPC 2.0 messages can be recognized by checking for the key named
|
||||||
|
``jsonrpc`` with the string value ``2.0``. For details on the messaging format,
|
||||||
|
please refer to the `JSON-RPC 2.0 spec
|
||||||
|
<http://www.jsonrpc.org/specification>`_.
|
||||||
|
|
||||||
|
All methods (not attributes) in the :ref:`core-api` is made available through
|
||||||
|
JSON-RPC calls over the WebSocket. For example,
|
||||||
|
:meth:`mopidy.core.PlaybackController.play` is available as the JSON-RPC method
|
||||||
|
``core.playback.play``.
|
||||||
|
|
||||||
|
The core API's attributes is made available through setters and getters. For
|
||||||
|
example, the attribute :attr:`mopidy.core.PlaybackController.current_track` is
|
||||||
|
available as the JSON-RPC method ``core.playback.get_current_track``.
|
||||||
|
|
||||||
|
Example JSON-RPC request::
|
||||||
|
|
||||||
|
{"jsonrpc": "2.0", "id": 1, "method": "core.playback.get_current_track"}
|
||||||
|
|
||||||
|
Example JSON-RPC response::
|
||||||
|
|
||||||
|
{"jsonrpc": "2.0", "id": 1, "result": {"__model__": "Track", "...": "..."}}
|
||||||
|
|
||||||
|
The JSON-RPC method ``core.describe`` returns a data structure describing all
|
||||||
|
available methods. If you're unsure how the core API maps to JSON-RPC, having a
|
||||||
|
look at the ``core.describe`` response can be helpful.
|
||||||
|
|
||||||
|
|
||||||
|
Mopidy.js JavaScript library
|
||||||
|
============================
|
||||||
|
|
||||||
|
We've made a JavaScript library, Mopidy.js, which wraps the WebSocket and gets
|
||||||
|
you quickly started with working on your client instead of figuring out how to
|
||||||
|
communicate with Mopidy.
|
||||||
|
|
||||||
|
|
||||||
|
Getting the library
|
||||||
|
-------------------
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Thus, if you use Mopidy to host your web client, like described above, you can
|
||||||
|
load the latest version of Mopidy.js by adding the following script tag to your
|
||||||
|
HTML file:
|
||||||
|
|
||||||
|
.. code-block:: html
|
||||||
|
|
||||||
|
<script type="text/javascript" src="/mopidy/mopidy.min.js"></script>
|
||||||
|
|
||||||
|
If you don't use Mopidy to host your web client, you can find the JS files in
|
||||||
|
the Git repo at:
|
||||||
|
|
||||||
|
- ``mopidy/frontends/http/data/mopidy.js``
|
||||||
|
- ``mopidy/frontends/http/data/mopidy.min.js``
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
|
||||||
|
Creating an instance
|
||||||
|
--------------------
|
||||||
|
|
||||||
|
Once you got Mopidy.js loaded, you need to create an instance of the wrapper:
|
||||||
|
|
||||||
|
.. code-block:: js
|
||||||
|
|
||||||
|
var mopidy = new Mopidy();
|
||||||
|
|
||||||
|
When you instantiate ``Mopidy()`` without arguments, it will connect to
|
||||||
|
the WebSocket at ``/mopidy/ws/`` on the current host. Thus, if you don't host
|
||||||
|
your web client using Mopidy's web server, you'll need to pass the URL to the
|
||||||
|
WebSocket end point:
|
||||||
|
|
||||||
|
.. code-block:: js
|
||||||
|
|
||||||
|
var mopidy = new Mopidy({
|
||||||
|
webSocketUrl: "ws://localhost:6680/mopidy/ws/"
|
||||||
|
});
|
||||||
|
|
||||||
|
It is also possible to create an instance first and connect to the WebSocket
|
||||||
|
later:
|
||||||
|
|
||||||
|
.. code-block:: js
|
||||||
|
|
||||||
|
var mopidy = new Mopidy({autoConnect: false});
|
||||||
|
// ... do other stuff, like hooking up events ...
|
||||||
|
mopidy.connect();
|
||||||
|
|
||||||
|
|
||||||
|
Hooking up to events
|
||||||
|
--------------------
|
||||||
|
|
||||||
|
Once you have a Mopidy.js object, you can hook up to the events it emits. To
|
||||||
|
explore your possibilities, it can be useful to subscribe to all events and log
|
||||||
|
them:
|
||||||
|
|
||||||
|
.. code-block:: js
|
||||||
|
|
||||||
|
mopidy.on(console.log);
|
||||||
|
|
||||||
|
Several types of events are emitted:
|
||||||
|
|
||||||
|
- You can get notified about when the Mopidy.js object is connected to the
|
||||||
|
server and ready for method calls, when it's offline, and when it's trying to
|
||||||
|
reconnect to the server by looking at the events ``state:online``,
|
||||||
|
``state:offline``, ``reconnectionPending``, and ``reconnecting``.
|
||||||
|
|
||||||
|
- You can get events sent from the Mopidy server by looking at the events with
|
||||||
|
the name prefix ``event:``, like ``event:trackPlaybackStarted``.
|
||||||
|
|
||||||
|
- You can introspect what happens internally on the WebSocket by looking at the
|
||||||
|
events emitted with the name prefix ``websocket:``.
|
||||||
|
|
||||||
|
Mopidy.js uses the event emitter library `BANE
|
||||||
|
<https://github.com/busterjs/bane>`_, so you should refer to BANE's
|
||||||
|
short API documentation to see how you can hook up your listeners to the
|
||||||
|
different events.
|
||||||
|
|
||||||
|
|
||||||
|
Calling core API methods
|
||||||
|
------------------------
|
||||||
|
|
||||||
|
Once your Mopidy.js object has connected to the Mopidy server and emits the
|
||||||
|
``state:online`` event, it is ready to accept core API method calls:
|
||||||
|
|
||||||
|
.. code-block:: js
|
||||||
|
|
||||||
|
mopidy.on("state:online", function () [
|
||||||
|
mopidy.playback.next();
|
||||||
|
});
|
||||||
|
|
||||||
|
Any calls you make before the ``state:online`` event is emitted will fail. If
|
||||||
|
you've hooked up an errback (more on that a bit later) to the promise returned
|
||||||
|
from the call, the errback will be called with an error message.
|
||||||
|
|
||||||
|
All methods in Mopidy's :ref:`core-api` is available via Mopidy.js. The core
|
||||||
|
API attributes is *not* available, but that shouldn't be a problem as we've
|
||||||
|
added (undocumented) getters and setters for all of them, so you can access the
|
||||||
|
attributes as well from JavaScript.
|
||||||
|
|
||||||
|
Both the WebSocket API and the JavaScript API are based on introspection of the
|
||||||
|
core Python API. Thus, they will always be up to date and immediately reflect
|
||||||
|
any changes we do to the core API.
|
||||||
|
|
||||||
|
The best way to explore the JavaScript API, is probably by opening your
|
||||||
|
browser's console, and using its tab completion to navigate the API. You'll
|
||||||
|
find the Mopidy core API exposed under ``mopidy.playback``,
|
||||||
|
``mopidy.tracklist``, ``mopidy.playlists``, and ``mopidy.library``.
|
||||||
|
|
||||||
|
All methods in the JavaScript API have an associated data structure describing
|
||||||
|
the Python params it expects, and most methods also have the Python API
|
||||||
|
documentation available. This is available right there in the browser console,
|
||||||
|
by looking at the method's ``description`` and ``params`` attributes:
|
||||||
|
|
||||||
|
.. code-block:: js
|
||||||
|
|
||||||
|
console.log(mopidy.playback.next.params);
|
||||||
|
console.log(mopidy.playback.next.description);
|
||||||
|
|
||||||
|
JSON-RPC 2.0 limits method parameters to be sent *either* by-position or
|
||||||
|
by-name. Combinations of both, like we're used to from Python, isn't supported
|
||||||
|
by JSON-RPC 2.0. To further limit this, Mopidy.js currently only supports
|
||||||
|
passing parameters by-position.
|
||||||
|
|
||||||
|
Obviously, you'll want to get a return value from many of your method calls.
|
||||||
|
Since everything is happening across the WebSocket and maybe even across the
|
||||||
|
network, you'll get the results asynchronously. Instead of having to pass
|
||||||
|
callbacks and errbacks to every method you call, the methods return "promise"
|
||||||
|
objects, which you can use to pipe the future result as input to another
|
||||||
|
method, or to hook up callback and errback functions.
|
||||||
|
|
||||||
|
.. code-block:: js
|
||||||
|
|
||||||
|
var track = mopidy.playback.getCurrentTrack();
|
||||||
|
// => ``track`` isn't a track, but a "promise" object
|
||||||
|
|
||||||
|
Instead, typical usage will look like this:
|
||||||
|
|
||||||
|
.. code-block:: js
|
||||||
|
|
||||||
|
var printCurrentTrack = function (track) {
|
||||||
|
if (track) {
|
||||||
|
console.log("Currently playing:", track.name, "by",
|
||||||
|
track.artists[0].name, "from", track.album.name);
|
||||||
|
} else {
|
||||||
|
console.log("No current track");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mopidy.playback.getCurrentTrack().then(printCurrentTrack, console.error);
|
||||||
|
|
||||||
|
The first function passed to ``then()``, ``printCurrentTrack``, is the callback
|
||||||
|
that will be called if the method call succeeds. The second function,
|
||||||
|
``console.error``, is the errback that will be called if anything goes wrong.
|
||||||
|
If you don't hook up an errback, debugging will be hard as errors will silently
|
||||||
|
go missing.
|
||||||
|
|
||||||
|
For debugging, you may be interested in errors from function without
|
||||||
|
interesting return values as well. In that case, you can pass ``null`` as the
|
||||||
|
callback:
|
||||||
|
|
||||||
|
.. code-block:: js
|
||||||
|
|
||||||
|
mopidy.playback.next().then(null, console.error);
|
||||||
|
|
||||||
|
The promise objects returned by Mopidy.js adheres to the `CommonJS Promises/A
|
||||||
|
<http://wiki.commonjs.org/wiki/Promises/A>`_ standard. We use the
|
||||||
|
implementation known as `when.js <https://github.com/cujojs/when>`_. Please
|
||||||
|
refer to when.js' documentation or the standard for further details on how to
|
||||||
|
work with promise objects.
|
||||||
|
|
||||||
|
|
||||||
|
Cleaning up
|
||||||
|
-----------
|
||||||
|
|
||||||
|
If you for some reason want to clean up after Mopidy.js before the web page is
|
||||||
|
closed or navigated away from, you can close the WebSocket, unregister all
|
||||||
|
event listeners, and delete the object like this:
|
||||||
|
|
||||||
|
.. code-block:: js
|
||||||
|
|
||||||
|
// Close the WebSocket without reconnecting. Letting the object be garbage
|
||||||
|
// collected will have the same effect, so this isn't striclty necessary.
|
||||||
|
mopidy.close();
|
||||||
|
|
||||||
|
// Unregister all event listeners. If you don't do this, you may have
|
||||||
|
// lingering references to the object causing the garbage collector to not
|
||||||
|
// clean up after it.
|
||||||
|
mopidy.off();
|
||||||
|
|
||||||
|
// Delete your reference to the object, so it can be garbage collected.
|
||||||
|
mopidy = null;
|
||||||
|
|
||||||
|
|
||||||
|
Example to get started with
|
||||||
|
---------------------------
|
||||||
|
|
||||||
|
1. Create an empty directory for your web client.
|
||||||
|
|
||||||
|
2. Change the setting :attr:`mopidy.settings.HTTP_SERVER_STATIC_DIR` to point
|
||||||
|
to your new directory.
|
||||||
|
|
||||||
|
3. Make sure that you've included
|
||||||
|
``mopidy.frontends.http.HttpFrontend`` in
|
||||||
|
:attr:`mopidy.settings.FRONTENDS`.
|
||||||
|
|
||||||
|
4. Start/restart Mopidy.
|
||||||
|
|
||||||
|
5. Create a file in the directory named ``index.html`` containing e.g. "Hello,
|
||||||
|
world!".
|
||||||
|
|
||||||
|
6. Visit http://localhost:6680/ to confirm that you can view your new HTML file
|
||||||
|
there.
|
||||||
|
|
||||||
|
7. Include Mopidy.js in your web page:
|
||||||
|
|
||||||
|
.. code-block:: html
|
||||||
|
|
||||||
|
<script type="text/javascript" src="/mopidy/mopidy.min.js"></script>
|
||||||
|
|
||||||
|
8. Add one of the following Mopidy.js examples of how to queue and start
|
||||||
|
playback of your first playlist either to your web page or a JavaScript file
|
||||||
|
that you include in your web page.
|
||||||
|
|
||||||
|
"Imperative" style:
|
||||||
|
|
||||||
|
.. code-block:: js
|
||||||
|
|
||||||
|
var trackDesc = function (track) {
|
||||||
|
return track.name + " by " + track.artists[0].name +
|
||||||
|
" from " + track.album.name;
|
||||||
|
};
|
||||||
|
|
||||||
|
var queueAndPlayFirstPlaylist = function () {
|
||||||
|
mopidy.playlists.getPlaylists().then(function (playlists) {
|
||||||
|
var playlist = playlists[0];
|
||||||
|
console.log("Loading playlist:", playlist.name);
|
||||||
|
mopidy.tracklist.add(playlist.tracks).then(function (tlTracks) {
|
||||||
|
mopidy.playback.play(tlTracks[0]).then(function () {
|
||||||
|
mopidy.playback.getCurrentTrack().then(function (track) {
|
||||||
|
console.log("Now playing:", trackDesc(track));
|
||||||
|
}, console.error);
|
||||||
|
}, console.error);
|
||||||
|
}, console.error);
|
||||||
|
}, console.error);
|
||||||
|
};
|
||||||
|
|
||||||
|
var mopidy = new Mopidy(); // Connect to server
|
||||||
|
mopidy.on(console.log); // Log all events
|
||||||
|
mopidy.on("state:online", queueAndPlayFirstPlaylist);
|
||||||
|
|
||||||
|
Approximately the same behavior in a more functional style, using chaining
|
||||||
|
of promisies.
|
||||||
|
|
||||||
|
.. code-block:: js
|
||||||
|
|
||||||
|
var getFirst = function (list) {
|
||||||
|
return list[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
var extractTracks = function (playlist) {
|
||||||
|
return playlist.tracks;
|
||||||
|
};
|
||||||
|
|
||||||
|
var printTypeAndName = function (model) {
|
||||||
|
console.log(model.__model__ + ": " + model.name);
|
||||||
|
// By returning the playlist, this function can be inserted
|
||||||
|
// anywhere a model with a name is piped in the chain.
|
||||||
|
return model;
|
||||||
|
};
|
||||||
|
|
||||||
|
var trackDesc = function (track) {
|
||||||
|
return track.name + " by " + track.artists[0].name +
|
||||||
|
" from " + track.album.name;
|
||||||
|
};
|
||||||
|
|
||||||
|
var printNowPlaying = function () {
|
||||||
|
// By returning any arguments we get, the function can be inserted
|
||||||
|
// anywhere in the chain.
|
||||||
|
var args = arguments;
|
||||||
|
return mopidy.playback.getCurrentTrack().then(function (track) {
|
||||||
|
console.log("Now playing:", trackDesc(track));
|
||||||
|
return args;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
var queueAndPlayFirstPlaylist = function () {
|
||||||
|
mopidy.playlists.getPlaylists()
|
||||||
|
// => list of Playlists
|
||||||
|
.then(getFirst, console.error)
|
||||||
|
// => Playlist
|
||||||
|
.then(printTypeAndName, console.error)
|
||||||
|
// => Playlist
|
||||||
|
.then(extractTracks, console.error)
|
||||||
|
// => list of Tracks
|
||||||
|
.then(mopidy.tracklist.add, console.error)
|
||||||
|
// => list of TlTracks
|
||||||
|
.then(getFirst, console.error)
|
||||||
|
// => TlTrack
|
||||||
|
.then(mopidy.playback.play, console.error)
|
||||||
|
// => null
|
||||||
|
.then(printNowPlaying, console.error);
|
||||||
|
};
|
||||||
|
|
||||||
|
var mopidy = new Mopidy(); // Connect to server
|
||||||
|
mopidy.on(console.log); // Log all events
|
||||||
|
mopidy.on("state:online", queueAndPlayFirstPlaylist);
|
||||||
|
|
||||||
|
9. The web page should now queue and play your first playlist every time your
|
||||||
|
load it. See the browser's console for output from the function, any errors,
|
||||||
|
and a all events that are emitted.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# flake8: noqa
|
||||||
|
from .actor import HttpFrontend
|
||||||
113
mopidy/frontends/http/actor.py
Normal file
113
mopidy/frontends/http/actor.py
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pykka
|
||||||
|
|
||||||
|
from mopidy import exceptions, models, settings
|
||||||
|
from mopidy.core import CoreListener
|
||||||
|
|
||||||
|
try:
|
||||||
|
import cherrypy
|
||||||
|
from ws4py.messaging import TextMessage
|
||||||
|
from ws4py.server.cherrypyserver import WebSocketPlugin, WebSocketTool
|
||||||
|
except ImportError as import_error:
|
||||||
|
raise exceptions.OptionalDependencyError(import_error)
|
||||||
|
|
||||||
|
from . import ws
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger('mopidy.frontends.http')
|
||||||
|
|
||||||
|
|
||||||
|
class HttpFrontend(pykka.ThreadingActor, CoreListener):
|
||||||
|
def __init__(self, core):
|
||||||
|
super(HttpFrontend, self).__init__()
|
||||||
|
self.core = core
|
||||||
|
self._setup_server()
|
||||||
|
self._setup_websocket_plugin()
|
||||||
|
app = self._create_app()
|
||||||
|
self._setup_logging(app)
|
||||||
|
|
||||||
|
def _setup_server(self):
|
||||||
|
cherrypy.config.update({
|
||||||
|
'engine.autoreload_on': False,
|
||||||
|
'server.socket_host': (
|
||||||
|
settings.HTTP_SERVER_HOSTNAME.encode('utf-8')),
|
||||||
|
'server.socket_port': settings.HTTP_SERVER_PORT,
|
||||||
|
})
|
||||||
|
|
||||||
|
def _setup_websocket_plugin(self):
|
||||||
|
WebSocketPlugin(cherrypy.engine).subscribe()
|
||||||
|
cherrypy.tools.websocket = WebSocketTool()
|
||||||
|
|
||||||
|
def _create_app(self):
|
||||||
|
root = RootResource()
|
||||||
|
root.mopidy = MopidyResource()
|
||||||
|
root.mopidy.ws = ws.WebSocketResource(self.core)
|
||||||
|
|
||||||
|
if settings.HTTP_SERVER_STATIC_DIR:
|
||||||
|
static_dir = settings.HTTP_SERVER_STATIC_DIR
|
||||||
|
else:
|
||||||
|
static_dir = os.path.join(os.path.dirname(__file__), 'data')
|
||||||
|
logger.debug('HTTP server will serve "%s" at /', static_dir)
|
||||||
|
|
||||||
|
mopidy_dir = os.path.join(os.path.dirname(__file__), 'data')
|
||||||
|
favicon = os.path.join(mopidy_dir, 'favicon.png')
|
||||||
|
|
||||||
|
config = {
|
||||||
|
b'/': {
|
||||||
|
'tools.staticdir.on': True,
|
||||||
|
'tools.staticdir.index': 'index.html',
|
||||||
|
'tools.staticdir.dir': static_dir,
|
||||||
|
},
|
||||||
|
b'/favicon.ico': {
|
||||||
|
'tools.staticfile.on': True,
|
||||||
|
'tools.staticfile.filename': favicon,
|
||||||
|
},
|
||||||
|
b'/mopidy': {
|
||||||
|
'tools.staticdir.on': True,
|
||||||
|
'tools.staticdir.index': 'mopidy.html',
|
||||||
|
'tools.staticdir.dir': mopidy_dir,
|
||||||
|
},
|
||||||
|
b'/mopidy/ws': {
|
||||||
|
'tools.websocket.on': True,
|
||||||
|
'tools.websocket.handler_cls': ws.WebSocketHandler,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return cherrypy.tree.mount(root, '/', config)
|
||||||
|
|
||||||
|
def _setup_logging(self, app):
|
||||||
|
cherrypy.log.access_log.setLevel(logging.NOTSET)
|
||||||
|
cherrypy.log.error_log.setLevel(logging.NOTSET)
|
||||||
|
cherrypy.log.screen = False
|
||||||
|
|
||||||
|
app.log.access_log.setLevel(logging.NOTSET)
|
||||||
|
app.log.error_log.setLevel(logging.NOTSET)
|
||||||
|
|
||||||
|
def on_start(self):
|
||||||
|
logger.debug('Starting HTTP server')
|
||||||
|
cherrypy.engine.start()
|
||||||
|
logger.info('HTTP server running at %s', cherrypy.server.base())
|
||||||
|
|
||||||
|
def on_stop(self):
|
||||||
|
logger.debug('Stopping HTTP server')
|
||||||
|
cherrypy.engine.exit()
|
||||||
|
logger.info('Stopped HTTP server')
|
||||||
|
|
||||||
|
def on_event(self, name, **data):
|
||||||
|
event = data
|
||||||
|
event['event'] = name
|
||||||
|
message = json.dumps(event, cls=models.ModelJSONEncoder)
|
||||||
|
cherrypy.engine.publish('websocket-broadcast', TextMessage(message))
|
||||||
|
|
||||||
|
|
||||||
|
class RootResource(object):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class MopidyResource(object):
|
||||||
|
pass
|
||||||
BIN
mopidy/frontends/http/data/favicon.png
Normal file
BIN
mopidy/frontends/http/data/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.9 KiB |
29
mopidy/frontends/http/data/index.html
Normal file
29
mopidy/frontends/http/data/index.html
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Mopidy HTTP frontend</title>
|
||||||
|
<link rel="stylesheet" type="text/css" href="mopidy.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="box focus">
|
||||||
|
<h1>Mopidy HTTP frontend</h1>
|
||||||
|
|
||||||
|
<p>This web server is a part of the music server Mopidy. To learn more
|
||||||
|
about Mopidy, please visit
|
||||||
|
<a href="http://www.mopidy.com/">www.mopidy.com</a>.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="box">
|
||||||
|
<h2>Static content serving</h2>
|
||||||
|
|
||||||
|
<p>To see your own content instead of this placeholder page, change the
|
||||||
|
setting <tt>HTTP_SERVER_STATIC_DIR</tt> to point to the directory
|
||||||
|
containing your static files. This can be used to host e.g. a pure
|
||||||
|
HTML/CSS/JavaScript Mopidy client.</p>
|
||||||
|
|
||||||
|
<p>If you replace this page with your own content, the Mopidy resources
|
||||||
|
at <a href="/mopidy/">/mopidy/</a> will still be available.</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
75
mopidy/frontends/http/data/mopidy.css
Normal file
75
mopidy/frontends/http/data/mopidy.css
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
html {
|
||||||
|
background: #e8ecef;
|
||||||
|
color: #555;
|
||||||
|
font-family: "Droid Serif", "Georgia", "Times New Roman", "Palatino",
|
||||||
|
"Hoefler Text", "Baskerville", serif;
|
||||||
|
font-size: 150%;
|
||||||
|
line-height: 1.4em;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
max-width: 20em;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
div.box {
|
||||||
|
background: white;
|
||||||
|
border-radius: 5px;
|
||||||
|
box-shadow: 5px 5px 5px #d8dcdf;
|
||||||
|
margin: 2em 0;
|
||||||
|
padding: 1em;
|
||||||
|
}
|
||||||
|
div.box.focus {
|
||||||
|
background: #465158;
|
||||||
|
color: #e8ecef;
|
||||||
|
}
|
||||||
|
div.icon {
|
||||||
|
float: right;
|
||||||
|
}
|
||||||
|
h1, h2 {
|
||||||
|
font-family: "Ubuntu", "Arial", "Helvetica", "Lucida Grande",
|
||||||
|
"Verdana", "Gill Sans", sans-serif;
|
||||||
|
line-height: 1.1em;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
margin: 0.2em 0 0;
|
||||||
|
}
|
||||||
|
p.next {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
color: #555;
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: 1px dotted;
|
||||||
|
}
|
||||||
|
img {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
code, pre {
|
||||||
|
font-family: "Droid Sans Mono", Menlo, Courier New, Courier, Mono, monospace;
|
||||||
|
font-size: 9pt;
|
||||||
|
line-height: 1.2em;
|
||||||
|
padding: 0.5em 1em;
|
||||||
|
margin: 1em 0;
|
||||||
|
white-space: pre;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.box code,
|
||||||
|
.box pre {
|
||||||
|
background: #e8ecef;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
.box a {
|
||||||
|
color: #465158;
|
||||||
|
}
|
||||||
|
.box a:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
.box.focus a {
|
||||||
|
color: #e8ecef;
|
||||||
|
}
|
||||||
|
.center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
#ws-console {
|
||||||
|
height: 200px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
51
mopidy/frontends/http/data/mopidy.html
Normal file
51
mopidy/frontends/http/data/mopidy.html
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Mopidy HTTP frontend</title>
|
||||||
|
<link rel="stylesheet" type="text/css" href="mopidy.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="box focus">
|
||||||
|
<h1>Mopidy HTTP frontend</h1>
|
||||||
|
|
||||||
|
<p>This web server is a part of the music server Mopidy. To learn more
|
||||||
|
about Mopidy, please visit <a
|
||||||
|
href="http://www.mopidy.com/">www.mopidy.com</a>.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="box">
|
||||||
|
<h2>WebSocket endpoint</h2>
|
||||||
|
|
||||||
|
<p>Mopidy has a WebSocket endpoint at <tt>/mopidy/ws/</tt>. You can use
|
||||||
|
this end point to access Mopidy's full API, and to get notified about
|
||||||
|
events happening in Mopidy.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="box">
|
||||||
|
<h2>Example</h2>
|
||||||
|
|
||||||
|
<p>Here you can see events arriving from Mopidy in real time:</p>
|
||||||
|
|
||||||
|
<pre id="ws-console"></pre>
|
||||||
|
|
||||||
|
<p>Nothing to see? Try playing a track using your MPD client.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="box focus">
|
||||||
|
<h2>Documentation</h2>
|
||||||
|
|
||||||
|
<p>For more information, please refer to the Mopidy documentation at
|
||||||
|
<a href="http://docs.mopidy.com/">docs.mopidy.com</a>.</p>
|
||||||
|
</div>
|
||||||
|
<script type="text/javascript">
|
||||||
|
var ws = new WebSocket("ws://" + document.location.host + "/mopidy/ws/");
|
||||||
|
ws.onmessage = function (message) {
|
||||||
|
var console = document.getElementById('ws-console');
|
||||||
|
var newLine = (new Date()).toLocaleTimeString() + ": " +
|
||||||
|
message.data + "\n";
|
||||||
|
console.innerHTML = newLine + console.innerHTML;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1187
mopidy/frontends/http/data/mopidy.js
Normal file
1187
mopidy/frontends/http/data/mopidy.js
Normal file
File diff suppressed because it is too large
Load Diff
5
mopidy/frontends/http/data/mopidy.min.js
vendored
Normal file
5
mopidy/frontends/http/data/mopidy.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
72
mopidy/frontends/http/ws.py
Normal file
72
mopidy/frontends/http/ws.py
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from mopidy import core, exceptions, models
|
||||||
|
from mopidy.utils import jsonrpc
|
||||||
|
|
||||||
|
try:
|
||||||
|
import cherrypy
|
||||||
|
from ws4py.websocket import WebSocket
|
||||||
|
except ImportError as import_error:
|
||||||
|
raise exceptions.OptionalDependencyError(import_error)
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger('mopidy.frontends.http')
|
||||||
|
|
||||||
|
|
||||||
|
class WebSocketResource(object):
|
||||||
|
def __init__(self, core_proxy):
|
||||||
|
self._core = core_proxy
|
||||||
|
inspector = jsonrpc.JsonRpcInspector(
|
||||||
|
objects={
|
||||||
|
'core.library': core.LibraryController,
|
||||||
|
'core.playback': core.PlaybackController,
|
||||||
|
'core.playlists': core.PlaylistsController,
|
||||||
|
'core.tracklist': core.TracklistController,
|
||||||
|
})
|
||||||
|
self.jsonrpc = jsonrpc.JsonRpcWrapper(
|
||||||
|
objects={
|
||||||
|
'core.describe': inspector.describe,
|
||||||
|
'core.library': self._core.library,
|
||||||
|
'core.playback': self._core.playback,
|
||||||
|
'core.playlists': self._core.playlists,
|
||||||
|
'core.tracklist': self._core.tracklist,
|
||||||
|
},
|
||||||
|
decoders=[models.model_json_decoder],
|
||||||
|
encoders=[models.ModelJSONEncoder])
|
||||||
|
|
||||||
|
@cherrypy.expose
|
||||||
|
def index(self):
|
||||||
|
logger.debug('WebSocket handler created')
|
||||||
|
cherrypy.request.ws_handler.jsonrpc = self.jsonrpc
|
||||||
|
|
||||||
|
|
||||||
|
class WebSocketHandler(WebSocket):
|
||||||
|
def opened(self):
|
||||||
|
remote = cherrypy.request.remote
|
||||||
|
logger.debug(
|
||||||
|
'New WebSocket connection from %s:%d',
|
||||||
|
remote.ip, remote.port)
|
||||||
|
|
||||||
|
def closed(self, code, reason=None):
|
||||||
|
remote = cherrypy.request.remote
|
||||||
|
logger.debug(
|
||||||
|
'Closed WebSocket connection from %s:%d '
|
||||||
|
'with code %s and reason %r',
|
||||||
|
remote.ip, remote.port, code, reason)
|
||||||
|
|
||||||
|
def received_message(self, request):
|
||||||
|
remote = cherrypy.request.remote
|
||||||
|
request = str(request)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
'Received WebSocket message from %s:%d: %r',
|
||||||
|
remote.ip, remote.port, request)
|
||||||
|
|
||||||
|
response = self.jsonrpc.handle_json(request)
|
||||||
|
if response:
|
||||||
|
self.send(response)
|
||||||
|
logger.debug(
|
||||||
|
'Sent WebSocket message to %s:%d: %r',
|
||||||
|
remote.ip, remote.port, response)
|
||||||
@ -2,6 +2,7 @@ from __future__ import unicode_literals
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import urllib
|
||||||
|
|
||||||
from mopidy import settings
|
from mopidy import settings
|
||||||
from mopidy.frontends.mpd import protocol
|
from mopidy.frontends.mpd import protocol
|
||||||
@ -153,42 +154,56 @@ def tracks_to_tag_cache_format(tracks):
|
|||||||
|
|
||||||
|
|
||||||
def _add_to_tag_cache(result, folders, files):
|
def _add_to_tag_cache(result, folders, files):
|
||||||
music_folder = settings.LOCAL_MUSIC_PATH
|
base_path = settings.LOCAL_MUSIC_PATH.encode('utf-8')
|
||||||
regexp = '^' + re.escape(music_folder).rstrip('/') + '/?'
|
|
||||||
|
|
||||||
for path, entry in folders.items():
|
for path, entry in folders.items():
|
||||||
name = os.path.split(path)[1]
|
try:
|
||||||
mtime = get_mtime(os.path.join(music_folder, path))
|
text_path = path.decode('utf-8')
|
||||||
result.append(('directory', path))
|
except UnicodeDecodeError:
|
||||||
result.append(('mtime', mtime))
|
text_path = urllib.quote(path).decode('utf-8')
|
||||||
|
name = os.path.split(text_path)[1]
|
||||||
|
result.append(('directory', text_path))
|
||||||
|
result.append(('mtime', get_mtime(os.path.join(base_path, path))))
|
||||||
result.append(('begin', name))
|
result.append(('begin', name))
|
||||||
_add_to_tag_cache(result, *entry)
|
_add_to_tag_cache(result, *entry)
|
||||||
result.append(('end', name))
|
result.append(('end', name))
|
||||||
|
|
||||||
result.append(('songList begin',))
|
result.append(('songList begin',))
|
||||||
|
|
||||||
for track in files:
|
for track in files:
|
||||||
track_result = dict(track_to_mpd_format(track))
|
track_result = dict(track_to_mpd_format(track))
|
||||||
|
|
||||||
path = uri_to_path(track_result['file'])
|
path = uri_to_path(track_result['file'])
|
||||||
|
try:
|
||||||
|
text_path = path.decode('utf-8')
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
text_path = urllib.quote(path).decode('utf-8')
|
||||||
|
relative_path = os.path.relpath(path, base_path)
|
||||||
|
relative_uri = urllib.quote(relative_path)
|
||||||
|
|
||||||
|
track_result['file'] = relative_uri
|
||||||
track_result['mtime'] = get_mtime(path)
|
track_result['mtime'] = get_mtime(path)
|
||||||
track_result['file'] = re.sub(regexp, '', path)
|
track_result['key'] = os.path.basename(text_path)
|
||||||
track_result['key'] = os.path.basename(track_result['file'])
|
|
||||||
track_result = order_mpd_track_info(track_result.items())
|
track_result = order_mpd_track_info(track_result.items())
|
||||||
|
|
||||||
result.extend(track_result)
|
result.extend(track_result)
|
||||||
|
|
||||||
result.append(('songList end',))
|
result.append(('songList end',))
|
||||||
|
|
||||||
|
|
||||||
def tracks_to_directory_tree(tracks):
|
def tracks_to_directory_tree(tracks):
|
||||||
directories = ({}, [])
|
directories = ({}, [])
|
||||||
|
|
||||||
for track in tracks:
|
for track in tracks:
|
||||||
path = ''
|
path = b''
|
||||||
current = directories
|
current = directories
|
||||||
|
|
||||||
local_folder = settings.LOCAL_MUSIC_PATH
|
absolute_track_dir_path = os.path.dirname(uri_to_path(track.uri))
|
||||||
track_path = uri_to_path(track.uri)
|
relative_track_dir_path = re.sub(
|
||||||
track_path = re.sub('^' + re.escape(local_folder), '', track_path)
|
'^' + re.escape(settings.LOCAL_MUSIC_PATH), b'',
|
||||||
track_dir = os.path.dirname(track_path)
|
absolute_track_dir_path)
|
||||||
|
|
||||||
for part in split_path(track_dir):
|
for part in split_path(relative_track_dir_path):
|
||||||
path = os.path.join(path, part)
|
path = os.path.join(path, part)
|
||||||
if path not in current[0]:
|
if path not in current[0]:
|
||||||
current[0][path] = ({}, [])
|
current[0][path] = ({}, [])
|
||||||
|
|||||||
@ -1,11 +1,35 @@
|
|||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
import logging
|
|
||||||
import datetime
|
import datetime
|
||||||
|
import logging
|
||||||
|
import optparse
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
import gobject
|
import gobject
|
||||||
gobject.threads_init()
|
gobject.threads_init()
|
||||||
|
|
||||||
|
|
||||||
|
# Extract any non-GStreamer arguments, and leave the GStreamer arguments for
|
||||||
|
# processing by GStreamer. This needs to be done before GStreamer is imported,
|
||||||
|
# so that GStreamer doesn't hijack e.g. ``--help``.
|
||||||
|
# NOTE This naive fix does not support values like ``bar`` in
|
||||||
|
# ``--gst-foo bar``. Use equals to pass values, like ``--gst-foo=bar``.
|
||||||
|
|
||||||
|
def is_gst_arg(argument):
|
||||||
|
return argument.startswith('--gst') or argument == '--help-gst'
|
||||||
|
|
||||||
|
gstreamer_args = [arg for arg in sys.argv[1:] if is_gst_arg(arg)]
|
||||||
|
mopidy_args = [arg for arg in sys.argv[1:] if not is_gst_arg(arg)]
|
||||||
|
sys.argv[1:] = gstreamer_args
|
||||||
|
|
||||||
|
|
||||||
|
# Add ../ to the path so we can run Mopidy from a Git checkout without
|
||||||
|
# installing it on the system.
|
||||||
|
sys.path.insert(
|
||||||
|
0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../')))
|
||||||
|
|
||||||
|
|
||||||
import pygst
|
import pygst
|
||||||
pygst.require('0.10')
|
pygst.require('0.10')
|
||||||
import gst
|
import gst
|
||||||
@ -13,12 +37,14 @@ import gst
|
|||||||
from mopidy import settings
|
from mopidy import settings
|
||||||
from mopidy.frontends.mpd import translator as mpd_translator
|
from mopidy.frontends.mpd import translator as mpd_translator
|
||||||
from mopidy.models import Track, Artist, Album
|
from mopidy.models import Track, Artist, Album
|
||||||
from mopidy.utils import log, path
|
from mopidy.utils import log, path, versioning
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
options = parse_options()
|
||||||
|
|
||||||
log.setup_root_logger()
|
log.setup_root_logger()
|
||||||
log.setup_console_logging(2)
|
log.setup_console_logging(options.verbosity_level)
|
||||||
|
|
||||||
tracks = []
|
tracks = []
|
||||||
|
|
||||||
@ -28,16 +54,18 @@ def main():
|
|||||||
logging.debug('Added %s', track.uri)
|
logging.debug('Added %s', track.uri)
|
||||||
|
|
||||||
def debug(uri, error, debug):
|
def debug(uri, error, debug):
|
||||||
logging.error('Failed %s: %s - %s', uri, error, debug)
|
logging.warning('Failed %s: %s', uri, error)
|
||||||
|
logging.debug('Debug info for %s: %s', uri, debug)
|
||||||
|
|
||||||
logging.info('Scanning %s', settings.LOCAL_MUSIC_PATH)
|
logging.info('Scanning %s', settings.LOCAL_MUSIC_PATH)
|
||||||
|
|
||||||
scanner = Scanner(settings.LOCAL_MUSIC_PATH, store, debug)
|
scanner = Scanner(settings.LOCAL_MUSIC_PATH, store, debug)
|
||||||
try:
|
try:
|
||||||
scanner.start()
|
scanner.start()
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
scanner.stop()
|
scanner.stop()
|
||||||
|
|
||||||
logging.info('Done')
|
logging.info('Done scanning; writing tag cache...')
|
||||||
|
|
||||||
for row in mpd_translator.tracks_to_tag_cache_format(tracks):
|
for row in mpd_translator.tracks_to_tag_cache_format(tracks):
|
||||||
if len(row) == 1:
|
if len(row) == 1:
|
||||||
@ -45,6 +73,22 @@ def main():
|
|||||||
else:
|
else:
|
||||||
print ('%s: %s' % row).encode('utf-8')
|
print ('%s: %s' % row).encode('utf-8')
|
||||||
|
|
||||||
|
logging.info('Done writing tag cache')
|
||||||
|
|
||||||
|
|
||||||
|
def parse_options():
|
||||||
|
parser = optparse.OptionParser(
|
||||||
|
version='Mopidy %s' % versioning.get_version())
|
||||||
|
parser.add_option(
|
||||||
|
'-q', '--quiet',
|
||||||
|
action='store_const', const=0, dest='verbosity_level',
|
||||||
|
help='less output (warning level)')
|
||||||
|
parser.add_option(
|
||||||
|
'-v', '--verbose',
|
||||||
|
action='count', default=1, dest='verbosity_level',
|
||||||
|
help='more output (debug level)')
|
||||||
|
return parser.parse_args(args=mopidy_args)[0]
|
||||||
|
|
||||||
|
|
||||||
def translator(data):
|
def translator(data):
|
||||||
albumartist_kwargs = {}
|
albumartist_kwargs = {}
|
||||||
@ -62,8 +106,12 @@ def translator(data):
|
|||||||
|
|
||||||
if gst.TAG_DATE in data and data[gst.TAG_DATE]:
|
if gst.TAG_DATE in data and data[gst.TAG_DATE]:
|
||||||
date = data[gst.TAG_DATE]
|
date = data[gst.TAG_DATE]
|
||||||
date = datetime.date(date.year, date.month, date.day)
|
try:
|
||||||
track_kwargs['date'] = date
|
date = datetime.date(date.year, date.month, date.day)
|
||||||
|
except ValueError:
|
||||||
|
pass # Ignore invalid dates
|
||||||
|
else:
|
||||||
|
track_kwargs['date'] = date.isoformat()
|
||||||
|
|
||||||
_retrieve(gst.TAG_TITLE, 'name', track_kwargs)
|
_retrieve(gst.TAG_TITLE, 'name', track_kwargs)
|
||||||
_retrieve(gst.TAG_TRACK_NUMBER, 'track_no', track_kwargs)
|
_retrieve(gst.TAG_TRACK_NUMBER, 'track_no', track_kwargs)
|
||||||
@ -188,3 +236,7 @@ class Scanner(object):
|
|||||||
def stop(self):
|
def stop(self):
|
||||||
self.pipe.set_state(gst.STATE_NULL)
|
self.pipe.set_state(gst.STATE_NULL)
|
||||||
self.loop.quit()
|
self.loop.quit()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
|
|||||||
@ -80,6 +80,39 @@ FRONTENDS = (
|
|||||||
'mopidy.frontends.mpris.MprisFrontend',
|
'mopidy.frontends.mpris.MprisFrontend',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
#: Which address Mopidy's HTTP server should bind to.
|
||||||
|
#:
|
||||||
|
#: Used by :mod:`mopidy.frontends.http`.
|
||||||
|
#:
|
||||||
|
#: Examples:
|
||||||
|
#:
|
||||||
|
#: ``127.0.0.1``
|
||||||
|
#: Listens only on the IPv4 loopback interface. Default.
|
||||||
|
#: ``::1``
|
||||||
|
#: Listens only on the IPv6 loopback interface.
|
||||||
|
#: ``0.0.0.0``
|
||||||
|
#: Listens on all IPv4 interfaces.
|
||||||
|
#: ``::``
|
||||||
|
#: Listens on all interfaces, both IPv4 and IPv6.
|
||||||
|
HTTP_SERVER_HOSTNAME = u'127.0.0.1'
|
||||||
|
|
||||||
|
#: Which TCP port Mopidy's HTTP server should listen to.
|
||||||
|
#:
|
||||||
|
#: Used by :mod:`mopidy.frontends.http`.
|
||||||
|
#:
|
||||||
|
#: Default: 6680
|
||||||
|
HTTP_SERVER_PORT = 6680
|
||||||
|
|
||||||
|
#: Which directory Mopidy's HTTP server should serve at /.
|
||||||
|
#:
|
||||||
|
#: Change this to have Mopidy serve e.g. files for your JavaScript client.
|
||||||
|
#: /api and /ws will continue to work as usual even if you change this setting.
|
||||||
|
#:
|
||||||
|
#: Used by :mod:`mopidy.frontends.http`.
|
||||||
|
#:
|
||||||
|
#: Default: None
|
||||||
|
HTTP_SERVER_STATIC_DIR = None
|
||||||
|
|
||||||
#: Your `Last.fm <http://www.last.fm/>`_ username.
|
#: Your `Last.fm <http://www.last.fm/>`_ username.
|
||||||
#:
|
#:
|
||||||
#: Used by :mod:`mopidy.frontends.lastfm`.
|
#: Used by :mod:`mopidy.frontends.lastfm`.
|
||||||
|
|||||||
@ -35,6 +35,8 @@ def format_dependency_list(adapters=None):
|
|||||||
pylast_info,
|
pylast_info,
|
||||||
dbus_info,
|
dbus_info,
|
||||||
serial_info,
|
serial_info,
|
||||||
|
cherrypy_info,
|
||||||
|
ws4py_info,
|
||||||
]
|
]
|
||||||
|
|
||||||
lines = []
|
lines = []
|
||||||
@ -189,3 +191,25 @@ def serial_info():
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
pass
|
pass
|
||||||
return dep_info
|
return dep_info
|
||||||
|
|
||||||
|
|
||||||
|
def cherrypy_info():
|
||||||
|
dep_info = {'name': 'cherrypy'}
|
||||||
|
try:
|
||||||
|
import cherrypy
|
||||||
|
dep_info['version'] = cherrypy.__version__
|
||||||
|
dep_info['path'] = cherrypy.__file__
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
return dep_info
|
||||||
|
|
||||||
|
|
||||||
|
def ws4py_info():
|
||||||
|
dep_info = {'name': 'ws4py'}
|
||||||
|
try:
|
||||||
|
import ws4py
|
||||||
|
dep_info['version'] = ws4py.__version__
|
||||||
|
dep_info['path'] = ws4py.__file__
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
return dep_info
|
||||||
|
|||||||
383
mopidy/utils/jsonrpc.py
Normal file
383
mopidy/utils/jsonrpc.py
Normal file
@ -0,0 +1,383 @@
|
|||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
import json
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
import pykka
|
||||||
|
|
||||||
|
|
||||||
|
class JsonRpcWrapper(object):
|
||||||
|
"""
|
||||||
|
Wrap objects and make them accessible through JSON-RPC 2.0 messaging.
|
||||||
|
|
||||||
|
This class takes responsibility of communicating with the objects and
|
||||||
|
processing of JSON-RPC 2.0 messages. The transport of the messages over
|
||||||
|
HTTP, WebSocket, TCP, or whatever is of no concern to this class.
|
||||||
|
|
||||||
|
The wrapper supports exporting the methods of one or more objects. Either
|
||||||
|
way, the objects must be exported with method name prefixes, called
|
||||||
|
"mounts".
|
||||||
|
|
||||||
|
To expose objects, add them all to the objects mapping. The key in the
|
||||||
|
mapping is used as the object's mounting point in the exposed API::
|
||||||
|
|
||||||
|
jrw = JsonRpcWrapper(objects={
|
||||||
|
'foo': foo,
|
||||||
|
'hello': lambda: 'Hello, world!',
|
||||||
|
})
|
||||||
|
|
||||||
|
This will export the Python callables on the left as the JSON-RPC 2.0
|
||||||
|
method names on the right::
|
||||||
|
|
||||||
|
foo.bar() -> foo.bar
|
||||||
|
foo.baz() -> foo.baz
|
||||||
|
lambda -> hello
|
||||||
|
|
||||||
|
Only the public methods of the mounted objects, or functions/methods
|
||||||
|
included directly in the mapping, will be exposed.
|
||||||
|
|
||||||
|
If a method returns a :class:`pykka.Future`, the future will be completed
|
||||||
|
and its value unwrapped before the JSON-RPC wrapper returns the response.
|
||||||
|
|
||||||
|
For further details on the JSON-RPC 2.0 spec, see
|
||||||
|
http://www.jsonrpc.org/specification
|
||||||
|
|
||||||
|
:param objects: mapping between mounting points and exposed functions or
|
||||||
|
class instances
|
||||||
|
:type objects: dict
|
||||||
|
:param decoders: object builders to be used by :func`json.loads`
|
||||||
|
:type decoders: list of functions taking a dict and returning a dict
|
||||||
|
:param encoders: object serializers to be used by :func:`json.dumps`
|
||||||
|
:type encoders: list of :class:`json.JSONEncoder` subclasses with the
|
||||||
|
method :meth:`default` implemented
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, objects, decoders=None, encoders=None):
|
||||||
|
if '' in objects.keys():
|
||||||
|
raise AttributeError(
|
||||||
|
'The empty string is not allowed as an object mount')
|
||||||
|
self.objects = objects
|
||||||
|
self.decoder = get_combined_json_decoder(decoders or [])
|
||||||
|
self.encoder = get_combined_json_encoder(encoders or [])
|
||||||
|
|
||||||
|
def handle_json(self, request):
|
||||||
|
"""
|
||||||
|
Handles an incoming request encoded as a JSON string.
|
||||||
|
|
||||||
|
Returns a response as a JSON string for commands, and :class:`None` for
|
||||||
|
notifications.
|
||||||
|
|
||||||
|
:param request: the serialized JSON-RPC request
|
||||||
|
:type request: string
|
||||||
|
:rtype: string or :class:`None`
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
request = json.loads(request, object_hook=self.decoder)
|
||||||
|
except ValueError:
|
||||||
|
response = JsonRpcParseError().get_response()
|
||||||
|
else:
|
||||||
|
response = self.handle_data(request)
|
||||||
|
if response is None:
|
||||||
|
return None
|
||||||
|
return json.dumps(response, cls=self.encoder)
|
||||||
|
|
||||||
|
def handle_data(self, request):
|
||||||
|
"""
|
||||||
|
Handles an incoming request in the form of a Python data structure.
|
||||||
|
|
||||||
|
Returns a Python data structure for commands, or a :class:`None` for
|
||||||
|
notifications.
|
||||||
|
|
||||||
|
:param request: the unserialized JSON-RPC request
|
||||||
|
:type request: dict
|
||||||
|
:rtype: dict, list, or :class:`None`
|
||||||
|
"""
|
||||||
|
if isinstance(request, list):
|
||||||
|
return self._handle_batch(request)
|
||||||
|
else:
|
||||||
|
return self._handle_single_request(request)
|
||||||
|
|
||||||
|
def _handle_batch(self, requests):
|
||||||
|
if not requests:
|
||||||
|
return JsonRpcInvalidRequestError(
|
||||||
|
data='Batch list cannot be empty').get_response()
|
||||||
|
|
||||||
|
responses = []
|
||||||
|
for request in requests:
|
||||||
|
response = self._handle_single_request(request)
|
||||||
|
if response:
|
||||||
|
responses.append(response)
|
||||||
|
|
||||||
|
return responses or None
|
||||||
|
|
||||||
|
def _handle_single_request(self, request):
|
||||||
|
try:
|
||||||
|
self._validate_request(request)
|
||||||
|
args, kwargs = self._get_params(request)
|
||||||
|
except JsonRpcInvalidRequestError as error:
|
||||||
|
return error.get_response()
|
||||||
|
|
||||||
|
try:
|
||||||
|
method = self._get_method(request['method'])
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = method(*args, **kwargs)
|
||||||
|
|
||||||
|
if self._is_notification(request):
|
||||||
|
return None
|
||||||
|
|
||||||
|
result = self._unwrap_result(result)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'jsonrpc': '2.0',
|
||||||
|
'id': request['id'],
|
||||||
|
'result': result,
|
||||||
|
}
|
||||||
|
except TypeError as error:
|
||||||
|
raise JsonRpcInvalidParamsError(data={
|
||||||
|
'type': error.__class__.__name__,
|
||||||
|
'message': unicode(error),
|
||||||
|
'traceback': traceback.format_exc(),
|
||||||
|
})
|
||||||
|
except Exception as error:
|
||||||
|
raise JsonRpcApplicationError(data={
|
||||||
|
'type': error.__class__.__name__,
|
||||||
|
'message': unicode(error),
|
||||||
|
'traceback': traceback.format_exc(),
|
||||||
|
})
|
||||||
|
except JsonRpcError as error:
|
||||||
|
if self._is_notification(request):
|
||||||
|
return None
|
||||||
|
return error.get_response(request['id'])
|
||||||
|
|
||||||
|
def _validate_request(self, request):
|
||||||
|
if not isinstance(request, dict):
|
||||||
|
raise JsonRpcInvalidRequestError(
|
||||||
|
data='Request must be an object')
|
||||||
|
if not 'jsonrpc' in request:
|
||||||
|
raise JsonRpcInvalidRequestError(
|
||||||
|
data='"jsonrpc" member must be included')
|
||||||
|
if request['jsonrpc'] != '2.0':
|
||||||
|
raise JsonRpcInvalidRequestError(
|
||||||
|
data='"jsonrpc" value must be "2.0"')
|
||||||
|
if not 'method' in request:
|
||||||
|
raise JsonRpcInvalidRequestError(
|
||||||
|
data='"method" member must be included')
|
||||||
|
if not isinstance(request['method'], unicode):
|
||||||
|
raise JsonRpcInvalidRequestError(
|
||||||
|
data='"method" must be a string')
|
||||||
|
|
||||||
|
def _get_params(self, request):
|
||||||
|
if not 'params' in request:
|
||||||
|
return [], {}
|
||||||
|
params = request['params']
|
||||||
|
if isinstance(params, list):
|
||||||
|
return params, {}
|
||||||
|
elif isinstance(params, dict):
|
||||||
|
return [], params
|
||||||
|
else:
|
||||||
|
raise JsonRpcInvalidRequestError(
|
||||||
|
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)):
|
||||||
|
# The mounted object is the callable
|
||||||
|
return self.objects[method_path]
|
||||||
|
|
||||||
|
# The mounted object contains the callable
|
||||||
|
|
||||||
|
if '.' not in method_path:
|
||||||
|
raise JsonRpcMethodNotFoundError(
|
||||||
|
data='Could not find object mount in method name "%s"' % (
|
||||||
|
method_path))
|
||||||
|
|
||||||
|
mount, method_name = method_path.rsplit('.', 1)
|
||||||
|
|
||||||
|
if method_name.startswith('_'):
|
||||||
|
raise JsonRpcMethodNotFoundError(
|
||||||
|
data='Private methods are not exported')
|
||||||
|
|
||||||
|
try:
|
||||||
|
obj = self.objects[mount]
|
||||||
|
except KeyError:
|
||||||
|
raise JsonRpcMethodNotFoundError(
|
||||||
|
data='No object found at "%s"' % mount)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return getattr(obj, method_name)
|
||||||
|
except AttributeError:
|
||||||
|
raise JsonRpcMethodNotFoundError(
|
||||||
|
data='Object mounted at "%s" has no member "%s"' % (
|
||||||
|
mount, method_name))
|
||||||
|
|
||||||
|
def _is_notification(self, request):
|
||||||
|
return 'id' not in request
|
||||||
|
|
||||||
|
def _unwrap_result(self, result):
|
||||||
|
if isinstance(result, pykka.Future):
|
||||||
|
result = result.get()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class JsonRpcError(Exception):
|
||||||
|
code = -32000
|
||||||
|
message = 'Unspecified server error'
|
||||||
|
|
||||||
|
def __init__(self, data=None):
|
||||||
|
self.data = data
|
||||||
|
|
||||||
|
def get_response(self, request_id=None):
|
||||||
|
response = {
|
||||||
|
'jsonrpc': '2.0',
|
||||||
|
'id': request_id,
|
||||||
|
'error': {
|
||||||
|
'code': self.code,
|
||||||
|
'message': self.message,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if self.data:
|
||||||
|
response['error']['data'] = self.data
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
class JsonRpcParseError(JsonRpcError):
|
||||||
|
code = -32700
|
||||||
|
message = 'Parse error'
|
||||||
|
|
||||||
|
|
||||||
|
class JsonRpcInvalidRequestError(JsonRpcError):
|
||||||
|
code = -32600
|
||||||
|
message = 'Invalid Request'
|
||||||
|
|
||||||
|
|
||||||
|
class JsonRpcMethodNotFoundError(JsonRpcError):
|
||||||
|
code = -32601
|
||||||
|
message = 'Method not found'
|
||||||
|
|
||||||
|
|
||||||
|
class JsonRpcInvalidParamsError(JsonRpcError):
|
||||||
|
code = -32602
|
||||||
|
message = 'Invalid params'
|
||||||
|
|
||||||
|
|
||||||
|
class JsonRpcApplicationError(JsonRpcError):
|
||||||
|
code = 0
|
||||||
|
message = 'Application error'
|
||||||
|
|
||||||
|
|
||||||
|
def get_combined_json_decoder(decoders):
|
||||||
|
def decode(dct):
|
||||||
|
for decoder in decoders:
|
||||||
|
dct = decoder(dct)
|
||||||
|
return dct
|
||||||
|
return decode
|
||||||
|
|
||||||
|
|
||||||
|
def get_combined_json_encoder(encoders):
|
||||||
|
class JsonRpcEncoder(json.JSONEncoder):
|
||||||
|
def default(self, obj):
|
||||||
|
for encoder in encoders:
|
||||||
|
try:
|
||||||
|
return encoder().default(obj)
|
||||||
|
except TypeError:
|
||||||
|
pass # Try next encoder
|
||||||
|
return json.JSONEncoder.default(self, obj)
|
||||||
|
return JsonRpcEncoder
|
||||||
|
|
||||||
|
|
||||||
|
class JsonRpcInspector(object):
|
||||||
|
"""
|
||||||
|
Inspects a group of classes and functions to create a description of what
|
||||||
|
methods they can expose over JSON-RPC 2.0.
|
||||||
|
|
||||||
|
To inspect one or more classes, add them all to the objects mapping. The
|
||||||
|
key in the mapping is used as the classes' mounting point in the exposed
|
||||||
|
API::
|
||||||
|
|
||||||
|
jri = JsonRpcInspector(objects={
|
||||||
|
'foo': Foo,
|
||||||
|
'hello': lambda: 'Hello, world!',
|
||||||
|
})
|
||||||
|
|
||||||
|
Since the inspector is based on inspecting classes and not instances, it
|
||||||
|
will not include methods added dynamically. The wrapper works with
|
||||||
|
instances, and it will thus export dynamically added methods as well.
|
||||||
|
|
||||||
|
:param objects: mapping between mounts and exposed functions or classes
|
||||||
|
:type objects: dict
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, objects):
|
||||||
|
if '' in objects.keys():
|
||||||
|
raise AttributeError(
|
||||||
|
'The empty string is not allowed as an object mount')
|
||||||
|
self.objects = objects
|
||||||
|
|
||||||
|
def describe(self):
|
||||||
|
"""
|
||||||
|
Inspects the object and returns a data structure which describes the
|
||||||
|
available properties and methods.
|
||||||
|
"""
|
||||||
|
methods = {}
|
||||||
|
for mount, obj in self.objects.iteritems():
|
||||||
|
if inspect.isroutine(obj):
|
||||||
|
methods[mount] = self._describe_method(obj)
|
||||||
|
else:
|
||||||
|
obj_methods = self._get_methods(obj)
|
||||||
|
for name, description in obj_methods.iteritems():
|
||||||
|
if mount:
|
||||||
|
name = '%s.%s' % (mount, name)
|
||||||
|
methods[name] = description
|
||||||
|
return methods
|
||||||
|
|
||||||
|
def _get_methods(self, obj):
|
||||||
|
methods = {}
|
||||||
|
for name, value in inspect.getmembers(obj):
|
||||||
|
if name.startswith('_'):
|
||||||
|
continue
|
||||||
|
if not inspect.isroutine(value):
|
||||||
|
continue
|
||||||
|
method = self._describe_method(value)
|
||||||
|
if method:
|
||||||
|
methods[name] = method
|
||||||
|
return methods
|
||||||
|
|
||||||
|
def _describe_method(self, method):
|
||||||
|
return {
|
||||||
|
'description': inspect.getdoc(method),
|
||||||
|
'params': self._describe_params(method),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _describe_params(self, method):
|
||||||
|
argspec = inspect.getargspec(method)
|
||||||
|
|
||||||
|
defaults = argspec.defaults and list(argspec.defaults) or []
|
||||||
|
num_args_without_default = len(argspec.args) - len(defaults)
|
||||||
|
no_defaults = [None] * num_args_without_default
|
||||||
|
defaults = no_defaults + defaults
|
||||||
|
|
||||||
|
params = []
|
||||||
|
|
||||||
|
for arg, default in zip(argspec.args, defaults):
|
||||||
|
if arg == 'self':
|
||||||
|
continue
|
||||||
|
params.append({'name': arg})
|
||||||
|
|
||||||
|
if argspec.defaults:
|
||||||
|
for i, default in enumerate(reversed(argspec.defaults)):
|
||||||
|
params[len(params) - i - 1]['default'] = default
|
||||||
|
|
||||||
|
if argspec.varargs:
|
||||||
|
params.append({
|
||||||
|
'name': argspec.varargs,
|
||||||
|
'varargs': True,
|
||||||
|
})
|
||||||
|
|
||||||
|
if argspec.keywords:
|
||||||
|
params.append({
|
||||||
|
'name': argspec.keywords,
|
||||||
|
'kwargs': True,
|
||||||
|
})
|
||||||
|
|
||||||
|
return params
|
||||||
@ -46,6 +46,9 @@ def setup_console_logging(verbosity_level):
|
|||||||
if verbosity_level < 3:
|
if verbosity_level < 3:
|
||||||
logging.getLogger('pykka').setLevel(logging.INFO)
|
logging.getLogger('pykka').setLevel(logging.INFO)
|
||||||
|
|
||||||
|
if verbosity_level < 2:
|
||||||
|
logging.getLogger('cherrypy').setLevel(logging.WARNING)
|
||||||
|
|
||||||
|
|
||||||
def setup_debug_logging_to_file():
|
def setup_debug_logging_to_file():
|
||||||
formatter = logging.Formatter(settings.DEBUG_LOG_FORMAT)
|
formatter = logging.Formatter(settings.DEBUG_LOG_FORMAT)
|
||||||
|
|||||||
@ -13,14 +13,21 @@ import glib
|
|||||||
|
|
||||||
logger = logging.getLogger('mopidy.utils.path')
|
logger = logging.getLogger('mopidy.utils.path')
|
||||||
|
|
||||||
DATA_PATH = os.path.join(str(glib.get_user_data_dir()), 'mopidy')
|
XDG_CACHE_DIR = glib.get_user_cache_dir().decode('utf-8')
|
||||||
SETTINGS_PATH = os.path.join(str(glib.get_user_config_dir()), 'mopidy')
|
XDG_CONFIG_DIR = glib.get_user_config_dir().decode('utf-8')
|
||||||
SETTINGS_FILE = os.path.join(SETTINGS_PATH, 'settings.py')
|
XDG_DATA_DIR = glib.get_user_data_dir().decode('utf-8')
|
||||||
|
XDG_MUSIC_DIR = glib.get_user_special_dir(glib.USER_DIRECTORY_MUSIC)
|
||||||
|
if XDG_MUSIC_DIR:
|
||||||
|
XDG_MUSIC_DIR = XDG_MUSIC_DIR.decode('utf-8')
|
||||||
XDG_DIRS = {
|
XDG_DIRS = {
|
||||||
'XDG_CACHE_DIR': glib.get_user_cache_dir(),
|
'XDG_CACHE_DIR': XDG_CACHE_DIR,
|
||||||
'XDG_DATA_DIR': glib.get_user_data_dir(),
|
'XDG_CONFIG_DIR': XDG_CONFIG_DIR,
|
||||||
'XDG_MUSIC_DIR': glib.get_user_special_dir(glib.USER_DIRECTORY_MUSIC),
|
'XDG_DATA_DIR': XDG_DATA_DIR,
|
||||||
|
'XDG_MUSIC_DIR': XDG_MUSIC_DIR,
|
||||||
}
|
}
|
||||||
|
DATA_PATH = os.path.join(unicode(XDG_DATA_DIR), 'mopidy')
|
||||||
|
SETTINGS_PATH = os.path.join(unicode(XDG_CONFIG_DIR), 'mopidy')
|
||||||
|
SETTINGS_FILE = os.path.join(unicode(SETTINGS_PATH), 'settings.py')
|
||||||
|
|
||||||
|
|
||||||
def get_or_create_folder(folder):
|
def get_or_create_folder(folder):
|
||||||
@ -44,19 +51,40 @@ def get_or_create_file(filename):
|
|||||||
|
|
||||||
|
|
||||||
def path_to_uri(*paths):
|
def path_to_uri(*paths):
|
||||||
|
"""
|
||||||
|
Convert OS specific path to file:// URI.
|
||||||
|
|
||||||
|
Accepts either unicode strings or bytestrings. The encoding of any
|
||||||
|
bytestring will be maintained so that :func:`uri_to_path` can return the
|
||||||
|
same bytestring.
|
||||||
|
|
||||||
|
Returns a file:// URI as an unicode string.
|
||||||
|
"""
|
||||||
path = os.path.join(*paths)
|
path = os.path.join(*paths)
|
||||||
path = path.encode('utf-8')
|
if isinstance(path, unicode):
|
||||||
|
path = path.encode('utf-8')
|
||||||
if sys.platform == 'win32':
|
if sys.platform == 'win32':
|
||||||
return 'file:' + urllib.pathname2url(path)
|
return 'file:' + urllib.quote(path)
|
||||||
return 'file://' + urllib.pathname2url(path)
|
return 'file://' + urllib.quote(path)
|
||||||
|
|
||||||
|
|
||||||
def uri_to_path(uri):
|
def uri_to_path(uri):
|
||||||
|
"""
|
||||||
|
Convert the file:// to a OS specific path.
|
||||||
|
|
||||||
|
Returns a bytestring, since the file path can contain chars with other
|
||||||
|
encoding than UTF-8.
|
||||||
|
|
||||||
|
If we had returned these paths as unicode strings, you wouldn't be able to
|
||||||
|
look up the matching dir or file on your file system because the exact path
|
||||||
|
would be lost by ignoring its encoding.
|
||||||
|
"""
|
||||||
|
if isinstance(uri, unicode):
|
||||||
|
uri = uri.encode('utf-8')
|
||||||
if sys.platform == 'win32':
|
if sys.platform == 'win32':
|
||||||
path = urllib.url2pathname(re.sub('^file:', '', uri))
|
return urllib.unquote(re.sub(b'^file:', b'', uri))
|
||||||
else:
|
else:
|
||||||
path = urllib.url2pathname(re.sub('^file://', '', uri))
|
return urllib.unquote(re.sub(b'^file://', b'', uri))
|
||||||
return path.encode('latin1').decode('utf-8') # Undo double encoding
|
|
||||||
|
|
||||||
|
|
||||||
def split_path(path):
|
def split_path(path):
|
||||||
@ -65,7 +93,7 @@ def split_path(path):
|
|||||||
path, part = os.path.split(path)
|
path, part = os.path.split(path)
|
||||||
if part:
|
if part:
|
||||||
parts.insert(0, part)
|
parts.insert(0, part)
|
||||||
if not path or path == '/':
|
if not path or path == b'/':
|
||||||
break
|
break
|
||||||
return parts
|
return parts
|
||||||
|
|
||||||
@ -78,30 +106,32 @@ def expand_path(path):
|
|||||||
|
|
||||||
|
|
||||||
def find_files(path):
|
def find_files(path):
|
||||||
|
"""
|
||||||
|
Finds all files within a path.
|
||||||
|
|
||||||
|
Directories and files with names starting with ``.`` is ignored.
|
||||||
|
|
||||||
|
:returns: yields the full path to files as bytestrings
|
||||||
|
"""
|
||||||
|
if isinstance(path, unicode):
|
||||||
|
path = path.encode('utf-8')
|
||||||
|
|
||||||
if os.path.isfile(path):
|
if os.path.isfile(path):
|
||||||
if not isinstance(path, unicode):
|
if not os.path.basename(path).startswith(b'.'):
|
||||||
path = path.decode('utf-8')
|
|
||||||
if not os.path.basename(path).startswith('.'):
|
|
||||||
yield path
|
yield path
|
||||||
else:
|
else:
|
||||||
for dirpath, dirnames, filenames in os.walk(path):
|
for dirpath, dirnames, filenames in os.walk(path):
|
||||||
# Filter out hidden folders by modifying dirnames in place.
|
|
||||||
for dirname in dirnames:
|
for dirname in dirnames:
|
||||||
if dirname.startswith('.'):
|
if dirname.startswith(b'.'):
|
||||||
|
# Skip hidden folders by modifying dirnames inplace
|
||||||
dirnames.remove(dirname)
|
dirnames.remove(dirname)
|
||||||
|
|
||||||
for filename in filenames:
|
for filename in filenames:
|
||||||
# Skip hidden files.
|
if filename.startswith(b'.'):
|
||||||
if filename.startswith('.'):
|
# Skip hidden files
|
||||||
continue
|
continue
|
||||||
|
|
||||||
filename = os.path.join(dirpath, filename)
|
yield os.path.join(dirpath, filename)
|
||||||
if not isinstance(filename, unicode):
|
|
||||||
try:
|
|
||||||
filename = filename.decode('utf-8')
|
|
||||||
except UnicodeDecodeError:
|
|
||||||
filename = filename.decode('latin1')
|
|
||||||
yield filename
|
|
||||||
|
|
||||||
|
|
||||||
def check_file_path_is_inside_base_dir(file_path, base_path):
|
def check_file_path_is_inside_base_dir(file_path, base_path):
|
||||||
|
|||||||
2
requirements/http.txt
Normal file
2
requirements/http.txt
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
cherrypy >= 3.2.2
|
||||||
|
ws4py >= 0.2.3
|
||||||
@ -1 +1 @@
|
|||||||
pyspotify >= 1.9, < 1.10
|
pyspotify >= 1.9, < 1.11
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
import mock
|
||||||
|
|
||||||
from mopidy import audio
|
from mopidy import audio
|
||||||
|
|
||||||
from tests import unittest
|
from tests import unittest
|
||||||
@ -9,6 +11,15 @@ class AudioListenerTest(unittest.TestCase):
|
|||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.listener = audio.AudioListener()
|
self.listener = audio.AudioListener()
|
||||||
|
|
||||||
|
def test_on_event_forwards_to_specific_handler(self):
|
||||||
|
self.listener.state_changed = mock.Mock()
|
||||||
|
|
||||||
|
self.listener.on_event(
|
||||||
|
'state_changed', old_state='stopped', new_state='playing')
|
||||||
|
|
||||||
|
self.listener.state_changed.assert_called_with(
|
||||||
|
old_state='stopped', new_state='playing')
|
||||||
|
|
||||||
def test_listener_has_default_impl_for_reached_end_of_stream(self):
|
def test_listener_has_default_impl_for_reached_end_of_stream(self):
|
||||||
self.listener.reached_end_of_stream()
|
self.listener.reached_end_of_stream()
|
||||||
|
|
||||||
|
|||||||
@ -1,13 +1,22 @@
|
|||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
import mock
|
||||||
|
|
||||||
from mopidy.backends.listener import BackendListener
|
from mopidy.backends.listener import BackendListener
|
||||||
|
|
||||||
from tests import unittest
|
from tests import unittest
|
||||||
|
|
||||||
|
|
||||||
class CoreListenerTest(unittest.TestCase):
|
class BackendListenerTest(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.listener = BackendListener()
|
self.listener = BackendListener()
|
||||||
|
|
||||||
|
def test_on_event_forwards_to_specific_handler(self):
|
||||||
|
self.listener.playlists_loaded = mock.Mock()
|
||||||
|
|
||||||
|
self.listener.on_event('playlists_loaded')
|
||||||
|
|
||||||
|
self.listener.playlists_loaded.assert_called_with()
|
||||||
|
|
||||||
def test_listener_has_default_impl_for_playlists_loaded(self):
|
def test_listener_has_default_impl_for_playlists_loaded(self):
|
||||||
self.listener.playlists_loaded()
|
self.listener.playlists_loaded()
|
||||||
|
|||||||
@ -1,8 +1,17 @@
|
|||||||
|
from mopidy import settings
|
||||||
from mopidy.backends.local import LocalBackend
|
from mopidy.backends.local import LocalBackend
|
||||||
|
|
||||||
from tests import unittest
|
from tests import unittest, path_to_data_dir
|
||||||
from tests.backends.base import events
|
from tests.backends.base import events
|
||||||
|
|
||||||
|
|
||||||
class LocalBackendEventsTest(events.BackendEventsTest, unittest.TestCase):
|
class LocalBackendEventsTest(events.BackendEventsTest, unittest.TestCase):
|
||||||
backend_class = LocalBackend
|
backend_class = LocalBackend
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
settings.LOCAL_TAG_CACHE_FILE = path_to_data_dir('empty_tag_cache')
|
||||||
|
super(LocalBackendEventsTest, self).setUp()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
super(LocalBackendEventsTest, self).tearDown()
|
||||||
|
settings.runtime.clear()
|
||||||
|
|||||||
@ -18,6 +18,7 @@ class LocalPlaybackControllerTest(PlaybackControllerTest, unittest.TestCase):
|
|||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
settings.BACKENDS = ('mopidy.backends.local.LocalBackend',)
|
settings.BACKENDS = ('mopidy.backends.local.LocalBackend',)
|
||||||
|
settings.LOCAL_TAG_CACHE_FILE = path_to_data_dir('empty_tag_cache')
|
||||||
super(LocalPlaybackControllerTest, self).setUp()
|
super(LocalPlaybackControllerTest, self).setUp()
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
|
|||||||
@ -18,6 +18,14 @@ class LocalPlaylistsControllerTest(
|
|||||||
|
|
||||||
backend_class = LocalBackend
|
backend_class = LocalBackend
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
settings.LOCAL_TAG_CACHE_FILE = path_to_data_dir('empty_tag_cache')
|
||||||
|
super(LocalPlaylistsControllerTest, self).setUp()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
super(LocalPlaylistsControllerTest, self).tearDown()
|
||||||
|
settings.runtime.clear()
|
||||||
|
|
||||||
def test_created_playlist_is_persisted(self):
|
def test_created_playlist_is_persisted(self):
|
||||||
path = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test.m3u')
|
path = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test.m3u')
|
||||||
self.assertFalse(os.path.exists(path))
|
self.assertFalse(os.path.exists(path))
|
||||||
|
|||||||
@ -4,7 +4,7 @@ from mopidy import settings
|
|||||||
from mopidy.backends.local import LocalBackend
|
from mopidy.backends.local import LocalBackend
|
||||||
from mopidy.models import Track
|
from mopidy.models import Track
|
||||||
|
|
||||||
from tests import unittest
|
from tests import unittest, path_to_data_dir
|
||||||
from tests.backends.base.tracklist import TracklistControllerTest
|
from tests.backends.base.tracklist import TracklistControllerTest
|
||||||
from tests.backends.local import generate_song
|
from tests.backends.local import generate_song
|
||||||
|
|
||||||
@ -16,6 +16,7 @@ class LocalTracklistControllerTest(TracklistControllerTest, unittest.TestCase):
|
|||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
settings.BACKENDS = ('mopidy.backends.local.LocalBackend',)
|
settings.BACKENDS = ('mopidy.backends.local.LocalBackend',)
|
||||||
|
settings.LOCAL_TAG_CACHE_FILE = path_to_data_dir('empty_tag_cache')
|
||||||
super(LocalTracklistControllerTest, self).setUp()
|
super(LocalTracklistControllerTest, self).setUp()
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
|
|||||||
@ -5,9 +5,9 @@ from __future__ import unicode_literals
|
|||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
from mopidy.utils.path import path_to_uri
|
|
||||||
from mopidy.backends.local.translator import parse_m3u, parse_mpd_tag_cache
|
from mopidy.backends.local.translator import parse_m3u, parse_mpd_tag_cache
|
||||||
from mopidy.models import Track, Artist, Album
|
from mopidy.models import Track, Artist, Album
|
||||||
|
from mopidy.utils.path import path_to_uri
|
||||||
|
|
||||||
from tests import unittest, path_to_data_dir
|
from tests import unittest, path_to_data_dir
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
import mock
|
||||||
|
|
||||||
from mopidy.core import CoreListener, PlaybackState
|
from mopidy.core import CoreListener, PlaybackState
|
||||||
from mopidy.models import Playlist, Track
|
from mopidy.models import Playlist, Track
|
||||||
|
|
||||||
@ -10,6 +12,15 @@ class CoreListenerTest(unittest.TestCase):
|
|||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.listener = CoreListener()
|
self.listener = CoreListener()
|
||||||
|
|
||||||
|
def test_on_event_forwards_to_specific_handler(self):
|
||||||
|
self.listener.track_playback_paused = mock.Mock()
|
||||||
|
|
||||||
|
self.listener.on_event(
|
||||||
|
'track_playback_paused', track=Track(), position=0)
|
||||||
|
|
||||||
|
self.listener.track_playback_paused.assert_called_with(
|
||||||
|
track=Track(), position=0)
|
||||||
|
|
||||||
def test_listener_has_default_impl_for_track_playback_paused(self):
|
def test_listener_has_default_impl_for_track_playback_paused(self):
|
||||||
self.listener.track_playback_paused(Track(), 0)
|
self.listener.track_playback_paused(Track(), 0)
|
||||||
|
|
||||||
|
|||||||
0
tests/frontends/http/__init__.py
Normal file
0
tests/frontends/http/__init__.py
Normal file
47
tests/frontends/http/events_test.py
Normal file
47
tests/frontends/http/events_test.py
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
try:
|
||||||
|
import cherrypy
|
||||||
|
except ImportError:
|
||||||
|
cherrypy = False
|
||||||
|
try:
|
||||||
|
import ws4py
|
||||||
|
except ImportError:
|
||||||
|
ws4py = False
|
||||||
|
import mock
|
||||||
|
|
||||||
|
from mopidy.exceptions import OptionalDependencyError
|
||||||
|
try:
|
||||||
|
from mopidy.frontends.http import HttpFrontend
|
||||||
|
except OptionalDependencyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
from tests import unittest
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(cherrypy, 'cherrypy not found')
|
||||||
|
@unittest.skipUnless(ws4py, 'ws4py not found')
|
||||||
|
@mock.patch('cherrypy.engine.publish')
|
||||||
|
class HttpEventsTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.http = HttpFrontend(core=mock.Mock())
|
||||||
|
|
||||||
|
def test_track_playback_paused_is_broadcasted(self, publish):
|
||||||
|
publish.reset_mock()
|
||||||
|
self.http.on_event('track_playback_paused', foo='bar')
|
||||||
|
self.assertEqual(publish.call_args[0][0], 'websocket-broadcast')
|
||||||
|
self.assertDictEqual(
|
||||||
|
json.loads(str(publish.call_args[0][1])), {
|
||||||
|
'event': 'track_playback_paused',
|
||||||
|
'foo': 'bar',
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_track_playback_resumed_is_broadcasted(self, publish):
|
||||||
|
publish.reset_mock()
|
||||||
|
self.http.on_event('track_playback_resumed', foo='bar')
|
||||||
|
self.assertEqual(publish.call_args[0][0], 'websocket-broadcast')
|
||||||
|
self.assertDictEqual(
|
||||||
|
json.loads(str(publish.call_args[0][1])), {
|
||||||
|
'event': 'track_playback_resumed',
|
||||||
|
'foo': 'bar',
|
||||||
|
})
|
||||||
@ -131,10 +131,9 @@ class TracksToTagCacheFormatTest(unittest.TestCase):
|
|||||||
mtime.undo_fake()
|
mtime.undo_fake()
|
||||||
|
|
||||||
def translate(self, track):
|
def translate(self, track):
|
||||||
folder = settings.LOCAL_MUSIC_PATH
|
base_path = settings.LOCAL_MUSIC_PATH.encode('utf-8')
|
||||||
result = dict(translator.track_to_mpd_format(track))
|
result = dict(translator.track_to_mpd_format(track))
|
||||||
result['file'] = uri_to_path(result['file'])
|
result['file'] = uri_to_path(result['file'])[len(base_path) + 1:]
|
||||||
result['file'] = result['file'][len(folder) + 1:]
|
|
||||||
result['key'] = os.path.basename(result['file'])
|
result['key'] = os.path.basename(result['file'])
|
||||||
result['mtime'] = mtime('')
|
result['mtime'] = mtime('')
|
||||||
return translator.order_mpd_track_info(result.items())
|
return translator.order_mpd_track_info(result.items())
|
||||||
@ -197,7 +196,7 @@ class TracksToTagCacheFormatTest(unittest.TestCase):
|
|||||||
result = self.consume_headers(result)
|
result = self.consume_headers(result)
|
||||||
song_list, result = self.consume_song_list(result)
|
song_list, result = self.consume_song_list(result)
|
||||||
|
|
||||||
self.assertEqual(song_list, formated)
|
self.assertEqual(formated, song_list)
|
||||||
self.assertEqual(len(result), 0)
|
self.assertEqual(len(result), 0)
|
||||||
|
|
||||||
def test_tag_cache_has_formated_track_with_key_and_mtime(self):
|
def test_tag_cache_has_formated_track_with_key_and_mtime(self):
|
||||||
@ -208,7 +207,7 @@ class TracksToTagCacheFormatTest(unittest.TestCase):
|
|||||||
result = self.consume_headers(result)
|
result = self.consume_headers(result)
|
||||||
song_list, result = self.consume_song_list(result)
|
song_list, result = self.consume_song_list(result)
|
||||||
|
|
||||||
self.assertEqual(song_list, formated)
|
self.assertEqual(formated, song_list)
|
||||||
self.assertEqual(len(result), 0)
|
self.assertEqual(len(result), 0)
|
||||||
|
|
||||||
def test_tag_cache_suports_directories(self):
|
def test_tag_cache_suports_directories(self):
|
||||||
@ -224,7 +223,7 @@ class TracksToTagCacheFormatTest(unittest.TestCase):
|
|||||||
|
|
||||||
song_list, result = self.consume_song_list(folder)
|
song_list, result = self.consume_song_list(folder)
|
||||||
self.assertEqual(len(result), 0)
|
self.assertEqual(len(result), 0)
|
||||||
self.assertEqual(song_list, formated)
|
self.assertEqual(formated, song_list)
|
||||||
|
|
||||||
def test_tag_cache_diretory_header_is_right(self):
|
def test_tag_cache_diretory_header_is_right(self):
|
||||||
track = Track(uri='file:///dir/subdir/folder/sub/song.mp3')
|
track = Track(uri='file:///dir/subdir/folder/sub/song.mp3')
|
||||||
@ -256,7 +255,7 @@ class TracksToTagCacheFormatTest(unittest.TestCase):
|
|||||||
|
|
||||||
song_list, result = self.consume_song_list(folder)
|
song_list, result = self.consume_song_list(folder)
|
||||||
self.assertEqual(len(result), 0)
|
self.assertEqual(len(result), 0)
|
||||||
self.assertEqual(song_list, formated)
|
self.assertEqual(formated, song_list)
|
||||||
|
|
||||||
def test_tag_cache_supports_multiple_tracks(self):
|
def test_tag_cache_supports_multiple_tracks(self):
|
||||||
tracks = [
|
tracks = [
|
||||||
@ -273,7 +272,7 @@ class TracksToTagCacheFormatTest(unittest.TestCase):
|
|||||||
result = self.consume_headers(result)
|
result = self.consume_headers(result)
|
||||||
song_list, result = self.consume_song_list(result)
|
song_list, result = self.consume_song_list(result)
|
||||||
|
|
||||||
self.assertEqual(song_list, formated)
|
self.assertEqual(formated, song_list)
|
||||||
self.assertEqual(len(result), 0)
|
self.assertEqual(len(result), 0)
|
||||||
|
|
||||||
def test_tag_cache_supports_multiple_tracks_in_dirs(self):
|
def test_tag_cache_supports_multiple_tracks_in_dirs(self):
|
||||||
@ -292,12 +291,12 @@ class TracksToTagCacheFormatTest(unittest.TestCase):
|
|||||||
folder, result = self.consume_directory(result)
|
folder, result = self.consume_directory(result)
|
||||||
song_list, song_result = self.consume_song_list(folder)
|
song_list, song_result = self.consume_song_list(folder)
|
||||||
|
|
||||||
self.assertEqual(song_list, formated[1])
|
self.assertEqual(formated[1], song_list)
|
||||||
self.assertEqual(len(song_result), 0)
|
self.assertEqual(len(song_result), 0)
|
||||||
|
|
||||||
song_list, result = self.consume_song_list(result)
|
song_list, result = self.consume_song_list(result)
|
||||||
self.assertEqual(len(result), 0)
|
self.assertEqual(len(result), 0)
|
||||||
self.assertEqual(song_list, formated[0])
|
self.assertEqual(formated[0], song_list)
|
||||||
|
|
||||||
|
|
||||||
class TracksToDirectoryTreeTest(unittest.TestCase):
|
class TracksToDirectoryTreeTest(unittest.TestCase):
|
||||||
|
|||||||
@ -1,7 +1,5 @@
|
|||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
from datetime import date
|
|
||||||
|
|
||||||
from mopidy.scanner import Scanner, translator
|
from mopidy.scanner import Scanner, translator
|
||||||
from mopidy.models import Track, Artist, Album
|
from mopidy.models import Track, Artist, Album
|
||||||
|
|
||||||
@ -53,7 +51,7 @@ class TranslatorTest(unittest.TestCase):
|
|||||||
self.track = {
|
self.track = {
|
||||||
'uri': 'uri',
|
'uri': 'uri',
|
||||||
'name': 'trackname',
|
'name': 'trackname',
|
||||||
'date': date(2006, 1, 1),
|
'date': '2006-01-01',
|
||||||
'track_no': 1,
|
'track_no': 1,
|
||||||
'length': 4531,
|
'length': 4531,
|
||||||
'musicbrainz_id': 'mbtrackid',
|
'musicbrainz_id': 'mbtrackid',
|
||||||
@ -129,6 +127,11 @@ class TranslatorTest(unittest.TestCase):
|
|||||||
del self.track['date']
|
del self.track['date']
|
||||||
self.check()
|
self.check()
|
||||||
|
|
||||||
|
def test_invalid_date(self):
|
||||||
|
self.data['date'] = FakeGstDate(65535, 1, 1)
|
||||||
|
del self.track['date']
|
||||||
|
self.check()
|
||||||
|
|
||||||
|
|
||||||
class ScannerTest(unittest.TestCase):
|
class ScannerTest(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
|||||||
@ -27,6 +27,16 @@ try:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
spotify = False
|
spotify = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
import cherrypy
|
||||||
|
except ImportError:
|
||||||
|
cherrypy = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
import ws4py
|
||||||
|
except ImportError:
|
||||||
|
ws4py = False
|
||||||
|
|
||||||
from mopidy.utils import deps
|
from mopidy.utils import deps
|
||||||
|
|
||||||
from tests import unittest
|
from tests import unittest
|
||||||
@ -115,3 +125,19 @@ class DepsTest(unittest.TestCase):
|
|||||||
self.assertEquals('pyserial', result['name'])
|
self.assertEquals('pyserial', result['name'])
|
||||||
self.assertEquals(serial.VERSION, result['version'])
|
self.assertEquals(serial.VERSION, result['version'])
|
||||||
self.assertIn('serial', result['path'])
|
self.assertIn('serial', result['path'])
|
||||||
|
|
||||||
|
@unittest.skipUnless(cherrypy, 'cherrypy not found')
|
||||||
|
def test_cherrypy_info(self):
|
||||||
|
result = deps.cherrypy_info()
|
||||||
|
|
||||||
|
self.assertEquals('cherrypy', result['name'])
|
||||||
|
self.assertEquals(cherrypy.__version__, result['version'])
|
||||||
|
self.assertIn('cherrypy', result['path'])
|
||||||
|
|
||||||
|
@unittest.skipUnless(ws4py, 'ws4py not found')
|
||||||
|
def test_ws4py_info(self):
|
||||||
|
result = deps.ws4py_info()
|
||||||
|
|
||||||
|
self.assertEquals('ws4py', result['name'])
|
||||||
|
self.assertEquals(ws4py.__version__, result['version'])
|
||||||
|
self.assertIn('ws4py', result['path'])
|
||||||
|
|||||||
612
tests/utils/jsonrpc_test.py
Normal file
612
tests/utils/jsonrpc_test.py
Normal file
@ -0,0 +1,612 @@
|
|||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pykka
|
||||||
|
import mock
|
||||||
|
|
||||||
|
from mopidy import core, models
|
||||||
|
from mopidy.backends import dummy
|
||||||
|
from mopidy.utils import jsonrpc
|
||||||
|
|
||||||
|
from tests import unittest
|
||||||
|
|
||||||
|
|
||||||
|
class Calculator(object):
|
||||||
|
def model(self):
|
||||||
|
return 'TI83'
|
||||||
|
|
||||||
|
def add(self, a, b):
|
||||||
|
"""Returns the sum of the given numbers"""
|
||||||
|
return a + b
|
||||||
|
|
||||||
|
def sub(self, a, b):
|
||||||
|
return a - b
|
||||||
|
|
||||||
|
def describe(self):
|
||||||
|
return {
|
||||||
|
'add': 'Returns the sum of the terms',
|
||||||
|
'sub': 'Returns the diff of the terms',
|
||||||
|
}
|
||||||
|
|
||||||
|
def take_it_all(self, a, b, c=True, *args, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _secret(self):
|
||||||
|
return 'Grand Unified Theory'
|
||||||
|
|
||||||
|
|
||||||
|
class JsonRpcTestBase(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.backend = dummy.DummyBackend.start(audio=None).proxy()
|
||||||
|
self.core = core.Core.start(backends=[self.backend]).proxy()
|
||||||
|
|
||||||
|
self.jrw = jsonrpc.JsonRpcWrapper(
|
||||||
|
objects={
|
||||||
|
'hello': lambda: 'Hello, world!',
|
||||||
|
'calc': Calculator(),
|
||||||
|
'core': self.core,
|
||||||
|
'core.playback': self.core.playback,
|
||||||
|
'core.tracklist': self.core.tracklist,
|
||||||
|
},
|
||||||
|
encoders=[models.ModelJSONEncoder],
|
||||||
|
decoders=[models.model_json_decoder])
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
pykka.ActorRegistry.stop_all()
|
||||||
|
|
||||||
|
|
||||||
|
class JsonRpcSetupTest(JsonRpcTestBase):
|
||||||
|
def test_empty_object_mounts_is_not_allowed(self):
|
||||||
|
test = lambda: jsonrpc.JsonRpcWrapper(objects={'': Calculator()})
|
||||||
|
self.assertRaises(AttributeError, test)
|
||||||
|
|
||||||
|
|
||||||
|
class JsonRpcSerializationTest(JsonRpcTestBase):
|
||||||
|
def test_handle_json_converts_from_and_to_json(self):
|
||||||
|
self.jrw.handle_data = mock.Mock()
|
||||||
|
self.jrw.handle_data.return_value = {'foo': 'response'}
|
||||||
|
|
||||||
|
request = '{"foo": "request"}'
|
||||||
|
response = self.jrw.handle_json(request)
|
||||||
|
|
||||||
|
self.jrw.handle_data.assert_called_once_with({'foo': 'request'})
|
||||||
|
self.assertEqual(response, '{"foo": "response"}')
|
||||||
|
|
||||||
|
def test_handle_json_decodes_mopidy_models(self):
|
||||||
|
self.jrw.handle_data = mock.Mock()
|
||||||
|
self.jrw.handle_data.return_value = []
|
||||||
|
|
||||||
|
request = '{"foo": {"__model__": "Artist", "name": "bar"}}'
|
||||||
|
self.jrw.handle_json(request)
|
||||||
|
|
||||||
|
self.jrw.handle_data.assert_called_once_with(
|
||||||
|
{'foo': models.Artist(name='bar')})
|
||||||
|
|
||||||
|
def test_handle_json_encodes_mopidy_models(self):
|
||||||
|
self.jrw.handle_data = mock.Mock()
|
||||||
|
self.jrw.handle_data.return_value = {'foo': models.Artist(name='bar')}
|
||||||
|
|
||||||
|
request = '[]'
|
||||||
|
response = self.jrw.handle_json(request)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
response, '{"foo": {"__model__": "Artist", "name": "bar"}}')
|
||||||
|
|
||||||
|
def test_handle_json_returns_nothing_for_notices(self):
|
||||||
|
request = '{"jsonrpc": "2.0", "method": "core.get_uri_schemes"}'
|
||||||
|
response = self.jrw.handle_json(request)
|
||||||
|
|
||||||
|
self.assertEqual(response, None)
|
||||||
|
|
||||||
|
def test_invalid_json_command_causes_parse_error(self):
|
||||||
|
request = (
|
||||||
|
'{"jsonrpc": "2.0", "method": "foobar, "params": "bar", "baz]')
|
||||||
|
response = self.jrw.handle_json(request)
|
||||||
|
response = json.loads(response)
|
||||||
|
|
||||||
|
self.assertEqual(response['jsonrpc'], '2.0')
|
||||||
|
error = response['error']
|
||||||
|
self.assertEqual(error['code'], -32700)
|
||||||
|
self.assertEqual(error['message'], 'Parse error')
|
||||||
|
|
||||||
|
def test_invalid_json_batch_causes_parse_error(self):
|
||||||
|
request = """[
|
||||||
|
{"jsonrpc": "2.0", "method": "sum", "params": [1,2,4], "id": "1"},
|
||||||
|
{"jsonrpc": "2.0", "method"
|
||||||
|
]"""
|
||||||
|
response = self.jrw.handle_json(request)
|
||||||
|
response = json.loads(response)
|
||||||
|
|
||||||
|
self.assertEqual(response['jsonrpc'], '2.0')
|
||||||
|
error = response['error']
|
||||||
|
self.assertEqual(error['code'], -32700)
|
||||||
|
self.assertEqual(error['message'], 'Parse error')
|
||||||
|
|
||||||
|
|
||||||
|
class JsonRpcSingleCommandTest(JsonRpcTestBase):
|
||||||
|
def test_call_method_on_root(self):
|
||||||
|
request = {
|
||||||
|
'jsonrpc': '2.0',
|
||||||
|
'method': 'hello',
|
||||||
|
'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'], 'Hello, world!')
|
||||||
|
|
||||||
|
def test_call_method_on_plain_object(self):
|
||||||
|
request = {
|
||||||
|
'jsonrpc': '2.0',
|
||||||
|
'method': 'calc.model',
|
||||||
|
'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'], 'TI83')
|
||||||
|
|
||||||
|
def test_call_method_which_returns_dict_from_plain_object(self):
|
||||||
|
request = {
|
||||||
|
'jsonrpc': '2.0',
|
||||||
|
'method': 'calc.describe',
|
||||||
|
'id': 1,
|
||||||
|
}
|
||||||
|
response = self.jrw.handle_data(request)
|
||||||
|
|
||||||
|
self.assertEqual(response['jsonrpc'], '2.0')
|
||||||
|
self.assertEqual(response['id'], 1)
|
||||||
|
self.assertNotIn('error', response)
|
||||||
|
self.assertIn('add', response['result'])
|
||||||
|
self.assertIn('sub', response['result'])
|
||||||
|
|
||||||
|
def test_call_method_on_actor_root(self):
|
||||||
|
request = {
|
||||||
|
'jsonrpc': '2.0',
|
||||||
|
'method': 'core.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_on_actor_member(self):
|
||||||
|
request = {
|
||||||
|
'jsonrpc': '2.0',
|
||||||
|
'method': 'core.playback.get_volume',
|
||||||
|
'id': 1,
|
||||||
|
}
|
||||||
|
response = self.jrw.handle_data(request)
|
||||||
|
|
||||||
|
self.assertEqual(response['result'], None)
|
||||||
|
|
||||||
|
def test_call_method_with_positional_params(self):
|
||||||
|
request = {
|
||||||
|
'jsonrpc': '2.0',
|
||||||
|
'method': 'core.playback.set_volume',
|
||||||
|
'params': [37],
|
||||||
|
'id': 1,
|
||||||
|
}
|
||||||
|
response = self.jrw.handle_data(request)
|
||||||
|
|
||||||
|
self.assertEqual(response['result'], None)
|
||||||
|
self.assertEqual(self.core.playback.get_volume().get(), 37)
|
||||||
|
|
||||||
|
def test_call_methods_with_named_params(self):
|
||||||
|
request = {
|
||||||
|
'jsonrpc': '2.0',
|
||||||
|
'method': 'core.playback.set_volume',
|
||||||
|
'params': {'volume': 37},
|
||||||
|
'id': 1,
|
||||||
|
}
|
||||||
|
response = self.jrw.handle_data(request)
|
||||||
|
|
||||||
|
self.assertEqual(response['result'], None)
|
||||||
|
self.assertEqual(self.core.playback.get_volume().get(), 37)
|
||||||
|
|
||||||
|
|
||||||
|
class JsonRpcSingleNotificationTest(JsonRpcTestBase):
|
||||||
|
def test_notification_does_not_return_a_result(self):
|
||||||
|
request = {
|
||||||
|
'jsonrpc': '2.0',
|
||||||
|
'method': 'core.get_uri_schemes',
|
||||||
|
}
|
||||||
|
response = self.jrw.handle_data(request)
|
||||||
|
|
||||||
|
self.assertIsNone(response)
|
||||||
|
|
||||||
|
def test_notification_makes_an_observable_change(self):
|
||||||
|
self.assertEqual(self.core.playback.get_volume().get(), None)
|
||||||
|
|
||||||
|
request = {
|
||||||
|
'jsonrpc': '2.0',
|
||||||
|
'method': 'core.playback.set_volume',
|
||||||
|
'params': [37],
|
||||||
|
}
|
||||||
|
response = self.jrw.handle_data(request)
|
||||||
|
|
||||||
|
self.assertIsNone(response)
|
||||||
|
self.assertEqual(self.core.playback.get_volume().get(), 37)
|
||||||
|
|
||||||
|
def test_notification_unknown_method_returns_nothing(self):
|
||||||
|
request = {
|
||||||
|
'jsonrpc': '2.0',
|
||||||
|
'method': 'bogus',
|
||||||
|
'params': ['bogus'],
|
||||||
|
}
|
||||||
|
response = self.jrw.handle_data(request)
|
||||||
|
|
||||||
|
self.assertIsNone(response)
|
||||||
|
|
||||||
|
|
||||||
|
class JsonRpcBatchTest(JsonRpcTestBase):
|
||||||
|
def test_batch_of_only_commands_returns_all(self):
|
||||||
|
self.core.playback.set_random(True).get()
|
||||||
|
|
||||||
|
request = [
|
||||||
|
{'jsonrpc': '2.0', 'method': 'core.playback.get_repeat', 'id': 1},
|
||||||
|
{'jsonrpc': '2.0', 'method': 'core.playback.get_random', 'id': 2},
|
||||||
|
{'jsonrpc': '2.0', 'method': 'core.playback.get_single', 'id': 3},
|
||||||
|
]
|
||||||
|
response = self.jrw.handle_data(request)
|
||||||
|
|
||||||
|
self.assertEqual(len(response), 3)
|
||||||
|
|
||||||
|
response = dict((row['id'], row) for row in response)
|
||||||
|
self.assertEqual(response[1]['result'], False)
|
||||||
|
self.assertEqual(response[2]['result'], True)
|
||||||
|
self.assertEqual(response[3]['result'], False)
|
||||||
|
|
||||||
|
def test_batch_of_commands_and_notifications_returns_some(self):
|
||||||
|
self.core.playback.set_random(True).get()
|
||||||
|
|
||||||
|
request = [
|
||||||
|
{'jsonrpc': '2.0', 'method': 'core.playback.get_repeat'},
|
||||||
|
{'jsonrpc': '2.0', 'method': 'core.playback.get_random', 'id': 2},
|
||||||
|
{'jsonrpc': '2.0', 'method': 'core.playback.get_single', 'id': 3},
|
||||||
|
]
|
||||||
|
response = self.jrw.handle_data(request)
|
||||||
|
|
||||||
|
self.assertEqual(len(response), 2)
|
||||||
|
|
||||||
|
response = dict((row['id'], row) for row in response)
|
||||||
|
self.assertNotIn(1, response)
|
||||||
|
self.assertEqual(response[2]['result'], True)
|
||||||
|
self.assertEqual(response[3]['result'], False)
|
||||||
|
|
||||||
|
def test_batch_of_only_notifications_returns_nothing(self):
|
||||||
|
self.core.playback.set_random(True).get()
|
||||||
|
|
||||||
|
request = [
|
||||||
|
{'jsonrpc': '2.0', 'method': 'core.playback.get_repeat'},
|
||||||
|
{'jsonrpc': '2.0', 'method': 'core.playback.get_random'},
|
||||||
|
{'jsonrpc': '2.0', 'method': 'core.playback.get_single'},
|
||||||
|
]
|
||||||
|
response = self.jrw.handle_data(request)
|
||||||
|
|
||||||
|
self.assertIsNone(response)
|
||||||
|
|
||||||
|
|
||||||
|
class JsonRpcSingleCommandErrorTest(JsonRpcTestBase):
|
||||||
|
def test_application_error_response(self):
|
||||||
|
request = {
|
||||||
|
'jsonrpc': '2.0',
|
||||||
|
'method': 'core.tracklist.index',
|
||||||
|
'params': ['bogus'],
|
||||||
|
'id': 1,
|
||||||
|
}
|
||||||
|
response = self.jrw.handle_data(request)
|
||||||
|
|
||||||
|
self.assertNotIn('result', response)
|
||||||
|
|
||||||
|
error = response['error']
|
||||||
|
self.assertEqual(error['code'], 0)
|
||||||
|
self.assertEqual(error['message'], 'Application error')
|
||||||
|
|
||||||
|
data = error['data']
|
||||||
|
self.assertEqual(data['type'], 'ValueError')
|
||||||
|
self.assertIn('not in list', data['message'])
|
||||||
|
self.assertIn('traceback', data)
|
||||||
|
self.assertIn('Traceback (most recent call last):', data['traceback'])
|
||||||
|
|
||||||
|
def test_missing_jsonrpc_member_causes_invalid_request_error(self):
|
||||||
|
request = {
|
||||||
|
'method': 'core.get_uri_schemes',
|
||||||
|
'id': 1,
|
||||||
|
}
|
||||||
|
response = self.jrw.handle_data(request)
|
||||||
|
|
||||||
|
self.assertIsNone(response['id'])
|
||||||
|
error = response['error']
|
||||||
|
self.assertEqual(error['code'], -32600)
|
||||||
|
self.assertEqual(error['message'], 'Invalid Request')
|
||||||
|
self.assertEqual(error['data'], '"jsonrpc" member must be included')
|
||||||
|
|
||||||
|
def test_wrong_jsonrpc_version_causes_invalid_request_error(self):
|
||||||
|
request = {
|
||||||
|
'jsonrpc': '3.0',
|
||||||
|
'method': 'core.get_uri_schemes',
|
||||||
|
'id': 1,
|
||||||
|
}
|
||||||
|
response = self.jrw.handle_data(request)
|
||||||
|
|
||||||
|
self.assertIsNone(response['id'])
|
||||||
|
error = response['error']
|
||||||
|
self.assertEqual(error['code'], -32600)
|
||||||
|
self.assertEqual(error['message'], 'Invalid Request')
|
||||||
|
self.assertEqual(error['data'], '"jsonrpc" value must be "2.0"')
|
||||||
|
|
||||||
|
def test_missing_method_member_causes_invalid_request_error(self):
|
||||||
|
request = {
|
||||||
|
'jsonrpc': '2.0',
|
||||||
|
'id': 1,
|
||||||
|
}
|
||||||
|
response = self.jrw.handle_data(request)
|
||||||
|
|
||||||
|
self.assertIsNone(response['id'])
|
||||||
|
error = response['error']
|
||||||
|
self.assertEqual(error['code'], -32600)
|
||||||
|
self.assertEqual(error['message'], 'Invalid Request')
|
||||||
|
self.assertEqual(error['data'], '"method" member must be included')
|
||||||
|
|
||||||
|
def test_invalid_method_value_causes_invalid_request_error(self):
|
||||||
|
request = {
|
||||||
|
'jsonrpc': '2.0',
|
||||||
|
'method': 1,
|
||||||
|
'id': 1,
|
||||||
|
}
|
||||||
|
response = self.jrw.handle_data(request)
|
||||||
|
|
||||||
|
self.assertIsNone(response['id'])
|
||||||
|
error = response['error']
|
||||||
|
self.assertEqual(error['code'], -32600)
|
||||||
|
self.assertEqual(error['message'], 'Invalid Request')
|
||||||
|
self.assertEqual(error['data'], '"method" must be a string')
|
||||||
|
|
||||||
|
def test_invalid_params_value_causes_invalid_request_error(self):
|
||||||
|
request = {
|
||||||
|
'jsonrpc': '2.0',
|
||||||
|
'method': 'core.get_uri_schemes',
|
||||||
|
'params': 'foobar',
|
||||||
|
'id': 1,
|
||||||
|
}
|
||||||
|
response = self.jrw.handle_data(request)
|
||||||
|
|
||||||
|
self.assertIsNone(response['id'])
|
||||||
|
error = response['error']
|
||||||
|
self.assertEqual(error['code'], -32600)
|
||||||
|
self.assertEqual(error['message'], 'Invalid Request')
|
||||||
|
self.assertEqual(
|
||||||
|
error['data'], '"params", if given, must be an array or an object')
|
||||||
|
|
||||||
|
def test_method_on_without_object_causes_unknown_method_error(self):
|
||||||
|
request = {
|
||||||
|
'jsonrpc': '2.0',
|
||||||
|
'method': 'bogus',
|
||||||
|
'id': 1,
|
||||||
|
}
|
||||||
|
response = self.jrw.handle_data(request)
|
||||||
|
|
||||||
|
error = response['error']
|
||||||
|
self.assertEqual(error['code'], -32601)
|
||||||
|
self.assertEqual(error['message'], 'Method not found')
|
||||||
|
self.assertEqual(
|
||||||
|
error['data'],
|
||||||
|
'Could not find object mount in method name "bogus"')
|
||||||
|
|
||||||
|
def test_method_on_unknown_object_causes_unknown_method_error(self):
|
||||||
|
request = {
|
||||||
|
'jsonrpc': '2.0',
|
||||||
|
'method': 'bogus.bogus',
|
||||||
|
'id': 1,
|
||||||
|
}
|
||||||
|
response = self.jrw.handle_data(request)
|
||||||
|
|
||||||
|
error = response['error']
|
||||||
|
self.assertEqual(error['code'], -32601)
|
||||||
|
self.assertEqual(error['message'], 'Method not found')
|
||||||
|
self.assertEqual(error['data'], 'No object found at "bogus"')
|
||||||
|
|
||||||
|
def test_unknown_method_on_known_object_causes_unknown_method_error(self):
|
||||||
|
request = {
|
||||||
|
'jsonrpc': '2.0',
|
||||||
|
'method': 'core.bogus',
|
||||||
|
'id': 1,
|
||||||
|
}
|
||||||
|
response = self.jrw.handle_data(request)
|
||||||
|
|
||||||
|
error = response['error']
|
||||||
|
self.assertEqual(error['code'], -32601)
|
||||||
|
self.assertEqual(error['message'], 'Method not found')
|
||||||
|
self.assertEqual(
|
||||||
|
error['data'], 'Object mounted at "core" has no member "bogus"')
|
||||||
|
|
||||||
|
def test_private_method_causes_unknown_method_error(self):
|
||||||
|
request = {
|
||||||
|
'jsonrpc': '2.0',
|
||||||
|
'method': 'core._secret',
|
||||||
|
'id': 1,
|
||||||
|
}
|
||||||
|
response = self.jrw.handle_data(request)
|
||||||
|
|
||||||
|
error = response['error']
|
||||||
|
self.assertEqual(error['code'], -32601)
|
||||||
|
self.assertEqual(error['message'], 'Method not found')
|
||||||
|
self.assertEqual(error['data'], 'Private methods are not exported')
|
||||||
|
|
||||||
|
def test_invalid_params_causes_invalid_params_error(self):
|
||||||
|
request = {
|
||||||
|
'jsonrpc': '2.0',
|
||||||
|
'method': 'core.get_uri_schemes',
|
||||||
|
'params': ['bogus'],
|
||||||
|
'id': 1,
|
||||||
|
}
|
||||||
|
response = self.jrw.handle_data(request)
|
||||||
|
|
||||||
|
error = response['error']
|
||||||
|
self.assertEqual(error['code'], -32602)
|
||||||
|
self.assertEqual(error['message'], 'Invalid params')
|
||||||
|
|
||||||
|
data = error['data']
|
||||||
|
self.assertEqual(data['type'], 'TypeError')
|
||||||
|
self.assertEqual(
|
||||||
|
data['message'],
|
||||||
|
'get_uri_schemes() takes exactly 1 argument (2 given)')
|
||||||
|
self.assertIn('traceback', data)
|
||||||
|
self.assertIn('Traceback (most recent call last):', data['traceback'])
|
||||||
|
|
||||||
|
|
||||||
|
class JsonRpcBatchErrorTest(JsonRpcTestBase):
|
||||||
|
def test_empty_batch_list_causes_invalid_request_error(self):
|
||||||
|
request = []
|
||||||
|
response = self.jrw.handle_data(request)
|
||||||
|
|
||||||
|
self.assertIsNone(response['id'])
|
||||||
|
error = response['error']
|
||||||
|
self.assertEqual(error['code'], -32600)
|
||||||
|
self.assertEqual(error['message'], 'Invalid Request')
|
||||||
|
self.assertEqual(error['data'], 'Batch list cannot be empty')
|
||||||
|
|
||||||
|
def test_batch_with_invalid_command_causes_invalid_request_error(self):
|
||||||
|
request = [1]
|
||||||
|
response = self.jrw.handle_data(request)
|
||||||
|
|
||||||
|
self.assertEqual(len(response), 1)
|
||||||
|
response = response[0]
|
||||||
|
self.assertIsNone(response['id'])
|
||||||
|
error = response['error']
|
||||||
|
self.assertEqual(error['code'], -32600)
|
||||||
|
self.assertEqual(error['message'], 'Invalid Request')
|
||||||
|
self.assertEqual(error['data'], 'Request must be an object')
|
||||||
|
|
||||||
|
def test_batch_with_invalid_commands_causes_invalid_request_error(self):
|
||||||
|
request = [1, 2, 3]
|
||||||
|
response = self.jrw.handle_data(request)
|
||||||
|
|
||||||
|
self.assertEqual(len(response), 3)
|
||||||
|
response = response[2]
|
||||||
|
self.assertIsNone(response['id'])
|
||||||
|
error = response['error']
|
||||||
|
self.assertEqual(error['code'], -32600)
|
||||||
|
self.assertEqual(error['message'], 'Invalid Request')
|
||||||
|
self.assertEqual(error['data'], 'Request must be an object')
|
||||||
|
|
||||||
|
def test_batch_of_both_successfull_and_failing_requests(self):
|
||||||
|
request = [
|
||||||
|
# Call with positional params
|
||||||
|
{'jsonrpc': '2.0', 'method': 'core.playback.set_volume',
|
||||||
|
'params': [47], 'id': '1'},
|
||||||
|
# Notification
|
||||||
|
{'jsonrpc': '2.0', 'method': 'core.playback.set_consume',
|
||||||
|
'params': [True]},
|
||||||
|
# Call with positional params
|
||||||
|
{'jsonrpc': '2.0', 'method': 'core.playback.set_repeat',
|
||||||
|
'params': [False], 'id': '2'},
|
||||||
|
# Invalid request
|
||||||
|
{'foo': 'boo'},
|
||||||
|
# Unknown method
|
||||||
|
{'jsonrpc': '2.0', 'method': 'foo.get',
|
||||||
|
'params': {'name': 'myself'}, 'id': '5'},
|
||||||
|
# Call without params
|
||||||
|
{'jsonrpc': '2.0', 'method': 'core.playback.get_random',
|
||||||
|
'id': '9'},
|
||||||
|
]
|
||||||
|
response = self.jrw.handle_data(request)
|
||||||
|
|
||||||
|
self.assertEqual(len(response), 5)
|
||||||
|
response = dict((row['id'], row) for row in response)
|
||||||
|
self.assertEqual(response['1']['result'], None)
|
||||||
|
self.assertEqual(response['2']['result'], None)
|
||||||
|
self.assertEqual(response[None]['error']['code'], -32600)
|
||||||
|
self.assertEqual(response['5']['error']['code'], -32601)
|
||||||
|
self.assertEqual(response['9']['result'], False)
|
||||||
|
|
||||||
|
|
||||||
|
class JsonRpcInspectorTest(JsonRpcTestBase):
|
||||||
|
def test_empty_object_mounts_is_not_allowed(self):
|
||||||
|
test = lambda: jsonrpc.JsonRpcInspector(objects={'': Calculator})
|
||||||
|
self.assertRaises(AttributeError, test)
|
||||||
|
|
||||||
|
def test_can_describe_method_on_root(self):
|
||||||
|
inspector = jsonrpc.JsonRpcInspector({
|
||||||
|
'hello': lambda: 'Hello, world!',
|
||||||
|
})
|
||||||
|
|
||||||
|
methods = inspector.describe()
|
||||||
|
|
||||||
|
self.assertIn('hello', methods)
|
||||||
|
self.assertEqual(len(methods['hello']['params']), 0)
|
||||||
|
|
||||||
|
def test_inspector_can_describe_an_object_with_methods(self):
|
||||||
|
inspector = jsonrpc.JsonRpcInspector({
|
||||||
|
'calc': Calculator,
|
||||||
|
})
|
||||||
|
|
||||||
|
methods = inspector.describe()
|
||||||
|
|
||||||
|
self.assertIn('calc.add', methods)
|
||||||
|
self.assertEqual(
|
||||||
|
methods['calc.add']['description'],
|
||||||
|
'Returns the sum of the given numbers')
|
||||||
|
|
||||||
|
self.assertIn('calc.sub', methods)
|
||||||
|
self.assertIn('calc.take_it_all', methods)
|
||||||
|
self.assertNotIn('calc._secret', methods)
|
||||||
|
self.assertNotIn('calc.__init__', methods)
|
||||||
|
|
||||||
|
method = methods['calc.take_it_all']
|
||||||
|
self.assertIn('params', method)
|
||||||
|
|
||||||
|
params = method['params']
|
||||||
|
|
||||||
|
self.assertEqual(params[0]['name'], 'a')
|
||||||
|
self.assertNotIn('default', params[0])
|
||||||
|
|
||||||
|
self.assertEqual(params[1]['name'], 'b')
|
||||||
|
self.assertNotIn('default', params[1])
|
||||||
|
|
||||||
|
self.assertEqual(params[2]['name'], 'c')
|
||||||
|
self.assertEqual(params[2]['default'], True)
|
||||||
|
|
||||||
|
self.assertEqual(params[3]['name'], 'args')
|
||||||
|
self.assertNotIn('default', params[3])
|
||||||
|
self.assertEqual(params[3]['varargs'], True)
|
||||||
|
|
||||||
|
self.assertEqual(params[4]['name'], 'kwargs')
|
||||||
|
self.assertNotIn('default', params[4])
|
||||||
|
self.assertEqual(params[4]['kwargs'], True)
|
||||||
|
|
||||||
|
def test_inspector_can_describe_a_bunch_of_large_classes(self):
|
||||||
|
inspector = jsonrpc.JsonRpcInspector({
|
||||||
|
'core.library': core.LibraryController,
|
||||||
|
'core.playback': core.PlaybackController,
|
||||||
|
'core.playlists': core.PlaylistsController,
|
||||||
|
'core.tracklist': core.TracklistController,
|
||||||
|
})
|
||||||
|
|
||||||
|
methods = inspector.describe()
|
||||||
|
|
||||||
|
self.assertIn('core.library.lookup', methods.keys())
|
||||||
|
self.assertEquals(
|
||||||
|
methods['core.library.lookup']['params'][0]['name'], 'uri')
|
||||||
|
|
||||||
|
self.assertIn('core.playback.next', methods)
|
||||||
|
self.assertEquals(len(methods['core.playback.next']['params']), 0)
|
||||||
|
|
||||||
|
self.assertIn('core.playlists.get_playlists', methods)
|
||||||
|
self.assertEquals(
|
||||||
|
len(methods['core.playlists.get_playlists']['params']), 0)
|
||||||
|
|
||||||
|
self.assertIn('core.tracklist.filter', methods.keys())
|
||||||
|
self.assertEquals(
|
||||||
|
methods['core.tracklist.filter']['params'][0]['name'], 'criteria')
|
||||||
|
self.assertEquals(
|
||||||
|
methods['core.tracklist.filter']['params'][0]['kwargs'], True)
|
||||||
@ -90,31 +90,55 @@ class PathToFileURITest(unittest.TestCase):
|
|||||||
result = path.path_to_uri('/tmp/æøå')
|
result = path.path_to_uri('/tmp/æøå')
|
||||||
self.assertEqual(result, 'file:///tmp/%C3%A6%C3%B8%C3%A5')
|
self.assertEqual(result, 'file:///tmp/%C3%A6%C3%B8%C3%A5')
|
||||||
|
|
||||||
|
def test_utf8_in_path(self):
|
||||||
|
if sys.platform == 'win32':
|
||||||
|
result = path.path_to_uri('C:/æøå'.encode('utf-8'))
|
||||||
|
self.assertEqual(result, 'file:///C://%C3%A6%C3%B8%C3%A5')
|
||||||
|
else:
|
||||||
|
result = path.path_to_uri('/tmp/æøå'.encode('utf-8'))
|
||||||
|
self.assertEqual(result, 'file:///tmp/%C3%A6%C3%B8%C3%A5')
|
||||||
|
|
||||||
|
def test_latin1_in_path(self):
|
||||||
|
if sys.platform == 'win32':
|
||||||
|
result = path.path_to_uri('C:/æøå'.encode('latin-1'))
|
||||||
|
self.assertEqual(result, 'file:///C://%E6%F8%E5')
|
||||||
|
else:
|
||||||
|
result = path.path_to_uri('/tmp/æøå'.encode('latin-1'))
|
||||||
|
self.assertEqual(result, 'file:///tmp/%E6%F8%E5')
|
||||||
|
|
||||||
|
|
||||||
class UriToPathTest(unittest.TestCase):
|
class UriToPathTest(unittest.TestCase):
|
||||||
def test_simple_uri(self):
|
def test_simple_uri(self):
|
||||||
if sys.platform == 'win32':
|
if sys.platform == 'win32':
|
||||||
result = path.uri_to_path('file:///C://WINDOWS/clock.avi')
|
result = path.uri_to_path('file:///C://WINDOWS/clock.avi')
|
||||||
self.assertEqual(result, 'C:/WINDOWS/clock.avi')
|
self.assertEqual(result, 'C:/WINDOWS/clock.avi'.encode('utf-8'))
|
||||||
else:
|
else:
|
||||||
result = path.uri_to_path('file:///etc/fstab')
|
result = path.uri_to_path('file:///etc/fstab')
|
||||||
self.assertEqual(result, '/etc/fstab')
|
self.assertEqual(result, '/etc/fstab'.encode('utf-8'))
|
||||||
|
|
||||||
def test_space_in_uri(self):
|
def test_space_in_uri(self):
|
||||||
if sys.platform == 'win32':
|
if sys.platform == 'win32':
|
||||||
result = path.uri_to_path('file:///C://test%20this')
|
result = path.uri_to_path('file:///C://test%20this')
|
||||||
self.assertEqual(result, 'C:/test this')
|
self.assertEqual(result, 'C:/test this'.encode('utf-8'))
|
||||||
else:
|
else:
|
||||||
result = path.uri_to_path('file:///tmp/test%20this')
|
result = path.uri_to_path('file:///tmp/test%20this')
|
||||||
self.assertEqual(result, '/tmp/test this')
|
self.assertEqual(result, '/tmp/test this'.encode('utf-8'))
|
||||||
|
|
||||||
def test_unicode_in_uri(self):
|
def test_unicode_in_uri(self):
|
||||||
if sys.platform == 'win32':
|
if sys.platform == 'win32':
|
||||||
result = path.uri_to_path('file:///C://%C3%A6%C3%B8%C3%A5')
|
result = path.uri_to_path('file:///C://%C3%A6%C3%B8%C3%A5')
|
||||||
self.assertEqual(result, 'C:/æøå')
|
self.assertEqual(result, 'C:/æøå'.encode('utf-8'))
|
||||||
else:
|
else:
|
||||||
result = path.uri_to_path('file:///tmp/%C3%A6%C3%B8%C3%A5')
|
result = path.uri_to_path('file:///tmp/%C3%A6%C3%B8%C3%A5')
|
||||||
self.assertEqual(result, '/tmp/æøå')
|
self.assertEqual(result, '/tmp/æøå'.encode('utf-8'))
|
||||||
|
|
||||||
|
def test_latin1_in_uri(self):
|
||||||
|
if sys.platform == 'win32':
|
||||||
|
result = path.uri_to_path('file:///C://%E6%F8%E5')
|
||||||
|
self.assertEqual(result, 'C:/æøå'.encode('latin-1'))
|
||||||
|
else:
|
||||||
|
result = path.uri_to_path('file:///tmp/%E6%F8%E5')
|
||||||
|
self.assertEqual(result, '/tmp/æøå'.encode('latin-1'))
|
||||||
|
|
||||||
|
|
||||||
class SplitPathTest(unittest.TestCase):
|
class SplitPathTest(unittest.TestCase):
|
||||||
@ -177,11 +201,11 @@ class FindFilesTest(unittest.TestCase):
|
|||||||
self.assertEqual(len(files), 1)
|
self.assertEqual(len(files), 1)
|
||||||
self.assert_(files[0], path_to_data_dir('blank.mp3'))
|
self.assert_(files[0], path_to_data_dir('blank.mp3'))
|
||||||
|
|
||||||
def test_names_are_unicode(self):
|
def test_names_are_bytestrings(self):
|
||||||
is_unicode = lambda f: isinstance(f, unicode)
|
is_bytes = lambda f: isinstance(f, bytes)
|
||||||
for name in self.find(''):
|
for name in self.find(''):
|
||||||
self.assert_(
|
self.assert_(
|
||||||
is_unicode(name), '%s is not unicode object' % repr(name))
|
is_bytes(name), '%s is not bytes object' % repr(name))
|
||||||
|
|
||||||
def test_ignores_hidden_folders(self):
|
def test_ignores_hidden_folders(self):
|
||||||
self.assertEqual(self.find('.hidden'), [])
|
self.assertEqual(self.find('.hidden'), [])
|
||||||
|
|||||||
@ -31,5 +31,6 @@ class VersionTest(unittest.TestCase):
|
|||||||
self.assertLess(SV('0.7.2'), SV('0.7.3'))
|
self.assertLess(SV('0.7.2'), SV('0.7.3'))
|
||||||
self.assertLess(SV('0.7.3'), SV('0.8.0'))
|
self.assertLess(SV('0.7.3'), SV('0.8.0'))
|
||||||
self.assertLess(SV('0.8.0'), SV('0.8.1'))
|
self.assertLess(SV('0.8.0'), SV('0.8.1'))
|
||||||
self.assertLess(SV('0.8.1'), SV(__version__))
|
self.assertLess(SV('0.8.1'), SV('0.9.0'))
|
||||||
self.assertLess(SV(__version__), SV('0.9.1'))
|
self.assertLess(SV('0.9.0'), SV(__version__))
|
||||||
|
self.assertLess(SV(__version__), SV('0.10.1'))
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user