diff --git a/.mailmap b/.mailmap index b38c3f66..2ff779fc 100644 --- a/.mailmap +++ b/.mailmap @@ -5,3 +5,6 @@ Kristian Klette Johannes Knutsen Johannes Knutsen John Bäckstrand +Alli Witheford +Alexandre Petitjean +Alexandre Petitjean diff --git a/.travis.yml b/.travis.yml index df08679b..0b68eb8f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,15 +1,18 @@ language: python install: - - "wget -q -O - http://apt.mopidy.com/mopidy.gpg | sudo apt-key add -" - - "sudo wget -q -O /etc/apt/sources.list.d/mopidy.list http://apt.mopidy.com/mopidy.list" + - "wget -O - http://apt.mopidy.com/mopidy.gpg | sudo apt-key add -" + - "sudo wget -O /etc/apt/sources.list.d/mopidy.list http://apt.mopidy.com/mopidy.list" - "sudo apt-get update || true" - "sudo apt-get install $(apt-cache depends mopidy | awk '$2 !~ /mopidy/ {print $2}')" + - "pip install flake8" before_script: - "rm $VIRTUAL_ENV/lib/python$TRAVIS_PYTHON_VERSION/no-global-site-packages.txt" -script: nosetests +script: + - "flake8 $(find . -iname '*.py')" + - "nosetests" notifications: irc: diff --git a/AUTHORS b/AUTHORS index 87925152..052865b7 100644 --- a/AUTHORS +++ b/AUTHORS @@ -19,3 +19,8 @@ - Nick Steel - Zan Dobersek - Thomas Refis +- Janez Troha +- Tobias Sauerwein +- Alli Witheford +- Alexandre Petitjean +- Pavol Babincak diff --git a/README.rst b/README.rst index f667b7db..a34b1bb6 100644 --- a/README.rst +++ b/README.rst @@ -25,4 +25,13 @@ To get started with Mopidy, check out `the docs `_. - Mailing list: `mopidy@googlegroups.com `_ - Twitter: `@mopidy `_ -.. image:: https://secure.travis-ci.org/mopidy/mopidy.png?branch=develop +.. image:: https://travis-ci.org/mopidy/mopidy.png?branch=develop + :target: https://travis-ci.org/mopidy/mopidy + +.. image:: https://pypip.in/v/Mopidy/badge.png + :target: https://crate.io/packages/Mopidy/ + :alt: Latest PyPI version + +.. image:: https://pypip.in/d/Mopidy/badge.png + :target: https://crate.io/packages/Mopidy/ + :alt: Number of PyPI downloads diff --git a/docs/changelog.rst b/docs/changelog.rst index 6241f748..0fb05f8c 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,15 +4,16 @@ Changelog This changelog is used to track all major changes to Mopidy. -v0.15.0 (UNRELEASED) +v0.15.0 (2013-09-19) ==================== -(no description yet) +A release with a number of small and medium fixes, with no specific focus. **Dependencies** - Mopidy no longer supports Python 2.6. Currently, the only Python version - supported by Mopidy is Python 2.7. (Fixes: :issue:`344`) + supported by Mopidy is Python 2.7. We're continuously working towards running + Mopidy on Python 3. (Fixes: :issue:`344`) **Command line options** @@ -20,7 +21,76 @@ v0.15.0 (UNRELEASED) options. - :option:`mopidy --show-config` will now take into consideration any - :option:`mopidy --option` arguments appearing later on the command line. + :option:`mopidy --option` arguments appearing later on the command line. This + helps you see the effective configuration for runs with the same + :option:`mopidy --options` arguments. + +**Audio** + +- Added support for audio visualization. :confval:`audio/visualizer` can now be + set to GStreamer visualizers. + +- Properly encode localized mixer names before logging. + +**Local backend** + +- An album's number of discs and a track's disc number are now extracted when + scanning your music collection. + +- The scanner now gives up scanning a file after a second, and continues with + the next file. This fixes some hangs on non-media files, like logs. (Fixes: + :issue:`476`, :issue:`483`) + +- Added support for pluggable library updaters. This allows extension writers + to start providing their own custom libraries instead of being stuck with + just our tag cache as the only option. + +- Converted local backend to use new ``local:playlist:path`` and + ``local:track:path`` URI scheme. Also moves support of ``file://`` to + streaming backend. + +**Spotify backend** + +- Prepend playlist folder names to the playlist name, so that the playlist + hierarchy from your Spotify account is available in Mopidy. (Fixes: + :issue:`62`) + +- Fix proxy config values that was broken with the config system change in + 0.14. (Fixes: :issue:`472`) + +**MPD frontend** + +- Replace newline, carriage return and forward slash in playlist names. (Fixes: + :issue:`474`, :issue:`480`) + +- Accept ``listall`` and ``listallinfo`` commands without the URI parameter. + The methods are still not implemented, but now the commands are accepted as + valid. + +**HTTP frontend** + +- Fix too broad truth test that caused :class:`mopidy.models.TlTrack` + objects with ``tlid`` set to ``0`` to be sent to the HTTP client without the + ``tlid`` field. (Fixes: :issue:`501`) + +- Upgrade Mopidy.js dependencies. This version has been released to NPM as + Mopidy.js v0.1.1. + +**Extension support** + +- :class:`mopidy.config.Secret` is now deserialized to unicode instead of + bytes. This may require modifications to extensions. + + +v0.14.2 (2013-07-01) +==================== + +This is a maintenance release to make Mopidy 0.14 work with pyspotify 1.11. + +**Dependencies** + +- pyspotify >= 1.9, < 2 is now required for Spotify support. In other words, + you're free to upgrade to pyspotify 1.11, but it isn't a requirement. v0.14.1 (2013-04-28) diff --git a/docs/conf.py b/docs/conf.py index a71c9a61..f3e4166c 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -33,7 +33,10 @@ class Mock(object): if name in ('__file__', '__path__'): return '/dev/null' elif (name[0] == name[0].upper() - and not name.startswith('MIXER_TRACK_')): + # gst.interfaces.MIXER_TRACK_* + and not name.startswith('MIXER_TRACK_') + # dbus.String() + and not name == 'String'): return type(name, (), {}) else: return Mock() @@ -98,7 +101,7 @@ master_doc = 'index' # General information about the project. project = 'Mopidy' -copyright = '2010-2013, Stein Magnus Jodal and contributors' +copyright = '2009-2013, Stein Magnus Jodal and contributors' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the diff --git a/docs/config.rst b/docs/config.rst index 0daf7d9d..5b8f5de1 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -90,6 +90,16 @@ Core configuration values ``gst-inspect-0.10`` to see what output properties can be set on the sink. For example: ``gst-inspect-0.10 shout2send`` +.. confval:: audio/visualizer + + Visualizer to use. + + Can be left blank if no visualizer is desired. Otherwise this expects a + GStreamer visualizer. Typical values are ``monoscope``, ``goom``, + ``goom2k1`` or one of the `libvisual`_ visualizers. + +.. _libvisual: http://gstreamer.freedesktop.org/data/doc/gstreamer/head/gst-plugins-base-plugins/html/gst-plugins-base-plugins-plugin-libvisual.html + .. confval:: logging/console_format The log format used for informational logging. diff --git a/docs/devtools.rst b/docs/devtools.rst index bc066cd0..6b8e6e30 100644 --- a/docs/devtools.rst +++ b/docs/devtools.rst @@ -22,8 +22,8 @@ tested by Jenkins before it is merged into the ``develop`` branch, which is a bit late, but good enough to get broad testing before new code is released. In addition to running tests, the Jenkins CI server also gathers coverage -statistics and uses pylint to check for errors and possible improvements in our -code. So, if you're out of work, the code coverage and pylint data at the CI +statistics and uses flake8 to check for errors and possible improvements in our +code. So, if you're out of work, the code coverage and flake8 data at the CI server should give you a place to start. diff --git a/docs/ext/index.rst b/docs/ext/index.rst index 562766ce..736f2fb6 100644 --- a/docs/ext/index.rst +++ b/docs/ext/index.rst @@ -46,6 +46,22 @@ Issues: https://github.com/dz0ny/mopidy-beets/issues +Mopidy-GMusic +------------- + +Provides a backend for playing music from `Google Play Music +`_. + +Author: + Ronald Hecht +PyPI: + `Mopidy-GMusic `_ +GitHub: + `hechtus/mopidy-gmusic `_ +Issues: + https://github.com/hechtus/mopidy-gmusic/issues + + Mopidy-NAD ---------- @@ -61,6 +77,22 @@ Issues: https://github.com/mopidy/mopidy/issues +Mopidy-SomaFM +------------- + +Provides a backend for playing music from the `SomaFM `_ +service. + +Author: + Alexandre Petitjean +PyPI: + `Mopidy-SomaFM `_ +GitHub: + `AlexandrePTJ/mopidy-somafm `_ +Issues: + https://github.com/AlexandrePTJ/mopidy-somafm/issues + + Mopidy-SoundCloud ----------------- @@ -75,3 +107,19 @@ GitHub: `dz0ny/mopidy-soundcloud `_ Issues: https://github.com/dz0ny/mopidy-soundcloud/issues + + +Mopidy-Subsonic +--------------- + +Provides a backend for playing music from a `Subsonic Music Streamer +`_ library. + +Author: + Bradon Kanyid +PyPI: + `Mopidy-Subsonic `_ +GitHub: + `rattboi/mopidy-subsonic `_ +Issues: + https://github.com/rattboi/mopidy-subsonic/issues diff --git a/docs/ext/local.rst b/docs/ext/local.rst index fc89e69a..1abebb1d 100644 --- a/docs/ext/local.rst +++ b/docs/ext/local.rst @@ -47,6 +47,11 @@ Configuration values Path to tag cache for local media. +.. confval:: local/scan_timeout + + Number of milliseconds before giving up scanning a file and moving on to + the next file. + Usage ===== diff --git a/docs/index.rst b/docs/index.rst index fb91244d..ca40c96c 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -48,7 +48,7 @@ About :maxdepth: 1 authors - licenses + license changelog versioning diff --git a/docs/installation/raspberrypi.rst b/docs/installation/raspberrypi.rst index cc46c8b1..e266dee2 100644 --- a/docs/installation/raspberrypi.rst +++ b/docs/installation/raspberrypi.rst @@ -16,12 +16,20 @@ distribution. .. _raspi-wheezy: -How to for Debian 7 (Wheezy) -============================ +How to for Raspbian "wheezy" and Debian "wheezy" +================================================ -#. Download the latest wheezy disk image from - http://downloads.raspberrypi.org/images/debian/7/. I used the one dated - 2012-08-08. +This guide applies for both: + +- Raspian "wheezy" for armhf (hard-float), and +- Debian "wheezy" for armel (soft-float) + +If you don't know which one to select, go for the armhf variant, as it'll give +you a lot better performance. + +#. Download the latest "wheezy" disk image from + http://www.raspberrypi.org/downloads/. This was last tested with the images + from 2013-05-25 for armhf and 2013-05-29 for armel. #. Flash the OS image to your SD card. See http://elinux.org/RPi_Easy_SD_Card_Setup for help. @@ -82,10 +90,10 @@ card. #. Ensure your system is up to date. On Debian based systems run:: sudo apt-get update - sudo apt-get full-upgrade + sudo apt-get dist-upgrade #. Ensure you have a new enough firmware. On Debian based systems - `rpi-update `_ + `rpi-update `_ can be used. #. Update either ``~/.asoundrc`` or ``/etc/asound.conf`` to the diff --git a/docs/license.rst b/docs/license.rst new file mode 100644 index 00000000..98928f63 --- /dev/null +++ b/docs/license.rst @@ -0,0 +1,10 @@ +******* +License +******* + +Mopidy is copyright 2009-2013 Stein Magnus Jodal and contributors. For a list +of contributors, see :doc:`authors`. For details on who have contributed what, +please refer to our git repository. + +Mopidy is licensed under the `Apache License, Version 2.0 +`_. diff --git a/docs/licenses.rst b/docs/licenses.rst deleted file mode 100644 index fc2530e5..00000000 --- a/docs/licenses.rst +++ /dev/null @@ -1,34 +0,0 @@ -******** -Licenses -******** - -For a list of contributors, see :doc:`authors`. For details on who have -contributed what, please refer to our git repository. - -Source code license -=================== - -Copyright 2009-2013 Stein Magnus Jodal and contributors - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. - - -Documentation license -===================== - -Copyright 2010-2013 Stein Magnus Jodal and contributors - -This work is licensed under the Creative Commons Attribution-ShareAlike 3.0 -Unported License. To view a copy of this license, visit -http://creativecommons.org/licenses/by-sa/3.0/ or send a letter to Creative -Commons, 171 Second Street, Suite 300, San Francisco, California, 94105, USA. diff --git a/fabfile.py b/fabfile.py index 3321cb16..f23da2b1 100644 --- a/fabfile.py +++ b/fabfile.py @@ -1,21 +1,62 @@ -from fabric.api import local, settings +from fabric.api import execute, local, settings, task +@task +def docs(): + local('make -C docs/ html') + + +@task +def autodocs(): + auto(docs) + + +@task def test(path=None): path = path or 'tests/' local('nosetests ' + path) +@task def autotest(path=None): + auto(test, path=path) + + +@task +def coverage(path=None): + path = path or 'tests/' + local( + 'nosetests --with-coverage --cover-package=mopidy ' + '--cover-branches --cover-html ' + path) + + +@task +def autocoverage(path=None): + auto(coverage, path=path) + + +@task +def lint(path=None): + path = path or '.' + local('flake8 $(find %s -iname "*.py")' % path) + + +@task +def autolint(path=None): + auto(lint, path=path) + + +def auto(task, *args, **kwargs): while True: local('clear') with settings(warn_only=True): - test(path) + execute(task, *args, **kwargs) local( 'inotifywait -q -e create -e modify -e delete ' - '--exclude ".*\.(pyc|sw.)" -r mopidy/ tests/') + '--exclude ".*\.(pyc|sw.)" -r docs/ mopidy/ tests/') +@task def update_authors(): # Keep authors in the order of appearance and use awk to filter out dupes local( diff --git a/js/README.md b/js/README.md index 0e5e17c9..eddfa99f 100644 --- a/js/README.md +++ b/js/README.md @@ -51,15 +51,15 @@ Building from source 1. Install [Node.js](http://nodejs.org/) and npm. There is a PPA if you're running Ubuntu: - sudo apt-get install python-software-properties - sudo add-apt-repository ppa:chris-lea/node.js - sudo apt-get update - sudo apt-get install nodejs npm + 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 2. Enter the `js/` in Mopidy's Git repo dir and install all dependencies: - cd js/ - npm install + cd js/ + npm install That's it. diff --git a/js/lib/bane-0.4.0.js b/js/lib/bane-1.0.0.js similarity index 98% rename from js/lib/bane-0.4.0.js rename to js/lib/bane-1.0.0.js index a1da6efa..8051764d 100644 --- a/js/lib/bane-0.4.0.js +++ b/js/lib/bane-1.0.0.js @@ -3,10 +3,10 @@ * * https://github.com/busterjs/bane * - * @version 0.4.0 + * @version 1.0.0 */ -((typeof define === "function" && define.amd && function (m) { define(m); }) || +((typeof define === "function" && define.amd && function (m) { define("bane", m); }) || (typeof module === "object" && function (m) { module.exports = m(); }) || function (m) { this.bane = m(); } )(function () { @@ -152,7 +152,7 @@ notifyListener(event, toNotify[i], args); } - toNotify = listeners(this, event).slice() + toNotify = listeners(this, event).slice(); args = slice.call(arguments, 1); for (i = 0, l = toNotify.length; i < l; ++i) { notifyListener(event, toNotify[i], args); diff --git a/js/lib/when-2.0.0.js b/js/lib/when-2.4.0.js similarity index 61% rename from js/lib/when-2.0.0.js rename to js/lib/when-2.4.0.js index 78249532..aa386275 100644 --- a/js/lib/when-2.0.0.js +++ b/js/lib/when-2.4.0.js @@ -9,27 +9,30 @@ * * @author Brian Cavalier * @author John Hann - * @version 2.0.0 + * @version 2.4.0 */ -(function(define) { 'use strict'; -define(function () { +(function(define, global) { 'use strict'; +define(function (require) { // Public API - when.defer = defer; // Create a deferred + when.promise = promise; // Create a pending promise when.resolve = resolve; // Create a resolved promise when.reject = reject; // Create a rejected promise + when.defer = defer; // Create a {promise, resolver} pair when.join = join; // Join 2 or more promises when.all = all; // Resolve a list of promises when.map = map; // Array.map() for promises when.reduce = reduce; // Array.reduce() for promises + when.settle = settle; // Settle a list of promises when.any = any; // One-winner race when.some = some; // Multi-winner race - when.isPromise = isPromise; // Determine if a thing is a promise + when.isPromise = isPromiseLike; // DEPRECATED: use isPromiseLike + when.isPromiseLike = isPromiseLike; // Is something promise-like, aka thenable /** * Register an observer for a promise or immediate value. @@ -57,13 +60,35 @@ define(function () { * a trusted when.js promise. Any other duck-typed promise is considered * untrusted. * @constructor + * @param {function} sendMessage function to deliver messages to the promise's handler + * @param {function?} inspect function that reports the promise's state * @name Promise */ - function Promise(then) { - this.then = then; + function Promise(sendMessage, inspect) { + this._message = sendMessage; + this.inspect = inspect; } Promise.prototype = { + /** + * Register handlers for this promise. + * @param [onFulfilled] {Function} fulfillment handler + * @param [onRejected] {Function} rejection handler + * @param [onProgress] {Function} progress handler + * @return {Promise} new Promise + */ + then: function(onFulfilled, onRejected, onProgress) { + /*jshint unused:false*/ + var args, sendMessage; + + args = arguments; + sendMessage = this._message; + + return _promise(function(resolve, reject, notify) { + sendMessage('when', args, resolve, notify); + }, this._status && this._status.observed()); + }, + /** * Register a rejection handler. Shortcut for .then(undefined, onRejected) * @param {function?} onRejected @@ -84,9 +109,7 @@ define(function () { * @returns {Promise} */ ensure: function(onFulfilledOrRejected) { - var self = this; - - return this.then(injectHandler, injectHandler).yield(self); + return this.then(injectHandler, injectHandler)['yield'](this); function injectHandler() { return resolve(onFulfilledOrRejected()); @@ -107,6 +130,16 @@ define(function () { }); }, + /** + * Runs a side effect when this promise fulfills, without changing the + * fulfillment value. + * @param {function} onFulfilledSideEffect + * @returns {Promise} + */ + tap: function(onFulfilledSideEffect) { + return this.then(onFulfilledSideEffect)['yield'](this); + }, + /** * Assumes that this promise will fulfill with an array, and arranges * for the onFulfilled to be called with the array as its argument list @@ -162,13 +195,16 @@ define(function () { } /** - * Creates a new Deferred with fully isolated resolver and promise parts, - * either or both of which may be given out safely to consumers. + * Creates a {promise, resolver} pair, either or both of which + * may be given out safely to consumers. * The resolver has resolve, reject, and progress. The promise - * only has then. + * has then plus extended promise API. * * @return {{ * promise: Promise, + * resolve: function:Promise, + * reject: function:Promise, + * notify: function:Promise * resolver: { * resolve: function:Promise, * reject: function:Promise, @@ -216,12 +252,26 @@ define(function () { /** * Creates a new promise whose fate is determined by resolver. - * @private (for now) * @param {function} resolver function(resolve, reject, notify) * @returns {Promise} promise whose fate is determine by resolver */ function promise(resolver) { - var value, handlers = []; + return _promise(resolver, monitorApi.PromiseStatus && monitorApi.PromiseStatus()); + } + + /** + * Creates a new promise, linked to parent, whose fate is determined + * by resolver. + * @param {function} resolver function(resolve, reject, notify) + * @param {Promise?} status promise from which the new promise is begotten + * @returns {Promise} promise whose fate is determine by resolver + * @private + */ + function _promise(resolver, status) { + var self, value, consumers = []; + + self = new Promise(_message, inspect); + self._status = status; // Call the provider resolver to seal the promise's fate try { @@ -231,29 +281,34 @@ define(function () { } // Return the promise - return new Promise(then); + return self; /** - * Register handlers for this promise. - * @param [onFulfilled] {Function} fulfillment handler - * @param [onRejected] {Function} rejection handler - * @param [onProgress] {Function} progress handler - * @return {Promise} new Promise + * Private message delivery. Queues and delivers messages to + * the promise's ultimate fulfillment value or rejection reason. + * @private + * @param {String} type + * @param {Array} args + * @param {Function} resolve + * @param {Function} notify */ - function then(onFulfilled, onRejected, onProgress) { - return promise(function(resolve, reject, notify) { - handlers - // Call handlers later, after resolution - ? handlers.push(function(value) { - value.then(onFulfilled, onRejected, onProgress) - .then(resolve, reject, notify); - }) - // Call handlers soon, but not in the current stack - : enqueue(function() { - value.then(onFulfilled, onRejected, onProgress) - .then(resolve, reject, notify); - }); - }); + function _message(type, args, resolve, notify) { + consumers ? consumers.push(deliver) : enqueue(function() { deliver(value); }); + + function deliver(p) { + p._message(type, args, resolve, notify); + } + } + + /** + * Returns a snapshot of the promise's state at the instant inspect() + * is called. The returned object is not live and will not update as + * the promise's state changes. + * @returns {{ state:String, value?:*, reason?:* }} status snapshot + * of the promise. + */ + function inspect() { + return value ? value.inspect() : toPendingState(); } /** @@ -262,14 +317,17 @@ define(function () { * @param {*|Promise} val resolution value */ function promiseResolve(val) { - if(!handlers) { + if(!consumers) { return; } value = coerce(val); - scheduleHandlers(handlers, value); + scheduleConsumers(consumers, value); + consumers = undef; - handlers = undef; + if(status) { + updateStatus(value, status); + } } /** @@ -285,27 +343,90 @@ define(function () { * @param {*} update progress event payload to pass to all listeners */ function promiseNotify(update) { - if(handlers) { - scheduleHandlers(handlers, progressing(update)); + if(consumers) { + scheduleConsumers(consumers, progressed(update)); } } } + /** + * Creates a fulfilled, local promise as a proxy for a value + * NOTE: must never be exposed + * @param {*} value fulfillment value + * @returns {Promise} + */ + function fulfilled(value) { + return near( + new NearFulfilledProxy(value), + function() { return toFulfilledState(value); } + ); + } + + /** + * Creates a rejected, local promise with the supplied reason + * NOTE: must never be exposed + * @param {*} reason rejection reason + * @returns {Promise} + */ + function rejected(reason) { + return near( + new NearRejectedProxy(reason), + function() { return toRejectedState(reason); } + ); + } + + /** + * Creates a near promise using the provided proxy + * NOTE: must never be exposed + * @param {object} proxy proxy for the promise's ultimate value or reason + * @param {function} inspect function that returns a snapshot of the + * returned near promise's state + * @returns {Promise} + */ + function near(proxy, inspect) { + return new Promise(function (type, args, resolve) { + try { + resolve(proxy[type].apply(proxy, args)); + } catch(e) { + resolve(rejected(e)); + } + }, inspect); + } + + /** + * Create a progress promise with the supplied update. + * @private + * @param {*} update + * @return {Promise} progress promise + */ + function progressed(update) { + return new Promise(function (type, args, _, notify) { + var onProgress = args[2]; + try { + notify(typeof onProgress === 'function' ? onProgress(update) : update); + } catch(e) { + notify(e); + } + }); + } + /** * Coerces x to a trusted Promise * * @private * @param {*} x thing to coerce - * @returns {Promise} Guaranteed to return a trusted Promise. If x + * @returns {*} Guaranteed to return a trusted Promise. If x * is trusted, returns x, otherwise, returns a new, trusted, already-resolved * Promise whose resolution value is: * * the resolution value of x if it's a foreign promise, or * * x if it's a value */ function coerce(x) { - if(x instanceof Promise) { + if (x instanceof Promise) { return x; - } else if (x !== Object(x)) { + } + + if (!(x === Object(x) && 'then' in x)) { return fulfilled(x); } @@ -332,61 +453,34 @@ define(function () { } /** - * Create an already-fulfilled promise for the supplied value - * @private + * Proxy for a near, fulfilled value * @param {*} value - * @return {Promise} fulfilled promise + * @constructor */ - function fulfilled(value) { - var self = new Promise(function (onFulfilled) { - try { - return typeof onFulfilled == 'function' - ? coerce(onFulfilled(value)) : self; - } catch (e) { - return rejected(e); - } - }); - - return self; + function NearFulfilledProxy(value) { + this.value = value; } + NearFulfilledProxy.prototype.when = function(onResult) { + return typeof onResult === 'function' ? onResult(this.value) : this.value; + }; + /** - * Create an already-rejected promise with the supplied rejection reason. - * @private + * Proxy for a near rejection * @param {*} reason - * @return {Promise} rejected promise + * @constructor */ - function rejected(reason) { - var self = new Promise(function (_, onRejected) { - try { - return typeof onRejected == 'function' - ? coerce(onRejected(reason)) : self; - } catch (e) { - return rejected(e); - } - }); - - return self; + function NearRejectedProxy(reason) { + this.reason = reason; } - /** - * Create a progress promise with the supplied update. - * @private - * @param {*} update - * @return {Promise} progress promise - */ - function progressing(update) { - var self = new Promise(function (_, __, onProgress) { - try { - return typeof onProgress == 'function' - ? progressing(onProgress(update)) : self; - } catch (e) { - return progressing(e); - } - }); - - return self; - } + NearRejectedProxy.prototype.when = function(_, onError) { + if(typeof onError === 'function') { + return onError(this.reason); + } else { + throw this.reason; + } + }; /** * Schedule a task that will process a list of handlers @@ -395,7 +489,7 @@ define(function () { * @param {Array} handlers queue of handlers to execute * @param {*} value passed as the only arg to each handler */ - function scheduleHandlers(handlers, value) { + function scheduleConsumers(handlers, value) { enqueue(function() { var handler, i = 0; while (handler = handlers[i++]) { @@ -404,14 +498,23 @@ define(function () { }); } + function updateStatus(value, status) { + value.then(statusFulfilled, statusRejected); + + function statusFulfilled() { status.fulfilled(); } + function statusRejected(r) { status.rejected(r); } + } + /** - * Determines if promiseOrValue is a promise or not - * - * @param {*} promiseOrValue anything - * @returns {boolean} true if promiseOrValue is a {@link Promise} + * Determines if x is promise-like, i.e. a thenable object + * NOTE: Will return true for *any thenable object*, and isn't truly + * safe, since it may attempt to access the `then` property of x (i.e. + * clever/malicious getters may do weird things) + * @param {*} x anything + * @returns {boolean} true if x is promise-like */ - function isPromise(promiseOrValue) { - return promiseOrValue && typeof promiseOrValue.then === 'function'; + function isPromiseLike(x) { + return x && typeof x.then === 'function'; } /** @@ -423,17 +526,15 @@ define(function () { * @param {Array} promisesOrValues array of anything, may contain a mix * of promises and values * @param howMany {number} number of promisesOrValues to resolve - * @param {function?} [onFulfilled] resolution handler - * @param {function?} [onRejected] rejection handler - * @param {function?} [onProgress] progress handler + * @param {function?} [onFulfilled] DEPRECATED, use returnedPromise.then() + * @param {function?} [onRejected] DEPRECATED, use returnedPromise.then() + * @param {function?} [onProgress] DEPRECATED, use returnedPromise.then() * @returns {Promise} promise that will resolve to an array of howMany values that * resolved first, or will reject with an array of * (promisesOrValues.length - howMany) + 1 rejection reasons. */ function some(promisesOrValues, howMany, onFulfilled, onRejected, onProgress) { - checkCallbacks(2, arguments); - return when(promisesOrValues, function(promisesOrValues) { return promise(resolveSome).then(onFulfilled, onRejected, onProgress); @@ -457,7 +558,7 @@ define(function () { rejectOne = function(reason) { reasons.push(reason); if(!--toReject) { - fulfillOne = rejectOne = noop; + fulfillOne = rejectOne = identity; reject(reasons); } }; @@ -466,7 +567,7 @@ define(function () { // This orders the values based on promise resolution order values.push(val); if (!--toResolve) { - fulfillOne = rejectOne = noop; + fulfillOne = rejectOne = identity; resolve(values); } }; @@ -496,9 +597,9 @@ define(function () { * * @param {Array|Promise} promisesOrValues array of anything, may contain a mix * of {@link Promise}s and values - * @param {function?} [onFulfilled] resolution handler - * @param {function?} [onRejected] rejection handler - * @param {function?} [onProgress] progress handler + * @param {function?} [onFulfilled] DEPRECATED, use returnedPromise.then() + * @param {function?} [onRejected] DEPRECATED, use returnedPromise.then() + * @param {function?} [onProgress] DEPRECATED, use returnedPromise.then() * @returns {Promise} promise that will resolve to the value that resolved first, or * will reject with an array of all rejected inputs. */ @@ -519,14 +620,13 @@ define(function () { * * @param {Array|Promise} promisesOrValues array of anything, may contain a mix * of {@link Promise}s and values - * @param {function?} [onFulfilled] resolution handler - * @param {function?} [onRejected] rejection handler - * @param {function?} [onProgress] progress handler + * @param {function?} [onFulfilled] DEPRECATED, use returnedPromise.then() + * @param {function?} [onRejected] DEPRECATED, use returnedPromise.then() + * @param {function?} [onProgress] DEPRECATED, use returnedPromise.then() * @returns {Promise} */ function all(promisesOrValues, onFulfilled, onRejected, onProgress) { - checkCallbacks(1, arguments); - return map(promisesOrValues, identity).then(onFulfilled, onRejected, onProgress); + return _map(promisesOrValues, identity).then(onFulfilled, onRejected, onProgress); } /** @@ -535,28 +635,49 @@ define(function () { * have fulfilled, or will reject when *any one* of the input promises rejects. */ function join(/* ...promises */) { - return map(arguments, identity); + return _map(arguments, identity); } /** - * Traditional map function, similar to `Array.prototype.map()`, but allows - * input to contain {@link Promise}s and/or values, and mapFunc may return - * either a value or a {@link Promise} - * - * @param {Array|Promise} array array of anything, may contain a mix - * of {@link Promise}s and values - * @param {function} mapFunc mapping function mapFunc(value) which may return - * either a {@link Promise} or value - * @returns {Promise} a {@link Promise} that will resolve to an array containing - * the mapped output values. + * Settles all input promises such that they are guaranteed not to + * be pending once the returned promise fulfills. The returned promise + * will always fulfill, except in the case where `array` is a promise + * that rejects. + * @param {Array|Promise} array or promise for array of promises to settle + * @returns {Promise} promise that always fulfills with an array of + * outcome snapshots for each input promise. + */ + function settle(array) { + return _map(array, toFulfilledState, toRejectedState); + } + + /** + * Promise-aware array map function, similar to `Array.prototype.map()`, + * but input array may contain promises or values. + * @param {Array|Promise} array array of anything, may contain promises and values + * @param {function} mapFunc map function which may return a promise or value + * @returns {Promise} promise that will fulfill with an array of mapped values + * or reject if any input promise rejects. */ function map(array, mapFunc) { + return _map(array, mapFunc); + } + + /** + * Internal map that allows a fallback to handle rejections + * @param {Array|Promise} array array of anything, may contain promises and values + * @param {function} mapFunc map function which may return a promise or value + * @param {function?} fallback function to handle rejected promises + * @returns {Promise} promise that will fulfill with an array of mapped values + * or reject if any input promise rejects. + */ + function _map(array, mapFunc, fallback) { return when(array, function(array) { - return promise(resolveMap); + return _promise(resolveMap); function resolveMap(resolve, reject, notify) { - var results, len, toResolve, resolveOne, i; + var results, len, toResolve, i; // Since we know the resulting length, we can preallocate the results // array to avoid array expansions. @@ -565,27 +686,28 @@ define(function () { if(!toResolve) { resolve(results); - } else { + return; + } - resolveOne = function(item, i) { - when(item, mapFunc).then(function(mapped) { - results[i] = mapped; - - if(!--toResolve) { - resolve(results); - } - }, reject, notify); - }; - - // Since mapFunc may be async, get all invocations of it into flight - for(i = 0; i < len; i++) { - if(i in array) { - resolveOne(array[i], i); - } else { - --toResolve; - } + // Since mapFunc may be async, get all invocations of it into flight + for(i = 0; i < len; i++) { + if(i in array) { + resolveOne(array[i], i); + } else { + --toResolve; } } + + function resolveOne(item, i) { + when(item, mapFunc, fallback).then(function(mapped) { + results[i] = mapped; + notify(mapped); + + if(!--toResolve) { + resolve(results); + } + }, reject); + } } }); } @@ -625,12 +747,46 @@ define(function () { }); } + // Snapshot states + + /** + * Creates a fulfilled state snapshot + * @private + * @param {*} x any value + * @returns {{state:'fulfilled',value:*}} + */ + function toFulfilledState(x) { + return { state: 'fulfilled', value: x }; + } + + /** + * Creates a rejected state snapshot + * @private + * @param {*} x any reason + * @returns {{state:'rejected',reason:*}} + */ + function toRejectedState(x) { + return { state: 'rejected', reason: x }; + } + + /** + * Creates a pending state snapshot + * @private + * @returns {{state:'pending'}} + */ + function toPendingState() { + return { state: 'pending' }; + } + // - // Utilities, etc. + // Internals, utilities, etc. // var reduceArray, slice, fcall, nextTick, handlerQueue, - timeout, funcProto, call, arrayProto, undef; + setTimeout, funcProto, call, arrayProto, monitorApi, + cjsRequire, undef; + + cjsRequire = require; // // Shared handler queue processing @@ -648,20 +804,13 @@ define(function () { */ function enqueue(task) { if(handlerQueue.push(task) === 1) { - scheduleDrainQueue(); + nextTick(drainQueue); } } /** - * Schedule the queue to be drained in the next tick. - */ - function scheduleDrainQueue() { - nextTick(drainQueue); - } - - /** - * Drain the handler queue entirely or partially, being careful to allow - * the queue to be extended while it is being processed, and to continue + * Drain the handler queue entirely, being careful to allow the + * queue to be extended while it is being processed, and to continue * processing until it is truly empty. */ function drainQueue() { @@ -674,20 +823,36 @@ define(function () { handlerQueue = []; } - // - // Capture function and array utils - // - /*global setImmediate:true*/ + // capture setTimeout to avoid being caught by fake timers + // used in time based tests + setTimeout = global.setTimeout; - // capture setTimeout to avoid being caught by fake timers used in time based tests - timeout = setTimeout; - nextTick = typeof setImmediate === 'function' - ? typeof window === 'undefined' - ? setImmediate - : setImmediate.bind(window) - : typeof process === 'object' - ? process.nextTick - : function(task) { timeout(task, 0); }; + // Allow attaching the monitor to when() if env has no console + monitorApi = typeof console != 'undefined' ? console : when; + + // Prefer setImmediate or MessageChannel, cascade to node, + // vertx and finally setTimeout + /*global setImmediate,MessageChannel,process*/ + if (typeof setImmediate === 'function') { + nextTick = setImmediate.bind(global); + } else if(typeof MessageChannel !== 'undefined') { + var channel = new MessageChannel(); + channel.port1.onmessage = drainQueue; + nextTick = function() { channel.port2.postMessage(0); }; + } else if (typeof process === 'object' && process.nextTick) { + nextTick = process.nextTick; + } else { + try { + // vert.x 1.x || 2.x + nextTick = cjsRequire('vertx').runOnLoop || cjsRequire('vertx').runOnContext; + } catch(ignore) { + nextTick = function(t) { setTimeout(t, 0); }; + } + } + + // + // Capture/polyfill function and array utils + // // Safe function calls funcProto = Function.prototype; @@ -748,40 +913,10 @@ define(function () { return reduced; }; - // - // Utility functions - // - - /** - * Helper that checks arrayOfCallbacks to ensure that each element is either - * a function, or null or undefined. - * @private - * @param {number} start index at which to start checking items in arrayOfCallbacks - * @param {Array} arrayOfCallbacks array to check - * @throws {Error} if any element of arrayOfCallbacks is something other than - * a functions, null, or undefined. - */ - function checkCallbacks(start, arrayOfCallbacks) { - // TODO: Promises/A+ update type checking and docs - var arg, i = arrayOfCallbacks.length; - - while(i > start) { - arg = arrayOfCallbacks[--i]; - - if (arg != null && typeof arg != 'function') { - throw new Error('arg '+i+' must be a function'); - } - } - } - - function noop() {} - function identity(x) { return x; } return when; }); -})( - typeof define === 'function' && define.amd ? define : function (factory) { module.exports = factory(); } -); +})(typeof define === 'function' && define.amd ? define : function (factory) { module.exports = factory(require); }, this); diff --git a/js/package.json b/js/package.json index 1623e3f8..5b8e46d8 100644 --- a/js/package.json +++ b/js/package.json @@ -1,6 +1,6 @@ { "name": "mopidy", - "version": "0.1.0", + "version": "0.1.1", "description": "Client lib for controlling a Mopidy music server over a WebSocket", "homepage": "http://www.mopidy.com/", "author": { @@ -14,19 +14,19 @@ }, "main": "src/mopidy.js", "dependencies": { - "bane": "~0.4.0", - "faye-websocket": "~0.4.4", - "when": "~2.0.0" + "bane": "~1.0.0", + "faye-websocket": "~0.7.0", + "when": "~2.4.0" }, "devDependencies": { - "buster": "~0.6.12", + "buster": "~0.6.13", "grunt": "~0.4.1", "grunt-buster": "~0.2.1", "grunt-contrib-concat": "~0.3.0", - "grunt-contrib-jshint": "~0.4.3", - "grunt-contrib-uglify": "~0.2.0", - "grunt-contrib-watch": "~0.4.3", - "phantomjs": "~1.9.0" + "grunt-contrib-jshint": "~0.6.4", + "grunt-contrib-uglify": "~0.2.4", + "grunt-contrib-watch": "~0.5.3", + "phantomjs": "~1.9.2-0" }, "scripts": { "test": "grunt test", diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 12694927..6ef80b0f 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -1,8 +1,6 @@ from __future__ import unicode_literals -# pylint: disable = E0611,F0401 from distutils.version import StrictVersion as SV -# pylint: enable = E0611,F0401 import sys import warnings @@ -23,4 +21,4 @@ if (isinstance(pykka.__version__, basestring) warnings.filterwarnings('ignore', 'could not open display') -__version__ = '0.14.1' +__version__ = '0.15.0' diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 0118395c..aa0c751e 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -17,12 +17,6 @@ mopidy_args = sys.argv[1:] sys.argv[1:] = [] -# Add ../ to the path so we can run Mopidy from a Git checkout without -# installing it on the system. -sys.path.insert( - 0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../'))) - - from mopidy import commands, ext from mopidy.audio import Audio from mopidy import config as config_lib @@ -42,15 +36,12 @@ def main(): if args.show_deps: commands.show_deps() - loop = gobject.MainLoop() - enabled_extensions = [] # Make sure it is defined before the finally block - logging_initialized = False - # TODO: figure out a way to make the boilerplate in this file reusable in # scanner and other places we need it. try: # Initial config without extensions to bootstrap logging. + logging_initialized = False logging_config, _ = config_lib.load( args.config_files, [], args.config_overrides) @@ -59,12 +50,16 @@ def main(): logging_config, args.verbosity_level, args.save_debug_log) logging_initialized = True + create_file_structures() + check_old_locations() + installed_extensions = ext.load_extensions() config, config_errors = config_lib.load( args.config_files, installed_extensions, args.config_overrides) # Filter out disabled extensions and remove any config errors for them. + enabled_extensions = [] for extension in installed_extensions: enabled = config[extension.ext_name]['enabled'] if ext.validate_extension(extension) and enabled: @@ -79,31 +74,38 @@ def main(): proxied_config = config_lib.Proxy(config) log.setup_log_levels(proxied_config) - create_file_structures() - check_old_locations() ext.register_gstreamer_elements(enabled_extensions) # Anything that wants to exit after this point must use # mopidy.utils.process.exit_process as actors have been started. - audio = setup_audio(proxied_config) - backends = setup_backends(proxied_config, enabled_extensions, audio) - core = setup_core(audio, backends) - setup_frontends(proxied_config, enabled_extensions, core) - loop.run() + start(proxied_config, enabled_extensions) except KeyboardInterrupt: - if logging_initialized: - logger.info('Interrupted. Exiting...') + pass except Exception as ex: if logging_initialized: logger.exception(ex) raise - finally: - loop.quit() - stop_frontends(enabled_extensions) - stop_core() - stop_backends(enabled_extensions) - stop_audio() - process.stop_remaining_actors() + + +def create_file_structures(): + path.get_or_create_dir(b'$XDG_DATA_DIR/mopidy') + path.get_or_create_file(b'$XDG_CONFIG_DIR/mopidy/mopidy.conf') + + +def check_old_locations(): + dot_mopidy_dir = path.expand_path(b'~/.mopidy') + if os.path.isdir(dot_mopidy_dir): + logger.warning( + 'Old Mopidy dot dir found at %s. Please migrate your config to ' + 'the ini-file based config format. See release notes for further ' + 'instructions.', dot_mopidy_dir) + + old_settings_file = path.expand_path(b'$XDG_CONFIG_DIR/mopidy/settings.py') + if os.path.isfile(old_settings_file): + logger.warning( + 'Old Mopidy settings file found at %s. Please migrate your ' + 'config to the ini-file based config format. See release notes ' + 'for further instructions.', old_settings_file) def log_extension_info(all_extensions, enabled_extensions): @@ -125,28 +127,27 @@ def check_config_errors(errors): sys.exit(1) -def check_old_locations(): - dot_mopidy_dir = path.expand_path(b'~/.mopidy') - if os.path.isdir(dot_mopidy_dir): - logger.warning( - 'Old Mopidy dot dir found at %s. Please migrate your config to ' - 'the ini-file based config format. See release notes for further ' - 'instructions.', dot_mopidy_dir) - - old_settings_file = path.expand_path(b'$XDG_CONFIG_DIR/mopidy/settings.py') - if os.path.isfile(old_settings_file): - logger.warning( - 'Old Mopidy settings file found at %s. Please migrate your ' - 'config to the ini-file based config format. See release notes ' - 'for further instructions.', old_settings_file) +def start(config, extensions): + loop = gobject.MainLoop() + try: + audio = start_audio(config) + backends = start_backends(config, extensions, audio) + core = start_core(audio, backends) + start_frontends(config, extensions, core) + loop.run() + except KeyboardInterrupt: + logger.info('Interrupted. Exiting...') + return + finally: + loop.quit() + stop_frontends(extensions) + stop_core() + stop_backends(extensions) + stop_audio() + process.stop_remaining_actors() -def create_file_structures(): - path.get_or_create_dir(b'$XDG_DATA_DIR/mopidy') - path.get_or_create_file(b'$XDG_CONFIG_DIR/mopidy/mopidy.conf') - - -def setup_audio(config): +def start_audio(config): logger.info('Starting Mopidy audio') return Audio.start(config=config).proxy() @@ -156,7 +157,7 @@ def stop_audio(): process.stop_actors_by_class(Audio) -def setup_backends(config, extensions, audio): +def start_backends(config, extensions, audio): backend_classes = [] for extension in extensions: backend_classes.extend(extension.get_backend_classes()) @@ -180,7 +181,7 @@ def stop_backends(extensions): process.stop_actors_by_class(backend_class) -def setup_core(audio, backends): +def start_core(audio, backends): logger.info('Starting Mopidy core') return Core.start(audio=audio, backends=backends).proxy() @@ -190,7 +191,7 @@ def stop_core(): process.stop_actors_by_class(Core) -def setup_frontends(config, extensions, core): +def start_frontends(config, extensions, core): frontend_classes = [] for extension in extensions: frontend_classes.extend(extension.get_frontend_classes()) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index c68a5417..6f539707 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -25,6 +25,22 @@ playlists.register_elements() MB = 1 << 20 +# GST_PLAY_FLAG_VIDEO (1<<0) +# GST_PLAY_FLAG_AUDIO (1<<1) +# GST_PLAY_FLAG_TEXT (1<<2) +# GST_PLAY_FLAG_VIS (1<<3) +# GST_PLAY_FLAG_SOFT_VOLUME (1<<4) +# GST_PLAY_FLAG_NATIVE_AUDIO (1<<5) +# GST_PLAY_FLAG_NATIVE_VIDEO (1<<6) +# GST_PLAY_FLAG_DOWNLOAD (1<<7) +# GST_PLAY_FLAG_BUFFERING (1<<8) +# GST_PLAY_FLAG_DEINTERLACE (1<<9) +# GST_PLAY_FLAG_SOFT_COLORBALANCE (1<<10) + +# Default flags to use for playbin: AUDIO, SOFT_VOLUME, DOWNLOAD +PLAYBIN_FLAGS = (1 << 1) | (1 << 4) | (1 << 7) +PLAYBIN_VIS_FLAGS = PLAYBIN_FLAGS | (1 << 3) + class Audio(pykka.ThreadingActor): """ @@ -58,6 +74,7 @@ class Audio(pykka.ThreadingActor): try: self._setup_playbin() self._setup_output() + self._setup_visualizer() self._setup_mixer() self._setup_message_processor() except gobject.GError as ex: @@ -81,9 +98,7 @@ class Audio(pykka.ThreadingActor): def _setup_playbin(self): playbin = gst.element_factory_make('playbin2') - - fakesink = gst.element_factory_make('fakesink') - playbin.set_property('video-sink', fakesink) + playbin.set_property('flags', PLAYBIN_FLAGS) self._connect(playbin, 'about-to-finish', self._on_about_to_finish) self._connect(playbin, 'notify::source', self._on_new_source) @@ -152,6 +167,20 @@ class Audio(pykka.ThreadingActor): 'Failed to create audio output "%s": %s', output_desc, ex) process.exit_process() + def _setup_visualizer(self): + visualizer_element = self._config['audio']['visualizer'] + if not visualizer_element: + return + try: + visualizer = gst.element_factory_make(visualizer_element) + self._playbin.set_property('vis-plugin', visualizer) + self._playbin.set_property('flags', PLAYBIN_VIS_FLAGS) + logger.info('Audio visualizer set to "%s"', visualizer_element) + except gobject.GError as ex: + logger.error( + 'Failed to create audio visualizer "%s": %s', + visualizer_element, ex) + def _setup_mixer(self): mixer_desc = self._config['audio']['mixer'] track_desc = self._config['audio']['mixer_track'] @@ -196,7 +225,8 @@ class Audio(pykka.ThreadingActor): self._mixer_track.min_volume, self._mixer_track.max_volume) logger.info( 'Audio mixer set to "%s" using track "%s"', - mixer.get_factory().get_name(), track.label) + str(mixer.get_factory().get_name()).decode('utf-8'), + str(track.label).decode('utf-8')) def _select_mixer_track(self, mixer, track_label): # Ignore tracks without volumes, then look for track with diff --git a/mopidy/audio/mixers/auto.py b/mopidy/audio/mixers/auto.py index 587994cb..6e240ebe 100644 --- a/mopidy/audio/mixers/auto.py +++ b/mopidy/audio/mixers/auto.py @@ -29,9 +29,7 @@ class AutoAudioMixer(gst.Bin): gst.Bin.__init__(self) mixer = self._find_mixer() if mixer: - # pylint: disable=E1101 self.add(mixer) - # pylint: enable=E1101 logger.debug('AutoAudioMixer chose: %s', mixer.get_name()) else: logger.debug('AutoAudioMixer did not find any usable mixers') diff --git a/mopidy/backends/base.py b/mopidy/backends/base.py index f0561b4c..6b980f06 100644 --- a/mopidy/backends/base.py +++ b/mopidy/backends/base.py @@ -15,11 +15,6 @@ class Backend(object): #: the backend doesn't provide a library. library = None - #: The library update provider. An instance of - #: :class:`~mopidy.backends.base.BaseLibraryUpdateProvider`, or - #: :class:`None` if the backend doesn't provide a library. - updater = None - #: The playback provider. An instance of #: :class:`~mopidy.backends.base.BasePlaybackProvider`, or :class:`None` if #: the backend doesn't provide playback. @@ -40,9 +35,6 @@ class Backend(object): def has_library(self): return self.library is not None - def has_updater(self): - return self.updater is not None - def has_playback(self): return self.playback is not None @@ -96,15 +88,7 @@ class BaseLibraryProvider(object): class BaseLibraryUpdateProvider(object): - """ - :param backend: backend the controller is a part of - :type backend: :class:`mopidy.backends.base.Backend` - """ - - pykka_traversable = True - - def __init__(self, backend): - self.backend = backend + uri_schemes = [] def load(self): """Loads the library and returns all tracks in it. @@ -172,9 +156,22 @@ class BasePlaybackProvider(object): :rtype: :class:`True` if successful, else :class:`False` """ self.audio.prepare_change() - self.audio.set_uri(track.uri).get() + self.change_track(track) return self.audio.start_playback().get() + def change_track(self, track): + """ + Swith to provided track. + + *MAY be reimplemented by subclass.* + + :param track: the track to play + :type track: :class:`mopidy.models.Track` + :rtype: :class:`True` if successful, else :class:`False` + """ + self.audio.set_uri(track.uri).get() + return True + def resume(self): """ Resume playback at the same time position playback was paused. diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index f718eeb5..5c6fec47 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -21,6 +21,7 @@ class Extension(ext.Extension): schema['media_dir'] = config.Path() schema['playlists_dir'] = config.Path() schema['tag_cache_file'] = config.Path() + schema['scan_timeout'] = config.Integer(minimum=0) return schema def validate_environment(self): @@ -29,3 +30,7 @@ class Extension(ext.Extension): def get_backend_classes(self): from .actor import LocalBackend return [LocalBackend] + + def get_library_updaters(self): + from .library import LocalLibraryUpdateProvider + return [LocalLibraryUpdateProvider] diff --git a/mopidy/backends/local/actor.py b/mopidy/backends/local/actor.py index b73c53e2..f3611891 100644 --- a/mopidy/backends/local/actor.py +++ b/mopidy/backends/local/actor.py @@ -8,8 +8,9 @@ import pykka from mopidy.backends import base from mopidy.utils import encoding, path -from .library import LocalLibraryProvider, LocalLibraryUpdateProvider +from .library import LocalLibraryProvider from .playlists import LocalPlaylistsProvider +from .playback import LocalPlaybackProvider logger = logging.getLogger('mopidy.backends.local') @@ -23,11 +24,10 @@ class LocalBackend(pykka.ThreadingActor, base.Backend): self.check_dirs_and_files() self.library = LocalLibraryProvider(backend=self) - self.updater = LocalLibraryUpdateProvider(backend=self) - self.playback = base.BasePlaybackProvider(audio=audio, backend=self) + self.playback = LocalPlaybackProvider(audio=audio, backend=self) self.playlists = LocalPlaylistsProvider(backend=self) - self.uri_schemes = ['file'] + self.uri_schemes = ['local'] def check_dirs_and_files(self): if not os.path.isdir(self.config['local']['media_dir']): diff --git a/mopidy/backends/local/ext.conf b/mopidy/backends/local/ext.conf index 54c3ab78..7e0f0f2b 100644 --- a/mopidy/backends/local/ext.conf +++ b/mopidy/backends/local/ext.conf @@ -3,3 +3,4 @@ enabled = true media_dir = $XDG_MUSIC_DIR playlists_dir = $XDG_DATA_DIR/mopidy/local/playlists tag_cache_file = $XDG_DATA_DIR/mopidy/local/tag_cache +scan_timeout = 1000 diff --git a/mopidy/backends/local/library.py b/mopidy/backends/local/library.py index 43768cd4..9dd112e9 100644 --- a/mopidy/backends/local/library.py +++ b/mopidy/backends/local/library.py @@ -81,7 +81,8 @@ class LocalLibraryProvider(base.BaseLibraryProvider): result_tracks = filter(any_filter, result_tracks) else: raise LookupError('Invalid lookup field: %s' % field) - return SearchResult(uri='file:search', tracks=result_tracks) + # TODO: add local:search: + return SearchResult(uri='local:search', tracks=result_tracks) def search(self, query=None, uris=None): # TODO Only return results within URI roots given by ``uris`` @@ -122,7 +123,8 @@ class LocalLibraryProvider(base.BaseLibraryProvider): result_tracks = filter(any_filter, result_tracks) else: raise LookupError('Invalid lookup field: %s' % field) - return SearchResult(uri='file:search', tracks=result_tracks) + # TODO: add local:search: + return SearchResult(uri='local:search', tracks=result_tracks) def _validate_query(self, query): for (_, values) in query.iteritems(): @@ -135,11 +137,12 @@ class LocalLibraryProvider(base.BaseLibraryProvider): # TODO: rename and move to tagcache extension. class LocalLibraryUpdateProvider(base.BaseLibraryProvider): - def __init__(self, *args, **kwargs): - super(LocalLibraryUpdateProvider, self).__init__(*args, **kwargs) + uri_schemes = ['local'] + + def __init__(self, config): self._tracks = {} - self._media_dir = self.backend.config['local']['media_dir'] - self._tag_cache_file = self.backend.config['local']['tag_cache_file'] + self._media_dir = config['local']['media_dir'] + self._tag_cache_file = config['local']['tag_cache_file'] def load(self): tracks = parse_mpd_tag_cache(self._tag_cache_file, self._media_dir) @@ -156,6 +159,8 @@ class LocalLibraryUpdateProvider(base.BaseLibraryProvider): def commit(self): directory, basename = os.path.split(self._tag_cache_file) + + # TODO: cleanup directory/basename.* files. tmp = tempfile.NamedTemporaryFile( prefix=basename + '.', dir=directory, delete=False) diff --git a/mopidy/backends/local/playback.py b/mopidy/backends/local/playback.py new file mode 100644 index 00000000..eda06ff7 --- /dev/null +++ b/mopidy/backends/local/playback.py @@ -0,0 +1,19 @@ +from __future__ import unicode_literals + +import logging +import os + +from mopidy.backends import base +from mopidy.utils import path + +logger = logging.getLogger('mopidy.backends.local') + + +class LocalPlaybackProvider(base.BasePlaybackProvider): + def change_track(self, track): + media_dir = self.backend.config['local']['media_dir'] + # TODO: check that type is correct. + file_path = path.uri_to_path(track.uri).split(':', 1)[1] + file_path = os.path.join(media_dir, file_path) + track = track.copy(uri=path.path_to_uri(file_path)) + return super(LocalPlaybackProvider, self).change_track(track) diff --git a/mopidy/backends/local/playlists.py b/mopidy/backends/local/playlists.py index cd370eaa..af3814ae 100644 --- a/mopidy/backends/local/playlists.py +++ b/mopidy/backends/local/playlists.py @@ -24,7 +24,7 @@ class LocalPlaylistsProvider(base.BasePlaylistsProvider): def create(self, name): name = formatting.slugify(name) - uri = path.path_to_uri(self._get_m3u_path(name)) + uri = 'local:playlist:%s.m3u' % name playlist = Playlist(uri=uri, name=name) return self.save(playlist) @@ -37,6 +37,7 @@ class LocalPlaylistsProvider(base.BasePlaylistsProvider): self._delete_m3u(playlist.uri) def lookup(self, uri): + # TODO: store as {uri: playlist}? for playlist in self._playlists: if playlist.uri == uri: return playlist @@ -45,8 +46,8 @@ class LocalPlaylistsProvider(base.BasePlaylistsProvider): playlists = [] for m3u in glob.glob(os.path.join(self._playlists_dir, '*.m3u')): - uri = path.path_to_uri(m3u) name = os.path.splitext(os.path.basename(m3u))[0] + uri = 'local:playlist:%s' % name tracks = [] for track_uri in parse_m3u(m3u, self._media_dir): @@ -61,6 +62,7 @@ class LocalPlaylistsProvider(base.BasePlaylistsProvider): playlists.append(playlist) self.playlists = playlists + # TODO: send what scheme we loaded them for? listener.BackendListener.send('playlists_loaded') logger.info( @@ -86,38 +88,30 @@ class LocalPlaylistsProvider(base.BasePlaylistsProvider): return playlist - def _get_m3u_path(self, name): - name = formatting.slugify(name) - file_path = os.path.join(self._playlists_dir, name + '.m3u') + def _m3u_uri_to_path(self, uri): + # TODO: create uri handling helpers for local uri types. + file_path = path.uri_to_path(uri).split(':', 1)[1] + file_path = os.path.join(self._playlists_dir, file_path) path.check_file_path_is_inside_base_dir(file_path, self._playlists_dir) return file_path def _save_m3u(self, playlist): - file_path = path.uri_to_path(playlist.uri) - path.check_file_path_is_inside_base_dir(file_path, self._playlists_dir) + file_path = self._m3u_uri_to_path(playlist.uri) with open(file_path, 'w') as file_handle: for track in playlist.tracks: - if track.uri.startswith('file://'): - uri = path.uri_to_path(track.uri) - else: - uri = track.uri - file_handle.write(uri + '\n') + file_handle.write(track.uri + '\n') def _delete_m3u(self, uri): - file_path = path.uri_to_path(uri) - path.check_file_path_is_inside_base_dir(file_path, self._playlists_dir) + file_path = self._m3u_uri_to_path(uri) if os.path.exists(file_path): os.remove(file_path) def _rename_m3u(self, playlist): - src_file_path = path.uri_to_path(playlist.uri) - path.check_file_path_is_inside_base_dir( - src_file_path, self._playlists_dir) + dst_name = formatting.slugify(playlist.name) + dst_uri = 'local:playlist:%s.m3u' % dst_name - dst_file_path = self._get_m3u_path(playlist.name) - path.check_file_path_is_inside_base_dir( - dst_file_path, self._playlists_dir) + src_file_path = self._m3u_uri_to_path(playlist.uri) + dst_file_path = self._m3u_uri_to_path(dst_uri) shutil.move(src_file_path, dst_file_path) - - return playlist.copy(uri=path.path_to_uri(dst_file_path)) + return playlist.copy(uri=dst_uri) diff --git a/mopidy/backends/local/translator.py b/mopidy/backends/local/translator.py index 4ae10af2..7cd46fbb 100644 --- a/mopidy/backends/local/translator.py +++ b/mopidy/backends/local/translator.py @@ -1,7 +1,8 @@ from __future__ import unicode_literals import logging -import urllib +import os +import urlparse from mopidy.models import Track, Artist, Album from mopidy.utils.encoding import locale_decode @@ -30,7 +31,6 @@ def parse_m3u(file_path, media_dir): - m3u files are latin-1. - This function does not bother with Extended M3U directives. """ - # TODO: uris as bytes uris = [] try: @@ -46,16 +46,19 @@ def parse_m3u(file_path, media_dir): if line.startswith('#'): continue - # FIXME what about other URI types? - if line.startswith('file://'): + if urlparse.urlsplit(line).scheme: uris.append(line) + elif os.path.normpath(line) == os.path.abspath(line): + path = path_to_uri(line) + uris.append(path) else: - path = path_to_uri(media_dir, line) + path = path_to_uri(os.path.join(media_dir, line)) uris.append(path) return uris +# TODO: remove music_dir from API def parse_mpd_tag_cache(tag_cache, music_dir=''): """ Converts a MPD tag_cache into a lists of tracks, artists and albums. @@ -86,17 +89,17 @@ def parse_mpd_tag_cache(tag_cache, music_dir=''): key, value = line.split(b': ', 1) if key == b'key': - _convert_mpd_data(current, tracks, music_dir) + _convert_mpd_data(current, tracks) current.clear() current[key.lower()] = value.decode('utf-8') - _convert_mpd_data(current, tracks, music_dir) + _convert_mpd_data(current, tracks) return tracks -def _convert_mpd_data(data, tracks, music_dir): +def _convert_mpd_data(data, tracks): if not data: return @@ -160,15 +163,8 @@ def _convert_mpd_data(data, tracks, music_dir): path = data['file'][1:] else: path = data['file'] - path = urllib.unquote(path.encode('utf-8')) - - if isinstance(music_dir, unicode): - music_dir = music_dir.encode('utf-8') - - # Make sure we only pass bytestrings to path_to_uri to avoid implicit - # decoding of bytestrings to unicode strings - track_kwargs['uri'] = path_to_uri(music_dir, path) + track_kwargs['uri'] = 'local:track:%s' % path track_kwargs['length'] = int(data.get('time', 0)) * 1000 track = Track(**track_kwargs) diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index c0592ea7..3ab4498b 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -18,9 +18,6 @@ logger = logging.getLogger('mopidy.backends.spotify') BITRATES = {96: 2, 160: 0, 320: 1} -# pylint: disable = R0901 -# SpotifySessionManager: Too many ancestors (9/7) - class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): cache_location = None @@ -33,9 +30,17 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): self.cache_location = config['spotify']['cache_dir'] self.settings_location = config['spotify']['cache_dir'] + full_proxy = '' + if config['proxy']['hostname']: + full_proxy = config['proxy']['hostname'] + if config['proxy']['port']: + full_proxy += ':' + str(config['proxy']['port']) + if config['proxy']['scheme']: + full_proxy = config['proxy']['scheme'] + "://" + full_proxy + PyspotifySessionManager.__init__( self, config['spotify']['username'], config['spotify']['password'], - proxy=config['proxy']['hostname'], + proxy=full_proxy, proxy_username=config['proxy']['username'], proxy_password=config['proxy']['password']) @@ -108,9 +113,6 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): def music_delivery(self, session, frames, frame_size, num_frames, sample_type, sample_rate, channels): """Callback used by pyspotify""" - # pylint: disable = R0913 - # Too many arguments (8/5) - if not self.push_audio_data: return 0 @@ -173,9 +175,14 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): logger.debug('Still getting data; skipped refresh of playlists') return playlists = [] + folders = [] for spotify_playlist in self.session.playlist_container(): + if spotify_playlist.type() == 'folder_start': + folders.append(spotify_playlist) + if spotify_playlist.type() == 'folder_end': + folders.pop() playlists.append(translator.to_mopidy_playlist( - spotify_playlist, + spotify_playlist, folders=folders, bitrate=self.bitrate, username=self.username)) playlists.append(translator.to_mopidy_playlist( self.session.starred(), diff --git a/mopidy/backends/spotify/translator.py b/mopidy/backends/spotify/translator.py index 60961cf8..f35cad2e 100644 --- a/mopidy/backends/spotify/translator.py +++ b/mopidy/backends/spotify/translator.py @@ -67,7 +67,8 @@ def to_mopidy_track(spotify_track, bitrate=None): return track_cache[uri] -def to_mopidy_playlist(spotify_playlist, bitrate=None, username=None): +def to_mopidy_playlist( + spotify_playlist, folders=None, bitrate=None, username=None): if spotify_playlist is None or spotify_playlist.type() != 'playlist': return try: @@ -78,6 +79,9 @@ def to_mopidy_playlist(spotify_playlist, bitrate=None, username=None): if not spotify_playlist.is_loaded(): return Playlist(uri=uri, name='[loading...]') name = spotify_playlist.name() + if folders: + folder_names = '/'.join(folder.name() for folder in folders) + name = folder_names + '/' + name tracks = [ to_mopidy_track(spotify_track, bitrate=bitrate) for spotify_track in spotify_playlist diff --git a/mopidy/backends/stream/ext.conf b/mopidy/backends/stream/ext.conf index 9caafac1..dc0287da 100644 --- a/mopidy/backends/stream/ext.conf +++ b/mopidy/backends/stream/ext.conf @@ -1,6 +1,7 @@ [stream] enabled = true protocols = + file http https mms diff --git a/mopidy/config/__init__.py b/mopidy/config/__init__.py index e9ae7d86..0767b50c 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -24,9 +24,13 @@ _audio_schema = ConfigSchema('audio') _audio_schema['mixer'] = String() _audio_schema['mixer_track'] = String(optional=True) _audio_schema['output'] = String() +_audio_schema['visualizer'] = String(optional=True) _proxy_schema = ConfigSchema('proxy') +_proxy_schema['scheme'] = String(optional=True, + choices=['http', 'https', 'socks4', 'socks5']) _proxy_schema['hostname'] = Hostname(optional=True) +_proxy_schema['port'] = Port(optional=True) _proxy_schema['username'] = String(optional=True) _proxy_schema['password'] = Secret(optional=True) diff --git a/mopidy/config/convert.py b/mopidy/config/convert.py index 6cb20fcd..3c3edb85 100644 --- a/mopidy/config/convert.py +++ b/mopidy/config/convert.py @@ -39,6 +39,7 @@ def convert(settings): helper('audio/output', 'OUTPUT') helper('proxy/hostname', 'SPOTIFY_PROXY_HOST') + helper('proxy/port', 'SPOTIFY_PROXY_PORT') helper('proxy/username', 'SPOTIFY_PROXY_USERNAME') helper('proxy/password', 'SPOTIFY_PROXY_PASSWORD') diff --git a/mopidy/config/default.conf b/mopidy/config/default.conf index b525ef47..06749ee1 100644 --- a/mopidy/config/default.conf +++ b/mopidy/config/default.conf @@ -11,8 +11,11 @@ pykka = info mixer = autoaudiomixer mixer_track = output = autoaudiosink +visualizer = [proxy] +scheme = hostname = +port = username = password = diff --git a/mopidy/config/types.py b/mopidy/config/types.py index 3451992a..d264de30 100644 --- a/mopidy/config/types.py +++ b/mopidy/config/types.py @@ -3,7 +3,6 @@ from __future__ import unicode_literals import logging import re import socket -import sys from mopidy.utils import path from mopidy.config import validators @@ -72,9 +71,9 @@ class String(ConfigValue): def deserialize(self, value): value = decode(value).strip() validators.validate_required(value, self._required) - validators.validate_choice(value, self._choices) if not value: return None + validators.validate_choice(value, self._choices) return value def serialize(self, value, display=False): @@ -83,41 +82,38 @@ class String(ConfigValue): return encode(value) -class Secret(ConfigValue): - """Secret value. +class Secret(String): + """Secret string value. - Should be used for passwords, auth tokens etc. Deserializing will not - convert to unicode. Will mask value when being displayed. + Is decoded as utf-8 and \\n \\t escapes should work and be preserved. + + Should be used for passwords, auth tokens etc. Will mask value when being + displayed. """ def __init__(self, optional=False, choices=None): self._required = not optional - - def deserialize(self, value): - value = value.strip() - validators.validate_required(value, self._required) - if not value: - return None - return value + self._choices = None # Choices doesn't make sense for secrets def serialize(self, value, display=False): - if isinstance(value, unicode): - value = value.encode('utf-8') - if value is None: - return b'' - elif display: + if value is not None and display: return b'********' - return value + return super(Secret, self).serialize(value, display) class Integer(ConfigValue): """Integer value.""" - def __init__(self, minimum=None, maximum=None, choices=None): + def __init__( + self, minimum=None, maximum=None, choices=None, optional=False): + self._required = not optional self._minimum = minimum self._maximum = maximum self._choices = choices def deserialize(self, value): + validators.validate_required(value, self._required) + if not value: + return None value = int(value) validators.validate_choice(value, self._choices) validators.validate_minimum(value, self._minimum) @@ -223,8 +219,9 @@ class Port(Integer): allocate a port for us. """ # TODO: consider probing if port is free or not? - def __init__(self, choices=None): - super(Port, self).__init__(minimum=0, maximum=2**16-1, choices=choices) + def __init__(self, choices=None, optional=False): + super(Port, self).__init__( + minimum=0, maximum=2 ** 16 - 1, choices=choices, optional=optional) class Path(ConfigValue): @@ -256,7 +253,7 @@ class Path(ConfigValue): def serialize(self, value, display=False): if isinstance(value, unicode): - value = value.encode(sys.getfilesystemencoding()) + raise ValueError('paths should always be bytes') if isinstance(value, ExpandedPath): return value.original return value diff --git a/mopidy/core/library.py b/mopidy/core/library.py index 50d75144..cdc3f53a 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -69,8 +69,9 @@ class LibraryController(object): """ query = query or kwargs futures = [ - backend.library.find_exact(query=query, uris=uris) - for (backend, uris) in self._get_backends_to_uris(uris).items()] + backend.library.find_exact(query=query, uris=backend_uris) + for (backend, backend_uris) + in self._get_backends_to_uris(uris).items()] return [result for result in pykka.get_all(futures) if result] def lookup(self, uri): @@ -145,6 +146,7 @@ class LibraryController(object): """ query = query or kwargs futures = [ - backend.library.search(query=query, uris=uris) - for (backend, uris) in self._get_backends_to_uris(uris).items()] + backend.library.search(query=query, uris=backend_uris) + for (backend, backend_uris) + in self._get_backends_to_uris(uris).items()] return [result for result in pykka.get_all(futures) if result] diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 2e79827a..ea849dbf 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -13,9 +13,6 @@ logger = logging.getLogger('mopidy.core') class PlaybackController(object): - # pylint: disable = R0902 - # Too many instance attributes - pykka_traversable = True def __init__(self, audio, backends, core): @@ -175,9 +172,6 @@ class PlaybackController(object): """ def get_tl_track_at_eot(self): - # pylint: disable = R0911 - # Too many return statements - tl_tracks = self.core.tracklist.tl_tracks if not tl_tracks: @@ -401,6 +395,7 @@ class PlaybackController(object): if self.random and self._shuffled: self._shuffled.remove(tl_track) if on_error_step == 1: + # TODO: can cause an endless loop for single track repeat. self.next() elif on_error_step == -1: self.previous() diff --git a/mopidy/ext.py b/mopidy/ext.py index d7c5c96f..5db7c093 100644 --- a/mopidy/ext.py +++ b/mopidy/ext.py @@ -79,6 +79,15 @@ class Extension(object): """ return [] + def get_library_updaters(self): + """List of library updater classes + + :returns: list of + :class:`~mopidy.backends.base.BaseLibraryUpdateProvider` + subclasses + """ + return [] + def register_gstreamer_elements(self): """Hook for registering custom GStreamer elements diff --git a/mopidy/frontends/http/data/mopidy.js b/mopidy/frontends/http/data/mopidy.js index 1669eaff..3e4e832e 100644 --- a/mopidy/frontends/http/data/mopidy.js +++ b/mopidy/frontends/http/data/mopidy.js @@ -1,8 +1,8 @@ -/*! Mopidy.js - built 2013-03-31 +/*! Mopidy.js - built 2013-09-17 * http://www.mopidy.com/ * Copyright (c) 2013 Stein Magnus Jodal and contributors * Licensed under the Apache License, Version 2.0 */ -((typeof define === "function" && define.amd && function (m) { define(m); }) || +((typeof define === "function" && define.amd && function (m) { define("bane", m); }) || (typeof module === "object" && function (m) { module.exports = m(); }) || function (m) { this.bane = m(); } )(function () { @@ -148,7 +148,7 @@ notifyListener(event, toNotify[i], args); } - toNotify = listeners(this, event).slice() + toNotify = listeners(this, event).slice(); args = slice.call(arguments, 1); for (i = 0, l = toNotify.length; i < l; ++i) { notifyListener(event, toNotify[i], args); @@ -187,27 +187,30 @@ if (typeof window !== "undefined") { * * @author Brian Cavalier * @author John Hann - * @version 2.0.0 + * @version 2.4.0 */ -(function(define) { 'use strict'; -define(function () { +(function(define, global) { 'use strict'; +define(function (require) { // Public API - when.defer = defer; // Create a deferred + when.promise = promise; // Create a pending promise when.resolve = resolve; // Create a resolved promise when.reject = reject; // Create a rejected promise + when.defer = defer; // Create a {promise, resolver} pair when.join = join; // Join 2 or more promises when.all = all; // Resolve a list of promises when.map = map; // Array.map() for promises when.reduce = reduce; // Array.reduce() for promises + when.settle = settle; // Settle a list of promises when.any = any; // One-winner race when.some = some; // Multi-winner race - when.isPromise = isPromise; // Determine if a thing is a promise + when.isPromise = isPromiseLike; // DEPRECATED: use isPromiseLike + when.isPromiseLike = isPromiseLike; // Is something promise-like, aka thenable /** * Register an observer for a promise or immediate value. @@ -235,13 +238,35 @@ define(function () { * a trusted when.js promise. Any other duck-typed promise is considered * untrusted. * @constructor + * @param {function} sendMessage function to deliver messages to the promise's handler + * @param {function?} inspect function that reports the promise's state * @name Promise */ - function Promise(then) { - this.then = then; + function Promise(sendMessage, inspect) { + this._message = sendMessage; + this.inspect = inspect; } Promise.prototype = { + /** + * Register handlers for this promise. + * @param [onFulfilled] {Function} fulfillment handler + * @param [onRejected] {Function} rejection handler + * @param [onProgress] {Function} progress handler + * @return {Promise} new Promise + */ + then: function(onFulfilled, onRejected, onProgress) { + /*jshint unused:false*/ + var args, sendMessage; + + args = arguments; + sendMessage = this._message; + + return _promise(function(resolve, reject, notify) { + sendMessage('when', args, resolve, notify); + }, this._status && this._status.observed()); + }, + /** * Register a rejection handler. Shortcut for .then(undefined, onRejected) * @param {function?} onRejected @@ -262,9 +287,7 @@ define(function () { * @returns {Promise} */ ensure: function(onFulfilledOrRejected) { - var self = this; - - return this.then(injectHandler, injectHandler).yield(self); + return this.then(injectHandler, injectHandler)['yield'](this); function injectHandler() { return resolve(onFulfilledOrRejected()); @@ -285,6 +308,16 @@ define(function () { }); }, + /** + * Runs a side effect when this promise fulfills, without changing the + * fulfillment value. + * @param {function} onFulfilledSideEffect + * @returns {Promise} + */ + tap: function(onFulfilledSideEffect) { + return this.then(onFulfilledSideEffect)['yield'](this); + }, + /** * Assumes that this promise will fulfill with an array, and arranges * for the onFulfilled to be called with the array as its argument list @@ -340,13 +373,16 @@ define(function () { } /** - * Creates a new Deferred with fully isolated resolver and promise parts, - * either or both of which may be given out safely to consumers. + * Creates a {promise, resolver} pair, either or both of which + * may be given out safely to consumers. * The resolver has resolve, reject, and progress. The promise - * only has then. + * has then plus extended promise API. * * @return {{ * promise: Promise, + * resolve: function:Promise, + * reject: function:Promise, + * notify: function:Promise * resolver: { * resolve: function:Promise, * reject: function:Promise, @@ -394,12 +430,26 @@ define(function () { /** * Creates a new promise whose fate is determined by resolver. - * @private (for now) * @param {function} resolver function(resolve, reject, notify) * @returns {Promise} promise whose fate is determine by resolver */ function promise(resolver) { - var value, handlers = []; + return _promise(resolver, monitorApi.PromiseStatus && monitorApi.PromiseStatus()); + } + + /** + * Creates a new promise, linked to parent, whose fate is determined + * by resolver. + * @param {function} resolver function(resolve, reject, notify) + * @param {Promise?} status promise from which the new promise is begotten + * @returns {Promise} promise whose fate is determine by resolver + * @private + */ + function _promise(resolver, status) { + var self, value, consumers = []; + + self = new Promise(_message, inspect); + self._status = status; // Call the provider resolver to seal the promise's fate try { @@ -409,29 +459,34 @@ define(function () { } // Return the promise - return new Promise(then); + return self; /** - * Register handlers for this promise. - * @param [onFulfilled] {Function} fulfillment handler - * @param [onRejected] {Function} rejection handler - * @param [onProgress] {Function} progress handler - * @return {Promise} new Promise + * Private message delivery. Queues and delivers messages to + * the promise's ultimate fulfillment value or rejection reason. + * @private + * @param {String} type + * @param {Array} args + * @param {Function} resolve + * @param {Function} notify */ - function then(onFulfilled, onRejected, onProgress) { - return promise(function(resolve, reject, notify) { - handlers - // Call handlers later, after resolution - ? handlers.push(function(value) { - value.then(onFulfilled, onRejected, onProgress) - .then(resolve, reject, notify); - }) - // Call handlers soon, but not in the current stack - : enqueue(function() { - value.then(onFulfilled, onRejected, onProgress) - .then(resolve, reject, notify); - }); - }); + function _message(type, args, resolve, notify) { + consumers ? consumers.push(deliver) : enqueue(function() { deliver(value); }); + + function deliver(p) { + p._message(type, args, resolve, notify); + } + } + + /** + * Returns a snapshot of the promise's state at the instant inspect() + * is called. The returned object is not live and will not update as + * the promise's state changes. + * @returns {{ state:String, value?:*, reason?:* }} status snapshot + * of the promise. + */ + function inspect() { + return value ? value.inspect() : toPendingState(); } /** @@ -440,14 +495,17 @@ define(function () { * @param {*|Promise} val resolution value */ function promiseResolve(val) { - if(!handlers) { + if(!consumers) { return; } value = coerce(val); - scheduleHandlers(handlers, value); + scheduleConsumers(consumers, value); + consumers = undef; - handlers = undef; + if(status) { + updateStatus(value, status); + } } /** @@ -463,27 +521,90 @@ define(function () { * @param {*} update progress event payload to pass to all listeners */ function promiseNotify(update) { - if(handlers) { - scheduleHandlers(handlers, progressing(update)); + if(consumers) { + scheduleConsumers(consumers, progressed(update)); } } } + /** + * Creates a fulfilled, local promise as a proxy for a value + * NOTE: must never be exposed + * @param {*} value fulfillment value + * @returns {Promise} + */ + function fulfilled(value) { + return near( + new NearFulfilledProxy(value), + function() { return toFulfilledState(value); } + ); + } + + /** + * Creates a rejected, local promise with the supplied reason + * NOTE: must never be exposed + * @param {*} reason rejection reason + * @returns {Promise} + */ + function rejected(reason) { + return near( + new NearRejectedProxy(reason), + function() { return toRejectedState(reason); } + ); + } + + /** + * Creates a near promise using the provided proxy + * NOTE: must never be exposed + * @param {object} proxy proxy for the promise's ultimate value or reason + * @param {function} inspect function that returns a snapshot of the + * returned near promise's state + * @returns {Promise} + */ + function near(proxy, inspect) { + return new Promise(function (type, args, resolve) { + try { + resolve(proxy[type].apply(proxy, args)); + } catch(e) { + resolve(rejected(e)); + } + }, inspect); + } + + /** + * Create a progress promise with the supplied update. + * @private + * @param {*} update + * @return {Promise} progress promise + */ + function progressed(update) { + return new Promise(function (type, args, _, notify) { + var onProgress = args[2]; + try { + notify(typeof onProgress === 'function' ? onProgress(update) : update); + } catch(e) { + notify(e); + } + }); + } + /** * Coerces x to a trusted Promise * * @private * @param {*} x thing to coerce - * @returns {Promise} Guaranteed to return a trusted Promise. If x + * @returns {*} Guaranteed to return a trusted Promise. If x * is trusted, returns x, otherwise, returns a new, trusted, already-resolved * Promise whose resolution value is: * * the resolution value of x if it's a foreign promise, or * * x if it's a value */ function coerce(x) { - if(x instanceof Promise) { + if (x instanceof Promise) { return x; - } else if (x !== Object(x)) { + } + + if (!(x === Object(x) && 'then' in x)) { return fulfilled(x); } @@ -510,61 +631,34 @@ define(function () { } /** - * Create an already-fulfilled promise for the supplied value - * @private + * Proxy for a near, fulfilled value * @param {*} value - * @return {Promise} fulfilled promise + * @constructor */ - function fulfilled(value) { - var self = new Promise(function (onFulfilled) { - try { - return typeof onFulfilled == 'function' - ? coerce(onFulfilled(value)) : self; - } catch (e) { - return rejected(e); - } - }); - - return self; + function NearFulfilledProxy(value) { + this.value = value; } + NearFulfilledProxy.prototype.when = function(onResult) { + return typeof onResult === 'function' ? onResult(this.value) : this.value; + }; + /** - * Create an already-rejected promise with the supplied rejection reason. - * @private + * Proxy for a near rejection * @param {*} reason - * @return {Promise} rejected promise + * @constructor */ - function rejected(reason) { - var self = new Promise(function (_, onRejected) { - try { - return typeof onRejected == 'function' - ? coerce(onRejected(reason)) : self; - } catch (e) { - return rejected(e); - } - }); - - return self; + function NearRejectedProxy(reason) { + this.reason = reason; } - /** - * Create a progress promise with the supplied update. - * @private - * @param {*} update - * @return {Promise} progress promise - */ - function progressing(update) { - var self = new Promise(function (_, __, onProgress) { - try { - return typeof onProgress == 'function' - ? progressing(onProgress(update)) : self; - } catch (e) { - return progressing(e); - } - }); - - return self; - } + NearRejectedProxy.prototype.when = function(_, onError) { + if(typeof onError === 'function') { + return onError(this.reason); + } else { + throw this.reason; + } + }; /** * Schedule a task that will process a list of handlers @@ -573,7 +667,7 @@ define(function () { * @param {Array} handlers queue of handlers to execute * @param {*} value passed as the only arg to each handler */ - function scheduleHandlers(handlers, value) { + function scheduleConsumers(handlers, value) { enqueue(function() { var handler, i = 0; while (handler = handlers[i++]) { @@ -582,14 +676,23 @@ define(function () { }); } + function updateStatus(value, status) { + value.then(statusFulfilled, statusRejected); + + function statusFulfilled() { status.fulfilled(); } + function statusRejected(r) { status.rejected(r); } + } + /** - * Determines if promiseOrValue is a promise or not - * - * @param {*} promiseOrValue anything - * @returns {boolean} true if promiseOrValue is a {@link Promise} + * Determines if x is promise-like, i.e. a thenable object + * NOTE: Will return true for *any thenable object*, and isn't truly + * safe, since it may attempt to access the `then` property of x (i.e. + * clever/malicious getters may do weird things) + * @param {*} x anything + * @returns {boolean} true if x is promise-like */ - function isPromise(promiseOrValue) { - return promiseOrValue && typeof promiseOrValue.then === 'function'; + function isPromiseLike(x) { + return x && typeof x.then === 'function'; } /** @@ -601,17 +704,15 @@ define(function () { * @param {Array} promisesOrValues array of anything, may contain a mix * of promises and values * @param howMany {number} number of promisesOrValues to resolve - * @param {function?} [onFulfilled] resolution handler - * @param {function?} [onRejected] rejection handler - * @param {function?} [onProgress] progress handler + * @param {function?} [onFulfilled] DEPRECATED, use returnedPromise.then() + * @param {function?} [onRejected] DEPRECATED, use returnedPromise.then() + * @param {function?} [onProgress] DEPRECATED, use returnedPromise.then() * @returns {Promise} promise that will resolve to an array of howMany values that * resolved first, or will reject with an array of * (promisesOrValues.length - howMany) + 1 rejection reasons. */ function some(promisesOrValues, howMany, onFulfilled, onRejected, onProgress) { - checkCallbacks(2, arguments); - return when(promisesOrValues, function(promisesOrValues) { return promise(resolveSome).then(onFulfilled, onRejected, onProgress); @@ -635,7 +736,7 @@ define(function () { rejectOne = function(reason) { reasons.push(reason); if(!--toReject) { - fulfillOne = rejectOne = noop; + fulfillOne = rejectOne = identity; reject(reasons); } }; @@ -644,7 +745,7 @@ define(function () { // This orders the values based on promise resolution order values.push(val); if (!--toResolve) { - fulfillOne = rejectOne = noop; + fulfillOne = rejectOne = identity; resolve(values); } }; @@ -674,9 +775,9 @@ define(function () { * * @param {Array|Promise} promisesOrValues array of anything, may contain a mix * of {@link Promise}s and values - * @param {function?} [onFulfilled] resolution handler - * @param {function?} [onRejected] rejection handler - * @param {function?} [onProgress] progress handler + * @param {function?} [onFulfilled] DEPRECATED, use returnedPromise.then() + * @param {function?} [onRejected] DEPRECATED, use returnedPromise.then() + * @param {function?} [onProgress] DEPRECATED, use returnedPromise.then() * @returns {Promise} promise that will resolve to the value that resolved first, or * will reject with an array of all rejected inputs. */ @@ -697,14 +798,13 @@ define(function () { * * @param {Array|Promise} promisesOrValues array of anything, may contain a mix * of {@link Promise}s and values - * @param {function?} [onFulfilled] resolution handler - * @param {function?} [onRejected] rejection handler - * @param {function?} [onProgress] progress handler + * @param {function?} [onFulfilled] DEPRECATED, use returnedPromise.then() + * @param {function?} [onRejected] DEPRECATED, use returnedPromise.then() + * @param {function?} [onProgress] DEPRECATED, use returnedPromise.then() * @returns {Promise} */ function all(promisesOrValues, onFulfilled, onRejected, onProgress) { - checkCallbacks(1, arguments); - return map(promisesOrValues, identity).then(onFulfilled, onRejected, onProgress); + return _map(promisesOrValues, identity).then(onFulfilled, onRejected, onProgress); } /** @@ -713,28 +813,49 @@ define(function () { * have fulfilled, or will reject when *any one* of the input promises rejects. */ function join(/* ...promises */) { - return map(arguments, identity); + return _map(arguments, identity); } /** - * Traditional map function, similar to `Array.prototype.map()`, but allows - * input to contain {@link Promise}s and/or values, and mapFunc may return - * either a value or a {@link Promise} - * - * @param {Array|Promise} array array of anything, may contain a mix - * of {@link Promise}s and values - * @param {function} mapFunc mapping function mapFunc(value) which may return - * either a {@link Promise} or value - * @returns {Promise} a {@link Promise} that will resolve to an array containing - * the mapped output values. + * Settles all input promises such that they are guaranteed not to + * be pending once the returned promise fulfills. The returned promise + * will always fulfill, except in the case where `array` is a promise + * that rejects. + * @param {Array|Promise} array or promise for array of promises to settle + * @returns {Promise} promise that always fulfills with an array of + * outcome snapshots for each input promise. + */ + function settle(array) { + return _map(array, toFulfilledState, toRejectedState); + } + + /** + * Promise-aware array map function, similar to `Array.prototype.map()`, + * but input array may contain promises or values. + * @param {Array|Promise} array array of anything, may contain promises and values + * @param {function} mapFunc map function which may return a promise or value + * @returns {Promise} promise that will fulfill with an array of mapped values + * or reject if any input promise rejects. */ function map(array, mapFunc) { + return _map(array, mapFunc); + } + + /** + * Internal map that allows a fallback to handle rejections + * @param {Array|Promise} array array of anything, may contain promises and values + * @param {function} mapFunc map function which may return a promise or value + * @param {function?} fallback function to handle rejected promises + * @returns {Promise} promise that will fulfill with an array of mapped values + * or reject if any input promise rejects. + */ + function _map(array, mapFunc, fallback) { return when(array, function(array) { - return promise(resolveMap); + return _promise(resolveMap); function resolveMap(resolve, reject, notify) { - var results, len, toResolve, resolveOne, i; + var results, len, toResolve, i; // Since we know the resulting length, we can preallocate the results // array to avoid array expansions. @@ -743,27 +864,28 @@ define(function () { if(!toResolve) { resolve(results); - } else { + return; + } - resolveOne = function(item, i) { - when(item, mapFunc).then(function(mapped) { - results[i] = mapped; - - if(!--toResolve) { - resolve(results); - } - }, reject, notify); - }; - - // Since mapFunc may be async, get all invocations of it into flight - for(i = 0; i < len; i++) { - if(i in array) { - resolveOne(array[i], i); - } else { - --toResolve; - } + // Since mapFunc may be async, get all invocations of it into flight + for(i = 0; i < len; i++) { + if(i in array) { + resolveOne(array[i], i); + } else { + --toResolve; } } + + function resolveOne(item, i) { + when(item, mapFunc, fallback).then(function(mapped) { + results[i] = mapped; + notify(mapped); + + if(!--toResolve) { + resolve(results); + } + }, reject); + } } }); } @@ -803,12 +925,46 @@ define(function () { }); } + // Snapshot states + + /** + * Creates a fulfilled state snapshot + * @private + * @param {*} x any value + * @returns {{state:'fulfilled',value:*}} + */ + function toFulfilledState(x) { + return { state: 'fulfilled', value: x }; + } + + /** + * Creates a rejected state snapshot + * @private + * @param {*} x any reason + * @returns {{state:'rejected',reason:*}} + */ + function toRejectedState(x) { + return { state: 'rejected', reason: x }; + } + + /** + * Creates a pending state snapshot + * @private + * @returns {{state:'pending'}} + */ + function toPendingState() { + return { state: 'pending' }; + } + // - // Utilities, etc. + // Internals, utilities, etc. // var reduceArray, slice, fcall, nextTick, handlerQueue, - timeout, funcProto, call, arrayProto, undef; + setTimeout, funcProto, call, arrayProto, monitorApi, + cjsRequire, undef; + + cjsRequire = require; // // Shared handler queue processing @@ -826,20 +982,13 @@ define(function () { */ function enqueue(task) { if(handlerQueue.push(task) === 1) { - scheduleDrainQueue(); + nextTick(drainQueue); } } /** - * Schedule the queue to be drained in the next tick. - */ - function scheduleDrainQueue() { - nextTick(drainQueue); - } - - /** - * Drain the handler queue entirely or partially, being careful to allow - * the queue to be extended while it is being processed, and to continue + * Drain the handler queue entirely, being careful to allow the + * queue to be extended while it is being processed, and to continue * processing until it is truly empty. */ function drainQueue() { @@ -852,20 +1001,36 @@ define(function () { handlerQueue = []; } - // - // Capture function and array utils - // - /*global setImmediate:true*/ + // capture setTimeout to avoid being caught by fake timers + // used in time based tests + setTimeout = global.setTimeout; - // capture setTimeout to avoid being caught by fake timers used in time based tests - timeout = setTimeout; - nextTick = typeof setImmediate === 'function' - ? typeof window === 'undefined' - ? setImmediate - : setImmediate.bind(window) - : typeof process === 'object' - ? process.nextTick - : function(task) { timeout(task, 0); }; + // Allow attaching the monitor to when() if env has no console + monitorApi = typeof console != 'undefined' ? console : when; + + // Prefer setImmediate or MessageChannel, cascade to node, + // vertx and finally setTimeout + /*global setImmediate,MessageChannel,process*/ + if (typeof setImmediate === 'function') { + nextTick = setImmediate.bind(global); + } else if(typeof MessageChannel !== 'undefined') { + var channel = new MessageChannel(); + channel.port1.onmessage = drainQueue; + nextTick = function() { channel.port2.postMessage(0); }; + } else if (typeof process === 'object' && process.nextTick) { + nextTick = process.nextTick; + } else { + try { + // vert.x 1.x || 2.x + nextTick = cjsRequire('vertx').runOnLoop || cjsRequire('vertx').runOnContext; + } catch(ignore) { + nextTick = function(t) { setTimeout(t, 0); }; + } + } + + // + // Capture/polyfill function and array utils + // // Safe function calls funcProto = Function.prototype; @@ -926,43 +1091,13 @@ define(function () { return reduced; }; - // - // Utility functions - // - - /** - * Helper that checks arrayOfCallbacks to ensure that each element is either - * a function, or null or undefined. - * @private - * @param {number} start index at which to start checking items in arrayOfCallbacks - * @param {Array} arrayOfCallbacks array to check - * @throws {Error} if any element of arrayOfCallbacks is something other than - * a functions, null, or undefined. - */ - function checkCallbacks(start, arrayOfCallbacks) { - // TODO: Promises/A+ update type checking and docs - var arg, i = arrayOfCallbacks.length; - - while(i > start) { - arg = arrayOfCallbacks[--i]; - - if (arg != null && typeof arg != 'function') { - throw new Error('arg '+i+' must be a function'); - } - } - } - - function noop() {} - function identity(x) { return x; } return when; }); -})( - typeof define === 'function' && define.amd ? define : function (factory) { module.exports = factory(); } -); +})(typeof define === 'function' && define.amd ? define : function (factory) { module.exports = factory(require); }, this); if (typeof module === "object" && typeof require === "function") { var bane = require("bane"); diff --git a/mopidy/frontends/http/data/mopidy.min.js b/mopidy/frontends/http/data/mopidy.min.js index 08ee1dac..75d9fff1 100644 --- a/mopidy/frontends/http/data/mopidy.min.js +++ b/mopidy/frontends/http/data/mopidy.min.js @@ -1,5 +1,5 @@ -/*! Mopidy.js - built 2013-03-31 +/*! Mopidy.js - built 2013-09-17 * http://www.mopidy.com/ * Copyright (c) 2013 Stein Magnus Jodal and contributors * Licensed under the Apache License, Version 2.0 */ -function Mopidy(e){return this instanceof Mopidy?(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(),void 0):new Mopidy(e)}if(("function"==typeof define&&define.amd&&function(e){define(e)}||"object"==typeof module&&function(e){module.exports=e()}||function(e){this.bane=e()})(function(){"use strict";function e(e,t,n){var o,i=n.length;if(i>0)for(o=0;i>o;++o)n[o](e,t);else setTimeout(function(){throw t.message=e+" listener threw error: "+t.message,t},0)}function t(e){if("function"!=typeof e)throw new TypeError("Listener is not function");return e}function n(e){return e.supervisors||(e.supervisors=[]),e.supervisors}function o(e,t){return e.listeners||(e.listeners={}),t&&!e.listeners[t]&&(e.listeners[t]=[]),t?e.listeners[t]:e.listeners}function i(e){return e.errbacks||(e.errbacks=[]),e.errbacks}function r(r){function c(t,n,o){try{n.listener.apply(n.thisp||r,o)}catch(s){e(t,s,i(r))}}return r=r||{},r.on=function(e,i,r){return"function"==typeof e?n(this).push({listener:e,thisp:i}):(o(this,e).push({listener:t(i),thisp:r}),void 0)},r.off=function(e,t){var r,s,c,f;if(!e){r=n(this),r.splice(0,r.length),s=o(this);for(c in s)s.hasOwnProperty(c)&&(r=o(this,c),r.splice(0,r.length));return r=i(this),r.splice(0,r.length),void 0}if("function"==typeof e?(r=n(this),t=e):r=o(this,e),!t)return r.splice(0,r.length),void 0;for(c=0,f=r.length;f>c;++c)if(r[c].listener===t)return r.splice(c,1),void 0},r.once=function(e,t,n){var o=function(){r.off(e,o),t.apply(this,arguments)};r.on(e,o,n)},r.bind=function(e,t){var n,o,i;if(t)for(o=0,i=t.length;i>o;++o){if("function"!=typeof e[t[o]])throw Error("No such method "+t[o]);this.on(t[o],e[t[o]],e)}else for(n in e)"function"==typeof e[n]&&this.on(n,e[n],e);return e},r.emit=function(e){var t,i,r=n(this),f=s.call(arguments);for(t=0,i=r.length;i>t;++t)c(e,r[t],f);for(r=o(this,e).slice(),f=s.call(arguments,1),t=0,i=r.length;i>t;++t)c(e,r[t],f)},r.errback=function(e){this.errbacks||(this.errbacks=[]),this.errbacks.push(t(e))},r}var s=Array.prototype.slice;return{createEventEmitter:r}}),"undefined"!=typeof window&&(window.define=function(e){try{delete window.define}catch(t){window.define=void 0}window.when=e()},window.define.amd={}),function(e){"use strict";e(function(){function e(e,t,o,i){return n(e).then(t,o,i)}function t(e){this.then=e}function n(e){return r(function(t){t(e)})}function o(t){return e(t,f)}function i(){function e(e,r,s){t.resolve=t.resolver.resolve=function(t){return i?n(t):(i=!0,e(t),o)},t.reject=t.resolver.reject=function(e){return i?n(f(e)):(i=!0,r(e),o)},t.notify=t.resolver.notify=function(e){return s(e),e}}var t,o,i;return t={promise:R,resolve:R,reject:R,notify:R,resolver:{resolve:R,reject:R,notify:R}},t.promise=o=r(e),t}function r(e){function n(e,t,n){return r(function(o,i,r){p?p.push(function(s){s.then(e,t,n).then(o,i,r)}):k(function(){h.then(e,t,n).then(o,i,r)})})}function o(e){p&&(h=s(e),a(p,h),p=R)}function i(e){o(f(e))}function c(e){p&&a(p,u(e))}var h,p=[];try{e(o,i,c)}catch(l){i(l)}return new t(n)}function s(e){return e instanceof t?e:e!==Object(e)?c(e):r(function(t,n,o){k(function(){try{var i=e.then;"function"==typeof i?j(i,e,t,n,o):t(c(e))}catch(r){n(r)}})})}function c(e){var n=new t(function(t){try{return"function"==typeof t?s(t(e)):n}catch(o){return f(o)}});return n}function f(e){var n=new t(function(t,o){try{return"function"==typeof o?s(o(e)):n}catch(i){return f(i)}});return n}function u(e){var n=new t(function(t,o,i){try{return"function"==typeof i?u(i(e)):n}catch(r){return u(r)}});return n}function a(e,t){k(function(){for(var n,o=0;n=e[o++];)n(t)})}function h(e){return e&&"function"==typeof e.then}function p(t,n,o,i,s){return m(2,arguments),e(t,function(t){function c(o,i,r){function s(e){l(e)}function c(e){p(e)}var f,u,a,h,p,l,d,y;if(d=t.length>>>0,f=Math.max(0,Math.min(n,d)),a=[],u=d-f+1,h=[],f)for(l=function(e){h.push(e),--u||(p=l=v,i(h))},p=function(e){a.push(e),--f||(p=l=v,o(a))},y=0;d>y;++y)y in t&&e(t[y],c,s,r);else o(a)}return r(c).then(o,i,s)})}function l(e,t,n,o){function i(e){return t?t(e[0]):e[0]}return p(e,1,i,n,o)}function d(e,t,n,o){return m(1,arguments),b(e,M).then(t,n,o)}function y(){return b(arguments,M)}function b(t,n){return e(t,function(t){function o(o,i,r){var s,c,f,u,a;if(f=c=t.length>>>0,s=[],f)for(u=function(t,c){e(t,n).then(function(e){s[c]=e,--f||o(s)},i,r)},a=0;c>a;a++)a in t?u(t[a],a):--f;else o(s)}return r(o)})}function w(t,n){var o=j(E,arguments,1);return e(t,function(t){var i;return i=t.length,o[0]=function(t,o,r){return e(t,function(t){return e(o,function(e){return n(t,e,r,i)})})},S.apply(t,o)})}function k(e){1===W.push(e)&&g()}function g(){D(_)}function _(){for(var e,t=0;e=W[t++];)e();W=[]}function m(e,t){for(var n,o=t.length;o>e;)if(n=t[--o],null!=n&&"function"!=typeof n)throw Error("arg "+o+" must be a function")}function v(){}function M(e){return e}e.defer=i,e.resolve=n,e.reject=o,e.join=y,e.all=d,e.map=b,e.reduce=w,e.any=l,e.some=p,e.isPromise=h,t.prototype={otherwise:function(e){return this.then(R,e)},ensure:function(e){function t(){return n(e())}var o=this;return this.then(t,t).yield(o)},yield:function(e){return this.then(function(){return e})},spread:function(e){return this.then(function(t){return d(t,function(t){return e.apply(R,t)})})},always:function(e,t){return this.then(e,e,t)}};var S,E,j,D,W,O,q,C,x,R;return W=[],O=setTimeout,D="function"==typeof setImmediate?"undefined"==typeof window?setImmediate:setImmediate.bind(window):"object"==typeof process?process.nextTick:function(e){O(e,0)},q=Function.prototype,C=q.call,j=q.bind?C.bind(C):function(e,t){return e.apply(t,E.call(arguments,2))},x=[],E=x.slice,S=x.reduce||function(e){var t,n,o,i,r;if(r=0,t=Object(this),i=t.length>>>0,n=arguments,1>=n.length)for(;;){if(r in t){o=t[r++];break}if(++r>=i)throw new TypeError}else o=n[1];for(;i>r;++r)r in t&&(o=e(o,t[r],r,t));return o},e})}("function"==typeof define&&define.amd?define:function(e){module.exports=e()}),"object"==typeof module&&"function"==typeof require)var bane=require("bane"),websocket=require("faye-websocket"),when=require("when");Mopidy.WebSocket="object"==typeof module&&"function"==typeof require?websocket.Client:window.WebSocket,Mopidy.prototype._configure=function(e){var t="undefined"!=typeof document&&document.location.host||"localhost";return e.webSocketUrl=e.webSocketUrl||"ws://"+t+"/mopidy/ws/",e.autoConnect!==!1&&(e.autoConnect=!0),e.backoffDelayMin=e.backoffDelayMin||1e3,e.backoffDelayMax=e.backoffDelayMax||64e3,e},Mopidy.prototype._getConsole=function(){var e=e!==void 0&&e||{};return e.log=e.log||function(){},e.warn=e.warn||function(){},e.error=e.error||function(){},e},Mopidy.prototype._delegateEvents=function(){this.off("websocket:close"),this.off("websocket:error"),this.off("websocket:incomingMessage"),this.off("websocket:open"),this.off("state:offline"),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===Mopidy.WebSocket.OPEN)return;this._webSocket.close()}this._webSocket=this._settings.webSocket||new Mopidy.WebSocket(this._settings.webSocketUrl),this._webSocket.onclose=function(e){this.emit("websocket:close",e)}.bind(this),this._webSocket.onerror=function(e){this.emit("websocket:error",e)}.bind(this),this._webSocket.onopen=function(){this.emit("websocket:open")}.bind(this),this._webSocket.onmessage=function(e){this.emit("websocket:incomingMessage",e)}.bind(this)},Mopidy.prototype._cleanup=function(e){Object.keys(this._pendingRequests).forEach(function(t){var n=this._pendingRequests[t];delete this._pendingRequests[t],n.reject({message:"WebSocket closed",closeEvent:e})}.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=2*this._backoffDelay,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(e){this._console.warn("WebSocket error:",e.stack||e)},Mopidy.prototype._send=function(e){var t=when.defer();switch(this._webSocket.readyState){case Mopidy.WebSocket.CONNECTING:t.resolver.reject({message:"WebSocket is still connecting"});break;case Mopidy.WebSocket.CLOSING:t.resolver.reject({message:"WebSocket is closing"});break;case Mopidy.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}}(),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))return this._console.warn("Unexpected response received. Message was:",e),void 0;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(){return 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&&"core"===t[0]&&(t=t.slice(1)),t},o=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 r=n(i),s=this._snakeToCamel(r.slice(-1)[0]),c=o(r.slice(0,-1));c[s]=t(i),c[s].description=e[i].description,c[s].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("_","")})},"object"==typeof exports&&(exports.Mopidy=Mopidy); \ No newline at end of file +function Mopidy(a){return this instanceof Mopidy?(this._settings=this._configure(a||{}),this._console=this._getConsole(),this._backoffDelay=this._settings.backoffDelayMin,this._pendingRequests={},this._webSocket=null,bane.createEventEmitter(this),this._delegateEvents(),this._settings.autoConnect&&this.connect(),void 0):new Mopidy(a)}if(("function"==typeof define&&define.amd&&function(a){define("bane",a)}||"object"==typeof module&&function(a){module.exports=a()}||function(a){this.bane=a()})(function(){"use strict";function a(a,b,c){var d,e=c.length;if(e>0)for(d=0;e>d;++d)c[d](a,b);else setTimeout(function(){throw b.message=a+" listener threw error: "+b.message,b},0)}function b(a){if("function"!=typeof a)throw new TypeError("Listener is not function");return a}function c(a){return a.supervisors||(a.supervisors=[]),a.supervisors}function d(a,b){return a.listeners||(a.listeners={}),b&&!a.listeners[b]&&(a.listeners[b]=[]),b?a.listeners[b]:a.listeners}function e(a){return a.errbacks||(a.errbacks=[]),a.errbacks}function f(f){function h(b,c,d){try{c.listener.apply(c.thisp||f,d)}catch(g){a(b,g,e(f))}}return f=f||{},f.on=function(a,e,f){return"function"==typeof a?c(this).push({listener:a,thisp:e}):(d(this,a).push({listener:b(e),thisp:f}),void 0)},f.off=function(a,b){var f,g,h,i;if(!a){f=c(this),f.splice(0,f.length),g=d(this);for(h in g)g.hasOwnProperty(h)&&(f=d(this,h),f.splice(0,f.length));return f=e(this),f.splice(0,f.length),void 0}if("function"==typeof a?(f=c(this),b=a):f=d(this,a),!b)return f.splice(0,f.length),void 0;for(h=0,i=f.length;i>h;++h)if(f[h].listener===b)return f.splice(h,1),void 0},f.once=function(a,b,c){var d=function(){f.off(a,d),b.apply(this,arguments)};f.on(a,d,c)},f.bind=function(a,b){var c,d,e;if(b)for(d=0,e=b.length;e>d;++d){if("function"!=typeof a[b[d]])throw new Error("No such method "+b[d]);this.on(b[d],a[b[d]],a)}else for(c in a)"function"==typeof a[c]&&this.on(c,a[c],a);return a},f.emit=function(a){var b,e,f=c(this),i=g.call(arguments);for(b=0,e=f.length;e>b;++b)h(a,f[b],i);for(f=d(this,a).slice(),i=g.call(arguments,1),b=0,e=f.length;e>b;++b)h(a,f[b],i)},f.errback=function(a){this.errbacks||(this.errbacks=[]),this.errbacks.push(b(a))},f}var g=Array.prototype.slice;return{createEventEmitter:f}}),"undefined"!=typeof window&&(window.define=function(a){try{delete window.define}catch(b){window.define=void 0}window.when=a()},window.define.amd={}),function(a,b){"use strict";a(function(a){function c(a,b,c,d){return e(a).then(b,c,d)}function d(a,b){this._message=a,this.inspect=b}function e(a){return h(function(b){b(a)})}function f(a){return c(a,k)}function g(){function a(a,f,g){b.resolve=b.resolver.resolve=function(b){return d?e(b):(d=!0,a(b),c)},b.reject=b.resolver.reject=function(a){return d?e(k(a)):(d=!0,f(a),c)},b.notify=b.resolver.notify=function(a){return g(a),a}}var b,c,d;return b={promise:S,resolve:S,reject:S,notify:S,resolver:{resolve:S,reject:S,notify:S}},b.promise=c=h(a),b}function h(a){return i(a,Q.PromiseStatus&&Q.PromiseStatus())}function i(a,b){function c(a,b,c,d){function e(e){e._message(a,b,c,d)}l?l.push(e):E(function(){e(j)})}function e(){return j?j.inspect():D()}function f(a){l&&(j=n(a),q(l,j),l=S,b&&r(j,b))}function g(a){f(k(a))}function h(a){l&&q(l,m(a))}var i,j,l=[];i=new d(c,e),i._status=b;try{a(f,g,h)}catch(o){g(o)}return i}function j(a){return l(new o(a),function(){return B(a)})}function k(a){return l(new p(a),function(){return C(a)})}function l(a,b){return new d(function(b,c,d){try{d(a[b].apply(a,c))}catch(e){d(k(e))}},b)}function m(a){return new d(function(b,c,d,e){var f=c[2];try{e("function"==typeof f?f(a):a)}catch(g){e(g)}})}function n(a){return a instanceof d?a:a===Object(a)&&"then"in a?h(function(b,c,d){E(function(){try{var e=a.then;"function"==typeof e?J(e,a,b,c,d):b(j(a))}catch(f){c(f)}})}):j(a)}function o(a){this.value=a}function p(a){this.reason=a}function q(a,b){E(function(){for(var c,d=0;c=a[d++];)c(b)})}function r(a,b){function c(){b.fulfilled()}function d(a){b.rejected(a)}a.then(c,d)}function s(a){return a&&"function"==typeof a.then}function t(a,b,d,e,f){return c(a,function(a){function g(d,e,f){function g(a){n(a)}function h(a){m(a)}var i,j,k,l,m,n,o,p;if(o=a.length>>>0,i=Math.max(0,Math.min(b,o)),k=[],j=o-i+1,l=[],i)for(n=function(a){l.push(a),--j||(m=n=G,e(l))},m=function(a){k.push(a),--i||(m=n=G,d(k))},p=0;o>p;++p)p in a&&c(a[p],h,g,f);else d(k)}return h(g).then(d,e,f)})}function u(a,b,c,d){function e(a){return b?b(a[0]):a[0]}return t(a,1,e,c,d)}function v(a,b,c,d){return z(a,G).then(b,c,d)}function w(){return z(arguments,G)}function x(a){return z(a,B,C)}function y(a,b){return z(a,b)}function z(a,b,d){return c(a,function(a){function e(e,f,g){function h(a,h){c(a,b,d).then(function(a){i[h]=a,g(a),--k||e(i)},f)}var i,j,k,l;if(k=j=a.length>>>0,i=[],!k)return e(i),void 0;for(l=0;j>l;l++)l in a?h(a[l],l):--k}return i(e)})}function A(a,b){var d=J(I,arguments,1);return c(a,function(a){var e;return e=a.length,d[0]=function(a,d,f){return c(a,function(a){return c(d,function(c){return b(a,c,f,e)})})},H.apply(a,d)})}function B(a){return{state:"fulfilled",value:a}}function C(a){return{state:"rejected",reason:a}}function D(){return{state:"pending"}}function E(a){1===L.push(a)&&K(F)}function F(){for(var a,b=0;a=L[b++];)a();L=[]}function G(a){return a}c.promise=h,c.resolve=e,c.reject=f,c.defer=g,c.join=w,c.all=v,c.map=y,c.reduce=A,c.settle=x,c.any=u,c.some=t,c.isPromise=s,c.isPromiseLike=s,d.prototype={then:function(){var a,b;return a=arguments,b=this._message,i(function(c,d,e){b("when",a,c,e)},this._status&&this._status.observed())},otherwise:function(a){return this.then(S,a)},ensure:function(a){function b(){return e(a())}return this.then(b,b).yield(this)},yield:function(a){return this.then(function(){return a})},tap:function(a){return this.then(a).yield(this)},spread:function(a){return this.then(function(b){return v(b,function(b){return a.apply(S,b)})})},always:function(a,b){return this.then(a,a,b)}},o.prototype.when=function(a){return"function"==typeof a?a(this.value):this.value},p.prototype.when=function(a,b){if("function"==typeof b)return b(this.reason);throw this.reason};var H,I,J,K,L,M,N,O,P,Q,R,S;if(R=a,L=[],M=b.setTimeout,Q="undefined"!=typeof console?console:c,"function"==typeof setImmediate)K=setImmediate.bind(b);else if("undefined"!=typeof MessageChannel){var T=new MessageChannel;T.port1.onmessage=F,K=function(){T.port2.postMessage(0)}}else if("object"==typeof process&&process.nextTick)K=process.nextTick;else try{K=R("vertx").runOnLoop||R("vertx").runOnContext}catch(U){K=function(a){M(a,0)}}return N=Function.prototype,O=N.call,J=N.bind?O.bind(O):function(a,b){return a.apply(b,I.call(arguments,2))},P=[],I=P.slice,H=P.reduce||function(a){var b,c,d,e,f;if(f=0,b=Object(this),e=b.length>>>0,c=arguments,c.length<=1)for(;;){if(f in b){d=b[f++];break}if(++f>=e)throw new TypeError}else d=c[1];for(;e>f;++f)f in b&&(d=a(d,b[f],f,b));return d},c})}("function"==typeof define&&define.amd?define:function(a){module.exports=a(require)},this),"object"==typeof module&&"function"==typeof require)var bane=require("bane"),websocket=require("faye-websocket"),when=require("when");Mopidy.WebSocket="object"==typeof module&&"function"==typeof require?websocket.Client:window.WebSocket,Mopidy.prototype._configure=function(a){var b="undefined"!=typeof document&&document.location.host||"localhost";return a.webSocketUrl=a.webSocketUrl||"ws://"+b+"/mopidy/ws/",a.autoConnect!==!1&&(a.autoConnect=!0),a.backoffDelayMin=a.backoffDelayMin||1e3,a.backoffDelayMax=a.backoffDelayMax||64e3,a},Mopidy.prototype._getConsole=function(){var a="undefined"!=typeof a&&a||{};return a.log=a.log||function(){},a.warn=a.warn||function(){},a.error=a.error||function(){},a},Mopidy.prototype._delegateEvents=function(){this.off("websocket:close"),this.off("websocket:error"),this.off("websocket:incomingMessage"),this.off("websocket:open"),this.off("state:offline"),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===Mopidy.WebSocket.OPEN)return;this._webSocket.close()}this._webSocket=this._settings.webSocket||new Mopidy.WebSocket(this._settings.webSocketUrl),this._webSocket.onclose=function(a){this.emit("websocket:close",a)}.bind(this),this._webSocket.onerror=function(a){this.emit("websocket:error",a)}.bind(this),this._webSocket.onopen=function(){this.emit("websocket:open")}.bind(this),this._webSocket.onmessage=function(a){this.emit("websocket:incomingMessage",a)}.bind(this)},Mopidy.prototype._cleanup=function(a){Object.keys(this._pendingRequests).forEach(function(b){var c=this._pendingRequests[b];delete this._pendingRequests[b],c.reject({message:"WebSocket closed",closeEvent:a})}.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=2*this._backoffDelay,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(a){this._console.warn("WebSocket error:",a.stack||a)},Mopidy.prototype._send=function(a){var b=when.defer();switch(this._webSocket.readyState){case Mopidy.WebSocket.CONNECTING:b.resolver.reject({message:"WebSocket is still connecting"});break;case Mopidy.WebSocket.CLOSING:b.resolver.reject({message:"WebSocket is closing"});break;case Mopidy.WebSocket.CLOSED:b.resolver.reject({message:"WebSocket is closed"});break;default:a.jsonrpc="2.0",a.id=this._nextRequestId(),this._pendingRequests[a.id]=b.resolver,this._webSocket.send(JSON.stringify(a)),this.emit("websocket:outgoingMessage",a)}return b.promise},Mopidy.prototype._nextRequestId=function(){var a=-1;return function(){return a+=1}}(),Mopidy.prototype._handleMessage=function(a){try{var b=JSON.parse(a.data);b.hasOwnProperty("id")?this._handleResponse(b):b.hasOwnProperty("event")?this._handleEvent(b):this._console.warn("Unknown message type received. Message was: "+a.data)}catch(c){if(!(c instanceof SyntaxError))throw c;this._console.warn("WebSocket message parsing failed. Message was: "+a.data)}},Mopidy.prototype._handleResponse=function(a){if(!this._pendingRequests.hasOwnProperty(a.id))return this._console.warn("Unexpected response received. Message was:",a),void 0;var b=this._pendingRequests[a.id];delete this._pendingRequests[a.id],a.hasOwnProperty("result")?b.resolve(a.result):a.hasOwnProperty("error")?(b.reject(a.error),this._console.warn("Server returned error:",a.error)):(b.reject({message:"Response without 'result' or 'error' received",data:{response:a}}),this._console.warn("Response without 'result' or 'error' received. Message was:",a))},Mopidy.prototype._handleEvent=function(a){var b=a.event,c=a;delete c.event,this.emit("event:"+this._snakeToCamel(b),c)},Mopidy.prototype._getApiSpec=function(){return this._send({method:"core.describe"}).then(this._createApi.bind(this),this._handleWebSocketError).then(null,this._handleWebSocketError)},Mopidy.prototype._createApi=function(a){var b=function(a){return function(){var b=Array.prototype.slice.call(arguments);return this._send({method:a,params:b})}.bind(this)}.bind(this),c=function(a){var b=a.split(".");return b.length>=1&&"core"===b[0]&&(b=b.slice(1)),b},d=function(a){var b=this;return a.forEach(function(a){a=this._snakeToCamel(a),b[a]=b[a]||{},b=b[a]}.bind(this)),b}.bind(this),e=function(e){var f=c(e),g=this._snakeToCamel(f.slice(-1)[0]),h=d(f.slice(0,-1));h[g]=b(e),h[g].description=a[e].description,h[g].params=a[e].params}.bind(this);Object.keys(a).forEach(e),this.emit("state:online")},Mopidy.prototype._snakeToCamel=function(a){return a.replace(/(_[a-z])/g,function(a){return a.toUpperCase().replace("_","")})},"object"==typeof exports&&(exports.Mopidy=Mopidy); \ No newline at end of file diff --git a/mopidy/frontends/mpd/dispatcher.py b/mopidy/frontends/mpd/dispatcher.py index 6590897d..ec3b71f8 100644 --- a/mopidy/frontends/mpd/dispatcher.py +++ b/mopidy/frontends/mpd/dispatcher.py @@ -236,6 +236,8 @@ class MpdContext(object): #: The subsytems that we want to be notified about in idle mode. subscriptions = None + _invalid_playlist_chars = re.compile(r'[\n\r/]') + def __init__(self, dispatcher, session=None, config=None, core=None): self.dispatcher = dispatcher self.session = session @@ -248,10 +250,11 @@ class MpdContext(object): self.refresh_playlists_mapping() def create_unique_name(self, playlist_name): - name = playlist_name + stripped_name = self._invalid_playlist_chars.sub(' ', playlist_name) + name = stripped_name i = 2 while name in self._playlist_uri_from_name: - name = '%s [%d]' % (playlist_name, i) + name = '%s [%d]' % (stripped_name, i) i += 1 return name @@ -266,6 +269,7 @@ class MpdContext(object): for playlist in self.core.playlists.playlists.get(): if not playlist.name: continue + # TODO: add scheme to name perhaps 'foo (spotify)' etc. name = self.create_unique_name(playlist.name) self._playlist_uri_from_name[name] = playlist.uri self._playlist_name_from_uri[playlist.uri] = name diff --git a/mopidy/frontends/mpd/protocol/__init__.py b/mopidy/frontends/mpd/protocol/__init__.py index 1827624b..0eadea7d 100644 --- a/mopidy/frontends/mpd/protocol/__init__.py +++ b/mopidy/frontends/mpd/protocol/__init__.py @@ -72,9 +72,7 @@ def load_protocol_modules(): The protocol modules must be imported to get them registered in :attr:`request_handlers` and :attr:`mpd_commands`. """ - # pylint: disable = W0612 from . import ( # noqa audio_output, channels, command_list, connection, current_playlist, empty, music_db, playback, reflection, status, stickers, stored_playlists) - # pylint: enable = W0612 diff --git a/mopidy/frontends/mpd/protocol/current_playlist.py b/mopidy/frontends/mpd/protocol/current_playlist.py index 055d39e6..20452203 100644 --- a/mopidy/frontends/mpd/protocol/current_playlist.py +++ b/mopidy/frontends/mpd/protocol/current_playlist.py @@ -101,9 +101,6 @@ def deleteid(context, tlid): Deletes the song ``SONGID`` from the playlist """ tlid = int(tlid) - tl_track = context.core.playback.current_tl_track.get() - if tl_track and tl_track.tlid == tlid: - context.core.playback.next() tl_tracks = context.core.tracklist.remove(tlid=tlid).get() if not tl_tracks: raise MpdNoExistError('No such song', command='deleteid') diff --git a/mopidy/frontends/mpd/protocol/music_db.py b/mopidy/frontends/mpd/protocol/music_db.py index ff79c33a..f81d57ee 100644 --- a/mopidy/frontends/mpd/protocol/music_db.py +++ b/mopidy/frontends/mpd/protocol/music_db.py @@ -39,8 +39,8 @@ def _artist_as_track(artist): artists=[artist]) -@handle_request(r'^count "(?P[^"]+)" "(?P[^"]*)"$') -def count(context, tag, needle): +@handle_request(r'^count ' + QUERY_RE) +def count(context, mpd_query): """ *musicpd.org, music database section:* @@ -48,6 +48,11 @@ def count(context, tag, needle): Counts the number of songs and their total playtime in the db matching ``TAG`` exactly. + + *GMPC:* + + - does not add quotes around the tag argument. + - use multiple tag-needle pairs to make more specific searches. """ return [('songs', 0), ('playtime', 0)] # TODO @@ -240,8 +245,9 @@ def _list_date(context, query): return dates -@handle_request(r'^listall "(?P[^"]+)"') -def listall(context, uri): +@handle_request(r'^listall$') +@handle_request(r'^listall "(?P[^"]+)"$') +def listall(context, uri=None): """ *musicpd.org, music database section:* @@ -252,8 +258,9 @@ def listall(context, uri): raise MpdNotImplemented # TODO -@handle_request(r'^listallinfo "(?P[^"]+)"') -def listallinfo(context, uri): +@handle_request(r'^listallinfo$') +@handle_request(r'^listallinfo "(?P[^"]+)"$') +def listallinfo(context, uri=None): """ *musicpd.org, music database section:* diff --git a/mopidy/frontends/mpd/translator.py b/mopidy/frontends/mpd/translator.py index 8e9d12e0..804f693a 100644 --- a/mopidy/frontends/mpd/translator.py +++ b/mopidy/frontends/mpd/translator.py @@ -156,14 +156,14 @@ def query_from_mpd_list_format(field, mpd_query): if field == 'album': if not tokens[0]: raise ValueError - return {'artist': [tokens[0]]} # See above NOTE + return {'artist': [tokens[0]]} else: raise MpdArgError( 'should be "Album" for 3 arguments', command='list') elif len(tokens) % 2 == 0: query = {} while tokens: - key = str(tokens[0].lower()) # See above NOTE + key = tokens[0].lower() value = tokens[1] tokens = tokens[2:] if key not in ('artist', 'album', 'date', 'genre'): diff --git a/mopidy/frontends/mpris/actor.py b/mopidy/frontends/mpris/actor.py index fae8618f..d44e9262 100644 --- a/mopidy/frontends/mpris/actor.py +++ b/mopidy/frontends/mpris/actor.py @@ -34,7 +34,7 @@ class MprisFrontend(pykka.ThreadingActor, CoreListener): self.mpris_object = objects.MprisObject(self.config, self.core) self._send_startup_notification() except Exception as e: - logger.error('MPRIS frontend setup failed (%s)', e) + logger.warning('MPRIS frontend setup failed (%s)', e) self.stop() def on_stop(self): diff --git a/mopidy/models.py b/mopidy/models.py index fe390ddf..3fc92bb4 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -90,7 +90,7 @@ class ImmutableObject(object): for v in value] elif isinstance(value, ImmutableObject): value = value.serialize() - if value: + if not (isinstance(value, list) and len(value) == 0): data[public_key] = value return data diff --git a/mopidy/scanner.py b/mopidy/scanner.py index 77085f90..81ac5c53 100644 --- a/mopidy/scanner.py +++ b/mopidy/scanner.py @@ -27,7 +27,6 @@ pygst.require('0.10') import gst from mopidy import config as config_lib, ext -from mopidy.audio import dummy as dummy_audio from mopidy.models import Track, Artist, Album from mopidy.utils import log, path, versioning @@ -45,21 +44,36 @@ def main(): log.setup_root_logger() log.setup_console_logging(logging_config, args.verbosity_level) - extensions = dict((e.ext_name, e) for e in ext.load_extensions()) + extensions = ext.load_extensions() config, errors = config_lib.load( - config_files, extensions.values(), config_overrides) + config_files, extensions, config_overrides) log.setup_log_levels(config) if not config['local']['media_dir']: logging.warning('Config value local/media_dir is not set.') return + if not config['local']['scan_timeout']: + logging.warning('Config value local/scan_timeout is not set.') + return + # TODO: missing config error checking and other default setup code. - audio = dummy_audio.DummyAudio() - local_backend_classes = extensions['local'].get_backend_classes() - local_backend = local_backend_classes[0](config, audio) - local_updater = local_backend.updater + updaters = {} + for e in extensions: + for updater_class in e.get_library_updaters(): + if updater_class and 'local' in updater_class.uri_schemes: + updaters[e.ext_name] = updater_class + + if not updaters: + logging.error('No usable library updaters found.') + return + elif len(updaters) > 1: + logging.error('More than one library updater found. ' + 'Provided by: %s', ', '.join(updaters.keys())) + return + + local_updater = updaters.values()[0](config) # TODO: switch to actor? media_dir = config['local']['media_dir'] @@ -97,9 +111,11 @@ def main(): logging.warning('Failed %s: %s', uri, error) logging.debug('Debug info for %s: %s', uri, debug) + scan_timeout = config['local']['scan_timeout'] + logging.info('Scanning new and modified tracks.') # TODO: just pass the library in instead? - scanner = Scanner(uris_update, store, debug) + scanner = Scanner(uris_update, store, debug, scan_timeout) try: scanner.start() except KeyboardInterrupt: @@ -139,6 +155,7 @@ def translator(data): _retrieve(gst.TAG_ALBUM, 'name', album_kwargs) _retrieve(gst.TAG_TRACK_COUNT, 'num_tracks', album_kwargs) + _retrieve(gst.TAG_ALBUM_VOLUME_COUNT, 'num_discs', album_kwargs) _retrieve(gst.TAG_ARTIST, 'name', artist_kwargs) if gst.TAG_DATE in data and data[gst.TAG_DATE]: @@ -152,6 +169,7 @@ def translator(data): _retrieve(gst.TAG_TITLE, 'name', track_kwargs) _retrieve(gst.TAG_TRACK_NUMBER, 'track_no', track_kwargs) + _retrieve(gst.TAG_ALBUM_VOLUME_NUMBER, 'disc_no', track_kwargs) # Following keys don't seem to have TAG_* constant. _retrieve('album-artist', 'name', albumartist_kwargs) @@ -174,12 +192,15 @@ def translator(data): class Scanner(object): - def __init__(self, uris, data_callback, error_callback=None): + def __init__( + self, uris, data_callback, error_callback=None, scan_timeout=1000): self.data = {} self.uris = iter(uris) self.data_callback = data_callback self.error_callback = error_callback + self.scan_timeout = scan_timeout self.loop = gobject.MainLoop() + self.timeout_id = None self.fakesink = gst.element_factory_make('fakesink') self.fakesink.set_property('signal-handoffs', True) @@ -250,6 +271,14 @@ class Scanner(object): self.error_callback(uri, error, debug) self.next_uri() + def process_timeout(self): + if self.error_callback: + uri = self.uribin.get_property('uri') + self.error_callback( + uri, 'Scan timed out after %d ms' % self.scan_timeout, None) + self.next_uri() + return False + def get_duration(self): self.pipe.get_state() # Block until state change is done. try: @@ -260,6 +289,9 @@ class Scanner(object): def next_uri(self): self.data = {} + if self.timeout_id: + gobject.source_remove(self.timeout_id) + self.timeout_id = None try: uri = next(self.uris) except StopIteration: @@ -267,6 +299,8 @@ class Scanner(object): return False self.pipe.set_state(gst.STATE_NULL) self.uribin.set_property('uri', uri) + self.timeout_id = gobject.timeout_add( + self.scan_timeout, self.process_timeout) self.pipe.set_state(gst.STATE_PLAYING) return True diff --git a/mopidy/utils/path.py b/mopidy/utils/path.py index e4d717d1..c5aa6e45 100644 --- a/mopidy/utils/path.py +++ b/mopidy/utils/path.py @@ -2,12 +2,9 @@ from __future__ import unicode_literals import logging import os -import re -# pylint: disable = W0402 import string -# pylint: enable = W0402 -import sys import urllib +import urlparse import glib @@ -51,7 +48,7 @@ def get_or_create_file(file_path): return file_path -def path_to_uri(*paths): +def path_to_uri(path): """ Convert OS specific path to file:// URI. @@ -61,17 +58,15 @@ def path_to_uri(*paths): Returns a file:// URI as an unicode string. """ - path = os.path.join(*paths) if isinstance(path, unicode): path = path.encode('utf-8') - if sys.platform == 'win32': - return 'file:' + urllib.quote(path) - return 'file://' + urllib.quote(path) + path = urllib.quote(path) + return urlparse.urlunsplit((b'file', b'', path, b'', b'')) def uri_to_path(uri): """ - Convert the file:// to a OS specific path. + Convert an URI to a OS specific path. Returns a bytestring, since the file path can contain chars with other encoding than UTF-8. @@ -82,10 +77,7 @@ def uri_to_path(uri): """ if isinstance(uri, unicode): uri = uri.encode('utf-8') - if sys.platform == 'win32': - return urllib.unquote(re.sub(b'^file:', b'', uri)) - else: - return urllib.unquote(re.sub(b'^file://', b'', uri)) + return urllib.unquote(urlparse.urlsplit(uri).path) def split_path(path): diff --git a/mopidy/utils/versioning.py b/mopidy/utils/versioning.py index 3ad72458..e8856473 100644 --- a/mopidy/utils/versioning.py +++ b/mopidy/utils/versioning.py @@ -14,11 +14,9 @@ def get_version(): def get_git_version(): process = Popen(['git', 'describe'], stdout=PIPE, stderr=PIPE) - # pylint: disable = E1101 if process.wait() != 0: raise EnvironmentError('Execution of "git describe" failed') version = process.stdout.read().strip() - # pylint: enable = E1101 if version.startswith('v'): version = version[1:] return version diff --git a/pylintrc b/pylintrc deleted file mode 100644 index 41e1ab5d..00000000 --- a/pylintrc +++ /dev/null @@ -1,21 +0,0 @@ -[MESSAGES CONTROL] -# -# Disabled messages -# ----------------- -# -# C0103 - Invalid name "%s" (should match %s) -# C0111 - Missing docstring -# R0201 - Method could be a function -# R0801 - Similar lines in %s files -# R0902 - Too many instance attributes (%s/%s) -# R0903 - Too few public methods (%s/%s) -# R0904 - Too many public methods (%s/%s) -# R0912 - Too many branches (%s/%s) -# R0913 - Too many arguments (%s/%s) -# R0921 - Abstract class not referenced -# W0141 - Used builtin function '%s' -# W0142 - Used * or ** magic -# W0511 - TODO, FIXME and XXX in the code -# W0613 - Unused argument %r -# -disable = C0103,C0111,R0201,R0801,R0902,R0903,R0904,R0912,R0913,R0921,W0141,W0142,W0511,W0613 diff --git a/requirements/external_mixers.txt b/requirements/external_mixers.txt deleted file mode 100644 index 20cb7864..00000000 --- a/requirements/external_mixers.txt +++ /dev/null @@ -1,2 +0,0 @@ -pyserial -# Available as python-serial in Debian/Ubuntu diff --git a/requirements/http.txt b/requirements/http.txt index aea7c1a8..f38bfa3c 100644 --- a/requirements/http.txt +++ b/requirements/http.txt @@ -2,4 +2,5 @@ cherrypy >= 3.2.2 # Available as python-cherrypy3 in Debian/Ubuntu ws4py >= 0.2.3 -# Available as python-ws4py from apt.mopidy.com +# Available as python-ws4py in newer Debian/Ubuntu and from apt.mopidy.com for +# older releases of Debian/Ubuntu diff --git a/requirements/spotify.txt b/requirements/spotify.txt index 333e55c8..d11a5c04 100644 --- a/requirements/spotify.txt +++ b/requirements/spotify.txt @@ -1,4 +1,4 @@ -pyspotify >= 1.9, < 1.11 +pyspotify >= 1.9, < 2 # The libspotify Python wrapper # Available as the python-spotify package from apt.mopidy.com diff --git a/requirements/tests.txt b/requirements/tests.txt index c093682b..8aacebbc 100644 --- a/requirements/tests.txt +++ b/requirements/tests.txt @@ -2,4 +2,3 @@ coverage flake8 mock >= 1.0 nose -pylint diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index bce0a6e2..00000000 --- a/setup.cfg +++ /dev/null @@ -1,6 +0,0 @@ -[nosetests] -verbosity = 1 -#with-coverage = 1 -cover-package = mopidy -cover-inclusive = 1 -cover-html = 1 diff --git a/setup.py b/setup.py index 8e02d53d..c5eea724 100644 --- a/setup.py +++ b/setup.py @@ -28,7 +28,7 @@ setup( 'Pykka >= 1.1', ], extras_require={ - 'spotify': ['pyspotify >= 1.9, < 1.11'], + 'spotify': ['pyspotify >= 1.9, < 2'], 'scrobbler': ['pylast >= 0.5.7'], 'http': ['cherrypy >= 3.2.2', 'ws4py >= 0.2.3'], }, @@ -36,7 +36,6 @@ setup( tests_require=[ 'nose', 'mock >= 1.0', - 'unittest2', ], entry_points={ 'console_scripts': [ @@ -61,7 +60,6 @@ setup( 'License :: OSI Approved :: Apache Software License', 'Operating System :: MacOS :: MacOS X', 'Operating System :: POSIX :: Linux', - 'Programming Language :: Python :: 2.6', 'Programming Language :: Python :: 2.7', 'Topic :: Multimedia :: Sound/Audio :: Players', ], diff --git a/tests/__init__.py b/tests/__init__.py index b358f32b..a384669e 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,12 +1,11 @@ from __future__ import unicode_literals import os -import sys def path_to_data_dir(name): if not isinstance(name, bytes): - name = name.encode(sys.getfilesystemencoding()) + name = name.encode('utf-8') path = os.path.dirname(__file__) path = os.path.join(path, b'data') path = os.path.abspath(path) diff --git a/tests/audio/actor_test.py b/tests/audio/actor_test.py index c311bdc3..617131cc 100644 --- a/tests/audio/actor_test.py +++ b/tests/audio/actor_test.py @@ -21,6 +21,7 @@ class AudioTest(unittest.TestCase): 'mixer': 'fakemixer track_max_volume=65536', 'mixer_track': None, 'output': 'fakesink', + 'visualizer': None, } } self.song_uri = path_to_uri(path_to_data_dir('song1.wav')) @@ -70,6 +71,7 @@ class AudioTest(unittest.TestCase): 'mixer': 'fakemixer track_max_volume=40', 'mixer_track': None, 'output': 'fakesink', + 'visualizer': None, } } self.audio = audio.Audio.start(config=config).proxy() diff --git a/tests/backends/base/library.py b/tests/backends/base/library.py index 4bc525c8..23c76f38 100644 --- a/tests/backends/base/library.py +++ b/tests/backends/base/library.py @@ -7,8 +7,6 @@ import pykka from mopidy import core from mopidy.models import Track, Album, Artist -from tests import path_to_data_dir - class LibraryControllerTest(object): artists = [Artist(name='artist1'), Artist(name='artist2'), Artist()] @@ -17,13 +15,10 @@ class LibraryControllerTest(object): Album(name='album2', artists=artists[1:2]), Album()] tracks = [ - Track( - uri='file://' + path_to_data_dir('uri1'), name='track1', - artists=artists[:1], album=albums[0], date='2001-02-03', - length=4000), - Track( - uri='file://' + path_to_data_dir('uri2'), name='track2', - artists=artists[1:2], album=albums[1], date='2002', length=4000), + Track(uri='local:track:path1', name='track1', artists=artists[:1], + album=albums[0], date='2001-02-03', length=4000), + Track(uri='local:track:path2', name='track2', artists=artists[1:2], + album=albums[1], date='2002', length=4000), Track()] config = {} @@ -66,11 +61,11 @@ class LibraryControllerTest(object): self.assertEqual(list(result[0].tracks), []) def test_find_exact_uri(self): - track_1_uri = 'file://' + path_to_data_dir('uri1') + track_1_uri = 'local:track:path1' result = self.library.find_exact(uri=track_1_uri) self.assertEqual(list(result[0].tracks), self.tracks[:1]) - track_2_uri = 'file://' + path_to_data_dir('uri2') + track_2_uri = 'local:track:path2' result = self.library.find_exact(uri=track_2_uri) self.assertEqual(list(result[0].tracks), self.tracks[1:2]) @@ -136,10 +131,10 @@ class LibraryControllerTest(object): self.assertEqual(list(result[0].tracks), []) def test_search_uri(self): - result = self.library.search(uri=['RI1']) + result = self.library.search(uri=['TH1']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) - result = self.library.search(uri=['RI2']) + result = self.library.search(uri=['TH2']) self.assertEqual(list(result[0].tracks), self.tracks[1:2]) def test_search_track(self): @@ -183,7 +178,7 @@ class LibraryControllerTest(object): self.assertEqual(list(result[0].tracks), self.tracks[:1]) result = self.library.search(any=['Bum1']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) - result = self.library.search(any=['RI1']) + result = self.library.search(any=['TH1']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) def test_search_wrong_type(self): diff --git a/tests/backends/local/__init__.py b/tests/backends/local/__init__.py index 684e12d8..1738722f 100644 --- a/tests/backends/local/__init__.py +++ b/tests/backends/local/__init__.py @@ -1,9 +1,4 @@ from __future__ import unicode_literals -from mopidy.utils.path import path_to_uri -from tests import path_to_data_dir - - -song = path_to_data_dir('song%s.wav') -generate_song = lambda i: path_to_uri(song % i) +generate_song = lambda i: 'local:track:song%s.wav' % i diff --git a/tests/backends/local/playback_test.py b/tests/backends/local/playback_test.py index 4c699699..530f09c8 100644 --- a/tests/backends/local/playback_test.py +++ b/tests/backends/local/playback_test.py @@ -5,7 +5,6 @@ import unittest from mopidy.backends.local import actor from mopidy.core import PlaybackState from mopidy.models import Track -from mopidy.utils.path import path_to_uri from tests import path_to_data_dir from tests.backends.base.playback import PlaybackControllerTest @@ -24,25 +23,25 @@ class LocalPlaybackControllerTest(PlaybackControllerTest, unittest.TestCase): tracks = [ Track(uri=generate_song(i), length=4464) for i in range(1, 4)] - def add_track(self, path): - uri = path_to_uri(path_to_data_dir(path)) + def add_track(self, uri): track = Track(uri=uri, length=4464) self.tracklist.add([track]) def test_uri_scheme(self): - self.assertIn('file', self.core.uri_schemes) + self.assertNotIn('file', self.core.uri_schemes) + self.assertIn('local', self.core.uri_schemes) def test_play_mp3(self): - self.add_track('blank.mp3') + self.add_track('local:track:blank.mp3') self.playback.play() self.assertEqual(self.playback.state, PlaybackState.PLAYING) def test_play_ogg(self): - self.add_track('blank.ogg') + self.add_track('local:track:blank.ogg') self.playback.play() self.assertEqual(self.playback.state, PlaybackState.PLAYING) def test_play_flac(self): - self.add_track('blank.flac') + self.add_track('local:track:blank.flac') self.playback.play() self.assertEqual(self.playback.state, PlaybackState.PLAYING) diff --git a/tests/backends/local/playlists_test.py b/tests/backends/local/playlists_test.py index 2882e476..d405e887 100644 --- a/tests/backends/local/playlists_test.py +++ b/tests/backends/local/playlists_test.py @@ -7,7 +7,6 @@ import unittest from mopidy.backends.local import actor from mopidy.models import Track -from mopidy.utils.path import path_to_uri from tests import path_to_data_dir from tests.backends.base.playlists import ( @@ -89,21 +88,18 @@ class LocalPlaylistsControllerTest( def test_playlist_contents_is_written_to_disk(self): track = Track(uri=generate_song(1)) - track_path = track.uri[len('file://'):] playlist = self.core.playlists.create('test') - playlist_path = playlist.uri[len('file://'):] + playlist_path = os.path.join(self.playlists_dir, 'test.m3u') playlist = playlist.copy(tracks=[track]) playlist = self.core.playlists.save(playlist) with open(playlist_path) as playlist_file: contents = playlist_file.read() - self.assertEqual(track_path, contents.strip()) + self.assertEqual(track.uri, contents.strip()) def test_playlists_are_loaded_at_startup(self): - playlist_path = os.path.join(self.playlists_dir, 'test.m3u') - - track = Track(uri=path_to_uri(path_to_data_dir('uri2'))) + track = Track(uri='local:track:path2') playlist = self.core.playlists.create('test') playlist = playlist.copy(tracks=[track]) playlist = self.core.playlists.save(playlist) @@ -112,8 +108,7 @@ class LocalPlaylistsControllerTest( self.assert_(backend.playlists.playlists) self.assertEqual( - path_to_uri(playlist_path), - backend.playlists.playlists[0].uri) + 'local:playlist:test', backend.playlists.playlists[0].uri) self.assertEqual( playlist.name, backend.playlists.playlists[0].name) self.assertEqual( diff --git a/tests/backends/local/translator_test.py b/tests/backends/local/translator_test.py index 4f958232..5ed07fca 100644 --- a/tests/backends/local/translator_test.py +++ b/tests/backends/local/translator_test.py @@ -98,7 +98,7 @@ expected_tracks = [] def generate_track(path, ident): - uri = path_to_uri(path_to_data_dir(path)) + uri = 'local:track:%s' % path track = Track( uri=uri, name='trackname', artists=expected_artists, album=expected_albums[0], track_no=1, date='2006', length=4000, @@ -126,11 +126,10 @@ class MPDTagCacheToTracksTest(unittest.TestCase): def test_simple_cache(self): tracks = parse_mpd_tag_cache( path_to_data_dir('simple_tag_cache'), path_to_data_dir('')) - uri = path_to_uri(path_to_data_dir('song1.mp3')) track = Track( - uri=uri, name='trackname', artists=expected_artists, track_no=1, - album=expected_albums[0], date='2006', length=4000, - last_modified=1272319626) + uri='local:track:song1.mp3', name='trackname', + artists=expected_artists, track_no=1, album=expected_albums[0], + date='2006', length=4000, last_modified=1272319626) self.assertEqual(set([track]), tracks) def test_advanced_cache(self): @@ -142,12 +141,11 @@ class MPDTagCacheToTracksTest(unittest.TestCase): tracks = parse_mpd_tag_cache( path_to_data_dir('utf8_tag_cache'), path_to_data_dir('')) - uri = path_to_uri(path_to_data_dir('song1.mp3')) artists = [Artist(name='æøå')] album = Album(name='æøå', artists=artists) track = Track( - uri=uri, name='æøå', artists=artists, album=album, length=4000, - last_modified=1272319626) + uri='local:track:song1.mp3', name='æøå', artists=artists, + album=album, length=4000, last_modified=1272319626) self.assertEqual(track, list(tracks)[0]) @@ -159,8 +157,8 @@ class MPDTagCacheToTracksTest(unittest.TestCase): def test_cache_with_blank_track_info(self): tracks = parse_mpd_tag_cache( path_to_data_dir('blank_tag_cache'), path_to_data_dir('')) - uri = path_to_uri(path_to_data_dir('song1.mp3')) - expected = Track(uri=uri, length=4000, last_modified=1272319626) + expected = Track( + uri='local:track:song1.mp3', length=4000, last_modified=1272319626) self.assertEqual(set([expected]), tracks) def test_musicbrainz_tagcache(self): @@ -183,10 +181,10 @@ class MPDTagCacheToTracksTest(unittest.TestCase): def test_albumartist_tag_cache(self): tracks = parse_mpd_tag_cache( path_to_data_dir('albumartist_tag_cache'), path_to_data_dir('')) - uri = path_to_uri(path_to_data_dir('song1.mp3')) artist = Artist(name='albumartistname') album = expected_albums[0].copy(artists=[artist]) track = Track( - uri=uri, name='trackname', artists=expected_artists, track_no=1, - album=album, date='2006', length=4000, last_modified=1272319626) + uri='local:track:song1.mp3', name='trackname', + artists=expected_artists, track_no=1, album=album, date='2006', + length=4000, last_modified=1272319626) self.assertEqual(track, list(tracks)[0]) diff --git a/tests/config/schemas_test.py b/tests/config/schemas_test.py index 8bcfc651..9da8f667 100644 --- a/tests/config/schemas_test.py +++ b/tests/config/schemas_test.py @@ -81,7 +81,8 @@ class ConfigSchemaTest(unittest.TestCase): class LogLevelConfigSchemaTest(unittest.TestCase): def test_conversion(self): schema = schemas.LogLevelConfigSchema('test') - result, errors = schema.deserialize({'foo.bar': 'DEBUG', 'baz': 'INFO'}) + result, errors = schema.deserialize( + {'foo.bar': 'DEBUG', 'baz': 'INFO'}) self.assertEqual(logging.DEBUG, result['foo.bar']) self.assertEqual(logging.INFO, result['baz']) diff --git a/tests/config/types_test.py b/tests/config/types_test.py index 24f1265e..0df3dfb4 100644 --- a/tests/config/types_test.py +++ b/tests/config/types_test.py @@ -5,7 +5,6 @@ from __future__ import unicode_literals import logging import mock import socket -import sys import unittest from mopidy.config import types @@ -99,13 +98,18 @@ class StringTest(unittest.TestCase): self.assertIsInstance(result, bytes) self.assertEqual(b'', result) + def test_deserialize_enforces_choices_optional(self): + value = types.String(optional=True, choices=['foo', 'bar', 'baz']) + self.assertEqual(None, value.deserialize(b'')) + self.assertRaises(ValueError, value.deserialize, b'foobar') + class SecretTest(unittest.TestCase): - def test_deserialize_passes_through(self): + def test_deserialize_decodes_utf8(self): value = types.Secret() - result = value.deserialize(b'foo') - self.assertIsInstance(result, bytes) - self.assertEqual(b'foo', result) + result = value.deserialize('æøå'.encode('utf-8')) + self.assertIsInstance(result, unicode) + self.assertEqual('æøå', result) def test_deserialize_enforces_required(self): value = types.Secret() @@ -164,6 +168,10 @@ class IntegerTest(unittest.TestCase): self.assertEqual(5, value.deserialize('5')) self.assertRaises(ValueError, value.deserialize, '15') + def test_deserialize_respects_optional(self): + value = types.Integer(optional=True) + self.assertEqual(None, value.deserialize('')) + class BooleanTest(unittest.TestCase): def test_deserialize_conversion_success(self): @@ -367,7 +375,4 @@ class PathTest(unittest.TestCase): def test_serialize_unicode_string(self): value = types.Path() - expected = 'æøå'.encode(sys.getfilesystemencoding()) - result = value.serialize('æøå') - self.assertEqual(expected, result) - self.assertIsInstance(result, bytes) + self.assertRaises(ValueError, value.serialize, 'æøå') diff --git a/tests/data/library_tag_cache b/tests/data/library_tag_cache index 50771a0a..9dc11777 100644 --- a/tests/data/library_tag_cache +++ b/tests/data/library_tag_cache @@ -3,22 +3,22 @@ mpd_version: 0.14.2 fs_charset: UTF-8 info_end songList begin -key: uri1 -file: /uri1 +key: key1 +file: /path1 Artist: artist1 Title: track1 Album: album1 Date: 2001-02-03 Time: 4 -key: uri2 -file: /uri2 +key: key1 +file: /path2 Artist: artist2 Title: track2 Album: album2 Date: 2002 Time: 4 -key: uri3 -file: /uri3 +key: key3 +file: /path3 Artist: artist3 Title: track3 Album: album3 diff --git a/tests/data/scanner/empty.wav b/tests/data/scanner/empty.wav new file mode 100644 index 00000000..efe516b3 Binary files /dev/null and b/tests/data/scanner/empty.wav differ diff --git a/tests/data/scanner/example.log b/tests/data/scanner/example.log new file mode 100644 index 00000000..c49a044d Binary files /dev/null and b/tests/data/scanner/example.log differ diff --git a/tests/frontends/mpd/protocol/music_db_test.py b/tests/frontends/mpd/protocol/music_db_test.py index d16a636b..21c6721f 100644 --- a/tests/frontends/mpd/protocol/music_db_test.py +++ b/tests/frontends/mpd/protocol/music_db_test.py @@ -7,7 +7,19 @@ from tests.frontends.mpd import protocol class MusicDatabaseHandlerTest(protocol.BaseTestCase): def test_count(self): - self.sendRequest('count "tag" "needle"') + self.sendRequest('count "artist" "needle"') + self.assertInResponse('songs: 0') + self.assertInResponse('playtime: 0') + self.assertInResponse('OK') + + def test_count_without_quotes(self): + self.sendRequest('count artist "needle"') + self.assertInResponse('songs: 0') + self.assertInResponse('playtime: 0') + self.assertInResponse('OK') + + def test_count_with_multiple_pairs(self): + self.sendRequest('count "artist" "foo" "album" "bar"') self.assertInResponse('songs: 0') self.assertInResponse('playtime: 0') self.assertInResponse('OK') @@ -70,11 +82,19 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): self.assertEqual(playlists[0].tracks[0].uri, 'dummy:a') self.assertInResponse('OK') - def test_listall(self): + def test_listall_without_uri(self): + self.sendRequest('listall') + self.assertEqualResponse('ACK [0@0] {} Not implemented') + + def test_listall_with_uri(self): self.sendRequest('listall "file:///dev/urandom"') self.assertEqualResponse('ACK [0@0] {} Not implemented') - def test_listallinfo(self): + def test_listallinfo_without_uri(self): + self.sendRequest('listallinfo') + self.assertEqualResponse('ACK [0@0] {} Not implemented') + + def test_listallinfo_with_uri(self): self.sendRequest('listallinfo "file:///dev/urandom"') self.assertEqualResponse('ACK [0@0] {} Not implemented') diff --git a/tests/frontends/mpd/protocol/stored_playlists_test.py b/tests/frontends/mpd/protocol/stored_playlists_test.py index 8199be2b..d75944c4 100644 --- a/tests/frontends/mpd/protocol/stored_playlists_test.py +++ b/tests/frontends/mpd/protocol/stored_playlists_test.py @@ -10,8 +10,8 @@ from tests.frontends.mpd import protocol class PlaylistsHandlerTest(protocol.BaseTestCase): def test_listplaylist(self): self.backend.playlists.playlists = [ - Playlist(name='name', uri='dummy:name', - tracks=[Track(uri='dummy:a')])] + Playlist( + name='name', uri='dummy:name', tracks=[Track(uri='dummy:a')])] self.sendRequest('listplaylist "name"') self.assertInResponse('file: dummy:a') @@ -19,8 +19,8 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): def test_listplaylist_without_quotes(self): self.backend.playlists.playlists = [ - Playlist(name='name', uri='dummy:name', - tracks=[Track(uri='dummy:a')])] + Playlist( + name='name', uri='dummy:name', tracks=[Track(uri='dummy:a')])] self.sendRequest('listplaylist name') self.assertInResponse('file: dummy:a') @@ -41,8 +41,8 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): def test_listplaylistinfo(self): self.backend.playlists.playlists = [ - Playlist(name='name', uri='dummy:name', - tracks=[Track(uri='dummy:a')])] + Playlist( + name='name', uri='dummy:name', tracks=[Track(uri='dummy:a')])] self.sendRequest('listplaylistinfo "name"') self.assertInResponse('file: dummy:a') @@ -52,8 +52,8 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): def test_listplaylistinfo_without_quotes(self): self.backend.playlists.playlists = [ - Playlist(name='name', uri='dummy:name', - tracks=[Track(uri='dummy:a')])] + Playlist( + name='name', uri='dummy:name', tracks=[Track(uri='dummy:a')])] self.sendRequest('listplaylistinfo name') self.assertInResponse('file: dummy:a') @@ -107,6 +107,30 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): self.assertNotInResponse('playlist: ') self.assertInResponse('OK') + def test_listplaylists_replaces_newline_with_space(self): + self.backend.playlists.playlists = [ + Playlist(name='a\n', uri='dummy:')] + self.sendRequest('listplaylists') + self.assertInResponse('playlist: a ') + self.assertNotInResponse('playlist: a\n') + self.assertInResponse('OK') + + def test_listplaylists_replaces_carriage_return_with_space(self): + self.backend.playlists.playlists = [ + Playlist(name='a\r', uri='dummy:')] + self.sendRequest('listplaylists') + self.assertInResponse('playlist: a ') + self.assertNotInResponse('playlist: a\r') + self.assertInResponse('OK') + + def test_listplaylists_replaces_forward_slash_with_space(self): + self.backend.playlists.playlists = [ + Playlist(name='a/', uri='dummy:')] + self.sendRequest('listplaylists') + self.assertInResponse('playlist: a ') + self.assertNotInResponse('playlist: a/') + self.assertInResponse('OK') + def test_load_appends_to_tracklist(self): self.core.tracklist.add([Track(uri='a'), Track(uri='b')]) self.assertEqual(len(self.core.tracklist.tracks.get()), 2) diff --git a/tests/help_test.py b/tests/help_test.py index 4f210031..574e4fd7 100644 --- a/tests/help_test.py +++ b/tests/help_test.py @@ -12,7 +12,10 @@ class HelpTest(unittest.TestCase): def test_help_has_mopidy_options(self): mopidy_dir = os.path.dirname(mopidy.__file__) args = [sys.executable, mopidy_dir, '--help'] - process = subprocess.Popen(args, stdout=subprocess.PIPE) + process = subprocess.Popen( + args, + env={'PYTHONPATH': os.path.join(mopidy_dir, '..')}, + stdout=subprocess.PIPE) output = process.communicate()[0] self.assertIn('--version', output) self.assertIn('--help', output) diff --git a/tests/models_test.py b/tests/models_test.py index a0fe08c7..afd1858b 100644 --- a/tests/models_test.py +++ b/tests/models_test.py @@ -95,6 +95,11 @@ class ArtistTest(unittest.TestCase): {'__model__': 'Artist', 'uri': 'uri', 'name': 'name'}, Artist(uri='uri', name='name').serialize()) + def test_serialize_falsy_values(self): + self.assertDictEqual( + {'__model__': 'Artist', 'uri': '', 'name': None}, + Artist(uri='', name=None).serialize()) + def test_to_json_and_back(self): artist1 = Artist(uri='uri', name='name') serialized = json.dumps(artist1, cls=ModelJSONEncoder) diff --git a/tests/scanner_test.py b/tests/scanner_test.py index c9671523..ca007533 100644 --- a/tests/scanner_test.py +++ b/tests/scanner_test.py @@ -26,6 +26,8 @@ class TranslatorTest(unittest.TestCase): 'album-artist': 'albumartistname', 'title': 'trackname', 'track-count': 2, + 'album-disc-number': 2, + 'album-disc-count': 3, 'date': FakeGstDate(2006, 1, 1,), 'container-format': 'ID3 tag', 'duration': 4531, @@ -39,6 +41,7 @@ class TranslatorTest(unittest.TestCase): self.album = { 'name': 'albumname', 'num_tracks': 2, + 'num_discs': 3, 'musicbrainz_id': 'mbalbumid', } @@ -57,6 +60,7 @@ class TranslatorTest(unittest.TestCase): 'name': 'trackname', 'date': '2006-01-01', 'track_no': 1, + 'disc_no': 2, 'length': 4531, 'musicbrainz_id': 'mbtrackid', 'last_modified': 1234, @@ -206,6 +210,14 @@ class ScannerTest(unittest.TestCase): self.scan('scanner/image') self.assert_(self.errors) + def test_log_file_is_ignored(self): + self.scan('scanner/example.log') + self.assert_(self.errors) + + def test_empty_wav_file_is_ignored(self): + self.scan('scanner/empty.wav') + self.assert_(self.errors) + @unittest.SkipTest def test_song_without_time_is_handeled(self): pass diff --git a/tests/utils/path_test.py b/tests/utils/path_test.py index a19e48f7..ed9f8044 100644 --- a/tests/utils/path_test.py +++ b/tests/utils/path_test.py @@ -4,7 +4,6 @@ from __future__ import unicode_literals import os import shutil -import sys import tempfile import unittest @@ -117,86 +116,42 @@ class GetOrCreateFileTest(unittest.TestCase): class PathToFileURITest(unittest.TestCase): def test_simple_path(self): - if sys.platform == 'win32': - result = path.path_to_uri('C:/WINDOWS/clock.avi') - self.assertEqual(result, 'file:///C://WINDOWS/clock.avi') - else: - result = path.path_to_uri('/etc/fstab') - self.assertEqual(result, 'file:///etc/fstab') - - def test_dir_and_path(self): - if sys.platform == 'win32': - result = path.path_to_uri('C:/WINDOWS/', 'clock.avi') - self.assertEqual(result, 'file:///C://WINDOWS/clock.avi') - else: - result = path.path_to_uri('/etc', 'fstab') - self.assertEqual(result, 'file:///etc/fstab') + result = path.path_to_uri('/etc/fstab') + self.assertEqual(result, 'file:///etc/fstab') def test_space_in_path(self): - if sys.platform == 'win32': - result = path.path_to_uri('C:/test this') - self.assertEqual(result, 'file:///C://test%20this') - else: - result = path.path_to_uri('/tmp/test this') - self.assertEqual(result, 'file:///tmp/test%20this') + result = path.path_to_uri('/tmp/test this') + self.assertEqual(result, 'file:///tmp/test%20this') def test_unicode_in_path(self): - if sys.platform == 'win32': - result = path.path_to_uri('C:/æøå') - self.assertEqual(result, 'file:///C://%C3%A6%C3%B8%C3%A5') - else: - result = path.path_to_uri('/tmp/æøå') - self.assertEqual(result, 'file:///tmp/%C3%A6%C3%B8%C3%A5') + result = path.path_to_uri('/tmp/æøå') + self.assertEqual(result, 'file:///tmp/%C3%A6%C3%B8%C3%A5') def test_utf8_in_path(self): - if sys.platform == 'win32': - result = path.path_to_uri('C:/æøå'.encode('utf-8')) - self.assertEqual(result, 'file:///C://%C3%A6%C3%B8%C3%A5') - else: - result = path.path_to_uri('/tmp/æøå'.encode('utf-8')) - self.assertEqual(result, 'file:///tmp/%C3%A6%C3%B8%C3%A5') + result = path.path_to_uri('/tmp/æøå'.encode('utf-8')) + self.assertEqual(result, 'file:///tmp/%C3%A6%C3%B8%C3%A5') def test_latin1_in_path(self): - if sys.platform == 'win32': - result = path.path_to_uri('C:/æøå'.encode('latin-1')) - self.assertEqual(result, 'file:///C://%E6%F8%E5') - else: - result = path.path_to_uri('/tmp/æøå'.encode('latin-1')) - self.assertEqual(result, 'file:///tmp/%E6%F8%E5') + result = path.path_to_uri('/tmp/æøå'.encode('latin-1')) + self.assertEqual(result, 'file:///tmp/%E6%F8%E5') class UriToPathTest(unittest.TestCase): def test_simple_uri(self): - if sys.platform == 'win32': - result = path.uri_to_path('file:///C://WINDOWS/clock.avi') - self.assertEqual(result, 'C:/WINDOWS/clock.avi'.encode('utf-8')) - else: - result = path.uri_to_path('file:///etc/fstab') - self.assertEqual(result, '/etc/fstab'.encode('utf-8')) + result = path.uri_to_path('file:///etc/fstab') + self.assertEqual(result, '/etc/fstab'.encode('utf-8')) def test_space_in_uri(self): - if sys.platform == 'win32': - result = path.uri_to_path('file:///C://test%20this') - self.assertEqual(result, 'C:/test this'.encode('utf-8')) - else: - result = path.uri_to_path('file:///tmp/test%20this') - self.assertEqual(result, '/tmp/test this'.encode('utf-8')) + result = path.uri_to_path('file:///tmp/test%20this') + self.assertEqual(result, '/tmp/test this'.encode('utf-8')) def test_unicode_in_uri(self): - if sys.platform == 'win32': - result = path.uri_to_path('file:///C://%C3%A6%C3%B8%C3%A5') - self.assertEqual(result, 'C:/æøå'.encode('utf-8')) - else: - result = path.uri_to_path('file:///tmp/%C3%A6%C3%B8%C3%A5') - self.assertEqual(result, '/tmp/æøå'.encode('utf-8')) + result = path.uri_to_path('file:///tmp/%C3%A6%C3%B8%C3%A5') + self.assertEqual(result, '/tmp/æøå'.encode('utf-8')) def test_latin1_in_uri(self): - if sys.platform == 'win32': - result = path.uri_to_path('file:///C://%E6%F8%E5') - self.assertEqual(result, 'C:/æøå'.encode('latin-1')) - else: - result = path.uri_to_path('file:///tmp/%E6%F8%E5') - self.assertEqual(result, '/tmp/æøå'.encode('latin-1')) + result = path.uri_to_path('file:///tmp/%E6%F8%E5') + self.assertEqual(result, '/tmp/æøå'.encode('latin-1')) class SplitPathTest(unittest.TestCase): diff --git a/tests/version_test.py b/tests/version_test.py index 10c35c8f..6503ef39 100644 --- a/tests/version_test.py +++ b/tests/version_test.py @@ -37,5 +37,7 @@ class VersionTest(unittest.TestCase): self.assertLess(SV('0.11.1'), SV('0.12.0')) self.assertLess(SV('0.12.0'), SV('0.13.0')) self.assertLess(SV('0.13.0'), SV('0.14.0')) - self.assertLess(SV('0.14.0'), SV(__version__)) - self.assertLess(SV(__version__), SV('0.14.2')) + self.assertLess(SV('0.14.0'), SV('0.14.1')) + self.assertLess(SV('0.14.1'), SV('0.14.2')) + self.assertLess(SV('0.14.2'), SV(__version__)) + self.assertLess(SV(__version__), SV('0.15.1'))