Release v0.10.0

This commit is contained in:
Stein Magnus Jodal 2012-12-13 00:20:54 +01:00
commit f94716d3b7
60 changed files with 5604 additions and 95 deletions

1
.gitignore vendored
View File

@ -11,4 +11,5 @@ coverage.xml
dist/
docs/_build/
mopidy.log*
node_modules/
nosetests.xml

View File

@ -5,6 +5,7 @@ install:
- "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 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:
- "rm $VIRTUAL_ENV/lib/python$TRAVIS_PYTHON_VERSION/no-global-site-packages.txt"

View File

@ -7,6 +7,7 @@ include mopidy/backends/spotify/spotify_appkey.key
include pylintrc
recursive-include docs *
prune docs/_build
recursive-include mopidy/frontends/http/data/
recursive-include requirements *
recursive-include tests *.py
recursive-include tests/data *

View File

@ -4,6 +4,57 @@ Changes
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)
===================

14
docs/clients/http.rst Normal file
View 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.

View File

@ -39,6 +39,7 @@ class Mock(object):
MOCK_MODULES = [
'cherrypy',
'dbus',
'dbus.mainloop',
'dbus.mainloop.glib',
@ -53,6 +54,11 @@ MOCK_MODULES = [
'pykka.registry',
'pylast',
'serial',
'ws4py',
'ws4py.messaging',
'ws4py.server',
'ws4py.server.cherrypyserver',
'ws4py.websocket',
]
for mod_name in MOCK_MODULES:
sys.modules[mod_name] = Mock()

View File

@ -167,12 +167,19 @@ can install Mopidy from PyPI using 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:
- Pykka >= 1.0::
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
popular Linux distributions. Search for GStreamer in your package manager,
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 \
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
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
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::
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
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
Menu or from an UPnP client via Rygel, you need some additional
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
# Fedora:
sudo pip-python install -U mopidy
To upgrade Mopidy to future releases, just rerun this command.
Alternatively, if you want to track Mopidy development closer, you may

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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);
}
}
});

View File

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

View File

@ -19,7 +19,20 @@ class AudioListener(object):
"""Helper to allow calling of audio listener events"""
listeners = pykka.ActorRegistry.get_by_class(AudioListener)
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):
"""

View File

@ -21,7 +21,20 @@ class BackendListener(object):
"""Helper to allow calling of backend listener events"""
listeners = pykka.ActorRegistry.get_by_class(BackendListener)
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):
"""

View File

@ -57,7 +57,7 @@ class LocalPlaylistsProvider(base.BasePlaylistsProvider):
# from other backends
tracks += self.backend.library.lookup(track_uri)
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)
playlists.append(playlist)

View File

@ -1,6 +1,7 @@
from __future__ import unicode_literals
import logging
import urllib
from mopidy.models import Track, Artist, Album
from mopidy.utils.encoding import locale_decode
@ -35,7 +36,7 @@ def parse_m3u(file_path, music_folder):
with open(file_path) as m3u:
contents = m3u.readlines()
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
for line in contents:
@ -64,7 +65,7 @@ def parse_mpd_tag_cache(tag_cache, music_dir=''):
with open(tag_cache) as library:
contents = library.read()
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
current = {}
@ -139,6 +140,7 @@ def _convert_mpd_data(data, tracks, music_dir):
path = data['file'][1:]
else:
path = data['file']
path = urllib.unquote(path)
if artist_kwargs:
artist = Artist(**artist_kwargs)

View File

@ -21,7 +21,7 @@ https://github.com/mopidy/mopidy/issues?labels=Spotify+backend
**Dependencies:**
- 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:**

View File

