diff --git a/AUTHORS b/AUTHORS index d536c059..d6ede848 100644 --- a/AUTHORS +++ b/AUTHORS @@ -13,3 +13,8 @@ - Matt Bray - Trygve Aaberge - Wouter van Wijk +- Jeremy B. Merrill +- 0xadam +- herrernst +- Nick Steel +- Zan Dobersek diff --git a/README.rst b/README.rst index c7eea228..8598b153 100644 --- a/README.rst +++ b/README.rst @@ -22,4 +22,5 @@ To get started with Mopidy, check out `the docs `_. - `Issue tracker `_ - `CI server `_ - IRC: ``#mopidy`` at `irc.freenode.net `_ +- Mailing list: `mopidy@googlegroups.com `_ - `Download development snapshot `_ diff --git a/docs/_static/dz0ny-mopidy-lux.png b/docs/_static/dz0ny-mopidy-lux.png new file mode 100644 index 00000000..ffdd67ac Binary files /dev/null and b/docs/_static/dz0ny-mopidy-lux.png differ diff --git a/docs/_static/woutervanwijk-mopidy-webclient.png b/docs/_static/woutervanwijk-mopidy-webclient.png index 0dd99acc..eee75168 100644 Binary files a/docs/_static/woutervanwijk-mopidy-webclient.png and b/docs/_static/woutervanwijk-mopidy-webclient.png differ diff --git a/docs/api/models.rst b/docs/api/models.rst index 5833e58c..4171acb6 100644 --- a/docs/api/models.rst +++ b/docs/api/models.rst @@ -18,6 +18,10 @@ Data model relations Track -> Artist [ label="has 0..n" ] Album -> Artist [ label="has 0..n" ] + SearchResult -> Artist [ label="has 0..n" ] + SearchResult -> Album [ label="has 0..n" ] + SearchResult -> Track [ label="has 0..n" ] + Data model API ============== diff --git a/docs/changes.rst b/docs/changes.rst index a04de250..ee1ea5d7 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -5,6 +5,86 @@ Changes This change log is used to track all major changes to Mopidy. +v0.13.0 (2013-03-31) +==================== + +The 0.13 release brings small improvements and bugfixes throughout Mopidy. +There are no major new features, just incremental improvement of what we +already have. + +**Dependencies** + +- Pykka >= 1.1 is now required. + +**Core** + +- Removed the :attr:`mopidy.settings.DEBUG_THREAD` setting and the + :option:`--debug-thread` command line option. Sending SIGUSR1 to + the Mopidy process will now always make it log tracebacks for all alive + threads. + +- Log a warning if a track isn't playable to make it more obvious that backend + X needs backend Y to be present for playback to work. + +- :meth:`mopidy.core.TracklistController.add` now accepts an ``uri`` which it + will lookup in the library and then add to the tracklist. This is helpful + for e.g. web clients that doesn't want to transfer all track meta data back + to the server just to add it to the tracklist when the server already got all + the needed information easily available. (Fixes: :issue:`325`) + +- Change the following methods to accept an ``uris`` keyword argument: + + - :meth:`mopidy.core.LibraryController.find_exact` + - :meth:`mopidy.core.LibraryController.search` + + Search queries will only be forwarded to backends handling the given URI + roots, and the backends may use the URI roots to further limit what results + are returned. For example, a search with ``uris=['file:']`` will only be + processed by the local backend. A search with + ``uris=['file:///media/music']`` will only be processed by the local backend, + and, if such filtering is supported by the backend, will only return results + with URIs within the given URI root. + +**Audio sub-system** + +- Make audio error logging handle log messages with non-ASCII chars. (Fixes: + :issue:`347`) + +**Local backend** + +- Make ``mopidy-scan`` work with Ogg Vorbis files. (Fixes: :issue:`275`) + +- Fix playback of files with non-ASCII chars in their file path. (Fixes: + :issue:`353`) + +**Spotify backend** + +- Let GStreamer handle time position tracking and seeks. (Fixes: :issue:`191`) + +- For all playlists owned by other Spotify users, we now append the owner's + username to the playlist name. (Partly fixes: :issue:`114`) + +**HTTP frontend** + +- Mopidy.js now works both from browsers and from Node.js environments. This + means that you now can make Mopidy clients in Node.js. Mopidy.js has been + published to the `npm registry `_ for easy + installation in Node.js projects. + +- Upgrade Mopidy.js' build system Grunt from 0.3 to 0.4. + +- Upgrade Mopidy.js' dependencies when.js from 1.6.1 to 2.0.0. + +- Expose :meth:`mopidy.core.Core.get_uri_schemes` to HTTP clients. It is + available through Mopidy.js as ``mopidy.getUriSchemes()``. + +**MPRIS frontend** + +- Publish album art URIs if available. + +- Publish disc number of track if available. + + v0.12.0 (2013-03-12) ==================== diff --git a/docs/clients/http.rst b/docs/clients/http.rst index 54d87d62..5381eaff 100644 --- a/docs/clients/http.rst +++ b/docs/clients/http.rst @@ -18,8 +18,8 @@ woutervanwijk/Mopidy-Webclient ============================== .. image:: /_static/woutervanwijk-mopidy-webclient.png - :width: 410 - :height: 511 + :width: 382 + :height: 621 The first web client for Mopidy is still under development, but is already very usable. It targets both desktop and mobile browsers. @@ -29,6 +29,24 @@ and point the :attr:`mopidy.settings.HTTP_SERVER_STATIC_DIR` setting towards your copy of the web client. +Mopidy Lux +========== + +.. image:: /_static/dz0ny-mopidy-lux.png + :width: 1000 + :height: 645 + +New web client developed by Janez Troha. See +https://github.com/dz0ny/mopidy-lux for details. + + +JukePi +====== + +New web client developed by Meantime IT in the UK for their office jukebox. See +https://github.com/meantimeit/jukepi for details. + + Rompr ===== diff --git a/docs/development.rst b/docs/development.rst index 1211cec4..c1c1d291 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -305,10 +305,16 @@ Debugging deadlocks Between the numerous Pykka threads and GStreamer interactions there can sometimes be a potential for deadlocks. In an effort to make these slightly -simpler to debug the setting :attr:`mopidy.settings.DEBUG_THREAD` or the option -``--debug-thread`` can be used to turn on an extra debug thread. This thread is -not linked to the regular program flow, and it's only task is to dump traceback -showing the other threads state when we get a ``SIGUSR1``. +simpler to debug Mopidy registers a ``SIGUSR1`` signal handler which logs the +traceback of all alive threads. + +To trigger the signal handler, you can use the ``pkill`` command to +send the ``SIGUSR1`` signal to any Mopidy processes:: + + pkill -SIGUSR1 mopidy + +If you check the log, you should now find one log record with a full traceback +for each of the currently alive threads in Mopidy. Writing documentation diff --git a/docs/extensiondev.rst b/docs/extensiondev.rst new file mode 100644 index 00000000..ff831b34 --- /dev/null +++ b/docs/extensiondev.rst @@ -0,0 +1,410 @@ +********************* +Extension development +********************* + +.. warning:: Draft + + This document is a draft open for discussion. It shows how we imagine that + development of Mopidy extensions should become in the future, not how to + currently develop an extension for Mopidy. + + +Mopidy started as simply an MPD server that could play music from Spotify. +Early on Mopidy got multiple "frontends" to expose Mopidy to more than just MPD +clients: for example the Last.fm frontend what scrobbles what you've listened +to to your Last.fm account, the MPRIS frontend that integrates Mopidy into the +Ubuntu Sound Menu, and the HTTP server and JavaScript player API making web +based Mopidy clients possible. In Mopidy 0.9 we added support for multiple +music sources without stopping and reconfiguring Mopidy: for example the local +backend for playing music from your disk, the stream backend for playing +Internet radio streams, and the Spotify and SoundCloud backends, for playing +music directly from those services. + +All of these are examples of what you can accomplish by creating a Mopidy +extension. If you want to create your own Mopidy extension for something that +does not exist yet, this guide to extension development will help you get your +extension running in no time, and make it feel the way users would expect your +extension to behave. + + +Anatomy of an extension +======================= + +Extensions are all located in a Python package called ``mopidy_something`` +where "something" is the name of the application, library or web service you +want to integrated with Mopidy. So for example if you plan to add support for a +service named Soundspot to Mopidy, you would name your extension's Python +package ``mopidy_soundspot``. + +The name of the actual extension (the human readable name) however would be +something like "Mopidy-Soundspot". Make sure to include the name "Mopidy" +somewhere in that name and that you check the capitalization. This is the name +users will use when they install your extension from PyPI. + +The extension must be shipped with a ``setup.py`` file and be registered on +`PyPI `_. Also make sure the development version link +in your package details work so that people can easily install the development +version into their virtualenv simply by running e.g. ``pip install +Mopidy-Soundspot==dev``. + +Mopidy extensions must be licensed under an Apache 2.0 (like Mopidy itself), +BSD, MIT or more liberal license to be able to be enlisted in the Mopidy +documentation. The license text should be included in the ``LICENSE`` file in +the root of the extension's Git repo. + +Combining this together, we get the following folder structure for our +extension, Mopidy-Soundspot:: + + mopidy-soundspot/ # The Git repo root + LICENSE # The license text + README.rst # Document what it is and how to use it + mopidy_soundspot/ # Your code + __init__.py + config.ini # Default configuration for the extension + ... + setup.py # Installation script + +Example content for the most important files follows below. + + +Example README.rst +================== + +The README file should quickly tell what the extension does, how to install it, +and how to configure it. The README should contain a development snapshot link +to a tarball of the latest development version of the extension. It's important +that the development snapshot link ends with ``#egg=mopidy-something-dev`` for +installation using ``pip install mopidy-something==dev`` to work. + +.. code-block:: rst + + Mopidy-Soundspot + ================ + + `Mopidy `_ extension for playing music from + `Soundspot `_. + + Usage + ----- + + Requires a Soundspot Platina subscription and the pysoundspot library. + + Install by running:: + + sudo pip install Mopidy-Soundspot + + Or install the Debian/Ubuntu package from `apt.mopidy.com + `_. + + Before starting Mopidy, you must add your Soundspot username and password + to the Mopidy configuration file:: + + [soundspot] + username = alice + password = secret + + Project resources + ----------------- + + - `Source code `_ + - `Issue tracker `_ + - `Download development snapshot `_ + + +Example setup.py +================ + +The ``setup.py`` file must use setuptools/distribute, and not distutils. This +is because Mopidy extensions use setuptools' entry point functionality to +register themselves as available Mopidy extensions when they are installed on +your system. + +The example below also includes a couple of convenient tricks for reading the +package version from the source code so that it it's just defined in a single +place, and to reuse the README file as the long description of the package for +the PyPI registration. + +The package must have ``install_requires`` on ``setuptools`` and ``Mopidy``, in +addition to any other dependencies required by your extension. The +``entry_points`` part must be included. The ``mopidy.extension`` part cannot be +changed, but the innermost string should be changed. It's format is +``my_ext_name = my_py_module:MyExtClass``. ``my_ext_name`` should be a short +name for your extension, typically the part after "Mopidy-" in lowercase. This +name is used e.g. to name the config section for your extension. The +``my_py_module:MyExtClass`` part is simply the Python path to the extension +class that will connect the rest of the dots. + +:: + + from __future__ import unicode_literals + + import re + from setuptools import setup + + + def get_version(filename): + content = open(filename).read() + metadata = dict(re.findall("__([a-z]+)__ = '([^']+)'", content)) + return metadata['version'] + + + setup( + name='Mopidy-Soundspot', + version=get_version('mopidy_soundspot/__init__.py'), + url='http://example.com/mopidy-soundspot/', + license='Apache License, Version 2.0', + author='Your Name', + author_email='your-email@example.com', + description='Very short description', + long_description=open('README.rst').read(), + packages=['mopidy_soundspot'], + # If you ship package instead of a single module instead, use + # 'py_modules' instead of 'packages': + #py_modules=['mopidy_soundspot'], + zip_safe=False, + include_package_data=True, + platforms='any', + install_requires=[ + 'setuptools', + 'Mopidy', + 'pysoundspot', + ], + entry_points={ + 'mopidy.extension': [ + 'soundspot = mopidy_soundspot:Extension', + ], + }, + classifiers=[ + 'Environment :: No Input/Output (Daemon)', + 'Intended Audience :: End Users/Desktop', + 'License :: OSI Approved :: Apache Software License', + 'Operating System :: OS Independent', + 'Programming Language :: Python :: 2', + 'Topic :: Multimedia :: Sound/Audio :: Players', + ], + ) + + +Example __init__.py +=================== + +The ``__init__.py`` file should be placed inside the ``mopidy_soundspot`` +Python package. The root of your Python package should have an ``__version__`` +attribute with a :pep:`386` compliant version number, for example "0.1". Next, +it should have a class named ``Extension`` which inherits from Mopidy's +extension base class. This is the class referred to in the ``entry_points`` +part of ``setup.py``. Any imports of other files in your extension should be +kept inside methods. This ensures that this file can be imported without +raising :exc:`ImportError` exceptions for missing dependencies, etc. + +:: + + from __future__ import unicode_literals + + import os + + import pygst + pygst.require('0.10') + import gst + import gobject + + from mopidy.exceptions import ExtensionError + from mopidy.utils import ext + + + __version__ = '0.1' + + + class Extension(ext.Extension): + + name = 'Mopidy-Soundspot' + version = __version__ + + @classmethod + def get_default_config(cls): + config_file = os.path.join( + os.path.dirname(__file__), 'config.ini') + return open(config_file).read() + + @classmethod + def validate_config(cls, config): + # ``config`` is the complete config document for the Mopidy + # instance. The extension is free to check any config value it is + # interested in, not just its own config values. + + if not config.getboolean('soundspot', 'enabled'): + return + if not config.get('soundspot', 'username'): + raise ExtensionError('Config soundspot.username not set') + if not config.get('soundspot', 'password'): + raise ExtensionError('Config soundspot.password not set') + + @classmethod + def validate_environment(cls): + # This method can validate anything it wants about the environment + # the extension is running in. Examples include checking if all + # dependencies are installed. + + try: + import pysoundspot + except ImportError as e: + raise ExtensionError('pysoundspot library not found', e) + + # You will typically only implement one of the next three methods + # in a single extension. + + @classmethod + def get_frontend_class(cls): + from .frontend import SoundspotFrontend + return SoundspotFrontend + + @classmethod + def get_backend_class(cls): + from .backend import SoundspotBackend + return SoundspotBackend + + @classmethod + def register_gstreamer_elements(cls): + from .mixer import SoundspotMixer + + gobject.type_register(SoundspotMixer) + gst.element_register( + SoundspotMixer, 'soundspotmixer', gst.RANK_MARGINAL) + + + +Example config.ini +================== + +The default configuration for the extension is located in a ``config.ini`` file +inside the Python package. It contains a single config section, with a name +matching the short name used for the extension in the ``entry_points`` part of +``setup.py``. + +All extensions should include an ``enabled`` config which should default to +``true``. Leave any configurations that doesn't have meaningful defaults blank, +like ``username`` and ``password``. + +.. code-block:: ini + + [soundspot] + enabled = true + username = + password = + + +Example frontend +================ + +If you want to *use* Mopidy's core API from your extension, then you want to +implement a frontend. + +The skeleton of a frontend would look like this. Notice that the frontend gets +passed a reference to the core API when it's created. See the +:ref:`frontend-api` for more details. + +:: + + import pykka + + from mopidy.core import CoreListener + + + class SoundspotFrontend(pykka.ThreadingActor, CoreListener): + def __init__(self, core): + super(SoundspotFrontend, self).__init__() + self.core = core + + # Your frontend implementation + + +Example backend +=============== + +If you want to extend Mopidy to support new music and playlist sources, you +want to implement a backend. A backend does not have access to Mopidy's core +API at all and got a bunch of interfaces to implement. + +The skeleton of a backend would look like this. See :ref:`backend-api` for more +details. + +:: + + import pykka + + from mopidy.backends import base + + + class SoundspotBackend(pykka.ThreadingActor, base.BaseBackend): + def __init__(self, audio): + super(SoundspotBackend, self).__init__() + self.audio = audio + + # Your backend implementation + + +Example GStreamer element +========================= + +If you want to extend Mopidy's GStreamer pipeline with new custom GStreamer +elements, you'll need to register them in GStreamer before they can be used. + +Basically, you just implement your GStreamer element in Python and then make +your :meth:`Extension.register_gstreamer_elements` method register all your +custom GStreamer elements. + +For examples of custom GStreamer elements implemented in Python, see +:mod:`mopidy.audio.mixers`. + + +Implementation steps +==================== + +A rough plan of how to make the above document the reality of how Mopidy +extensions work. + +1. Implement :class:`mopidy.utils.ext.Extension` base class and the + :exc:`mopidy.exceptions.ExtensionError` exception class. + +2. Switch from using distutils to setuptools to package and install Mopidy so + that we can register entry points for the bundled extensions and get + information about all extensions available on the system from + :mod:`pkg_resources`. + +3. Add :class:`Extension` classes for all existing frontends and backends. Make + sure to add default config files and config validation, even though this + will not be used at this implementation stage. + +4. Add entry points for the existing extensions in the ``setup.py`` file. + +5. Rewrite the startup procedure to find extensions and thus frontends and + backends via :mod:`pkg_resouces` instead of the ``FRONTENDS`` and + ``BACKENDS`` settings. + +6. Remove the ``FRONTENDS`` and ``BACKENDS`` settings. + +7. Switch to ini file based configuration, using :mod:`ConfigParser`. The + default config is the combination of a core config file plus the config from + each installed extension. To find the effective config for the system, the + following config sources are added together, with the later ones overriding + the earlier ones: + + - the default config built from Mopidy core and all installed extensions, + + - ``/etc/mopidy.conf``, + + - ``~/.config/mopidy.conf``, + + - any config file provided via command line arguments, and + + - any config values provided via command line arguments. + +8. Add command line options for: + + - loading an additional config file for this execution of Mopidy, + + - setting a config value for this execution of Mopidy, + + - printing the effective config and exit, and + + - write a config value permanently to ``~/.config/mopidy.conf`` and exit. diff --git a/docs/index.rst b/docs/index.rst index bce84b5a..54745298 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -17,9 +17,10 @@ including Windows, Mac OS X, Linux, Android, and iOS. To install Mopidy, start by reading :ref:`installation`. If you get stuck, we usually hang around at ``#mopidy`` at `irc.freenode.net -`_. If you stumble into a bug or got a feature request, -please create an issue in the `issue tracker -`_. +`_ and also got a `mailing list at Google Groups +`_. If you stumble +into a bug or got a feature request, please create an issue in the `issue +tracker `_. Project resources @@ -30,13 +31,14 @@ Project resources - `Issue tracker `_ - `CI server `_ - IRC: ``#mopidy`` at `irc.freenode.net `_ +- Mailing list: `mopidy@googlegroups.com `_ User documentation ================== .. toctree:: - :maxdepth: 3 + :maxdepth: 2 installation/index installation/raspberrypi @@ -52,7 +54,7 @@ Reference documentation ======================= .. toctree:: - :maxdepth: 3 + :maxdepth: 2 api/index modules/index @@ -62,9 +64,10 @@ Development documentation ========================= .. toctree:: - :maxdepth: 3 + :maxdepth: 2 development + extensiondev Indices and tables diff --git a/docs/installation/raspberrypi.rst b/docs/installation/raspberrypi.rst index 37f64274..020b6edd 100644 --- a/docs/installation/raspberrypi.rst +++ b/docs/installation/raspberrypi.rst @@ -209,8 +209,12 @@ software packages, as Wheezy is going to be the next release of Debian. aplay /usr/share/sounds/alsa/Front_Center.wav - To make the change to analog output stick, you can add the ``amixer`` command - to e.g. ``/etc/rc.local``, which will be executed when the system is + If you hear a voice saying "Front Center", then your sound is working. Don't + be concerned if this test sound includes static. Test your sound with + GStreamer to determine the sound quality of Mopidy. + + To make the change to analog output stick, you can add the ``amixer`` + command to e.g. ``/etc/rc.local``, which will be executed when the system is booting. diff --git a/js/Gruntfile.js b/js/Gruntfile.js new file mode 100644 index 00000000..3039e98c --- /dev/null +++ b/js/Gruntfile.js @@ -0,0 +1,79 @@ +/*global module:false*/ +module.exports = function (grunt) { + + grunt.initConfig({ + meta: { + banner: "/*! Mopidy.js - built " + + "<%= grunt.template.today('yyyy-mm-dd') %>\n" + + " * http://www.mopidy.com/\n" + + " * Copyright (c) <%= grunt.template.today('yyyy') %> " + + "Stein Magnus Jodal and contributors\n" + + " * Licensed under the Apache License, Version 2.0 */\n", + files: { + own: ["Gruntfile.js", "src/**/*.js", "test/**/*-test.js"], + concat: "../mopidy/frontends/http/data/mopidy.js", + minified: "../mopidy/frontends/http/data/mopidy.min.js" + } + }, + concat: { + options: { + banner: "<%= meta.banner %>", + stripBanners: true + }, + all: { + files: { + "<%= meta.files.concat %>": [ + "lib/bane-*.js", + "lib/when-define-shim.js", + "lib/when-*.js", + "src/mopidy.js" + ] + } + } + }, + jshint: { + options: { + curly: true, + eqeqeq: true, + immed: true, + indent: 4, + latedef: true, + newcap: true, + noarg: true, + sub: true, + quotmark: "double", + undef: true, + unused: true, + eqnull: true, + browser: true, + devel: true, + globals: {} + }, + files: "<%= meta.files.own %>" + }, + uglify: { + options: { + banner: "<%= meta.banner %>" + }, + all: { + files: { + "<%= meta.files.minified %>": ["<%= meta.files.concat %>"] + } + } + }, + watch: { + files: "<%= meta.files.own %>", + tasks: ["default"] + } + }); + + grunt.registerTask("test", ["jshint", "buster"]); + grunt.registerTask("build", ["test", "concat", "uglify"]); + grunt.registerTask("default", ["build"]); + + grunt.loadNpmTasks("grunt-buster"); + grunt.loadNpmTasks("grunt-contrib-concat"); + grunt.loadNpmTasks("grunt-contrib-jshint"); + grunt.loadNpmTasks("grunt-contrib-uglify"); + grunt.loadNpmTasks("grunt-contrib-watch"); +}; diff --git a/js/README.md b/js/README.md new file mode 100644 index 00000000..9601b64a --- /dev/null +++ b/js/README.md @@ -0,0 +1,82 @@ +Mopidy.js +========= + +Mopidy.js is a JavaScript library that is installed as a part of Mopidy's HTTP +frontend or from npm. The library makes Mopidy's core API available from the +browser or a Node.js environment, using JSON-RPC messages over a WebSocket to +communicate with Mopidy. + + +Getting it for browser use +-------------------------- + +Regular and minified versions of Mopidy.js, ready for use, is installed +together with Mopidy. When the HTTP frontend is running, the files are +available at: + +- http://localhost:6680/mopidy/mopidy.js +- http://localhost:6680/mopidy/mopidy.min.js + +You may need to adjust hostname and port for your local setup. + +In the source repo, you can find the files at: + +- `mopidy/frontends/http/data/mopidy.js` +- `mopidy/frontends/http/data/mopidy.min.js` + + +Getting it for Node.js use +-------------------------- + +If you want to use Mopidy.js from Node.js instead of a browser, you can install +Mopidy.js using npm: + + npm install mopidy + +After npm completes, you can import Mopidy.js using ``require()``: + + var Mopidy = require("mopidy").Mopidy; + + +Using the library +----------------- + +See Mopidy's [HTTP frontend +documentation](http://docs.mopidy.com/en/latest/modules/frontends/http/). + + +Building from source +-------------------- + +1. Install [Node.js](http://nodejs.org/) and npm. There is a PPA if you're + running Ubuntu: + + sudo apt-get install python-software-properties + sudo add-apt-repository ppa:chris-lea/node.js + sudo apt-get update + sudo apt-get install nodejs npm + +2. Enter the `js/` in Mopidy's Git repo dir and install all dependencies: + + cd js/ + npm install + +That's it. + +You can now run the tests: + + npm test + +To run tests automatically when you save a file: + + npm run-script watch + +To run tests, concatenate, minify the source, and update the JavaScript files +in `mopidy/frontends/http/data/`: + + npm run-script build + +To run other [grunt](http://gruntjs.com/) targets which isn't predefined in +`package.json` and thus isn't available through `npm run-script`: + + PATH=./node_modules/.bin:$PATH grunt foo diff --git a/js/README.rst b/js/README.rst deleted file mode 100644 index e8782213..00000000 --- a/js/README.rst +++ /dev/null @@ -1,62 +0,0 @@ -********* -Mopidy.js -********* - -This is the source for the JavaScript library that is installed as a part of -Mopidy's HTTP frontend. The library makes Mopidy's core API available from the -browser, using JSON-RPC messages over a WebSocket to communicate with Mopidy. - - -Getting it -========== - -Regular and minified versions of Mopidy.js, ready for use, is installed -together with Mopidy. When the HTTP frontend is running, the files are -available at: - -- http://localhost:6680/mopidy/mopidy.js -- http://localhost:6680/mopidy/mopidy.min.js - -You may need to adjust hostname and port for your local setup. - -In the source repo, you can find the files at: - -- ``mopidy/frontends/http/data/mopidy.js`` -- ``mopidy/frontends/http/data/mopidy.min.js`` - - -Building from source -==================== - -1. Install `Node.js `_ and npm. There is a PPA if you're - running Ubuntu:: - - sudo apt-get install python-software-properties - sudo add-apt-repository ppa:chris-lea/node.js - sudo apt-get update - sudo apt-get install nodejs npm - -2. Enter the ``js/`` dir and install development dependencies:: - - cd js/ - npm install - -That's it. - -You can now run the tests:: - - npm test - -To run tests automatically when you save a file:: - - npm run-script watch - -To run tests, concatenate, minify the source, and update the JavaScript files -in ``mopidy/frontends/http/data/``:: - - npm run-script build - -To run other `grunt `_ targets which isn't predefined in -``package.json`` and thus isn't available through ``npm run-script``:: - - PATH=./node_modules/.bin:$PATH grunt foo diff --git a/js/buster.js b/js/buster.js index f789885a..1cc517c8 100644 --- a/js/buster.js +++ b/js/buster.js @@ -1,8 +1,24 @@ var config = module.exports; -config["tests"] = { +config.browser_tests = { environment: "browser", - libs: ["lib/**/*.js"], + libs: [ + "lib/bane-*.js", + "lib/when-define-shim.js", + "lib/when-*.js" + ], + sources: ["src/**/*.js"], + testHelpers: ["test/**/*-helper.js"], + tests: ["test/**/*-test.js"] +}; + +config.node_tests = { + environment: "node", + libs: [ + "lib/bane-*.js", + "lib/when-define-shim.js", + "lib/when-*.js" + ], sources: ["src/**/*.js"], testHelpers: ["test/**/*-helper.js"], tests: ["test/**/*-test.js"] diff --git a/js/grunt.js b/js/grunt.js deleted file mode 100644 index 46afc8af..00000000 --- a/js/grunt.js +++ /dev/null @@ -1,72 +0,0 @@ -/*global module:false*/ -module.exports = function (grunt) { - - grunt.initConfig({ - meta: { - banner: "/*! Mopidy.js - built " + - "<%= grunt.template.today('yyyy-mm-dd') %>\n" + - " * http://www.mopidy.com/\n" + - " * Copyright (c) <%= grunt.template.today('yyyy') %> " + - "Stein Magnus Jodal and contributors\n" + - " * Licensed under the Apache License, Version 2.0 */" - }, - dirs: { - dest: "../mopidy/frontends/http/data" - }, - lint: { - files: ["grunt.js", "src/**/*.js", "test/**/*-test.js"] - }, - buster: { - test: { - config: "buster.js" - } - }, - concat: { - dist: { - src: [ - "", - "lib/bane-*.js", - "lib/when-*.js", - "src/mopidy.js" - ], - dest: "<%= dirs.dest %>/mopidy.js" - } - }, - min: { - dist: { - src: ["", ""], - dest: "<%= dirs.dest %>/mopidy.min.js" - } - }, - watch: { - files: "", - tasks: "lint buster concat min" - }, - jshint: { - options: { - curly: true, - eqeqeq: true, - immed: true, - indent: 4, - latedef: true, - newcap: true, - noarg: true, - sub: true, - quotmark: "double", - undef: true, - unused: true, - eqnull: true, - browser: true, - devel: true - }, - globals: {} - }, - uglify: {} - }); - - grunt.registerTask("test", "lint buster"); - grunt.registerTask("build", "lint buster concat min"); - grunt.registerTask("default", "build"); - - grunt.loadNpmTasks("grunt-buster"); -}; diff --git a/js/lib/when-1.6.1.js b/js/lib/when-1.6.1.js deleted file mode 100644 index e9a3bfc3..00000000 --- a/js/lib/when-1.6.1.js +++ /dev/null @@ -1,731 +0,0 @@ -/** @license MIT License (c) copyright B Cavalier & J Hann */ - -/** - * A lightweight CommonJS Promises/A and when() implementation - * when is part of the cujo.js family of libraries (http://cujojs.com/) - * - * Licensed under the MIT License at: - * http://www.opensource.org/licenses/mit-license.php - * - * @version 1.6.1 - */ - -(function(define) { 'use strict'; -define(['module'], function () { - var reduceArray, slice, undef; - - // - // Public API - // - - when.defer = defer; // Create a deferred - when.resolve = resolve; // Create a resolved promise - when.reject = reject; // Create a rejected promise - - when.join = join; // Join 2 or more promises - - when.all = all; // Resolve a list of promises - when.some = some; // Resolve a sub-set of promises - when.any = any; // Resolve one promise in a list - - when.map = map; // Array.map() for promises - when.reduce = reduce; // Array.reduce() for promises - - when.chain = chain; // Make a promise trigger another resolver - - when.isPromise = isPromise; // Determine if a thing is a promise - - /** - * Register an observer for a promise or immediate value. - * @function - * @name when - * @namespace - * - * @param promiseOrValue {*} - * @param {Function} [callback] callback to be called when promiseOrValue is - * successfully fulfilled. If promiseOrValue is an immediate value, callback - * will be invoked immediately. - * @param {Function} [errback] callback to be called when promiseOrValue is - * rejected. - * @param {Function} [progressHandler] callback to be called when progress updates - * are issued for promiseOrValue. - * @returns {Promise} a new {@link Promise} that will complete with the return - * value of callback or errback or the completion value of promiseOrValue if - * callback and/or errback is not supplied. - */ - function when(promiseOrValue, callback, errback, progressHandler) { - // Get a trusted promise for the input promiseOrValue, and then - // register promise handlers - return resolve(promiseOrValue).then(callback, errback, progressHandler); - } - - /** - * Returns promiseOrValue if promiseOrValue is a {@link Promise}, a new Promise if - * promiseOrValue is a foreign promise, or a new, already-fulfilled {@link Promise} - * whose value is promiseOrValue if promiseOrValue is an immediate value. - * @memberOf when - * - * @param promiseOrValue {*} - * @returns Guaranteed to return a trusted Promise. If promiseOrValue is a when.js {@link Promise} - * returns promiseOrValue, otherwise, returns a new, already-resolved, when.js {@link Promise} - * whose resolution value is: - * * the resolution value of promiseOrValue if it's a foreign promise, or - * * promiseOrValue if it's a value - */ - function resolve(promiseOrValue) { - var promise, deferred; - - if(promiseOrValue instanceof Promise) { - // It's a when.js promise, so we trust it - promise = promiseOrValue; - - } else { - // It's not a when.js promise. See if it's a foreign promise or a value. - - // Some promises, particularly Q promises, provide a valueOf method that - // attempts to synchronously return the fulfilled value of the promise, or - // returns the unresolved promise itself. Attempting to break a fulfillment - // value out of a promise appears to be necessary to break cycles between - // Q and When attempting to coerce each-other's promises in an infinite loop. - // For promises that do not implement "valueOf", the Object#valueOf is harmless. - // See: https://github.com/kriskowal/q/issues/106 - // IMPORTANT: Must check for a promise here, since valueOf breaks other things - // like Date. - if (isPromise(promiseOrValue) && typeof promiseOrValue.valueOf === 'function') { - promiseOrValue = promiseOrValue.valueOf(); - } - - if(isPromise(promiseOrValue)) { - // It looks like a thenable, but we don't know where it came from, - // so we don't trust its implementation entirely. Introduce a trusted - // middleman when.js promise - deferred = defer(); - - // IMPORTANT: This is the only place when.js should ever call .then() on - // an untrusted promise. - promiseOrValue.then(deferred.resolve, deferred.reject, deferred.progress); - promise = deferred.promise; - - } else { - // It's a value, not a promise. Create a resolved promise for it. - promise = fulfilled(promiseOrValue); - } - } - - return promise; - } - - /** - * Returns a rejected promise for the supplied promiseOrValue. If - * promiseOrValue is a value, it will be the rejection value of the - * returned promise. If promiseOrValue is a promise, its - * completion value will be the rejected value of the returned promise - * @memberOf when - * - * @param promiseOrValue {*} the rejected value of the returned {@link Promise} - * @return {Promise} rejected {@link Promise} - */ - function reject(promiseOrValue) { - return when(promiseOrValue, function(value) { - return rejected(value); - }); - } - - /** - * Trusted Promise constructor. A Promise created from this constructor is - * a trusted when.js promise. Any other duck-typed promise is considered - * untrusted. - * @constructor - * @name Promise - */ - function Promise(then) { - this.then = then; - } - - Promise.prototype = { - /** - * Register a callback that will be called when a promise is - * resolved or rejected. Optionally also register a progress handler. - * Shortcut for .then(alwaysback, alwaysback, progback) - * @memberOf Promise - * @param alwaysback {Function} - * @param progback {Function} - * @return {Promise} - */ - always: function(alwaysback, progback) { - return this.then(alwaysback, alwaysback, progback); - }, - - /** - * Register a rejection handler. Shortcut for .then(null, errback) - * @memberOf Promise - * @param errback {Function} - * @return {Promise} - */ - otherwise: function(errback) { - return this.then(undef, errback); - } - }; - - /** - * Create an already-resolved promise for the supplied value - * @private - * - * @param value anything - * @return {Promise} - */ - function fulfilled(value) { - var p = new Promise(function(callback) { - try { - return resolve(callback ? callback(value) : value); - } catch(e) { - return rejected(e); - } - }); - - return p; - } - - /** - * Create an already-rejected {@link Promise} with the supplied - * rejection reason. - * @private - * - * @param reason rejection reason - * @return {Promise} - */ - function rejected(reason) { - var p = new Promise(function(callback, errback) { - try { - return errback ? resolve(errback(reason)) : rejected(reason); - } catch(e) { - return rejected(e); - } - }); - - return p; - } - - /** - * Creates a new, Deferred with fully isolated resolver and promise parts, - * either or both of which may be given out safely to consumers. - * The Deferred itself has the full API: resolve, reject, progress, and - * then. The resolver has resolve, reject, and progress. The promise - * only has then. - * @memberOf when - * @function - * - * @return {Deferred} - */ - function defer() { - var deferred, promise, handlers, progressHandlers, - _then, _progress, _resolve; - - /** - * The promise for the new deferred - * @type {Promise} - */ - promise = new Promise(then); - - /** - * The full Deferred object, with {@link Promise} and {@link Resolver} parts - * @class Deferred - * @name Deferred - */ - deferred = { - then: then, - resolve: promiseResolve, - reject: promiseReject, - // TODO: Consider renaming progress() to notify() - progress: promiseProgress, - - promise: promise, - - resolver: { - resolve: promiseResolve, - reject: promiseReject, - progress: promiseProgress - } - }; - - handlers = []; - progressHandlers = []; - - /** - * Pre-resolution then() that adds the supplied callback, errback, and progback - * functions to the registered listeners - * @private - * - * @param [callback] {Function} resolution handler - * @param [errback] {Function} rejection handler - * @param [progback] {Function} progress handler - * @throws {Error} if any argument is not null, undefined, or a Function - */ - _then = function(callback, errback, progback) { - var deferred, progressHandler; - - deferred = defer(); - progressHandler = progback - ? function(update) { - try { - // Allow progress handler to transform progress event - deferred.progress(progback(update)); - } catch(e) { - // Use caught value as progress - deferred.progress(e); - } - } - : deferred.progress; - - handlers.push(function(promise) { - promise.then(callback, errback) - .then(deferred.resolve, deferred.reject, progressHandler); - }); - - progressHandlers.push(progressHandler); - - return deferred.promise; - }; - - /** - * Issue a progress event, notifying all progress listeners - * @private - * @param update {*} progress event payload to pass to all listeners - */ - _progress = function(update) { - processQueue(progressHandlers, update); - return update; - }; - - /** - * Transition from pre-resolution state to post-resolution state, notifying - * all listeners of the resolution or rejection - * @private - * @param completed {Promise} the completed value of this deferred - */ - _resolve = function(completed) { - completed = resolve(completed); - - // Replace _then with one that directly notifies with the result. - _then = completed.then; - // Replace _resolve so that this Deferred can only be completed once - _resolve = resolve; - // Make _progress a noop, to disallow progress for the resolved promise. - _progress = noop; - - // Notify handlers - processQueue(handlers, completed); - - // Free progressHandlers array since we'll never issue progress events - progressHandlers = handlers = undef; - - return completed; - }; - - return deferred; - - /** - * Wrapper to allow _then to be replaced safely - * @param [callback] {Function} resolution handler - * @param [errback] {Function} rejection handler - * @param [progback] {Function} progress handler - * @return {Promise} new Promise - * @throws {Error} if any argument is not null, undefined, or a Function - */ - function then(callback, errback, progback) { - return _then(callback, errback, progback); - } - - /** - * Wrapper to allow _resolve to be replaced - */ - function promiseResolve(val) { - return _resolve(val); - } - - /** - * Wrapper to allow _resolve to be replaced - */ - function promiseReject(err) { - return _resolve(rejected(err)); - } - - /** - * Wrapper to allow _progress to be replaced - * @param {*} update progress update - */ - function promiseProgress(update) { - return _progress(update); - } - } - - /** - * Determines if promiseOrValue is a promise or not. Uses the feature - * test from http://wiki.commonjs.org/wiki/Promises/A to determine if - * promiseOrValue is a promise. - * - * @param {*} promiseOrValue anything - * @returns {Boolean} true if promiseOrValue is a {@link Promise} - */ - function isPromise(promiseOrValue) { - return promiseOrValue && typeof promiseOrValue.then === 'function'; - } - - /** - * Initiates a competitive race, returning a promise that will resolve when - * howMany of the supplied promisesOrValues have resolved, or will reject when - * it becomes impossible for howMany to resolve, for example, when - * (promisesOrValues.length - howMany) + 1 input promises reject. - * @memberOf when - * - * @param promisesOrValues {Array} array of anything, may contain a mix - * of {@link Promise}s and values - * @param howMany {Number} number of promisesOrValues to resolve - * @param [callback] {Function} resolution handler - * @param [errback] {Function} rejection handler - * @param [progback] {Function} progress handler - * @returns {Promise} promise that will resolve to an array of howMany values that - * resolved first, or will reject with an array of (promisesOrValues.length - howMany) + 1 - * rejection reasons. - */ - function some(promisesOrValues, howMany, callback, errback, progback) { - - checkCallbacks(2, arguments); - - return when(promisesOrValues, function(promisesOrValues) { - - var toResolve, toReject, values, reasons, deferred, fulfillOne, rejectOne, progress, len, i; - - len = promisesOrValues.length >>> 0; - - toResolve = Math.max(0, Math.min(howMany, len)); - values = []; - - toReject = (len - toResolve) + 1; - reasons = []; - - deferred = defer(); - - // No items in the input, resolve immediately - if (!toResolve) { - deferred.resolve(values); - - } else { - progress = deferred.progress; - - rejectOne = function(reason) { - reasons.push(reason); - if(!--toReject) { - fulfillOne = rejectOne = noop; - deferred.reject(reasons); - } - }; - - fulfillOne = function(val) { - // This orders the values based on promise resolution order - // Another strategy would be to use the original position of - // the corresponding promise. - values.push(val); - - if (!--toResolve) { - fulfillOne = rejectOne = noop; - deferred.resolve(values); - } - }; - - for(i = 0; i < len; ++i) { - if(i in promisesOrValues) { - when(promisesOrValues[i], fulfiller, rejecter, progress); - } - } - } - - return deferred.then(callback, errback, progback); - - function rejecter(reason) { - rejectOne(reason); - } - - function fulfiller(val) { - fulfillOne(val); - } - - }); - } - - /** - * Initiates a competitive race, returning a promise that will resolve when - * any one of the supplied promisesOrValues has resolved or will reject when - * *all* promisesOrValues have rejected. - * @memberOf when - * - * @param promisesOrValues {Array|Promise} array of anything, may contain a mix - * of {@link Promise}s and values - * @param [callback] {Function} resolution handler - * @param [errback] {Function} rejection handler - * @param [progback] {Function} progress handler - * @returns {Promise} promise that will resolve to the value that resolved first, or - * will reject with an array of all rejected inputs. - */ - function any(promisesOrValues, callback, errback, progback) { - - function unwrapSingleResult(val) { - return callback ? callback(val[0]) : val[0]; - } - - return some(promisesOrValues, 1, unwrapSingleResult, errback, progback); - } - - /** - * Return a promise that will resolve only once all the supplied promisesOrValues - * have resolved. The resolution value of the returned promise will be an array - * containing the resolution values of each of the promisesOrValues. - * @memberOf when - * - * @param promisesOrValues {Array|Promise} array of anything, may contain a mix - * of {@link Promise}s and values - * @param [callback] {Function} - * @param [errback] {Function} - * @param [progressHandler] {Function} - * @returns {Promise} - */ - function all(promisesOrValues, callback, errback, progressHandler) { - checkCallbacks(1, arguments); - return map(promisesOrValues, identity).then(callback, errback, progressHandler); - } - - /** - * Joins multiple promises into a single returned promise. - * @memberOf when - * @param {Promise|*} [...promises] two or more promises to join - * @return {Promise} a promise that will fulfill when *all* the input promises - * have fulfilled, or will reject when *any one* of the input promises rejects. - */ - function join(/* ...promises */) { - return map(arguments, identity); - } - - /** - * Traditional map function, similar to `Array.prototype.map()`, but allows - * input to contain {@link Promise}s and/or values, and mapFunc may return - * either a value or a {@link Promise} - * - * @memberOf when - * - * @param promise {Array|Promise} array of anything, may contain a mix - * of {@link Promise}s and values - * @param mapFunc {Function} mapping function mapFunc(value) which may return - * either a {@link Promise} or value - * @returns {Promise} a {@link Promise} that will resolve to an array containing - * the mapped output values. - */ - function map(promise, mapFunc) { - return when(promise, function(array) { - var results, len, toResolve, resolve, reject, i, d; - - // Since we know the resulting length, we can preallocate the results - // array to avoid array expansions. - toResolve = len = array.length >>> 0; - results = []; - d = defer(); - - if(!toResolve) { - d.resolve(results); - } else { - - reject = d.reject; - resolve = function resolveOne(item, i) { - when(item, mapFunc).then(function(mapped) { - results[i] = mapped; - - if(!--toResolve) { - d.resolve(results); - } - }, reject); - }; - - // Since mapFunc may be async, get all invocations of it into flight - for(i = 0; i < len; i++) { - if(i in array) { - resolve(array[i], i); - } else { - --toResolve; - } - } - - } - - return d.promise; - - }); - } - - /** - * Traditional reduce function, similar to `Array.prototype.reduce()`, but - * input may contain {@link Promise}s and/or values, and reduceFunc - * may return either a value or a {@link Promise}, *and* initialValue may - * be a {@link Promise} for the starting value. - * @memberOf when - * - * @param promise {Array|Promise} array of anything, may contain a mix - * of {@link Promise}s and values. May also be a {@link Promise} for - * an array. - * @param reduceFunc {Function} reduce function reduce(currentValue, nextValue, index, total), - * where total is the total number of items being reduced, and will be the same - * in each call to reduceFunc. - * @param [initialValue] {*} starting value, or a {@link Promise} for the starting value - * @returns {Promise} that will resolve to the final reduced value - */ - function reduce(promise, reduceFunc /*, initialValue */) { - var args = slice.call(arguments, 1); - - return when(promise, function(array) { - var total; - - total = array.length; - - // Wrap the supplied reduceFunc with one that handles promises and then - // delegates to the supplied. - args[0] = function (current, val, i) { - return when(current, function (c) { - return when(val, function (value) { - return reduceFunc(c, value, i, total); - }); - }); - }; - - return reduceArray.apply(array, args); - }); - } - - /** - * Ensure that resolution of promiseOrValue will complete resolver with the completion - * value of promiseOrValue, or instead with resolveValue if it is provided. - * @memberOf when - * - * @param promiseOrValue - * @param resolver {Resolver} - * @param [resolveValue] anything - * @returns {Promise} - */ - function chain(promiseOrValue, resolver, resolveValue) { - var useResolveValue = arguments.length > 2; - - return when(promiseOrValue, - function(val) { - return resolver.resolve(useResolveValue ? resolveValue : val); - }, - resolver.reject, - resolver.progress - ); - } - - // - // Utility functions - // - - function processQueue(queue, value) { - var handler, i = 0; - - while (handler = queue[i++]) { - handler(value); - } - } - - /** - * Helper that checks arrayOfCallbacks to ensure that each element is either - * a function, or null or undefined. - * @private - * - * @param arrayOfCallbacks {Array} array to check - * @throws {Error} if any element of arrayOfCallbacks is something other than - * a Functions, null, or undefined. - */ - function checkCallbacks(start, arrayOfCallbacks) { - var arg, i = arrayOfCallbacks.length; - - while(i > start) { - arg = arrayOfCallbacks[--i]; - - if (arg != null && typeof arg != 'function') { - throw new Error('arg '+i+' must be a function'); - } - } - } - - /** - * No-Op function used in method replacement - * @private - */ - function noop() {} - - slice = [].slice; - - // ES5 reduce implementation if native not available - // See: http://es5.github.com/#x15.4.4.21 as there are many - // specifics and edge cases. - reduceArray = [].reduce || - function(reduceFunc /*, initialValue */) { - /*jshint maxcomplexity: 7*/ - - // ES5 dictates that reduce.length === 1 - - // This implementation deviates from ES5 spec in the following ways: - // 1. It does not check if reduceFunc is a Callable - - var arr, args, reduced, len, i; - - i = 0; - // This generates a jshint warning, despite being valid - // "Missing 'new' prefix when invoking a constructor." - // See https://github.com/jshint/jshint/issues/392 - arr = Object(this); - len = arr.length >>> 0; - args = arguments; - - // If no initialValue, use first item of array (we know length !== 0 here) - // and adjust i to start at second item - if(args.length <= 1) { - // Skip to the first real element in the array - for(;;) { - if(i in arr) { - reduced = arr[i++]; - break; - } - - // If we reached the end of the array without finding any real - // elements, it's a TypeError - if(++i >= len) { - throw new TypeError(); - } - } - } else { - // If initialValue provided, use it - reduced = args[1]; - } - - // Do the actual reduce - for(;i < len; ++i) { - // Skip holes - if(i in arr) { - reduced = reduceFunc(reduced, arr[i], i, arr); - } - } - - return reduced; - }; - - function identity(x) { - return x; - } - - return when; -}); -})(typeof define == 'function' && define.amd - ? define - : function (deps, factory) { typeof exports === 'object' - ? (module.exports = factory()) - : (this.when = factory()); - } - // Boilerplate for AMD, Node, and browser global -); diff --git a/js/lib/when-2.0.0.js b/js/lib/when-2.0.0.js new file mode 100644 index 00000000..78249532 --- /dev/null +++ b/js/lib/when-2.0.0.js @@ -0,0 +1,787 @@ +/** @license MIT License (c) copyright 2011-2013 original author or authors */ + +/** + * A lightweight CommonJS Promises/A and when() implementation + * when is part of the cujo.js family of libraries (http://cujojs.com/) + * + * Licensed under the MIT License at: + * http://www.opensource.org/licenses/mit-license.php + * + * @author Brian Cavalier + * @author John Hann + * @version 2.0.0 + */ +(function(define) { 'use strict'; +define(function () { + + // Public API + + when.defer = defer; // Create a deferred + when.resolve = resolve; // Create a resolved promise + when.reject = reject; // Create a rejected promise + + when.join = join; // Join 2 or more promises + + when.all = all; // Resolve a list of promises + when.map = map; // Array.map() for promises + when.reduce = reduce; // Array.reduce() for promises + + when.any = any; // One-winner race + when.some = some; // Multi-winner race + + when.isPromise = isPromise; // Determine if a thing is a promise + + /** + * Register an observer for a promise or immediate value. + * + * @param {*} promiseOrValue + * @param {function?} [onFulfilled] callback to be called when promiseOrValue is + * successfully fulfilled. If promiseOrValue is an immediate value, callback + * will be invoked immediately. + * @param {function?} [onRejected] callback to be called when promiseOrValue is + * rejected. + * @param {function?} [onProgress] callback to be called when progress updates + * are issued for promiseOrValue. + * @returns {Promise} a new {@link Promise} that will complete with the return + * value of callback or errback or the completion value of promiseOrValue if + * callback and/or errback is not supplied. + */ + function when(promiseOrValue, onFulfilled, onRejected, onProgress) { + // Get a trusted promise for the input promiseOrValue, and then + // register promise handlers + return resolve(promiseOrValue).then(onFulfilled, onRejected, onProgress); + } + + /** + * Trusted Promise constructor. A Promise created from this constructor is + * a trusted when.js promise. Any other duck-typed promise is considered + * untrusted. + * @constructor + * @name Promise + */ + function Promise(then) { + this.then = then; + } + + Promise.prototype = { + /** + * Register a rejection handler. Shortcut for .then(undefined, onRejected) + * @param {function?} onRejected + * @return {Promise} + */ + otherwise: function(onRejected) { + return this.then(undef, onRejected); + }, + + /** + * Ensures that onFulfilledOrRejected will be called regardless of whether + * this promise is fulfilled or rejected. onFulfilledOrRejected WILL NOT + * receive the promises' value or reason. Any returned value will be disregarded. + * onFulfilledOrRejected may throw or return a rejected promise to signal + * an additional error. + * @param {function} onFulfilledOrRejected handler to be called regardless of + * fulfillment or rejection + * @returns {Promise} + */ + ensure: function(onFulfilledOrRejected) { + var self = this; + + return this.then(injectHandler, injectHandler).yield(self); + + function injectHandler() { + return resolve(onFulfilledOrRejected()); + } + }, + + /** + * Shortcut for .then(function() { return value; }) + * @param {*} value + * @return {Promise} a promise that: + * - is fulfilled if value is not a promise, or + * - if value is a promise, will fulfill with its value, or reject + * with its reason. + */ + 'yield': function(value) { + return this.then(function() { + return value; + }); + }, + + /** + * Assumes that this promise will fulfill with an array, and arranges + * for the onFulfilled to be called with the array as its argument list + * i.e. onFulfilled.apply(undefined, array). + * @param {function} onFulfilled function to receive spread arguments + * @return {Promise} + */ + spread: function(onFulfilled) { + return this.then(function(array) { + // array may contain promises, so resolve its contents. + return all(array, function(array) { + return onFulfilled.apply(undef, array); + }); + }); + }, + + /** + * Shortcut for .then(onFulfilledOrRejected, onFulfilledOrRejected) + * @deprecated + */ + always: function(onFulfilledOrRejected, onProgress) { + return this.then(onFulfilledOrRejected, onFulfilledOrRejected, onProgress); + } + }; + + /** + * Returns a resolved promise. The returned promise will be + * - fulfilled with promiseOrValue if it is a value, or + * - if promiseOrValue is a promise + * - fulfilled with promiseOrValue's value after it is fulfilled + * - rejected with promiseOrValue's reason after it is rejected + * @param {*} value + * @return {Promise} + */ + function resolve(value) { + return promise(function(resolve) { + resolve(value); + }); + } + + /** + * Returns a rejected promise for the supplied promiseOrValue. The returned + * promise will be rejected with: + * - promiseOrValue, if it is a value, or + * - if promiseOrValue is a promise + * - promiseOrValue's value after it is fulfilled + * - promiseOrValue's reason after it is rejected + * @param {*} promiseOrValue the rejected value of the returned {@link Promise} + * @return {Promise} rejected {@link Promise} + */ + function reject(promiseOrValue) { + return when(promiseOrValue, rejected); + } + + /** + * Creates a new Deferred with fully isolated resolver and promise parts, + * either or both of which may be given out safely to consumers. + * The resolver has resolve, reject, and progress. The promise + * only has then. + * + * @return {{ + * promise: Promise, + * resolver: { + * resolve: function:Promise, + * reject: function:Promise, + * notify: function:Promise + * }}} + */ + function defer() { + var deferred, pending, resolved; + + // Optimize object shape + deferred = { + promise: undef, resolve: undef, reject: undef, notify: undef, + resolver: { resolve: undef, reject: undef, notify: undef } + }; + + deferred.promise = pending = promise(makeDeferred); + + return deferred; + + function makeDeferred(resolvePending, rejectPending, notifyPending) { + deferred.resolve = deferred.resolver.resolve = function(value) { + if(resolved) { + return resolve(value); + } + resolved = true; + resolvePending(value); + return pending; + }; + + deferred.reject = deferred.resolver.reject = function(reason) { + if(resolved) { + return resolve(rejected(reason)); + } + resolved = true; + rejectPending(reason); + return pending; + }; + + deferred.notify = deferred.resolver.notify = function(update) { + notifyPending(update); + return update; + }; + } + } + + /** + * 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 = []; + + // Call the provider resolver to seal the promise's fate + try { + resolver(promiseResolve, promiseReject, promiseNotify); + } catch(e) { + promiseReject(e); + } + + // Return the promise + return new Promise(then); + + /** + * Register handlers for this promise. + * @param [onFulfilled] {Function} fulfillment handler + * @param [onRejected] {Function} rejection handler + * @param [onProgress] {Function} progress handler + * @return {Promise} new Promise + */ + 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); + }); + }); + } + + /** + * Transition from pre-resolution state to post-resolution state, notifying + * all listeners of the ultimate fulfillment or rejection + * @param {*|Promise} val resolution value + */ + function promiseResolve(val) { + if(!handlers) { + return; + } + + value = coerce(val); + scheduleHandlers(handlers, value); + + handlers = undef; + } + + /** + * Reject this promise with the supplied reason, which will be used verbatim. + * @param {*} reason reason for the rejection + */ + function promiseReject(reason) { + promiseResolve(rejected(reason)); + } + + /** + * Issue a progress event, notifying all progress listeners + * @param {*} update progress event payload to pass to all listeners + */ + function promiseNotify(update) { + if(handlers) { + scheduleHandlers(handlers, progressing(update)); + } + } + } + + /** + * Coerces x to a trusted Promise + * + * @private + * @param {*} x thing to coerce + * @returns {Promise} 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) { + return x; + } else if (x !== Object(x)) { + return fulfilled(x); + } + + return promise(function(resolve, reject, notify) { + enqueue(function() { + try { + // We must check and assimilate in the same tick, but not the + // current tick, careful only to access promiseOrValue.then once. + var untrustedThen = x.then; + + if(typeof untrustedThen === 'function') { + fcall(untrustedThen, x, resolve, reject, notify); + } else { + // It's a value, create a fulfilled wrapper + resolve(fulfilled(x)); + } + + } catch(e) { + // Something went wrong, reject + reject(e); + } + }); + }); + } + + /** + * Create an already-fulfilled promise for the supplied value + * @private + * @param {*} value + * @return {Promise} fulfilled promise + */ + 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; + } + + /** + * Create an already-rejected promise with the supplied rejection reason. + * @private + * @param {*} reason + * @return {Promise} rejected promise + */ + 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; + } + + /** + * 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; + } + + /** + * Schedule a task that will process a list of handlers + * in the next queue drain run. + * @private + * @param {Array} handlers queue of handlers to execute + * @param {*} value passed as the only arg to each handler + */ + function scheduleHandlers(handlers, value) { + enqueue(function() { + var handler, i = 0; + while (handler = handlers[i++]) { + handler(value); + } + }); + } + + /** + * Determines if promiseOrValue is a promise or not + * + * @param {*} promiseOrValue anything + * @returns {boolean} true if promiseOrValue is a {@link Promise} + */ + function isPromise(promiseOrValue) { + return promiseOrValue && typeof promiseOrValue.then === 'function'; + } + + /** + * Initiates a competitive race, returning a promise that will resolve when + * howMany of the supplied promisesOrValues have resolved, or will reject when + * it becomes impossible for howMany to resolve, for example, when + * (promisesOrValues.length - howMany) + 1 input promises reject. + * + * @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 + * @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); + + function resolveSome(resolve, reject, notify) { + var toResolve, toReject, values, reasons, fulfillOne, rejectOne, len, i; + + len = promisesOrValues.length >>> 0; + + toResolve = Math.max(0, Math.min(howMany, len)); + values = []; + + toReject = (len - toResolve) + 1; + reasons = []; + + // No items in the input, resolve immediately + if (!toResolve) { + resolve(values); + + } else { + rejectOne = function(reason) { + reasons.push(reason); + if(!--toReject) { + fulfillOne = rejectOne = noop; + reject(reasons); + } + }; + + fulfillOne = function(val) { + // This orders the values based on promise resolution order + values.push(val); + if (!--toResolve) { + fulfillOne = rejectOne = noop; + resolve(values); + } + }; + + for(i = 0; i < len; ++i) { + if(i in promisesOrValues) { + when(promisesOrValues[i], fulfiller, rejecter, notify); + } + } + } + + function rejecter(reason) { + rejectOne(reason); + } + + function fulfiller(val) { + fulfillOne(val); + } + } + }); + } + + /** + * Initiates a competitive race, returning a promise that will resolve when + * any one of the supplied promisesOrValues has resolved or will reject when + * *all* promisesOrValues have rejected. + * + * @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 + * @returns {Promise} promise that will resolve to the value that resolved first, or + * will reject with an array of all rejected inputs. + */ + function any(promisesOrValues, onFulfilled, onRejected, onProgress) { + + function unwrapSingleResult(val) { + return onFulfilled ? onFulfilled(val[0]) : val[0]; + } + + return some(promisesOrValues, 1, unwrapSingleResult, onRejected, onProgress); + } + + /** + * Return a promise that will resolve only once all the supplied promisesOrValues + * have resolved. The resolution value of the returned promise will be an array + * containing the resolution values of each of the promisesOrValues. + * @memberOf when + * + * @param {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 + * @returns {Promise} + */ + function all(promisesOrValues, onFulfilled, onRejected, onProgress) { + checkCallbacks(1, arguments); + return map(promisesOrValues, identity).then(onFulfilled, onRejected, onProgress); + } + + /** + * Joins multiple promises into a single returned promise. + * @return {Promise} a promise that will fulfill when *all* the input promises + * have fulfilled, or will reject when *any one* of the input promises rejects. + */ + function join(/* ...promises */) { + return map(arguments, identity); + } + + /** + * Traditional map function, similar to `Array.prototype.map()`, but allows + * input to contain {@link Promise}s and/or values, and mapFunc may return + * either a value or a {@link Promise} + * + * @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. + */ + function map(array, mapFunc) { + return when(array, function(array) { + + return promise(resolveMap); + + function resolveMap(resolve, reject, notify) { + var results, len, toResolve, resolveOne, i; + + // Since we know the resulting length, we can preallocate the results + // array to avoid array expansions. + toResolve = len = array.length >>> 0; + results = []; + + if(!toResolve) { + resolve(results); + } else { + + 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; + } + } + } + } + }); + } + + /** + * Traditional reduce function, similar to `Array.prototype.reduce()`, but + * input may contain promises and/or values, and reduceFunc + * may return either a value or a promise, *and* initialValue may + * be a promise for the starting value. + * + * @param {Array|Promise} promise array or promise for an array of anything, + * may contain a mix of promises and values. + * @param {function} reduceFunc reduce function reduce(currentValue, nextValue, index, total), + * where total is the total number of items being reduced, and will be the same + * in each call to reduceFunc. + * @returns {Promise} that will resolve to the final reduced value + */ + function reduce(promise, reduceFunc /*, initialValue */) { + var args = fcall(slice, arguments, 1); + + return when(promise, function(array) { + var total; + + total = array.length; + + // Wrap the supplied reduceFunc with one that handles promises and then + // delegates to the supplied. + args[0] = function (current, val, i) { + return when(current, function (c) { + return when(val, function (value) { + return reduceFunc(c, value, i, total); + }); + }); + }; + + return reduceArray.apply(array, args); + }); + } + + // + // Utilities, etc. + // + + var reduceArray, slice, fcall, nextTick, handlerQueue, + timeout, funcProto, call, arrayProto, undef; + + // + // Shared handler queue processing + // + // Credit to Twisol (https://github.com/Twisol) for suggesting + // this type of extensible queue + trampoline approach for + // next-tick conflation. + + handlerQueue = []; + + /** + * Enqueue a task. If the queue is not currently scheduled to be + * drained, schedule it. + * @param {function} task + */ + function enqueue(task) { + if(handlerQueue.push(task) === 1) { + scheduleDrainQueue(); + } + } + + /** + * 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 + * processing until it is truly empty. + */ + function drainQueue() { + var task, i = 0; + + while(task = handlerQueue[i++]) { + task(); + } + + handlerQueue = []; + } + + // + // Capture function and array utils + // + /*global setImmediate:true*/ + + // 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); }; + + // Safe function calls + funcProto = Function.prototype; + call = funcProto.call; + fcall = funcProto.bind + ? call.bind(call) + : function(f, context) { + return f.apply(context, slice.call(arguments, 2)); + }; + + // Safe array ops + arrayProto = []; + slice = arrayProto.slice; + + // ES5 reduce implementation if native not available + // See: http://es5.github.com/#x15.4.4.21 as there are many + // specifics and edge cases. ES5 dictates that reduce.length === 1 + // This implementation deviates from ES5 spec in the following ways: + // 1. It does not check if reduceFunc is a Callable + reduceArray = arrayProto.reduce || + function(reduceFunc /*, initialValue */) { + /*jshint maxcomplexity: 7*/ + var arr, args, reduced, len, i; + + i = 0; + arr = Object(this); + len = arr.length >>> 0; + args = arguments; + + // If no initialValue, use first item of array (we know length !== 0 here) + // and adjust i to start at second item + if(args.length <= 1) { + // Skip to the first real element in the array + for(;;) { + if(i in arr) { + reduced = arr[i++]; + break; + } + + // If we reached the end of the array without finding any real + // elements, it's a TypeError + if(++i >= len) { + throw new TypeError(); + } + } + } else { + // If initialValue provided, use it + reduced = args[1]; + } + + // Do the actual reduce + for(;i < len; ++i) { + if(i in arr) { + reduced = reduceFunc(reduced, arr[i], i, arr); + } + } + + 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(); } +); diff --git a/js/lib/when-define-shim.js b/js/lib/when-define-shim.js new file mode 100644 index 00000000..ad135517 --- /dev/null +++ b/js/lib/when-define-shim.js @@ -0,0 +1,11 @@ +if (typeof window !== "undefined") { + window.define = function (factory) { + try { + delete window.define; + } catch (e) { + window.define = void 0; // IE + } + window.when = factory(); + }; + window.define.amd = {}; +} diff --git a/js/package.json b/js/package.json index a8737cfb..b208be3f 100644 --- a/js/package.json +++ b/js/package.json @@ -1,15 +1,36 @@ { - "name": "mopidy", - "version": "0.0.0", - "devDependencies": { - "buster": "*", - "grunt": "*", - "grunt-buster": "*", - "phantomjs": "*" - }, - "scripts": { - "test": "grunt test", - "build": "grunt build", - "watch": "grunt watch" - } + "name": "mopidy", + "version": "0.1.0", + "description": "Client lib for controlling a Mopidy music server over a WebSocket", + "homepage": "http://www.mopidy.com/", + "author": { + "name": "Stein Magnus Jodal", + "email": "stein.magnus@jodal.no", + "url": "http://www.jodal.no" + }, + "repository": { + "type": "git", + "url": "git://github.com/mopidy/mopidy.git" + }, + "main": "src/mopidy.js", + "dependencies": { + "bane": "~0.4.0", + "faye-websocket": "~0.4.4", + "when": "~2.0.0" + }, + "devDependencies": { + "buster": "~0.6.12", + "grunt": "~0.4.0", + "grunt-buster": "~0.1.2", + "grunt-contrib-concat": "~0.1.3", + "grunt-contrib-jshint": "~0.2.0", + "grunt-contrib-uglify": "~0.1.2", + "grunt-contrib-watch": "~0.3.1", + "phantomjs": "~1.8.2" + }, + "scripts": { + "test": "grunt test", + "build": "grunt build", + "watch": "grunt watch" + } } diff --git a/js/src/mopidy.js b/js/src/mopidy.js index 5a75a836..980256b5 100644 --- a/js/src/mopidy.js +++ b/js/src/mopidy.js @@ -1,4 +1,10 @@ -/*global bane:false, when:false*/ +/*global exports:false, require:false*/ + +if (typeof module === "object" && typeof require === "function") { + var bane = require("bane"); + var websocket = require("faye-websocket"); + var when = require("when"); +} function Mopidy(settings) { if (!(this instanceof Mopidy)) { @@ -20,9 +26,17 @@ function Mopidy(settings) { } } +if (typeof module === "object" && typeof require === "function") { + Mopidy.WebSocket = websocket.Client; +} else { + Mopidy.WebSocket = window.WebSocket; +} + Mopidy.prototype._configure = function (settings) { + var currentHost = (typeof document !== "undefined" && + document.location.host) || "localhost"; settings.webSocketUrl = settings.webSocketUrl || - "ws://" + document.location.host + "/mopidy/ws/"; + "ws://" + currentHost + "/mopidy/ws/"; if (settings.autoConnect !== false) { settings.autoConnect = true; @@ -35,7 +49,7 @@ Mopidy.prototype._configure = function (settings) { }; Mopidy.prototype._getConsole = function () { - var console = window.console || {}; + var console = typeof console !== "undefined" && console || {}; console.log = console.log || function () {}; console.warn = console.warn || function () {}; @@ -63,7 +77,7 @@ Mopidy.prototype._delegateEvents = function () { Mopidy.prototype.connect = function () { if (this._webSocket) { - if (this._webSocket.readyState === WebSocket.OPEN) { + if (this._webSocket.readyState === Mopidy.WebSocket.OPEN) { return; } else { this._webSocket.close(); @@ -71,7 +85,7 @@ Mopidy.prototype.connect = function () { } this._webSocket = this._settings.webSocket || - new WebSocket(this._settings.webSocketUrl); + new Mopidy.WebSocket(this._settings.webSocketUrl); this._webSocket.onclose = function (close) { this.emit("websocket:close", close); @@ -136,17 +150,17 @@ Mopidy.prototype._send = function (message) { var deferred = when.defer(); switch (this._webSocket.readyState) { - case WebSocket.CONNECTING: + case Mopidy.WebSocket.CONNECTING: deferred.resolver.reject({ message: "WebSocket is still connecting" }); break; - case WebSocket.CLOSING: + case Mopidy.WebSocket.CLOSING: deferred.resolver.reject({ message: "WebSocket is closing" }); break; - case WebSocket.CLOSED: + case Mopidy.WebSocket.CLOSED: deferred.resolver.reject({ message: "WebSocket is closed" }); @@ -228,7 +242,7 @@ Mopidy.prototype._handleEvent = function (eventMessage) { }; Mopidy.prototype._getApiSpec = function () { - this._send({method: "core.describe"}) + return this._send({method: "core.describe"}) .then(this._createApi.bind(this), this._handleWebSocketError) .then(null, this._handleWebSocketError); }; @@ -280,3 +294,7 @@ Mopidy.prototype._snakeToCamel = function (name) { return match.toUpperCase().replace("_", ""); }); }; + +if (typeof exports === "object") { + exports.Mopidy = Mopidy; +} diff --git a/js/test/mopidy-test.js b/js/test/mopidy-test.js index 8842ebf4..0bf97f60 100644 --- a/js/test/mopidy-test.js +++ b/js/test/mopidy-test.js @@ -1,4 +1,10 @@ -/*global buster:false, assert:false, refute:false, when:false, Mopidy:false*/ +/*global require:false, assert:false, refute:false*/ + +if (typeof module === "object" && typeof require === "function") { + var buster = require("buster"); + var Mopidy = require("../src/mopidy").Mopidy; + var when = require("when"); +} buster.testCase("Mopidy", { setUp: function () { @@ -14,10 +20,11 @@ buster.testCase("Mopidy", { fakeWebSocket.OPEN = 1; fakeWebSocket.CLOSING = 2; fakeWebSocket.CLOSED = 3; - this.realWebSocket = WebSocket; - window.WebSocket = fakeWebSocket; - this.webSocketConstructorStub = this.stub(window, "WebSocket"); + this.realWebSocket = Mopidy.WebSocket; + Mopidy.WebSocket = fakeWebSocket; + + this.webSocketConstructorStub = this.stub(Mopidy, "WebSocket"); this.webSocket = { close: this.stub(), @@ -27,15 +34,18 @@ buster.testCase("Mopidy", { }, tearDown: function () { - window.WebSocket = this.realWebSocket; + Mopidy.WebSocket = this.realWebSocket; }, "constructor": { "connects when autoConnect is true": function () { new Mopidy({autoConnect: true}); + var currentHost = typeof document !== "undefined" && + document.location.host || "localhost"; + assert.calledOnceWith(this.webSocketConstructorStub, - "ws://" + document.location.host + "/mopidy/ws/"); + "ws://" + currentHost + "/mopidy/ws/"); }, "does not connect when autoConnect is false": function () { @@ -67,12 +77,15 @@ buster.testCase("Mopidy", { mopidy.connect(); + var currentHost = typeof document !== "undefined" && + document.location.host || "localhost"; + assert.calledOnceWith(this.webSocketConstructorStub, - "ws://" + document.location.host + "/mopidy/ws/"); + "ws://" + currentHost + "/mopidy/ws/"); }, "does nothing when the WebSocket is open": function () { - this.webSocket.readyState = WebSocket.OPEN; + this.webSocket.readyState = Mopidy.WebSocket.OPEN; var mopidy = new Mopidy({webSocket: this.webSocket}); mopidy.connect(); @@ -367,7 +380,7 @@ buster.testCase("Mopidy", { }, "immediately rejects request if CONNECTING": function (done) { - this.mopidy._webSocket.readyState = WebSocket.CONNECTING; + this.mopidy._webSocket.readyState = Mopidy.WebSocket.CONNECTING; var promise = this.mopidy._send({method: "foo"}); @@ -381,7 +394,7 @@ buster.testCase("Mopidy", { }, "immediately rejects request if CLOSING": function (done) { - this.mopidy._webSocket.readyState = WebSocket.CLOSING; + this.mopidy._webSocket.readyState = Mopidy.WebSocket.CLOSING; var promise = this.mopidy._send({method: "foo"}); @@ -395,7 +408,7 @@ buster.testCase("Mopidy", { }, "immediately rejects request if CLOSED": function (done) { - this.mopidy._webSocket.readyState = WebSocket.CLOSED; + this.mopidy._webSocket.readyState = Mopidy.WebSocket.CLOSED; var promise = this.mopidy._send({method: "foo"}); @@ -597,16 +610,16 @@ buster.testCase("Mopidy", { assert.calledOnceWith(stub); }, - "gets Api description from server and calls _createApi": function () { + "gets Api description from server and calls _createApi": function (done) { var methods = {}; var sendStub = this.stub(this.mopidy, "_send"); sendStub.returns(when.resolve(methods)); var _createApiStub = this.stub(this.mopidy, "_createApi"); - this.mopidy._getApiSpec(); - - assert.calledOnceWith(sendStub, {method: "core.describe"}); - assert.calledOnceWith(_createApiStub, methods); + this.mopidy._getApiSpec().then(done(function () { + assert.calledOnceWith(sendStub, {method: "core.describe"}); + assert.calledOnceWith(_createApiStub, methods); + })); } }, diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 0a8e68aa..b091ef25 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -23,7 +23,7 @@ if (isinstance(pykka.__version__, basestring) warnings.filterwarnings('ignore', 'could not open display') -__version__ = '0.12.0' +__version__ = '0.13.0' from mopidy import settings as default_settings_module diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 2847497a..a7e914a9 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -9,6 +9,8 @@ import sys import gobject gobject.threads_init() +import pykka.debug + # Extract any non-GStreamer arguments, and leave the GStreamer arguments for # processing by GStreamer. This needs to be done before GStreamer is imported, @@ -43,15 +45,11 @@ logger = logging.getLogger('mopidy.main') def main(): signal.signal(signal.SIGTERM, process.exit_handler) + signal.signal(signal.SIGUSR1, pykka.debug.log_thread_tracebacks) loop = gobject.MainLoop() options = parse_options() - if options.debug_thread or settings.DEBUG_THREAD: - debug_thread = process.DebugThread() - debug_thread.start() - signal.signal(signal.SIGUSR1, debug_thread.handler) - try: log.setup_logging(options.verbosity_level, options.save_debug_log) check_old_folders() diff --git a/mopidy/audio/__init__.py b/mopidy/audio/__init__.py index 7cf1dcee..d38358c4 100644 --- a/mopidy/audio/__init__.py +++ b/mopidy/audio/__init__.py @@ -2,5 +2,8 @@ from __future__ import unicode_literals # flake8: noqa from .actor import Audio +from .dummy import DummyAudio from .listener import AudioListener from .constants import PlaybackState +from .utils import (calculate_duration, create_buffer, millisecond_to_clocktime, + supported_uri_schemes) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index f6f8dbe9..11d2741f 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -12,7 +12,7 @@ import pykka from mopidy import settings from mopidy.utils import process -from . import mixers +from . import mixers, utils from .constants import PlaybackState from .listener import AudioListener @@ -21,6 +21,9 @@ logger = logging.getLogger('mopidy.audio') mixers.register_mixers() +MB = 1 << 20 + + class Audio(pykka.ThreadingActor): """ Audio output through `GStreamer `_. @@ -39,16 +42,19 @@ class Audio(pykka.ThreadingActor): super(Audio, self).__init__() self._playbin = None + self._signal_ids = {} # {(element, event): signal_id} + self._mixer = None self._mixer_track = None self._mixer_scale = None self._software_mixing = False - self._appsrc = None self._volume_set = None - self._notify_source_signal_id = None - self._about_to_finish_id = None - self._message_signal_id = None + self._appsrc = None + self._appsrc_caps = None + self._appsrc_need_data_callback = None + self._appsrc_enough_data_callback = None + self._appsrc_seek_data_callback = None def on_start(self): try: @@ -65,42 +71,75 @@ class Audio(pykka.ThreadingActor): self._teardown_mixer() self._teardown_playbin() + def _connect(self, element, event, *args): + """Helper to keep track of signal ids based on element+event""" + self._signal_ids[(element, event)] = element.connect(event, *args) + + def _disconnect(self, element, event): + """Helper to disconnect signals created with _connect helper.""" + signal_id = self._signal_ids.pop((element, event), None) + if signal_id is not None: + element.disconnect(signal_id) + def _setup_playbin(self): - self._playbin = gst.element_factory_make('playbin2') + playbin = gst.element_factory_make('playbin2') fakesink = gst.element_factory_make('fakesink') - self._playbin.set_property('video-sink', fakesink) + playbin.set_property('video-sink', fakesink) - self._about_to_finish_id = self._playbin.connect( - 'about-to-finish', self._on_about_to_finish) - self._notify_source_signal_id = self._playbin.connect( - 'notify::source', self._on_new_source) + self._connect(playbin, 'about-to-finish', self._on_about_to_finish) + self._connect(playbin, 'notify::source', self._on_new_source) + + self._playbin = playbin def _on_about_to_finish(self, element): - self._appsrc = None + source, self._appsrc = self._appsrc, None + if source is None: + return + self._appsrc_caps = None + + self._disconnect(source, 'need-data') + self._disconnect(source, 'enough-data') + self._disconnect(source, 'seek-data') def _on_new_source(self, element, pad): uri = element.get_property('uri') if not uri or not uri.startswith('appsrc://'): return - # These caps matches the audio data provided by libspotify - default_caps = gst.Caps( - b'audio/x-raw-int, endianness=(int)1234, channels=(int)2, ' - b'width=(int)16, depth=(int)16, signed=(boolean)true, ' - b'rate=(int)44100') source = element.get_property('source') - source.set_property('caps', default_caps) - # GStreamer does not like unicode + source.set_property('caps', self._appsrc_caps) source.set_property('format', b'time') + source.set_property('stream-type', b'seekable') + source.set_property('max-bytes', 1 * MB) + source.set_property('min-percent', 50) + + self._connect(source, 'need-data', self._appsrc_on_need_data) + self._connect(source, 'enough-data', self._appsrc_on_enough_data) + self._connect(source, 'seek-data', self._appsrc_on_seek_data) self._appsrc = source + def _appsrc_on_need_data(self, appsrc, gst_length_hint): + length_hint = utils.clocktime_to_millisecond(gst_length_hint) + if self._appsrc_need_data_callback is not None: + self._appsrc_need_data_callback(length_hint) + return True + + def _appsrc_on_enough_data(self, appsrc): + if self._appsrc_enough_data_callback is not None: + self._appsrc_enough_data_callback() + return True + + def _appsrc_on_seek_data(self, appsrc, gst_position): + position = utils.clocktime_to_millisecond(gst_position) + if self._appsrc_seek_data_callback is not None: + self._appsrc_seek_data_callback(position) + return True + def _teardown_playbin(self): - if self._about_to_finish_id: - self._playbin.disconnect(self._about_to_finish_id) - if self._notify_source_signal_id: - self._playbin.disconnect(self._notify_source_signal_id) + self._disconnect(self._playbin, 'about-to-finish') + self._disconnect(self._playbin, 'notify::source') self._playbin.set_state(gst.STATE_NULL) def _setup_output(self): @@ -183,28 +222,34 @@ class Audio(pykka.ThreadingActor): def _setup_message_processor(self): bus = self._playbin.get_bus() bus.add_signal_watch() - self._message_signal_id = bus.connect('message', self._on_message) + self._connect(bus, 'message', self._on_message) def _teardown_message_processor(self): - if self._message_signal_id: - bus = self._playbin.get_bus() - bus.disconnect(self._message_signal_id) - bus.remove_signal_watch() + bus = self._playbin.get_bus() + self._disconnect(bus, 'message') + bus.remove_signal_watch() def _on_message(self, bus, message): if (message.type == gst.MESSAGE_STATE_CHANGED and message.src == self._playbin): old_state, new_state, pending_state = message.parse_state_changed() self._on_playbin_state_changed(old_state, new_state, pending_state) + elif message.type == gst.MESSAGE_BUFFERING: + percent = message.parse_buffering() + logger.debug('Buffer %d%% full', percent) elif message.type == gst.MESSAGE_EOS: self._on_end_of_stream() elif message.type == gst.MESSAGE_ERROR: error, debug = message.parse_error() - logger.error('%s %s', error, debug) + logger.error( + '%s Debug message: %s', + str(error).decode('utf-8'), debug.decode('utf-8') or 'None') self.stop_playback() elif message.type == gst.MESSAGE_WARNING: error, debug = message.parse_warning() - logger.warning('%s %s', error, debug) + logger.warning( + '%s Debug message: %s', + str(error).decode('utf-8'), debug.decode('utf-8') or 'None') def _on_playbin_state_changed(self, old_state, new_state, pending_state): if new_state == gst.STATE_READY and pending_state == gst.STATE_NULL: @@ -250,6 +295,32 @@ class Audio(pykka.ThreadingActor): """ self._playbin.set_property('uri', uri) + def set_appsrc( + self, caps, need_data=None, enough_data=None, seek_data=None): + """ + Switch to using appsrc for getting audio to be played. + + You *MUST* call :meth:`prepare_change` before calling this method. + + :param caps: GStreamer caps string describing the audio format to + expect + :type caps: string + :param need_data: callback for when appsrc needs data + :type need_data: callable which takes data length hint in ms + :param enough_data: callback for when appsrc has enough data + :type enough_data: callable + :param seek_data: callback for when data from a new position is needed + to continue playback + :type seek_data: callable which takes time position in ms + """ + if isinstance(caps, unicode): + caps = caps.encode('utf-8') + self._appsrc_caps = gst.Caps(caps) + self._appsrc_need_data_callback = need_data + self._appsrc_enough_data_callback = enough_data + self._appsrc_seek_data_callback = seek_data + self._playbin.set_property('uri', 'appsrc://') + def emit_data(self, buffer_): """ Call this to deliver raw audio data to be played. @@ -282,13 +353,11 @@ class Audio(pykka.ThreadingActor): :rtype: int """ - if self._playbin.get_state()[1] == gst.STATE_NULL: - return 0 try: - position = self._playbin.query_position(gst.FORMAT_TIME)[0] - return position // gst.MSECOND - except gst.QueryError, e: - logger.error('time_position failed: %s', e) + gst_position = self._playbin.query_position(gst.FORMAT_TIME)[0] + return utils.clocktime_to_millisecond(gst_position) + except gst.QueryError: + logger.debug('Position query failed') return 0 def set_position(self, position): @@ -299,12 +368,9 @@ class Audio(pykka.ThreadingActor): :type position: int :rtype: :class:`True` if successful, else :class:`False` """ - self._playbin.get_state() # block until state changes are done - handeled = self._playbin.seek_simple( - gst.Format(gst.FORMAT_TIME), gst.SEEK_FLAG_FLUSH, - position * gst.MSECOND) - self._playbin.get_state() # block until seek is done - return handeled + gst_position = utils.millisecond_to_clocktime(position) + return self._playbin.seek_simple( + gst.Format(gst.FORMAT_TIME), gst.SEEK_FLAG_FLUSH, gst_position) def start_playback(self): """ diff --git a/mopidy/audio/dummy.py b/mopidy/audio/dummy.py new file mode 100644 index 00000000..ad14390f --- /dev/null +++ b/mopidy/audio/dummy.py @@ -0,0 +1,68 @@ +"""A dummy audio actor for use in tests. + +This class implements the audio API in the simplest way possible. It is used in +tests of the core and backends. +""" + +from __future__ import unicode_literals + +import pykka + +from .constants import PlaybackState +from .listener import AudioListener + + +class DummyAudio(pykka.ThreadingActor): + def __init__(self): + super(DummyAudio, self).__init__() + self.state = PlaybackState.STOPPED + self._position = 0 + + def set_on_end_of_track(self, callback): + pass + + def set_uri(self, uri): + pass + + def set_appsrc(self, *args, **kwargs): + pass + + def emit_data(self, buffer_): + pass + + def emit_end_of_stream(self): + pass + + def get_position(self): + return self._position + + def set_position(self, position): + self._position = position + return True + + def start_playback(self): + return self._change_state(PlaybackState.PLAYING) + + def pause_playback(self): + return self._change_state(PlaybackState.PAUSED) + + def prepare_change(self): + return True + + def stop_playback(self): + return self._change_state(PlaybackState.STOPPED) + + def get_volume(self): + return 0 + + def set_volume(self, volume): + pass + + def set_metadata(self, track): + pass + + def _change_state(self, new_state): + old_state, self.state = self.state, new_state + AudioListener.send( + 'state_changed', old_state=old_state, new_state=new_state) + return True diff --git a/mopidy/audio/utils.py b/mopidy/audio/utils.py index 3f5f685e..15196b20 100644 --- a/mopidy/audio/utils.py +++ b/mopidy/audio/utils.py @@ -5,6 +5,39 @@ pygst.require('0.10') import gst +def calculate_duration(num_samples, sample_rate): + """Determine duration of samples using GStreamer helper for precise + math.""" + return gst.util_uint64_scale(num_samples, gst.SECOND, sample_rate) + + +def create_buffer(data, capabilites=None, timestamp=None, duration=None): + """Create a new GStreamer buffer based on provided data. + + Mainly intended to keep gst imports out of non-audio modules. + """ + buffer_ = gst.Buffer(data) + if capabilites: + if isinstance(capabilites, basestring): + capabilites = gst.caps_from_string(capabilites) + buffer_.set_caps(capabilites) + if timestamp: + buffer_.timestamp = timestamp + if duration: + buffer_.duration = duration + return buffer_ + + +def millisecond_to_clocktime(value): + """Convert a millisecond time to internal GStreamer time.""" + return value * gst.MSECOND + + +def clocktime_to_millisecond(value): + """Convert an internal GStreamer time to millisecond time.""" + return value // gst.MSECOND + + def supported_uri_schemes(uri_schemes): """Determine which URIs we can actually support from provided whitelist. @@ -12,7 +45,7 @@ def supported_uri_schemes(uri_schemes): :type uri_schemes: list or set or URI schemes as strings. :rtype: set of URI schemes we can support via this GStreamer install. """ - supported_schemes= set() + supported_schemes = set() registry = gst.registry_get_default() for factory in registry.get_feature_list(gst.TYPE_ELEMENT_FACTORY): diff --git a/mopidy/backends/base.py b/mopidy/backends/base.py index f49aa89b..415ef2a5 100644 --- a/mopidy/backends/base.py +++ b/mopidy/backends/base.py @@ -53,7 +53,7 @@ class BaseLibraryProvider(object): def __init__(self, backend): self.backend = backend - def find_exact(self, **query): + def find_exact(self, query=None, uris=None): """ See :meth:`mopidy.core.LibraryController.find_exact`. @@ -77,7 +77,7 @@ class BaseLibraryProvider(object): """ pass - def search(self, **query): + def search(self, query=None, uris=None): """ See :meth:`mopidy.core.LibraryController.search`. diff --git a/mopidy/backends/local/library.py b/mopidy/backends/local/library.py index eb328ce2..f2a1a520 100644 --- a/mopidy/backends/local/library.py +++ b/mopidy/backends/local/library.py @@ -35,7 +35,11 @@ class LocalLibraryProvider(base.BaseLibraryProvider): logger.debug('Failed to lookup %r', uri) return [] - def find_exact(self, **query): + def find_exact(self, query=None, uris=None): + # TODO Only return results within URI roots given by ``uris`` + + if query is None: + query = {} self._validate_query(query) result_tracks = self._uri_mapping.values() @@ -72,7 +76,11 @@ class LocalLibraryProvider(base.BaseLibraryProvider): raise LookupError('Invalid lookup field: %s' % field) return SearchResult(uri='file:search', tracks=result_tracks) - def search(self, **query): + def search(self, query=None, uris=None): + # TODO Only return results within URI roots given by ``uris`` + + if query is None: + query = {} self._validate_query(query) result_tracks = self._uri_mapping.values() diff --git a/mopidy/backends/local/translator.py b/mopidy/backends/local/translator.py index b029d367..a36be927 100644 --- a/mopidy/backends/local/translator.py +++ b/mopidy/backends/local/translator.py @@ -142,12 +142,6 @@ def _convert_mpd_data(data, tracks, music_dir): albumartist_kwargs[b'musicbrainz_id'] = ( data['musicbrainz_albumartistid']) - if data['file'][0] == '/': - path = data['file'][1:] - else: - path = data['file'] - path = urllib.unquote(path) - if artist_kwargs: artist = Artist(**artist_kwargs) track_kwargs[b'artists'] = [artist] @@ -160,7 +154,19 @@ def _convert_mpd_data(data, tracks, music_dir): album = Album(**album_kwargs) track_kwargs[b'album'] = album + if data['file'][0] == '/': + 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[b'uri'] = path_to_uri(music_dir, path) + track_kwargs[b'length'] = int(data.get('time', 0)) * 1000 track = Track(**track_kwargs) diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py index 96e5f616..7afde913 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -62,8 +62,8 @@ class SpotifyTrack(Track): class SpotifyLibraryProvider(base.BaseLibraryProvider): - def find_exact(self, **query): - return self.search(**query) + def find_exact(self, query=None, uris=None): + return self.search(query=query, uris=uris) def lookup(self, uri): try: @@ -131,7 +131,9 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): def refresh(self, uri=None): pass # TODO - def search(self, **query): + def search(self, query=None, uris=None): + # TODO Only return results within URI roots given by ``uris`` + if not query: return self._get_all_tracks() diff --git a/mopidy/backends/spotify/playback.py b/mopidy/backends/spotify/playback.py index e4534172..bda17634 100644 --- a/mopidy/backends/spotify/playback.py +++ b/mopidy/backends/spotify/playback.py @@ -1,113 +1,94 @@ from __future__ import unicode_literals import logging -import time +import functools from spotify import Link, SpotifyError +from mopidy import audio from mopidy.backends import base -from mopidy.core import PlaybackState logger = logging.getLogger('mopidy.backends.spotify') +def need_data_callback(spotify_backend, length_hint): + spotify_backend.playback.on_need_data(length_hint) + + +def enough_data_callback(spotify_backend): + spotify_backend.playback.on_enough_data() + + +def seek_data_callback(spotify_backend, time_position): + spotify_backend.playback.on_seek_data(time_position) + + class SpotifyPlaybackProvider(base.BasePlaybackProvider): + # These GStreamer caps matches the audio data provided by libspotify + _caps = ( + 'audio/x-raw-int, endianness=(int)1234, channels=(int)2, ' + 'width=(int)16, depth=(int)16, signed=(boolean)true, ' + 'rate=(int)44100') + def __init__(self, *args, **kwargs): super(SpotifyPlaybackProvider, self).__init__(*args, **kwargs) - - self._timer = TrackPositionTimer() - - def pause(self): - self._timer.pause() - - return super(SpotifyPlaybackProvider, self).pause() + self._first_seek = False def play(self, track): if track.uri is None: return False + spotify_backend = self.backend.actor_ref.proxy() + need_data_callback_bound = functools.partial( + need_data_callback, spotify_backend) + enough_data_callback_bound = functools.partial( + enough_data_callback, spotify_backend) + seek_data_callback_bound = functools.partial( + seek_data_callback, spotify_backend) + + self._first_seek = True + try: self.backend.spotify.session.load( Link.from_string(track.uri).as_track()) self.backend.spotify.session.play(1) + self.backend.spotify.buffer_timestamp = 0 self.audio.prepare_change() - self.audio.set_uri('appsrc://') + self.audio.set_appsrc( + self._caps, + need_data=need_data_callback_bound, + enough_data=enough_data_callback_bound, + seek_data=seek_data_callback_bound) self.audio.start_playback() self.audio.set_metadata(track) - self._timer.play() - return True except SpotifyError as e: logger.info('Playback of %s failed: %s', track.uri, e) return False - def resume(self): - time_position = self.get_time_position() - self._timer.resume() - self.audio.prepare_change() - result = self.seek(time_position) - self.audio.start_playback() - return result - - def seek(self, time_position): - self.backend.spotify.session.seek(time_position) - self._timer.seek(time_position) - return True - def stop(self): self.backend.spotify.session.play(0) - return super(SpotifyPlaybackProvider, self).stop() - def get_time_position(self): - # XXX: The default implementation of get_time_position hangs/times out - # when used with the Spotify backend and GStreamer appsrc. If this can - # be resolved, we no longer need to use a wall clock based time - # position for Spotify playback. - return self._timer.get_time_position() + def on_need_data(self, length_hint): + logger.debug('playback.on_need_data(%d) called', length_hint) + self.backend.spotify.push_audio_data = True + def on_enough_data(self): + logger.debug('playback.on_enough_data() called') + self.backend.spotify.push_audio_data = False -class TrackPositionTimer(object): - """ - Keeps track of time position in a track using the wall clock and playback - events. + def on_seek_data(self, time_position): + logger.debug('playback.on_seek_data(%d) called', time_position) - To not introduce a reverse dependency on the playback controller, this - class keeps track of playback state itself. - """ + if time_position == 0 and self._first_seek: + self._first_seek = False + logger.debug('Skipping seek due to issue #300') + return - def __init__(self): - self._state = PlaybackState.STOPPED - self._accumulated = 0 - self._started = 0 - - def play(self): - self._state = PlaybackState.PLAYING - self._accumulated = 0 - self._started = self._wall_time() - - def pause(self): - self._state = PlaybackState.PAUSED - self._accumulated += self._wall_time() - self._started - - def resume(self): - self._state = PlaybackState.PLAYING - - def seek(self, time_position): - self._started = self._wall_time() - self._accumulated = time_position - - def get_time_position(self): - if self._state == PlaybackState.PLAYING: - time_since_started = self._wall_time() - self._started - return self._accumulated + time_since_started - elif self._state == PlaybackState.PAUSED: - return self._accumulated - elif self._state == PlaybackState.STOPPED: - return 0 - - def _wall_time(self): - return int(time.time() * 1000) + self.backend.spotify.buffer_timestamp = audio.millisecond_to_clocktime( + time_position) + self.backend.spotify.session.seek(time_position) diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index 8326d7b4..6f386aae 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -1,9 +1,5 @@ from __future__ import unicode_literals -import pygst -pygst.require('0.10') -import gst - import logging import os import threading @@ -46,6 +42,8 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): self.backend_ref = backend_ref self.connected = threading.Event() + self.push_audio_data = True + self.buffer_timestamp = 0 self.container_manager = None self.playlist_manager = None @@ -107,6 +105,10 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): """Callback used by pyspotify""" # pylint: disable = R0913 # Too many arguments (8/5) + + if not self.push_audio_data: + return 0 + assert sample_type == 0, 'Expects 16-bit signed integer samples' capabilites = """ audio/x-raw-int, @@ -120,8 +122,14 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): 'sample_rate': sample_rate, 'channels': channels, } - buffer_ = gst.Buffer(bytes(frames)) - buffer_.set_caps(gst.caps_from_string(capabilites)) + + duration = audio.calculate_duration(num_frames, sample_rate) + buffer_ = audio.create_buffer(bytes(frames), + capabilites=capabilites, + timestamp=self.buffer_timestamp, + duration=duration) + + self.buffer_timestamp += duration if self.audio.emit_data(buffer_).get(): return num_frames diff --git a/mopidy/backends/spotify/translator.py b/mopidy/backends/spotify/translator.py index 4d2f36a0..ba5f85da 100644 --- a/mopidy/backends/spotify/translator.py +++ b/mopidy/backends/spotify/translator.py @@ -70,13 +70,16 @@ def to_mopidy_playlist(spotify_playlist): uri = str(Link.from_playlist(spotify_playlist)) if not spotify_playlist.is_loaded(): return Playlist(uri=uri, name='[loading...]') - if not spotify_playlist.name(): + name = spotify_playlist.name() + if not name: # Other user's "starred" playlists isn't handled properly by pyspotify # See https://github.com/mopidy/pyspotify/issues/81 return + if spotify_playlist.owner().canonical_name() != settings.SPOTIFY_USERNAME: + name += ' by ' + spotify_playlist.owner().canonical_name() return Playlist( uri=uri, - name=spotify_playlist.name(), + name=name, tracks=[ to_mopidy_track(spotify_track) for spotify_track in spotify_playlist diff --git a/mopidy/backends/stream/actor.py b/mopidy/backends/stream/actor.py index 0c91f291..f80ac7a9 100644 --- a/mopidy/backends/stream/actor.py +++ b/mopidy/backends/stream/actor.py @@ -5,10 +5,9 @@ import urlparse import pykka -from mopidy import settings -from mopidy.audio import utils +from mopidy import audio as audio_lib, settings from mopidy.backends import base -from mopidy.models import SearchResult, Track +from mopidy.models import Track logger = logging.getLogger('mopidy.backends.stream') @@ -21,7 +20,7 @@ class StreamBackend(pykka.ThreadingActor, base.Backend): self.playback = base.BasePlaybackProvider(audio=audio, backend=self) self.playlists = None - self.uri_schemes = utils.supported_uri_schemes( + self.uri_schemes = audio_lib.supported_uri_schemes( settings.STREAM_PROTOCOLS) diff --git a/mopidy/core/library.py b/mopidy/core/library.py index e4be7ce8..50d75144 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -1,5 +1,6 @@ from __future__ import unicode_literals +from collections import defaultdict import urlparse import pykka @@ -16,31 +17,60 @@ class LibraryController(object): uri_scheme = urlparse.urlparse(uri).scheme return self.backends.with_library_by_uri_scheme.get(uri_scheme, None) - def find_exact(self, query=None, **kwargs): + def _get_backends_to_uris(self, uris): + if uris: + backends_to_uris = defaultdict(list) + for uri in uris: + backend = self._get_backend(uri) + if backend is not None: + backends_to_uris[backend].append(uri) + else: + backends_to_uris = dict([ + (b, None) for b in self.backends.with_library]) + return backends_to_uris + + def find_exact(self, query=None, uris=None, **kwargs): """ Search the library for tracks where ``field`` is ``values``. + If the query is empty, and the backend can support it, all available + tracks are returned. + + If ``uris`` is given, the search is limited to results from within the + URI roots. For example passing ``uris=['file:']`` will limit the search + to the local backend. + Examples:: - # Returns results matching 'a' + # Returns results matching 'a' from any backend find_exact({'any': ['a']}) find_exact(any=['a']) - # Returns results matching artist 'xyz' + # Returns results matching artist 'xyz' from any backend find_exact({'artist': ['xyz']}) find_exact(artist=['xyz']) - # Returns results matching 'a' and 'b' and artist 'xyz' + # Returns results matching 'a' and 'b' and artist 'xyz' from any + # backend find_exact({'any': ['a', 'b'], 'artist': ['xyz']}) find_exact(any=['a', 'b'], artist=['xyz']) + # Returns results matching 'a' if within the given URI roots + # "file:///media/music" and "spotify:" + find_exact( + {'any': ['a']}, uris=['file:///media/music', 'spotify:']) + find_exact(any=['a'], uris=['file:///media/music', 'spotify:']) + :param query: one or more queries to search for :type query: dict + :param uris: zero or more URI roots to limit the search to + :type uris: list of strings or :class:`None` :rtype: list of :class:`mopidy.models.SearchResult` """ query = query or kwargs futures = [ - b.library.find_exact(**query) for b in self.backends.with_library] + backend.library.find_exact(query=query, uris=uris) + for (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): @@ -76,29 +106,45 @@ class LibraryController(object): b.library.refresh(uri) for b in self.backends.with_library] pykka.get_all(futures) - def search(self, query=None, **kwargs): + def search(self, query=None, uris=None, **kwargs): """ Search the library for tracks where ``field`` contains ``values``. + If the query is empty, and the backend can support it, all available + tracks are returned. + + If ``uris`` is given, the search is limited to results from within the + URI roots. For example passing ``uris=['file:']`` will limit the search + to the local backend. + Examples:: - # Returns results matching 'a' + # Returns results matching 'a' in any backend search({'any': ['a']}) search(any=['a']) - # Returns results matching artist 'xyz' + # Returns results matching artist 'xyz' in any backend search({'artist': ['xyz']}) search(artist=['xyz']) - # Returns results matching 'a' and 'b' and artist 'xyz' + # Returns results matching 'a' and 'b' and artist 'xyz' in any + # backend search({'any': ['a', 'b'], 'artist': ['xyz']}) search(any=['a', 'b'], artist=['xyz']) + # Returns results matching 'a' if within the given URI roots + # "file:///media/music" and "spotify:" + search({'any': ['a']}, uris=['file:///media/music', 'spotify:']) + search(any=['a'], uris=['file:///media/music', 'spotify:']) + :param query: one or more queries to search for :type query: dict + :param uris: zero or more URI roots to limit the search to + :type uris: list of strings or :class:`None` :rtype: list of :class:`mopidy.models.SearchResult` """ query = query or kwargs futures = [ - b.library.search(**query) for b in self.backends.with_library] + backend.library.search(query=query, uris=uris) + for (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 21f09ad2..2e79827a 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -397,13 +397,14 @@ class PlaybackController(object): self.state = PlaybackState.PLAYING backend = self._get_backend() if not backend or not backend.playback.play(tl_track.track).get(): - # Track is not playable + logger.warning('Track is not playable: %s', tl_track.track.uri) if self.random and self._shuffled: self._shuffled.remove(tl_track) if on_error_step == 1: self.next() elif on_error_step == -1: self.previous() + return if self.random and self.current_tl_track in self._shuffled: self._shuffled.remove(self.current_tl_track) diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 402e6c09..1c8f437f 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -62,10 +62,13 @@ class TracklistController(object): Is not reset before Mopidy is restarted. """ - def add(self, tracks, at_position=None): + def add(self, tracks=None, at_position=None, uri=None): """ Add the track or list of tracks to the tracklist. + If ``uri`` is given instead of ``tracks``, the URI is looked up in the + library and the resulting tracks are added to the tracklist. + If ``at_position`` is given, the tracks placed at the given position in the tracklist. If ``at_position`` is not given, the tracks are appended to the end of the tracklist. @@ -76,9 +79,18 @@ class TracklistController(object): :type tracks: list of :class:`mopidy.models.Track` :param at_position: position in tracklist to add track :type at_position: int or :class:`None` + :param uri: URI for tracks to add + :type uri: string :rtype: list of :class:`mopidy.models.TlTrack` """ + assert tracks is not None or uri is not None, \ + 'tracks or uri must be provided' + + if tracks is None and uri is not None: + tracks = self._core.library.lookup(uri) + tl_tracks = [] + for track in tracks: tl_track = TlTrack(self._next_tlid, track) self._next_tlid += 1 diff --git a/mopidy/frontends/http/__init__.py b/mopidy/frontends/http/__init__.py index ab8dff42..e81ddf3f 100644 --- a/mopidy/frontends/http/__init__.py +++ b/mopidy/frontends/http/__init__.py @@ -128,8 +128,8 @@ you quickly started with working on your client instead of figuring out how to communicate with Mopidy. -Getting the library -------------------- +Getting the library for browser use +----------------------------------- Regular and minified versions of Mopidy.js, ready for use, is installed together with Mopidy. When the HTTP frontend is running, the files are @@ -154,9 +154,28 @@ the Git repo at: - ``mopidy/frontends/http/data/mopidy.js`` - ``mopidy/frontends/http/data/mopidy.min.js`` + +Getting the library for Node.js use +----------------------------------- + +If you want to use Mopidy.js from Node.js instead of a browser, you can install +Mopidy.js using npm:: + + npm install mopidy + +After npm completes, you can import Mopidy.js using ``require()``: + +.. code-block:: js + + var Mopidy = require("mopidy").Mopidy; + + +Getting the library for development on the library +-------------------------------------------------- + If you want to work on the Mopidy.js library itself, you'll find a complete development setup in the ``js/`` dir in our repo. The instructions in -``js/README.rst`` will guide you on your way. +``js/README.md`` will guide you on your way. Creating an instance @@ -170,8 +189,8 @@ Once you got Mopidy.js loaded, you need to create an instance of the wrapper: When you instantiate ``Mopidy()`` without arguments, it will connect to the WebSocket at ``/mopidy/ws/`` on the current host. Thus, if you don't host -your web client using Mopidy's web server, you'll need to pass the URL to the -WebSocket end point: +your web client using Mopidy's web server, or if you use Mopidy.js from a +Node.js environment, you'll need to pass the URL to the WebSocket end point: .. code-block:: js diff --git a/mopidy/frontends/http/data/mopidy.js b/mopidy/frontends/http/data/mopidy.js index f41695e6..1669eaff 100644 --- a/mopidy/frontends/http/data/mopidy.js +++ b/mopidy/frontends/http/data/mopidy.js @@ -1,16 +1,7 @@ -/*! Mopidy.js - built 2013-01-16 +/*! Mopidy.js - built 2013-03-31 * http://www.mopidy.com/ * Copyright (c) 2013 Stein Magnus Jodal and contributors * Licensed under the Apache License, Version 2.0 */ - -/** - * BANE - Browser globals, AMD and Node Events - * - * https://github.com/busterjs/bane - * - * @version 0.4.0 - */ - ((typeof define === "function" && define.amd && function (m) { define(m); }) || (typeof module === "object" && function (m) { module.exports = m(); }) || function (m) { this.bane = m(); } @@ -175,7 +166,17 @@ return { createEventEmitter: createEventEmitter }; }); -/** @license MIT License (c) copyright B Cavalier & J Hann */ +if (typeof window !== "undefined") { + window.define = function (factory) { + try { + delete window.define; + } catch (e) { + window.define = void 0; // IE + } + window.when = factory(); + }; + window.define.amd = {}; +} /** * A lightweight CommonJS Promises/A and when() implementation @@ -184,128 +185,49 @@ * Licensed under the MIT License at: * http://www.opensource.org/licenses/mit-license.php * - * @version 1.6.1 + * @author Brian Cavalier + * @author John Hann + * @version 2.0.0 */ - (function(define) { 'use strict'; -define(['module'], function () { - var reduceArray, slice, undef; +define(function () { - // // Public API - // - when.defer = defer; // Create a deferred - when.resolve = resolve; // Create a resolved promise - when.reject = reject; // Create a rejected promise + when.defer = defer; // Create a deferred + when.resolve = resolve; // Create a resolved promise + when.reject = reject; // Create a rejected promise - when.join = join; // Join 2 or more promises + when.join = join; // Join 2 or more promises - when.all = all; // Resolve a list of promises - when.some = some; // Resolve a sub-set of promises - when.any = any; // Resolve one promise in a list + when.all = all; // Resolve a list of promises + when.map = map; // Array.map() for promises + when.reduce = reduce; // Array.reduce() for promises - when.map = map; // Array.map() for promises - when.reduce = reduce; // Array.reduce() for promises + when.any = any; // One-winner race + when.some = some; // Multi-winner race - when.chain = chain; // Make a promise trigger another resolver - - when.isPromise = isPromise; // Determine if a thing is a promise + when.isPromise = isPromise; // Determine if a thing is a promise /** * Register an observer for a promise or immediate value. - * @function - * @name when - * @namespace * - * @param promiseOrValue {*} - * @param {Function} [callback] callback to be called when promiseOrValue is + * @param {*} promiseOrValue + * @param {function?} [onFulfilled] callback to be called when promiseOrValue is * successfully fulfilled. If promiseOrValue is an immediate value, callback * will be invoked immediately. - * @param {Function} [errback] callback to be called when promiseOrValue is + * @param {function?} [onRejected] callback to be called when promiseOrValue is * rejected. - * @param {Function} [progressHandler] callback to be called when progress updates + * @param {function?} [onProgress] callback to be called when progress updates * are issued for promiseOrValue. * @returns {Promise} a new {@link Promise} that will complete with the return * value of callback or errback or the completion value of promiseOrValue if * callback and/or errback is not supplied. */ - function when(promiseOrValue, callback, errback, progressHandler) { + function when(promiseOrValue, onFulfilled, onRejected, onProgress) { // Get a trusted promise for the input promiseOrValue, and then // register promise handlers - return resolve(promiseOrValue).then(callback, errback, progressHandler); - } - - /** - * Returns promiseOrValue if promiseOrValue is a {@link Promise}, a new Promise if - * promiseOrValue is a foreign promise, or a new, already-fulfilled {@link Promise} - * whose value is promiseOrValue if promiseOrValue is an immediate value. - * @memberOf when - * - * @param promiseOrValue {*} - * @returns Guaranteed to return a trusted Promise. If promiseOrValue is a when.js {@link Promise} - * returns promiseOrValue, otherwise, returns a new, already-resolved, when.js {@link Promise} - * whose resolution value is: - * * the resolution value of promiseOrValue if it's a foreign promise, or - * * promiseOrValue if it's a value - */ - function resolve(promiseOrValue) { - var promise, deferred; - - if(promiseOrValue instanceof Promise) { - // It's a when.js promise, so we trust it - promise = promiseOrValue; - - } else { - // It's not a when.js promise. See if it's a foreign promise or a value. - - // Some promises, particularly Q promises, provide a valueOf method that - // attempts to synchronously return the fulfilled value of the promise, or - // returns the unresolved promise itself. Attempting to break a fulfillment - // value out of a promise appears to be necessary to break cycles between - // Q and When attempting to coerce each-other's promises in an infinite loop. - // For promises that do not implement "valueOf", the Object#valueOf is harmless. - // See: https://github.com/kriskowal/q/issues/106 - // IMPORTANT: Must check for a promise here, since valueOf breaks other things - // like Date. - if (isPromise(promiseOrValue) && typeof promiseOrValue.valueOf === 'function') { - promiseOrValue = promiseOrValue.valueOf(); - } - - if(isPromise(promiseOrValue)) { - // It looks like a thenable, but we don't know where it came from, - // so we don't trust its implementation entirely. Introduce a trusted - // middleman when.js promise - deferred = defer(); - - // IMPORTANT: This is the only place when.js should ever call .then() on - // an untrusted promise. - promiseOrValue.then(deferred.resolve, deferred.reject, deferred.progress); - promise = deferred.promise; - - } else { - // It's a value, not a promise. Create a resolved promise for it. - promise = fulfilled(promiseOrValue); - } - } - - return promise; - } - - /** - * Returns a rejected promise for the supplied promiseOrValue. If - * promiseOrValue is a value, it will be the rejection value of the - * returned promise. If promiseOrValue is a promise, its - * completion value will be the rejected value of the returned promise - * @memberOf when - * - * @param promiseOrValue {*} the rejected value of the returned {@link Promise} - * @return {Promise} rejected {@link Promise} - */ - function reject(promiseOrValue) { - return when(promiseOrValue, function(value) { - return rejected(value); - }); + return resolve(promiseOrValue).then(onFulfilled, onRejected, onProgress); } /** @@ -321,228 +243,350 @@ define(['module'], function () { Promise.prototype = { /** - * Register a callback that will be called when a promise is - * resolved or rejected. Optionally also register a progress handler. - * Shortcut for .then(alwaysback, alwaysback, progback) - * @memberOf Promise - * @param alwaysback {Function} - * @param progback {Function} + * Register a rejection handler. Shortcut for .then(undefined, onRejected) + * @param {function?} onRejected * @return {Promise} */ - always: function(alwaysback, progback) { - return this.then(alwaysback, alwaysback, progback); + otherwise: function(onRejected) { + return this.then(undef, onRejected); }, /** - * Register a rejection handler. Shortcut for .then(null, errback) - * @memberOf Promise - * @param errback {Function} + * Ensures that onFulfilledOrRejected will be called regardless of whether + * this promise is fulfilled or rejected. onFulfilledOrRejected WILL NOT + * receive the promises' value or reason. Any returned value will be disregarded. + * onFulfilledOrRejected may throw or return a rejected promise to signal + * an additional error. + * @param {function} onFulfilledOrRejected handler to be called regardless of + * fulfillment or rejection + * @returns {Promise} + */ + ensure: function(onFulfilledOrRejected) { + var self = this; + + return this.then(injectHandler, injectHandler).yield(self); + + function injectHandler() { + return resolve(onFulfilledOrRejected()); + } + }, + + /** + * Shortcut for .then(function() { return value; }) + * @param {*} value + * @return {Promise} a promise that: + * - is fulfilled if value is not a promise, or + * - if value is a promise, will fulfill with its value, or reject + * with its reason. + */ + 'yield': function(value) { + return this.then(function() { + return value; + }); + }, + + /** + * Assumes that this promise will fulfill with an array, and arranges + * for the onFulfilled to be called with the array as its argument list + * i.e. onFulfilled.apply(undefined, array). + * @param {function} onFulfilled function to receive spread arguments * @return {Promise} */ - otherwise: function(errback) { - return this.then(undef, errback); + spread: function(onFulfilled) { + return this.then(function(array) { + // array may contain promises, so resolve its contents. + return all(array, function(array) { + return onFulfilled.apply(undef, array); + }); + }); + }, + + /** + * Shortcut for .then(onFulfilledOrRejected, onFulfilledOrRejected) + * @deprecated + */ + always: function(onFulfilledOrRejected, onProgress) { + return this.then(onFulfilledOrRejected, onFulfilledOrRejected, onProgress); } }; /** - * Create an already-resolved promise for the supplied value - * @private - * - * @param value anything + * Returns a resolved promise. The returned promise will be + * - fulfilled with promiseOrValue if it is a value, or + * - if promiseOrValue is a promise + * - fulfilled with promiseOrValue's value after it is fulfilled + * - rejected with promiseOrValue's reason after it is rejected + * @param {*} value * @return {Promise} */ - function fulfilled(value) { - var p = new Promise(function(callback) { - try { - return resolve(callback ? callback(value) : value); - } catch(e) { - return rejected(e); - } + function resolve(value) { + return promise(function(resolve) { + resolve(value); }); - - return p; } /** - * Create an already-rejected {@link Promise} with the supplied - * rejection reason. - * @private - * - * @param reason rejection reason - * @return {Promise} + * Returns a rejected promise for the supplied promiseOrValue. The returned + * promise will be rejected with: + * - promiseOrValue, if it is a value, or + * - if promiseOrValue is a promise + * - promiseOrValue's value after it is fulfilled + * - promiseOrValue's reason after it is rejected + * @param {*} promiseOrValue the rejected value of the returned {@link Promise} + * @return {Promise} rejected {@link Promise} */ - function rejected(reason) { - var p = new Promise(function(callback, errback) { - try { - return errback ? resolve(errback(reason)) : rejected(reason); - } catch(e) { - return rejected(e); - } - }); - - return p; + function reject(promiseOrValue) { + return when(promiseOrValue, rejected); } /** - * Creates a new, Deferred with fully isolated resolver and promise parts, + * Creates a new Deferred with fully isolated resolver and promise parts, * either or both of which may be given out safely to consumers. - * The Deferred itself has the full API: resolve, reject, progress, and - * then. The resolver has resolve, reject, and progress. The promise + * The resolver has resolve, reject, and progress. The promise * only has then. - * @memberOf when - * @function * - * @return {Deferred} + * @return {{ + * promise: Promise, + * resolver: { + * resolve: function:Promise, + * reject: function:Promise, + * notify: function:Promise + * }}} */ function defer() { - var deferred, promise, handlers, progressHandlers, - _then, _progress, _resolve; + var deferred, pending, resolved; - /** - * The promise for the new deferred - * @type {Promise} - */ - promise = new Promise(then); - - /** - * The full Deferred object, with {@link Promise} and {@link Resolver} parts - * @class Deferred - * @name Deferred - */ + // Optimize object shape deferred = { - then: then, - resolve: promiseResolve, - reject: promiseReject, - // TODO: Consider renaming progress() to notify() - progress: promiseProgress, - - promise: promise, - - resolver: { - resolve: promiseResolve, - reject: promiseReject, - progress: promiseProgress - } + promise: undef, resolve: undef, reject: undef, notify: undef, + resolver: { resolve: undef, reject: undef, notify: undef } }; - handlers = []; - progressHandlers = []; - - /** - * Pre-resolution then() that adds the supplied callback, errback, and progback - * functions to the registered listeners - * @private - * - * @param [callback] {Function} resolution handler - * @param [errback] {Function} rejection handler - * @param [progback] {Function} progress handler - * @throws {Error} if any argument is not null, undefined, or a Function - */ - _then = function(callback, errback, progback) { - var deferred, progressHandler; - - deferred = defer(); - progressHandler = progback - ? function(update) { - try { - // Allow progress handler to transform progress event - deferred.progress(progback(update)); - } catch(e) { - // Use caught value as progress - deferred.progress(e); - } - } - : deferred.progress; - - handlers.push(function(promise) { - promise.then(callback, errback) - .then(deferred.resolve, deferred.reject, progressHandler); - }); - - progressHandlers.push(progressHandler); - - return deferred.promise; - }; - - /** - * Issue a progress event, notifying all progress listeners - * @private - * @param update {*} progress event payload to pass to all listeners - */ - _progress = function(update) { - processQueue(progressHandlers, update); - return update; - }; - - /** - * Transition from pre-resolution state to post-resolution state, notifying - * all listeners of the resolution or rejection - * @private - * @param completed {Promise} the completed value of this deferred - */ - _resolve = function(completed) { - completed = resolve(completed); - - // Replace _then with one that directly notifies with the result. - _then = completed.then; - // Replace _resolve so that this Deferred can only be completed once - _resolve = resolve; - // Make _progress a noop, to disallow progress for the resolved promise. - _progress = noop; - - // Notify handlers - processQueue(handlers, completed); - - // Free progressHandlers array since we'll never issue progress events - progressHandlers = handlers = undef; - - return completed; - }; + deferred.promise = pending = promise(makeDeferred); return deferred; - /** - * Wrapper to allow _then to be replaced safely - * @param [callback] {Function} resolution handler - * @param [errback] {Function} rejection handler - * @param [progback] {Function} progress handler - * @return {Promise} new Promise - * @throws {Error} if any argument is not null, undefined, or a Function - */ - function then(callback, errback, progback) { - return _then(callback, errback, progback); - } + function makeDeferred(resolvePending, rejectPending, notifyPending) { + deferred.resolve = deferred.resolver.resolve = function(value) { + if(resolved) { + return resolve(value); + } + resolved = true; + resolvePending(value); + return pending; + }; - /** - * Wrapper to allow _resolve to be replaced - */ - function promiseResolve(val) { - return _resolve(val); - } + deferred.reject = deferred.resolver.reject = function(reason) { + if(resolved) { + return resolve(rejected(reason)); + } + resolved = true; + rejectPending(reason); + return pending; + }; - /** - * Wrapper to allow _resolve to be replaced - */ - function promiseReject(err) { - return _resolve(rejected(err)); - } - - /** - * Wrapper to allow _progress to be replaced - * @param {*} update progress update - */ - function promiseProgress(update) { - return _progress(update); + deferred.notify = deferred.resolver.notify = function(update) { + notifyPending(update); + return update; + }; } } /** - * Determines if promiseOrValue is a promise or not. Uses the feature - * test from http://wiki.commonjs.org/wiki/Promises/A to determine if - * promiseOrValue is a promise. + * 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 = []; + + // Call the provider resolver to seal the promise's fate + try { + resolver(promiseResolve, promiseReject, promiseNotify); + } catch(e) { + promiseReject(e); + } + + // Return the promise + return new Promise(then); + + /** + * Register handlers for this promise. + * @param [onFulfilled] {Function} fulfillment handler + * @param [onRejected] {Function} rejection handler + * @param [onProgress] {Function} progress handler + * @return {Promise} new Promise + */ + 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); + }); + }); + } + + /** + * Transition from pre-resolution state to post-resolution state, notifying + * all listeners of the ultimate fulfillment or rejection + * @param {*|Promise} val resolution value + */ + function promiseResolve(val) { + if(!handlers) { + return; + } + + value = coerce(val); + scheduleHandlers(handlers, value); + + handlers = undef; + } + + /** + * Reject this promise with the supplied reason, which will be used verbatim. + * @param {*} reason reason for the rejection + */ + function promiseReject(reason) { + promiseResolve(rejected(reason)); + } + + /** + * Issue a progress event, notifying all progress listeners + * @param {*} update progress event payload to pass to all listeners + */ + function promiseNotify(update) { + if(handlers) { + scheduleHandlers(handlers, progressing(update)); + } + } + } + + /** + * Coerces x to a trusted Promise + * + * @private + * @param {*} x thing to coerce + * @returns {Promise} 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) { + return x; + } else if (x !== Object(x)) { + return fulfilled(x); + } + + return promise(function(resolve, reject, notify) { + enqueue(function() { + try { + // We must check and assimilate in the same tick, but not the + // current tick, careful only to access promiseOrValue.then once. + var untrustedThen = x.then; + + if(typeof untrustedThen === 'function') { + fcall(untrustedThen, x, resolve, reject, notify); + } else { + // It's a value, create a fulfilled wrapper + resolve(fulfilled(x)); + } + + } catch(e) { + // Something went wrong, reject + reject(e); + } + }); + }); + } + + /** + * Create an already-fulfilled promise for the supplied value + * @private + * @param {*} value + * @return {Promise} fulfilled promise + */ + 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; + } + + /** + * Create an already-rejected promise with the supplied rejection reason. + * @private + * @param {*} reason + * @return {Promise} rejected promise + */ + 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; + } + + /** + * 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; + } + + /** + * Schedule a task that will process a list of handlers + * in the next queue drain run. + * @private + * @param {Array} handlers queue of handlers to execute + * @param {*} value passed as the only arg to each handler + */ + function scheduleHandlers(handlers, value) { + enqueue(function() { + var handler, i = 0; + while (handler = handlers[i++]) { + handler(value); + } + }); + } + + /** + * Determines if promiseOrValue is a promise or not * * @param {*} promiseOrValue anything - * @returns {Boolean} true if promiseOrValue is a {@link Promise} + * @returns {boolean} true if promiseOrValue is a {@link Promise} */ function isPromise(promiseOrValue) { return promiseOrValue && typeof promiseOrValue.then === 'function'; @@ -553,80 +597,73 @@ define(['module'], function () { * howMany of the supplied promisesOrValues have resolved, or will reject when * it becomes impossible for howMany to resolve, for example, when * (promisesOrValues.length - howMany) + 1 input promises reject. - * @memberOf when * - * @param promisesOrValues {Array} array of anything, may contain a mix - * of {@link Promise}s and values - * @param howMany {Number} number of promisesOrValues to resolve - * @param [callback] {Function} resolution handler - * @param [errback] {Function} rejection handler - * @param [progback] {Function} progress handler + * @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 * @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. + * resolved first, or will reject with an array of + * (promisesOrValues.length - howMany) + 1 rejection reasons. */ - function some(promisesOrValues, howMany, callback, errback, progback) { + function some(promisesOrValues, howMany, onFulfilled, onRejected, onProgress) { checkCallbacks(2, arguments); return when(promisesOrValues, function(promisesOrValues) { - var toResolve, toReject, values, reasons, deferred, fulfillOne, rejectOne, progress, len, i; + return promise(resolveSome).then(onFulfilled, onRejected, onProgress); - len = promisesOrValues.length >>> 0; + function resolveSome(resolve, reject, notify) { + var toResolve, toReject, values, reasons, fulfillOne, rejectOne, len, i; - toResolve = Math.max(0, Math.min(howMany, len)); - values = []; + len = promisesOrValues.length >>> 0; - toReject = (len - toResolve) + 1; - reasons = []; + toResolve = Math.max(0, Math.min(howMany, len)); + values = []; - deferred = defer(); + toReject = (len - toResolve) + 1; + reasons = []; - // No items in the input, resolve immediately - if (!toResolve) { - deferred.resolve(values); + // No items in the input, resolve immediately + if (!toResolve) { + resolve(values); - } else { - progress = deferred.progress; + } else { + rejectOne = function(reason) { + reasons.push(reason); + if(!--toReject) { + fulfillOne = rejectOne = noop; + reject(reasons); + } + }; - rejectOne = function(reason) { - reasons.push(reason); - if(!--toReject) { - fulfillOne = rejectOne = noop; - deferred.reject(reasons); - } - }; + fulfillOne = function(val) { + // This orders the values based on promise resolution order + values.push(val); + if (!--toResolve) { + fulfillOne = rejectOne = noop; + resolve(values); + } + }; - fulfillOne = function(val) { - // This orders the values based on promise resolution order - // Another strategy would be to use the original position of - // the corresponding promise. - values.push(val); - - if (!--toResolve) { - fulfillOne = rejectOne = noop; - deferred.resolve(values); - } - }; - - for(i = 0; i < len; ++i) { - if(i in promisesOrValues) { - when(promisesOrValues[i], fulfiller, rejecter, progress); + for(i = 0; i < len; ++i) { + if(i in promisesOrValues) { + when(promisesOrValues[i], fulfiller, rejecter, notify); + } } } + + function rejecter(reason) { + rejectOne(reason); + } + + function fulfiller(val) { + fulfillOne(val); + } } - - return deferred.then(callback, errback, progback); - - function rejecter(reason) { - rejectOne(reason); - } - - function fulfiller(val) { - fulfillOne(val); - } - }); } @@ -634,23 +671,22 @@ define(['module'], function () { * Initiates a competitive race, returning a promise that will resolve when * any one of the supplied promisesOrValues has resolved or will reject when * *all* promisesOrValues have rejected. - * @memberOf when * - * @param promisesOrValues {Array|Promise} array of anything, may contain a mix + * @param {Array|Promise} promisesOrValues array of anything, may contain a mix * of {@link Promise}s and values - * @param [callback] {Function} resolution handler - * @param [errback] {Function} rejection handler - * @param [progback] {Function} progress handler + * @param {function?} [onFulfilled] resolution handler + * @param {function?} [onRejected] rejection handler + * @param {function?} [onProgress] progress handler * @returns {Promise} promise that will resolve to the value that resolved first, or * will reject with an array of all rejected inputs. */ - function any(promisesOrValues, callback, errback, progback) { + function any(promisesOrValues, onFulfilled, onRejected, onProgress) { function unwrapSingleResult(val) { - return callback ? callback(val[0]) : val[0]; + return onFulfilled ? onFulfilled(val[0]) : val[0]; } - return some(promisesOrValues, 1, unwrapSingleResult, errback, progback); + return some(promisesOrValues, 1, unwrapSingleResult, onRejected, onProgress); } /** @@ -659,22 +695,20 @@ define(['module'], function () { * containing the resolution values of each of the promisesOrValues. * @memberOf when * - * @param promisesOrValues {Array|Promise} array of anything, may contain a mix + * @param {Array|Promise} promisesOrValues array of anything, may contain a mix * of {@link Promise}s and values - * @param [callback] {Function} - * @param [errback] {Function} - * @param [progressHandler] {Function} + * @param {function?} [onFulfilled] resolution handler + * @param {function?} [onRejected] rejection handler + * @param {function?} [onProgress] progress handler * @returns {Promise} */ - function all(promisesOrValues, callback, errback, progressHandler) { + function all(promisesOrValues, onFulfilled, onRejected, onProgress) { checkCallbacks(1, arguments); - return map(promisesOrValues, identity).then(callback, errback, progressHandler); + return map(promisesOrValues, identity).then(onFulfilled, onRejected, onProgress); } /** * Joins multiple promises into a single returned promise. - * @memberOf when - * @param {Promise|*} [...promises] two or more promises to join * @return {Promise} a promise that will fulfill when *all* the input promises * have fulfilled, or will reject when *any one* of the input promises rejects. */ @@ -687,74 +721,68 @@ define(['module'], function () { * input to contain {@link Promise}s and/or values, and mapFunc may return * either a value or a {@link Promise} * - * @memberOf when - * - * @param promise {Array|Promise} array of anything, may contain a mix + * @param {Array|Promise} array array of anything, may contain a mix * of {@link Promise}s and values - * @param mapFunc {Function} mapping function mapFunc(value) which may return + * @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. */ - function map(promise, mapFunc) { - return when(promise, function(array) { - var results, len, toResolve, resolve, reject, i, d; + function map(array, mapFunc) { + return when(array, function(array) { - // Since we know the resulting length, we can preallocate the results - // array to avoid array expansions. - toResolve = len = array.length >>> 0; - results = []; - d = defer(); + return promise(resolveMap); - if(!toResolve) { - d.resolve(results); - } else { + function resolveMap(resolve, reject, notify) { + var results, len, toResolve, resolveOne, i; - reject = d.reject; - resolve = function resolveOne(item, i) { - when(item, mapFunc).then(function(mapped) { - results[i] = mapped; + // Since we know the resulting length, we can preallocate the results + // array to avoid array expansions. + toResolve = len = array.length >>> 0; + results = []; - if(!--toResolve) { - d.resolve(results); + if(!toResolve) { + resolve(results); + } else { + + 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; } - }, reject); - }; - - // Since mapFunc may be async, get all invocations of it into flight - for(i = 0; i < len; i++) { - if(i in array) { - resolve(array[i], i); - } else { - --toResolve; } } - } - - return d.promise; - }); } /** * Traditional reduce function, similar to `Array.prototype.reduce()`, but - * input may contain {@link Promise}s and/or values, and reduceFunc - * may return either a value or a {@link Promise}, *and* initialValue may - * be a {@link Promise} for the starting value. - * @memberOf when + * input may contain promises and/or values, and reduceFunc + * may return either a value or a promise, *and* initialValue may + * be a promise for the starting value. * - * @param promise {Array|Promise} array of anything, may contain a mix - * of {@link Promise}s and values. May also be a {@link Promise} for - * an array. - * @param reduceFunc {Function} reduce function reduce(currentValue, nextValue, index, total), + * @param {Array|Promise} promise array or promise for an array of anything, + * may contain a mix of promises and values. + * @param {function} reduceFunc reduce function reduce(currentValue, nextValue, index, total), * where total is the total number of items being reduced, and will be the same * in each call to reduceFunc. - * @param [initialValue] {*} starting value, or a {@link Promise} for the starting value * @returns {Promise} that will resolve to the final reduced value */ function reduce(promise, reduceFunc /*, initialValue */) { - var args = slice.call(arguments, 1); + var args = fcall(slice, arguments, 1); return when(promise, function(array) { var total; @@ -775,87 +803,94 @@ define(['module'], function () { }); } + // + // Utilities, etc. + // + + var reduceArray, slice, fcall, nextTick, handlerQueue, + timeout, funcProto, call, arrayProto, undef; + + // + // Shared handler queue processing + // + // Credit to Twisol (https://github.com/Twisol) for suggesting + // this type of extensible queue + trampoline approach for + // next-tick conflation. + + handlerQueue = []; + /** - * Ensure that resolution of promiseOrValue will complete resolver with the completion - * value of promiseOrValue, or instead with resolveValue if it is provided. - * @memberOf when - * - * @param promiseOrValue - * @param resolver {Resolver} - * @param [resolveValue] anything - * @returns {Promise} + * Enqueue a task. If the queue is not currently scheduled to be + * drained, schedule it. + * @param {function} task */ - function chain(promiseOrValue, resolver, resolveValue) { - var useResolveValue = arguments.length > 2; - - return when(promiseOrValue, - function(val) { - return resolver.resolve(useResolveValue ? resolveValue : val); - }, - resolver.reject, - resolver.progress - ); - } - - // - // Utility functions - // - - function processQueue(queue, value) { - var handler, i = 0; - - while (handler = queue[i++]) { - handler(value); + function enqueue(task) { + if(handlerQueue.push(task) === 1) { + scheduleDrainQueue(); } } /** - * Helper that checks arrayOfCallbacks to ensure that each element is either - * a function, or null or undefined. - * @private - * - * @param arrayOfCallbacks {Array} array to check - * @throws {Error} if any element of arrayOfCallbacks is something other than - * a Functions, null, or undefined. + * Schedule the queue to be drained in the next tick. */ - function checkCallbacks(start, arrayOfCallbacks) { - var arg, i = arrayOfCallbacks.length; - - while(i > start) { - arg = arrayOfCallbacks[--i]; - - if (arg != null && typeof arg != 'function') { - throw new Error('arg '+i+' must be a function'); - } - } + function scheduleDrainQueue() { + nextTick(drainQueue); } /** - * No-Op function used in method replacement - * @private + * Drain the handler queue entirely or partially, being careful to allow + * the queue to be extended while it is being processed, and to continue + * processing until it is truly empty. */ - function noop() {} + function drainQueue() { + var task, i = 0; - slice = [].slice; + while(task = handlerQueue[i++]) { + task(); + } + + handlerQueue = []; + } + + // + // Capture function and array utils + // + /*global setImmediate:true*/ + + // 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); }; + + // Safe function calls + funcProto = Function.prototype; + call = funcProto.call; + fcall = funcProto.bind + ? call.bind(call) + : function(f, context) { + return f.apply(context, slice.call(arguments, 2)); + }; + + // Safe array ops + arrayProto = []; + slice = arrayProto.slice; // ES5 reduce implementation if native not available // See: http://es5.github.com/#x15.4.4.21 as there are many - // specifics and edge cases. - reduceArray = [].reduce || + // specifics and edge cases. ES5 dictates that reduce.length === 1 + // This implementation deviates from ES5 spec in the following ways: + // 1. It does not check if reduceFunc is a Callable + reduceArray = arrayProto.reduce || function(reduceFunc /*, initialValue */) { /*jshint maxcomplexity: 7*/ - - // ES5 dictates that reduce.length === 1 - - // This implementation deviates from ES5 spec in the following ways: - // 1. It does not check if reduceFunc is a Callable - var arr, args, reduced, len, i; i = 0; - // This generates a jshint warning, despite being valid - // "Missing 'new' prefix when invoking a constructor." - // See https://github.com/jshint/jshint/issues/392 arr = Object(this); len = arr.length >>> 0; args = arguments; @@ -883,7 +918,6 @@ define(['module'], function () { // Do the actual reduce for(;i < len; ++i) { - // Skip holes if(i in arr) { reduced = reduceFunc(reduced, arr[i], i, arr); } @@ -892,22 +926,49 @@ define(['module'], 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 (deps, factory) { typeof exports === 'object' - ? (module.exports = factory()) - : (this.when = factory()); - } - // Boilerplate for AMD, Node, and browser global +})( + typeof define === 'function' && define.amd ? define : function (factory) { module.exports = factory(); } ); -/*global bane:false, when:false*/ +if (typeof module === "object" && typeof require === "function") { + var bane = require("bane"); + var websocket = require("faye-websocket"); + var when = require("when"); +} function Mopidy(settings) { if (!(this instanceof Mopidy)) { @@ -929,9 +990,17 @@ function Mopidy(settings) { } } +if (typeof module === "object" && typeof require === "function") { + Mopidy.WebSocket = websocket.Client; +} else { + Mopidy.WebSocket = window.WebSocket; +} + Mopidy.prototype._configure = function (settings) { + var currentHost = (typeof document !== "undefined" && + document.location.host) || "localhost"; settings.webSocketUrl = settings.webSocketUrl || - "ws://" + document.location.host + "/mopidy/ws/"; + "ws://" + currentHost + "/mopidy/ws/"; if (settings.autoConnect !== false) { settings.autoConnect = true; @@ -944,7 +1013,7 @@ Mopidy.prototype._configure = function (settings) { }; Mopidy.prototype._getConsole = function () { - var console = window.console || {}; + var console = typeof console !== "undefined" && console || {}; console.log = console.log || function () {}; console.warn = console.warn || function () {}; @@ -972,7 +1041,7 @@ Mopidy.prototype._delegateEvents = function () { Mopidy.prototype.connect = function () { if (this._webSocket) { - if (this._webSocket.readyState === WebSocket.OPEN) { + if (this._webSocket.readyState === Mopidy.WebSocket.OPEN) { return; } else { this._webSocket.close(); @@ -980,7 +1049,7 @@ Mopidy.prototype.connect = function () { } this._webSocket = this._settings.webSocket || - new WebSocket(this._settings.webSocketUrl); + new Mopidy.WebSocket(this._settings.webSocketUrl); this._webSocket.onclose = function (close) { this.emit("websocket:close", close); @@ -1045,17 +1114,17 @@ Mopidy.prototype._send = function (message) { var deferred = when.defer(); switch (this._webSocket.readyState) { - case WebSocket.CONNECTING: + case Mopidy.WebSocket.CONNECTING: deferred.resolver.reject({ message: "WebSocket is still connecting" }); break; - case WebSocket.CLOSING: + case Mopidy.WebSocket.CLOSING: deferred.resolver.reject({ message: "WebSocket is closing" }); break; - case WebSocket.CLOSED: + case Mopidy.WebSocket.CLOSED: deferred.resolver.reject({ message: "WebSocket is closed" }); @@ -1137,7 +1206,7 @@ Mopidy.prototype._handleEvent = function (eventMessage) { }; Mopidy.prototype._getApiSpec = function () { - this._send({method: "core.describe"}) + return this._send({method: "core.describe"}) .then(this._createApi.bind(this), this._handleWebSocketError) .then(null, this._handleWebSocketError); }; @@ -1189,3 +1258,7 @@ Mopidy.prototype._snakeToCamel = function (name) { return match.toUpperCase().replace("_", ""); }); }; + +if (typeof exports === "object") { + exports.Mopidy = Mopidy; +} diff --git a/mopidy/frontends/http/data/mopidy.min.js b/mopidy/frontends/http/data/mopidy.min.js index e727cefd..08ee1dac 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-01-16 +/*! Mopidy.js - built 2013-03-31 * http://www.mopidy.com/ * Copyright (c) 2013 Stein Magnus Jodal and contributors * Licensed under the Apache License, Version 2.0 */ -function Mopidy(e){if(!(this instanceof Mopidy))return new Mopidy(e);this._settings=this._configure(e||{}),this._console=this._getConsole(),this._backoffDelay=this._settings.backoffDelayMin,this._pendingRequests={},this._webSocket=null,bane.createEventEmitter(this),this._delegateEvents(),this._settings.autoConnect&&this.connect()}(typeof define=="function"&&define.amd&&function(e){define(e)}||typeof module=="object"&&function(e){module.exports=e()}||function(e){this.bane=e()})(function(){"use strict";function t(e,t,n){var r,i=n.length;if(i>0){for(r=0;r>>0,o=Math.max(0,Math.min(t,v)),a=[],u=v-o+1,l=[],c=f();if(!o)c.resolve(a);else{d=c.progress,p=function(e){l.push(e),--u||(h=p=w,c.reject(l))},h=function(e){a.push(e),--o||(h=p=w,c.resolve(a))};for(m=0;m>>0,n=[],l=f();if(!s)l.resolve(n);else{u=l.reject,o=function(i,o){r(i,t).then(function(e){n[o]=e,--s||l.resolve(n)},u)};for(a=0;a2;return r(e,function(e){return t.resolve(i?n:e)},t.reject,t.progress)}function y(e,t){var n,r=0;while(n=e[r++])n(t)}function b(e,t){var n,r=t.length;while(r>e){n=t[--r];if(n!=null&&typeof n!="function")throw new Error("arg "+r+" must be a function")}}function w(){}function E(e){return e}var e,t,n;return r.defer=f,r.resolve=i,r.reject=s,r.join=d,r.all=p,r.some=c,r.any=h,r.map=v,r.reduce=m,r.chain=g,r.isPromise=l,o.prototype={always:function(e,t){return this.then(e,e,t)},otherwise:function(e){return this.then(n,e)}},t=[].slice,e=[].reduce||function(e){var t,n,r,i,s;s=0,t=Object(this),i=t.length>>>0,n=arguments;if(n.length<=1)for(;;){if(s in t){r=t[s++];break}if(++s>=i)throw new TypeError}else r=n[1];for(;sthis._settings.backoffDelayMax&&(this._backoffDelay=this._settings.backoffDelayMax)},Mopidy.prototype._resetBackoffDelay=function(){this._backoffDelay=this._settings.backoffDelayMin},Mopidy.prototype.close=function(){this.off("state:offline",this._reconnect),this._webSocket.close()},Mopidy.prototype._handleWebSocketError=function(e){this._console.warn("WebSocket error:",e.stack||e)},Mopidy.prototype._send=function(e){var t=when.defer();switch(this._webSocket.readyState){case WebSocket.CONNECTING:t.resolver.reject({message:"WebSocket is still connecting"});break;case WebSocket.CLOSING:t.resolver.reject({message:"WebSocket is closing"});break;case WebSocket.CLOSED:t.resolver.reject({message:"WebSocket is closed"});break;default:e.jsonrpc="2.0",e.id=this._nextRequestId(),this._pendingRequests[e.id]=t.resolver,this._webSocket.send(JSON.stringify(e)),this.emit("websocket:outgoingMessage",e)}return t.promise},Mopidy.prototype._nextRequestId=function(){var e=-1;return function(){return e+=1,e}}(),Mopidy.prototype._handleMessage=function(e){try{var t=JSON.parse(e.data);t.hasOwnProperty("id")?this._handleResponse(t):t.hasOwnProperty("event")?this._handleEvent(t):this._console.warn("Unknown message type received. Message was: "+e.data)}catch(n){if(!(n instanceof SyntaxError))throw n;this._console.warn("WebSocket message parsing failed. Message was: "+e.data)}},Mopidy.prototype._handleResponse=function(e){if(!this._pendingRequests.hasOwnProperty(e.id)){this._console.warn("Unexpected response received. Message was:",e);return}var t=this._pendingRequests[e.id];delete this._pendingRequests[e.id],e.hasOwnProperty("result")?t.resolve(e.result):e.hasOwnProperty("error")?(t.reject(e.error),this._console.warn("Server returned error:",e.error)):(t.reject({message:"Response without 'result' or 'error' received",data:{response:e}}),this._console.warn("Response without 'result' or 'error' received. Message was:",e))},Mopidy.prototype._handleEvent=function(e){var t=e.event,n=e;delete n.event,this.emit("event:"+this._snakeToCamel(t),n)},Mopidy.prototype._getApiSpec=function(){this._send({method:"core.describe"}).then(this._createApi.bind(this),this._handleWebSocketError).then(null,this._handleWebSocketError)},Mopidy.prototype._createApi=function(e){var t=function(e){return function(){var t=Array.prototype.slice.call(arguments);return this._send({method:e,params:t})}.bind(this)}.bind(this),n=function(e){var t=e.split(".");return t.length>=1&&t[0]==="core"&&(t=t.slice(1)),t},r=function(e){var t=this;return e.forEach(function(e){e=this._snakeToCamel(e),t[e]=t[e]||{},t=t[e]}.bind(this)),t}.bind(this),i=function(i){var s=n(i),o=this._snakeToCamel(s.slice(-1)[0]),u=r(s.slice(0,-1));u[o]=t(i),u[o].description=e[i].description,u[o].params=e[i].params}.bind(this);Object.keys(e).forEach(i),this.emit("state:online")},Mopidy.prototype._snakeToCamel=function(e){return e.replace(/(_[a-z])/g,function(e){return e.toUpperCase().replace("_","")})}; \ No newline at end of file +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 diff --git a/mopidy/frontends/http/ws.py b/mopidy/frontends/http/ws.py index d325c359..7f24850f 100644 --- a/mopidy/frontends/http/ws.py +++ b/mopidy/frontends/http/ws.py @@ -20,6 +20,7 @@ class WebSocketResource(object): self._core = core_proxy inspector = jsonrpc.JsonRpcInspector( objects={ + 'core.get_uri_schemes': core.Core.get_uri_schemes, 'core.library': core.LibraryController, 'core.playback': core.PlaybackController, 'core.playlists': core.PlaylistsController, @@ -28,6 +29,7 @@ class WebSocketResource(object): self.jsonrpc = jsonrpc.JsonRpcWrapper( objects={ 'core.describe': inspector.describe, + 'core.get_uri_schemes': self._core.get_uri_schemes, 'core.library': self._core.library, 'core.playback': self._core.playback, 'core.playlists': self._core.playlists, diff --git a/mopidy/frontends/mpd/protocol/current_playlist.py b/mopidy/frontends/mpd/protocol/current_playlist.py index d1b0e59a..055d39e6 100644 --- a/mopidy/frontends/mpd/protocol/current_playlist.py +++ b/mopidy/frontends/mpd/protocol/current_playlist.py @@ -22,11 +22,9 @@ def add(context, uri): """ if not uri: return - tracks = context.core.library.lookup(uri).get() - if tracks: - context.core.tracklist.add(tracks) - return - raise MpdNoExistError('directory or file not found', command='add') + tl_tracks = context.core.tracklist.add(uri=uri).get() + if not tl_tracks: + raise MpdNoExistError('directory or file not found', command='add') @handle_request(r'^addid "(?P[^"]*)"( "(?P\d+)")*$') @@ -52,12 +50,11 @@ def addid(context, uri, songpos=None): raise MpdNoExistError('No such song', command='addid') if songpos is not None: songpos = int(songpos) - tracks = context.core.library.lookup(uri).get() - if not tracks: - raise MpdNoExistError('No such song', command='addid') if songpos and songpos > context.core.tracklist.length.get(): raise MpdArgError('Bad song index', command='addid') - tl_tracks = context.core.tracklist.add(tracks, at_position=songpos).get() + tl_tracks = context.core.tracklist.add(uri=uri, at_position=songpos).get() + if not tl_tracks: + raise MpdNoExistError('No such song', command='addid') return ('Id', tl_tracks[0].tlid) diff --git a/mopidy/frontends/mpris/objects.py b/mopidy/frontends/mpris/objects.py index 15ef9383..04a72676 100644 --- a/mopidy/frontends/mpris/objects.py +++ b/mopidy/frontends/mpris/objects.py @@ -279,9 +279,8 @@ class MprisObject(dbus.service.Object): return # NOTE Check if URI has MIME type known to the backend, if MIME support # is added to the backend. - tracks = self.core.library.lookup(uri).get() - if tracks: - tl_tracks = self.core.tracklist.add(tracks).get() + tl_tracks = self.core.tracklist.add(uri=uri).get() + if tl_tracks: self.core.playback.play(tl_tracks[0]) else: logger.debug('Track with URI "%s" not found in library.', uri) @@ -375,6 +374,12 @@ class MprisObject(dbus.service.Object): artists.sort(key=lambda a: a.name) metadata['xesam:albumArtist'] = dbus.Array( [a.name for a in artists if a.name], signature='s') + if track.album and track.album.images: + url = list(track.album.images)[0] + if url: + metadata['mpris:artUrl'] = url + if track.disc_no: + metadata['xesam:discNumber'] = track.disc_no if track.track_no: metadata['xesam:trackNumber'] = track.track_no return dbus.Dictionary(metadata, signature='sv') diff --git a/mopidy/models.py b/mopidy/models.py index e14fd8b4..4020febb 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -203,6 +203,9 @@ class Album(ImmutableObject): #: The album image URIs. Read-only. images = frozenset() + # XXX If we want to keep the order of images we shouldn't use frozenset() + # as it doesn't preserve order. I'm deferring this issue until we got + # actual usage of this field with more than one image. def __init__(self, *args, **kwargs): # NOTE kwargs dict keys must be bytestrings to work on Python < 2.6.5 diff --git a/mopidy/scanner.py b/mopidy/scanner.py index bfeb9fd1..4b057774 100644 --- a/mopidy/scanner.py +++ b/mopidy/scanner.py @@ -153,7 +153,8 @@ class Scanner(object): self.fakesink.connect('handoff', self.process_handoff) self.uribin = gst.element_factory_make('uridecodebin') - self.uribin.set_property('caps', gst.Caps(b'audio/x-raw-int')) + self.uribin.set_property('caps', + gst.Caps(b'audio/x-raw-int; audio/x-raw-float')) self.uribin.connect('pad-added', self.process_new_pad) self.pipe = gst.element_factory_make('pipeline') diff --git a/mopidy/settings.py b/mopidy/settings.py index fd3dfd6f..d0d279c2 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -20,7 +20,7 @@ from __future__ import unicode_literals #: BACKENDS = ( #: u'mopidy.backends.local.LocalBackend', #: u'mopidy.backends.spotify.SpotifyBackend', -#: u'mopidy.backends.spotify.StreamBackend', +#: u'mopidy.backends.stream.StreamBackend', #: ) BACKENDS = ( 'mopidy.backends.local.LocalBackend', @@ -49,14 +49,6 @@ DEBUG_LOG_FORMAT = '%(levelname)-8s %(asctime)s' + \ #: DEBUG_LOG_FILENAME = u'mopidy.log' DEBUG_LOG_FILENAME = 'mopidy.log' -#: If we should start a background thread that dumps thread's traceback when we -#: get a SIGUSR1. Mainly a debug tool for figuring out deadlocks. -#: -#: Default:: -#: -#: DEBUG_THREAD = False -DEBUG_THREAD = False - #: Location of the Mopidy .desktop file. #: #: Used by :mod:`mopidy.frontends.mpris`. diff --git a/mopidy/utils/jsonrpc.py b/mopidy/utils/jsonrpc.py index 8230aada..503921dc 100644 --- a/mopidy/utils/jsonrpc.py +++ b/mopidy/utils/jsonrpc.py @@ -181,7 +181,7 @@ class JsonRpcWrapper(object): data='"params", if given, must be an array or an object') def _get_method(self, method_path): - if inspect.isroutine(self.objects.get(method_path, None)): + if callable(self.objects.get(method_path, None)): # The mounted object is the callable return self.objects[method_path] diff --git a/mopidy/utils/process.py b/mopidy/utils/process.py index 6be8937c..c8c85f59 100644 --- a/mopidy/utils/process.py +++ b/mopidy/utils/process.py @@ -2,10 +2,8 @@ from __future__ import unicode_literals import logging import signal -import sys import thread import threading -import traceback from pykka import ActorDeadError from pykka.registry import ActorRegistry @@ -79,29 +77,3 @@ class BaseThread(threading.Thread): def run_inside_try(self): raise NotImplementedError - - -class DebugThread(threading.Thread): - daemon = True - name = 'DebugThread' - - event = threading.Event() - - def handler(self, signum, frame): - logger.info('Got %s signal', SIGNALS[signum]) - self.event.set() - - def run(self): - while True: - self.event.wait() - threads = dict((t.ident, t.name) for t in threading.enumerate()) - - for ident, frame in sys._current_frames().items(): - if self.ident != ident: - stack = ''.join(traceback.format_stack(frame)) - logger.debug( - 'Current state of %s (%s):\n%s', - threads.get(ident, '?'), ident, stack) - del frame - - self.event.clear() diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index 6eb462ce..8ae61e5b 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -142,7 +142,13 @@ def validate_settings(defaults, settings): 'SPOTIFY_LIB_CACHE': 'SPOTIFY_CACHE_PATH', } - list_of_one_or_more = [ + must_be_iterable = [ + 'BACKENDS', + 'FRONTENDS', + 'STREAM_PROTOCOLS', + ] + + must_have_value_set = [ 'BACKENDS', 'FRONTENDS', ] @@ -171,13 +177,13 @@ def validate_settings(defaults, settings): 'Deprecated setting, please set the value via the GStreamer ' 'bin in OUTPUT.') - elif setting in list_of_one_or_more: - if not hasattr(value, '__iter__'): - errors[setting] = ( - 'Must be a tuple. ' - "Remember the comma after single values: (u'value',)") - if not value: - errors[setting] = 'Must contain at least one value.' + elif setting in must_be_iterable and not hasattr(value, '__iter__'): + errors[setting] = ( + 'Must be a tuple. ' + "Remember the comma after single values: (u'value',)") + + elif setting in must_have_value_set and not value: + errors[setting] = 'Must be set.' elif setting not in defaults and not setting.startswith('CUSTOM_'): errors[setting] = 'Unknown setting.' diff --git a/requirements/core.txt b/requirements/core.txt index 7a28564f..9ffac2cf 100644 --- a/requirements/core.txt +++ b/requirements/core.txt @@ -1,2 +1,2 @@ -Pykka >= 1.0 +Pykka >= 1.1 # Available as python-pykka from apt.mopidy.com diff --git a/tests/backends/base/events.py b/tests/backends/base/events.py index 2b6df347..1d31a721 100644 --- a/tests/backends/base/events.py +++ b/tests/backends/base/events.py @@ -10,7 +10,7 @@ from mopidy.backends import listener @mock.patch.object(listener.BackendListener, 'send') class BackendEventsTest(object): def setUp(self): - self.audio = mock.Mock(spec=audio.Audio) + self.audio = audio.DummyAudio.start().proxy() self.backend = self.backend_class.start(audio=self.audio).proxy() self.core = core.Core.start(backends=[self.backend]).proxy() diff --git a/tests/backends/base/playback.py b/tests/backends/base/playback.py index 09dffbab..e12d54a5 100644 --- a/tests/backends/base/playback.py +++ b/tests/backends/base/playback.py @@ -4,6 +4,8 @@ import mock import random import time +import pykka + from mopidy import audio, core from mopidy.core import PlaybackState from mopidy.models import Track @@ -18,7 +20,7 @@ class PlaybackControllerTest(object): tracks = [] def setUp(self): - self.audio = mock.Mock(spec=audio.Audio) + self.audio = audio.DummyAudio.start().proxy() self.backend = self.backend_class.start(audio=self.audio).proxy() self.core = core.Core(backends=[self.backend]) self.playback = self.core.playback @@ -29,6 +31,9 @@ class PlaybackControllerTest(object): assert self.tracks[0].length >= 2000, \ 'First song needs to be at least 2000 miliseconds' + def tearDown(self): + pykka.ActorRegistry.stop_all() + def test_initial_state_is_stopped(self): self.assertEqual(self.playback.state, PlaybackState.STOPPED) diff --git a/tests/backends/base/playlists.py b/tests/backends/base/playlists.py index c162e500..2184168f 100644 --- a/tests/backends/base/playlists.py +++ b/tests/backends/base/playlists.py @@ -4,7 +4,6 @@ import os import shutil import tempfile -import mock import pykka from mopidy import audio, core, settings @@ -19,7 +18,7 @@ class PlaylistsControllerTest(object): settings.LOCAL_TAG_CACHE_FILE = path_to_data_dir('library_tag_cache') settings.LOCAL_MUSIC_PATH = path_to_data_dir('') - self.audio = mock.Mock(spec=audio.Audio) + self.audio = audio.DummyAudio.start().proxy() self.backend = self.backend_class.start(audio=self.audio).proxy() self.core = core.Core(backends=[self.backend]) diff --git a/tests/backends/base/tracklist.py b/tests/backends/base/tracklist.py index 71f44018..39fb020d 100644 --- a/tests/backends/base/tracklist.py +++ b/tests/backends/base/tracklist.py @@ -1,6 +1,5 @@ from __future__ import unicode_literals -import mock import random import pykka @@ -16,9 +15,9 @@ class TracklistControllerTest(object): tracks = [] def setUp(self): - self.audio = mock.Mock(spec=audio.Audio) + self.audio = audio.DummyAudio.start().proxy() self.backend = self.backend_class.start(audio=self.audio).proxy() - self.core = core.Core(audio=audio, backends=[self.backend]) + self.core = core.Core(audio=self.audio, backends=[self.backend]) self.controller = self.core.tracklist self.playback = self.core.playback diff --git a/tests/core/library_test.py b/tests/core/library_test.py index e01696c7..6e9d240a 100644 --- a/tests/core/library_test.py +++ b/tests/core/library_test.py @@ -87,8 +87,27 @@ class CoreLibraryTest(unittest.TestCase): self.assertIn(result1, result) self.assertIn(result2, result) - self.library1.find_exact.assert_called_once_with(any=['a']) - self.library2.find_exact.assert_called_once_with(any=['a']) + self.library1.find_exact.assert_called_once_with( + query=dict(any=['a']), uris=None) + self.library2.find_exact.assert_called_once_with( + query=dict(any=['a']), uris=None) + + def test_find_exact_with_uris_selects_dummy1_backend(self): + self.core.library.find_exact( + any=['a'], uris=['dummy1:', 'dummy1:foo', 'dummy3:']) + + self.library1.find_exact.assert_called_once_with( + query=dict(any=['a']), uris=['dummy1:', 'dummy1:foo']) + self.assertFalse(self.library2.find_exact.called) + + def test_find_exact_with_uris_selects_both_backends(self): + self.core.library.find_exact( + any=['a'], uris=['dummy1:', 'dummy1:foo', 'dummy2:']) + + self.library1.find_exact.assert_called_once_with( + query=dict(any=['a']), uris=['dummy1:', 'dummy1:foo']) + self.library2.find_exact.assert_called_once_with( + query=dict(any=['a']), uris=['dummy2:']) def test_find_exact_filters_out_none(self): track1 = Track(uri='dummy1:a') @@ -103,8 +122,10 @@ class CoreLibraryTest(unittest.TestCase): self.assertIn(result1, result) self.assertNotIn(None, result) - self.library1.find_exact.assert_called_once_with(any=['a']) - self.library2.find_exact.assert_called_once_with(any=['a']) + self.library1.find_exact.assert_called_once_with( + query=dict(any=['a']), uris=None) + self.library2.find_exact.assert_called_once_with( + query=dict(any=['a']), uris=None) def test_find_accepts_query_dict_instead_of_kwargs(self): track1 = Track(uri='dummy1:a') @@ -121,8 +142,10 @@ class CoreLibraryTest(unittest.TestCase): self.assertIn(result1, result) self.assertIn(result2, result) - self.library1.find_exact.assert_called_once_with(any=['a']) - self.library2.find_exact.assert_called_once_with(any=['a']) + self.library1.find_exact.assert_called_once_with( + query=dict(any=['a']), uris=None) + self.library2.find_exact.assert_called_once_with( + query=dict(any=['a']), uris=None) def test_search_combines_results_from_all_backends(self): track1 = Track(uri='dummy1:a') @@ -139,8 +162,27 @@ class CoreLibraryTest(unittest.TestCase): self.assertIn(result1, result) self.assertIn(result2, result) - self.library1.search.assert_called_once_with(any=['a']) - self.library2.search.assert_called_once_with(any=['a']) + self.library1.search.assert_called_once_with( + query=dict(any=['a']), uris=None) + self.library2.search.assert_called_once_with( + query=dict(any=['a']), uris=None) + + def test_search_with_uris_selects_dummy1_backend(self): + self.core.library.search( + query=dict(any=['a']), uris=['dummy1:', 'dummy1:foo', 'dummy3:']) + + self.library1.search.assert_called_once_with( + query=dict(any=['a']), uris=['dummy1:', 'dummy1:foo']) + self.assertFalse(self.library2.search.called) + + def test_search_with_uris_selects_both_backends(self): + self.core.library.search( + query=dict(any=['a']), uris=['dummy1:', 'dummy1:foo', 'dummy2:']) + + self.library1.search.assert_called_once_with( + query=dict(any=['a']), uris=['dummy1:', 'dummy1:foo']) + self.library2.search.assert_called_once_with( + query=dict(any=['a']), uris=['dummy2:']) def test_search_filters_out_none(self): track1 = Track(uri='dummy1:a') @@ -155,8 +197,10 @@ class CoreLibraryTest(unittest.TestCase): self.assertIn(result1, result) self.assertNotIn(None, result) - self.library1.search.assert_called_once_with(any=['a']) - self.library2.search.assert_called_once_with(any=['a']) + self.library1.search.assert_called_once_with( + query=dict(any=['a']), uris=None) + self.library2.search.assert_called_once_with( + query=dict(any=['a']), uris=None) def test_search_accepts_query_dict_instead_of_kwargs(self): track1 = Track(uri='dummy1:a') @@ -173,5 +217,7 @@ class CoreLibraryTest(unittest.TestCase): self.assertIn(result1, result) self.assertIn(result2, result) - self.library1.search.assert_called_once_with(any=['a']) - self.library2.search.assert_called_once_with(any=['a']) + self.library1.search.assert_called_once_with( + query=dict(any=['a']), uris=None) + self.library2.search.assert_called_once_with( + query=dict(any=['a']), uris=None) diff --git a/tests/core/tracklist_test.py b/tests/core/tracklist_test.py index 550cfe63..93d914ed 100644 --- a/tests/core/tracklist_test.py +++ b/tests/core/tracklist_test.py @@ -1,5 +1,8 @@ from __future__ import unicode_literals +import mock + +from mopidy.backends import base from mopidy.core import Core from mopidy.models import Track @@ -9,13 +12,31 @@ from tests import unittest class TracklistTest(unittest.TestCase): def setUp(self): self.tracks = [ - Track(uri='a', name='foo'), - Track(uri='b', name='foo'), - Track(uri='c', name='bar') + Track(uri='dummy1:a', name='foo'), + Track(uri='dummy1:b', name='foo'), + Track(uri='dummy1:c', name='bar'), ] - self.core = Core(audio=None, backends=[]) + + self.backend = mock.Mock() + self.backend.uri_schemes.get.return_value = ['dummy1'] + self.library = mock.Mock(spec=base.BaseLibraryProvider) + self.backend.library = self.library + + self.core = Core(audio=None, backends=[self.backend]) self.tl_tracks = self.core.tracklist.add(self.tracks) + def test_add_by_uri_looks_up_uri_in_library(self): + track = Track(uri='dummy1:x', name='x') + self.library.lookup().get.return_value = [track] + self.library.lookup.reset_mock() + + tl_tracks = self.core.tracklist.add(uri='dummy1:x') + + self.library.lookup.assert_called_once_with('dummy1:x') + self.assertEqual(1, len(tl_tracks)) + self.assertEqual(track, tl_tracks[0].track) + self.assertEqual(tl_tracks, self.core.tracklist.tl_tracks[-1:]) + def test_remove_removes_tl_tracks_matching_query(self): tl_tracks = self.core.tracklist.remove(name='foo') diff --git a/tests/data/scanner/simple/song1.ogg b/tests/data/scanner/simple/song1.ogg new file mode 100644 index 00000000..18f5a2d9 Binary files /dev/null and b/tests/data/scanner/simple/song1.ogg differ diff --git a/tests/frontends/mpris/player_interface_test.py b/tests/frontends/mpris/player_interface_test.py index c48ffa98..0c477dc8 100644 --- a/tests/frontends/mpris/player_interface_test.py +++ b/tests/frontends/mpris/player_interface_test.py @@ -200,6 +200,37 @@ class PlayerInterfaceTest(unittest.TestCase): self.assertIn('xesam:albumArtist', result.keys()) self.assertEqual(result['xesam:albumArtist'], ['a', 'b']) + def test_get_metadata_use_first_album_image_as_art_url(self): + # XXX Currently, the album image order isn't preserved because they + # are stored as a frozenset(). We pick the first in the set, which is + # sorted alphabetically, thus we get 'bar.jpg', not 'foo.jpg', which + # would probably make more sense. + self.core.tracklist.add([Track(album=Album(images=[ + 'http://example.com/foo.jpg', 'http://example.com/bar.jpg']))]) + self.core.playback.play() + result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') + self.assertIn('mpris:artUrl', result.keys()) + self.assertEqual(result['mpris:artUrl'], 'http://example.com/bar.jpg') + + def test_get_metadata_has_no_art_url_if_no_album(self): + self.core.tracklist.add([Track()]) + self.core.playback.play() + result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') + self.assertNotIn('mpris:artUrl', result.keys()) + + def test_get_metadata_has_no_art_url_if_no_album_images(self): + self.core.tracklist.add([Track(Album(images=[]))]) + self.core.playback.play() + result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') + self.assertNotIn('mpris:artUrl', result.keys()) + + def test_get_metadata_has_disc_number_in_album(self): + self.core.tracklist.add([Track(disc_no=2)]) + self.core.playback.play() + result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') + self.assertIn('xesam:discNumber', result.keys()) + self.assertEqual(result['xesam:discNumber'], 2) + def test_get_metadata_has_track_number_in_album(self): self.core.tracklist.add([Track(track_no=7)]) self.core.playback.play() diff --git a/tests/scanner_test.py b/tests/scanner_test.py index d8466e26..617f2537 100644 --- a/tests/scanner_test.py +++ b/tests/scanner_test.py @@ -172,22 +172,29 @@ class ScannerTest(unittest.TestCase): self.check( 'scanner/simple/song1.mp3', 'uri', 'file://%s' % path_to_data_dir('scanner/simple/song1.mp3')) + self.check( + 'scanner/simple/song1.ogg', 'uri', + 'file://%s' % path_to_data_dir('scanner/simple/song1.ogg')) def test_duration_is_set(self): self.scan('scanner/simple') self.check('scanner/simple/song1.mp3', 'duration', 4680) + self.check('scanner/simple/song1.ogg', 'duration', 4680) def test_artist_is_set(self): self.scan('scanner/simple') self.check('scanner/simple/song1.mp3', 'artist', 'name') + self.check('scanner/simple/song1.ogg', 'artist', 'name') def test_album_is_set(self): self.scan('scanner/simple') self.check('scanner/simple/song1.mp3', 'album', 'albumname') + self.check('scanner/simple/song1.ogg', 'album', 'albumname') def test_track_is_set(self): self.scan('scanner/simple') self.check('scanner/simple/song1.mp3', 'title', 'trackname') + self.check('scanner/simple/song1.ogg', 'title', 'trackname') def test_nonexistant_folder_does_not_fail(self): self.scan('scanner/does-not-exist') diff --git a/tests/utils/jsonrpc_test.py b/tests/utils/jsonrpc_test.py index 44ec1b09..226d4614 100644 --- a/tests/utils/jsonrpc_test.py +++ b/tests/utils/jsonrpc_test.py @@ -48,6 +48,7 @@ class JsonRpcTestBase(unittest.TestCase): 'core': self.core, 'core.playback': self.core.playback, 'core.tracklist': self.core.tracklist, + 'get_uri_schemes': self.core.get_uri_schemes, }, encoders=[models.ModelJSONEncoder], decoders=[models.model_json_decoder]) @@ -188,6 +189,23 @@ class JsonRpcSingleCommandTest(JsonRpcTestBase): self.assertEqual(response['result'], None) + def test_call_method_which_is_a_directly_mounted_actor_member(self): + # 'get_uri_schemes' isn't a regular callable, but a Pykka + # CallableProxy. This test checks that CallableProxy objects are + # threated by JsonRpcWrapper like any other callable. + + request = { + 'jsonrpc': '2.0', + 'method': 'get_uri_schemes', + 'id': 1, + } + response = self.jrw.handle_data(request) + + self.assertEqual(response['jsonrpc'], '2.0') + self.assertEqual(response['id'], 1) + self.assertNotIn('error', response) + self.assertEqual(response['result'], ['dummy']) + def test_call_method_with_positional_params(self): request = { 'jsonrpc': '2.0', @@ -588,6 +606,7 @@ class JsonRpcInspectorTest(JsonRpcTestBase): def test_inspector_can_describe_a_bunch_of_large_classes(self): inspector = jsonrpc.JsonRpcInspector({ + 'core.get_uri_schemes': core.Core.get_uri_schemes, 'core.library': core.LibraryController, 'core.playback': core.PlaybackController, 'core.playlists': core.PlaylistsController, @@ -596,6 +615,9 @@ class JsonRpcInspectorTest(JsonRpcTestBase): methods = inspector.describe() + self.assertIn('core.get_uri_schemes', methods) + self.assertEquals(len(methods['core.get_uri_schemes']['params']), 0) + self.assertIn('core.library.lookup', methods.keys()) self.assertEquals( methods['core.library.lookup']['params'][0]['name'], 'uri') diff --git a/tests/utils/settings_test.py b/tests/utils/settings_test.py index 1dcac1bb..51f0d89c 100644 --- a/tests/utils/settings_test.py +++ b/tests/utils/settings_test.py @@ -79,13 +79,13 @@ class ValidateSettingsTest(unittest.TestCase): result = setting_utils.validate_settings( self.defaults, {'FRONTENDS': []}) self.assertEqual( - result['FRONTENDS'], 'Must contain at least one value.') + result['FRONTENDS'], 'Must be set.') def test_empty_backends_list_returns_error(self): result = setting_utils.validate_settings( self.defaults, {'BACKENDS': []}) self.assertEqual( - result['BACKENDS'], 'Must contain at least one value.') + result['BACKENDS'], 'Must be set.') def test_noniterable_multivalue_setting_returns_error(self): result = setting_utils.validate_settings( diff --git a/tests/version_test.py b/tests/version_test.py index a444ea1f..b199bb1f 100644 --- a/tests/version_test.py +++ b/tests/version_test.py @@ -35,5 +35,6 @@ class VersionTest(unittest.TestCase): self.assertLess(SV('0.9.0'), SV('0.10.0')) self.assertLess(SV('0.10.0'), SV('0.11.0')) self.assertLess(SV('0.11.0'), SV('0.11.1')) - self.assertLess(SV('0.11.1'), SV(__version__)) - self.assertLess(SV(__version__), SV('0.12.1')) + self.assertLess(SV('0.11.1'), SV('0.12.0')) + self.assertLess(SV('0.12.0'), SV(__version__)) + self.assertLess(SV(__version__), SV('0.13.1'))