diff --git a/.gitignore b/.gitignore index 21adc7af..9229541f 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,5 @@ coverage.xml dist/ docs/_build/ mopidy.log* +node_modules/ nosetests.xml diff --git a/js/README.rst b/js/README.rst new file mode 100644 index 00000000..a68dd9a0 --- /dev/null +++ b/js/README.rst @@ -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 `_ 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 `_ and `Grunt + `_ 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 `_ 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. diff --git a/js/buster.js b/js/buster.js new file mode 100644 index 00000000..f789885a --- /dev/null +++ b/js/buster.js @@ -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"] +}; diff --git a/js/grunt.js b/js/grunt.js new file mode 100644 index 00000000..7be4d882 --- /dev/null +++ b/js/grunt.js @@ -0,0 +1,65 @@ +/*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: ["", "lib/**/*.js", "src/mopidy.js"], + dest: "<%= dirs.dest %>/mopidy.js" + } + }, + min: { + dist: { + src: ["", ""], + dest: "<%= dirs.dest %>/mopidy.min.js" + } + }, + watch: { + 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"); +}; diff --git a/js/lib/bane-0.4.0.js b/js/lib/bane-0.4.0.js new file mode 100644 index 00000000..a1da6efa --- /dev/null +++ b/js/lib/bane-0.4.0.js @@ -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 }; +}); diff --git a/js/lib/when-1.6.1.js b/js/lib/when-1.6.1.js new file mode 100644 index 00000000..e9a3bfc3 --- /dev/null +++ b/js/lib/when-1.6.1.js @@ -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 +); diff --git a/js/src/mopidy.js b/js/src/mopidy.js new file mode 100644 index 00000000..66c17b79 --- /dev/null +++ b/js/src/mopidy.js @@ -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("_", ""); + }); +}; diff --git a/js/test/bind-helper.js b/js/test/bind-helper.js new file mode 100644 index 00000000..a5a3e0f4 --- /dev/null +++ b/js/test/bind-helper.js @@ -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; + }; +} diff --git a/js/test/mopidy-test.js b/js/test/mopidy-test.js new file mode 100644 index 00000000..fd1f73c6 --- /dev/null +++ b/js/test/mopidy-test.js @@ -0,0 +1,669 @@ +/*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": { + "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); + } + } +}); diff --git a/mopidy/frontends/http/__init__.py b/mopidy/frontends/http/__init__.py index d98734b2..d81d4791 100644 --- a/mopidy/frontends/http/__init__.py +++ b/mopidy/frontends/http/__init__.py @@ -115,17 +115,335 @@ Example JSON-RPC request:: Example JSON-RPC response:: - {"jsonrpc": "2.0", "id": 1, "result": {"__model__": "Track", ...}} + {"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. -JavaScript wrapper -================== -A JavaScript library wrapping the JSON-RPC over WebSocket API is under -development. Details on it will appear here when it's released. +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 + + + +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/" + }); + + +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 +`_, 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 +`_ standard. We use the +implementation known as `when.js `_. Please +refer to when.js' documentation or the standard for further details on how to +work with promise objects. + + +Cleaning up +----------- + +If you for some reason want to clean up after Mopidy.js before the web page is +closed or navigated away from, you can close the WebSocket, unregister all +event listeners, and delete the object like this: + +.. code-block:: js + + // Close the WebSocket without reconnecting. Letting the object be garbage + // collected will have the same effect, so this isn't 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 + + + +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 diff --git a/mopidy/frontends/http/data/mopidy.js b/mopidy/frontends/http/data/mopidy.js new file mode 100644 index 00000000..6249ef7f --- /dev/null +++ b/mopidy/frontends/http/data/mopidy.js @@ -0,0 +1,1187 @@ +/*! Mopidy.js - built 2012-12-04 + * http://www.mopidy.com/ + * Copyright (c) 2012 Stein Magnus Jodal and contributors + * Licensed under the Apache License, Version 2.0 */ + +/** + * 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 }; +}); + +/** @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 +); + +/*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("_", ""); + }); +}; diff --git a/mopidy/frontends/http/data/mopidy.min.js b/mopidy/frontends/http/data/mopidy.min.js new file mode 100644 index 00000000..42d34319 --- /dev/null +++ b/mopidy/frontends/http/data/mopidy.min.js @@ -0,0 +1,5 @@ +/*! Mopidy.js - built 2012-12-04 + * http://www.mopidy.com/ + * Copyright (c) 2012 Stein Magnus Jodal and contributors + * Licensed under the Apache License, Version 2.0 */ +function Mopidy(e){this._settings=this._configure(e||{}),this._console=this._getConsole(),this._backoffDelay=this._settings.backoffDelayMin,this._pendingRequests={},this._webSocket=null,bane.createEventEmitter(this),this._delegateEvents(),this._settings.autoConnect&&this._connect()}(typeof define=="function"&&define.amd&&function(e){define(e)}||typeof module=="object"&&function(e){module.exports=e()}||function(e){this.bane=e()})(function(){"use strict";function t(e,t,n){var r,i=n.length;if(i>0){for(r=0;r>>0,o=Math.max(0,Math.min(t,v)),a=[],u=v-o+1,l=[],c=f();if(!o)c.resolve(a);else{d=c.progress,p=function(e){l.push(e),--u||(h=p=w,c.reject(l))},h=function(e){a.push(e),--o||(h=p=w,c.resolve(a))};for(m=0;m>>0,n=[],l=f();if(!s)l.resolve(n);else{u=l.reject,o=function(i,o){r(i,t).then(function(e){n[o]=e,--s||l.resolve(n)},u)};for(a=0;a2;return r(e,function(e){return t.resolve(i?n:e)},t.reject,t.progress)}function y(e,t){var n,r=0;while(n=e[r++])n(t)}function b(e,t){var n,r=t.length;while(r>e){n=t[--r];if(n!=null&&typeof n!="function")throw new Error("arg "+r+" must be a function")}}function w(){}function E(e){return e}var e,t,n;return r.defer=f,r.resolve=i,r.reject=s,r.join=d,r.all=p,r.some=c,r.any=h,r.map=v,r.reduce=m,r.chain=g,r.isPromise=l,o.prototype={always:function(e,t){return this.then(e,e,t)},otherwise:function(e){return this.then(n,e)}},t=[].slice,e=[].reduce||function(e){var t,n,r,i,s;s=0,t=Object(this),i=t.length>>>0,n=arguments;if(n.length<=1)for(;;){if(s in t){r=t[s++];break}if(++s>=i)throw new TypeError}else r=n[1];for(;sthis._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(e){this._console.warn("WebSocket error:",e.stack||e)},Mopidy.prototype._send=function(e){var t=when.defer();switch(this._webSocket.readyState){case WebSocket.CONNECTING:t.resolver.reject({message:"WebSocket is still connecting"});break;case WebSocket.CLOSING:t.resolver.reject({message:"WebSocket is closing"});break;case WebSocket.CLOSED:t.resolver.reject({message:"WebSocket is closed"});break;default:e.jsonrpc="2.0",e.id=this._nextRequestId(),this._pendingRequests[e.id]=t.resolver,this._webSocket.send(JSON.stringify(e)),this.emit("websocket:outgoingMessage",e)}return t.promise},Mopidy.prototype._nextRequestId=function(){var e=-1;return function(){return e+=1,e}}(),Mopidy.prototype._handleMessage=function(e){try{var t=JSON.parse(e.data);t.hasOwnProperty("id")?this._handleResponse(t):t.hasOwnProperty("event")?this._handleEvent(t):this._console.warn("Unknown message type received. Message was: "+e.data)}catch(n){if(!(n instanceof SyntaxError))throw n;this._console.warn("WebSocket message parsing failed. Message was: "+e.data)}},Mopidy.prototype._handleResponse=function(e){if(!this._pendingRequests.hasOwnProperty(e.id)){this._console.warn("Unexpected response received. Message was:",e);return}var t=this._pendingRequests[e.id];delete this._pendingRequests[e.id],e.hasOwnProperty("result")?t.resolve(e.result):e.hasOwnProperty("error")?(t.reject(e.error),this._console.warn("Server returned error:",e.error)):(t.reject({message:"Response without 'result' or 'error' received",data:{response:e}}),this._console.warn("Response without 'result' or 'error' received. Message was:",e))},Mopidy.prototype._handleEvent=function(e){var t=e.event,n=e;delete n.event,this.emit("event:"+this._snakeToCamel(t),n)},Mopidy.prototype._getApiSpec=function(){this._send({method:"core.describe"}).then(this._createApi.bind(this),this._handleWebSocketError).then(null,this._handleWebSocketError)},Mopidy.prototype._createApi=function(e){var t=function(e){return function(){var t=Array.prototype.slice.call(arguments);return this._send({method:e,params:t})}.bind(this)}.bind(this),n=function(e){var t=e.split(".");return t.length>=1&&t[0]==="core"&&(t=t.slice(1)),t},r=function(e){var t=this;return e.forEach(function(e){e=this._snakeToCamel(e),t[e]=t[e]||{},t=t[e]}.bind(this)),t}.bind(this),i=function(i){var s=n(i),o=this._snakeToCamel(s.slice(-1)[0]),u=r(s.slice(0,-1));u[o]=t(i),u[o].description=e[i].description,u[o].params=e[i].params}.bind(this);Object.keys(e).forEach(i),this.emit("state:online")},Mopidy.prototype._snakeToCamel=function(e){return e.replace(/(_[a-z])/g,function(e){return e.toUpperCase().replace("_","")})}; \ No newline at end of file