@ -46,7 +46,6 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager):
self.backend_ref = backend_ref
self.connected = threading.Event()
self.session = None
self.container_manager = None
self.playlist_manager = None
@ -64,17 +63,20 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager):
return
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(
'Preferred Spotify bitrate is %s kbps',
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.playlist_manager = SpotifyPlaylistManager(self)
self.container_manager.watch(self.session.playlist_container())
self.container_manager.watch(session.playlist_container())
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
# test with clean Spotify cache. In cases with an outdated cache
# the time improvements should be a lot greater.
self._initial_data_receive_completed = True
self.refresh_playlists()
if not self._initial_data_receive_completed:
self._initial_data_receive_completed = True
self.refresh_playlists()
def end_of_track(self, session):
"""Callback used by pyspotify"""
@ -178,5 +181,7 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager):
def logout(self):
"""Log 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()

View File

@ -6,32 +6,45 @@ from mopidy import settings
from mopidy.models import Artist, Album, Track, Playlist
artist_cache = {}
album_cache = {}
track_cache = {}
def to_mopidy_artist(spotify_artist):
if spotify_artist is None:
return
uri = str(Link.from_artist(spotify_artist))
if uri in artist_cache:
return artist_cache[uri]
if not spotify_artist.is_loaded():
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):
if spotify_album is None:
return
uri = str(Link.from_album(spotify_album))
if uri in album_cache:
return album_cache[uri]
if not spotify_album.is_loaded():
return Album(uri=uri, name='[loading...]')
return Album(
album_cache[uri] = Album(
uri=uri,
name=spotify_album.name(),
artists=[to_mopidy_artist(spotify_album.artist())],
date=spotify_album.year())
return album_cache[uri]
def to_mopidy_track(spotify_track):
if spotify_track is None:
return
uri = str(Link.from_track(spotify_track, 0))
if uri in track_cache:
return track_cache[uri]
if not spotify_track.is_loaded():
return Track(uri=uri, name='[loading...]')
spotify_album = spotify_track.album()
@ -39,7 +52,7 @@ def to_mopidy_track(spotify_track):
date = spotify_album.year()
else:
date = None
return Track(
track_cache[uri] = Track(
uri=uri,
name=spotify_track.name(),
artists=[to_mopidy_artist(a) for a in spotify_track.artists()],
@ -48,6 +61,7 @@ def to_mopidy_track(spotify_track):
date=date,
length=spotify_track.duration(),
bitrate=settings.SPOTIFY_BITRATE)
return track_cache[uri]
def to_mopidy_playlist(spotify_playlist):

View File

@ -19,7 +19,20 @@ class CoreListener(object):
"""Helper to allow calling of core listener events"""
listeners = pykka.ActorRegistry.get_by_class(CoreListener)
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):
"""

View File

@ -53,6 +53,9 @@ class PlaybackController(object):
Tracks are not removed from the playlist.
"""
def get_current_tl_track(self):
return self.current_tl_track
current_tl_track = None
"""
The currently playing or selected :class:`mopidy.models.TlTrack`, or

View 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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

View 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&nbsp;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>

View 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;
}

View 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&nbsp;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>

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View 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)

View File

@ -2,6 +2,7 @@ from __future__ import unicode_literals
import os
import re
import urllib
from mopidy import settings
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):
music_folder = settings.LOCAL_MUSIC_PATH
regexp = '^' + re.escape(music_folder).rstrip('/') + '/?'
base_path = settings.LOCAL_MUSIC_PATH.encode('utf-8')
for path, entry in folders.items():
name = os.path.split(path)[1]
mtime = get_mtime(os.path.join(music_folder, path))
result.append(('directory', path))
result.append(('mtime', mtime))
try:
text_path = path.decode('utf-8')
except UnicodeDecodeError:
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))
_add_to_tag_cache(result, *entry)
result.append(('end', name))
result.append(('songList begin',))
for track in files:
track_result = dict(track_to_mpd_format(track))
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['file'] = re.sub(regexp, '', path)
track_result['key'] = os.path.basename(track_result['file'])
track_result['key'] = os.path.basename(text_path)
track_result = order_mpd_track_info(track_result.items())
result.extend(track_result)
result.append(('songList end',))
def tracks_to_directory_tree(tracks):
directories = ({}, [])
for track in tracks:
path = ''
path = b''
current = directories
local_folder = settings.LOCAL_MUSIC_PATH
track_path = uri_to_path(track.uri)
track_path = re.sub('^' + re.escape(local_folder), '', track_path)
track_dir = os.path.dirname(track_path)
absolute_track_dir_path = os.path.dirname(uri_to_path(track.uri))
relative_track_dir_path = re.sub(
'^' + re.escape(settings.LOCAL_MUSIC_PATH), b'',
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)
if path not in current[0]:
current[0][path] = ({}, [])

View File

