Merge pull request #744 from jodal/feature/js-updates

Various updates to Mopidy.js
This commit is contained in:
Thomas Adamcik 2014-06-16 22:26:29 +02:00
commit af238d5259
7 changed files with 264 additions and 131 deletions

View File

@ -74,7 +74,7 @@ development setup in the ``js/`` dir in our repo. The instructions in
Creating an instance
====================
Once you got Mopidy.js loaded, you need to create an instance of the wrapper:
Once you have Mopidy.js loaded, you need to create an instance of the wrapper:
.. code-block:: js
@ -100,6 +100,31 @@ later:
// ... do other stuff, like hooking up events ...
mopidy.connect();
When creating an instance, you can specify the following settings:
``autoConnect``
Whether or not to connect to the WebSocket on instance creation. Defaults
to true.
``backoffDelayMin``
The minimum number of milliseconds to wait after a connection error before
we try to reconnect. For every failed attempt, the backoff delay is doubled
until it reaches ``backoffDelayMax``. Defaults to 1000.
``backoffDelayMax``
The maximum number of milliseconds to wait after a connection error before
we try to reconnect. Defaults to 64000.
``webSocket``
An existing WebSocket object to be used instead of creating a new
WebSocket. Defaults to undefined.
``webSocketUrl``
URL used when creating new WebSocket objects. Defaults to
``ws://<document.location.host>/mopidy/ws``, or
``ws://localhost/mopidy/ws`` if ``document.location.host`` isn't
available, like it is in the browser environment.
Hooking up to events
====================
@ -145,7 +170,8 @@ Once your Mopidy.js object has connected to the Mopidy server and emits the
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.
from the call, the errback will be called with a ``Mopidy.ConnectionError``
instance.
All methods in Mopidy's :ref:`core-api` is available via Mopidy.js. The core
API attributes is *not* available, but that shouldn't be a problem as we've
@ -204,22 +230,34 @@ Instead, typical usage will look like this:
}
};
mopidy.playback.getCurrentTrack().then(
printCurrentTrack, console.error.bind(console));
mopidy.playback.getCurrentTrack()
.done(printCurrentTrack);
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.
The function passed to ``done()``, ``printCurrentTrack``, is the callback
that will be called if the method call succeeds. If anything goes wrong,
``done()`` will throw an exception.
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:
If you want to explicitly handle any errors and avoid an exception being
thrown, you can register an error handler function anywhere in a promise
chain. The function will be called with the error object as the only argument:
.. code-block:: js
mopidy.playback.next().then(null, console.error.bind(console));
mopidy.playback.getCurrentTrack()
.catch(console.error.bind(console));
.done(printCurrentTrack);
You can also register the error handler at the end of the promise chain by
passing it as the second argument to ``done()``:
.. code-block:: js
mopidy.playback.getCurrentTrack()
.done(printCurrentTrack, console.error.bind(console));
If you don't hook up an error handler function and never call ``done()`` on the
promise object, when.js will log warnings to the console that you have
unhandled errors. In general, unhandled errors will not go silently missing.
The promise objects returned by Mopidy.js adheres to the `CommonJS Promises/A
<http://wiki.commonjs.org/wiki/Promises/A>`_ standard. We use the
@ -283,44 +321,38 @@ Example to get started with
.. code-block:: js
var consoleError = console.error.bind(console);
var trackDesc = function (track) {
return track.name + " by " + track.artists[0].name +
" from " + track.album.name;
};
var queueAndPlayFirstPlaylist = function () {
mopidy.playlists.getPlaylists().then(function (playlists) {
var playlist = playlists[0];
var queueAndPlay = function (playlistNum, trackNum) {
playlistNum = playlistNum || 0;
trackNum = trackNum || 0;
mopidy.playlists.getPlaylists().done(function (playlists) {
var playlist = playlists[playlistNum];
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) {
mopidy.tracklist.add(playlist.tracks).done(function (tlTracks) {
mopidy.playback.play(tlTracks[trackNum]).done(function () {
mopidy.playback.getCurrentTrack().done(function (track) {
console.log("Now playing:", trackDesc(track));
}, consoleError);
}, consoleError);
}, consoleError);
}, consoleError);
});
});
});
});
};
var mopidy = new Mopidy(); // Connect to server
mopidy.on(console.log.bind(console)); // Log all events
mopidy.on("state:online", queueAndPlayFirstPlaylist);
mopidy.on("state:online", queueAndPlay);
Approximately the same behavior in a more functional style, using chaining
of promisies.
of promises.
.. code-block:: js
var consoleError = console.error.bind(console);
var getFirst = function (list) {
return list[0];
};
var extractTracks = function (playlist) {
return playlist.tracks;
var get = function (key, object) {
return object[key];
};
var printTypeAndName = function (model) {
@ -339,33 +371,36 @@ Example to get started with
// 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;
});
return mopidy.playback.getCurrentTrack()
.done(function (track) {
console.log("Now playing:", trackDesc(track));
return args;
});
};
var queueAndPlayFirstPlaylist = function () {
var queueAndPlay = function (playlistNum, trackNum) {
playlistNum = playlistNum || 0;
trackNum = trackNum || 0;
mopidy.playlists.getPlaylists()
// => list of Playlists
.then(getFirst, consoleError)
.fold(get, playlistNum)
// => Playlist
.then(printTypeAndName, consoleError)
.then(printTypeAndName)
// => Playlist
.then(extractTracks, consoleError)
.fold(get, 'tracks')
// => list of Tracks
.then(mopidy.tracklist.add, consoleError)
.then(mopidy.tracklist.add)
// => list of TlTracks
.then(getFirst, consoleError)
.fold(get, trackNum)
// => TlTrack
.then(mopidy.playback.play, consoleError)
.then(mopidy.playback.play)
// => null
.then(printNowPlaying, consoleError);
.done(printNowPlaying, console.error.bind(console));
};
var mopidy = new Mopidy(); // Connect to server
mopidy.on(console.log.bind(console)); // Log all events
mopidy.on("state:online", queueAndPlayFirstPlaylist);
mopidy.on("state:online", queueAndPlay);
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,

View File

@ -78,6 +78,20 @@ Feature release.
Mopidy's HTTP server among other Zeroconf-published HTTP servers on the
local network.
- Update Mopidy.js to use when.js 3. If you maintain a Mopidy client, you
should review the `differences between when.js 2 and 3
<https://github.com/cujojs/when/blob/master/docs/api.md#upgrading-to-30-from-2x>`_
and the `when.js debugging guide
<https://github.com/cujojs/when/blob/master/docs/api.md#debugging-promises>`_.
This version has been released to npm as Mopidy.js v0.3.0.
- All of Mopidy.js' promise rejection values are now of the Error type. This
ensures that all JavaScript VMs will show a useful stack trace if a rejected
promise's value is used to throw an exception. To allow catch clauses to
handle different errors differently, server side errors are of the type
``Mopidy.ServerError``, and connection related errors are of the type
``Mopidy.ConnectionError``.
**MPD frontend**
- Proper command tokenization for MPD requests. This replaces the old regex

View File

@ -26,7 +26,7 @@ module.exports = function (grunt) {
},
options: {
postBundleCB: function (err, src, next) {
next(null, grunt.template.process("<%= meta.banner %>") + src);
next(err, grunt.template.process("<%= meta.banner %>") + src);
},
standalone: "Mopidy"
}
@ -45,7 +45,7 @@ module.exports = function (grunt) {
},
options: {
postBundleCB: function (err, src, next) {
next(null, grunt.template.process("<%= meta.banner %>") + src);
next(err, grunt.template.process("<%= meta.banner %>") + src);
},
standalone: "Mopidy"
}

View File

@ -47,13 +47,9 @@ See the [Mopidy.js documentation](http://docs.mopidy.com/en/latest/api/js/).
Building from source
--------------------
1. Install [Node.js](http://nodejs.org/) and npm. There is a PPA if you're
running Ubuntu:
1. Install [Node.js](http://nodejs.org/) and npm. If you're running Ubuntu:
sudo apt-get install python-software-properties
sudo add-apt-repository ppa:chris-lea/node.js
sudo apt-get update
sudo apt-get install nodejs
sudo apt-get install nodejs-legacy npm
2. Enter the `js/` in Mopidy's Git repo dir and install all dependencies:
@ -84,6 +80,20 @@ To run other [grunt](http://gruntjs.com/) targets which isn't predefined in
Changelog
---------
### 0.3.0 (UNRELEASED)
- Upgrade to when.js 3, which brings great performance improvements and better
debugging facilities. If you maintain a Mopidy client, you should review the
[differences between when.js 2 and 3](https://github.com/cujojs/when/blob/master/docs/api.md#upgrading-to-30-from-2x)
and the
[when.js debugging guide](https://github.com/cujojs/when/blob/master/docs/api.md#debugging-promises).
- All promise rejection values are now of the Error type. This ensures that all
JavaScript VMs will show a useful stack trace if a rejected promise's value
is used to throw an exception. To allow catch clauses to handle different
errors differently, server side errors are of the type `Mopidy.ServerError`,
and connection related errors are of the type `Mopidy.ConnectionError`.
### 0.2.0 (2014-01-04)
- **Backwards incompatible change for Node.js users:**

View File

@ -1,6 +1,6 @@
{
"name": "mopidy",
"version": "0.2.0",
"version": "0.3.0",
"description": "Client lib for controlling a Mopidy music server over a WebSocket",
"homepage": "http://www.mopidy.com/",
"author": {
@ -16,17 +16,18 @@
"dependencies": {
"bane": "~1.1.0",
"faye-websocket": "~0.7.2",
"when": "~2.7.1"
"when": "~3.2.3"
},
"devDependencies": {
"buster": "~0.7.8",
"grunt": "~0.4.2",
"buster": "~0.7.13",
"browserify": "~3",
"grunt": "~0.4.5",
"grunt-buster": "~0.3.1",
"grunt-browserify": "~1.3.0",
"grunt-contrib-jshint": "~0.8.0",
"grunt-contrib-uglify": "~0.2.7",
"grunt-contrib-watch": "~0.5.3",
"phantomjs": "~1.9.2-6"
"grunt-browserify": "~1.3.2",
"grunt-contrib-jshint": "~0.10.0",
"grunt-contrib-uglify": "~0.5.0",
"grunt-contrib-watch": "~0.6.1",
"phantomjs": "~1.9.7-8"
},
"scripts": {
"test": "grunt test",

View File

@ -24,13 +24,27 @@ function Mopidy(settings) {
}
}
Mopidy.ConnectionError = function (message) {
this.name = "ConnectionError";
this.message = message;
};
Mopidy.ConnectionError.prototype = new Error();
Mopidy.ConnectionError.prototype.constructor = Mopidy.ConnectionError;
Mopidy.ServerError = function (message) {
this.name = "ServerError";
this.message = message;
};
Mopidy.ServerError.prototype = new Error();
Mopidy.ServerError.prototype.constructor = Mopidy.ServerError;
Mopidy.WebSocket = websocket.Client;
Mopidy.prototype._configure = function (settings) {
var currentHost = (typeof document !== "undefined" &&
document.location.host) || "localhost";
settings.webSocketUrl = settings.webSocketUrl ||
"ws://" + currentHost + "/mopidy/ws/";
"ws://" + currentHost + "/mopidy/ws";
if (settings.autoConnect !== false) {
settings.autoConnect = true;
@ -102,10 +116,9 @@ Mopidy.prototype._cleanup = function (closeEvent) {
Object.keys(this._pendingRequests).forEach(function (requestId) {
var resolver = this._pendingRequests[requestId];
delete this._pendingRequests[requestId];
resolver.reject({
message: "WebSocket closed",
closeEvent: closeEvent
});
var error = new Mopidy.ConnectionError("WebSocket closed");
error.closeEvent = closeEvent;
resolver.reject(error);
}.bind(this));
this.emit("state:offline");
@ -141,33 +154,25 @@ Mopidy.prototype._handleWebSocketError = function (error) {
};
Mopidy.prototype._send = function (message) {
var deferred = when.defer();
switch (this._webSocket.readyState) {
case Mopidy.WebSocket.CONNECTING:
deferred.resolver.reject({
message: "WebSocket is still connecting"
});
break;
return when.reject(
new Mopidy.ConnectionError("WebSocket is still connecting"));
case Mopidy.WebSocket.CLOSING:
deferred.resolver.reject({
message: "WebSocket is closing"
});
break;
return when.reject(
new Mopidy.ConnectionError("WebSocket is closing"));
case Mopidy.WebSocket.CLOSED:
deferred.resolver.reject({
message: "WebSocket is closed"
});
break;
return when.reject(
new Mopidy.ConnectionError("WebSocket is closed"));
default:
var deferred = when.defer();
message.jsonrpc = "2.0";
message.id = this._nextRequestId();
this._pendingRequests[message.id] = deferred.resolver;
this._webSocket.send(JSON.stringify(message));
this.emit("websocket:outgoingMessage", message);
return deferred.promise;
}
return deferred.promise;
};
Mopidy.prototype._nextRequestId = (function () {
@ -208,19 +213,22 @@ Mopidy.prototype._handleResponse = function (responseMessage) {
return;
}
var error;
var resolver = this._pendingRequests[responseMessage.id];
delete this._pendingRequests[responseMessage.id];
if (responseMessage.hasOwnProperty("result")) {
resolver.resolve(responseMessage.result);
} else if (responseMessage.hasOwnProperty("error")) {
resolver.reject(responseMessage.error);
error = new Mopidy.ServerError(responseMessage.error.message);
error.code = responseMessage.error.code;
error.data = responseMessage.error.data;
resolver.reject(error);
this._console.warn("Server returned error:", responseMessage.error);
} else {
resolver.reject({
message: "Response without 'result' or 'error' received",
data: {response: responseMessage}
});
error = new Error("Response without 'result' or 'error' received");
error.data = {response: responseMessage};
resolver.reject(error);
this._console.warn(
"Response without 'result' or 'error' received. Message was:",
responseMessage);

View File

@ -48,7 +48,7 @@ buster.testCase("Mopidy", {
document.location.host || "localhost";
assert.calledOnceWith(this.webSocketConstructorStub,
"ws://" + currentHost + "/mopidy/ws/");
"ws://" + currentHost + "/mopidy/ws");
},
"does not connect when autoConnect is false": function () {
@ -84,7 +84,7 @@ buster.testCase("Mopidy", {
document.location.host || "localhost";
assert.calledOnceWith(this.webSocketConstructorStub,
"ws://" + currentHost + "/mopidy/ws/");
"ws://" + currentHost + "/mopidy/ws");
},
"does nothing when the WebSocket is open": function () {
@ -169,12 +169,18 @@ buster.testCase("Mopidy", {
this.mopidy._cleanup(closeEvent);
assert.equals(Object.keys(this.mopidy._pendingRequests).length, 0);
when.join(promise1, promise2).then(done(function () {
assert(false, "Promises should be rejected");
}), done(function (error) {
assert.equals(error.message, "WebSocket closed");
assert.same(error.closeEvent, closeEvent);
}));
when.settle([promise1, promise2]).done(
done(function (descriptors) {
assert.equals(descriptors.length, 2);
descriptors.forEach(function (d) {
assert.equals(d.state, "rejected");
assert(d.reason instanceof Error);
assert(d.reason instanceof Mopidy.ConnectionError);
assert.equals(d.reason.message, "WebSocket closed");
assert.same(d.reason.closeEvent, closeEvent);
});
})
);
},
"emits 'state:offline' event when done": function () {
@ -388,12 +394,17 @@ buster.testCase("Mopidy", {
var promise = this.mopidy._send({method: "foo"});
refute.called(this.mopidy._webSocket.send);
promise.then(done(function () {
assert(false);
}), done(function (error) {
assert.equals(
error.message, "WebSocket is still connecting");
}));
promise.done(
done(function () {
assert(false);
}),
done(function (error) {
assert(error instanceof Error);
assert(error instanceof Mopidy.ConnectionError);
assert.equals(
error.message, "WebSocket is still connecting");
})
);
},
"immediately rejects request if CLOSING": function (done) {
@ -402,12 +413,16 @@ buster.testCase("Mopidy", {
var promise = this.mopidy._send({method: "foo"});
refute.called(this.mopidy._webSocket.send);
promise.then(done(function () {
assert(false);
}), done(function (error) {
assert.equals(
error.message, "WebSocket is closing");
}));
promise.done(
done(function () {
assert(false);
}),
done(function (error) {
assert(error instanceof Error);
assert(error instanceof Mopidy.ConnectionError);
assert.equals(error.message, "WebSocket is closing");
})
);
},
"immediately rejects request if CLOSED": function (done) {
@ -416,12 +431,16 @@ buster.testCase("Mopidy", {
var promise = this.mopidy._send({method: "foo"});
refute.called(this.mopidy._webSocket.send);
promise.then(done(function () {
assert(false);
}), done(function (error) {
assert.equals(
error.message, "WebSocket is closed");
}));
promise.done(
done(function () {
assert(false);
}),
done(function (error) {
assert(error instanceof Error);
assert(error instanceof Mopidy.ConnectionError);
assert.equals(error.message, "WebSocket is closed");
})
);
}
},
@ -544,7 +563,11 @@ buster.testCase("Mopidy", {
"rejects and logs requests which get errors back": function (done) {
var stub = this.stub(this.mopidy._console, "warn");
var promise = this.mopidy._send({method: "bar"});
var responseError = {message: "Error", data: {}};
var responseError = {
code: -32601,
message: "Method not found",
data: {}
};
var responseMessage = {
jsonrpc: "2.0",
id: Object.keys(this.mopidy._pendingRequests)[0],
@ -555,11 +578,49 @@ buster.testCase("Mopidy", {
assert.calledOnceWith(stub,
"Server returned error:", responseError);
promise.then(done(function () {
assert(false);
}), done(function (error) {
assert.equals(error, responseError);
}));
promise.done(
done(function () {
assert(false);
}),
done(function (error) {
assert(error instanceof Error);
assert.equals(error.code, responseError.code);
assert.equals(error.message, responseError.message);
assert.equals(error.data, responseError.data);
})
);
},
"rejects and logs requests which get errors without data": function (done) {
var stub = this.stub(this.mopidy._console, "warn");
var promise = this.mopidy._send({method: "bar"});
var responseError = {
code: -32601,
message: "Method not found"
// 'data' key intentionally missing
};
var responseMessage = {
jsonrpc: "2.0",
id: Object.keys(this.mopidy._pendingRequests)[0],
error: responseError
};
this.mopidy._handleResponse(responseMessage);
assert.calledOnceWith(stub,
"Server returned error:", responseError);
promise.done(
done(function () {
assert(false);
}),
done(function (error) {
assert(error instanceof Error);
assert(error instanceof Mopidy.ServerError);
assert.equals(error.code, responseError.code);
assert.equals(error.message, responseError.message);
refute.defined(error.data);
})
);
},
"rejects and logs responses without result or error": function (done) {
@ -575,14 +636,18 @@ buster.testCase("Mopidy", {
assert.calledOnceWith(stub,
"Response without 'result' or 'error' received. Message was:",
responseMessage);
promise.then(done(function () {
assert(false);
}), done(function (error) {
assert.equals(
error.message,
"Response without 'result' or 'error' received");
assert.equals(error.data.response, responseMessage);
}));
promise.done(
done(function () {
assert(false);
}),
done(function (error) {
assert(error instanceof Error);
assert.equals(
error.message,
"Response without 'result' or 'error' received");
assert.equals(error.data.response, responseMessage);
})
);
}
},