@ -1,11 +1,35 @@
from __future__ import unicode_literals
import logging
import datetime
import logging
import optparse
import os
import sys
import gobject
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
pygst.require('0.10')
import gst
@ -13,12 +37,14 @@ import gst
from mopidy import settings
from mopidy.frontends.mpd import translator as mpd_translator
from mopidy.models import Track, Artist, Album
from mopidy.utils import log, path
from mopidy.utils import log, path, versioning
def main():
options = parse_options()
log.setup_root_logger()
log.setup_console_logging(2)
log.setup_console_logging(options.verbosity_level)
tracks = []
@ -28,16 +54,18 @@ def main():
logging.debug('Added %s', track.uri)
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)
scanner = Scanner(settings.LOCAL_MUSIC_PATH, store, debug)
try:
scanner.start()
except KeyboardInterrupt:
scanner.stop()
logging.info('Done')
logging.info('Done scanning; writing tag cache...')
for row in mpd_translator.tracks_to_tag_cache_format(tracks):
if len(row) == 1:
@ -45,6 +73,22 @@ def main():
else:
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):
albumartist_kwargs = {}
@ -62,8 +106,12 @@ def translator(data):
if gst.TAG_DATE in data and data[gst.TAG_DATE]:
date = data[gst.TAG_DATE]
date = datetime.date(date.year, date.month, date.day)
track_kwargs['date'] = date
try:
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_TRACK_NUMBER, 'track_no', track_kwargs)
@ -188,3 +236,7 @@ class Scanner(object):
def stop(self):
self.pipe.set_state(gst.STATE_NULL)
self.loop.quit()
if __name__ == '__main__':
main()

View File

@ -80,6 +80,39 @@ FRONTENDS = (
'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.
#:
#: Used by :mod:`mopidy.frontends.lastfm`.

View File

@ -35,6 +35,8 @@ def format_dependency_list(adapters=None):
pylast_info,
dbus_info,
serial_info,
cherrypy_info,
ws4py_info,
]
lines = []
@ -189,3 +191,25 @@ def serial_info():
except ImportError:
pass
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
View 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

View File

@ -46,6 +46,9 @@ def setup_console_logging(verbosity_level):
if verbosity_level < 3:
logging.getLogger('pykka').setLevel(logging.INFO)
if verbosity_level < 2:
logging.getLogger('cherrypy').setLevel(logging.WARNING)
def setup_debug_logging_to_file():
formatter = logging.Formatter(settings.DEBUG_LOG_FORMAT)

View File

@ -13,14 +13,21 @@ import glib
logger = logging.getLogger('mopidy.utils.path')
DATA_PATH = os.path.join(str(glib.get_user_data_dir()), 'mopidy')
SETTINGS_PATH = os.path.join(str(glib.get_user_config_dir()), 'mopidy')
SETTINGS_FILE = os.path.join(SETTINGS_PATH, 'settings.py')
XDG_CACHE_DIR = glib.get_user_cache_dir().decode('utf-8')
XDG_CONFIG_DIR = glib.get_user_config_dir().decode('utf-8')
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_CACHE_DIR': glib.get_user_cache_dir(),
'XDG_DATA_DIR': glib.get_user_data_dir(),
'XDG_MUSIC_DIR': glib.get_user_special_dir(glib.USER_DIRECTORY_MUSIC),
'XDG_CACHE_DIR': XDG_CACHE_DIR,
'XDG_CONFIG_DIR': XDG_CONFIG_DIR,
'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):
@ -44,19 +51,40 @@ def get_or_create_file(filename):
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 = path.encode('utf-8')
if isinstance(path, unicode):
path = path.encode('utf-8')
if sys.platform == 'win32':
return 'file:' + urllib.pathname2url(path)
return 'file://' + urllib.pathname2url(path)
return 'file:' + urllib.quote(path)
return 'file://' + urllib.quote(path)
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':
path = urllib.url2pathname(re.sub('^file:', '', uri))
return urllib.unquote(re.sub(b'^file:', b'', uri))
else:
path = urllib.url2pathname(re.sub('^file://', '', uri))
return path.encode('latin1').decode('utf-8') # Undo double encoding
return urllib.unquote(re.sub(b'^file://', b'', uri))
def split_path(path):
@ -65,7 +93,7 @@ def split_path(path):
path, part = os.path.split(path)
if part:
parts.insert(0, part)
if not path or path == '/':
if not path or path == b'/':
break
return parts
@ -78,30 +106,32 @@ def expand_path(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 not isinstance(path, unicode):
path = path.decode('utf-8')
if not os.path.basename(path).startswith('.'):
if not os.path.basename(path).startswith(b'.'):
yield path
else:
for dirpath, dirnames, filenames in os.walk(path):
# Filter out hidden folders by modifying dirnames in place.
for dirname in dirnames:
if dirname.startswith('.'):
if dirname.startswith(b'.'):
# Skip hidden folders by modifying dirnames inplace
dirnames.remove(dirname)
for filename in filenames:
# Skip hidden files.
if filename.startswith('.'):
if filename.startswith(b'.'):
# Skip hidden files
continue
filename = os.path.join(dirpath, filename)
if not isinstance(filename, unicode):
try:
filename = filename.decode('utf-8')
except UnicodeDecodeError:
filename = filename.decode('latin1')
yield filename
yield os.path.join(dirpath, filename)
def check_file_path_is_inside_base_dir(file_path, base_path):

2
requirements/http.txt Normal file
View File

@ -0,0 +1,2 @@
cherrypy >= 3.2.2
ws4py >= 0.2.3

View File

@ -1 +1 @@
pyspotify >= 1.9, < 1.10
pyspotify >= 1.9, < 1.11

View File

@ -1,5 +1,7 @@
from __future__ import unicode_literals
import mock
from mopidy import audio
from tests import unittest
@ -9,6 +11,15 @@ class AudioListenerTest(unittest.TestCase):
def setUp(self):
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):
self.listener.reached_end_of_stream()

View File

@ -1,13 +1,22 @@
from __future__ import unicode_literals
import mock
from mopidy.backends.listener import BackendListener
from tests import unittest
class CoreListenerTest(unittest.TestCase):
class BackendListenerTest(unittest.TestCase):
def setUp(self):
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):
self.listener.playlists_loaded()

View File

@ -1,8 +1,17 @@
from mopidy import settings
from mopidy.backends.local import LocalBackend
from tests import unittest
from tests import unittest, path_to_data_dir
from tests.backends.base import events
class LocalBackendEventsTest(events.BackendEventsTest, unittest.TestCase):
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()

View File

@ -18,6 +18,7 @@ class LocalPlaybackControllerTest(PlaybackControllerTest, unittest.TestCase):
def setUp(self):
settings.BACKENDS = ('mopidy.backends.local.LocalBackend',)
settings.LOCAL_TAG_CACHE_FILE = path_to_data_dir('empty_tag_cache')
super(LocalPlaybackControllerTest, self).setUp()
def tearDown(self):

View File

@ -18,6 +18,14 @@ class LocalPlaylistsControllerTest(
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):
path = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test.m3u')
self.assertFalse(os.path.exists(path))

View File

@ -4,7 +4,7 @@ from mopidy import settings
from mopidy.backends.local import LocalBackend
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.local import generate_song
@ -16,6 +16,7 @@ class LocalTracklistControllerTest(TracklistControllerTest, unittest.TestCase):
def setUp(self):
settings.BACKENDS = ('mopidy.backends.local.LocalBackend',)
settings.LOCAL_TAG_CACHE_FILE = path_to_data_dir('empty_tag_cache')
super(LocalTracklistControllerTest, self).setUp()
def tearDown(self):

View File

@ -5,9 +5,9 @@ from __future__ import unicode_literals
import os
import tempfile
from mopidy.utils.path import path_to_uri
from mopidy.backends.local.translator import parse_m3u, parse_mpd_tag_cache
from mopidy.models import Track, Artist, Album
from mopidy.utils.path import path_to_uri
from tests import unittest, path_to_data_dir

View File

@ -1,5 +1,7 @@
from __future__ import unicode_literals
import mock
from mopidy.core import CoreListener, PlaybackState
from mopidy.models import Playlist, Track
@ -10,6 +12,15 @@ class CoreListenerTest(unittest.TestCase):
def setUp(self):
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):
self.listener.track_playback_paused(Track(), 0)

View File

View 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',
})

View File

@ -131,10 +131,9 @@ class TracksToTagCacheFormatTest(unittest.TestCase):
mtime.undo_fake()
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['file'] = uri_to_path(result['file'])
result['file'] = result['file'][len(folder) + 1:]
result['file'] = uri_to_path(result['file'])[len(base_path) + 1:]
result['key'] = os.path.basename(result['file'])
result['mtime'] = mtime('')
return translator.order_mpd_track_info(result.items())
@ -197,7 +196,7 @@ class TracksToTagCacheFormatTest(unittest.TestCase):
result = self.consume_headers(result)
song_list, result = self.consume_song_list(result)
self.assertEqual(song_list, formated)
self.assertEqual(formated, song_list)
self.assertEqual(len(result), 0)
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)
song_list, result = self.consume_song_list(result)
self.assertEqual(song_list, formated)
self.assertEqual(formated, song_list)
self.assertEqual(len(result), 0)
def test_tag_cache_suports_directories(self):
@ -224,7 +223,7 @@ class TracksToTagCacheFormatTest(unittest.TestCase):
song_list, result = self.consume_song_list(folder)
self.assertEqual(len(result), 0)
self.assertEqual(song_list, formated)
self.assertEqual(formated, song_list)
def test_tag_cache_diretory_header_is_right(self):
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)
self.assertEqual(len(result), 0)
self.assertEqual(song_list, formated)
self.assertEqual(formated, song_list)
def test_tag_cache_supports_multiple_tracks(self):
tracks = [
@ -273,7 +272,7 @@ class TracksToTagCacheFormatTest(unittest.TestCase):
result = self.consume_headers(result)
song_list, result = self.consume_song_list(result)
self.assertEqual(song_list, formated)
self.assertEqual(formated, song_list)
self.assertEqual(len(result), 0)
def test_tag_cache_supports_multiple_tracks_in_dirs(self):
@ -292,12 +291,12 @@ class TracksToTagCacheFormatTest(unittest.TestCase):
folder, result = self.consume_directory(result)
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)
song_list, result = self.consume_song_list(result)
self.assertEqual(len(result), 0)
self.assertEqual(song_list, formated[0])
self.assertEqual(formated[0], song_list)
class TracksToDirectoryTreeTest(unittest.TestCase):

View File

@ -1,7 +1,5 @@
from __future__ import unicode_literals
from datetime import date
from mopidy.scanner import Scanner, translator
from mopidy.models import Track, Artist, Album
@ -53,7 +51,7 @@ class TranslatorTest(unittest.TestCase):
self.track = {
'uri': 'uri',
'name': 'trackname',
'date': date(2006, 1, 1),
'date': '2006-01-01',
'track_no': 1,
'length': 4531,
'musicbrainz_id': 'mbtrackid',
@ -129,6 +127,11 @@ class TranslatorTest(unittest.TestCase):
del self.track['date']
self.check()
def test_invalid_date(self):
self.data['date'] = FakeGstDate(65535, 1, 1)
del self.track['date']
self.check()
class ScannerTest(unittest.TestCase):
def setUp(self):

View File

@ -27,6 +27,16 @@ try:
except ImportError:
spotify = False
try:
import cherrypy
except ImportError:
cherrypy = False
try:
import ws4py
except ImportError:
ws4py = False
from mopidy.utils import deps
from tests import unittest
@ -115,3 +125,19 @@ class DepsTest(unittest.TestCase):
self.assertEquals('pyserial', result['name'])
self.assertEquals(serial.VERSION, result['version'])
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
View 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)

View File

@ -90,31 +90,55 @@ class PathToFileURITest(unittest.TestCase):
result = path.path_to_uri('/tmp/æøå')
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):
def test_simple_uri(self):
if sys.platform == 'win32':
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:
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):
if sys.platform == 'win32':
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:
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):
if sys.platform == 'win32':
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:
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):
@ -177,11 +201,11 @@ class FindFilesTest(unittest.TestCase):
self.assertEqual(len(files), 1)
self.assert_(files[0], path_to_data_dir('blank.mp3'))
def test_names_are_unicode(self):
is_unicode = lambda f: isinstance(f, unicode)
def test_names_are_bytestrings(self):
is_bytes = lambda f: isinstance(f, bytes)
for name in self.find(''):
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):
self.assertEqual(self.find('.hidden'), [])

View File

@ -31,5 +31,6 @@ class VersionTest(unittest.TestCase):
self.assertLess(SV('0.7.2'), SV('0.7.3'))
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.1'), SV(__version__))
self.assertLess(SV(__version__), SV('0.9.1'))
self.assertLess(SV('0.8.1'), SV('0.9.0'))
self.assertLess(SV('0.9.0'), SV(__version__))
self.assertLess(SV(__version__), SV('0.10.1'))