diff --git a/.gitignore b/.gitignore index 863c9796..990d75ca 100644 --- a/.gitignore +++ b/.gitignore @@ -13,8 +13,7 @@ cover/ coverage.xml dist/ docs/_build/ -js/test/lib/ mopidy.log* -node_modules/ nosetests.xml xunit-*.xml +tmp/ diff --git a/.mailmap b/.mailmap index 45935be6..54e01b7d 100644 --- a/.mailmap +++ b/.mailmap @@ -5,6 +5,9 @@ Kristian Klette Johannes Knutsen Johannes Knutsen John Bäckstrand +David Caruso +Adam Rigg +Ernst Bammer Alli Witheford Alexandre Petitjean Alexandre Petitjean @@ -15,5 +18,9 @@ Janez Troha Janez Troha Luke Giuliani Colin Montgomerie +Nathan Harper Ignasi Fosch Christopher Schirner +Laura Barber +John Cass +Ronald Zielaznicki diff --git a/.travis.yml b/.travis.yml index 8e14280f..2058fcc7 100644 --- a/.travis.yml +++ b/.travis.yml @@ -23,6 +23,10 @@ script: after_success: - "if [ $TOX_ENV == 'py27' ]; then pip install coveralls; coveralls; fi" +branches: + except: + - debian + notifications: irc: channels: diff --git a/AUTHORS b/AUTHORS index b7bad761..91b71008 100644 --- a/AUTHORS +++ b/AUTHORS @@ -8,14 +8,14 @@ - John Bäckstrand - Fred Hatfull - Erling Børresen -- David C +- David Caruso - Christian Johansen - Matt Bray - Trygve Aaberge - Wouter van Wijk - Jeremy B. Merrill -- 0xadam -- herrernst +- Adam Rigg +- Ernst Bammer - Nick Steel - Zan Dobersek - Thomas Refis @@ -36,7 +36,7 @@ - Colin Montgomerie - Simon de Bakker - Arnaud Barisain-Monrose -- nathanharper +- Nathan Harper - Pierpaolo Frasa - Thomas Scholtes - Sam Willcocks @@ -44,5 +44,12 @@ - Arjun Naik - Christopher Schirner - Dmitry Sandalov -- Deni Bertovic +- Lukas Vogel - Thomas Amland +- Deni Bertovic +- Ali Ukani +- Dirk Groenen +- John Cass +- Laura Barber +- Jakab Kristóf +- Ronald Zielaznicki diff --git a/MANIFEST.in b/MANIFEST.in index 000fc1ad..5a99b8b8 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -9,14 +9,10 @@ include LICENSE include MANIFEST.in include tox.ini -recursive-include data * - recursive-include docs * prune docs/_build -recursive-include js * -prune js/node_modules -prune js/test/lib +recursive-include extra * recursive-include mopidy *.conf recursive-include mopidy/http/data * diff --git a/dev-requirements.txt b/dev-requirements.txt index b7367219..eba66348 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,5 +1,5 @@ # Automate tasks -fabric +invoke # Build documentation sphinx @@ -12,12 +12,11 @@ flake8-import-order mock # Test runners -nose +pytest +pytest-cov +pytest-xdist tox -# Measure test's code coverage -coverage - # Check that MANIFEST.in matches Git repo contents before making a release check-manifest diff --git a/docs/api/audio.rst b/docs/api/audio.rst index 550ca890..76389fb4 100644 --- a/docs/api/audio.rst +++ b/docs/api/audio.rst @@ -35,3 +35,9 @@ Audio scanner .. autoclass:: mopidy.audio.scan.Scanner :members: + +Audio utils +=========== + +.. automodule:: mopidy.audio.utils + :members: diff --git a/docs/api/concepts.rst b/docs/api/concepts.rst index 68718935..9c542777 100644 --- a/docs/api/concepts.rst +++ b/docs/api/concepts.rst @@ -6,14 +6,15 @@ Architecture and concepts The overall architecture of Mopidy is organized around multiple frontends and backends. The frontends use the core API. The core actor makes multiple backends -work as one. The backends connect to various music sources. Both the core actor -and the backends use the audio actor to play audio and control audio volume. +work as one. The backends connect to various music sources. The core actor use +the mixer actor to control volume, while the backends use the audio actor to +play audio. .. digraph:: overall_architecture "Multiple frontends" -> Core Core -> "Multiple backends" - Core -> Audio + Core -> Mixer "Multiple backends" -> Audio @@ -21,15 +22,16 @@ Frontends ========= Frontends expose Mopidy to the external world. They can implement servers for -protocols like MPD and MPRIS, and they can be used to update other services -when something happens in Mopidy, like the Last.fm scrobbler frontend does. See -:ref:`frontend-api` for more details. +protocols like HTTP, MPD and MPRIS, and they can be used to update other +services when something happens in Mopidy, like the Last.fm scrobbler frontend +does. See :ref:`frontend-api` for more details. .. digraph:: frontend_architecture + "HTTP\nfrontend" -> Core "MPD\nfrontend" -> Core "MPRIS\nfrontend" -> Core - "Last.fm\nfrontend" -> Core + "Scrobbler\nfrontend" -> Core Core @@ -54,6 +56,7 @@ See :ref:`core-api` for more details. Core -> "Library\ncontroller" Core -> "Playback\ncontroller" Core -> "Playlists\ncontroller" + Core -> "History\ncontroller" "Library\ncontroller" -> "Local backend" "Library\ncontroller" -> "Spotify backend" @@ -93,7 +96,16 @@ Audio ===== The audio actor is a thin wrapper around the parts of the GStreamer library we -use. In addition to playback, it's responsible for volume control through both -GStreamer's own volume mixers, and mixers we've created ourselves. If you -implement an advanced backend, you may need to implement your own playback -provider using the :ref:`audio-api`. +use. If you implement an advanced backend, you may need to implement your own +playback provider using the :ref:`audio-api`, but most backends can use the +default playback provider without any changes. + + +Mixer +===== + +The mixer actor is responsible for volume control and muting. The default +mixer use the audio actor to control volume in software. The alternative +implementations are typically independent of the audio actor, but instead use +some third party Python library or a serial interface to control other forms +of volume controls. diff --git a/docs/api/core.rst b/docs/api/core.rst index 38cc0f0a..27ab2f57 100644 --- a/docs/api/core.rst +++ b/docs/api/core.rst @@ -37,6 +37,15 @@ Manages everything related to the tracks we are currently playing. :members: +History controller +================== + +Keeps record of what tracks have been played. + +.. autoclass:: mopidy.core.HistoryController + :members: + + Playlists controller ==================== @@ -55,6 +64,14 @@ Manages the music library, e.g. searching for tracks to be added to a playlist. :members: +Mixer controller +================ + +Manages volume and muting. + +.. autoclass:: mopidy.core.MixerController + :members: + Core listener ============= diff --git a/docs/api/http-server.rst b/docs/api/http-server.rst index ee6f55fb..317a77c5 100644 --- a/docs/api/http-server.rst +++ b/docs/api/http-server.rst @@ -43,7 +43,7 @@ available at http://localhost:6680/mywebclient/foo.html. :: - from __future__ import unicode_literals + from __future__ import absolute_import, unicode_literals import os @@ -95,7 +95,7 @@ Mopidy $version``. :: - from __future__ import unicode_literals + from __future__ import absolute_import, unicode_literals import os @@ -149,7 +149,7 @@ http://localhost:6680/mywebclient/. :: - from __future__ import unicode_literals + from __future__ import absolute_import, unicode_literals import os diff --git a/docs/api/http.rst b/docs/api/http.rst index 3eff14fd..9a7d56bb 100644 --- a/docs/api/http.rst +++ b/docs/api/http.rst @@ -14,18 +14,6 @@ WebSocket API for use both from browsers and Node.js. The :ref:`http-explore-extension` extension, can also be used to get you familiarized with HTTP based APIs. -.. warning:: API stability - - Since the HTTP JSON-RPC API exposes our internal core API directly it is to - be regarded as **experimental**. We cannot promise to keep any form of - backwards compatibility between releases as we will need to change the core - API while working out how to support new use cases. Thus, if you use this - API, you must expect to do small adjustments to your client for every - release of Mopidy. - - From Mopidy 1.0 and onwards, we intend to keep the core API far more - stable. - .. _http-post-api: diff --git a/docs/api/index.rst b/docs/api/index.rst index 5aac825c..2402186e 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -4,13 +4,10 @@ API reference ************* -.. warning:: API stability +.. note:: What is public? Only APIs documented here are public and open for use by Mopidy - extensions. We will change these APIs, but will keep the changelog up to - date with all breaking changes. - - From Mopidy 1.0 and onwards, we intend to keep these APIs far more stable. + extensions. .. toctree:: diff --git a/docs/api/js.rst b/docs/api/js.rst index 372e7f4e..29866d14 100644 --- a/docs/api/js.rst +++ b/docs/api/js.rst @@ -8,18 +8,6 @@ We've made a JavaScript library, Mopidy.js, which wraps the :ref:`websocket-api` and gets you quickly started with working on your client instead of figuring out how to communicate with Mopidy. -.. warning:: API stability - - Since the Mopidy.js API exposes our internal core API directly it is to be - regarded as **experimental**. We cannot promise to keep any form of - backwards compatibility between releases as we will need to change the core - API while working out how to support new use cases. Thus, if you use this - API, you must expect to do small adjustments to your client for every - release of Mopidy. - - From Mopidy 1.0 and onwards, we intend to keep the core API far more - stable. - Getting the library for browser use =================================== @@ -66,9 +54,10 @@ After npm completes, you can import Mopidy.js using ``require()``: 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.md`` will guide you on your way. +If you want to work on the Mopidy.js library itself, you'll find the source +code and a complete development setup in the `Mopidy.js Git repo +`_. The instructions in ``README.md`` will +guide you on your way. Creating an instance @@ -288,9 +277,10 @@ unhandled errors. In general, unhandled errors will not go silently missing. The promise objects returned by Mopidy.js adheres to the `CommonJS Promises/A `_ standard. We use the -implementation known as `when.js `_. Please -refer to when.js' documentation or the standard for further details on how to -work with promise objects. +implementation known as `when.js `_, and +reexport it as ``Mopidy.when`` so you don't have to duplicate the dependency. +Please refer to when.js' documentation or the standard for further details on +how to work with promise objects. Cleaning up diff --git a/docs/api/models.rst b/docs/api/models.rst index 11ec017c..23a08002 100644 --- a/docs/api/models.rst +++ b/docs/api/models.rst @@ -28,19 +28,54 @@ Data model relations .. digraph:: model_relations - Playlist -> Track [ label="has 0..n" ] - Track -> Album [ label="has 0..1" ] - Track -> Artist [ label="has 0..n" ] - Album -> Artist [ label="has 0..n" ] + Ref -> Album [ style="dotted", weight=1 ] + Ref -> Artist [ style="dotted", weight=1 ] + Ref -> Directory [ style="dotted", weight=1 ] + Ref -> Playlist [ style="dotted", weight=1 ] + Ref -> Track [ style="dotted", weight=1 ] - SearchResult -> Artist [ label="has 0..n" ] - SearchResult -> Album [ label="has 0..n" ] - SearchResult -> Track [ label="has 0..n" ] + Playlist -> Track [ label="has 0..n", weight=2 ] + Track -> Album [ label="has 0..1", weight=10 ] + Track -> Artist [ label="has 0..n", weight=10 ] + Album -> Artist [ label="has 0..n", weight=10 ] + + Image + + SearchResult -> Artist [ label="has 0..n", weight=1 ] + SearchResult -> Album [ label="has 0..n", weight=1 ] + SearchResult -> Track [ label="has 0..n", weight=1 ] + + TlTrack -> Track [ label="has 1", weight=20 ] Data model API ============== -.. automodule:: mopidy.models +.. module:: mopidy.models :synopsis: Data model API - :members: + +.. autoclass:: mopidy.models.Ref + +.. autoclass:: mopidy.models.Track + +.. autoclass:: mopidy.models.Album + +.. autoclass:: mopidy.models.Artist + +.. autoclass:: mopidy.models.Playlist + +.. autoclass:: mopidy.models.Image + +.. autoclass:: mopidy.models.TlTrack + +.. autoclass:: mopidy.models.SearchResult + + +Data model helpers +================== + +.. autoclass:: mopidy.models.ImmutableObject + +.. autoclass:: mopidy.models.ModelJSONEncoder + +.. autofunction:: mopidy.models.model_json_decoder diff --git a/docs/authors.rst b/docs/authors.rst index 1a0f21ed..90ec6f23 100644 --- a/docs/authors.rst +++ b/docs/authors.rst @@ -14,7 +14,7 @@ our Git repository. .. include:: ../AUTHORS -If you already enjoy Mopidy, or don't enjoy it and want to help us making -Mopidy better, the best way to do so is to contribute back to the community. -You can contribute code, documentation, tests, bug reports, or help other -users, spreading the word, etc. See :ref:`contributing` for a head start. +If want to help us making Mopidy better, the best way to do so is to contribute +back to the community, either through code, documentation, tests, bug reports, +or by helping other users, spreading the word, etc. See :ref:`contributing` for +a head start. diff --git a/docs/changelog.rst b/docs/changelog.rst index 1b88c489..b976c169 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -5,6 +5,463 @@ Changelog This changelog is used to track all major changes to Mopidy. +v1.0.0 (2015-03-25) +=================== + +Three months after our fifth anniversary, Mopidy 1.0 is finally here! + +Since the release of 0.19, we've closed or merged approximately 140 issues and +pull requests through more than 600 commits by a record high 19 extraordinary +people, including seven newcomers. Thanks to :ref:`everyone ` who has +:ref:`contributed `! + +For the longest time, the focus of Mopidy 1.0 was to be another incremental +improvement, to be numbered 0.20. The result is still very much an incremental +improvement, with lots of small and larger improvements across Mopidy's +functionality. + +The major features of Mopidy 1.0 are: + +- :ref:`Semantic Versioning `. We promise to not break APIs before + Mopidy 2.0. A Mopidy extension working with Mopidy 1.0 should continue to + work with all Mopidy 1.x releases. + +- Preparation work to ease migration to a cleaned up and leaner core API in + Mopidy 2.0, and to give us some of the benefits of the cleaned up core API + right away. + +- Preparation work to enable gapless playback in an upcoming 1.x release. + +Dependencies +------------ + +Since the previous release there are no changes to Mopidy's dependencies. +However, porting from GStreamer 0.10 to 1.x and support for running Mopidy with +Python 3.4+ is not far off on our roadmap. + +Core API +-------- + +In the API used by all frontends and web extensions there is lots of methods +and arguments that are now deprecated in preparation for the next major +release. With the exception of some internals that leaked out in the playback +controller, no core APIs have been removed in this release. In other words, +most clients should continue to work unchanged when upgrading to Mopidy 1.0. +Though, it is strongly encouraged to review any use of the deprecated parts of +the API as those parts will be removed in Mopidy 2.0. + +- **Deprecated:** Deprecate all Python properties in the core API. The + previously undocumented getter and setter methods are now the official API. + This aligns the Python API with the WebSocket/JavaScript API. Python + frontends needs to be updated. WebSocket/JavaScript API users are not + affected. (Fixes: :issue:`952`) + +- Add :class:`mopidy.core.HistoryController` which keeps track of what tracks + have been played. (Fixes: :issue:`423`, :issue:`1056`, PR: :issue:`803`, + :issue:`1063`) + +- Add :class:`mopidy.core.MixerController` which keeps track of volume and + mute. (Fixes: :issue:`962`) + +Core library controller +~~~~~~~~~~~~~~~~~~~~~~~ + +- **Deprecated:** :meth:`mopidy.core.LibraryController.find_exact`. Use + :meth:`mopidy.core.LibraryController.search` with the ``exact`` keyword + argument set to :class:`True`. + +- **Deprecated:** The ``uri`` argument to + :meth:`mopidy.core.LibraryController.lookup`. Use new ``uris`` keyword + argument instead. + +- Add ``exact`` keyword argument to + :meth:`mopidy.core.LibraryController.search`. + +- Add ``uris`` keyword argument to :meth:`mopidy.core.LibraryController.lookup` + which allows for simpler lookup of multiple URIs. (Fixes: :issue:`1008`, PR: + :issue:`1047`) + +- Updated :meth:`mopidy.core.LibraryController.search` and + :meth:`mopidy.core.LibraryController.find_exact` to normalize and warn about + malformed queries from clients. (Fixes: :issue:`1067`, PR: :issue:`1073`) + +- Add :meth:`mopidy.core.LibraryController.get_distinct` for getting unique + values for a given field. (Fixes: :issue:`913`, PR: :issue:`1022`) + +- Add :meth:`mopidy.core.LibraryController.get_images` for looking up images + for any URI that is known to the backends. (Fixes :issue:`973`, PR: + :issue:`981`, :issue:`992` and :issue:`1013`) + +Core playlist controller +~~~~~~~~~~~~~~~~~~~~~~~~ + +- **Deprecated:** :meth:`mopidy.core.PlaylistsController.get_playlists`. Use + :meth:`~mopidy.core.PlaylistsController.as_list` and + :meth:`~mopidy.core.PlaylistsController.get_items` instead. (Fixes: + :issue:`1057`, PR: :issue:`1075`) + +- **Deprecated:** :meth:`mopidy.core.PlaylistsController.filter`. Use + :meth:`~mopidy.core.PlaylistsController.as_list` and filter yourself. + +- Add :meth:`mopidy.core.PlaylistsController.as_list`. (Fixes: :issue:`1057`, + PR: :issue:`1075`) + +- Add :meth:`mopidy.core.PlaylistsController.get_items`. (Fixes: :issue:`1057`, + PR: :issue:`1075`) + +Core tracklist controller +~~~~~~~~~~~~~~~~~~~~~~~~~ + +- **Removed:** The following methods were documented as internal. They are now + fully private and unavailable outside the core actor. (Fixes: :issue:`1058`, + PR: :issue:`1062`) + + - :meth:`mopidy.core.TracklistController.mark_played` + - :meth:`mopidy.core.TracklistController.mark_playing` + - :meth:`mopidy.core.TracklistController.mark_unplayable` + +- Add ``uris`` argument to :meth:`mopidy.core.TracklistController.add` which + allows for simpler addition of multiple URIs to the tracklist. (Fixes: + :issue:`1060`, PR: :issue:`1065`) + +Core playback controller +~~~~~~~~~~~~~~~~~~~~~~~~ + +- **Removed:** Remove several internal parts that were leaking into the public + API and was never intended to be used externally. (Fixes: :issue:`1070`, PR: + :issue:`1076`) + + - :meth:`mopidy.core.PlaybackController.change_track` is now internal. + + - Removed ``on_error_step`` keyword argument from + :meth:`mopidy.core.PlaybackController.play` + + - Removed ``clear_current_track`` keyword argument to + :meth:`mopidy.core.PlaybackController.stop`. + + - Made the following event triggers internal: + + - :meth:`mopidy.core.PlaybackController.on_end_of_track` + - :meth:`mopidy.core.PlaybackController.on_stream_changed` + - :meth:`mopidy.core.PlaybackController.on_tracklist_changed` + + - :meth:`mopidy.core.PlaybackController.set_current_tl_track` is now + internal. + +- **Deprecated:** The old methods on :class:`mopidy.core.PlaybackController` + for volume and mute management have been deprecated. Use + :class:`mopidy.core.MixerController` instead. (Fixes: :issue:`962`) + +- When seeking while paused, we no longer change to playing. (Fixes: + :issue:`939`, PR: :issue:`1018`) + +- Changed :meth:`mopidy.core.PlaybackController.play` to take the return value + from :meth:`mopidy.backend.PlaybackProvider.change_track` into account when + determining the success of the :meth:`~mopidy.core.PlaybackController.play` + call. (PR: :issue:`1071`) + +- Add :meth:`mopidy.core.Listener.stream_title_changed` and + :meth:`mopidy.core.PlaybackController.get_stream_title` for letting clients + know about the current title in streams. (PR: :issue:`938`, :issue:`1030`) + +Backend API +----------- + +In the API implemented by all backends there have been way fewer but somewhat +more drastic changes with some methods removed and new ones being required for +certain functionality to continue working. Most backends were already updated to +be compatible with Mopidy 1.0 before the release. New versions of the backends +will be released shortly after Mopidy itself. + +Backend library providers +~~~~~~~~~~~~~~~~~~~~~~~~~ + +- **Removed:** Remove :meth:`mopidy.backend.LibraryProvider.find_exact`. + +- Add an ``exact`` keyword argument to + :meth:`mopidy.backend.LibraryProvider.search` to replace the old + :meth:`~mopidy.backend.LibraryProvider.find_exact` method. + +Backend playlist providers +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +- **Removed:** Remove default implementation of + :attr:`mopidy.backend.PlaylistsProvider.playlists`. This is potentially + backwards incompatible. (PR: :issue:`1046`) + +- Changed the API for :class:`mopidy.backend.PlaylistsProvider`. Note that this + change is **not** backwards compatible. These changes are important to reduce + the Mopidy startup time. (Fixes: :issue:`1057`, PR: :issue:`1075`) + + - Add :meth:`mopidy.backend.PlaylistsProvider.as_list`. + + - Add :meth:`mopidy.backend.PlaylistsProvider.get_items`. + + - Remove :attr:`mopidy.backend.PlaylistsProvider.playlists` property. + +Backend playback providers +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +- Changed the API for :class:`mopidy.backend.PlaybackProvider`. Note that this + change is **not** backwards compatible for certain backends. These changes + are crucial to adding gapless in one of the upcoming releases. + (Fixes: :issue:`1052`, PR: :issue:`1064`) + + - :meth:`mopidy.backend.PlaybackProvider.translate_uri` has been added. It is + strongly recommended that all backends migrate to using this API for + translating "Mopidy URIs" to real ones for playback. + + - The semantics and signature of :meth:`mopidy.backend.PlaybackProvider.play` + has changed. The method is now only used to set the playback state to + playing, and no longer takes a track. + + Backends must migrate to + :meth:`mopidy.backend.PlaybackProvider.translate_uri` or + :meth:`mopidy.backend.PlaybackProvider.change_track` to continue working. + + - :meth:`mopidy.backend.PlaybackProvider.prepare_change` has been added. + +Models +------ + +- Add :class:`mopidy.models.Image` model to be returned by + :meth:`mopidy.core.LibraryController.get_images`. (Part of :issue:`973`) + +- Change the semantics of :attr:`mopidy.models.Track.last_modified` to be + milliseconds instead of seconds since Unix epoch, or a simple counter, + depending on the source of the track. This makes it match the semantics of + :attr:`mopidy.models.Playlist.last_modified`. (Fixes: :issue:`678`, PR: + :issue:`1036`) + +Commands +-------- + +- Make the ``mopidy`` command print a friendly error message if the + :mod:`gobject` Python module cannot be imported. (Fixes: :issue:`836`) + +- Add support for repeating the :option:`-v ` argument four times + to set the log level for all loggers to the lowest possible value, including + log records at levels lower than ``DEBUG`` too. + +- Add path to the current ``mopidy`` executable to the output of ``mopidy + deps``. This make it easier to see that a user is using pip-installed Mopidy + instead of APT-installed Mopidy without asking for ``which mopidy`` output. + +Configuration +------------- + +- Add support for the log level value ``all`` to the loglevels configurations. + This can be used to show absolutely all log records, including those at + custom levels below ``DEBUG``. + +- Add debug logging of unknown sections. (Fixes: :issue:`694`, PR: :issue:`1002`) + +Logging +------- + +- Add custom log level ``TRACE`` (numerical level 5), which can be used by + Mopidy and extensions to log at an even more detailed level than ``DEBUG``. + +- Add support for per logger color overrides. (Fixes: :issue:`808`, PR: + :issue:`1005`) + +Local backend +------------- + +- Improve error logging for scanner. (Fixes: :issue:`856`, PR: :issue:`874`) + +- Add symlink support with loop protection to file finder. (Fixes: + :issue:`858`, PR: :issue:`874`) + +- Add ``--force`` option for ``mopidy local scan`` for forcing a full rescan of + the library. (Fixes: :issue:`910`, PR: :issue:`1010`) + +- Stop ignoring ``offset`` and ``limit`` in searches when using the default + JSON backed local library. (Fixes: :issue:`917`, PR: :issue:`949`) + +- Removed double triggering of ``playlists_loaded`` event. + (Fixes: :issue:`998`, PR: :issue:`999`) + +- Cleanup and refactoring of local playlist code. Preserves playlist names + better and fixes bug in deletion of playlists. (Fixes: :issue:`937`, + PR: :issue:`995` and rebased into :issue:`1000`) + +- Sort local playlists by name. (Fixes: :issue:`1026`, PR: :issue:`1028`) + +- Moved playlist support out to a new extension, :ref:`ext-m3u`. + +- *Deprecated:* The config value :confval:`local/playlists_dir` is no longer in + use and can be removed from your config. + +Local library API +~~~~~~~~~~~~~~~~~ + +- Implementors of :meth:`mopidy.local.Library.lookup` should now return a list + of :class:`~mopidy.models.Track` instead of a single track, just like the + other ``lookup()`` methods in Mopidy. For now, returning a single track will + continue to work. (PR: :issue:`840`) + +- Add support for giving local libraries direct access to tags and duration. + (Fixes: :issue:`967`) + +- Add :meth:`mopidy.local.Library.get_images` for looking up images + for local URIs. (Fixes: :issue:`1031`, PR: :issue:`1032` and :issue:`1037`) + +Stream backend +-------------- + +- Add support for HTTP proxies when doing initial metadata lookup for a stream. + (Fixes :issue:`390`, PR: :issue:`982`) + +- Add basic tests for the stream library provider. + +M3U backend +----------- + +- Mopidy-M3U is a new bundled backend. It provides the same M3U support as was + previously part of the local backend. See :ref:`m3u-migration` for how to + migrate your local playlists to work with the M3U backend. (Fixes: + :issue:`1054`, PR: :issue:`1066`) + +- In playlist names, replace "/", which are illegal in M3U file names, + with "|". (PR: :issue:`1084`) + +MPD frontend +------------ + +- Add support for blacklisting MPD commands. This is used to prevent clients + from using ``listall`` and ``listallinfo`` which recursively lookup the entire + "database". If you insist on using a client that needs these commands change + :confval:`mpd/command_blacklist`. + +- Start setting the ``Name`` field with the stream title when listening to + radio streams. (Fixes: :issue:`944`, PR: :issue:`1030`) + +- Enable browsing of artist references, in addition to albums and playlists. + (PR: :issue:`884`) + +- Switch the ``list`` command over to using the new method + :meth:`mopidy.core.LibraryController.get_distinct` for increased performance. + (Fixes: :issue:`913`) + +- In stored playlist names, replace "/", which are illegal, with "|" instead of + a whitespace. Pipes are more similar to forward slash. + +- Share a single mapping between names and URIs across all MPD sessions. (Fixes: + :issue:`934`, PR: :issue:`968`) + +- Add support for ``toggleoutput`` command. (PR: :issue:`1015`) + +- The ``mixrampdb`` and ``mixrampdelay`` commands are now known to Mopidy, but + are not implemented. (PR: :issue:`1015`) + +- Fix crash on socket error when using a locale causing the exception's error + message to contain characters not in ASCII. (Fixes: issue:`971`, PR: + :issue:`1044`) + +HTTP frontend +------------- + +- **Deprecated:** Deprecated the :confval:`http/static_dir` config. Please make + your web clients pip-installable Mopidy extensions to make it easier to + install for end users. + +- Prevent a race condition in WebSocket event broadcasting from crashing the + web server. (PR: :issue:`1020`) + +Mixers +------ + +- Add support for disabling volume control in Mopidy entirely by setting the + configuration :confval:`audio/mixer` to ``none``. (Fixes: :issue:`936`, PR: + :issue:`1015`, :issue:`1035`) + +Audio +----- + +- **Removed:** Support for visualizers and the :confval:`audio/visualizer` + config value. The feature was originally added as a workaround for all the + people asking for ncmpcpp visualizer support, and since we could get it + almost for free thanks to GStreamer. But, this feature did never make sense + for a server such as Mopidy. + +- **Deprecated:** Deprecated :meth:`mopidy.audio.Audio.emit_end_of_stream`. + Pass a :class:`None` buffer to :meth:`mopidy.audio.Audio.emit_data` to end + the stream. This should only affect Mopidy-Spotify. + +- Add :meth:`mopidy.audio.AudioListener.tags_changed`. Notifies core when new + tags are found. + +- Add :meth:`mopidy.audio.Audio.get_current_tags` for looking up the current + tags of the playing media. + +- Internal code cleanup within audio subsystem: + + - Started splitting audio code into smaller better defined pieces. + + - Improved GStreamer related debug logging. + + - Provide better error messages for missing plugins. + + - Add foundation for trying to re-add multiple output support. + + - Add internal helper for converting GStreamer data types to Python. + + - Reduce scope of audio scanner to just find tags and duration. Modification + time, URI and minimum length handling are now outside of this class. + + - Update scanner to operate with milliseconds for duration. + + - Update scanner to use a custom source, typefind and decodebin. This allows + us to detect playlists before we try to decode them. + + - Refactored scanner to create a new pipeline per track, this is needed as + reseting decodebin is much slower than tearing it down and making a fresh + one. + +- Move and rename helper for converting tags to tracks. + +- Ignore albums without a name when converting tags to tracks. + +- Support UTF-8 in M3U playlists. (Fixes: :issue:`853`) + +- Add workaround for volume not persisting across tracks on OS X. + (Issue: :issue:`886`, PR: :issue:`958`) + +- Improved missing plugin error reporting in scanner. (PR: :issue:`1033`) + +- Introduced a new return type for the scanner, a named tuple with ``uri``, + ``tags``, ``duration``, ``seekable`` and ``mime``. (PR: :issue:`1033`) + +- Added support for checking if the media is seekable, and getting the initial + MIME type guess. (PR: :issue:`1033`) + +Mopidy.js client library +------------------------ + +This version has been released to npm as Mopidy.js v0.5.0. + +- Reexport When.js library as ``Mopidy.when``, to make it easily available to + users of Mopidy.js. (Fixes: :js:`1`) + +- Default to ``wss://`` as the WebSocket protocol if the page is hosted on + ``https://``. This has no effect if the ``webSocketUrl`` setting is + specified. (Pull request: :js:`2`) + +- Upgrade dependencies. + +Development +----------- + +- Add new :ref:`contribution guidelines `. + +- Add new :ref:`development guide `. + +- Speed up event emitting. + +- Changed test runner from nose to py.test. (PR: :issue:`1024`) + + v0.19.5 (2014-12-23) ==================== @@ -475,6 +932,7 @@ guys. Thanks to everyone that has contributed! - The dummy backend used for testing many frontends have moved from :mod:`mopidy.backends.dummy` to :mod:`mopidy.backend.dummy`. + (PR: :issue:`984`) **Commands** diff --git a/docs/clients/mpris.rst b/docs/clients/mpris.rst index 650372e6..aef02566 100644 --- a/docs/clients/mpris.rst +++ b/docs/clients/mpris.rst @@ -30,11 +30,11 @@ menu, including the official Spotify player and Mopidy. If you install Mopidy from apt.mopidy.com, the sound menu should work out of the box. If you install Mopidy in any other way, you need to make sure that the -file located at ``data/mopidy.desktop`` in the Mopidy git repo is installed as -``/usr/share/applications/mopidy.desktop``, and that the properties ``TryExec`` -and ``Exec`` in the file points to an existing executable file, preferably your -Mopidy executable. If this isn't in place, the sound menu will not detect that -Mopidy is running. +file located at ``extra/desktop/mopidy.desktop`` in the Mopidy git repo is +installed as ``/usr/share/applications/mopidy.desktop``, and that the +properties ``TryExec`` and ``Exec`` in the file points to an existing +executable file, preferably your Mopidy executable. If this isn't in place, the +sound menu will not detect that Mopidy is running. Next, Mopidy's MPRIS frontend must be running for the sound menu to be able to control Mopidy. The frontend is enabled by default, so as long as you have all diff --git a/docs/clients/upnp.rst b/docs/clients/upnp.rst index 9b24ae46..b5b18268 100644 --- a/docs/clients/upnp.rst +++ b/docs/clients/upnp.rst @@ -37,15 +37,13 @@ There are two ways Mopidy can be made available as an UPnP MediaRenderer: Using Mopidy-MPRIS and Rygel, or using Mopidy-MPD and upmpdcli. -.. _upmpdcli: - upmpdcli -------- `upmpdcli `_ is recommended, since it -is easier to setup, and offers `OpenHome ohMedia`_ -compatibility. upmpdcli exposes a UPnP MediaRenderer to the network, while -using the MPD protocol to control Mopidy. +is easier to setup, and offers `OpenHome +`_ compatibility. upmpdcli exposes a UPnP +MediaRenderer to the network, while using the MPD protocol to control Mopidy. 1. Install upmpdcli. On Debian/Ubuntu:: @@ -68,8 +66,6 @@ using the MPD protocol to control Mopidy. 4. A UPnP renderer should be available now. -.. _rygel: - Rygel ----- diff --git a/docs/command.rst b/docs/command.rst index 881fb513..ea9ccce7 100644 --- a/docs/command.rst +++ b/docs/command.rst @@ -43,7 +43,7 @@ Options .. cmdoption:: --verbose, -v - Show more output. Repeat up to 3 times for even more. + Show more output. Repeat up to four times for even more. .. cmdoption:: --save-debug-log diff --git a/docs/conf.py b/docs/conf.py index f9cdd613..fa75dd79 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -2,7 +2,7 @@ """Mopidy documentation build configuration file""" -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import os import sys @@ -34,11 +34,13 @@ class Mock(object): elif name == 'get_user_config_dir': # glib.get_user_config_dir() return str - elif (name[0] == name[0].upper() + elif (name[0] == name[0].upper() and + # gst.Caps + not name.startswith('Caps') and # gst.PadTemplate - and not name.startswith('PadTemplate') + not name.startswith('PadTemplate') and # dbus.String() - and not name == 'String'): + not name == 'String'): return type(name, (), {}) else: return Mock() @@ -52,6 +54,7 @@ MOCK_MODULES = [ 'glib', 'gobject', 'gst', + 'gst.pbutils', 'pygst', 'pykka', 'pykka.actor', @@ -111,6 +114,9 @@ modindex_common_prefix = ['mopidy.'] # -- Options for HTML output -------------------------------------------------- +# 'sphinx_rtd_theme' is bundled with Sphinx 1.3, which we don't have when +# building the docs as part of the Debian packages on e.g. Debian wheezy. +# html_theme = 'sphinx_rtd_theme' html_theme = 'default' html_theme_path = ['_themes'] html_static_path = ['_static'] @@ -154,6 +160,7 @@ man_pages = [ extlinks = { 'issue': ('https://github.com/mopidy/mopidy/issues/%s', '#'), 'commit': ('https://github.com/mopidy/mopidy/commit/%s', 'commit '), + 'js': ('https://github.com/mopidy/mopidy.js/issues/%s', 'mopidy.js#'), 'mpris': ( 'https://github.com/mopidy/mopidy-mpris/issues/%s', 'mopidy-mpris#'), 'discuss': ('https://discuss.mopidy.com/t/%s', 'discuss.mopidy.com/t/'), diff --git a/docs/config.rst b/docs/config.rst index f5f6bd19..46b15635 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -70,6 +70,8 @@ Audio configuration will affect the audio volume if you're streaming the audio from Mopidy through Shoutcast. + If you want to disable audio mixing set the value to ``none``. + If you want to use a hardware mixer, you need to install a Mopidy extension which integrates with your sound subsystem. E.g. for ALSA, install `Mopidy-ALSAMixer `_. @@ -93,17 +95,6 @@ Audio configuration ``gst-inspect-0.10`` to see what output properties can be set on the sink. For example: ``gst-inspect-0.10 shout2send`` -.. confval:: audio/visualizer - - Visualizer to use. - - Can be left blank if no visualizer is desired. Otherwise this expects a - GStreamer visualizer. Typical values are ``monoscope``, ``goom``, - ``goom2k1`` or one of the `libvisual`_ visualizers. - -.. _libvisual: http://gstreamer.freedesktop.org/data/doc/gstreamer/head/gst-plugins-base-plugins/html/gst-plugins-base-plugins-plugin-libvisual.html - - Logging configuration --------------------- @@ -142,6 +133,14 @@ Logging configuration level to use for that logger, one of ``debug``, ``info``, ``warning``, ``error``, or ``critical``. +.. confval:: logcolors/* + + The ``logcolors`` config section can be used to change the log color for + specific parts of Mopidy during development or debugging. Each key in the + config section should match the name of a logger. The value is the color + to use for that logger, one of ``black``, ``red``, ``green``, ``yellow``, + ``blue``, ``magenta``, ``cyan`` or ``white``. + .. _the Python logging docs: http://docs.python.org/2/library/logging.config.html diff --git a/docs/contributing.rst b/docs/contributing.rst index 8526f192..b5230b18 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -4,147 +4,125 @@ Contributing ************ -If you are thinking about making Mopidy better, or you just want to hack on it, -that’s great. Here are some tips to get you started. +If you want to contribute to Mopidy, here are some tips to get you started. -Getting started -=============== +.. _asking-questions: -#. Make sure you have a `GitHub account `_. +Asking questions +================ -#. `Submit `_ a ticket for your - issue, assuming one does not already exist. Clearly describe the issue - including steps to reproduce when it is a bug. +Please get in touch with us in one of these ways when requesting help with +Mopidy and its extensions: -#. Fork the repository on GitHub. +- Our discussion forum: `discuss.mopidy.com `_. + Just sign in and fire away. + +- Our IRC channel: `#mopidy `_ + on `irc.freenode.net `_, + with public `searchable logs `_. Be + prepared to hang around for a while, as we're not always around to answer + straight away. + +Before asking for help, it might be worth your time to read the +:ref:`troubleshooting` page, both so you might find a solution to your problem +but also to be able to provide useful details when asking for help. -Making changes -============== +Helping users +============= -#. Clone your fork on GitHub to your computer. - -#. Consider making a Python `virtualenv `_ for - Mopidy development to wall of Mopidy and it's dependencies from the rest of - your system. If you do so, create the virtualenv with the - ``--system-site-packages`` flag so that Mopidy can use globally installed - dependencies like GStreamer. If you don't use a virtualenv, you may need to - run the following ``pip`` and ``python setup.py`` commands with ``sudo`` to - install stuff globally on your computer. - -#. Install dependencies as described in the :ref:`installation` section. - -#. Install additional development dependencies:: - - pip install -r dev-requirements.txt - -#. Checkout a new branch (usually based on ``develop``) and name it accordingly - to what you intend to do. - - - Features get the prefix ``feature/`` - - - Bug fixes get the prefix ``fix/`` - - - Improvements to the documentation get the prefix ``docs/`` +If you want to contribute to Mopidy, a great place to start is by helping other +users on IRC and in the discussion forum. This is a contribution we value +highly. As more people help with user support, new users get faster and better +help. For your own benefit, you'll quickly learn what users find confusing, +difficult or lacking, giving you some ideas for where you may contribute +improvements, either to code or documentation. Lastly, this may also free up +time for other contributors to spend more time on fixing bugs or implementing +new features. -.. _run-from-git: +.. _issue-guidelines: -Running Mopidy from Git +Issue guidelines +================ + +#. If you need help, see :ref:`asking-questions` above. The GitHub issue + tracker is not a support forum. + +#. If you are not sure if what you're experiencing is a bug or not, post in the + `discussion forum `__ first to verify that it's + a bug. + +#. If you are sure that you've found a bug or have a feature request, check if + there's already an issue in the `issue tracker + `_. If there is, see if there is + anything you can add to help reproduce or fix the issue. + +#. If there is no exising issue matching your bug or feature request, create a + `new issue `_. Please include + as much relevant information as possible. If it's a bug, including how to + reproduce the bug and any relevant logs or error messages. + + +Pull request guidelines ======================= -If you want to hack on Mopidy, you should run Mopidy directly from the Git -repo. +#. Before spending any time on making a pull request: -#. Go to the Git repo root:: + - If it's a bug, :ref:`file an issue `. - cd mopidy/ + - If it's an enhancement, discuss it with other Mopidy developers first, + either in a GitHub issue, on the discussion forum, or on IRC. Making sure + your ideas and solutions are aligned with other contributors greatly + increases the odds of your pull request being quickly accepted. -#. To get a ``mopidy`` executable and register all bundled extensions with - setuptools, run:: +#. Create a new branch, based on the ``develop`` branch, for every feature or + bug fix. Keep branches small and on topic, as that makes them far easier to + review. We often use the following naming convention for branches: - python setup.py develop + - Features get the prefix ``feature/``, e.g. + ``feature/track-last-modified-as-ms``. - It still works to run ``python mopidy`` directly on the ``mopidy`` Python - package directory, but if you have never run ``python setup.py develop`` the - extensions bundled with Mopidy isn't registered with setuptools, so Mopidy - will start without any frontends or backends, making it quite useless. + - Bug fixes get the prefix ``fix/``, e.g. ``fix/902-consume-track-on-next``. -#. Now you can run the Mopidy command, and it will run using the code - in the Git repo:: + - Improvements to the documentation get the prefix ``docs/``, e.g. + ``docs/add-ext-mopidy-spotify-tunigo``. - mopidy +#. Follow the :ref:`code style `, especially make sure the + ``flake8`` linter does not complain about anything. Travis CI will check + that your pull request is "flake8 clean". See :ref:`code-linting`. - If you do any changes to the code, you'll just need to restart ``mopidy`` - to see the changes take effect. +#. Include tests for any new feature or substantial bug fix. See + :ref:`running-tests`. +#. Include documentation for any new feature. See :ref:`writing-docs`. -Testing -======= +#. Feel free to include a changelog entry in your pull request. The changelog + is in :file:`docs/changelog.rst`. -Mopidy has quite good test coverage, and we would like all new code going into -Mopidy to come with tests. +#. Write good commit messages. -#. To run all tests, go to the project directory and run:: + - Follow the template "topic: description" for the first line of the commit + message, e.g. "mpd: Switch list command to using list_distinct". See the + commit history for inspiration. - nosetests + - Use the rest of the commit message to explain anything you feel isn't + obvious. It's better to have the details here than in the pull request + description, since the commit message will live forever. - To run tests with test coverage statistics:: + - Write in the imperative, present tense: "add" not "added". - nosetests --with-coverage + For more inspiration, feel free to read these blog posts: - Test coverage statistics can also be viewed online at - `coveralls.io `_. + - `Writing Git commit messages + `_ -#. Always check the code for errors and style issues using flake8:: + - `A Note About Git Commit Messages + `_ - flake8 + - `On commit messages + `_ - If successful, the command will not print anything at all. - -#. Finally, there is the ultimate but a bit slower command. To run both tests, - docs build, and flake8 linting, run:: - - tox - - This will run exactly the same tests as `Travis CI - `_ runs for all our branches and pull - requests. If this command turns green, you can be quite confident that your - pull request will get the green flag from Travis as well, which is a - requirement for it to be merged. - - -Submitting changes -================== - -- One branch per feature or fix. Keep branches small and on topic. - -- Follow the :ref:`code style `, especially make sure ``flake8`` - does not complain about anything. - -- Write good commit messages. Here's three blog posts on how to do it right: - - - `Writing Git commit messages - `_ - - - `A Note About Git Commit Messages - `_ - - - `On commit messages - `_ - -- Send a pull request to the ``develop`` branch. See the `GitHub pull request - docs `_ for help. - - -Additional resources -==================== - -- IRC channel: ``#mopidy`` at `irc.freenode.net `_ - -- `Issue tracker `_ - -- `Mailing List `_ - -- `GitHub documentation `_ +#. Send a pull request to the ``develop`` branch. See the `GitHub pull request + docs `_ for help. diff --git a/docs/debian.rst b/docs/debian.rst index f37c0673..f939d9af 100644 --- a/docs/debian.rst +++ b/docs/debian.rst @@ -1,13 +1,20 @@ .. _debian: -************** -Debian package -************** +*************** +Debian packages +*************** -The Mopidy Debian package is available from `apt.mopidy.com +The Mopidy Debian package, ``mopidy``, is available from `apt.mopidy.com `__ as well as from Debian, Ubuntu and other Debian-based Linux distributions. +Some extensions are also available from all of these sources, while others, +like Mopidy-Spotify and its dependencies, are only available from +apt.mopidy.com. This may either be temporary until the package is uploaded to +Debian and with time propagates to the other distributions. It may also be more +long term, like in the Mopidy-Spotify case where there is uncertainities around +licensing and distribution of non-free packages. + Installation ============ diff --git a/docs/devenv.rst b/docs/devenv.rst new file mode 100644 index 00000000..c67426f7 --- /dev/null +++ b/docs/devenv.rst @@ -0,0 +1,593 @@ +.. _devenv: + +*********************** +Development environment +*********************** + +This page describes a common development setup for working with Mopidy and +Mopidy extensions. Of course, there may be other ways that work better for you +and the tools you use, but here's one recommended way to do it. + +.. contents:: + :local: + + +Initial setup +============= + +The following steps help you get a good initial setup. They build on each other +to some degree, so if you're not very familiar with Python development it might +be wise to proceed in the order laid out here. + +.. contents:: + :local: + + +Install Mopidy the regular way +------------------------------ + +Install Mopidy the regular way. Mopidy has some non-Python dependencies which +may be tricky to install. Thus we recommend to always start with a full regular +Mopidy install, as described in :ref:`installation`. That is, if you're running +e.g. Debian, start with installing Mopidy from Debian packages. + + +Make a development workspace +---------------------------- + +Make a directory to be used as a workspace for all your Mopidy development:: + + mkdir ~/mopidy-dev + +It will contain all the Git repositories you'll check out when working on +Mopidy and extensions. + + +Make a virtualenv +----------------- + +Make a Python `virtualenv `_ for Mopidy +development. The virtualenv will wall off Mopidy and its dependencies from the +rest of your system. All development and installation of Python dependencies, +versions of Mopidy, and extensions are done inside the virtualenv. This way +your regular Mopidy install, which you set up in the first step, is unaffected +by your hacking and will always be working. + +Most of us use the `virtualenvwrapper +`_ to ease working with +virtualenvs, so that's what we'll be using for the examples here. First, +install and setup virtualenvwrapper as described in their docs. + +To create a virtualenv named ``mopidy`` which uses Python 2.7, allows access to +system-wide packages like GStreamer, and uses the Mopidy workspace directory as +the "project path", run:: + + mkvirtualenv -a ~/mopidy-dev --python `which python2.7` \ + --system-site-packages mopidy + +Now, each time you open a terminal and want to activate the ``mopidy`` +virtualenv, run:: + + workon mopidy + +This will both activate the ``mopidy`` virtualenv, and change the current +working directory to ``~/mopidy-dev``. + + +Clone the repo from GitHub +-------------------------- + +Once inside the virtualenv, it's time to clone the ``mopidy/mopidy`` Git repo +from GitHub:: + + git clone https://github.com/mopidy/mopidy.git + +When you've cloned the ``mopidy`` Git repo, ``cd`` into it:: + + cd ~/mopidy-dev/mopidy/ + +With a fresh clone of the Git repo, you should start out on the ``develop`` +branch. This is where all features for the next feature release land. To +confirm that you're on the right branch, run:: + + git branch + + +Install development tools +------------------------- + +We use a number of Python development tools. The :file:`dev-requirements.txt` +file has comments describing what we use each dependency for, so we might just +as well include the file verbatim here: + +.. literalinclude:: ../dev-requirements.txt + +Install them all into the active virtualenv by running `pip +`_:: + + pip install --upgrade -r dev-requirements.txt + +To upgrade the tools in the future, just rerun the exact same command. + + +Install Mopidy from the Git repo +-------------------------------- + +Next up, we'll want to run Mopidy from the Git repo. There's two reasons for +this: first of all, it lets you easily change the source code, restart Mopidy, +and see the change take effect. Second, it's a convenient way to keep at the +bleeding edge, testing the latest developments in Mopidy itself or test some +extension against the latest Mopidy changes. + +Assuming you're still inside the Git repo, use pip to install Mopidy from the +Git repo in an "editable" form:: + + pip install --editable . + +This will not copy the source code into the virtualenv's ``site-packages`` +directory, but instead create a link there pointing to the Git repo. Using +``cdsitepackages`` from virtualenvwrapper, we can quickly show that the +installed :file:`Mopidy.egg-link` file points back to the Git repo:: + + $ cdsitepackages + $ cat Mopidy.egg-link + /home/user/mopidy-dev/mopidy + .% + $ + +It will also create a ``mopidy`` executable inside the virtualenv that will +always run the latest code from the Git repo. Using another +virtualenvwrapper command, ``cdvirtualenv``, we can show that too:: + + $ cdvirtualenv + $ cat bin/mopidy + ... + +The executable should contain something like this, using :mod:`pkg_resources` +to look up Mopidy's "console script" entry point:: + + #!/home/user/virtualenvs/mopidy/bin/python2 + # EASY-INSTALL-ENTRY-SCRIPT: 'Mopidy==0.19.5','console_scripts','mopidy' + __requires__ = 'Mopidy==0.19.5' + import sys + from pkg_resources import load_entry_point + + if __name__ == '__main__': + sys.exit( + load_entry_point('Mopidy==0.19.5', 'console_scripts', 'mopidy')() + ) + +.. note:: + + It still works to run ``python mopidy`` directly on the + :file:`~/mopidy-dev/mopidy/mopidy/` Python package directory, but if + you don't run the ``pip install`` command above, the extensions bundled + with Mopidy will not be registered with :mod:`pkg_resources`, making Mopidy + quite useless. + +Third, the ``pip install`` command will register the bundled Mopidy +extensions so that Mopidy may find them through :mod:`pkg_resources`. The +result of this can be seen in the Git repo, in a new directory called +:file:`Mopidy.egg-info`, which is ignored by Git. The +:file:`Mopidy.egg-info/entry_points.txt` file is of special interest as it +shows both how the above executable and the bundled extensions are connected to +the Mopidy source code: + +.. code-block:: ini + + [console_scripts] + mopidy = mopidy.__main__:main + + [mopidy.ext] + http = mopidy.http:Extension + local = mopidy.local:Extension + mpd = mopidy.mpd:Extension + softwaremixer = mopidy.softwaremixer:Extension + stream = mopidy.stream:Extension + +.. warning:: + + It's not uncommon to clean up in the Git repo now and then, e.g. by running + ``git clean``. + + If you do this, then the :file:`Mopidy.egg-info` directory will be removed, + and :mod:`pkg_resources` will no longer know how to locate the "console + script" entry point or the bundled Mopidy extensions. + + The fix is simply to run the install command again:: + + pip install --editable . + +Finally, we can go back to the workspace, again using a virtualenvwrapper +tool:: + + cdproject + + +.. _running-from-git: + +Running Mopidy from Git +======================= + +As long as the virtualenv is activated, you can start Mopidy from any +directory. Simply run:: + + mopidy + +To stop it again, press :kbd:`Ctrl+C`. + +Every time you change code in Mopidy or an extension and want to see it +live, you must restart Mopidy. + +If you want to iterate quickly while developing, it may sound a bit tedious to +restart Mopidy for every minor change. Then it's useful to have tests to +exercise your code... + + +.. _running-tests: + +Running tests +============= + +Mopidy has quite good test coverage, and we would like all new code going into +Mopidy to come with tests. + +.. contents:: + :local: + + +Test it all +----------- + +You need to know at least one command; the one that runs all the tests:: + + tox + +This will run exactly the same tests as `Travis CI +`_ runs for all our branches and pull +requests. If this command turns green, you can be quite confident that your +pull request will get the green flag from Travis as well, which is a +requirement for it to be merged. + +As this is the ultimate test command, it's also the one taking the most time to +run; up to a minute, depending on your system. But, if you have patience, this +is all you need to know. Always run this command before pushing your changes to +GitHub. + +If you take a look at the tox config file, :file:`tox.ini`, you'll see that tox +runs tests in multiple environments, including a ``flake8`` environment that +lints the source code for issues and a ``docs`` environment that tests that the +documentation can be built. You can also limit tox to just test specific +environments using the ``-e`` option, e.g. to run just unit tests:: + + tox -e py27 + +To learn more, see the `tox documentation `_ . + + +Running unit tests +------------------ + +Under the hood, ``tox -e py27`` will use `pytest `_ as the +test runner. We can also use it directly to run all tests:: + + py.test + +py.test has lots of possibilities, so you'll have to dive into their docs and +plugins to get full benefit from it. To get you interested, here are some +examples. + +We can limit to just tests in a single directory to save time:: + + py.test tests/http/ + +With the help of the pytest-xdist plugin, we can run tests with four Python +processes in parallel, which usually cuts the test time in half or more:: + + py.test -n 4 + +Another useful feature from pytest-xdist, is the possiblity to stop on the +first test failure, watch the file system for changes, and then rerun the +tests. This makes for a very quick code-test cycle:: + + py.test -f # or --looponfail + +With the help of the pytest-cov plugin, we can get a report on what parts of +the given module, ``mopidy`` in this example, are covered by the test suite:: + + py.test --cov=mopidy --cov-report=term-missing + +.. note:: + + Up to date test coverage statistics can also be viewed online at + `coveralls.io `_. + +If we want to speed up the test suite, we can even get a list of the ten +slowest tests:: + + py.test --durations=10 + +By now, you should be convinced that running py.test directly during +development can be very useful. + + +Continuous integration +---------------------- + +Mopidy uses the free service `Travis CI `_ +for automatically running the test suite when code is pushed to GitHub. This +works both for the main Mopidy repo, but also for any forks. This way, any +contributions to Mopidy through GitHub will automatically be tested by Travis +CI, and the build status will be visible in the GitHub pull request interface, +making it easier to evaluate the quality of pull requests. + +For each successful build, Travis submits code coverage data to `coveralls.io +`_. If you're out of work, coveralls might +help you find areas in the code which could need better test coverage. + +In addition, we run a Jenkins CI server at https://ci.mopidy.com/ that runs all +tests on multiple platforms (Ubuntu, OS X, x86, arm) for every commit we push +to the ``develop`` branch in the main Mopidy repo on GitHub. Thus, new code +isn't tested by Jenkins before it is merged into the ``develop`` branch, which +is a bit late, but good enough to get broad testing before new code is +released. + + +.. _code-linting: + +Style checking and linting +-------------------------- + +We're quite pedantic about :ref:`codestyle` and try hard to keep the Mopidy +code base a very clean and nice place to work in. + +Luckily, you can get very far by using the `flake8 +`_ linter to check your code for issues before +submitting a pull request. Mopidy passes all of flake8's checks, with only a +very few exceptions configured in :file:`setup.cfg`. You can either run the +``flake8`` tox environment, like Travis CI will do on your pull request:: + + tox -e flake8 + +Or you can run flake8 directly:: + + flake8 + +If successful, the command will not print anything at all. + +.. note:: + + In some rare cases it doesn't make sense to listen to flake8's warnings. In + those cases, ignore the check by appending ``# noqa: `` to + the source line that triggers the warning. The ``# noqa`` part will make + flake8 skip all checks on the line, while the warning code will help other + developers lookup what you are ignoring. + + +.. _writing-docs: + +Writing documentation +===================== + +To write documentation, we use `Sphinx `_. See their +site for lots of documentation on how to use Sphinx. + +.. note:: + + To generate a few graphs which are part of the documentation, you need some + additional dependencies. You can install them from APT with:: + + sudo apt-get install python-pygraphviz graphviz + +To build the documentation, go into the :file:`docs/` directory:: + + cd ~/mopidy-dev/mopidy/docs/ + +Then, to see all available build targets, run:: + + make + +To generate an HTML version of the documentation, run:: + + make html + +The generated HTML will be available at :file:`_build/html/index.html`. To open +it in a browser you can run either of the following commands, depending on your +OS:: + + xdg-open _build/html/index.html # Linux + open _build/html/index.html # OS X + +The documentation at https://docs.mopidy.com/ is hosted by `Read the Docs +`_, which automatically updates the documentation +when a change is pushed to the ``mopidy/mopidy`` repo at GitHub. + + +Working on extensions +===================== + +Much of the above also applies to Mopidy extensions, though they're often a bit +simpler. They don't have documentation sites and their test suites are either +small and fast, or sadly missing entirely. Most of them use tox and flake8, and +py.test can be used to run their test suites. + +.. contents:: + :local: + + +Installing extensions +--------------------- + +As always, the ``mopidy`` virtualenv should be active when working on +extensions:: + + workon mopidy + +Just like with non-development Mopidy installations, you can install extensions +using pip:: + + pip install Mopidy-Scrobbler + +Installing an extension from its Git repo works the same way as with Mopidy +itself. First, go to the Mopidy workspace:: + + cdproject # or cd ~/mopidy-dev/ + +Clone the desired Mopidy extension:: + + git clone https://github.com/mopidy/mopidy-spotify.git + +Change to the newly created extension directory:: + + cd mopidy-spotify/ + +Then, install the extension in "editable" mode, so that it can be imported from +anywhere inside the virtualenv and the extension is registered and discoverable +through :mod:`pkg_resources`:: + + pip install --editable . + +Every extension will have a ``README.rst`` file. It may contain information +about extra dependencies required, development process, etc. Extensions usually +have a changelog in the readme file. + + +Upgrading extensions +-------------------- + +Extensions often have a much quicker life cycle than Mopidy itself, often with +daily releases in periods of active development. To find outdated extensions in +your virtualenv, you can run:: + + pip search mopidy + +This will list all available Mopidy extensions and compare the installed +versions with the latest available ones. + +To upgrade an extension installed with pip, simply use pip:: + + pip install --upgrade Mopidy-Scrobbler + +To upgrade an extension installed from a Git repo, it's usually enough to pull +the new changes in:: + + cd ~/mopidy-dev/mopidy-spotify/ + git pull + +Of course, if you have local modifications, you'll need to stash these away on +a branch or similar first. + +Depending on the changes to the extension, it may be necessary to update the +metadata about the extension package by installing it in "editable" mode +again:: + + pip install --editable . + + +Contribution workflow +===================== + +Before you being, make sure you've read the :ref:`contributing` page and the +guidelines there. This section will focus more on the practical workflow. + +For the examples, we're making a change to Mopidy. Approximately the same +workflow should work for most Mopidy extensions too. + +.. contents:: + :local: + + +Setting up Git remotes +---------------------- + +Assuming we already have a local Git clone of the upstream Git repo in +:file:`~/mopidy-dev/mopidy/`, we can run ``git remote -v`` to list the +configured remotes of the repo:: + + $ git remote -v + origin https://github.com/mopidy/mopidy.git (fetch) + origin https://github.com/mopidy/mopidy.git (push) + +For clarity, we can rename the ``origin`` remote to ``upstream``:: + + $ git remote rename origin upstream + $ git remote -v + upstream https://github.com/mopidy/mopidy.git (fetch) + upstream https://github.com/mopidy/mopidy.git (push) + +If you haven't already, `fork the repository +`_ to your own GitHub account. + +Then, add the new fork as a remote to your local clone:: + + git remote add myuser git@github.com:myuser/mopidy.git + +The end result is that you have both the upstream repo and your own fork as +remotes:: + + $ git remote -v + myuser git@github.com:myuser/mopidy.git (fetch) + myuser git@github.com:myuser/mopidy.git (push) + upstream https://github.com/mopidy/mopidy.git (fetch) + upstream https://github.com/mopidy/mopidy.git (push) + + +Creating a branch +----------------- + +Fetch the latest data from all remotes without affecting your working +directory:: + + git remote update + +Now, we are ready to create and checkout a new branch off of the upstream +``develop`` branch for our work:: + + git checkout -b fix/666-crash-on-foo upstream/develop + +Do the work, while remembering to adhere to code style, test the changes, make +necessary updates to the documentation, and making small commits with good +commit messages. All as described in :ref:`contributing` and elsewhere in +the :ref:`devenv` guide. + + +Creating a pull request +----------------------- + +When everything is done and committed, push the branch to your fork on GitHub:: + + git push myuser fix/666-crash-on-foo + +Go to the repository on GitHub where you want the change merged, in this case +https://github.com/mopidy/mopidy, and `create a pull request +`_. + + +Updating a pull request +----------------------- + +When the pull request is created, `Travis CI +`__ will run all tests on it. If something +fails, you'll get notified by email. You might as well just fix the issues +right away, as we won't merge a pull request without a green Travis build. See +:ref:`running-tests` on how to run the same tests locally as Travis CI runs on +your pull request. + +When you've fixed the issues, you can update the pull request simply by pushing +more commits to the same branch in your fork:: + + git push myuser fix/666-crash-on-foo + +Likewise, when you get review comments from other developers on your pull +request, you're expected to create additional commits which addresses the +comments. Push them to your branch so that the pull request is updated. + +.. note:: + + Setup the remote as the default push target for your branch:: + + git branch --set-upstream-to myuser/fix/666-crash-on-foo + + Then you can push more commits without specifying the remote:: + + git push diff --git a/docs/ext/backends.rst b/docs/ext/backends.rst index 6f3195ff..17e2a7ca 100644 --- a/docs/ext/backends.rst +++ b/docs/ext/backends.rst @@ -90,6 +90,17 @@ Mopidy-Local Bundled with Mopidy. See :ref:`ext-local`. +Mopidy-Local-Images +=================== + +https://github.com/tkem/mopidy-local-images + +Extension which plugs into Mopidy-Local to allow Web clients access to +album art embedded in local media files. Not to be used on its own, +but acting as a proxy between ``mopidy local scan`` and the actual +local library provider being used. + + Mopidy-Local-SQLite =================== diff --git a/docs/ext/http.rst b/docs/ext/http.rst index 54d44ce0..8745130f 100644 --- a/docs/ext/http.rst +++ b/docs/ext/http.rst @@ -73,17 +73,21 @@ See :ref:`config` for general help on configuring Mopidy. .. confval:: http/static_dir + **Deprecated:** This config is deprecated and will be removed in a future + version of Mopidy. + Which directory the HTTP server should serve at "/" Change this to have Mopidy serve e.g. files for your JavaScript client. - "/mopidy" will continue to work as usual even if you change this setting. + "/mopidy" will continue to work as usual even if you change this setting, + but any other Mopidy webclient installed with pip to be served at + "/ext_name" will stop working if you set this config. - This config value isn't deprecated yet, but you're strongly encouraged to - make Mopidy extensions which use the the :ref:`http-server-api` to host - static files on Mopidy's web server instead of using - :confval:`http/static_dir`. That way, installation of your web client will - be a lot easier for your end users, and multiple web clients can easily - share the same web server. + You're strongly encouraged to make Mopidy extensions which use the the + :ref:`http-server-api` to host static files on Mopidy's web server instead + of using :confval:`http/static_dir`. That way, installation of your web + client will be a lot easier for your end users, and multiple web clients + can easily share the same web server. .. confval:: http/zeroconf diff --git a/docs/ext/local.rst b/docs/ext/local.rst index 31d00d66..18f66adc 100644 --- a/docs/ext/local.rst +++ b/docs/ext/local.rst @@ -86,6 +86,10 @@ See :ref:`config` for general help on configuring Mopidy. Number of milliseconds before giving up scanning a file and moving on to the next file. +.. confval:: local/scan_follow_symlinks + + If we should follow symlinks found in :confval:`local/media_dir` + .. confval:: local/scan_flush_threshold Number of tracks to wait before telling library it should try and store diff --git a/docs/ext/local_images.jpg b/docs/ext/local_images.jpg new file mode 100644 index 00000000..a5336c46 Binary files /dev/null and b/docs/ext/local_images.jpg differ diff --git a/docs/ext/m3u.rst b/docs/ext/m3u.rst new file mode 100644 index 00000000..d05f88f1 --- /dev/null +++ b/docs/ext/m3u.rst @@ -0,0 +1,55 @@ +.. _ext-m3u: + +********** +Mopidy-M3U +********** + +Mopidy-M3U is an extension for reading and writing M3U playlists stored +on disk. It is bundled with Mopidy and enabled by default. + +This backend handles URIs starting with ``m3u:``. + + +.. _m3u-migration: + +Migrating from Mopidy-Local playlists +===================================== + +Mopidy-M3U was split out of the Mopidy-Local extension in Mopidy 1.0. To +migrate your playlists from Mopidy-Local, simply move them from the +:confval:`local/playlists_dir` directory to the :confval:`m3u/playlists_dir` +directory. Assuming you have not changed the default config, run the following +commands to migrate:: + + mkdir -p ~/.local/share/mopidy/m3u/ + mv ~/.local/share/mopidy/local/playlists/* ~/.local/share/mopidy/m3u/ + + +Editing playlists +================= + +There is a core playlist API in place for editing playlists. This is supported +by a few Mopidy clients, but not through Mopidy's MPD server yet. + +It is possible to edit playlists by editing the M3U files located in the +:confval:`m3u/playlists_dir` directory, usually +:file:`~/.local/share/mopidy/m3u/`, by hand with a text editor. See `Wikipedia +`__ for a short description of the quite +simple M3U playlist format. + + +Configuration +============= + +See :ref:`config` for general help on configuring Mopidy. + +.. literalinclude:: ../../mopidy/m3u/ext.conf + :language: ini + +.. confval:: m3u/enabled + + If the M3U extension should be enabled or not. + +.. confval:: m3u/playlists_dir + + Path to directory with M3U files. diff --git a/docs/ext/mobile.png b/docs/ext/mobile.png new file mode 100644 index 00000000..983aa27c Binary files /dev/null and b/docs/ext/mobile.png differ diff --git a/docs/ext/mpd.rst b/docs/ext/mpd.rst index ecfab949..b02226a2 100644 --- a/docs/ext/mpd.rst +++ b/docs/ext/mpd.rst @@ -99,3 +99,10 @@ See :ref:`config` for general help on configuring Mopidy. ``$hostname`` and ``$port`` can be used in the name. Set to an empty string to disable Zeroconf for MPD. + +.. confval:: mpd/command_blacklist + + List of MPD commands which are disabled by the server. By default this + setting blacklists ``listall`` and ``listallinfo``. These commands don't + fit well with many of Mopidy's backends and are better left disabled unless + you know what you are doing. diff --git a/docs/ext/web.rst b/docs/ext/web.rst index 5abf5b15..7355dbf9 100644 --- a/docs/ext/web.rst +++ b/docs/ext/web.rst @@ -30,17 +30,39 @@ To install, run:: pip install Mopidy-API-Explorer -Mopidy-HTTP-Kuechenradio -========================= +Mopidy-Local-Images +=================== -https://github.com/tkem/mopidy-http-kuechenradio +https://github.com/tkem/mopidy-local-images -A deliberately simple Mopidy Web client for mobile devices. Made with jQuery -Mobile by Thomas Kemmer. +Not a full-featured Web client, but rather a local library and Web +extension which allows other Web clients access to album art embedded +in local media files. + +.. image:: /ext/local_images.jpg + :width: 640 + :height: 480 To install, run:: - pip install Mopidy-HTTP-Kuechenradio + pip install Mopidy-Local-Images + + +Mopidy-Mobile +============= + +https://github.com/tkem/mopidy-mobile + +A Mopidy Web client extension and hybrid mobile app, made with Ionic, +AngularJS and Apache Cordova by Thomas Kemmer. + +.. image:: /ext/mobile.png + :width: 1024 + :height: 606 + +To install, run:: + + pip install Mopidy-Mobile Mopidy-Moped diff --git a/docs/extensiondev.rst b/docs/extensiondev.rst index 100e5b85..a2a5f463 100644 --- a/docs/extensiondev.rst +++ b/docs/extensiondev.rst @@ -159,7 +159,7 @@ class that will connect the rest of the dots. :: - from __future__ import unicode_literals + from __future__ import absolute_import, unicode_literals import re from setuptools import setup, find_packages @@ -189,11 +189,6 @@ class that will connect the rest of the dots. 'Pykka >= 1.1', 'pysoundspot', ], - test_suite='nose.collector', - tests_require=[ - 'nose', - 'mock >= 1.0', - ], entry_points={ 'mopidy.ext': [ 'soundspot = mopidy_soundspot:Extension', @@ -255,7 +250,7 @@ default config in documentation without duplicating it. This is ``mopidy_soundspot/__init__.py``:: - from __future__ import unicode_literals + from __future__ import absolute_import, unicode_literals import logging import os @@ -312,12 +307,6 @@ This is ``mopidy_soundspot/__init__.py``:: from .backend import SoundspotBackend registry.add('backend', SoundspotBackend) - # Register a custom GStreamer element - from .mixer import SoundspotMixer - gobject.type_register(SoundspotMixer) - gst.element_register( - SoundspotMixer, 'soundspotmixer', gst.RANK_MARGINAL) - # Or nothing to register e.g. command extension pass @@ -421,17 +410,6 @@ examples, see the :ref:`http-server-api` docs or explore with :ref:`http-explore-extension` extension. -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:`~mopidy.ext.Extension.setup` method register all your custom -GStreamer elements. - - Running an extension ==================== @@ -449,7 +427,7 @@ Python conventions In general, it would be nice if Mopidy extensions followed the same :ref:`codestyle` as Mopidy itself, as they're part of the same ecosystem. Among other things, the code style guide explains why all the above examples start -with ``from __future__ import unicode_literals``. +with ``from __future__ import absolute_import, unicode_literals``. Use of Mopidy APIs diff --git a/docs/index.rst b/docs/index.rst index 395e683e..e9775030 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -94,6 +94,7 @@ Extensions :maxdepth: 2 ext/local + ext/m3u ext/stream ext/http ext/mpd @@ -132,10 +133,11 @@ Development =========== .. toctree:: - :maxdepth: 1 + :maxdepth: 2 contributing - devtools + devenv + releasing codestyle extensiondev diff --git a/docs/installation/arch.rst b/docs/installation/arch.rst index 3f85bf51..f8492fdf 100644 --- a/docs/installation/arch.rst +++ b/docs/installation/arch.rst @@ -14,14 +14,27 @@ If you are running Arch Linux, you can install Mopidy using the To upgrade Mopidy to future releases, just upgrade your system using:: - yaourt -Syu - -#. Optional: If you want to use any Mopidy extensions, like Spotify support or - Last.fm scrobbling, AUR also has `packages for several Mopidy extensions - `_. - - For a full list of available Mopidy extensions, including those not - installable from AUR, see :ref:`ext`. + yaourt -Syua #. Finally, you need to set a couple of :doc:`config values `, and then you're ready to :doc:`run Mopidy `. + + +Installing extensions +===================== + +If you want to use any Mopidy extensions, like Spotify support or Last.fm +scrobbling, AUR also has `packages for lots of Mopidy extensions +`_. + +You can also install any Mopidy extension directly from PyPI with ``pip``. To +list all the extensions available from PyPI, run:: + + pip search mopidy + +Note that extensions installed from PyPI will only automatically install Python +dependencies. Please refer to the extension's documentation for information +about any other requirements needed for the extension to work properly. + +For a full list of available Mopidy extensions, including those not installable +from AUR, see :ref:`ext`. diff --git a/docs/installation/debian.rst b/docs/installation/debian.rst index f34eb255..4def3fbb 100644 --- a/docs/installation/debian.rst +++ b/docs/installation/debian.rst @@ -52,20 +52,6 @@ from scratch, we have a guide for installing Debian/Raspbian and Mopidy. See sudo apt-get update sudo apt-get install mopidy -#. Optional: If you want to use any Mopidy extensions, like Spotify support or - Last.fm scrobbling, you need to install additional packages. - - To list all the extensions available from apt.mopidy.com, you can run:: - - apt-cache search mopidy - - To install one of the listed packages, e.g. ``mopidy-spotify``, simply run:: - - sudo apt-get install mopidy-spotify - - For a full list of available Mopidy extensions, including those not - installable from apt.mopidy.com, see :ref:`ext`. - #. Before continuing, make sure you've read the :ref:`debian` section to learn about the differences between running Mopidy as a system service and manually as your own system user. @@ -78,3 +64,71 @@ figure it out for itself, run the following to upgrade right away:: sudo apt-get update sudo apt-get dist-upgrade + + +Installing extensions +===================== + +If you want to use any Mopidy extensions, like Spotify support or Last.fm +scrobbling, you need to install additional packages. + +To list all the extensions available from apt.mopidy.com, you can run:: + + apt-cache search mopidy + +To install one of the listed packages, e.g. ``mopidy-spotify``, simply run:: + + sudo apt-get install mopidy-spotify + +You can also install any Mopidy extension directly from PyPI with ``pip``. To +list all the extensions available from PyPI, run:: + + pip search mopidy + +Note that extensions installed from PyPI will only automatically install Python +dependencies. Please refer to the extension's documentation for information +about any other requirements needed for the extension to work properly. + +For a full list of available Mopidy extensions, including those not +installable from apt.mopidy.com, see :ref:`ext`. + + +Missing extensions +================== + +If you've installed a Mopidy extension with pip, restarted Mopidy, and Mopidy +doesn't find the extension, there's probably a simple explanation and solution. + +Mopidy installed with APT can detect and use Mopidy extensions installed with +both APT and pip. APT installs Mopidy as :file:`/usr/bin/mopidy`. + +Mopidy installed with pip can only detect Mopidy extensions installed with pip. +pip usually installs Mopidy as :file:`/usr/local/bin/mopidy`. + +If you have Mopidy installed from both APT and pip, then the pip-installed +Mopidy will probably shadow the APT-installed Mopidy because +:file:`/usr/local/bin` usually has precedence over :file:`/usr/bin` in the +``PATH`` environment variable. To check if this is the case on your system, you +can use ``which`` to see what installation of Mopidy you use when you run +``mopidy`` in your shell:: + + $ which mopidy + /usr/local/bin/mopidy + +If this is the case on your system, the recommended solution is to check that +you have Mopidy installed from APT too:: + + $ /usr/bin/mopidy --version + Mopidy 0.19.5 + +And then uninstall the pip-installed Mopidy:: + + sudo pip uninstall mopidy + +Depending on what shell you use, the shell may still try to use +:file:`/usr/local/bin/mopidy` even if it no longer exists. Check again with +``which mopidy`` what your shell believes is the right ``mopidy`` executable to +run. If the shell is still confused, you may need to restart it, or in the case +of zsh, run ``rehash`` to update the shell. + +For more details on why this works this way, see :ref:`debian`. diff --git a/docs/installation/index.rst b/docs/installation/index.rst index c8deae59..dba1fb3a 100644 --- a/docs/installation/index.rst +++ b/docs/installation/index.rst @@ -7,8 +7,9 @@ Installation There are several ways to install Mopidy. What way is best depends upon your OS and/or distribution. -If you want to contribute to the development of Mopidy, you should first read -the general installation instructions, then have a look at :ref:`run-from-git`. +If you want to contribute to the development of Mopidy, you should first follow +the instructions here to install a regular install of Mopidy, then continue +with reading :ref:`contributing` and :ref:`devenv`. .. toctree:: diff --git a/docs/installation/osx.rst b/docs/installation/osx.rst index 9c0e059e..71beece3 100644 --- a/docs/installation/osx.rst +++ b/docs/installation/osx.rst @@ -57,16 +57,77 @@ If you are running OS X, you can install everything needed with Homebrew. brew install mopidy -#. Optional: If you want to use any Mopidy extensions, like Spotify support or - Last.fm scrobbling, the Homebrew tap has formulas for several Mopidy - extensions as well. - - To list all the extensions available from our tap, you can run:: - - brew search mopidy - - For a full list of available Mopidy extensions, including those not - installable from Homebrew, see :ref:`ext`. - #. Finally, you need to set a couple of :doc:`config values `, and then you're ready to :doc:`run Mopidy `. + + +Installing extensions +===================== + +If you want to use any Mopidy extensions, like Spotify support or Last.fm +scrobbling, the Homebrew tap has formulas for several Mopidy extensions as +well. Extensions installed from Homebrew will come complete with all +dependencies, both Python and non-Python ones. + +To list all the extensions available from our tap, you can run:: + + brew search mopidy + +You can also install any Mopidy extension directly from PyPI with ``pip``, just +like on Linux. To list all the extensions available from PyPI, run:: + + pip search mopidy + +Note that extensions installed from PyPI will only automatically install Python +dependencies. Please refer to the extension's documentation for information +about any other requirements needed for the extension to work properly. + +For a full list of available Mopidy extensions, including those not installable +from Homebrew, see :ref:`ext`. + + +Running Mopidy automatically on login +===================================== + +On OS X, you can use launchd to start Mopidy automatically at login. + +If you installed Mopidy from Homebrew, simply run ``brew info mopidy`` and +follow the instructions in the "Caveats" section:: + + $ brew info mopidy + ... + ==> Caveats + To have launchd start mopidy at login: + ln -sfv /usr/local/opt/mopidy/*.plist ~/Library/LaunchAgents + Then to load mopidy now: + launchctl load ~/Library/LaunchAgents/homebrew.mopidy.mopidy.plist + Or, if you don't want/need launchctl, you can just run: + mopidy + +If you happen to be on OS X, but didn't install Mopidy with Homebrew, you can +get the same effect by adding the file +:file:`~/Library/LaunchAgents/mopidy.plist` with the following contents:: + + + + + + Label + mopidy + ProgramArguments + + /usr/local/bin/mopidy + + RunAtLoad + + KeepAlive + + + + +You might need to adjust the path to the ``mopidy`` executable, +``/usr/local/bin/mopidy``, to match your system. + +Then, to start Mopidy with launchd right away:: + + launchctl load ~/Library/LaunchAgents/mopidy.plist diff --git a/docs/installation/source.rst b/docs/installation/source.rst index c2c4161a..c2018984 100644 --- a/docs/installation/source.rst +++ b/docs/installation/source.rst @@ -6,7 +6,10 @@ Install from source If you are on Linux, but can't install :ref:`from the APT archive ` or :ref:`from AUR `, you can install Mopidy -from source by hand. +from PyPI using the ``pip`` installer. + +If you are looking to contribute or wish to install from source using ``git`` +please follow the directions :ref:`here `. #. First of all, you need Python 2.7. Check if you have Python and what version by running:: @@ -69,46 +72,32 @@ from source by hand. sudo pip install -U mopidy - To upgrade Mopidy to future releases, just rerun this command. + This will use ``pip`` to install the latest release of `Mopidy from PyPI + `_. To upgrade Mopidy to future + releases, just rerun this command. Alternatively, if you want to track Mopidy development closer, you may install a snapshot of Mopidy's ``develop`` Git branch using pip:: sudo pip install --allow-unverified=mopidy mopidy==dev -#. Optional: If you want Spotify support in Mopidy, you'll need to install - libspotify and the Mopidy-Spotify extension. - - #. Download and install the latest version of libspotify for your OS and CPU - architecture from `Spotify - `_. - - For libspotify 12.1.51 for 64-bit Linux the process is as follows:: - - wget https://developer.spotify.com/download/libspotify/libspotify-12.1.51-Linux-x86_64-release.tar.gz - tar zxfv libspotify-12.1.51-Linux-x86_64-release.tar.gz - cd libspotify-12.1.51-Linux-x86_64-release/ - sudo make install prefix=/usr/local - - Remember to adjust the above example for the latest libspotify version - supported by pyspotify, your OS, and your CPU architecture. - - #. If you're on Fedora, you must add a configuration file so libspotify.so - can be found:: - - echo /usr/local/lib | sudo tee /etc/ld.so.conf.d/libspotify.conf - sudo ldconfig - - #. Then install the latest release of Mopidy-Spotify using pip:: - - sudo pip install -U mopidy-spotify - -#. Optional: If you want to scrobble your played tracks to Last.fm, you need - to install Mopidy-Scrobbler:: - - sudo pip install -U mopidy-scrobbler - -#. For a full list of available Mopidy extensions, see :ref:`ext`. - #. Finally, you need to set a couple of :doc:`config values `, and then you're ready to :doc:`run Mopidy `. + + +Installing extensions +===================== + +If you want to use any Mopidy extensions, like Spotify support or Last.fm +scrobbling, you need to install additional Mopidy extensions. + +You can install any Mopidy extension directly from PyPI with ``pip``. To list +all the extensions available from PyPI, run:: + + pip search mopidy + +Note that extensions installed from PyPI will only automatically install Python +dependencies. Please refer to the extension's documentation for information +about any other requirements needed for the extension to work properly. + +For a full list of available Mopidy extensions see :ref:`ext`. diff --git a/docs/devtools.rst b/docs/releasing.rst similarity index 67% rename from docs/devtools.rst rename to docs/releasing.rst index 93798071..8a12cf7d 100644 --- a/docs/devtools.rst +++ b/docs/releasing.rst @@ -1,51 +1,10 @@ -***************** -Development tools -***************** +****************** +Release procedures +****************** -Here you'll find description of the development tools we use. - - -Continuous integration -====================== - -Mopidy uses the free service `Travis CI `_ -for automatically running the test suite when code is pushed to GitHub. This -works both for the main Mopidy repo, but also for any forks. This way, any -contributions to Mopidy through GitHub will automatically be tested by Travis -CI, and the build status will be visible in the GitHub pull request interface, -making it easier to evaluate the quality of pull requests. - -In addition, we run a Jenkins CI server at http://ci.mopidy.com/ that runs all -test on multiple platforms (Ubuntu, OS X, x86, arm) for every commit we push to -the ``develop`` branch in the main Mopidy repo on GitHub. Thus, new code isn't -tested by Jenkins before it is merged into the ``develop`` branch, which is a -bit late, but good enough to get broad testing before new code is released. - -In addition to running tests, the Jenkins CI server also gathers coverage -statistics and uses flake8 to check for errors and possible improvements in our -code. So, if you're out of work, the code coverage and flake8 data at the CI -server should give you a place to start. - - -Documentation writing -===================== - -To write documentation, we use `Sphinx `_. See their -site for lots of documentation on how to use Sphinx. To generate HTML from the -documentation files, you need some additional dependencies. - -You can install them through Debian/Ubuntu package management:: - - sudo apt-get install python-sphinx python-pygraphviz graphviz - -Then, to generate docs:: - - cd docs/ - make # For help on available targets - make html # To generate HTML docs - -The documentation at http://docs.mopidy.com/ is automatically updated when a -documentation update is pushed to ``mopidy/mopidy`` at GitHub. +Here we try to keep an up to date record of how Mopidy releases are made. This +documentation serves both as a checklist, to reduce the project's dependency on +key individuals, and as a stepping stone to more automation. Creating releases diff --git a/docs/troubleshooting.rst b/docs/troubleshooting.rst index 883abc3b..b7ff3c03 100644 --- a/docs/troubleshooting.rst +++ b/docs/troubleshooting.rst @@ -4,9 +4,9 @@ Troubleshooting *************** -If you run into problems with Mopidy, we usually hang around at ``#mopidy`` at -`irc.freenode.net `_ and also have a `mailing list at -Google Groups `_. +If you run into problems with Mopidy, we usually hang around at ``#mopidy`` on +`irc.freenode.net `_ and also have a `discussion forum +`_. If you stumble into a bug or have a feature request, please create an issue in the `issue tracker `_. @@ -89,4 +89,12 @@ level 3, you can run:: GST_DEBUG=3 mopidy -v This will produce a lot of output, but given some GStreamer knowledge this is -very useful for debugging GStreamer pipeline issues. +very useful for debugging GStreamer pipeline issues. Additionally +:envvar:`GST_DEBUG_FILE=gstreamer.log` can be used to redirect the debug +logging to a file instead of standard out. + +Lastly :envvar:`GST_DEBUG_DUMP_DOT_DIR` can be used to get descriptions of the +current pipeline in dot format. Currently we trigger a dump of the pipeline on +every completed state change:: + + GST_DEBUG_DUMP_DOT_DIR=. mopidy diff --git a/docs/versioning.rst b/docs/versioning.rst index cc7f58bc..bc93275b 100644 --- a/docs/versioning.rst +++ b/docs/versioning.rst @@ -1,23 +1,39 @@ +.. _versioning: + ********** Versioning ********** -Mopidy uses `Semantic Versioning `_, but since we're still -pre-1.0 that doesn't mean much yet. +Mopidy follows `Semantic Versioning `_. In summary this +means that our version numbers have three parts, MAJOR.MINOR.PATCH, which +change according to the following rules: + +- When we *make incompatible API changes*, we increase the MAJOR number. + +- When we *add features* in a backwards-compatible manner, we increase the + MINOR number. + +- When we *fix bugs* in a backwards-compatible manner, we increase the PATCH + number. + +The promise is that if you make a Mopidy extension for Mopidy 1.0, it should +work unchanged with any Mopidy 1.x release, but probably not with 2.0. When a +new major version is released, you must review the incompatible changes and +update your extension accordingly. Release schedule ================ We intend to have about one feature release every month in periods of active -development. The feature releases are numbered 0.x.0. The features added is a -mix of what we feel is most important/requested of the missing features, and -features we develop just because we find them fun to make, even though they may -be useful for very few users or for a limited use case. +development. The features added is a mix of what we feel is most +important/requested of the missing features, and features we develop just +because we find them fun to make, even though they may be useful for very few +users or for a limited use case. -Bugfix releases, numbered 0.x.y, will be released whenever we discover bugs -that are too serious to wait for the next feature release. We will only release -bugfix releases for the last feature release. E.g. when 0.14.0 is released, we -will no longer provide bugfix releases for the 0.13 series. In other words, -there will be just a single supported release at any point in time. This is to -not spread our limited resources too thin. +Bugfix releases will be released whenever we discover bugs that are too serious +to wait for the next feature release. We will only release bugfix releases for +the last feature release. E.g. when 1.2.0 is released, we will no longer +provide bugfix releases for the 1.1.x series. In other words, there will be just +a single supported release at any point in time. This is to not spread our +limited resources too thin. diff --git a/data/mopidy.desktop b/extra/desktop/mopidy.desktop similarity index 100% rename from data/mopidy.desktop rename to extra/desktop/mopidy.desktop diff --git a/extra/mopidyctl/mopidyctl b/extra/mopidyctl/mopidyctl new file mode 100755 index 00000000..76d2fa63 --- /dev/null +++ b/extra/mopidyctl/mopidyctl @@ -0,0 +1,24 @@ +#!/bin/sh + +SELF=$(basename $0) +DAEMON="/usr/bin/mopidy" +DAEMON_USER="mopidy" +CONFIG_FILES="/usr/share/mopidy/conf.d:/etc/mopidy/mopidy.conf" +CMD="$DAEMON --config $CONFIG_FILES $@" + +if [ $# -eq 0 ]; then + echo "Usage: $SELF [options]" 1>&2 + echo "Examples:" 1>&2 + echo " $SELF --help" 1>&2 + echo " $SELF config" 1>&2 + echo " $SELF local scan" 1>&2 + exit 1 +fi + +if [ $(id -u) -ne 0 ]; then + echo "$SELF must be run as root" 1>&2 + exit 2 +fi + +echo "Running \"$CMD\" as user $DAEMON_USER" 1>&2 +su -s /bin/sh -c "$CMD" -- $DAEMON_USER diff --git a/extra/mopidyctl/mopidyctl.8 b/extra/mopidyctl/mopidyctl.8 new file mode 100644 index 00000000..526165e9 --- /dev/null +++ b/extra/mopidyctl/mopidyctl.8 @@ -0,0 +1,17 @@ +.\" Manpage for mopidyctl +.TH "MOPIDYCTL" "8" "October 11, 2014" "1.0" "mopidyctl" +.SH NAME +mopidyctl \- manage the Mopidy music server system service +.SH SYNOPSIS +.B mopidyctl +[any mopidy(1) option] +.SH DESCRIPTION +The \fBmopidyctl\fP command runs \fBmopidy\fP subcommands in the +same environment as the Mopidy system service is running in. That is, as the +same user and with the same config as the Mopidy system service is using. +.SH OPTIONS +mopidyctl(8) takes the same options as mopidy(1). +.SH SEE ALSO +mopidy(1) +.SH COPYRIGHT +2014, Stein Magnus Jodal and contributors diff --git a/extra/systemd/mopidy.service b/extra/systemd/mopidy.service new file mode 100644 index 00000000..3d8abc20 --- /dev/null +++ b/extra/systemd/mopidy.service @@ -0,0 +1,16 @@ +[Unit] +Description=Mopidy music server +After=avahi-daemon.service +After=dbus.service +After=network.target +After=nss-lookup.target +After=pulseaudio.service +After=remote-fs.target +After=sound.target + +[Service] +User=mopidy +ExecStart=/usr/bin/mopidy --config /usr/share/mopidy/conf.d:/etc/mopidy/mopidy.conf + +[Install] +WantedBy=multi-user.target diff --git a/fabfile.py b/fabfile.py deleted file mode 100644 index f23da2b1..00000000 --- a/fabfile.py +++ /dev/null @@ -1,63 +0,0 @@ -from fabric.api import execute, local, settings, task - - -@task -def docs(): - local('make -C docs/ html') - - -@task -def autodocs(): - auto(docs) - - -@task -def test(path=None): - path = path or 'tests/' - local('nosetests ' + path) - - -@task -def autotest(path=None): - auto(test, path=path) - - -@task -def coverage(path=None): - path = path or 'tests/' - local( - 'nosetests --with-coverage --cover-package=mopidy ' - '--cover-branches --cover-html ' + path) - - -@task -def autocoverage(path=None): - auto(coverage, path=path) - - -@task -def lint(path=None): - path = path or '.' - local('flake8 $(find %s -iname "*.py")' % path) - - -@task -def autolint(path=None): - auto(lint, path=path) - - -def auto(task, *args, **kwargs): - while True: - local('clear') - with settings(warn_only=True): - execute(task, *args, **kwargs) - local( - 'inotifywait -q -e create -e modify -e delete ' - '--exclude ".*\.(pyc|sw.)" -r docs/ mopidy/ tests/') - - -@task -def update_authors(): - # Keep authors in the order of appearance and use awk to filter out dupes - local( - "git log --format='- %aN <%aE>' --reverse | awk '!x[$0]++' > AUTHORS") diff --git a/js/Gruntfile.js b/js/Gruntfile.js deleted file mode 100644 index 81221676..00000000 --- a/js/Gruntfile.js +++ /dev/null @@ -1,101 +0,0 @@ -/*global module:false*/ -module.exports = function (grunt) { - - grunt.initConfig({ - pkg: grunt.file.readJSON("package.json"), - meta: { - banner: "/*! Mopidy.js v<%= pkg.version %> - 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"], - main: "src/mopidy.js", - concat: "../mopidy/http/data/mopidy.js", - minified: "../mopidy/http/data/mopidy.min.js" - } - }, - buster: { - all: {} - }, - browserify: { - test_mopidy: { - files: { - "test/lib/mopidy.js": "<%= meta.files.main %>" - }, - options: { - postBundleCB: function (err, src, next) { - next(err, grunt.template.process("<%= meta.banner %>") + src); - }, - standalone: "Mopidy" - } - }, - test_when: { - files: { - "test/lib/when.js": "node_modules/when/when.js" - }, - options: { - standalone: "when" - } - }, - dist: { - files: { - "<%= meta.files.concat %>": "<%= meta.files.main %>" - }, - options: { - postBundleCB: function (err, src, next) { - next(err, grunt.template.process("<%= meta.banner %>") + src); - }, - standalone: "Mopidy" - } - } - }, - 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_build", ["browserify:test_when", "browserify:test_mopidy"]); - grunt.registerTask("test", ["jshint", "test_build", "buster"]); - grunt.registerTask("build", ["test", "browserify:dist", "uglify"]); - grunt.registerTask("default", ["build"]); - - grunt.loadNpmTasks("grunt-buster"); - grunt.loadNpmTasks("grunt-browserify"); - 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 deleted file mode 100644 index 1b368bf5..00000000 --- a/js/README.md +++ /dev/null @@ -1,121 +0,0 @@ -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/http/data/mopidy.js` -- `mopidy/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"); - - -Using the library ------------------ - -See the [Mopidy.js documentation](http://docs.mopidy.com/en/latest/api/js/). - - -Building from source --------------------- - -1. Install [Node.js](http://nodejs.org/) and npm. If you're running Ubuntu: - - sudo apt-get install nodejs-legacy 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 start - -To run tests, concatenate, minify the source, and update the JavaScript files -in `mopidy/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 - - -Changelog ---------- - -### 0.4.0 (2014-06-24) - -- Add support for method calls with by-name arguments. The old calling - convention, "by-position-only", is still the default, but this will change in - the future. A warning is printed to the console if you don't explicitly - select a calling convention. See the docs for details. - -### 0.3.0 (2014-06-16) - -- Upgrade to when.js 3, which brings great performance improvements and better - debugging facilities. If you maintain a Mopidy client, you should review the - [differences between when.js 2 and 3](https://github.com/cujojs/when/blob/master/docs/api.md#upgrading-to-30-from-2x) - and the - [when.js debugging guide](https://github.com/cujojs/when/blob/master/docs/api.md#debugging-promises). - -- All promise rejection values are now of the Error type. This ensures that all - JavaScript VMs will show a useful stack trace if a rejected promise's value - is used to throw an exception. To allow catch clauses to handle different - errors differently, server side errors are of the type `Mopidy.ServerError`, - and connection related errors are of the type `Mopidy.ConnectionError`. - -### 0.2.0 (2014-01-04) - -- **Backwards incompatible change for Node.js users:** - `var Mopidy = require('mopidy').Mopidy;` must be changed to - `var Mopidy = require('mopidy');` - -- Add support for [Browserify](http://browserify.org/). - -- Upgrade dependencies. - -### 0.1.1 (2013-09-17) - -- Upgrade dependencies. - -### 0.1.0 (2013-03-31) - -- Initial release as a Node.js module to the - [npm registry](https://npmjs.org/). diff --git a/js/buster.js b/js/buster.js deleted file mode 100644 index c5dec850..00000000 --- a/js/buster.js +++ /dev/null @@ -1,15 +0,0 @@ -var config = module.exports; - -config.browser_tests = { - environment: "browser", - libs: ["test/lib/*.js"], - testHelpers: ["test/**/*-helper.js"], - tests: ["test/**/*-test.js"] -}; - -config.node_tests = { - environment: "node", - sources: ["src/**/*.js"], - testHelpers: ["test/**/*-helper.js"], - tests: ["test/**/*-test.js"] -}; diff --git a/js/lib/websocket/browser.js b/js/lib/websocket/browser.js deleted file mode 100644 index e594246c..00000000 --- a/js/lib/websocket/browser.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = { Client: window.WebSocket }; diff --git a/js/lib/websocket/package.json b/js/lib/websocket/package.json deleted file mode 100644 index d1e2ac63..00000000 --- a/js/lib/websocket/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "browser": "browser.js", - "main": "server.js" -} diff --git a/js/lib/websocket/server.js b/js/lib/websocket/server.js deleted file mode 100644 index dd24f4be..00000000 --- a/js/lib/websocket/server.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = require('faye-websocket'); diff --git a/js/package.json b/js/package.json deleted file mode 100644 index b2b63f84..00000000 --- a/js/package.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "name": "mopidy", - "version": "0.4.0", - "description": "Client lib for controlling a Mopidy music server over a WebSocket", - "keywords": [ - "mopidy", - "music", - "client", - "websocket", - "json-rpc" - ], - "homepage": "http://www.mopidy.com/", - "bugs": "https://github.com/mopidy/mopidy/issues", - "license": "Apache-2.0", - "author": { - "name": "Stein Magnus Jodal", - "email": "stein.magnus@jodal.no", - "url": "http://www.jodal.no" - }, - "contributors": [ - { - "name": "Stein Magnus Jodal", - "email": "stein.magnus@jodal.no", - "url": "http://www.jodal.no" - }, - { - "name": "Paul Connolley", - "email": "paul.connolley@gmail.com" - } - ], - "main": "src/mopidy.js", - "repository": { - "type": "git", - "url": "git://github.com/mopidy/mopidy.git" - }, - "scripts": { - "test": "grunt test", - "build": "grunt build", - "start": "grunt watch" - }, - "dependencies": { - "bane": "~1.1.0", - "faye-websocket": "~0.7.2", - "when": "~3.2.3" - }, - "devDependencies": { - "buster": "~0.7.13", - "browserify": "~3", - "grunt": "~0.4.5", - "grunt-buster": "~0.3.1", - "grunt-browserify": "~1.3.2", - "grunt-contrib-jshint": "~0.10.0", - "grunt-contrib-uglify": "~0.5.0", - "grunt-contrib-watch": "~0.6.1", - "phantomjs": "~1.9.7-8" - }, - "engines": { - "node": "*" - } -} diff --git a/js/src/mopidy.js b/js/src/mopidy.js deleted file mode 100644 index 7e019dd4..00000000 --- a/js/src/mopidy.js +++ /dev/null @@ -1,331 +0,0 @@ -/*global module:true, require:false*/ - -var bane = require("bane"); -var websocket = require("../lib/websocket/"); -var when = require("when"); - -function Mopidy(settings) { - if (!(this instanceof Mopidy)) { - return new Mopidy(settings); - } - - this._console = this._getConsole(settings || {}); - this._settings = this._configure(settings || {}); - - this._backoffDelay = this._settings.backoffDelayMin; - this._pendingRequests = {}; - this._webSocket = null; - - bane.createEventEmitter(this); - this._delegateEvents(); - - if (this._settings.autoConnect) { - this.connect(); - } -} - -Mopidy.ConnectionError = function (message) { - this.name = "ConnectionError"; - this.message = message; -}; -Mopidy.ConnectionError.prototype = new Error(); -Mopidy.ConnectionError.prototype.constructor = Mopidy.ConnectionError; - -Mopidy.ServerError = function (message) { - this.name = "ServerError"; - this.message = message; -}; -Mopidy.ServerError.prototype = new Error(); -Mopidy.ServerError.prototype.constructor = Mopidy.ServerError; - -Mopidy.WebSocket = websocket.Client; - -Mopidy.prototype._getConsole = function (settings) { - if (typeof settings.console !== "undefined") { - return settings.console; - } - - var con = typeof console !== "undefined" && console || {}; - - con.log = con.log || function () {}; - con.warn = con.warn || function () {}; - con.error = con.error || function () {}; - - return con; -}; - -Mopidy.prototype._configure = function (settings) { - var currentHost = (typeof document !== "undefined" && - document.location.host) || "localhost"; - settings.webSocketUrl = settings.webSocketUrl || - "ws://" + currentHost + "/mopidy/ws"; - - if (settings.autoConnect !== false) { - settings.autoConnect = true; - } - - settings.backoffDelayMin = settings.backoffDelayMin || 1000; - settings.backoffDelayMax = settings.backoffDelayMax || 64000; - - if (typeof settings.callingConvention === "undefined") { - this._console.warn( - "Mopidy.js is using the default calling convention. The " + - "default will change in the future. You should explicitly " + - "specify which calling convention you use."); - } - settings.callingConvention = ( - settings.callingConvention || "by-position-only"); - - return settings; -}; - -Mopidy.prototype._delegateEvents = function () { - // Remove existing event handlers - this.off("websocket:close"); - this.off("websocket:error"); - this.off("websocket:incomingMessage"); - this.off("websocket:open"); - this.off("state:offline"); - - // Register basic set of event handlers - this.on("websocket:close", this._cleanup); - this.on("websocket:error", this._handleWebSocketError); - this.on("websocket:incomingMessage", this._handleMessage); - this.on("websocket:open", this._resetBackoffDelay); - this.on("websocket:open", this._getApiSpec); - this.on("state:offline", this._reconnect); -}; - -Mopidy.prototype.connect = function () { - if (this._webSocket) { - if (this._webSocket.readyState === Mopidy.WebSocket.OPEN) { - return; - } else { - this._webSocket.close(); - } - } - - this._webSocket = this._settings.webSocket || - new Mopidy.WebSocket(this._settings.webSocketUrl); - - this._webSocket.onclose = function (close) { - this.emit("websocket:close", close); - }.bind(this); - - this._webSocket.onerror = function (error) { - this.emit("websocket:error", error); - }.bind(this); - - this._webSocket.onopen = function () { - this.emit("websocket:open"); - }.bind(this); - - this._webSocket.onmessage = function (message) { - this.emit("websocket:incomingMessage", message); - }.bind(this); -}; - -Mopidy.prototype._cleanup = function (closeEvent) { - Object.keys(this._pendingRequests).forEach(function (requestId) { - var resolver = this._pendingRequests[requestId]; - delete this._pendingRequests[requestId]; - var error = new Mopidy.ConnectionError("WebSocket closed"); - error.closeEvent = closeEvent; - resolver.reject(error); - }.bind(this)); - - this.emit("state:offline"); -}; - -Mopidy.prototype._reconnect = function () { - this.emit("reconnectionPending", { - timeToAttempt: this._backoffDelay - }); - - setTimeout(function () { - this.emit("reconnecting"); - this.connect(); - }.bind(this), this._backoffDelay); - - this._backoffDelay = this._backoffDelay * 2; - if (this._backoffDelay > this._settings.backoffDelayMax) { - this._backoffDelay = this._settings.backoffDelayMax; - } -}; - -Mopidy.prototype._resetBackoffDelay = function () { - this._backoffDelay = this._settings.backoffDelayMin; -}; - -Mopidy.prototype.close = function () { - this.off("state:offline", this._reconnect); - this._webSocket.close(); -}; - -Mopidy.prototype._handleWebSocketError = function (error) { - this._console.warn("WebSocket error:", error.stack || error); -}; - -Mopidy.prototype._send = function (message) { - switch (this._webSocket.readyState) { - case Mopidy.WebSocket.CONNECTING: - return when.reject( - new Mopidy.ConnectionError("WebSocket is still connecting")); - case Mopidy.WebSocket.CLOSING: - return when.reject( - new Mopidy.ConnectionError("WebSocket is closing")); - case Mopidy.WebSocket.CLOSED: - return when.reject( - new Mopidy.ConnectionError("WebSocket is closed")); - default: - var deferred = when.defer(); - message.jsonrpc = "2.0"; - message.id = this._nextRequestId(); - this._pendingRequests[message.id] = deferred.resolver; - this._webSocket.send(JSON.stringify(message)); - this.emit("websocket:outgoingMessage", message); - return deferred.promise; - } -}; - -Mopidy.prototype._nextRequestId = (function () { - var lastUsed = -1; - return function () { - lastUsed += 1; - return lastUsed; - }; -}()); - -Mopidy.prototype._handleMessage = function (message) { - try { - var data = JSON.parse(message.data); - if (data.hasOwnProperty("id")) { - this._handleResponse(data); - } else if (data.hasOwnProperty("event")) { - this._handleEvent(data); - } else { - this._console.warn( - "Unknown message type received. Message was: " + - message.data); - } - } catch (error) { - if (error instanceof SyntaxError) { - this._console.warn( - "WebSocket message parsing failed. Message was: " + - message.data); - } else { - throw error; - } - } -}; - -Mopidy.prototype._handleResponse = function (responseMessage) { - if (!this._pendingRequests.hasOwnProperty(responseMessage.id)) { - this._console.warn( - "Unexpected response received. Message was:", responseMessage); - return; - } - - var error; - var resolver = this._pendingRequests[responseMessage.id]; - delete this._pendingRequests[responseMessage.id]; - - if (responseMessage.hasOwnProperty("result")) { - resolver.resolve(responseMessage.result); - } else if (responseMessage.hasOwnProperty("error")) { - error = new Mopidy.ServerError(responseMessage.error.message); - error.code = responseMessage.error.code; - error.data = responseMessage.error.data; - resolver.reject(error); - this._console.warn("Server returned error:", responseMessage.error); - } else { - error = new Error("Response without 'result' or 'error' received"); - error.data = {response: responseMessage}; - resolver.reject(error); - this._console.warn( - "Response without 'result' or 'error' received. Message was:", - responseMessage); - } -}; - -Mopidy.prototype._handleEvent = function (eventMessage) { - var type = eventMessage.event; - var data = eventMessage; - delete data.event; - - this.emit("event:" + this._snakeToCamel(type), data); -}; - -Mopidy.prototype._getApiSpec = function () { - return this._send({method: "core.describe"}) - .then(this._createApi.bind(this)) - .catch(this._handleWebSocketError); -}; - -Mopidy.prototype._createApi = function (methods) { - var byPositionOrByName = ( - this._settings.callingConvention === "by-position-or-by-name"); - - var caller = function (method) { - return function () { - var message = {method: method}; - if (arguments.length === 0) { - return this._send(message); - } - if (!byPositionOrByName) { - message.params = Array.prototype.slice.call(arguments); - return this._send(message); - } - if (arguments.length > 1) { - return when.reject(new Error( - "Expected zero arguments, a single array, " + - "or a single object.")); - } - if (!Array.isArray(arguments[0]) && - arguments[0] !== Object(arguments[0])) { - return when.reject(new TypeError( - "Expected an array or an object.")); - } - message.params = arguments[0]; - return this._send(message); - }.bind(this); - }.bind(this); - - var getPath = function (fullName) { - var path = fullName.split("."); - if (path.length >= 1 && path[0] === "core") { - path = path.slice(1); - } - return path; - }; - - var createObjects = function (objPath) { - var parentObj = this; - objPath.forEach(function (objName) { - objName = this._snakeToCamel(objName); - parentObj[objName] = parentObj[objName] || {}; - parentObj = parentObj[objName]; - }.bind(this)); - return parentObj; - }.bind(this); - - var createMethod = function (fullMethodName) { - var methodPath = getPath(fullMethodName); - var methodName = this._snakeToCamel(methodPath.slice(-1)[0]); - var object = createObjects(methodPath.slice(0, -1)); - object[methodName] = caller(fullMethodName); - object[methodName].description = methods[fullMethodName].description; - object[methodName].params = methods[fullMethodName].params; - }.bind(this); - - Object.keys(methods).forEach(createMethod); - this.emit("state:online"); -}; - -Mopidy.prototype._snakeToCamel = function (name) { - return name.replace(/(_[a-z])/g, function (match) { - return match.toUpperCase().replace("_", ""); - }); -}; - -module.exports = Mopidy; diff --git a/js/test/bind-helper.js b/js/test/bind-helper.js deleted file mode 100644 index a5a3e0f4..00000000 --- a/js/test/bind-helper.js +++ /dev/null @@ -1,29 +0,0 @@ -/* - * PhantomJS 1.6 does not support Function.prototype.bind, so we polyfill it. - * - * Implementation from: - * https://developer.mozilla.org/en-US/docs/JavaScript/Reference/Global_Objects/Function/bind - */ -if (!Function.prototype.bind) { - Function.prototype.bind = function (oThis) { - if (typeof this !== "function") { - // closest thing possible to the ECMAScript 5 internal IsCallable function - throw new TypeError("Function.prototype.bind - what is trying to be bound is not callable"); - } - - var aArgs = Array.prototype.slice.call(arguments, 1), - fToBind = this, - fNOP = function () {}, - fBound = function () { - return fToBind.apply(this instanceof fNOP && oThis - ? this - : oThis, - aArgs.concat(Array.prototype.slice.call(arguments))); - }; - - fNOP.prototype = this.prototype; - fBound.prototype = new fNOP(); - - return fBound; - }; -} diff --git a/js/test/mopidy-test.js b/js/test/mopidy-test.js deleted file mode 100644 index caf4ce21..00000000 --- a/js/test/mopidy-test.js +++ /dev/null @@ -1,964 +0,0 @@ -/*global require:false */ - -if (typeof module === "object" && typeof require === "function") { - var buster = require("buster"); - var Mopidy = require("../src/mopidy"); - var when = require("when"); -} - -var assert = buster.assert; -var refute = buster.refute; - -buster.testCase("Mopidy", { - setUp: function () { - // Sinon.JS doesn't manage to stub PhantomJS' WebSocket implementation, - // so we replace it with a dummy temporarily. - var fakeWebSocket = function () { - return { - send: function () {}, - close: function () {} - }; - }; - fakeWebSocket.CONNECTING = 0; - fakeWebSocket.OPEN = 1; - fakeWebSocket.CLOSING = 2; - fakeWebSocket.CLOSED = 3; - - this.realWebSocket = Mopidy.WebSocket; - Mopidy.WebSocket = fakeWebSocket; - - this.webSocketConstructorStub = this.stub(Mopidy, "WebSocket"); - - this.webSocket = { - close: this.stub(), - send: this.stub() - }; - this.mopidy = new Mopidy({ - callingConvention: "by-position-or-by-name", - webSocket: this.webSocket - }); - }, - - tearDown: function () { - Mopidy.WebSocket = this.realWebSocket; - }, - - "constructor": { - "connects when autoConnect is true": function () { - new Mopidy({ - autoConnect: true, - callingConvention: "by-position-or-by-name" - }); - - var currentHost = typeof document !== "undefined" && - document.location.host || "localhost"; - - assert.calledOnceWith(this.webSocketConstructorStub, - "ws://" + currentHost + "/mopidy/ws"); - }, - - "does not connect when autoConnect is false": function () { - new Mopidy({ - autoConnect: false, - callingConvention: "by-position-or-by-name" - }); - - refute.called(this.webSocketConstructorStub); - }, - - "does not connect when passed a WebSocket": function () { - new Mopidy({ - callingConvention: "by-position-or-by-name", - webSocket: {} - }); - - refute.called(this.webSocketConstructorStub); - }, - - "defaults to by-position-only calling convention": function () { - var console = { - warn: function () {} - }; - var mopidy = new Mopidy({ - console: console, - webSocket: this.webSocket, - }); - - assert.equals( - mopidy._settings.callingConvention, - "by-position-only"); - }, - - "warns if no calling convention explicitly selected": function () { - var console = { - warn: function () {} - }; - var stub = this.stub(console, "warn"); - - new Mopidy({console: console}); - - assert.calledOnceWith( - stub, - "Mopidy.js is using the default calling convention. The " + - "default will change in the future. You should explicitly " + - "specify which calling convention you use."); - }, - - "does not warn if calling convention chosen explicitly": function () { - var console = { - warn: function () {} - }; - var stub = this.stub(console, "warn"); - - new Mopidy({ - callingConvention: "by-position-or-by-name", - console: console - }); - - refute.called(stub); - }, - - "works without 'new' keyword": function () { - var mopidyConstructor = Mopidy; // To trick jshint into submission - - var mopidy = mopidyConstructor({ - callingConvention: "by-position-or-by-name", - webSocket: {} - }); - - assert.isObject(mopidy); - assert(mopidy instanceof Mopidy); - } - }, - - ".connect": { - "connects when autoConnect is false": function () { - var mopidy = new Mopidy({ - autoConnect: false, - callingConvention: "by-position-or-by-name" - }); - refute.called(this.webSocketConstructorStub); - - mopidy.connect(); - - var currentHost = typeof document !== "undefined" && - document.location.host || "localhost"; - - assert.calledOnceWith(this.webSocketConstructorStub, - "ws://" + currentHost + "/mopidy/ws"); - }, - - "does nothing when the WebSocket is open": function () { - this.webSocket.readyState = Mopidy.WebSocket.OPEN; - var mopidy = new Mopidy({ - callingConvention: "by-position-or-by-name", - webSocket: this.webSocket - }); - - mopidy.connect(); - - refute.called(this.webSocket.close); - refute.called(this.webSocketConstructorStub); - } - }, - - "WebSocket events": { - "emits 'websocket:close' when connection is closed": function () { - var spy = this.spy(); - this.mopidy.off("websocket:close"); - this.mopidy.on("websocket:close", spy); - - var closeEvent = {}; - this.webSocket.onclose(closeEvent); - - assert.calledOnceWith(spy, closeEvent); - }, - - "emits 'websocket:error' when errors occurs": function () { - var spy = this.spy(); - this.mopidy.off("websocket:error"); - this.mopidy.on("websocket:error", spy); - - var errorEvent = {}; - this.webSocket.onerror(errorEvent); - - assert.calledOnceWith(spy, errorEvent); - }, - - "emits 'websocket:incomingMessage' when a message arrives": function () { - var spy = this.spy(); - this.mopidy.off("websocket:incomingMessage"); - this.mopidy.on("websocket:incomingMessage", spy); - - var messageEvent = {data: "this is a message"}; - this.webSocket.onmessage(messageEvent); - - assert.calledOnceWith(spy, messageEvent); - }, - - "emits 'websocket:open' when connection is opened": function () { - var spy = this.spy(); - this.mopidy.off("websocket:open"); - this.mopidy.on("websocket:open", spy); - - this.webSocket.onopen(); - - assert.calledOnceWith(spy); - } - }, - - "._cleanup": { - setUp: function () { - this.mopidy.off("state:offline"); - }, - - "is called on 'websocket:close' event": function () { - var closeEvent = {}; - var stub = this.stub(this.mopidy, "_cleanup"); - this.mopidy._delegateEvents(); - - this.mopidy.emit("websocket:close", closeEvent); - - assert.calledOnceWith(stub, closeEvent); - }, - - "rejects all pending requests": function (done) { - var closeEvent = {}; - assert.equals(Object.keys(this.mopidy._pendingRequests).length, 0); - - var promise1 = this.mopidy._send({method: "foo"}); - var promise2 = this.mopidy._send({method: "bar"}); - assert.equals(Object.keys(this.mopidy._pendingRequests).length, 2); - - this.mopidy._cleanup(closeEvent); - - assert.equals(Object.keys(this.mopidy._pendingRequests).length, 0); - when.settle([promise1, promise2]).done( - done(function (descriptors) { - assert.equals(descriptors.length, 2); - descriptors.forEach(function (d) { - assert.equals(d.state, "rejected"); - assert(d.reason instanceof Error); - assert(d.reason instanceof Mopidy.ConnectionError); - assert.equals(d.reason.message, "WebSocket closed"); - assert.same(d.reason.closeEvent, closeEvent); - }); - }) - ); - }, - - "emits 'state:offline' event when done": function () { - var spy = this.spy(); - this.mopidy.on("state:offline", spy); - - this.mopidy._cleanup({}); - - assert.calledOnceWith(spy); - } - }, - - "._reconnect": { - "is called when the state changes to offline": function () { - var stub = this.stub(this.mopidy, "_reconnect"); - this.mopidy._delegateEvents(); - - this.mopidy.emit("state:offline"); - - assert.calledOnceWith(stub); - }, - - "tries to connect after an increasing backoff delay": function () { - var clock = this.useFakeTimers(); - var connectStub = this.stub(this.mopidy, "connect"); - var pendingSpy = this.spy(); - this.mopidy.on("reconnectionPending", pendingSpy); - var reconnectingSpy = this.spy(); - this.mopidy.on("reconnecting", reconnectingSpy); - - refute.called(connectStub); - - this.mopidy._reconnect(); - assert.calledOnceWith(pendingSpy, {timeToAttempt: 1000}); - clock.tick(0); - refute.called(connectStub); - clock.tick(1000); - assert.calledOnceWith(reconnectingSpy); - assert.calledOnce(connectStub); - - pendingSpy.reset(); - reconnectingSpy.reset(); - this.mopidy._reconnect(); - assert.calledOnceWith(pendingSpy, {timeToAttempt: 2000}); - assert.calledOnce(connectStub); - clock.tick(0); - assert.calledOnce(connectStub); - clock.tick(1000); - assert.calledOnce(connectStub); - clock.tick(1000); - assert.calledOnceWith(reconnectingSpy); - assert.calledTwice(connectStub); - - pendingSpy.reset(); - reconnectingSpy.reset(); - this.mopidy._reconnect(); - assert.calledOnceWith(pendingSpy, {timeToAttempt: 4000}); - assert.calledTwice(connectStub); - clock.tick(0); - assert.calledTwice(connectStub); - clock.tick(2000); - assert.calledTwice(connectStub); - clock.tick(2000); - assert.calledOnceWith(reconnectingSpy); - assert.calledThrice(connectStub); - }, - - "tries to connect at least about once per minute": function () { - var clock = this.useFakeTimers(); - var connectStub = this.stub(this.mopidy, "connect"); - var pendingSpy = this.spy(); - this.mopidy.on("reconnectionPending", pendingSpy); - this.mopidy._backoffDelay = this.mopidy._settings.backoffDelayMax; - - refute.called(connectStub); - - this.mopidy._reconnect(); - assert.calledOnceWith(pendingSpy, {timeToAttempt: 64000}); - clock.tick(0); - refute.called(connectStub); - clock.tick(64000); - assert.calledOnce(connectStub); - - pendingSpy.reset(); - this.mopidy._reconnect(); - assert.calledOnceWith(pendingSpy, {timeToAttempt: 64000}); - assert.calledOnce(connectStub); - clock.tick(0); - assert.calledOnce(connectStub); - clock.tick(64000); - assert.calledTwice(connectStub); - } - }, - - "._resetBackoffDelay": { - "is called on 'websocket:open' event": function () { - var stub = this.stub(this.mopidy, "_resetBackoffDelay"); - this.mopidy._delegateEvents(); - - this.mopidy.emit("websocket:open"); - - assert.calledOnceWith(stub); - }, - - "resets the backoff delay to the minimum value": function () { - this.mopidy._backoffDelay = this.mopidy._backoffDelayMax; - - this.mopidy._resetBackoffDelay(); - - assert.equals(this.mopidy._backoffDelay, - this.mopidy._settings.backoffDelayMin); - } - }, - - "close": { - "unregisters reconnection hooks": function () { - this.stub(this.mopidy, "off"); - - this.mopidy.close(); - - assert.calledOnceWith( - this.mopidy.off, "state:offline", this.mopidy._reconnect); - }, - - "closes the WebSocket": function () { - this.mopidy.close(); - - assert.calledOnceWith(this.mopidy._webSocket.close); - } - }, - - "._handleWebSocketError": { - "is called on 'websocket:error' event": function () { - var error = {}; - var stub = this.stub(this.mopidy, "_handleWebSocketError"); - this.mopidy._delegateEvents(); - - this.mopidy.emit("websocket:error", error); - - assert.calledOnceWith(stub, error); - }, - - "without stack logs the error to the console": function () { - var stub = this.stub(this.mopidy._console, "warn"); - var error = {}; - - this.mopidy._handleWebSocketError(error); - - assert.calledOnceWith(stub, "WebSocket error:", error); - }, - - "with stack logs the error to the console": function () { - var stub = this.stub(this.mopidy._console, "warn"); - var error = {stack: "foo"}; - - this.mopidy._handleWebSocketError(error); - - assert.calledOnceWith(stub, "WebSocket error:", error.stack); - } - }, - - "._send": { - "adds JSON-RPC fields to the message": function () { - this.stub(this.mopidy, "_nextRequestId").returns(1); - var stub = this.stub(JSON, "stringify"); - - this.mopidy._send({method: "foo"}); - - assert.calledOnceWith(stub, { - jsonrpc: "2.0", - id: 1, - method: "foo" - }); - }, - - "adds a resolver to the pending requests queue": function () { - this.stub(this.mopidy, "_nextRequestId").returns(1); - assert.equals(Object.keys(this.mopidy._pendingRequests).length, 0); - - this.mopidy._send({method: "foo"}); - - assert.equals(Object.keys(this.mopidy._pendingRequests).length, 1); - assert.isFunction(this.mopidy._pendingRequests[1].resolve); - }, - - "sends message on the WebSocket": function () { - refute.called(this.mopidy._webSocket.send); - - this.mopidy._send({method: "foo"}); - - assert.calledOnce(this.mopidy._webSocket.send); - }, - - "emits a 'websocket:outgoingMessage' event": function () { - var spy = this.spy(); - this.mopidy.on("websocket:outgoingMessage", spy); - this.stub(this.mopidy, "_nextRequestId").returns(1); - - this.mopidy._send({method: "foo"}); - - assert.calledOnceWith(spy, { - jsonrpc: "2.0", - id: 1, - method: "foo" - }); - }, - - "immediately rejects request if CONNECTING": function (done) { - this.mopidy._webSocket.readyState = Mopidy.WebSocket.CONNECTING; - - var promise = this.mopidy._send({method: "foo"}); - - refute.called(this.mopidy._webSocket.send); - promise.done( - done(function () { - assert(false); - }), - done(function (error) { - assert(error instanceof Error); - assert(error instanceof Mopidy.ConnectionError); - assert.equals( - error.message, "WebSocket is still connecting"); - }) - ); - }, - - "immediately rejects request if CLOSING": function (done) { - this.mopidy._webSocket.readyState = Mopidy.WebSocket.CLOSING; - - var promise = this.mopidy._send({method: "foo"}); - - refute.called(this.mopidy._webSocket.send); - promise.done( - done(function () { - assert(false); - }), - done(function (error) { - assert(error instanceof Error); - assert(error instanceof Mopidy.ConnectionError); - assert.equals(error.message, "WebSocket is closing"); - }) - ); - }, - - "immediately rejects request if CLOSED": function (done) { - this.mopidy._webSocket.readyState = Mopidy.WebSocket.CLOSED; - - var promise = this.mopidy._send({method: "foo"}); - - refute.called(this.mopidy._webSocket.send); - promise.done( - done(function () { - assert(false); - }), - done(function (error) { - assert(error instanceof Error); - assert(error instanceof Mopidy.ConnectionError); - assert.equals(error.message, "WebSocket is closed"); - }) - ); - } - }, - - "._nextRequestId": { - "returns an ever increasing ID": function () { - var base = this.mopidy._nextRequestId(); - assert.equals(this.mopidy._nextRequestId(), base + 1); - assert.equals(this.mopidy._nextRequestId(), base + 2); - assert.equals(this.mopidy._nextRequestId(), base + 3); - } - }, - - "._handleMessage": { - "is called on 'websocket:incomingMessage' event": function () { - var messageEvent = {}; - var stub = this.stub(this.mopidy, "_handleMessage"); - this.mopidy._delegateEvents(); - - this.mopidy.emit("websocket:incomingMessage", messageEvent); - - assert.calledOnceWith(stub, messageEvent); - }, - - "passes JSON-RPC responses on to _handleResponse": function () { - var stub = this.stub(this.mopidy, "_handleResponse"); - var message = { - jsonrpc: "2.0", - id: 1, - result: null - }; - var messageEvent = {data: JSON.stringify(message)}; - - this.mopidy._handleMessage(messageEvent); - - assert.calledOnceWith(stub, message); - }, - - "passes events on to _handleEvent": function () { - var stub = this.stub(this.mopidy, "_handleEvent"); - var message = { - event: "track_playback_started", - track: {} - }; - var messageEvent = {data: JSON.stringify(message)}; - - this.mopidy._handleMessage(messageEvent); - - assert.calledOnceWith(stub, message); - }, - - "logs unknown messages": function () { - var stub = this.stub(this.mopidy._console, "warn"); - var messageEvent = {data: JSON.stringify({foo: "bar"})}; - - this.mopidy._handleMessage(messageEvent); - - assert.calledOnceWith(stub, - "Unknown message type received. Message was: " + - messageEvent.data); - }, - - "logs JSON parsing errors": function () { - var stub = this.stub(this.mopidy._console, "warn"); - var messageEvent = {data: "foobarbaz"}; - - this.mopidy._handleMessage(messageEvent); - - assert.calledOnceWith(stub, - "WebSocket message parsing failed. Message was: " + - messageEvent.data); - } - }, - - "._handleResponse": { - "logs unexpected responses": function () { - var stub = this.stub(this.mopidy._console, "warn"); - var responseMessage = { - jsonrpc: "2.0", - id: 1337, - result: null - }; - - this.mopidy._handleResponse(responseMessage); - - assert.calledOnceWith(stub, - "Unexpected response received. Message was:", responseMessage); - }, - - "removes the matching request from the pending queue": function () { - assert.equals(Object.keys(this.mopidy._pendingRequests).length, 0); - this.mopidy._send({method: "bar"}); - assert.equals(Object.keys(this.mopidy._pendingRequests).length, 1); - - this.mopidy._handleResponse({ - jsonrpc: "2.0", - id: Object.keys(this.mopidy._pendingRequests)[0], - result: "baz" - }); - - assert.equals(Object.keys(this.mopidy._pendingRequests).length, 0); - }, - - "resolves requests which get results back": function (done) { - var promise = this.mopidy._send({method: "bar"}); - var responseResult = {}; - var responseMessage = { - jsonrpc: "2.0", - id: Object.keys(this.mopidy._pendingRequests)[0], - result: responseResult - }; - - this.mopidy._handleResponse(responseMessage); - promise.then(done(function (result) { - assert.equals(result, responseResult); - }), done(function () { - assert(false); - })); - }, - - "rejects and logs requests which get errors back": function (done) { - var stub = this.stub(this.mopidy._console, "warn"); - var promise = this.mopidy._send({method: "bar"}); - var responseError = { - code: -32601, - message: "Method not found", - data: {} - }; - var responseMessage = { - jsonrpc: "2.0", - id: Object.keys(this.mopidy._pendingRequests)[0], - error: responseError - }; - - this.mopidy._handleResponse(responseMessage); - - assert.calledOnceWith(stub, - "Server returned error:", responseError); - promise.done( - done(function () { - assert(false); - }), - done(function (error) { - assert(error instanceof Error); - assert.equals(error.code, responseError.code); - assert.equals(error.message, responseError.message); - assert.equals(error.data, responseError.data); - }) - ); - }, - - "rejects and logs requests which get errors without data": function (done) { - var stub = this.stub(this.mopidy._console, "warn"); - var promise = this.mopidy._send({method: "bar"}); - var responseError = { - code: -32601, - message: "Method not found" - // 'data' key intentionally missing - }; - var responseMessage = { - jsonrpc: "2.0", - id: Object.keys(this.mopidy._pendingRequests)[0], - error: responseError - }; - - this.mopidy._handleResponse(responseMessage); - - assert.calledOnceWith(stub, - "Server returned error:", responseError); - promise.done( - done(function () { - assert(false); - }), - done(function (error) { - assert(error instanceof Error); - assert(error instanceof Mopidy.ServerError); - assert.equals(error.code, responseError.code); - assert.equals(error.message, responseError.message); - refute.defined(error.data); - }) - ); - }, - - "rejects and logs responses without result or error": function (done) { - var stub = this.stub(this.mopidy._console, "warn"); - var promise = this.mopidy._send({method: "bar"}); - var responseMessage = { - jsonrpc: "2.0", - id: Object.keys(this.mopidy._pendingRequests)[0] - }; - - this.mopidy._handleResponse(responseMessage); - - assert.calledOnceWith(stub, - "Response without 'result' or 'error' received. Message was:", - responseMessage); - promise.done( - done(function () { - assert(false); - }), - done(function (error) { - assert(error instanceof Error); - assert.equals( - error.message, - "Response without 'result' or 'error' received"); - assert.equals(error.data.response, responseMessage); - }) - ); - } - }, - - "._handleEvent": { - "emits server side even on Mopidy object": function () { - var spy = this.spy(); - this.mopidy.on(spy); - var track = {}; - var message = { - event: "track_playback_started", - track: track - }; - - this.mopidy._handleEvent(message); - - assert.calledOnceWith(spy, - "event:trackPlaybackStarted", {track: track}); - } - }, - - "._getApiSpec": { - "is called on 'websocket:open' event": function () { - var stub = this.stub(this.mopidy, "_getApiSpec"); - this.mopidy._delegateEvents(); - - this.mopidy.emit("websocket:open"); - - assert.calledOnceWith(stub); - }, - - "gets Api description from server and calls _createApi": function (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().then(done(function () { - assert.calledOnceWith(sendStub, {method: "core.describe"}); - assert.calledOnceWith(_createApiStub, methods); - })); - } - }, - - "._createApi": { - "can create an API with methods on the root object": function () { - refute.defined(this.mopidy.hello); - refute.defined(this.mopidy.hi); - - this.mopidy._createApi({ - hello: { - description: "Says hello", - params: [] - }, - hi: { - description: "Says hi", - params: [] - } - }); - - assert.isFunction(this.mopidy.hello); - assert.equals(this.mopidy.hello.description, "Says hello"); - assert.equals(this.mopidy.hello.params, []); - assert.isFunction(this.mopidy.hi); - assert.equals(this.mopidy.hi.description, "Says hi"); - assert.equals(this.mopidy.hi.params, []); - }, - - "can create an API with methods on a sub-object": function () { - refute.defined(this.mopidy.hello); - - this.mopidy._createApi({ - "hello.world": { - description: "Says hello to the world", - params: [] - } - }); - - assert.defined(this.mopidy.hello); - assert.isFunction(this.mopidy.hello.world); - }, - - "strips off 'core' from method paths": function () { - refute.defined(this.mopidy.hello); - - this.mopidy._createApi({ - "core.hello.world": { - description: "Says hello to the world", - params: [] - } - }); - - assert.defined(this.mopidy.hello); - assert.isFunction(this.mopidy.hello.world); - }, - - "converts snake_case to camelCase": function () { - refute.defined(this.mopidy.mightyGreetings); - - this.mopidy._createApi({ - "mighty_greetings.hello_world": { - description: "Says hello to the world", - params: [] - } - }); - - assert.defined(this.mopidy.mightyGreetings); - assert.isFunction(this.mopidy.mightyGreetings.helloWorld); - }, - - "triggers 'state:online' event when API is ready for use": function () { - var spy = this.spy(); - this.mopidy.on("state:online", spy); - - this.mopidy._createApi({}); - - assert.calledOnceWith(spy); - }, - - "by-position-only calling convention": { - setUp: function () { - this.mopidy = new Mopidy({ - webSocket: this.webSocket, - callingConvention: "by-position-only" - }); - this.mopidy._createApi({ - foo: { - params: ["bar", "baz"] - } - }); - this.sendStub = this.stub(this.mopidy, "_send"); - - }, - - "sends no params if no arguments passed to function": function () { - this.mopidy.foo(); - - assert.calledOnceWith(this.sendStub, {method: "foo"}); - }, - - "sends messages with function arguments unchanged": function () { - this.mopidy.foo(31, 97); - - assert.calledOnceWith(this.sendStub, { - method: "foo", - params: [31, 97] - }); - }, - }, - - "by-position-or-by-name calling convention": { - setUp: function () { - this.mopidy = new Mopidy({ - webSocket: this.webSocket, - callingConvention: "by-position-or-by-name" - }); - this.mopidy._createApi({ - foo: { - params: ["bar", "baz"] - } - }); - this.sendStub = this.stub(this.mopidy, "_send"); - }, - - "must be turned on manually": function () { - assert.equals( - this.mopidy._settings.callingConvention, - "by-position-or-by-name"); - }, - - "sends no params if no arguments passed to function": function () { - this.mopidy.foo(); - - assert.calledOnceWith(this.sendStub, {method: "foo"}); - }, - - "sends by-position if argument is a list": function () { - this.mopidy.foo([31, 97]); - - assert.calledOnceWith(this.sendStub, { - method: "foo", - params: [31, 97] - }); - }, - - "sends by-name if argument is an object": function () { - this.mopidy.foo({bar: 31, baz: 97}); - - assert.calledOnceWith(this.sendStub, { - method: "foo", - params: {bar: 31, baz: 97} - }); - }, - - "rejects with error if more than one argument": function (done) { - var promise = this.mopidy.foo([1, 2], {c: 3, d: 4}); - - refute.called(this.sendStub); - - promise.done( - done(function () { - assert(false); - }), - done(function (error) { - assert(error instanceof Error); - assert.equals( - error.message, - "Expected zero arguments, a single array, " + - "or a single object."); - }) - ); - }, - - "rejects with error if string": function (done) { - var promise = this.mopidy.foo("hello"); - - refute.called(this.sendStub); - - promise.done( - done(function () { - assert(false); - }), - done(function (error) { - assert(error instanceof Error); - assert(error instanceof TypeError); - assert.equals( - error.message, "Expected an array or an object."); - }) - ); - }, - - "rejects with error if number": function (done) { - var promise = this.mopidy.foo(1337); - - refute.called(this.sendStub); - - promise.done( - done(function () { - assert(false); - }), - done(function (error) { - assert(error instanceof Error); - assert(error instanceof TypeError); - assert.equals( - error.message, "Expected an array or an object."); - }) - ); - } - } - } -}); diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 6c122057..388bb9f0 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -1,24 +1,33 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, print_function, unicode_literals +import platform import sys +import textwrap import warnings -from distutils.version import StrictVersion as SV - -import pykka if not (2, 7) <= sys.version_info < (3,): sys.exit( - 'Mopidy requires Python >= 2.7, < 3, but found %s' % - '.'.join(map(str, sys.version_info[:3]))) + 'ERROR: Mopidy requires Python 2.7, but found %s.' % + platform.python_version()) -if (isinstance(pykka.__version__, basestring) - and not SV('1.1') <= SV(pykka.__version__) < SV('2.0')): - sys.exit( - 'Mopidy requires Pykka >= 1.1, < 2, but found %s' % pykka.__version__) +try: + import gobject # noqa +except ImportError: + print(textwrap.dedent(""" + ERROR: The gobject Python package was not found. + + Mopidy requires GStreamer (and GObject) to work. These are C libraries + with a number of dependencies themselves, and cannot be installed with + the regular Python tools like pip. + + Please see http://docs.mopidy.com/en/latest/installation/ for + instructions on how to install the required dependencies. + """)) + raise warnings.filterwarnings('ignore', 'could not open display') -__version__ = '0.19.5' +__version__ = '1.0.0' diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 9620b936..96e10e18 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -1,4 +1,4 @@ -from __future__ import print_function, unicode_literals +from __future__ import absolute_import, print_function, unicode_literals import logging import os diff --git a/mopidy/audio/__init__.py b/mopidy/audio/__init__.py index fd6d41c9..a74d4456 100644 --- a/mopidy/audio/__init__.py +++ b/mopidy/audio/__init__.py @@ -1,8 +1,7 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals # flake8: noqa from .actor import Audio -from .dummy import DummyAudio from .listener import AudioListener from .constants import PlaybackState from .utils import ( diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index d2701784..b4c78ecb 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -1,15 +1,18 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import logging +import os import gobject import pygst pygst.require('0.10') import gst # noqa +import gst.pbutils import pykka +from mopidy import exceptions from mopidy.audio import playlists, utils from mopidy.audio.constants import PlaybackState from mopidy.audio.listener import AudioListener @@ -18,6 +21,11 @@ from mopidy.utils import process logger = logging.getLogger(__name__) +# This logger is only meant for debug logging of low level gstreamer info such +# as callbacks, event, messages and direct interaction with GStreamer such as +# set_state on a pipeline. +gst_logger = logging.getLogger('mopidy.audio.gst') + playlists.register_typefinders() playlists.register_elements() @@ -40,225 +48,270 @@ MB = 1 << 20 # GST_PLAY_FLAG_DEINTERLACE (1<<9) # GST_PLAY_FLAG_SOFT_COLORBALANCE (1<<10) -# Default flags to use for playbin: AUDIO, SOFT_VOLUME, DOWNLOAD -PLAYBIN_FLAGS = (1 << 1) | (1 << 4) | (1 << 7) -PLAYBIN_VIS_FLAGS = PLAYBIN_FLAGS | (1 << 3) +# Default flags to use for playbin: AUDIO, SOFT_VOLUME +# TODO: consider removing soft volume when we do multi outputs and handling it +# ourselves. +PLAYBIN_FLAGS = (1 << 1) | (1 << 4) -class Audio(pykka.ThreadingActor): - """ - Audio output through `GStreamer `_. - """ +class _Signals(object): + """Helper for tracking gobject signal registrations""" + def __init__(self): + self._ids = {} - #: The GStreamer state mapped to :class:`mopidy.audio.PlaybackState` - state = PlaybackState.STOPPED + def connect(self, element, event, func, *args): + """Connect a function + args to signal event on an element. - def __init__(self, config, mixer): - super(Audio, self).__init__() + Each event may only be handled by one callback in this implementation. + """ + assert (element, event) not in self._ids + self._ids[(element, event)] = element.connect(event, func, *args) - self._config = config - self._mixer = mixer - self._target_state = gst.STATE_NULL - self._buffering = False + def disconnect(self, element, event): + """Disconnect whatever handler we have for and element+event pair. - self._playbin = None - self._signal_ids = {} # {(element, event): signal_id} - - 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: - self._setup_preferences() - self._setup_playbin() - self._setup_output() - self._setup_mixer() - self._setup_visualizer() - self._setup_message_processor() - except gobject.GError as ex: - logger.exception(ex) - process.exit_process() - - def on_stop(self): - self._teardown_message_processor() - 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) + Does nothing it the handler has already been removed. + """ + signal_id = self._ids.pop((element, event), None) if signal_id is not None: element.disconnect(signal_id) - def _setup_preferences(self): - # Fix for https://github.com/mopidy/mopidy/issues/604 - registry = gst.registry_get_default() - jacksink = registry.find_feature( - 'jackaudiosink', gst.TYPE_ELEMENT_FACTORY) - if jacksink: - jacksink.set_rank(gst.RANK_SECONDARY) + def clear(self): + """Clear all registered signal handlers.""" + for element, event in self._ids.keys(): + element.disconnect(self._ids.pop((element, event))) - def _setup_playbin(self): - playbin = gst.element_factory_make('playbin2') - playbin.set_property('flags', PLAYBIN_FLAGS) - playbin.set_property('buffer-size', 2 * 1024 * 1024) - playbin.set_property('buffer-duration', 2 * gst.SECOND) +# TODO: expose this as a property on audio? +class _Appsrc(object): + """Helper class for dealing with appsrc based playback.""" + def __init__(self): + self._signals = _Signals() + self.reset() - self._connect(playbin, 'about-to-finish', self._on_about_to_finish) - self._connect(playbin, 'notify::source', self._on_new_source) - self._connect(playbin, 'source-setup', self._on_source_setup) + def reset(self): + """Reset the helper. - self._playbin = playbin + Should be called whenever the source changes and we are not setting up + a new appsrc. + """ + self.prepare(None, None, None, None) - def _on_about_to_finish(self, element): - source, self._appsrc = self._appsrc, None - if source is None: - return - self._appsrc_caps = None + def prepare(self, caps, need_data, enough_data, seek_data): + """Store info we will need when the appsrc element gets installed.""" + self._signals.clear() + self._source = None + self._caps = caps + self._need_data_callback = need_data + self._seek_data_callback = seek_data + self._enough_data_callback = enough_data - self._disconnect(source, 'need-data') - self._disconnect(source, 'enough-data') - self._disconnect(source, 'seek-data') + def configure(self, source): + """Configure the supplied source for use. - def _on_new_source(self, element, pad): - uri = element.get_property('uri') - if not uri or not uri.startswith('appsrc://'): - return - - source = element.get_property('source') - source.set_property('caps', self._appsrc_caps) + Should be called whenever we get a new appsrc. + """ + source.set_property('caps', self._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) + if self._need_data_callback: + self._signals.connect(source, 'need-data', self._on_signal, + self._need_data_callback) + if self._seek_data_callback: + self._signals.connect(source, 'seek-data', self._on_signal, + self._seek_data_callback) + if self._enough_data_callback: + self._signals.connect(source, 'enough-data', self._on_signal, None, + self._enough_data_callback) - self._appsrc = source + self._source = source - def _on_source_setup(self, element, source): - scheme = 'http' - hostname = self._config['proxy']['hostname'] - port = 80 + def push(self, buffer_): + if self._source is None: + return False - if hasattr(source.props, 'proxy') and hostname: - if self._config['proxy']['port']: - port = self._config['proxy']['port'] - if self._config['proxy']['scheme']: - scheme = self._config['proxy']['scheme'] + if buffer_ is None: + gst_logger.debug('Sending appsrc end-of-stream event.') + return self._source.emit('end-of-stream') == gst.FLOW_OK + else: + return self._source.emit('push-buffer', buffer_) == gst.FLOW_OK - proxy = "%s://%s:%d" % (scheme, hostname, port) - source.set_property('proxy', proxy) - source.set_property('proxy-id', self._config['proxy']['username']) - source.set_property('proxy-pw', self._config['proxy']['password']) - - 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) + def _on_signal(self, element, clocktime, func): + # This shim is used to ensure we always return true, and also handles + # that not all the callbacks have a time argument. + if clocktime is None: + func() + else: + func(utils.clocktime_to_millisecond(clocktime)) 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 +# TODO: expose this as a property on audio when #790 gets further along. +class _Outputs(gst.Bin): + def __init__(self): + gst.Bin.__init__(self) - def _teardown_playbin(self): - self._disconnect(self._playbin, 'about-to-finish') - self._disconnect(self._playbin, 'notify::source') - self._disconnect(self._playbin, 'source-setup') - self._playbin.set_state(gst.STATE_NULL) + self._tee = gst.element_factory_make('tee') + self.add(self._tee) - def _setup_output(self): - output_desc = self._config['audio']['output'] + # Queue element to buy us time between the about to finish event and + # the actual switch, i.e. about to switch can block for longer thanks + # to this queue. + # TODO: make the min-max values a setting? + # TODO: this does not belong in this class. + queue = gst.element_factory_make('queue') + queue.set_property('max-size-buffers', 0) + queue.set_property('max-size-bytes', 0) + queue.set_property('max-size-time', 5 * gst.SECOND) + queue.set_property('min-threshold-time', 3 * gst.SECOND) + self.add(queue) + + queue.link(self._tee) + + ghost_pad = gst.GhostPad('sink', queue.get_pad('sink')) + self.add_pad(ghost_pad) + + # Add an always connected fakesink which respects the clock so the tee + # doesn't fail even if we don't have any outputs. + fakesink = gst.element_factory_make('fakesink') + fakesink.set_property('sync', True) + self._add(fakesink) + + def add_output(self, description): + # XXX This only works for pipelines not in use until #790 gets done. try: output = gst.parse_bin_from_description( - output_desc, ghost_unconnected_pads=True) - self._playbin.set_property('audio-sink', output) - logger.info('Audio output set to "%s"', output_desc) + description, ghost_unconnected_pads=True) except gobject.GError as ex: logger.error( - 'Failed to create audio output "%s": %s', output_desc, ex) - process.exit_process() + 'Failed to create audio output "%s": %s', description, ex) + raise exceptions.AudioException(bytes(ex)) - def _setup_mixer(self): - if self._config['audio']['mixer'] != 'software': - return - self._mixer.audio = self.actor_ref.proxy() - self._connect(self._playbin, 'notify::volume', self._on_mixer_change) - self._connect(self._playbin, 'notify::mute', self._on_mixer_change) + self._add(output) + logger.info('Audio output set to "%s"', description) - # The Mopidy startup procedure will set the initial volume of a mixer, - # but this happens before the audio actor is injected into the software - # mixer and has no effect. Thus, we need to set the initial volume - # again. - initial_volume = self._config['audio']['mixer_volume'] - if initial_volume is not None: - self._mixer.set_volume(initial_volume) + def _add(self, element): + # All tee branches need a queue in front of them. + queue = gst.element_factory_make('queue') + self.add(element) + self.add(queue) + queue.link(element) + self._tee.link(queue) - def _on_mixer_change(self, element, gparamspec): - self._mixer.trigger_events_for_changed_values() - def _teardown_mixer(self): - if self._config['audio']['mixer'] != 'software': - return - self._disconnect(self._playbin, 'notify::volume') - self._disconnect(self._playbin, 'notify::mute') - self._mixer.audio = None +class SoftwareMixer(object): + pykka_traversable = True - def _setup_visualizer(self): - visualizer_element = self._config['audio']['visualizer'] - if not visualizer_element: - return - try: - visualizer = gst.element_factory_make(visualizer_element) - self._playbin.set_property('vis-plugin', visualizer) - self._playbin.set_property('flags', PLAYBIN_VIS_FLAGS) - logger.info('Audio visualizer set to "%s"', visualizer_element) - except gobject.GError as ex: - logger.error( - 'Failed to create audio visualizer "%s": %s', - visualizer_element, ex) + def __init__(self, mixer): + self._mixer = mixer + self._element = None + self._last_volume = None + self._last_mute = None + self._signals = _Signals() - def _setup_message_processor(self): - bus = self._playbin.get_bus() + def setup(self, element, mixer_ref): + self._element = element + + self._signals.connect(element, 'notify::volume', self._volume_changed) + self._signals.connect(element, 'notify::mute', self._mute_changed) + + self._mixer.setup(mixer_ref) + + def teardown(self): + self._signals.clear() + self._mixer.teardown() + + def get_volume(self): + return int(round(self._element.get_property('volume') * 100)) + + def set_volume(self, volume): + self._element.set_property('volume', volume / 100.0) + + def get_mute(self): + return self._element.get_property('mute') + + def set_mute(self, mute): + return self._element.set_property('mute', bool(mute)) + + def _volume_changed(self, element, property_): + old_volume, self._last_volume = self._last_volume, self.get_volume() + if old_volume != self._last_volume: + gst_logger.debug('Notify volume: %s', self._last_volume / 100.0) + self._mixer.trigger_volume_changed(self._last_volume) + + def _mute_changed(self, element, property_): + old_mute, self._last_mute = self._last_mute, self.get_mute() + if old_mute != self._last_mute: + gst_logger.debug('Notify mute: %s', self._last_mute) + self._mixer.trigger_mute_changed(self._last_mute) + + +class _Handler(object): + def __init__(self, audio): + self._audio = audio + self._element = None + self._pad = None + self._message_handler_id = None + self._event_handler_id = None + + def setup_message_handling(self, element): + self._element = element + bus = element.get_bus() bus.add_signal_watch() - self._connect(bus, 'message', self._on_message) + self._message_handler_id = bus.connect('message', self.on_message) - def _teardown_message_processor(self): - bus = self._playbin.get_bus() - self._disconnect(bus, 'message') + def setup_event_handling(self, pad): + self._pad = pad + self._event_handler_id = pad.add_event_probe(self.on_event) + + def teardown_message_handling(self): + bus = self._element.get_bus() bus.remove_signal_watch() + bus.disconnect(self._message_handler_id) + self._message_handler_id = None - def _on_message(self, bus, msg): - if msg.type == gst.MESSAGE_STATE_CHANGED and msg.src == self._playbin: - self._on_playbin_state_changed(*msg.parse_state_changed()) + def teardown_event_handling(self): + self._pad.remove_event_probe(self._event_handler_id) + self._event_handler_id = None + + def on_message(self, bus, msg): + if msg.type == gst.MESSAGE_STATE_CHANGED and msg.src == self._element: + self.on_playbin_state_changed(*msg.parse_state_changed()) elif msg.type == gst.MESSAGE_BUFFERING: - self._on_buffering(msg.parse_buffering()) + self.on_buffering(msg.parse_buffering(), msg.structure) elif msg.type == gst.MESSAGE_EOS: - self._on_end_of_stream() + self.on_end_of_stream() elif msg.type == gst.MESSAGE_ERROR: - self._on_error(*msg.parse_error()) + self.on_error(*msg.parse_error()) elif msg.type == gst.MESSAGE_WARNING: - self._on_warning(*msg.parse_warning()) + self.on_warning(*msg.parse_warning()) + elif msg.type == gst.MESSAGE_ASYNC_DONE: + self.on_async_done() + elif msg.type == gst.MESSAGE_TAG: + self.on_tag(msg.parse_tag()) + elif msg.type == gst.MESSAGE_ELEMENT: + if gst.pbutils.is_missing_plugin_message(msg): + self.on_missing_plugin(msg) + + def on_event(self, pad, event): + if event.type == gst.EVENT_NEWSEGMENT: + self.on_new_segment(*event.parse_new_segment()) + elif event.type == gst.EVENT_SINK_MESSAGE: + # Handle stream changed messages when they reach our output bin. + # If we listen for it on the bus we get one per tee branch. + msg = event.parse_sink_message() + if msg.structure.has_name('playbin2-stream-changed'): + self.on_stream_changed(msg.structure['uri']) + return True + + def on_playbin_state_changed(self, old_state, new_state, pending_state): + gst_logger.debug('Got state-changed message: old=%s new=%s pending=%s', + old_state.value_name, new_state.value_name, + pending_state.value_name) - def _on_playbin_state_changed(self, old_state, new_state, pending_state): if new_state == gst.STATE_READY and pending_state == gst.STATE_NULL: # XXX: We're not called on the last state change when going down to # NULL, so we rewrite the second to last call to get the expected @@ -273,43 +326,210 @@ class Audio(pykka.ThreadingActor): return # Ignore READY state as it's GStreamer specific new_state = _GST_STATE_MAPPING[new_state] - old_state, self.state = self.state, new_state + old_state, self._audio.state = self._audio.state, new_state - target_state = _GST_STATE_MAPPING[self._target_state] + target_state = _GST_STATE_MAPPING[self._audio._target_state] if target_state == new_state: target_state = None - logger.debug( - 'Triggering event: state_changed(old_state=%s, new_state=%s, ' - 'target_state=%s)', old_state, new_state, target_state) + logger.debug('Audio event: state_changed(old_state=%s, new_state=%s, ' + 'target_state=%s)', old_state, new_state, target_state) AudioListener.send('state_changed', old_state=old_state, new_state=new_state, target_state=target_state) + if new_state == PlaybackState.STOPPED: + logger.debug('Audio event: stream_changed(uri=None)') + AudioListener.send('stream_changed', uri=None) - def _on_buffering(self, percent): - if percent < 10 and not self._buffering: - self._playbin.set_state(gst.STATE_PAUSED) - self._buffering = True + if 'GST_DEBUG_DUMP_DOT_DIR' in os.environ: + gst.DEBUG_BIN_TO_DOT_FILE( + self._audio._playbin, gst.DEBUG_GRAPH_SHOW_ALL, 'mopidy') + + def on_buffering(self, percent, structure=None): + if structure and structure.has_field('buffering-mode'): + if structure['buffering-mode'] == gst.BUFFERING_LIVE: + return # Live sources stall in paused. + + level = logging.getLevelName('TRACE') + if percent < 10 and not self._audio._buffering: + self._audio._playbin.set_state(gst.STATE_PAUSED) + self._audio._buffering = True + level = logging.DEBUG if percent == 100: - self._buffering = False - if self._target_state == gst.STATE_PLAYING: - self._playbin.set_state(gst.STATE_PLAYING) + self._audio._buffering = False + if self._audio._target_state == gst.STATE_PLAYING: + self._audio._playbin.set_state(gst.STATE_PLAYING) + level = logging.DEBUG - logger.debug('Buffer %d%% full', percent) + gst_logger.log(level, 'Got buffering message: percent=%d%%', percent) - def _on_end_of_stream(self): - logger.debug('Triggering reached_end_of_stream event') + def on_end_of_stream(self): + gst_logger.debug('Got end-of-stream message.') + logger.debug('Audio event: reached_end_of_stream()') + self._audio._tags = {} AudioListener.send('reached_end_of_stream') - def _on_error(self, error, debug): - logger.error( - '%s Debug message: %s', - str(error).decode('utf-8'), debug.decode('utf-8') or 'None') - self.stop_playback() + def on_error(self, error, debug): + gst_logger.error(str(error).decode('utf-8')) + if debug: + gst_logger.debug(debug.decode('utf-8')) + # TODO: is this needed? + self._audio.stop_playback() - def _on_warning(self, error, debug): - logger.warning( - '%s Debug message: %s', - str(error).decode('utf-8'), debug.decode('utf-8') or 'None') + def on_warning(self, error, debug): + gst_logger.warning(str(error).decode('utf-8')) + if debug: + gst_logger.debug(debug.decode('utf-8')) + + def on_async_done(self): + gst_logger.debug('Got async-done.') + + def on_tag(self, taglist): + tags = utils.convert_taglist(taglist) + self._audio._tags.update(tags) + logger.debug('Audio event: tags_changed(tags=%r)', tags.keys()) + AudioListener.send('tags_changed', tags=tags.keys()) + + def on_missing_plugin(self, msg): + desc = gst.pbutils.missing_plugin_message_get_description(msg) + debug = gst.pbutils.missing_plugin_message_get_installer_detail(msg) + + gst_logger.debug('Got missing-plugin message: description:%s', desc) + logger.warning('Could not find a %s to handle media.', desc) + if gst.pbutils.install_plugins_supported(): + logger.info('You might be able to fix this by running: ' + 'gst-installer "%s"', debug) + # TODO: store the missing plugins installer info in a file so we can + # can provide a 'mopidy install-missing-plugins' if the system has the + # required helper installed? + + def on_new_segment(self, update, rate, format_, start, stop, position): + gst_logger.debug('Got new-segment event: update=%s rate=%s format=%s ' + 'start=%s stop=%s position=%s', update, rate, + format_.value_name, start, stop, position) + position_ms = position // gst.MSECOND + logger.debug('Audio event: position_changed(position=%s)', position_ms) + AudioListener.send('position_changed', position=position_ms) + + def on_stream_changed(self, uri): + gst_logger.debug('Got stream-changed message: uri=%s', uri) + logger.debug('Audio event: stream_changed(uri=%s)', uri) + AudioListener.send('stream_changed', uri=uri) + + +# TODO: create a player class which replaces the actors internals +class Audio(pykka.ThreadingActor): + """ + Audio output through `GStreamer `_. + """ + + #: The GStreamer state mapped to :class:`mopidy.audio.PlaybackState` + state = PlaybackState.STOPPED + + #: The software mixing interface :class:`mopidy.audio.actor.SoftwareMixer` + mixer = None + + def __init__(self, config, mixer): + super(Audio, self).__init__() + + self._config = config + self._target_state = gst.STATE_NULL + self._buffering = False + self._tags = {} + + self._playbin = None + self._outputs = None + self._about_to_finish_callback = None + + self._handler = _Handler(self) + self._appsrc = _Appsrc() + self._signals = _Signals() + + if mixer and self._config['audio']['mixer'] == 'software': + self.mixer = SoftwareMixer(mixer) + + def on_start(self): + try: + self._setup_preferences() + self._setup_playbin() + self._setup_output() + self._setup_mixer() + except gobject.GError as ex: + logger.exception(ex) + process.exit_process() + + def on_stop(self): + self._teardown_mixer() + self._teardown_playbin() + + def _setup_preferences(self): + # TODO: move out of audio actor? + # Fix for https://github.com/mopidy/mopidy/issues/604 + registry = gst.registry_get_default() + jacksink = registry.find_feature( + 'jackaudiosink', gst.TYPE_ELEMENT_FACTORY) + if jacksink: + jacksink.set_rank(gst.RANK_SECONDARY) + + def _setup_playbin(self): + playbin = gst.element_factory_make('playbin2') + playbin.set_property('flags', PLAYBIN_FLAGS) + + # TODO: turn into config values... + playbin.set_property('buffer-size', 2 * 1024 * 1024) + playbin.set_property('buffer-duration', 2 * gst.SECOND) + + self._signals.connect(playbin, 'source-setup', self._on_source_setup) + self._signals.connect(playbin, 'about-to-finish', + self._on_about_to_finish) + + self._playbin = playbin + self._handler.setup_message_handling(playbin) + + def _teardown_playbin(self): + self._handler.teardown_message_handling() + self._handler.teardown_event_handling() + self._signals.disconnect(self._playbin, 'about-to-finish') + self._signals.disconnect(self._playbin, 'source-setup') + self._playbin.set_state(gst.STATE_NULL) + + def _setup_output(self): + # We don't want to use outputs for regular testing, so just install + # an unsynced fakesink when someone asks for a 'testoutput'. + if self._config['audio']['output'] == 'testoutput': + self._outputs = gst.element_factory_make('fakesink') + else: + self._outputs = _Outputs() + try: + self._outputs.add_output(self._config['audio']['output']) + except exceptions.AudioException: + process.exit_process() # TODO: move this up the chain + + self._handler.setup_event_handling(self._outputs.get_pad('sink')) + self._playbin.set_property('audio-sink', self._outputs) + + def _setup_mixer(self): + if self.mixer: + self.mixer.setup(self._playbin, self.actor_ref.proxy().mixer) + + def _teardown_mixer(self): + if self.mixer: + self.mixer.teardown() + + def _on_about_to_finish(self, element): + gst_logger.debug('Got about-to-finish event.') + if self._about_to_finish_callback: + logger.debug('Running about to finish callback.') + self._about_to_finish_callback() + + def _on_source_setup(self, element, source): + gst_logger.debug('Got source-setup: element=%s', source) + + if source.get_factory().get_name() == 'appsrc': + self._appsrc.configure(source) + else: + self._appsrc.reset() + + utils.setup_proxy(source, self._config['proxy']) def set_uri(self, uri): """ @@ -320,8 +540,20 @@ class Audio(pykka.ThreadingActor): :param uri: the URI to play :type uri: string """ + + # XXX: Hack to workaround issue on Mac OS X where volume level + # does not persist between track changes. mopidy/mopidy#886 + if self.mixer is not None: + current_volume = self.mixer.get_volume() + else: + current_volume = None + + self._tags = {} # TODO: add test for this somehow self._playbin.set_property('uri', uri) + if self.mixer is not None and current_volume is not None: + self.mixer.set_volume(current_volume) + def set_appsrc( self, caps, need_data=None, enough_data=None, seek_data=None): """ @@ -340,29 +572,27 @@ class Audio(pykka.ThreadingActor): 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._appsrc.prepare( + gst.Caps(bytes(caps)), need_data, enough_data, seek_data) self._playbin.set_property('uri', 'appsrc://') def emit_data(self, buffer_): """ Call this to deliver raw audio data to be played. - Note that the uri must be set to ``appsrc://`` for this to work. + If the buffer is :class:`None`, the end-of-stream token is put on the + playbin. We will get a GStreamer message when the stream playback + reaches the token, and can then do any end-of-stream related tasks. - Returns true if data was delivered. + Note that the URI must be set to ``appsrc://`` for this to work. + + Returns :class:`True` if data was delivered. :param buffer_: buffer to pass to appsrc - :type buffer_: :class:`gst.Buffer` + :type buffer_: :class:`gst.Buffer` or :class:`None` :rtype: boolean """ - if not self._appsrc: - return False - return self._appsrc.emit('push-buffer', buffer_) == gst.FLOW_OK + return self._appsrc.push(buffer_) def emit_end_of_stream(self): """ @@ -371,8 +601,24 @@ class Audio(pykka.ThreadingActor): We will get a GStreamer message when the stream playback reaches the token, and can then do any end-of-stream related tasks. + + .. deprecated:: 1.0 + Use :meth:`emit_data` with a :class:`None` buffer instead. """ - self._playbin.get_property('source').emit('end-of-stream') + self._appsrc.push(None) + + def set_about_to_finish_callback(self, callback): + """ + Configure audio to use an about-to-finish callback. + + This should be used to achieve gapless playback. For this to work the + callback *MUST* call :meth:`set_uri` with the new URI to play and + block until this call has been made. :meth:`prepare_change` is not + needed before :meth:`set_uri` in this one special case. + + :param callable callback: Callback to run when we need the next URI. + """ + self._about_to_finish_callback = callback def get_position(self): """ @@ -384,6 +630,8 @@ class Audio(pykka.ThreadingActor): gst_position = self._playbin.query_position(gst.FORMAT_TIME)[0] return utils.clocktime_to_millisecond(gst_position) except gst.QueryError: + # TODO: take state into account for this and possibly also return + # None as the unknown value instead of zero? logger.debug('Position query failed') return 0 @@ -395,9 +643,12 @@ class Audio(pykka.ThreadingActor): :type position: int :rtype: :class:`True` if successful, else :class:`False` """ + # TODO: double check seek flags in use. gst_position = utils.millisecond_to_clocktime(position) - return self._playbin.seek_simple( + result = self._playbin.seek_simple( gst.Format(gst.FORMAT_TIME), gst.SEEK_FLAG_FLUSH, gst_position) + gst_logger.debug('Sent flushing seek: position=%s', gst_position) + return result def start_playback(self): """ @@ -435,6 +686,25 @@ class Audio(pykka.ThreadingActor): self._buffering = False return self._set_state(gst.STATE_NULL) + def wait_for_state_change(self): + """Block until any pending state changes are complete. + + Should only be used by tests. + """ + self._playbin.get_state() + + def enable_sync_handler(self): + """Enable manual processing of messages from bus. + + Should only be used by tests. + """ + def sync_handler(bus, message): + self._handler.on_message(bus, message) + return gst.BUS_DROP + + bus = self._playbin.get_bus() + bus.set_sync_handler(sync_handler) + def _set_state(self, state): """ Internal method for setting the raw GStreamer state. @@ -458,65 +728,18 @@ class Audio(pykka.ThreadingActor): """ self._target_state = state result = self._playbin.set_state(state) + gst_logger.debug('State change to %s: result=%s', state.value_name, + result.value_name) + if result == gst.STATE_CHANGE_FAILURE: logger.warning( 'Setting GStreamer state to %s failed', state.value_name) return False - elif result == gst.STATE_CHANGE_ASYNC: - logger.debug( - 'Setting GStreamer state to %s is async', state.value_name) - return True - else: - logger.debug( - 'Setting GStreamer state to %s is OK', state.value_name) - return True - - def get_volume(self): - """ - Get volume level of the software mixer. - - Example values: - - 0: - Minimum volume. - 100: - Maximum volume. - - :rtype: int in range [0..100] - """ - return int(round(self._playbin.get_property('volume') * 100)) - - def set_volume(self, volume): - """ - Set volume level of the software mixer. - - :param volume: the volume in the range [0..100] - :type volume: int - :rtype: :class:`True` if successful, else :class:`False` - """ - self._playbin.set_property('volume', volume / 100.0) - return True - - def get_mute(self): - """ - Get mute status of the software mixer. - - :rtype: :class:`True` if muted, :class:`False` if unmuted, - :class:`None` if no mixer is installed. - """ - return self._playbin.get_property('mute') - - def set_mute(self, mute): - """ - Mute or unmute of the software mixer. - - :param mute: Whether to mute the mixer or not. - :type mute: bool - :rtype: :class:`True` if successful, else :class:`False` - """ - self._playbin.set_property('mute', bool(mute)) + # TODO: at this point we could already emit stopped event instead + # of faking it in the message handling when result=OK return True + # TODO: bake this into setup appsrc perhaps? def set_metadata(self, track): """ Set track metadata for currently playing song. @@ -547,4 +770,22 @@ class Audio(pykka.ThreadingActor): taglist[gst.TAG_ALBUM] = track.album.name event = gst.event_new_tag(taglist) + # TODO: check if we get this back on our own bus? self._playbin.send_event(event) + gst_logger.debug('Sent tag event: track=%s', track.uri) + + def get_current_tags(self): + """ + Get the currently playing media's tags. + + If no tags have been found, or nothing is playing this returns an empty + dictionary. For each set of tags we collect a tags_changed event is + emitted with the keys of the changes tags. After such calls users may + call this function to get the updated values. + + :rtype: {key: [values]} dict for the current media. + """ + # TODO: should this be a (deep) copy? most likely yes + # TODO: should we return None when stopped? + # TODO: support only fetching keys we care about? + return self._tags diff --git a/mopidy/audio/constants.py b/mopidy/audio/constants.py index 08ad9768..718fde1b 100644 --- a/mopidy/audio/constants.py +++ b/mopidy/audio/constants.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals class PlaybackState(object): diff --git a/mopidy/audio/dummy.py b/mopidy/audio/dummy.py deleted file mode 100644 index ad14390f..00000000 --- a/mopidy/audio/dummy.py +++ /dev/null @@ -1,68 +0,0 @@ -"""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/listener.py b/mopidy/audio/listener.py index d5203ab9..280d4f86 100644 --- a/mopidy/audio/listener.py +++ b/mopidy/audio/listener.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals from mopidy import listener @@ -27,6 +27,26 @@ class AudioListener(listener.Listener): """ pass + def stream_changed(self, uri): + """ + Called whenever the audio stream changes. + + *MAY* be implemented by actor. + + :param string uri: URI the stream has started playing. + """ + pass + + def position_changed(self, position): + """ + Called whenever the position of the stream changes. + + *MAY* be implemented by actor. + + :param int position: Position in milliseconds. + """ + pass + def state_changed(self, old_state, new_state, target_state): """ Called after the playback state have changed. @@ -55,3 +75,21 @@ class AudioListener(listener.Listener): field or :class:`None` if this is a final state. """ pass + + def tags_changed(self, tags): + """ + Called whenever the current audio stream's tags change. + + This event signals that some track metadata has been updated. This can + be metadata such as artists, titles, organization, or details about the + actual audio such as bit-rates, numbers of channels etc. + + For the available tag keys please refer to GStreamer documentation for + tags. + + *MAY* be implemented by actor. + + :param tags: The tags that have just been updated. + :type tags: :class:`set` of strings + """ + pass diff --git a/mopidy/audio/playlists.py b/mopidy/audio/playlists.py index 1f161773..61bcb7a1 100644 --- a/mopidy/audio/playlists.py +++ b/mopidy/audio/playlists.py @@ -1,6 +1,5 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals -import ConfigParser as configparser import io import gobject @@ -9,6 +8,8 @@ import pygst pygst.require('0.10') import gst # noqa +from mopidy.compat import configparser + try: import xml.etree.cElementTree as elementtree except ImportError: @@ -57,11 +58,11 @@ def parse_m3u(data): # TODO: convert non URIs to file URIs. found_header = False for line in data.readlines(): - if found_header or line.startswith('#EXTM3U'): + if found_header or line.startswith(b'#EXTM3U'): found_header = True else: continue - if not line.startswith('#') and line.strip(): + if not line.startswith(b'#') and line.strip(): yield line.strip() diff --git a/mopidy/audio/scan.py b/mopidy/audio/scan.py index 6c23e954..3880d91a 100644 --- a/mopidy/audio/scan.py +++ b/mopidy/audio/scan.py @@ -1,47 +1,37 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, division, unicode_literals -import datetime -import os -import time +import collections import pygst pygst.require('0.10') import gst # noqa +import gst.pbutils from mopidy import exceptions -from mopidy.models import Album, Artist, Track -from mopidy.utils import encoding, path +from mopidy.audio import utils +from mopidy.utils import encoding + +_missing_plugin_desc = gst.pbutils.missing_plugin_message_get_description + +_Result = collections.namedtuple( + 'Result', ('uri', 'tags', 'duration', 'seekable', 'mime')) + +_RAW_AUDIO = gst.Caps(b'audio/x-raw-int; audio/x-raw-float') +# TODO: replace with a scan(uri, timeout=1000, proxy_config=None)? class Scanner(object): """ Helper to get tags and other relevant info from URIs. :param timeout: timeout for scanning a URI in ms - :type event: int - :param min_duration: minimum duration of scanned URI in ms, -1 for all. + :param proxy_config: dictionary containing proxy config strings. :type event: int """ - def __init__(self, timeout=1000, min_duration=100): - self._timeout_ms = timeout - self._min_duration_ms = min_duration - - sink = gst.element_factory_make('fakesink') - - audio_caps = gst.Caps(b'audio/x-raw-int; audio/x-raw-float') - pad_added = lambda src, pad: pad.link(sink.get_pad('sink')) - - self._uribin = gst.element_factory_make('uridecodebin') - self._uribin.set_property('caps', audio_caps) - self._uribin.connect('pad-added', pad_added) - - self._pipe = gst.element_factory_make('pipeline') - self._pipe.add(self._uribin) - self._pipe.add(sink) - - self._bus = self._pipe.get_bus() - self._bus.set_flushing(True) + def __init__(self, timeout=1000, proxy_config=None): + self._timeout_ms = int(timeout) + self._proxy_config = proxy_config or {} def scan(self, uri): """ @@ -49,150 +39,124 @@ class Scanner(object): :param uri: URI of the resource to scan. :type event: string - :return: Dictionary of tags, duration, mtime and uri information. + :return: A named tuple containing + ``(uri, tags, duration, seekable, mime)``. + ``tags`` is a dictionary of lists for all the tags we found. + ``duration`` is the length of the URI in milliseconds, or + :class:`None` if the URI has no duration. ``seekable`` is boolean. + indicating if a seek would succeed. """ + tags, duration, seekable, mime = None, None, None, None + pipeline = _setup_pipeline(uri, self._proxy_config) + try: - self._setup(uri) - tags = self._collect() # Ensure collect before queries. - data = {'uri': uri, 'tags': tags, - 'mtime': self._query_mtime(uri), - 'duration': self._query_duration()} + _start_pipeline(pipeline) + tags, mime = _process(pipeline, self._timeout_ms) + duration = _query_duration(pipeline) + seekable = _query_seekable(pipeline) finally: - self._reset() + pipeline.set_state(gst.STATE_NULL) + del pipeline - if self._min_duration_ms is None: - return data - elif data['duration'] >= self._min_duration_ms * gst.MSECOND: - return data - - raise exceptions.ScannerError('Rejecting file with less than %dms ' - 'audio data.' % self._min_duration_ms) - - def _setup(self, uri): - """Primes the pipeline for collection.""" - self._pipe.set_state(gst.STATE_READY) - self._uribin.set_property(b'uri', uri) - self._bus.set_flushing(False) - result = self._pipe.set_state(gst.STATE_PAUSED) - if result == gst.STATE_CHANGE_NO_PREROLL: - # Live sources don't pre-roll, so set to playing to get data. - self._pipe.set_state(gst.STATE_PLAYING) - - def _collect(self): - """Polls for messages to collect data.""" - start = time.time() - timeout_s = self._timeout_ms / float(1000) - tags = {} - - while time.time() - start < timeout_s: - if not self._bus.have_pending(): - continue - message = self._bus.pop() - - if message.type == gst.MESSAGE_ERROR: - raise exceptions.ScannerError( - encoding.locale_decode(message.parse_error()[0])) - elif message.type == gst.MESSAGE_EOS: - return tags - elif message.type == gst.MESSAGE_ASYNC_DONE: - if message.src == self._pipe: - return tags - elif message.type == gst.MESSAGE_TAG: - # Taglists are not really dicts, hence the lack of .items() and - # explicit .keys. We only keep the last tag for each key, as we - # assume this is the best, some formats will produce multiple - # taglists. Lastly we force everything to lists for conformity. - taglist = message.parse_tag() - for key in taglist.keys(): - value = taglist[key] - if not isinstance(value, list): - value = [value] - tags[key] = value - - raise exceptions.ScannerError('Timeout after %dms' % self._timeout_ms) - - def _reset(self): - """Ensures we cleanup child elements and flush the bus.""" - self._bus.set_flushing(True) - self._pipe.set_state(gst.STATE_NULL) - - def _query_duration(self): - try: - return self._pipe.query_duration(gst.FORMAT_TIME, None)[0] - except gst.QueryError: - return None - - def _query_mtime(self, uri): - if not uri.startswith('file:'): - return None - return os.path.getmtime(path.uri_to_path(uri)) + return _Result(uri, tags, duration, seekable, mime) -def _artists(tags, artist_name, artist_id=None): - # Name missing, don't set artist - if not tags.get(artist_name): - return None - # One artist name and id, provide artist with id. - if len(tags[artist_name]) == 1 and artist_id in tags: - return [Artist(name=tags[artist_name][0], - musicbrainz_id=tags[artist_id][0])] - # Multiple artist, provide artists without id. - return [Artist(name=name) for name in tags[artist_name]] +# Turns out it's _much_ faster to just create a new pipeline for every as +# decodebins and other elements don't seem to take well to being reused. +def _setup_pipeline(uri, proxy_config=None): + src = gst.element_make_from_uri(gst.URI_SRC, uri) + if not src: + raise exceptions.ScannerError('GStreamer can not open: %s' % uri) + + typefind = gst.element_factory_make('typefind') + decodebin = gst.element_factory_make('decodebin2') + sink = gst.element_factory_make('fakesink') + + pipeline = gst.element_factory_make('pipeline') + pipeline.add_many(src, typefind, decodebin, sink) + gst.element_link_many(src, typefind, decodebin) + + if proxy_config: + utils.setup_proxy(src, proxy_config) + + decodebin.set_property('caps', _RAW_AUDIO) + decodebin.connect('pad-added', _pad_added, sink) + typefind.connect('have-type', _have_type, decodebin) + + return pipeline -def _date(tags): - if not tags.get(gst.TAG_DATE): - return None +def _have_type(element, probability, caps, decodebin): + decodebin.set_property('sink-caps', caps) + msg = gst.message_new_application(element, caps.get_structure(0)) + element.get_bus().post(msg) + + +def _pad_added(element, pad, sink): + return pad.link(sink.get_pad('sink')) + + +def _start_pipeline(pipeline): + if pipeline.set_state(gst.STATE_PAUSED) == gst.STATE_CHANGE_NO_PREROLL: + pipeline.set_state(gst.STATE_PLAYING) + + +def _query_duration(pipeline): try: - date = tags[gst.TAG_DATE][0] - return datetime.date(date.year, date.month, date.day).isoformat() - except ValueError: + duration = pipeline.query_duration(gst.FORMAT_TIME, None)[0] + except gst.QueryError: return None + if duration < 0: + return None + else: + return duration // gst.MSECOND -def audio_data_to_track(data): - """Convert taglist data + our extras to a track.""" - tags = data['tags'] - album_kwargs = {} - track_kwargs = {} - track_kwargs['composers'] = _artists(tags, gst.TAG_COMPOSER) - track_kwargs['performers'] = _artists(tags, gst.TAG_PERFORMER) - track_kwargs['artists'] = _artists( - tags, gst.TAG_ARTIST, 'musicbrainz-artistid') - album_kwargs['artists'] = _artists( - tags, gst.TAG_ALBUM_ARTIST, 'musicbrainz-albumartistid') +def _query_seekable(pipeline): + query = gst.query_new_seeking(gst.FORMAT_TIME) + pipeline.query(query) + return query.parse_seeking()[1] - track_kwargs['genre'] = '; '.join(tags.get(gst.TAG_GENRE, [])) - track_kwargs['name'] = '; '.join(tags.get(gst.TAG_TITLE, [])) - if not track_kwargs['name']: - track_kwargs['name'] = '; '.join(tags.get(gst.TAG_ORGANIZATION, [])) - track_kwargs['comment'] = '; '.join(tags.get('comment', [])) - if not track_kwargs['comment']: - track_kwargs['comment'] = '; '.join(tags.get(gst.TAG_LOCATION, [])) - if not track_kwargs['comment']: - track_kwargs['comment'] = '; '.join(tags.get(gst.TAG_COPYRIGHT, [])) +def _process(pipeline, timeout_ms): + clock = pipeline.get_clock() + bus = pipeline.get_bus() + timeout = timeout_ms * gst.MSECOND + tags, mime, missing_description = {}, None, None - track_kwargs['track_no'] = tags.get(gst.TAG_TRACK_NUMBER, [None])[0] - track_kwargs['disc_no'] = tags.get(gst.TAG_ALBUM_VOLUME_NUMBER, [None])[0] - track_kwargs['bitrate'] = tags.get(gst.TAG_BITRATE, [None])[0] - track_kwargs['musicbrainz_id'] = tags.get('musicbrainz-trackid', [None])[0] + types = (gst.MESSAGE_ELEMENT | gst.MESSAGE_APPLICATION | gst.MESSAGE_ERROR + | gst.MESSAGE_EOS | gst.MESSAGE_ASYNC_DONE | gst.MESSAGE_TAG) - album_kwargs['name'] = tags.get(gst.TAG_ALBUM, [None])[0] - album_kwargs['num_tracks'] = tags.get(gst.TAG_TRACK_COUNT, [None])[0] - album_kwargs['num_discs'] = tags.get(gst.TAG_ALBUM_VOLUME_COUNT, [None])[0] - album_kwargs['musicbrainz_id'] = tags.get('musicbrainz-albumid', [None])[0] + start = clock.get_time() + while timeout > 0: + message = bus.timed_pop_filtered(timeout, types) - track_kwargs['date'] = _date(tags) - track_kwargs['last_modified'] = int(data.get('mtime') or 0) - track_kwargs['length'] = max( - 0, (data.get(gst.TAG_DURATION) or 0)) // gst.MSECOND + if message is None: + break + elif message.type == gst.MESSAGE_ELEMENT: + if gst.pbutils.is_missing_plugin_message(message): + missing_description = encoding.locale_decode( + _missing_plugin_desc(message)) + elif message.type == gst.MESSAGE_APPLICATION: + mime = message.structure.get_name() + if mime.startswith('text/') or mime == 'application/xml': + return tags, mime + elif message.type == gst.MESSAGE_ERROR: + error = encoding.locale_decode(message.parse_error()[0]) + if missing_description: + error = '%s (%s)' % (missing_description, error) + raise exceptions.ScannerError(error) + elif message.type == gst.MESSAGE_EOS: + return tags, mime + elif message.type == gst.MESSAGE_ASYNC_DONE: + if message.src == pipeline: + return tags, mime + elif message.type == gst.MESSAGE_TAG: + taglist = message.parse_tag() + # Note that this will only keep the last tag. + tags.update(utils.convert_taglist(taglist)) - # Clear out any empty values we found - track_kwargs = {k: v for k, v in track_kwargs.items() if v} - album_kwargs = {k: v for k, v in album_kwargs.items() if v} + timeout -= clock.get_time() - start - track_kwargs['uri'] = data['uri'] - track_kwargs['album'] = Album(**album_kwargs) - return Track(**track_kwargs) + raise exceptions.ScannerError('Timeout after %dms' % timeout_ms) diff --git a/mopidy/audio/utils.py b/mopidy/audio/utils.py index f9036748..1a8bf6a7 100644 --- a/mopidy/audio/utils.py +++ b/mopidy/audio/utils.py @@ -1,9 +1,18 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals + +import datetime +import logging +import numbers import pygst pygst.require('0.10') import gst # noqa +from mopidy import compat +from mopidy.models import Album, Artist, Track + +logger = logging.getLogger(__name__) + def calculate_duration(num_samples, sample_rate): """Determine duration of samples using GStreamer helper for precise @@ -18,7 +27,7 @@ def create_buffer(data, capabilites=None, timestamp=None, duration=None): """ buffer_ = gst.Buffer(data) if capabilites: - if isinstance(capabilites, basestring): + if isinstance(capabilites, compat.string_types): capabilites = gst.caps_from_string(capabilites) buffer_.set_caps(capabilites) if timestamp: @@ -54,3 +63,138 @@ def supported_uri_schemes(uri_schemes): supported_schemes.add(uri) return supported_schemes + + +def _artists(tags, artist_name, artist_id=None): + # Name missing, don't set artist + if not tags.get(artist_name): + return None + # One artist name and id, provide artist with id. + if len(tags[artist_name]) == 1 and artist_id in tags: + return [Artist(name=tags[artist_name][0], + musicbrainz_id=tags[artist_id][0])] + # Multiple artist, provide artists without id. + return [Artist(name=name) for name in tags[artist_name]] + + +# TODO: split based on "stream" and "track" based conversion? i.e. handle data +# from radios in it's own helper instead? +def convert_tags_to_track(tags): + """Convert our normalized tags to a track. + + :param tags: dictionary of tag keys with a list of values + :type tags: :class:`dict` + :rtype: :class:`mopidy.models.Track` + """ + album_kwargs = {} + track_kwargs = {} + + track_kwargs['composers'] = _artists(tags, gst.TAG_COMPOSER) + track_kwargs['performers'] = _artists(tags, gst.TAG_PERFORMER) + track_kwargs['artists'] = _artists( + tags, gst.TAG_ARTIST, 'musicbrainz-artistid') + album_kwargs['artists'] = _artists( + tags, gst.TAG_ALBUM_ARTIST, 'musicbrainz-albumartistid') + + track_kwargs['genre'] = '; '.join(tags.get(gst.TAG_GENRE, [])) + track_kwargs['name'] = '; '.join(tags.get(gst.TAG_TITLE, [])) + if not track_kwargs['name']: + track_kwargs['name'] = '; '.join(tags.get(gst.TAG_ORGANIZATION, [])) + + track_kwargs['comment'] = '; '.join(tags.get('comment', [])) + if not track_kwargs['comment']: + track_kwargs['comment'] = '; '.join(tags.get(gst.TAG_LOCATION, [])) + if not track_kwargs['comment']: + track_kwargs['comment'] = '; '.join(tags.get(gst.TAG_COPYRIGHT, [])) + + track_kwargs['track_no'] = tags.get(gst.TAG_TRACK_NUMBER, [None])[0] + track_kwargs['disc_no'] = tags.get(gst.TAG_ALBUM_VOLUME_NUMBER, [None])[0] + track_kwargs['bitrate'] = tags.get(gst.TAG_BITRATE, [None])[0] + track_kwargs['musicbrainz_id'] = tags.get('musicbrainz-trackid', [None])[0] + + album_kwargs['name'] = tags.get(gst.TAG_ALBUM, [None])[0] + album_kwargs['num_tracks'] = tags.get(gst.TAG_TRACK_COUNT, [None])[0] + album_kwargs['num_discs'] = tags.get(gst.TAG_ALBUM_VOLUME_COUNT, [None])[0] + album_kwargs['musicbrainz_id'] = tags.get('musicbrainz-albumid', [None])[0] + + if tags.get(gst.TAG_DATE) and tags.get(gst.TAG_DATE)[0]: + track_kwargs['date'] = tags[gst.TAG_DATE][0].isoformat() + + # Clear out any empty values we found + track_kwargs = {k: v for k, v in track_kwargs.items() if v} + album_kwargs = {k: v for k, v in album_kwargs.items() if v} + + # Only bother with album if we have a name to show. + if album_kwargs.get('name'): + track_kwargs['album'] = Album(**album_kwargs) + + return Track(**track_kwargs) + + +def setup_proxy(element, config): + """Configure a GStreamer element with proxy settings. + + :param element: element to setup proxy in. + :type element: :class:`gst.GstElement` + :param config: proxy settings to use. + :type config: :class:`dict` + """ + if not hasattr(element.props, 'proxy') or not config.get('hostname'): + return + + proxy = "%s://%s:%d" % (config.get('scheme', 'http'), + config.get('hostname'), + config.get('port', 80)) + + element.set_property('proxy', proxy) + element.set_property('proxy-id', config.get('username')) + element.set_property('proxy-pw', config.get('password')) + + +def convert_taglist(taglist): + """Convert a :class:`gst.Taglist` to plain Python types. + + Knows how to convert: + + - Dates + - Buffers + - Numbers + - Strings + - Booleans + + Unknown types will be ignored and debug logged. Tag keys are all strings + defined as part GStreamer under GstTagList_. + + .. _GstTagList: http://gstreamer.freedesktop.org/data/doc/gstreamer/\ +0.10.36/gstreamer/html/gstreamer-GstTagList.html + + :param taglist: A GStreamer taglist to be converted. + :type taglist: :class:`gst.Taglist` + :rtype: dictionary of tag keys with a list of values. + """ + result = {} + + # Taglists are not really dicts, hence the lack of .items() and + # explicit use of .keys() + for key in taglist.keys(): + result.setdefault(key, []) + + values = taglist[key] + if not isinstance(values, list): + values = [values] + + for value in values: + if isinstance(value, gst.Date): + try: + date = datetime.date(value.year, value.month, value.day) + result[key].append(date) + except ValueError: + logger.debug('Ignoring invalid date: %r = %r', key, value) + elif isinstance(value, gst.Buffer): + result[key].append(bytes(value)) + elif isinstance(value, (basestring, bool, numbers.Number)): + result[key].append(value) + else: + logger.debug('Ignoring unknown data: %r = %r', key, value) + + return result diff --git a/mopidy/backend/__init__.py b/mopidy/backend.py similarity index 58% rename from mopidy/backend/__init__.py rename to mopidy/backend.py index b8e37cb2..63184853 100644 --- a/mopidy/backend/__init__.py +++ b/mopidy/backend.py @@ -1,8 +1,6 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals -import copy - -from mopidy import listener +from mopidy import listener, models class Backend(object): @@ -70,12 +68,12 @@ class LibraryProvider(object): root_directory = None """ - :class:`models.Ref.directory` instance with a URI and name set + :class:`mopidy.models.Ref.directory` instance with a URI and name set representing the root of this library's browse tree. URIs must use one of the schemes supported by the backend, and name should be set to a human friendly value. - *MUST be set by any class that implements :meth:`LibraryProvider.browse`.* + *MUST be set by any class that implements* :meth:`LibraryProvider.browse`. """ def __init__(self, backend): @@ -92,14 +90,34 @@ class LibraryProvider(object): """ return [] - # TODO: replace with search(query, exact=True, ...) - def find_exact(self, query=None, uris=None): + def get_distinct(self, field, query=None): """ - See :meth:`mopidy.core.LibraryController.find_exact`. + See :meth:`mopidy.core.LibraryController.get_distinct`. *MAY be implemented by subclass.* + + Default implementation will simply return an empty set. """ - pass + return set() + + def get_images(self, uris): + """ + See :meth:`mopidy.core.LibraryController.get_images`. + + *MAY be implemented by subclass.* + + Default implementation will simply call lookup and try and use the + album art for any tracks returned. Most extensions should replace this + with something smarter or simply return an empty dictionary. + """ + result = {} + for uri in uris: + image_uris = set() + for track in self.lookup(uri): + if track.album and track.album.images: + image_uris.update(track.album.images) + result[uri] = [models.Image(uri=u) for u in image_uris] + return result def lookup(self, uri): """ @@ -117,11 +135,14 @@ class LibraryProvider(object): """ pass - def search(self, query=None, uris=None): + def search(self, query=None, uris=None, exact=False): """ See :meth:`mopidy.core.LibraryController.search`. *MAY be implemented by subclass.* + + .. versionadded:: 1.0 + The ``exact`` param which replaces the old ``find_exact``. """ pass @@ -150,31 +171,66 @@ class PlaybackProvider(object): """ return self.audio.pause_playback().get() - def play(self, track): + def play(self): """ - Play given track. + Start playback. *MAY be reimplemented by subclass.* - :param track: the track to play - :type track: :class:`mopidy.models.Track` :rtype: :class:`True` if successful, else :class:`False` """ - self.audio.prepare_change() - self.change_track(track) return self.audio.start_playback().get() + def prepare_change(self): + """ + Indicate that an URI change is about to happen. + + *MAY be reimplemented by subclass.* + + It is extremely unlikely it makes sense for any backends to override + this. For most practical purposes it should be considered an internal + call between backends and core that backend authors should not touch. + """ + self.audio.prepare_change().get() + + def translate_uri(self, uri): + """ + Convert custom URI scheme to real playable URI. + + *MAY be reimplemented by subclass.* + + This is very likely the *only* thing you need to override as a backend + author. Typically this is where you convert any Mopidy specific URI + to a real URI and then return it. If you can't convert the URI just + return :class:`None`. + + :param uri: the URI to translate + :type uri: string + :rtype: string or :class:`None` if the URI could not be translated + """ + return uri + def change_track(self, track): """ Swith to provided track. *MAY be reimplemented by subclass.* + It is unlikely it makes sense for any backends to override + this. For most practical purposes it should be considered an internal + call between backends and core that backend authors should not touch. + + The default implementation will call :meth:`translate_uri` which + is what you want to implement. + :param track: the track to play :type track: :class:`mopidy.models.Track` :rtype: :class:`True` if successful, else :class:`False` """ - self.audio.set_uri(track.uri).get() + uri = self.translate_uri(track.uri) + if not uri: + return False + self.audio.set_uri(uri).get() return True def resume(self): @@ -205,6 +261,9 @@ class PlaybackProvider(object): *MAY be reimplemented by subclass.* + Should not be used for tracking if tracks have been played or when we + are done playing them. + :rtype: :class:`True` if successful, else :class:`False` """ return self.audio.stop_playback().get() @@ -222,6 +281,10 @@ class PlaybackProvider(object): class PlaylistsProvider(object): """ + A playlist provider exposes a collection of playlists, methods to + create/change/delete playlists in this collection, and lookup of any + playlist the backend knows about. + :param backend: backend the controller is a part of :type backend: :class:`mopidy.backend.Backend` instance """ @@ -230,48 +293,80 @@ class PlaylistsProvider(object): def __init__(self, backend): self.backend = backend - self._playlists = [] - @property - def playlists(self): + def as_list(self): """ - Currently available playlists. + Get a list of the currently available playlists. - Read/write. List of :class:`mopidy.models.Playlist`. + Returns a list of :class:`~mopidy.models.Ref` objects referring to the + playlists. In other words, no information about the playlists' content + is given. + + :rtype: list of :class:`mopidy.models.Ref` + + .. versionadded:: 1.0 """ - return copy.copy(self._playlists) + raise NotImplementedError - @playlists.setter # noqa - def playlists(self, playlists): - self._playlists = playlists + def get_items(self, uri): + """ + Get the items in a playlist specified by ``uri``. + + Returns a list of :class:`~mopidy.models.Ref` objects referring to the + playlist's items. + + If a playlist with the given ``uri`` doesn't exist, it returns + :class:`None`. + + :rtype: list of :class:`mopidy.models.Ref`, or :class:`None` + + .. versionadded:: 1.0 + """ + raise NotImplementedError def create(self, name): """ - See :meth:`mopidy.core.PlaylistsController.create`. + Create a new empty playlist with the given name. + + Returns a new playlist with the given name and an URI. *MUST be implemented by subclass.* + + :param name: name of the new playlist + :type name: string + :rtype: :class:`mopidy.models.Playlist` """ raise NotImplementedError def delete(self, uri): """ - See :meth:`mopidy.core.PlaylistsController.delete`. + Delete playlist identified by the URI. *MUST be implemented by subclass.* + + :param uri: URI of the playlist to delete + :type uri: string """ raise NotImplementedError def lookup(self, uri): """ - See :meth:`mopidy.core.PlaylistsController.lookup`. + Lookup playlist with given URI in both the set of playlists and in any + other playlist source. + + Returns the playlists or :class:`None` if not found. *MUST be implemented by subclass.* + + :param uri: playlist URI + :type uri: string + :rtype: :class:`mopidy.models.Playlist` or :class:`None` """ raise NotImplementedError def refresh(self): """ - See :meth:`mopidy.core.PlaylistsController.refresh`. + Refresh the playlists in :attr:`playlists`. *MUST be implemented by subclass.* """ @@ -279,9 +374,18 @@ class PlaylistsProvider(object): def save(self, playlist): """ - See :meth:`mopidy.core.PlaylistsController.save`. + Save the given playlist. + + The playlist must have an ``uri`` attribute set. To create a new + playlist with an URI, use :meth:`create`. + + Returns the saved playlist or :class:`None` on failure. *MUST be implemented by subclass.* + + :param playlist: the playlist to save + :type playlist: :class:`mopidy.models.Playlist` + :rtype: :class:`mopidy.models.Playlist` or :class:`None` """ raise NotImplementedError diff --git a/mopidy/commands.py b/mopidy/commands.py index 237ec86b..ebb2c891 100644 --- a/mopidy/commands.py +++ b/mopidy/commands.py @@ -1,4 +1,4 @@ -from __future__ import print_function, unicode_literals +from __future__ import absolute_import, print_function, unicode_literals import argparse import collections @@ -13,7 +13,7 @@ import gobject from mopidy import config as config_lib, exceptions from mopidy.audio import Audio from mopidy.core import Core -from mopidy.utils import deps, process, versioning +from mopidy.utils import deps, process, timer, versioning logger = logging.getLogger(__name__) @@ -267,10 +267,12 @@ class RootCommand(Command): exit_status_code = 0 try: - mixer = self.start_mixer(config, mixer_class) + mixer = None + if mixer_class is not None: + mixer = self.start_mixer(config, mixer_class) audio = self.start_audio(config, mixer) backends = self.start_backends(config, backend_classes, audio) - core = self.start_core(mixer, backends) + core = self.start_core(mixer, backends, audio) self.start_frontends(config, frontend_classes, core) loop.run() except (exceptions.BackendError, @@ -288,7 +290,8 @@ class RootCommand(Command): self.stop_core() self.stop_backends(backend_classes) self.stop_audio() - self.stop_mixer(mixer_class) + if mixer_class is not None: + self.stop_mixer(mixer_class) process.stop_remaining_actors() return exit_status_code @@ -297,13 +300,18 @@ class RootCommand(Command): 'Available Mopidy mixers: %s', ', '.join(m.__name__ for m in mixer_classes) or 'none') + if config['audio']['mixer'] == 'none': + logger.debug('Mixer disabled') + return None + selected_mixers = [ m for m in mixer_classes if m.name == config['audio']['mixer']] if len(selected_mixers) != 1: logger.error( 'Did not find unique mixer "%s". Alternatives are: %s', config['audio']['mixer'], - ', '.join([m.name for m in mixer_classes])) + ', '.join([m.name for m in mixer_classes]) + ', none' or + 'none') process.exit_process() return selected_mixers[0] @@ -339,8 +347,9 @@ class RootCommand(Command): backends = [] for backend_class in backend_classes: try: - backend = backend_class.start( - config=config, audio=audio).proxy() + with timer.time_logger(backend_class.__name__): + backend = backend_class.start( + config=config, audio=audio).proxy() backends.append(backend) except exceptions.BackendError as exc: logger.error( @@ -350,9 +359,9 @@ class RootCommand(Command): return backends - def start_core(self, mixer, backends): + def start_core(self, mixer, backends, audio): logger.info('Starting Mopidy core') - return Core.start(mixer=mixer, backends=backends).proxy() + return Core.start(mixer=mixer, backends=backends, audio=audio).proxy() def start_frontends(self, config, frontend_classes, core): logger.info( @@ -361,7 +370,8 @@ class RootCommand(Command): for frontend_class in frontend_classes: try: - frontend_class.start(config=config, core=core) + with timer.time_logger(frontend_class.__name__): + frontend_class.start(config=config, core=core) except exceptions.FrontendError as exc: logger.error( 'Frontend (%s) initialization error: %s', diff --git a/mopidy/compat.py b/mopidy/compat.py new file mode 100644 index 00000000..b563f735 --- /dev/null +++ b/mopidy/compat.py @@ -0,0 +1,30 @@ +import sys + +PY2 = sys.version_info[0] == 2 +PY3 = sys.version_info[0] == 3 + +if PY2: + import ConfigParser as configparser # noqa + import Queue as queue # noqa + import thread # noqa + + string_types = basestring + text_type = unicode + + input = raw_input + + def itervalues(dct, **kwargs): + return iter(dct.itervalues(**kwargs)) + +else: + import configparser # noqa + import queue # noqa + import _thread as thread # noqa + + string_types = (str,) + text_type = str + + input = input + + def itervalues(dct, **kwargs): + return iter(dct.values(**kwargs)) diff --git a/mopidy/config/__init__.py b/mopidy/config/__init__.py index a6578825..f6fd2709 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -1,12 +1,13 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals -import ConfigParser as configparser import io import itertools import logging import os.path import re +from mopidy import compat +from mopidy.compat import configparser from mopidy.config import keyring from mopidy.config.schemas import * # noqa from mopidy.config.types import * # noqa @@ -21,14 +22,15 @@ _logging_schema['debug_format'] = String() _logging_schema['debug_file'] = Path() _logging_schema['config_file'] = Path(optional=True) -_loglevels_schema = LogLevelConfigSchema('loglevels') +_loglevels_schema = MapConfigSchema('loglevels', LogLevel()) +_logcolors_schema = MapConfigSchema('logcolors', LogColor()) _audio_schema = ConfigSchema('audio') _audio_schema['mixer'] = String() _audio_schema['mixer_track'] = Deprecated() _audio_schema['mixer_volume'] = Integer(optional=True, minimum=0, maximum=100) _audio_schema['output'] = String() -_audio_schema['visualizer'] = String(optional=True) +_audio_schema['visualizer'] = Deprecated() _proxy_schema = ConfigSchema('proxy') _proxy_schema['scheme'] = String(optional=True, @@ -41,7 +43,8 @@ _proxy_schema['password'] = Secret(optional=True) # NOTE: if multiple outputs ever comes something like LogLevelConfigSchema # _outputs_schema = config.AudioOutputConfigSchema() -_schemas = [_logging_schema, _loglevels_schema, _audio_schema, _proxy_schema] +_schemas = [_logging_schema, _loglevels_schema, _logcolors_schema, + _audio_schema, _proxy_schema] _INITIAL_HELP = """ # For further information about options in this file see: @@ -108,18 +111,16 @@ def format_initial(extensions): def _load(files, defaults, overrides): parser = configparser.RawConfigParser() - files = [path.expand_path(f) for f in files] - sources = ['builtin defaults'] + files + ['command line options'] - logger.info('Loading config from: %s', ', '.join(sources)) - # TODO: simply return path to config file for defaults so we can load it # all in the same way? + logger.info('Loading config from builtin defaults') for default in defaults: - if isinstance(default, unicode): + if isinstance(default, compat.text_type): default = default.encode('utf-8') parser.readfp(io.BytesIO(default)) # Load config from a series of config files + files = [path.expand_path(f) for f in files] for name in files: if os.path.isdir(name): for filename in os.listdir(name): @@ -137,6 +138,7 @@ def _load(files, defaults, overrides): for section in parser.sections(): raw_config[section] = dict(parser.items(section)) + logger.info('Loading config from command line options') for section, key, value in overrides: raw_config.setdefault(section, {})[key] = value @@ -144,7 +146,18 @@ def _load(files, defaults, overrides): def _load_file(parser, filename): + if not os.path.exists(filename): + logger.debug( + 'Loading config from %s failed; it does not exist', filename) + return + if not os.access(filename, os.R_OK): + logger.warning( + 'Loading config from %s failed; read permission missing', + filename) + return + try: + logger.info('Loading config from %s', filename) with io.open(filename, 'rb') as filehandle: parser.readfp(filehandle) except configparser.MissingSectionHeaderError as e: @@ -164,13 +177,19 @@ def _validate(raw_config, schemas): # Get validated config config = {} errors = {} + sections = set(raw_config) for schema in schemas: + sections.discard(schema.name) values = raw_config.get(schema.name, {}) result, error = schema.deserialize(values) if error: errors[schema.name] = error if result: config[schema.name] = result + + for section in sections: + logger.debug('Ignoring unknown config section: %s', section) + return config, errors diff --git a/mopidy/config/default.conf b/mopidy/config/default.conf index 6a900cf9..42edbbbd 100644 --- a/mopidy/config/default.conf +++ b/mopidy/config/default.conf @@ -9,11 +9,10 @@ config_file = mixer = software mixer_volume = output = autoaudiosink -visualizer = [proxy] scheme = hostname = -port = +port = username = password = diff --git a/mopidy/config/keyring.py b/mopidy/config/keyring.py index 4d251f52..fb6eded3 100644 --- a/mopidy/config/keyring.py +++ b/mopidy/config/keyring.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import logging @@ -9,6 +9,8 @@ try: except ImportError: dbus = None +from mopidy import compat + # XXX: Hack to workaround introspection bug caused by gnome-keyring, should be # fixed by version 3.5 per: @@ -57,7 +59,7 @@ def fetch(): result = [] secrets = service.GetSecrets(items, session, byte_arrays=True) - for item_path, values in secrets.iteritems(): + for item_path, values in secrets.items(): session_path, parameters, value, content_type = values attrs = _item_attributes(bus, item_path) result.append((attrs['section'], attrs['key'], bytes(value))) @@ -92,7 +94,7 @@ def set(section, key, value): if not collection: return False - if isinstance(value, unicode): + if isinstance(value, compat.text_type): value = value.encode('utf-8') session = service.OpenSession('plain', EMPTY_STRING)[1] @@ -161,7 +163,7 @@ def _prompt(bus, path): def _item_attributes(bus, path): item = _interface(bus, path, 'org.freedesktop.DBus.Properties') result = item.Get('org.freedesktop.Secret.Item', 'Attributes') - return dict((bytes(k), bytes(v)) for k, v in result.iteritems()) + return dict((bytes(k), bytes(v)) for k, v in result.items()) def _interface(bus, path, interface): diff --git a/mopidy/config/schemas.py b/mopidy/config/schemas.py index 12536c0c..2b055663 100644 --- a/mopidy/config/schemas.py +++ b/mopidy/config/schemas.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import collections @@ -25,10 +25,10 @@ def _levenshtein(a, b): if n > m: return _levenshtein(b, a) - current = xrange(n + 1) - for i in xrange(1, m + 1): + current = range(n + 1) + for i in range(1, m + 1): previous, current = current, [i] + [0] * n - for j in xrange(1, n + 1): + for j in range(1, n + 1): add, delete = previous[j] + 1, current[j - 1] + 1 change = previous[j - 1] if a[j - 1] != b[i - 1]: @@ -94,17 +94,16 @@ class ConfigSchema(collections.OrderedDict): return result -class LogLevelConfigSchema(object): - """Special cased schema for handling a config section with loglevels. +class MapConfigSchema(object): + """Schema for handling multiple unknown keys with the same type. - Expects the config keys to be logger names and the values to be log levels - as understood by the :class:`LogLevel` config value. Does not sub-class - :class:`ConfigSchema`, but implements the same serialize/deserialize - interface. + Does not sub-class :class:`ConfigSchema`, but implements the same + serialize/deserialize interface. """ - def __init__(self, name): + + def __init__(self, name, value_type): self.name = name - self._config_value = types.LogLevel() + self._value_type = value_type def deserialize(self, values): errors = {} @@ -112,7 +111,7 @@ class LogLevelConfigSchema(object): for key, value in values.items(): try: - result[key] = self._config_value.deserialize(value) + result[key] = self._value_type.deserialize(value) except ValueError as e: # deserialization failed result[key] = None errors[key] = str(e) @@ -121,5 +120,5 @@ class LogLevelConfigSchema(object): def serialize(self, values, display=False): result = collections.OrderedDict() for key in sorted(values.keys()): - result[key] = self._config_value.serialize(values[key], display) + result[key] = self._value_type.serialize(values[key], display) return result diff --git a/mopidy/config/types.py b/mopidy/config/types.py index 4498cb67..d074458b 100644 --- a/mopidy/config/types.py +++ b/mopidy/config/types.py @@ -1,22 +1,23 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import logging import re import socket +from mopidy import compat from mopidy.config import validators -from mopidy.utils import path +from mopidy.utils import log, path def decode(value): - if isinstance(value, unicode): + if isinstance(value, compat.text_type): return value # TODO: only unescape \n \t and \\? return value.decode('string-escape').decode('utf-8') def encode(value): - if not isinstance(value, unicode): + if not isinstance(value, compat.text_type): return value for char in ('\\', '\n', '\t'): # TODO: more escapes? value = value.replace(char, char.encode('unicode-escape')) @@ -24,8 +25,8 @@ def encode(value): class ExpandedPath(bytes): - def __new__(self, original, expanded): - return super(ExpandedPath, self).__new__(self, expanded) + def __new__(cls, original, expanded): + return super(ExpandedPath, cls).__new__(cls, expanded) def __init__(self, original, expanded): self.original = original @@ -196,11 +197,22 @@ class List(ConfigValue): return b'\n ' + b'\n '.join(encode(v) for v in value if v) +class LogColor(ConfigValue): + def deserialize(self, value): + validators.validate_choice(value.lower(), log.COLORS) + return value.lower() + + def serialize(self, value, display=False): + if value.lower() in log.COLORS: + return value.lower() + return b'' + + class LogLevel(ConfigValue): """Log level value. - Expects one of ``critical``, ``error``, ``warning``, ``info``, ``debug`` - with any casing. + Expects one of ``critical``, ``error``, ``warning``, ``info``, ``debug``, + or ``all``, with any casing. """ levels = { b'critical': logging.CRITICAL, @@ -208,6 +220,7 @@ class LogLevel(ConfigValue): b'warning': logging.WARNING, b'info': logging.INFO, b'debug': logging.DEBUG, + b'all': logging.NOTSET, } def deserialize(self, value): @@ -278,7 +291,7 @@ class Path(ConfigValue): return ExpandedPath(value, expanded) def serialize(self, value, display=False): - if isinstance(value, unicode): + if isinstance(value, compat.text_type): raise ValueError('paths should always be bytes') if isinstance(value, ExpandedPath): return value.original diff --git a/mopidy/config/validators.py b/mopidy/config/validators.py index a0ca25d9..d0549659 100644 --- a/mopidy/config/validators.py +++ b/mopidy/config/validators.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals # TODO: add validate regexp? diff --git a/mopidy/core/__init__.py b/mopidy/core/__init__.py index f49bbbe7..720f9c38 100644 --- a/mopidy/core/__init__.py +++ b/mopidy/core/__init__.py @@ -1,9 +1,11 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals # flake8: noqa from .actor import Core +from .history import HistoryController from .library import LibraryController from .listener import CoreListener +from .mixer import MixerController from .playback import PlaybackController, PlaybackState from .playlists import PlaylistsController from .tracklist import TracklistController diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index 66f2aa82..b21e9e20 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import collections import itertools @@ -7,12 +7,15 @@ import pykka from mopidy import audio, backend, mixer from mopidy.audio import PlaybackState +from mopidy.core.history import HistoryController from mopidy.core.library import LibraryController from mopidy.core.listener import CoreListener +from mopidy.core.mixer import MixerController from mopidy.core.playback import PlaybackController from mopidy.core.playlists import PlaylistsController from mopidy.core.tracklist import TracklistController from mopidy.utils import versioning +from mopidy.utils.deprecation import deprecated_property class Core( @@ -23,6 +26,14 @@ class Core( """The library controller. An instance of :class:`mopidy.core.LibraryController`.""" + history = None + """The playback history controller. An instance of + :class:`mopidy.core.HistoryController`.""" + + mixer = None + """The mixer controller. An instance of + :class:`mopidy.core.MixerController`.""" + playback = None """The playback controller. An instance of :class:`mopidy.core.PlaybackController`.""" @@ -35,38 +46,48 @@ class Core( """The tracklist controller. An instance of :class:`mopidy.core.TracklistController`.""" - def __init__(self, mixer=None, backends=None): + def __init__(self, mixer=None, backends=None, audio=None): super(Core, self).__init__() self.backends = Backends(backends) self.library = LibraryController(backends=self.backends, core=self) - - self.playback = PlaybackController( - mixer=mixer, backends=self.backends, core=self) - - self.playlists = PlaylistsController( - backends=self.backends, core=self) - + self.history = HistoryController() + self.mixer = MixerController(mixer=mixer) + self.playback = PlaybackController(backends=self.backends, core=self) + self.playlists = PlaylistsController(backends=self.backends, core=self) self.tracklist = TracklistController(core=self) + self.audio = audio + def get_uri_schemes(self): + """Get list of URI schemes we can handle""" futures = [b.uri_schemes for b in self.backends] results = pykka.get_all(futures) uri_schemes = itertools.chain(*results) return sorted(uri_schemes) - uri_schemes = property(get_uri_schemes) - """List of URI schemes we can handle""" + uri_schemes = deprecated_property(get_uri_schemes) + """ + .. deprecated:: 1.0 + Use :meth:`get_uri_schemes` instead. + """ def get_version(self): + """Get version of the Mopidy core API""" return versioning.get_version() - version = property(get_version) - """Version of the Mopidy core API""" + version = deprecated_property(get_version) + """ + .. deprecated:: 1.0 + Use :meth:`get_version` instead. + """ def reached_end_of_stream(self): - self.playback.on_end_of_track() + self.playback._on_end_of_track() + + def stream_changed(self, uri): + self.playback._on_stream_changed(uri) def state_changed(self, old_state, new_state, target_state): # XXX: This is a temporary fix for issue #232 while we wait for a more @@ -78,8 +99,8 @@ class Core( # We ignore cases when target state is set as this is buffering # updates (at least for now) and we need to get #234 fixed... - if (new_state == PlaybackState.PAUSED and not target_state - and self.playback.state != PlaybackState.PAUSED): + if (new_state == PlaybackState.PAUSED and not target_state and + self.playback.state != PlaybackState.PAUSED): self.playback.state = new_state self.playback._trigger_track_playback_paused() @@ -95,6 +116,22 @@ class Core( # Forward event from mixer to frontends CoreListener.send('mute_changed', mute=mute) + def tags_changed(self, tags): + if not self.audio or 'title' not in tags: + return + + tags = self.audio.get_current_tags().get() + if not tags: + return + + # TODO: this limits us to only streams that set organization, this is + # a hack to make sure we don't emit stream title changes for plain + # tracks. We need a better way to decide if something is a stream. + if 'title' in tags and tags['title'] and 'organization' in tags: + title = tags['title'][0] + self.playback._stream_title = title + CoreListener.send('stream_title_changed', title=title) + class Backends(list): def __init__(self, backends): @@ -106,7 +143,9 @@ class Backends(list): self.with_playlists = collections.OrderedDict() backends_by_scheme = {} - name = lambda b: b.actor_ref.actor_class.__name__ + + def name(b): + return b.actor_ref.actor_class.__name__ for b in backends: has_library = b.has_library().get() diff --git a/mopidy/core/history.py b/mopidy/core/history.py new file mode 100644 index 00000000..f0d5e9d4 --- /dev/null +++ b/mopidy/core/history.py @@ -0,0 +1,58 @@ +from __future__ import absolute_import, unicode_literals + +import copy +import logging +import time + +from mopidy import models + + +logger = logging.getLogger(__name__) + + +class HistoryController(object): + + def __init__(self): + self._history = [] + + def _add_track(self, track): + """Add track to the playback history. + + Internal method for :class:`mopidy.core.PlaybackController`. + + :param track: track to add + :type track: :class:`mopidy.models.Track` + """ + if not isinstance(track, models.Track): + raise TypeError('Only Track objects can be added to the history') + + timestamp = int(time.time() * 1000) + + name_parts = [] + if track.artists: + name_parts.append( + ', '.join([artist.name for artist in track.artists])) + if track.name is not None: + name_parts.append(track.name) + name = ' - '.join(name_parts) + ref = models.Ref.track(uri=track.uri, name=name) + + self._history.insert(0, (timestamp, ref)) + + def get_length(self): + """Get the number of tracks in the history. + + :returns: the history length + :rtype: int + """ + return len(self._history) + + def get_history(self): + """Get the track history. + + The timestamps are milliseconds since epoch. + + :returns: the track history + :rtype: list of (timestamp, :class:`mopidy.models.Ref`) tuples + """ + return copy.copy(self._history) diff --git a/mopidy/core/library.py b/mopidy/core/library.py index 50d7df19..89a2037a 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -1,11 +1,14 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import collections +import logging import operator import urlparse import pykka +logger = logging.getLogger(__name__) + class LibraryController(object): pykka_traversable = True @@ -60,6 +63,8 @@ class LibraryController(object): :param string uri: URI to browse :rtype: list of :class:`mopidy.models.Ref` + + .. versionadded:: 0.18 """ if uri is None: backends = self.backends.with_library_browse.values() @@ -72,52 +77,64 @@ class LibraryController(object): return [] return backend.library.browse(uri).get() - def find_exact(self, query=None, uris=None, **kwargs): + def get_distinct(self, field, query=None): """ - Search the library for tracks where ``field`` is ``values``. + List distinct values for a given field from the library. - If the query is empty, and the backend can support it, all available - tracks are returned. + This has mainly been added to support the list commands the MPD + protocol supports in a more sane fashion. Other frontends are not + recommended to use this method. - 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. + :param string field: One of ``artist``, ``albumartist``, ``album``, + ``composer``, ``performer``, ``date``or ``genre``. + :param dict query: Query to use for limiting results, see + :meth:`search` for details about the query format. + :rtype: set of values corresponding to the requested field type. - Examples:: - - # Returns results matching 'a' from any backend - find_exact({'any': ['a']}) - find_exact(any=['a']) - - # 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' 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` + .. versionadded:: 1.0 + """ + futures = [b.library.get_distinct(field, query) + for b in self.backends.with_library.values()] + result = set() + for r in pykka.get_all(futures): + result.update(r) + return result + + def get_images(self, uris): + """Lookup the images for the given URIs + + Backends can use this to return image URIs for any URI they know about + be it tracks, albums, playlists... The lookup result is a dictionary + mapping the provided URIs to lists of images. + + Unknown URIs or URIs the corresponding backend couldn't find anything + for will simply return an empty list for that URI. + + :param list uris: list of URIs to find images for + :rtype: {uri: tuple of :class:`mopidy.models.Image`} + + .. versionadded:: 1.0 """ - query = query or kwargs futures = [ - backend.library.find_exact(query=query, uris=backend_uris) + backend.library.get_images(backend_uris) for (backend, backend_uris) - in self._get_backends_to_uris(uris).items()] - return [result for result in pykka.get_all(futures) if result] + in self._get_backends_to_uris(uris).items() if backend_uris] - def lookup(self, uri): + results = {uri: tuple() for uri in uris} + for r in pykka.get_all(futures): + for uri, images in r.items(): + results[uri] += tuple(images) + return results + + def find_exact(self, query=None, uris=None, **kwargs): + """Search the library for tracks where ``field`` is ``values``. + + .. deprecated:: 1.0 + Use :meth:`search` with ``exact`` set. + """ + return self.search(query=query, uris=uris, exact=True, **kwargs) + + def lookup(self, uri=None, uris=None): """ Lookup the given URI. @@ -125,14 +142,45 @@ class LibraryController(object): them all. :param uri: track URI - :type uri: string - :rtype: list of :class:`mopidy.models.Track` + :type uri: string or :class:`None` + :param uris: track URIs + :type uris: list of string or :class:`None` + :rtype: list of :class:`mopidy.models.Track` if uri was set or + a {uri: list of :class:`mopidy.models.Track`} if uris was set. + + .. versionadded:: 1.0 + The ``uris`` argument. + + .. deprecated:: 1.0 + The ``uri`` argument. Use ``uris`` instead. """ - backend = self._get_backend(uri) - if backend: - return backend.library.lookup(uri).get() - else: - return [] + none_set = uri is None and uris is None + both_set = uri is not None and uris is not None + + if none_set or both_set: + raise ValueError("One of 'uri' or 'uris' must be set") + + if uri is not None: + uris = [uri] + + futures = {} + result = {} + backends = self._get_backends_to_uris(uris) + + # TODO: lookup(uris) to backend APIs + for backend, backend_uris in backends.items(): + for u in backend_uris or []: + futures[u] = backend.library.lookup(u) + + for u in uris: + if u in futures: + result[u] = futures[u].get() + else: + result[u] = [] + + if uri: + return result[uri] + return result def refresh(self, uri=None): """ @@ -150,12 +198,15 @@ class LibraryController(object): for b in self.backends.with_library.values()] pykka.get_all(futures) - def search(self, query=None, uris=None, **kwargs): + def search(self, query=None, uris=None, exact=False, **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. + .. deprecated:: 1.0 + Previously, if the query was empty, and the backend could support + it, all available tracks were returned. This has not changed, but + it is strongly discouraged. No new code should rely on this + behavior. If ``uris`` is given, the search is limited to results from within the URI roots. For example passing ``uris=['file:']`` will limit the search @@ -186,10 +237,42 @@ class LibraryController(object): :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` + + .. versionadded:: 1.0 + The ``exact`` keyword argument, which replaces :meth:`find_exact`. """ - query = query or kwargs - futures = [ - backend.library.search(query=query, uris=backend_uris) - for (backend, backend_uris) - in self._get_backends_to_uris(uris).items()] - return [result for result in pykka.get_all(futures) if result] + query = _normalize_query(query or kwargs) + futures = {} + for backend, backend_uris in self._get_backends_to_uris(uris).items(): + futures[backend] = backend.library.search( + query=query, uris=backend_uris, exact=exact) + + results = [] + for backend, future in futures.items(): + try: + results.append(future.get()) + except TypeError: + backend_name = backend.actor_ref.actor_class.__name__ + logger.warning( + '%s does not implement library.search() with "exact" ' + 'support. Please upgrade it.', backend_name) + return [r for r in results if r] + + +def _normalize_query(query): + broken_client = False + for (field, values) in query.items(): + if isinstance(values, basestring): + broken_client = True + query[field] = [values] + if broken_client: + logger.warning( + 'A client or frontend made a broken library search. Values in ' + 'queries must be lists of strings, not a string. Please check what' + ' sent this query and file a bug. Query: %s', query) + if not query: + logger.warning( + 'A client or frontend made a library search with an empty query. ' + 'This is strongly discouraged. Please check what sent this query ' + 'and file a bug.') + return query diff --git a/mopidy/core/listener.py b/mopidy/core/listener.py index f0bb1ea3..3ae03925 100644 --- a/mopidy/core/listener.py +++ b/mopidy/core/listener.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals from mopidy import listener @@ -163,3 +163,11 @@ class CoreListener(listener.Listener): :type time_position: int """ pass + + def stream_title_changed(self, title): + """ + Called whenever the currently playing stream title changes. + + *MAY* be implemented by actor. + """ + pass diff --git a/mopidy/core/mixer.py b/mopidy/core/mixer.py new file mode 100644 index 00000000..3388d706 --- /dev/null +++ b/mopidy/core/mixer.py @@ -0,0 +1,58 @@ +from __future__ import absolute_import, unicode_literals + +import logging + + +logger = logging.getLogger(__name__) + + +class MixerController(object): + pykka_traversable = True + + def __init__(self, mixer): + self._mixer = mixer + + def get_volume(self): + """Get the volume. + + Integer in range [0..100] or :class:`None` if unknown. + + The volume scale is linear. + """ + if self._mixer is not None: + return self._mixer.get_volume().get() + + def set_volume(self, volume): + """Set the volume. + + The volume is defined as an integer in range [0..100]. + + The volume scale is linear. + + Returns :class:`True` if call is successful, otherwise :class:`False`. + """ + if self._mixer is None: + return False + else: + return self._mixer.set_volume(volume).get() + + def get_mute(self): + """Get mute state. + + :class:`True` if muted, :class:`False` unmuted, :class:`None` if + unknown. + """ + if self._mixer is not None: + return self._mixer.get_mute().get() + + def set_mute(self, mute): + """Set mute state. + + :class:`True` to mute, :class:`False` to unmute. + + Returns :class:`True` if call is successful, otherwise :class:`False`. + """ + if self._mixer is None: + return False + else: + return self._mixer.set_mute(bool(mute)).get() diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index d6307e2f..61bbc60c 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -1,10 +1,12 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import logging import urlparse +import warnings from mopidy.audio import PlaybackState from mopidy.core import listener +from mopidy.utils.deprecation import deprecated_property logger = logging.getLogger(__name__) @@ -13,164 +15,225 @@ logger = logging.getLogger(__name__) class PlaybackController(object): pykka_traversable = True - def __init__(self, mixer, backends, core): - self.mixer = mixer + def __init__(self, backends, core): self.backends = backends self.core = core + self._current_tl_track = None + self._stream_title = None self._state = PlaybackState.STOPPED - self._volume = None - self._mute = False def _get_backend(self): - if self.current_tl_track is None: + # TODO: take in track instead + track = self.get_current_track() + if track is None: return None - uri = self.current_tl_track.track.uri + uri = track.uri uri_scheme = urlparse.urlparse(uri).scheme return self.backends.with_playback.get(uri_scheme, None) # Properties def get_current_tl_track(self): - return self.current_tl_track + """Get the currently playing or selected track. - current_tl_track = None + Returns a :class:`mopidy.models.TlTrack` or :class:`None`. + """ + return self._current_tl_track + + def _set_current_tl_track(self, value): + """Set the currently playing or selected track. + + *Internal:* This is only for use by Mopidy's test suite. + """ + self._current_tl_track = value + + current_tl_track = deprecated_property(get_current_tl_track) """ - The currently playing or selected :class:`mopidy.models.TlTrack`, or - :class:`None`. + .. deprecated:: 1.0 + Use :meth:`get_current_tl_track` instead. """ def get_current_track(self): - return self.current_tl_track and self.current_tl_track.track + """ + Get the currently playing or selected track. - current_track = property(get_current_track) - """ - The currently playing or selected :class:`mopidy.models.Track`. + Extracted from :meth:`get_current_tl_track` for convenience. - Read-only. Extracted from :attr:`current_tl_track` for convenience. + Returns a :class:`mopidy.models.Track` or :class:`None`. + """ + tl_track = self.get_current_tl_track() + if tl_track is not None: + return tl_track.track + + current_track = deprecated_property(get_current_track) """ + .. deprecated:: 1.0 + Use :meth:`get_current_track` instead. + """ + + def get_stream_title(self): + """Get the current stream title or :class:`None`.""" + return self._stream_title def get_state(self): + """Get The playback state.""" + return self._state def set_state(self, new_state): - (old_state, self._state) = (self.state, new_state) + """Set the playback state. + + Must be :attr:`PLAYING`, :attr:`PAUSED`, or :attr:`STOPPED`. + + Possible states and transitions: + + .. digraph:: state_transitions + + "STOPPED" -> "PLAYING" [ label="play" ] + "STOPPED" -> "PAUSED" [ label="pause" ] + "PLAYING" -> "STOPPED" [ label="stop" ] + "PLAYING" -> "PAUSED" [ label="pause" ] + "PLAYING" -> "PLAYING" [ label="play" ] + "PAUSED" -> "PLAYING" [ label="resume" ] + "PAUSED" -> "STOPPED" [ label="stop" ] + """ + (old_state, self._state) = (self.get_state(), new_state) logger.debug('Changing state: %s -> %s', old_state, new_state) self._trigger_playback_state_changed(old_state, new_state) - state = property(get_state, set_state) + state = deprecated_property(get_state, set_state) """ - The playback state. Must be :attr:`PLAYING`, :attr:`PAUSED`, or - :attr:`STOPPED`. - - Possible states and transitions: - - .. digraph:: state_transitions - - "STOPPED" -> "PLAYING" [ label="play" ] - "STOPPED" -> "PAUSED" [ label="pause" ] - "PLAYING" -> "STOPPED" [ label="stop" ] - "PLAYING" -> "PAUSED" [ label="pause" ] - "PLAYING" -> "PLAYING" [ label="play" ] - "PAUSED" -> "PLAYING" [ label="resume" ] - "PAUSED" -> "STOPPED" [ label="stop" ] + .. deprecated:: 1.0 + Use :meth:`get_state` and :meth:`set_state` instead. """ def get_time_position(self): + """Get time position in milliseconds.""" backend = self._get_backend() if backend: return backend.playback.get_time_position().get() else: return 0 - time_position = property(get_time_position) - """Time position in milliseconds.""" + time_position = deprecated_property(get_time_position) + """ + .. deprecated:: 1.0 + Use :meth:`get_time_position` instead. + """ def get_volume(self): - if self.mixer: - return self.mixer.get_volume().get() - else: - # For testing - return self._volume + """ + .. deprecated:: 1.0 + Use :meth:`core.mixer.get_volume() + ` instead. + """ + warnings.warn( + 'playback.get_volume() is deprecated', DeprecationWarning) + return self.core.mixer.get_volume() def set_volume(self, volume): - if self.mixer: - self.mixer.set_volume(volume) - else: - # For testing - self._volume = volume + """ + .. deprecated:: 1.0 + Use :meth:`core.mixer.set_volume() + ` instead. + """ + warnings.warn( + 'playback.set_volume() is deprecated', DeprecationWarning) + return self.core.mixer.set_volume(volume) - volume = property(get_volume, set_volume) - """Volume as int in range [0..100] or :class:`None` if unknown. The volume - scale is linear. + volume = deprecated_property(get_volume, set_volume) + """ + .. deprecated:: 1.0 + Use :meth:`core.mixer.get_volume() + ` and + :meth:`core.mixer.set_volume() + ` instead. """ def get_mute(self): - if self.mixer: - return self.mixer.get_mute().get() - else: - # For testing - return self._mute + """ + .. deprecated:: 1.0 + Use :meth:`core.mixer.get_mute() + ` instead. + """ + warnings.warn('playback.get_mute() is deprecated', DeprecationWarning) + return self.core.mixer.get_mute() - def set_mute(self, value): - value = bool(value) - if self.mixer: - self.mixer.set_mute(value) - else: - # For testing - self._mute = value + def set_mute(self, mute): + """ + .. deprecated:: 1.0 + Use :meth:`core.mixer.set_mute() + ` instead. + """ + warnings.warn('playback.set_mute() is deprecated', DeprecationWarning) + return self.core.mixer.set_mute(mute) - mute = property(get_mute, set_mute) - """Mute state as a :class:`True` if muted, :class:`False` otherwise""" + mute = deprecated_property(get_mute, set_mute) + """ + .. deprecated:: 1.0 + Use :meth:`core.mixer.get_mute() + ` and + :meth:`core.mixer.set_mute() + ` instead. + """ # Methods - def change_track(self, tl_track, on_error_step=1): + # TODO: remove this. + def _change_track(self, tl_track, on_error_step=1): """ Change to the given track, keeping the current playback state. :param tl_track: track to change to :type tl_track: :class:`mopidy.models.TlTrack` or :class:`None` :param on_error_step: direction to step at play error, 1 for next - track (default), -1 for previous track + track (default), -1 for previous track. **INTERNAL** :type on_error_step: int, -1 or 1 """ - old_state = self.state + old_state = self.get_state() self.stop() - self.current_tl_track = tl_track + self._set_current_tl_track(tl_track) if old_state == PlaybackState.PLAYING: - self.play(on_error_step=on_error_step) + self._play(on_error_step=on_error_step) elif old_state == PlaybackState.PAUSED: self.pause() - def on_end_of_track(self): + # TODO: this is not really end of track, this is on_need_next_track + def _on_end_of_track(self): """ Tell the playback controller that end of track is reached. Used by event handler in :class:`mopidy.core.Core`. """ - if self.state == PlaybackState.STOPPED: + if self.get_state() == PlaybackState.STOPPED: return - original_tl_track = self.current_tl_track + original_tl_track = self.get_current_tl_track() next_tl_track = self.core.tracklist.eot_track(original_tl_track) if next_tl_track: - self.change_track(next_tl_track) + self._change_track(next_tl_track) else: - self.stop(clear_current_track=True) + self.stop() + self._set_current_tl_track(None) - self.core.tracklist.mark_played(original_tl_track) + self.core.tracklist._mark_played(original_tl_track) - def on_tracklist_change(self): + def _on_tracklist_change(self): """ Tell the playback controller that the current playlist has changed. Used by :class:`mopidy.core.TracklistController`. """ - if self.current_tl_track not in self.core.tracklist.tl_tracks: - self.stop(clear_current_track=True) + tracklist = self.core.tracklist.get_tl_tracks() + if self.get_current_tl_track() not in tracklist: + self.stop() + self._set_current_tl_track(None) + + def _on_stream_changed(self, uri): + self._stream_title = None def next(self): """ @@ -179,43 +242,47 @@ class PlaybackController(object): The current playback state will be kept. If it was playing, playing will continue. If it was paused, it will still be paused, etc. """ - original_tl_track = self.current_tl_track + original_tl_track = self.get_current_tl_track() next_tl_track = self.core.tracklist.next_track(original_tl_track) if next_tl_track: - self.change_track(next_tl_track) + # TODO: switch to: + # backend.play(track) + # wait for state change? + self._change_track(next_tl_track) else: - self.stop(clear_current_track=True) + self.stop() + self._set_current_tl_track(None) - self.core.tracklist.mark_played(original_tl_track) + self.core.tracklist._mark_played(original_tl_track) def pause(self): """Pause playback.""" backend = self._get_backend() if not backend or backend.playback.pause().get(): - self.state = PlaybackState.PAUSED + # TODO: switch to: + # backend.track(pause) + # wait for state change? + self.set_state(PlaybackState.PAUSED) self._trigger_track_playback_paused() - def play(self, tl_track=None, on_error_step=1): + def play(self, tl_track=None): """ Play the given track, or if the given track is :class:`None`, play the currently active track. :param tl_track: track to play :type tl_track: :class:`mopidy.models.TlTrack` or :class:`None` - :param on_error_step: direction to step at play error, 1 for next - track (default), -1 for previous track - :type on_error_step: int, -1 or 1 """ + self._play(tl_track, on_error_step=1) - assert on_error_step in (-1, 1) - + def _play(self, tl_track=None, on_error_step=1): if tl_track is None: - if self.state == PlaybackState.PAUSED: + if self.get_state() == PlaybackState.PAUSED: return self.resume() - if self.current_tl_track is not None: - tl_track = self.current_tl_track + if self.get_current_tl_track() is not None: + tl_track = self.get_current_tl_track() else: if on_error_step == 1: tl_track = self.core.tracklist.next_track(tl_track) @@ -225,21 +292,37 @@ class PlaybackController(object): if tl_track is None: return - assert tl_track in self.core.tracklist.tl_tracks + assert tl_track in self.core.tracklist.get_tl_tracks() - if self.state == PlaybackState.PLAYING: + # TODO: switch to: + # backend.play(track) + # wait for state change? + + if self.get_state() == PlaybackState.PLAYING: self.stop() - self.current_tl_track = tl_track - self.state = PlaybackState.PLAYING + self._set_current_tl_track(tl_track) + self.set_state(PlaybackState.PLAYING) backend = self._get_backend() - success = backend and backend.playback.play(tl_track.track).get() + success = False + + if backend: + backend.playback.prepare_change() + try: + success = ( + backend.playback.change_track(tl_track.track).get() and + backend.playback.play().get()) + except TypeError: + logger.error('%s needs to be updated to work with this ' + 'version of Mopidy.', backend) if success: - self.core.tracklist.mark_playing(tl_track) + self.core.tracklist._mark_playing(tl_track) + self.core.history._add_track(tl_track.track) + # TODO: replace with stream-changed self._trigger_track_playback_started() else: - self.core.tracklist.mark_unplayable(tl_track) + self.core.tracklist._mark_unplayable(tl_track) if on_error_step == 1: # TODO: can cause an endless loop for single track repeat. self.next() @@ -253,18 +336,25 @@ class PlaybackController(object): The current playback state will be kept. If it was playing, playing will continue. If it was paused, it will still be paused, etc. """ - tl_track = self.current_tl_track - self.change_track( + tl_track = self.get_current_tl_track() + # TODO: switch to: + # self.play(....) + # wait for state change? + self._change_track( self.core.tracklist.previous_track(tl_track), on_error_step=-1) def resume(self): """If paused, resume playing the current track.""" - if self.state != PlaybackState.PAUSED: + if self.get_state() != PlaybackState.PAUSED: return backend = self._get_backend() if backend and backend.playback.resume().get(): - self.state = PlaybackState.PLAYING + self.set_state(PlaybackState.PLAYING) + # TODO: trigger via gst messages self._trigger_track_playback_resumed() + # TODO: switch to: + # backend.resume() + # wait for state change? def seek(self, time_position): """ @@ -277,10 +367,11 @@ class PlaybackController(object): if not self.core.tracklist.tracks: return False - if self.state == PlaybackState.STOPPED: + if self.current_track and self.current_track.length is None: + return False + + if self.get_state() == PlaybackState.STOPPED: self.play() - elif self.state == PlaybackState.PAUSED: - self.resume() if time_position < 0: time_position = 0 @@ -297,22 +388,14 @@ class PlaybackController(object): self._trigger_seeked(time_position) return success - def stop(self, clear_current_track=False): - """ - Stop playing. - - :param clear_current_track: whether to clear the current track _after_ - stopping - :type clear_current_track: boolean - """ - if self.state != PlaybackState.STOPPED: + def stop(self): + """Stop playing.""" + if self.get_state() != PlaybackState.STOPPED: backend = self._get_backend() - time_position_before_stop = self.time_position + time_position_before_stop = self.get_time_position() if not backend or backend.playback.stop().get(): - self.state = PlaybackState.STOPPED + self.set_state(PlaybackState.STOPPED) self._trigger_track_playback_ended(time_position_before_stop) - if clear_current_track: - self.current_tl_track = None def _trigger_track_playback_paused(self): logger.debug('Triggering track playback paused event') @@ -320,7 +403,8 @@ class PlaybackController(object): return listener.CoreListener.send( 'track_playback_paused', - tl_track=self.current_tl_track, time_position=self.time_position) + tl_track=self.get_current_tl_track(), + time_position=self.get_time_position()) def _trigger_track_playback_resumed(self): logger.debug('Triggering track playback resumed event') @@ -328,23 +412,24 @@ class PlaybackController(object): return listener.CoreListener.send( 'track_playback_resumed', - tl_track=self.current_tl_track, time_position=self.time_position) + tl_track=self.get_current_tl_track(), + time_position=self.get_time_position()) def _trigger_track_playback_started(self): logger.debug('Triggering track playback started event') - if self.current_tl_track is None: + if self.get_current_tl_track() is None: return listener.CoreListener.send( 'track_playback_started', - tl_track=self.current_tl_track) + tl_track=self.get_current_tl_track()) def _trigger_track_playback_ended(self, time_position_before_stop): logger.debug('Triggering track playback ended event') - if self.current_tl_track is None: + if self.get_current_tl_track() is None: return listener.CoreListener.send( 'track_playback_ended', - tl_track=self.current_tl_track, + tl_track=self.get_current_tl_track(), time_position=time_position_before_stop) def _trigger_playback_state_changed(self, old_state, new_state): diff --git a/mopidy/core/playlists.py b/mopidy/core/playlists.py index d5c03bb3..669e1f35 100644 --- a/mopidy/core/playlists.py +++ b/mopidy/core/playlists.py @@ -1,11 +1,16 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals -import itertools +import logging import urlparse import pykka -from . import listener +from mopidy.core import listener +from mopidy.models import Playlist +from mopidy.utils.deprecation import deprecated_property + + +logger = logging.getLogger(__name__) class PlaylistsController(object): @@ -15,20 +20,83 @@ class PlaylistsController(object): self.backends = backends self.core = core + def as_list(self): + """ + Get a list of the currently available playlists. + + Returns a list of :class:`~mopidy.models.Ref` objects referring to the + playlists. In other words, no information about the playlists' content + is given. + + :rtype: list of :class:`mopidy.models.Ref` + + .. versionadded:: 1.0 + """ + futures = { + b.actor_ref.actor_class.__name__: b.playlists.as_list() + for b in set(self.backends.with_playlists.values())} + + results = [] + for backend_name, future in futures.items(): + try: + results.extend(future.get()) + except NotImplementedError: + logger.warning( + '%s does not implement playlists.as_list(). ' + 'Please upgrade it.', backend_name) + + return results + + def get_items(self, uri): + """ + Get the items in a playlist specified by ``uri``. + + Returns a list of :class:`~mopidy.models.Ref` objects referring to the + playlist's items. + + If a playlist with the given ``uri`` doesn't exist, it returns + :class:`None`. + + :rtype: list of :class:`mopidy.models.Ref`, or :class:`None` + + .. versionadded:: 1.0 + """ + uri_scheme = urlparse.urlparse(uri).scheme + backend = self.backends.with_playlists.get(uri_scheme, None) + if backend: + return backend.playlists.get_items(uri).get() + def get_playlists(self, include_tracks=True): - futures = [b.playlists.playlists - for b in self.backends.with_playlists.values()] - results = pykka.get_all(futures) - playlists = list(itertools.chain(*results)) - if not include_tracks: - playlists = [p.copy(tracks=[]) for p in playlists] - return playlists + """ + Get the available playlists. - playlists = property(get_playlists) + :rtype: list of :class:`mopidy.models.Playlist` + + .. versionchanged:: 1.0 + If you call the method with ``include_tracks=False``, the + :attr:`~mopidy.models.Playlist.last_modified` field of the returned + playlists is no longer set. + + .. deprecated:: 1.0 + Use :meth:`as_list` and :meth:`get_items` instead. + """ + playlist_refs = self.as_list() + + if include_tracks: + playlists = {r.uri: self.lookup(r.uri) for r in playlist_refs} + # Use the playlist name from as_list() because it knows about any + # playlist folder hierarchy, which lookup() does not. + return [ + playlists[r.uri].copy(name=r.name) + for r in playlist_refs if playlists[r.uri] is not None] + else: + return [ + Playlist(uri=r.uri, name=r.name) for r in playlist_refs] + + playlists = deprecated_property(get_playlists) """ - The available playlists. - - Read-only. List of :class:`mopidy.models.Playlist`. + .. deprecated:: 1.0 + Use :meth:`as_list` and :meth:`get_items` instead. """ def create(self, name, uri_scheme=None): @@ -40,7 +108,7 @@ class PlaylistsController(object): :class:`None` or doesn't match a current backend, the first backend is asked to create the playlist. - All new playlists should be created by calling this method, and **not** + All new playlists must be created by calling this method, and **not** by creating new instances of :class:`mopidy.models.Playlist`. :param name: name of the new playlist @@ -53,7 +121,7 @@ class PlaylistsController(object): backend = self.backends.with_playlists[uri_scheme] else: # TODO: this fallback looks suspicious - backend = self.backends.with_playlists.values()[0] + backend = list(self.backends.with_playlists.values())[0] playlist = backend.playlists.create(name).get() listener.CoreListener.send('playlist_changed', playlist=playlist) return playlist @@ -94,6 +162,9 @@ class PlaylistsController(object): :param criteria: one or more criteria to match by :type criteria: dict :rtype: list of :class:`mopidy.models.Playlist` + + .. deprecated:: 1.0 + Use :meth:`as_list` and filter yourself. """ criteria = criteria or kwargs matches = self.playlists @@ -145,14 +216,14 @@ class PlaylistsController(object): Save the playlist. For a playlist to be saveable, it must have the ``uri`` attribute set. - You should not set the ``uri`` atribute yourself, but use playlist + You must not set the ``uri`` atribute yourself, but use playlist objects returned by :meth:`create` or retrieved from :attr:`playlists`, which will always give you saveable playlists. The method returns the saved playlist. The return playlist may differ from the saved playlist. E.g. if the playlist name was changed, the returned playlist may have a different URI. The caller of this method - should throw away the playlist sent to this method, and use the + must throw away the playlist sent to this method, and use the returned playlist instead. If the playlist's URI isn't set or doesn't match the URI scheme of a diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 80ed5aea..9186ae42 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -1,11 +1,13 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import collections import logging import random +from mopidy import compat from mopidy.core import listener from mopidy.models import TlTrack +from mopidy.utils.deprecation import deprecated_property logger = logging.getLogger(__name__) @@ -25,114 +27,176 @@ class TracklistController(object): # Properties def get_tl_tracks(self): + """Get tracklist as list of :class:`mopidy.models.TlTrack`.""" return self._tl_tracks[:] - tl_tracks = property(get_tl_tracks) + tl_tracks = deprecated_property(get_tl_tracks) """ - List of :class:`mopidy.models.TlTrack`. - - Read-only. + .. deprecated:: 1.0 + Use :meth:`get_tl_tracks` instead. """ def get_tracks(self): + """Get tracklist as list of :class:`mopidy.models.Track`.""" return [tl_track.track for tl_track in self._tl_tracks] - tracks = property(get_tracks) + tracks = deprecated_property(get_tracks) """ - List of :class:`mopidy.models.Track` in the tracklist. - - Read-only. + .. deprecated:: 1.0 + Use :meth:`get_tracks` instead. """ def get_length(self): + """Get length of the tracklist.""" return len(self._tl_tracks) - length = property(get_length) - """Length of the tracklist.""" + length = deprecated_property(get_length) + """ + .. deprecated:: 1.0 + Use :meth:`get_length` instead. + """ def get_version(self): + """ + Get the tracklist version. + + Integer which is increased every time the tracklist is changed. Is not + reset before Mopidy is restarted. + """ return self._version def _increase_version(self): self._version += 1 - self.core.playback.on_tracklist_change() + self.core.playback._on_tracklist_change() self._trigger_tracklist_changed() - version = property(get_version) + version = deprecated_property(get_version) """ - The tracklist version. - - Read-only. Integer which is increased every time the tracklist is changed. - Is not reset before Mopidy is restarted. + .. deprecated:: 1.0 + Use :meth:`get_version` instead. """ def get_consume(self): + """Get consume mode. + + :class:`True` + Tracks are removed from the tracklist when they have been played. + :class:`False` + Tracks are not removed from the tracklist. + """ return getattr(self, '_consume', False) def set_consume(self, value): + """Set consume mode. + + :class:`True` + Tracks are removed from the tracklist when they have been played. + :class:`False` + Tracks are not removed from the tracklist. + """ if self.get_consume() != value: self._trigger_options_changed() return setattr(self, '_consume', value) - consume = property(get_consume, set_consume) + consume = deprecated_property(get_consume, set_consume) """ - :class:`True` - Tracks are removed from the tracklist when they have been played. - :class:`False` - Tracks are not removed from the tracklist. + .. deprecated:: 1.0 + Use :meth:`get_consume` and :meth:`set_consume` instead. """ def get_random(self): + """Get random mode. + + :class:`True` + Tracks are selected at random from the tracklist. + :class:`False` + Tracks are played in the order of the tracklist. + """ return getattr(self, '_random', False) def set_random(self, value): + """Set random mode. + + :class:`True` + Tracks are selected at random from the tracklist. + :class:`False` + Tracks are played in the order of the tracklist. + """ + if self.get_random() != value: self._trigger_options_changed() if value: - self._shuffled = self.tl_tracks + self._shuffled = self.get_tl_tracks() random.shuffle(self._shuffled) return setattr(self, '_random', value) - random = property(get_random, set_random) + random = deprecated_property(get_random, set_random) """ - :class:`True` - Tracks are selected at random from the tracklist. - :class:`False` - Tracks are played in the order of the tracklist. + .. deprecated:: 1.0 + Use :meth:`get_random` and :meth:`set_random` instead. """ def get_repeat(self): + """ + Get repeat mode. + + :class:`True` + The tracklist is played repeatedly. + :class:`False` + The tracklist is played once. + """ return getattr(self, '_repeat', False) def set_repeat(self, value): + """ + Set repeat mode. + + To repeat a single track, set both ``repeat`` and ``single``. + + :class:`True` + The tracklist is played repeatedly. + :class:`False` + The tracklist is played once. + """ + if self.get_repeat() != value: self._trigger_options_changed() return setattr(self, '_repeat', value) - repeat = property(get_repeat, set_repeat) + repeat = deprecated_property(get_repeat, set_repeat) """ - :class:`True` - The tracklist is played repeatedly. To repeat a single track, select - both :attr:`repeat` and :attr:`single`. - :class:`False` - The tracklist is played once. + .. deprecated:: 1.0 + Use :meth:`get_repeat` and :meth:`set_repeat` instead. """ def get_single(self): + """ + Get single mode. + + :class:`True` + Playback is stopped after current song, unless in ``repeat`` mode. + :class:`False` + Playback continues after current song. + """ return getattr(self, '_single', False) def set_single(self, value): + """ + Set single mode. + + :class:`True` + Playback is stopped after current song, unless in ``repeat`` mode. + :class:`False` + Playback continues after current song. + """ if self.get_single() != value: self._trigger_options_changed() return setattr(self, '_single', value) - single = property(get_single, set_single) + single = deprecated_property(get_single, set_single) """ - :class:`True` - Playback is stopped after current song, unless in :attr:`repeat` - mode. - :class:`False` - Playback continues after current song. + .. deprecated:: 1.0 + Use :meth:`get_single` and :meth:`set_single` instead. """ # Methods @@ -160,9 +224,9 @@ class TracklistController(object): :type tl_track: :class:`mopidy.models.TlTrack` or :class:`None` :rtype: :class:`mopidy.models.TlTrack` or :class:`None` """ - if self.single and self.repeat: + if self.get_single() and self.get_repeat(): return tl_track - elif self.single: + elif self.get_single(): return None # Current difference between next and EOT handling is that EOT needs to @@ -185,30 +249,30 @@ class TracklistController(object): :rtype: :class:`mopidy.models.TlTrack` or :class:`None` """ - if not self.tl_tracks: + if not self.get_tl_tracks(): return None - if self.random and not self._shuffled: - if self.repeat or not tl_track: + if self.get_random() and not self._shuffled: + if self.get_repeat() or not tl_track: logger.debug('Shuffling tracks') - self._shuffled = self.tl_tracks + self._shuffled = self.get_tl_tracks() random.shuffle(self._shuffled) - if self.random: + if self.get_random(): try: return self._shuffled[0] except IndexError: return None if tl_track is None: - return self.tl_tracks[0] + return self.get_tl_tracks()[0] next_index = self.index(tl_track) + 1 - if self.repeat: - next_index %= len(self.tl_tracks) + if self.get_repeat(): + next_index %= len(self.get_tl_tracks()) try: - return self.tl_tracks[next_index] + return self.get_tl_tracks()[next_index] except IndexError: return None @@ -225,7 +289,7 @@ class TracklistController(object): :type tl_track: :class:`mopidy.models.TlTrack` or :class:`None` :rtype: :class:`mopidy.models.TlTrack` or :class:`None` """ - if self.repeat or self.consume or self.random: + if self.get_repeat() or self.get_consume() or self.get_random(): return tl_track position = self.index(tl_track) @@ -233,15 +297,18 @@ class TracklistController(object): if position in (None, 0): return None - return self.tl_tracks[position - 1] + return self.get_tl_tracks()[position - 1] - def add(self, tracks=None, at_position=None, uri=None): + def add(self, tracks=None, at_position=None, uri=None, uris=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 ``uris`` is given instead of ``tracks``, the URIs are 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. @@ -255,12 +322,24 @@ class TracklistController(object): :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) + .. versionadded:: 1.0 + The ``uris`` argument. + + .. deprecated:: 1.0 + The ``tracks`` and ``uri`` arguments. Use ``uris``. + """ + assert tracks is not None or uri is not None or uris is not None, \ + 'tracks, uri or uris must be provided' + + if tracks is None: + if uri is not None: + tracks = self.core.library.lookup(uri=uri) + elif uris is not None: + tracks = [] + track_map = self.core.library.lookup(uris=uris) + for uri in uris: + tracks.extend(track_map[uri]) tl_tracks = [] @@ -327,16 +406,16 @@ class TracklistController(object): """ criteria = criteria or kwargs matches = self._tl_tracks - for (key, values) in criteria.iteritems(): - if (not isinstance(values, collections.Iterable) - or isinstance(values, basestring)): + for (key, values) in criteria.items(): + if (not isinstance(values, collections.Iterable) or + isinstance(values, compat.string_types)): # Fail hard if anyone is using the <0.17 calling style raise ValueError('Filter values must be iterable: %r' % values) if key == 'tlid': - matches = filter(lambda ct: ct.tlid in values, matches) + matches = [ct for ct in matches if ct.tlid in values] else: - matches = filter( - lambda ct: getattr(ct.track, key) in values, matches) + matches = [ + ct for ct in matches if getattr(ct.track, key) in values] return matches def move(self, start, end, to_position): @@ -435,27 +514,27 @@ class TracklistController(object): """ return self._tl_tracks[start:end] - def mark_playing(self, tl_track): - """Private method used by :class:`mopidy.core.PlaybackController`.""" - if self.random and tl_track in self._shuffled: + def _mark_playing(self, tl_track): + """Internal method for :class:`mopidy.core.PlaybackController`.""" + if self.get_random() and tl_track in self._shuffled: self._shuffled.remove(tl_track) - def mark_unplayable(self, tl_track): - """Private method used by :class:`mopidy.core.PlaybackController`.""" + def _mark_unplayable(self, tl_track): + """Internal method for :class:`mopidy.core.PlaybackController`.""" logger.warning('Track is not playable: %s', tl_track.track.uri) - if self.random and tl_track in self._shuffled: + if self.get_random() and tl_track in self._shuffled: self._shuffled.remove(tl_track) - def mark_played(self, tl_track): - """Private method used by :class:`mopidy.core.PlaybackController`.""" + def _mark_played(self, tl_track): + """Internal method for :class:`mopidy.core.PlaybackController`.""" if self.consume and tl_track is not None: self.remove(tlid=[tl_track.tlid]) return True return False def _trigger_tracklist_changed(self): - if self.random: - self._shuffled = self.tl_tracks + if self.get_random(): + self._shuffled = self.get_tl_tracks() random.shuffle(self._shuffled) else: self._shuffled = [] diff --git a/mopidy/exceptions.py b/mopidy/exceptions.py index bf9b6dd9..4c4a0f6d 100644 --- a/mopidy/exceptions.py +++ b/mopidy/exceptions.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals class MopidyException(Exception): @@ -24,6 +24,12 @@ class ExtensionError(MopidyException): pass +class FindError(MopidyException): + def __init__(self, message, errno=None): + super(FindError, self).__init__(message, errno) + self.errno = errno + + class FrontendError(MopidyException): pass @@ -34,3 +40,7 @@ class MixerError(MopidyException): class ScannerError(MopidyException): pass + + +class AudioException(MopidyException): + pass diff --git a/mopidy/ext.py b/mopidy/ext.py index 24d85786..2f02c43b 100644 --- a/mopidy/ext.py +++ b/mopidy/ext.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import collections import logging diff --git a/mopidy/http/__init__.py b/mopidy/http/__init__.py index 95675386..3fa4bcd6 100644 --- a/mopidy/http/__init__.py +++ b/mopidy/http/__init__.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import logging import os diff --git a/mopidy/http/actor.py b/mopidy/http/actor.py index a61fc45c..200ef833 100644 --- a/mopidy/http/actor.py +++ b/mopidy/http/actor.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import json import logging diff --git a/mopidy/http/data/mopidy.js b/mopidy/http/data/mopidy.js index af54f768..7c95a56a 100644 --- a/mopidy/http/data/mopidy.js +++ b/mopidy/http/data/mopidy.js @@ -1,6 +1,6 @@ -/*! Mopidy.js v0.4.0 - built 2014-06-24 +/*! Mopidy.js v0.5.0 - built 2015-01-31 * http://www.mopidy.com/ - * Copyright (c) 2014 Stein Magnus Jodal and contributors + * Copyright (c) 2015 Stein Magnus Jodal and contributors * Licensed under the Apache License, Version 2.0 */ !function(e){if("object"==typeof exports)module.exports=e();else if("function"==typeof define&&define.amd)define(e);else{var f;"undefined"!=typeof window?f=window:"undefined"!=typeof global?f=global:"undefined"!=typeof self&&(f=self),f.Mopidy=e()}}(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);throw new Error("Cannot find module '"+o+"'")}var f=n[o]={exports:{}};t[o][0].call(f.exports,function(e){var n=t[o][1][e];return s(n?n:e)},f,f.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o>>0; - arrayForEach.call(promises, function(p) { - ++pending; - toPromise(p).then(resolve, handleReject); - }); + var pending = l; + var errors = []; - if(pending === 0) { - resolve(); + for (var h, x, i = 0; i < l; ++i) { + x = promises[i]; + if(x === void 0 && !(i in promises)) { + --pending; + continue; } - function handleReject(e) { - errors.push(e); - if(--pending === 0) { - reject(errors); - } + h = Promise._handler(x); + if(h.state() > 0) { + resolver.become(h); + Promise._visitRemaining(promises, i, h); + break; + } else { + h.visit(resolver, handleFulfill, handleReject); } - }); + } + + if(pending === 0) { + resolver.reject(new RangeError('any(): array must not be empty')); + } + + return p; + + function handleFulfill(x) { + /*jshint validthis:true*/ + errors = null; + this.resolve(x); // this === resolver + } + + function handleReject(e) { + /*jshint validthis:true*/ + if(this.resolved) { // this === resolver + return; + } + + errors.push(e); + if(--pending === 0) { + this.reject(errors); + } + } } /** @@ -516,116 +549,181 @@ define(function() { * @deprecated */ function some(promises, n) { - return new Promise(function(resolve, reject, notify) { - var nFulfill = 0; - var nReject; - var results = []; - var errors = []; + /*jshint maxcomplexity:7*/ + var p = Promise._defer(); + var resolver = p._handler; - arrayForEach.call(promises, function(p) { - ++nFulfill; - toPromise(p).then(handleResolve, handleReject, notify); - }); + var results = []; + var errors = []; - n = Math.max(n, 0); - nReject = (nFulfill - n + 1); - nFulfill = Math.min(n, nFulfill); + var l = promises.length>>>0; + var nFulfill = 0; + var nReject; + var x, i; // reused in both for() loops - if(nFulfill === 0) { - resolve(results); + // First pass: count actual array items + for(i=0; i nFulfill) { + resolver.reject(new RangeError('some(): array must contain at least ' + + n + ' item(s), but had ' + nFulfill)); + } else if(nFulfill === 0) { + resolver.resolve(results); + } + + // Second pass: observe each array item, make progress toward goals + for(i=0; i 0) { - --nFulfill; - results.push(x); + results.push(x); + if(--nFulfill === 0) { + errors = null; + this.resolve(results); + } + } - if(nFulfill === 0) { - resolve(results); - } - } + function reject(e) { + /*jshint validthis:true*/ + if(this.resolved) { // this === resolver + return; } - function handleReject(e) { - if(nReject > 0) { - --nReject; - errors.push(e); - - if(nReject === 0) { - reject(errors); - } - } + errors.push(e); + if(--nReject === 0) { + results = null; + this.reject(errors); } - }); + } } /** * Apply f to the value of each promise in a list of promises * and return a new list containing the results. * @param {array} promises - * @param {function} f - * @param {function} fallback + * @param {function(x:*, index:Number):*} f mapping function * @returns {Promise} */ - function map(promises, f, fallback) { - return all(arrayMap.call(promises, function(x) { - return toPromise(x).then(f, fallback); - })); + function map(promises, f) { + return Promise._traverse(f, promises); + } + + /** + * Filter the provided array of promises using the provided predicate. Input may + * contain promises and values + * @param {Array} promises array of promises and values + * @param {function(x:*, index:Number):boolean} predicate filtering predicate. + * Must return truthy (or promise for truthy) for items to retain. + * @returns {Promise} promise that will fulfill with an array containing all items + * for which predicate returned truthy. + */ + function filter(promises, predicate) { + var a = slice.call(promises); + return Promise._traverse(predicate, a).then(function(keep) { + return filterSync(a, keep); + }); + } + + function filterSync(promises, keep) { + // Safe because we know all promises have fulfilled if we've made it this far + var l = keep.length; + var filtered = new Array(l); + for(var i=0, j=0; i 2 - ? arrayReduce.call(promises, reducer, arguments[2]) - : arrayReduce.call(promises, reducer); - - function reducer(result, x, i) { - return toPromise(result).then(function(r) { - return toPromise(x).then(function(x) { - return f(r, x, i); - }); - }); + function settleOne(p) { + var h = Promise._handler(p); + if(h.state() === 0) { + return toPromise(p).then(state.fulfilled, state.rejected); } + + h._unreport(); + return state.inspect(h); } - function reduceRight(promises, f) { - return arguments.length > 2 - ? arrayReduceRight.call(promises, reducer, arguments[2]) - : arrayReduceRight.call(promises, reducer); + /** + * 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} promises array or promise for an array of anything, + * may contain a mix of promises and values. + * @param {function(accumulated:*, x:*, index:Number):*} f reduce function + * @returns {Promise} that will resolve to the final reduced value + */ + function reduce(promises, f /*, initialValue */) { + return arguments.length > 2 ? ar.call(promises, liftCombine(f), arguments[2]) + : ar.call(promises, liftCombine(f)); + } - function reducer(result, x, i) { - return toPromise(result).then(function(r) { - return toPromise(x).then(function(x) { - return f(r, x, i); - }); - }); - } + /** + * Traditional reduce function, similar to `Array.prototype.reduceRight()`, 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} promises array or promise for an array of anything, + * may contain a mix of promises and values. + * @param {function(accumulated:*, x:*, index:Number):*} f reduce function + * @returns {Promise} that will resolve to the final reduced value + */ + function reduceRight(promises, f /*, initialValue */) { + return arguments.length > 2 ? arr.call(promises, liftCombine(f), arguments[2]) + : arr.call(promises, liftCombine(f)); + } + + function liftCombine(f) { + return function(z, x, i) { + return applyFold(f, void 0, [z,x,i]); + }; } }; - }); -}(typeof define === 'function' && define.amd ? define : function(factory) { module.exports = factory(); })); +}(typeof define === 'function' && define.amd ? define : function(factory) { module.exports = factory(_dereq_); })); -},{}],9:[function(_dereq_,module,exports){ +},{"../apply":7,"../state":20}],9:[function(_dereq_,module,exports){ /** @license MIT License (c) copyright 2010-2014 original author or authors */ /** @author Brian Cavalier */ /** @author John Hann */ @@ -635,6 +733,7 @@ define(function() { return function flow(Promise) { + var resolve = Promise.resolve; var reject = Promise.reject; var origCatch = Promise.prototype['catch']; @@ -648,10 +747,7 @@ define(function() { * @returns {undefined} */ Promise.prototype.done = function(onResult, onError) { - var h = this._handler; - h.when({ resolve: this._maybeFatal, notify: noop, context: this, - receiver: h.receiver, fulfilled: onResult, rejected: onError, - progress: void 0 }); + this._handler.visit(this._handler.receiver, onResult, onError); }; /** @@ -663,15 +759,15 @@ define(function() { * @returns {*} */ Promise.prototype['catch'] = Promise.prototype.otherwise = function(onRejected) { - if (arguments.length === 1) { + if (arguments.length < 2) { return origCatch.call(this, onRejected); - } else { - if(typeof onRejected !== 'function') { - return this.ensure(rejectInvalidPredicate); - } - - return origCatch.call(this, createCatchFilter(arguments[1], onRejected)); } + + if(typeof onRejected !== 'function') { + return this.ensure(rejectInvalidPredicate); + } + + return origCatch.call(this, createCatchFilter(arguments[1], onRejected)); }; /** @@ -701,14 +797,29 @@ define(function() { */ Promise.prototype['finally'] = Promise.prototype.ensure = function(handler) { if(typeof handler !== 'function') { - // Optimization: result will not change, return same promise return this; } - handler = isolate(handler, this); - return this.then(handler, handler); + return this.then(function(x) { + return runSideEffect(handler, this, identity, x); + }, function(e) { + return runSideEffect(handler, this, reject, e); + }); }; + function runSideEffect (handler, thisArg, propagate, value) { + var result = handler.call(thisArg); + return maybeThenable(result) + ? propagateValue(result, propagate, value) + : propagate(value); + } + + function propagateValue (result, propagate, x) { + return resolve(result).then(function () { + return propagate(x); + }); + } + /** * Recover from a failure by returning a defaultValue. If defaultValue * is a promise, it's fulfillment value will be used. If defaultValue is @@ -763,15 +874,13 @@ define(function() { || (predicate != null && predicate.prototype instanceof Error); } - // prevent argument passing to f and ignore return value - function isolate(f, x) { - return function() { - f.call(this); - return x; - }; + function maybeThenable(x) { + return (typeof x === 'object' || typeof x === 'function') && x !== null; } - function noop() {} + function identity(x) { + return x; + } }); }(typeof define === 'function' && define.amd ? define : function(factory) { module.exports = factory(); })); @@ -787,9 +896,15 @@ define(function() { return function fold(Promise) { - Promise.prototype.fold = function(fn, arg) { + Promise.prototype.fold = function(f, z) { var promise = this._beget(); - this._handler.fold(promise._handler, fn, arg); + + this._handler.fold(function(z, x, to) { + Promise._handler(z).fold(function(x, z, to) { + to.resolve(f.call(this, z, x)); + }, x, this, to); + }, z, promise._handler.receiver, promise._handler); + return promise; }; @@ -805,21 +920,23 @@ define(function() { /** @author John Hann */ (function(define) { 'use strict'; -define(function() { +define(function(_dereq_) { - return function inspect(Promise) { + var inspect = _dereq_('../state').inspect; + + return function inspection(Promise) { Promise.prototype.inspect = function() { - return this._handler.inspect(); + return inspect(Promise._handler(this)); }; return Promise; }; }); -}(typeof define === 'function' && define.amd ? define : function(factory) { module.exports = factory(); })); +}(typeof define === 'function' && define.amd ? define : function(factory) { module.exports = factory(_dereq_); })); -},{}],12:[function(_dereq_,module,exports){ +},{"../state":20}],12:[function(_dereq_,module,exports){ /** @license MIT License (c) copyright 2010-2014 original author or authors */ /** @author Brian Cavalier */ /** @author John Hann */ @@ -837,6 +954,7 @@ define(function() { return Promise; /** + * @deprecated Use github.com/cujojs/most streams and most.iterate * Generate a (potentially infinite) stream of promised values: * x, f(x), f(f(x)), etc. until condition(x) returns true * @param {function} f function to generate a new x from the previous x @@ -854,6 +972,7 @@ define(function() { } /** + * @deprecated Use github.com/cujojs/most streams and most.unfold * Generate a (potentially infinite) stream of promised values * by applying handler(generator(seed)) iteratively until * condition(seed) returns true. @@ -895,6 +1014,7 @@ define(function() { return function progress(Promise) { /** + * @deprecated * Register a progress handler for this promise * @param {function} onProgress * @returns {Promise} @@ -917,9 +1037,15 @@ define(function() { (function(define) { 'use strict'; define(function(_dereq_) { - var timer = _dereq_('../timer'); + var env = _dereq_('../env'); var TimeoutError = _dereq_('../TimeoutError'); + function setTimeout(f, ms, x, y) { + return env.setTimer(function() { + f(x, y, ms); + }, ms); + } + return function timed(Promise) { /** * Return a new promise whose fulfillment value is revealed only @@ -929,57 +1055,61 @@ define(function(_dereq_) { */ Promise.prototype.delay = function(ms) { var p = this._beget(); - var h = p._handler; - - this._handler.map(function delay(x) { - timer.set(function() { h.resolve(x); }, ms); - }, h); - + this._handler.fold(handleDelay, ms, void 0, p._handler); return p; }; + function handleDelay(ms, x, h) { + setTimeout(resolveDelay, ms, x, h); + } + + function resolveDelay(x, h) { + h.resolve(x); + } + /** * Return a new promise that rejects after ms milliseconds unless * this promise fulfills earlier, in which case the returned promise * fulfills with the same value. * @param {number} ms milliseconds * @param {Error|*=} reason optional rejection reason to use, defaults - * to an Error if not provided + * to a TimeoutError if not provided * @returns {Promise} */ Promise.prototype.timeout = function(ms, reason) { - var hasReason = arguments.length > 1; var p = this._beget(); var h = p._handler; - var t = timer.set(onTimeout, ms); + var t = setTimeout(onTimeout, ms, reason, p._handler); - this._handler.chain(h, + this._handler.visit(h, function onFulfill(x) { - timer.clear(t); - this.resolve(x); // this = p._handler + env.clearTimer(t); + this.resolve(x); // this = h }, function onReject(x) { - timer.clear(t); - this.reject(x); // this = p._handler + env.clearTimer(t); + this.reject(x); // this = h }, h.notify); return p; - - function onTimeout() { - h.reject(hasReason - ? reason : new TimeoutError('timed out after ' + ms + 'ms')); - } }; + function onTimeout(reason, h, ms) { + var e = typeof reason === 'undefined' + ? new TimeoutError('timed out after ' + ms + 'ms') + : reason; + h.reject(e); + } + return Promise; }; }); }(typeof define === 'function' && define.amd ? define : function(factory) { module.exports = factory(_dereq_); })); -},{"../TimeoutError":6,"../timer":19}],15:[function(_dereq_,module,exports){ +},{"../TimeoutError":6,"../env":17}],15:[function(_dereq_,module,exports){ /** @license MIT License (c) copyright 2010-2014 original author or authors */ /** @author Brian Cavalier */ /** @author John Hann */ @@ -987,20 +1117,27 @@ define(function(_dereq_) { (function(define) { 'use strict'; define(function(_dereq_) { - var timer = _dereq_('../timer'); + var setTimer = _dereq_('../env').setTimer; + var format = _dereq_('../format'); return function unhandledRejection(Promise) { + var logError = noop; var logInfo = noop; + var localConsole; if(typeof console !== 'undefined') { - logError = typeof console.error !== 'undefined' - ? function (e) { console.error(e); } - : function (e) { console.log(e); }; + // Alias console to prevent things like uglify's drop_console option from + // removing console.log/error. Unhandled rejections fall into the same + // category as uncaught exceptions, and build tools shouldn't silence them. + localConsole = console; + logError = typeof localConsole.error !== 'undefined' + ? function (e) { localConsole.error(e); } + : function (e) { localConsole.log(e); }; - logInfo = typeof console.info !== 'undefined' - ? function (e) { console.info(e); } - : function (e) { console.log(e); }; + logInfo = typeof localConsole.info !== 'undefined' + ? function (e) { localConsole.info(e); } + : function (e) { localConsole.log(e); }; } Promise.onPotentiallyUnhandledRejection = function(rejection) { @@ -1017,12 +1154,12 @@ define(function(_dereq_) { var tasks = []; var reported = []; - var running = false; + var running = null; function report(r) { if(!r.handled) { reported.push(r); - logError('Potentially unhandled rejection [' + r.id + '] ' + formatError(r.value)); + logError('Potentially unhandled rejection [' + r.id + '] ' + format.formatError(r.value)); } } @@ -1030,20 +1167,19 @@ define(function(_dereq_) { var i = reported.indexOf(r); if(i >= 0) { reported.splice(i, 1); - logInfo('Handled previous rejection [' + r.id + '] ' + formatObject(r.value)); + logInfo('Handled previous rejection [' + r.id + '] ' + format.formatObject(r.value)); } } function enqueue(f, x) { tasks.push(f, x); - if(!running) { - running = true; - running = timer.set(flush, 0); + if(running === null) { + running = setTimer(flush, 0); } } function flush() { - running = false; + running = null; while(tasks.length > 0) { tasks.shift()(tasks.shift()); } @@ -1052,28 +1188,6 @@ define(function(_dereq_) { return Promise; }; - function formatError(e) { - var s = typeof e === 'object' && e.stack ? e.stack : formatObject(e); - return e instanceof Error ? s : s + ' (WARNING: non-Error used)'; - } - - function formatObject(o) { - var s = String(o); - if(s === '[object Object]' && typeof JSON !== 'undefined') { - s = tryStringify(o, s); - } - return s; - } - - function tryStringify(e, defaultValue) { - try { - return JSON.stringify(e); - } catch(e) { - // Ignore. Cannot JSON.stringify e, stick with String(e) - return defaultValue; - } - } - function throwit(e) { throw e; } @@ -1083,7 +1197,7 @@ define(function(_dereq_) { }); }(typeof define === 'function' && define.amd ? define : function(factory) { module.exports = factory(_dereq_); })); -},{"../timer":19}],16:[function(_dereq_,module,exports){ +},{"../env":17,"../format":18}],16:[function(_dereq_,module,exports){ /** @license MIT License (c) copyright 2010-2014 original author or authors */ /** @author Brian Cavalier */ /** @author John Hann */ @@ -1094,21 +1208,27 @@ define(function() { return function addWith(Promise) { /** * Returns a promise whose handlers will be called with `this` set to - * the supplied `thisArg`. Subsequent promises derived from the - * returned promise will also have their handlers called with `thisArg`. - * Calling `with` with undefined or no arguments will return a promise - * whose handlers will again be called in the usual Promises/A+ way (no `this`) - * thus safely undoing any previous `with` in the promise chain. + * the supplied receiver. Subsequent promises derived from the + * returned promise will also have their handlers called with receiver + * as `this`. Calling `with` with undefined or no arguments will return + * a promise whose handlers will again be called in the usual Promises/A+ + * way (no `this`) thus safely undoing any previous `with` in the + * promise chain. * * WARNING: Promises returned from `with`/`withThis` are NOT Promises/A+ * compliant, specifically violating 2.2.5 (http://promisesaplus.com/#point-41) * - * @param {object} thisArg `this` value for all handlers attached to + * @param {object} receiver `this` value for all handlers attached to * the returned promise. * @returns {Promise} */ - Promise.prototype['with'] = Promise.prototype.withThis - = Promise.prototype._bindContext; + Promise.prototype['with'] = Promise.prototype.withThis = function(receiver) { + var p = this._beget(); + var child = p._handler; + child.receiver = receiver; + this._handler.chain(child, receiver); + return p; + }; return Promise; }; @@ -1118,6 +1238,142 @@ define(function() { },{}],17:[function(_dereq_,module,exports){ +(function (process){ +/** @license MIT License (c) copyright 2010-2014 original author or authors */ +/** @author Brian Cavalier */ +/** @author John Hann */ + +/*global process,document,setTimeout,clearTimeout,MutationObserver,WebKitMutationObserver*/ +(function(define) { 'use strict'; +define(function(_dereq_) { + /*jshint maxcomplexity:6*/ + + // Sniff "best" async scheduling option + // Prefer process.nextTick or MutationObserver, then check for + // setTimeout, and finally vertx, since its the only env that doesn't + // have setTimeout + + var MutationObs; + var capturedSetTimeout = typeof setTimeout !== 'undefined' && setTimeout; + + // Default env + var setTimer = function(f, ms) { return setTimeout(f, ms); }; + var clearTimer = function(t) { return clearTimeout(t); }; + var asap = function (f) { return capturedSetTimeout(f, 0); }; + + // Detect specific env + if (isNode()) { // Node + asap = function (f) { return process.nextTick(f); }; + + } else if (MutationObs = hasMutationObserver()) { // Modern browser + asap = initMutationObserver(MutationObs); + + } else if (!capturedSetTimeout) { // vert.x + var vertxRequire = _dereq_; + var vertx = vertxRequire('vertx'); + setTimer = function (f, ms) { return vertx.setTimer(ms, f); }; + clearTimer = vertx.cancelTimer; + asap = vertx.runOnLoop || vertx.runOnContext; + } + + return { + setTimer: setTimer, + clearTimer: clearTimer, + asap: asap + }; + + function isNode () { + return typeof process !== 'undefined' && process !== null && + typeof process.nextTick === 'function'; + } + + function hasMutationObserver () { + return (typeof MutationObserver === 'function' && MutationObserver) || + (typeof WebKitMutationObserver === 'function' && WebKitMutationObserver); + } + + function initMutationObserver(MutationObserver) { + var scheduled; + var node = document.createTextNode(''); + var o = new MutationObserver(run); + o.observe(node, { characterData: true }); + + function run() { + var f = scheduled; + scheduled = void 0; + f(); + } + + var i = 0; + return function (f) { + scheduled = f; + node.data = (i ^= 1); + }; + } +}); +}(typeof define === 'function' && define.amd ? define : function(factory) { module.exports = factory(_dereq_); })); + +}).call(this,_dereq_("FWaASH")) +},{"FWaASH":3}],18:[function(_dereq_,module,exports){ +/** @license MIT License (c) copyright 2010-2014 original author or authors */ +/** @author Brian Cavalier */ +/** @author John Hann */ + +(function(define) { 'use strict'; +define(function() { + + return { + formatError: formatError, + formatObject: formatObject, + tryStringify: tryStringify + }; + + /** + * Format an error into a string. If e is an Error and has a stack property, + * it's returned. Otherwise, e is formatted using formatObject, with a + * warning added about e not being a proper Error. + * @param {*} e + * @returns {String} formatted string, suitable for output to developers + */ + function formatError(e) { + var s = typeof e === 'object' && e !== null && e.stack ? e.stack : formatObject(e); + return e instanceof Error ? s : s + ' (WARNING: non-Error used)'; + } + + /** + * Format an object, detecting "plain" objects and running them through + * JSON.stringify if possible. + * @param {Object} o + * @returns {string} + */ + function formatObject(o) { + var s = String(o); + if(s === '[object Object]' && typeof JSON !== 'undefined') { + s = tryStringify(o, s); + } + return s; + } + + /** + * Try to return the result of JSON.stringify(x). If that fails, return + * defaultValue + * @param {*} x + * @param {*} defaultValue + * @returns {String|*} JSON.stringify(x) or defaultValue + */ + function tryStringify(x, defaultValue) { + try { + return JSON.stringify(x); + } catch(e) { + return defaultValue; + } + } + +}); +}(typeof define === 'function' && define.amd ? define : function(factory) { module.exports = factory(); })); + +},{}],19:[function(_dereq_,module,exports){ +(function (process){ /** @license MIT License (c) copyright 2010-2014 original author or authors */ /** @author Brian Cavalier */ /** @author John Hann */ @@ -1128,6 +1384,7 @@ define(function() { return function makePromise(environment) { var tasks = environment.scheduler; + var emitRejection = initEmitRejection(); var objectCreate = Object.create || function(proto) { @@ -1149,10 +1406,10 @@ define(function() { /** * Run the supplied resolver * @param resolver - * @returns {makePromise.DeferredHandler} + * @returns {Pending} */ function init(resolver) { - var handler = new DeferredHandler(); + var handler = new Pending(); try { resolver(promiseResolve, promiseReject, promiseNotify); @@ -1180,6 +1437,7 @@ define(function() { } /** + * @deprecated * Issue a progress event, notifying all progress listeners * @param {*} x progress event payload to pass to all listeners */ @@ -1195,6 +1453,7 @@ define(function() { Promise.never = never; Promise._defer = defer; + Promise._handler = getHandler; /** * Returns a trusted promise. If x is already a trusted promise, it is @@ -1204,7 +1463,7 @@ define(function() { */ function resolve(x) { return isPromise(x) ? x - : new Promise(Handler, new AsyncHandler(getHandler(x))); + : new Promise(Handler, new Async(getHandler(x))); } /** @@ -1213,7 +1472,7 @@ define(function() { * @returns {Promise} rejected promise */ function reject(x) { - return new Promise(Handler, new AsyncHandler(new RejectedHandler(x))); + return new Promise(Handler, new Async(new Rejected(x))); } /** @@ -1230,7 +1489,7 @@ define(function() { * @returns {Promise} */ function defer() { - return new Promise(Handler, new DeferredHandler()); + return new Promise(Handler, new Pending()); } // Transformation and flow control @@ -1242,29 +1501,23 @@ define(function() { * this promise's fulfillment. * @param {function=} onFulfilled fulfillment handler * @param {function=} onRejected rejection handler - * @deprecated @param {function=} onProgress progress handler + * @param {function=} onProgress @deprecated progress handler * @return {Promise} new promise */ - Promise.prototype.then = function(onFulfilled, onRejected) { + Promise.prototype.then = function(onFulfilled, onRejected, onProgress) { var parent = this._handler; + var state = parent.join().state(); - if (typeof onFulfilled !== 'function' && parent.join().state() > 0) { + if ((typeof onFulfilled !== 'function' && state > 0) || + (typeof onRejected !== 'function' && state < 0)) { // Short circuit: value will not change, simply share handler - return new Promise(Handler, parent); + return new this.constructor(Handler, parent); } var p = this._beget(); var child = p._handler; - parent.when({ - resolve: child.resolve, - notify: child.notify, - context: child, - receiver: parent.receiver, - fulfilled: onFulfilled, - rejected: onRejected, - progress: arguments.length > 2 ? arguments[2] : void 0 - }); + parent.chain(child, parent.receiver, onFulfilled, onRejected, onProgress); return p; }; @@ -1279,49 +1532,25 @@ define(function() { return this.then(void 0, onRejected); }; - /** - * Private function to bind a thisArg for this promise's handlers - * @private - * @param {object} thisArg `this` value for all handlers attached to - * the returned promise. - * @returns {Promise} - */ - Promise.prototype._bindContext = function(thisArg) { - return new Promise(Handler, new BoundHandler(this._handler, thisArg)); - }; - /** * Creates a new, pending promise of the same type as this promise * @private * @returns {Promise} */ Promise.prototype._beget = function() { - var parent = this._handler; - var child = new DeferredHandler(parent.receiver, parent.join().context); - return new this.constructor(Handler, child); + return begetFrom(this._handler, this.constructor); }; - /** - * Check if x is a rejected promise, and if so, delegate to handler._fatal - * @private - * @param {*} x - */ - Promise.prototype._maybeFatal = function(x) { - if(!maybeThenable(x)) { - return; - } - - var handler = getHandler(x); - var context = this._handler.context; - handler.catchError(function() { - this._fatal(context); - }, handler); - }; + function begetFrom(parent, Promise) { + var child = new Pending(parent.receiver, parent.join().context); + return new Promise(Handler, child); + } // Array combinators Promise.all = all; Promise.race = race; + Promise._traverse = traverse; /** * Return a promise that will fulfill when all promises in the @@ -1331,13 +1560,28 @@ define(function() { * @returns {Promise} promise for array of fulfillment values */ function all(promises) { - /*jshint maxcomplexity:8*/ - var resolver = new DeferredHandler(); + return traverseWith(snd, null, promises); + } + + /** + * Array> -> Promise> + * @private + * @param {function} f function to apply to each promise's value + * @param {Array} promises array of promises + * @returns {Promise} promise for transformed values + */ + function traverse(f, promises) { + return traverseWith(tryCatch2, f, promises); + } + + function traverseWith(tryMap, f, promises) { + var handler = typeof f === 'function' ? mapAt : settleAt; + + var resolver = new Pending(); var pending = promises.length >>> 0; var results = new Array(pending); - var i, h, x, s; - for (i = 0; i < promises.length; ++i) { + for (var i = 0, x; i < promises.length && !resolver.resolved; ++i) { x = promises[i]; if (x === void 0 && !(i in promises)) { @@ -1345,40 +1589,64 @@ define(function() { continue; } - if (maybeThenable(x)) { - h = isPromise(x) - ? x._handler.join() - : getHandlerUntrusted(x); - - s = h.state(); - if (s === 0) { - resolveOne(resolver, results, h, i); - } else if (s > 0) { - results[i] = h.value; - --pending; - } else { - resolver.become(h); - break; - } - - } else { - results[i] = x; - --pending; - } + traverseAt(promises, handler, i, x, resolver); } if(pending === 0) { - resolver.become(new FulfilledHandler(results)); + resolver.become(new Fulfilled(results)); } return new Promise(Handler, resolver); - function resolveOne(resolver, results, handler, i) { - handler.map(function(x) { - results[i] = x; - if(--pending === 0) { - this.become(new FulfilledHandler(results)); - } - }, resolver); + + function mapAt(i, x, resolver) { + if(!resolver.resolved) { + traverseAt(promises, settleAt, i, tryMap(f, x, i), resolver); + } + } + + function settleAt(i, x, resolver) { + results[i] = x; + if(--pending === 0) { + resolver.become(new Fulfilled(results)); + } + } + } + + function traverseAt(promises, handler, i, x, resolver) { + if (maybeThenable(x)) { + var h = getHandlerMaybeThenable(x); + var s = h.state(); + + if (s === 0) { + h.fold(handler, i, void 0, resolver); + } else if (s > 0) { + handler(i, h.value, resolver); + } else { + resolver.become(h); + visitRemaining(promises, i+1, h); + } + } else { + handler(i, x, resolver); + } + } + + Promise._visitRemaining = visitRemaining; + function visitRemaining(promises, start, handler) { + for(var i=start; i 0) { - q.shift().run(); - } - - this._running = false; - - q = this._afterQueue; - while(q.length > 0) { - q.shift()(q.shift(), q.shift()); - } - }; - - return Scheduler; - -}); -}(typeof define === 'function' && define.amd ? define : function(factory) { module.exports = factory(_dereq_); })); - -},{"./Queue":5}],19:[function(_dereq_,module,exports){ -/** @license MIT License (c) copyright 2010-2014 original author or authors */ -/** @author Brian Cavalier */ -/** @author John Hann */ - -(function(define) { 'use strict'; -define(function(_dereq_) { - /*global setTimeout,clearTimeout*/ - var cjsRequire, vertx, setTimer, clearTimer; - - cjsRequire = _dereq_; - - try { - vertx = cjsRequire('vertx'); - setTimer = function (f, ms) { return vertx.setTimer(ms, f); }; - clearTimer = vertx.cancelTimer; - } catch (e) { - setTimer = function(f, ms) { return setTimeout(f, ms); }; - clearTimer = function(t) { return clearTimeout(t); }; - } +define(function() { return { - set: setTimer, - clear: clearTimer + pending: toPendingState, + fulfilled: toFulfilledState, + rejected: toRejectedState, + inspect: inspect }; -}); -}(typeof define === 'function' && define.amd ? define : function(factory) { module.exports = factory(_dereq_); })); + function toPendingState() { + return { state: 'pending' }; + } -},{}],20:[function(_dereq_,module,exports){ + function toRejectedState(e) { + return { state: 'rejected', reason: e }; + } + + function toFulfilledState(x) { + return { state: 'fulfilled', value: x }; + } + + function inspect(handler) { + var state = handler.state(); + return state === 0 ? toPendingState() + : state > 0 ? toFulfilledState(handler.value) + : toRejectedState(handler.value); + } + +}); +}(typeof define === 'function' && define.amd ? define : function(factory) { module.exports = factory(); })); + +},{}],21:[function(_dereq_,module,exports){ /** @license MIT License (c) copyright 2010-2014 original author or authors */ /** @@ -2074,7 +2348,7 @@ define(function(_dereq_) { * when is part of the cujoJS family of libraries (http://cujojs.com/) * @author Brian Cavalier * @author John Hann - * @version 3.2.3 + * @version 3.7.2 */ (function(define) { 'use strict'; define(function (_dereq_) { @@ -2096,7 +2370,7 @@ define(function (_dereq_) { return feature(Promise); }, _dereq_('./lib/Promise')); - var slice = Array.prototype.slice; + var apply = _dereq_('./lib/apply')(Promise); // Public API @@ -2108,8 +2382,8 @@ define(function (_dereq_) { when['try'] = attempt; // call a function and return a promise when.attempt = attempt; // alias for when.try - when.iterate = Promise.iterate; // Generate a stream of promises - when.unfold = Promise.unfold; // Generate a stream of promises + when.iterate = Promise.iterate; // DEPRECATED (use cujojs/most streams) Generate a stream of promises + when.unfold = Promise.unfold; // DEPRECATED (use cujojs/most streams) Generate a stream of promises when.join = join; // Join 2 or more promises @@ -2118,10 +2392,12 @@ define(function (_dereq_) { when.any = lift(Promise.any); // One-winner race when.some = lift(Promise.some); // Multi-winner race + when.race = lift(Promise.race); // First-to-settle race when.map = map; // Array.map() for promises - when.reduce = reduce; // Array.reduce() for promises - when.reduceRight = reduceRight; // Array.reduceRight() for promises + when.filter = filter; // Array.filter() for promises + when.reduce = lift(Promise.reduce); // Array.reduce() for promises + when.reduceRight = lift(Promise.reduceRight); // Array.reduceRight() for promises when.isPromiseLike = isPromiseLike; // Is something promise-like, aka thenable @@ -2141,21 +2417,19 @@ define(function (_dereq_) { * will be invoked immediately. * @param {function?} onRejected callback to be called when x is * rejected. - * @deprecated @param {function?} onProgress callback to be called when progress updates - * are issued for x. + * @param {function?} onProgress callback to be called when progress updates + * are issued for x. @deprecated * @returns {Promise} a new promise that will fulfill with the return * value of callback or errback or the completion value of promiseOrValue if * callback and/or errback is not supplied. */ - function when(x, onFulfilled, onRejected) { + function when(x, onFulfilled, onRejected, onProgress) { var p = Promise.resolve(x); - if(arguments.length < 2) { + if (arguments.length < 2) { return p; } - return arguments.length > 3 - ? p.then(onFulfilled, onRejected, arguments[3]) - : p.then(onFulfilled, onRejected); + return p.then(onFulfilled, onRejected, onProgress); } /** @@ -2175,7 +2449,10 @@ define(function (_dereq_) { */ function lift(f) { return function() { - return _apply(f, this, slice.call(arguments)); + for(var i=0, l=arguments.length, a=new Array(l); i0)for(d=0;e>d;++d)c[d](a,b);else setTimeout(function(){throw b.message=a+" listener threw error: "+b.message,b},0)}function b(a){if("function"!=typeof a)throw new TypeError("Listener is not function");return a}function c(a){return a.supervisors||(a.supervisors=[]),a.supervisors}function d(a,b){return a.listeners||(a.listeners={}),b&&!a.listeners[b]&&(a.listeners[b]=[]),b?a.listeners[b]:a.listeners}function e(a){return a.errbacks||(a.errbacks=[]),a.errbacks}function f(f){function h(b,c,d){try{c.listener.apply(c.thisp||f,d)}catch(g){a(b,g,e(f))}}return f=f||{},f.on=function(a,e,f){return"function"==typeof a?c(this).push({listener:a,thisp:e}):void d(this,a).push({listener:b(e),thisp:f})},f.off=function(a,b){var f,g,h,i;if(!a){f=c(this),f.splice(0,f.length),g=d(this);for(h in g)g.hasOwnProperty(h)&&(f=d(this,h),f.splice(0,f.length));return f=e(this),void f.splice(0,f.length)}if("function"==typeof a?(f=c(this),b=a):f=d(this,a),!b)return void f.splice(0,f.length);for(h=0,i=f.length;i>h;++h)if(f[h].listener===b)return void f.splice(h,1)},f.once=function(a,b,c){var d=function(){f.off(a,d),b.apply(this,arguments)};f.on(a,d,c)},f.bind=function(a,b){var c,d,e;if(b)for(d=0,e=b.length;e>d;++d){if("function"!=typeof a[b[d]])throw new Error("No such method "+b[d]);this.on(b[d],a[b[d]],a)}else for(c in a)"function"==typeof a[c]&&this.on(c,a[c],a);return a},f.emit=function(a){var b,e,f=c(this),i=g.call(arguments);for(b=0,e=f.length;e>b;++b)h(a,f[b],i);for(f=d(this,a).slice(),i=g.call(arguments,1),b=0,e=f.length;e>b;++b)h(a,f[b],i)},f.errback=function(a){this.errbacks||(this.errbacks=[]),this.errbacks.push(b(a))},f}var g=Array.prototype.slice;return{createEventEmitter:f,aggregate:function(a){var b=f();return a.forEach(function(a){a.on(function(a,c){b.emit(a,c)})}),b}}})},{}],3:[function(a,b){function c(){}var d=b.exports={};d.nextTick=function(){var a="undefined"!=typeof window&&window.setImmediate,b="undefined"!=typeof window&&window.postMessage&&window.addEventListener;if(a)return function(a){return window.setImmediate(a)};if(b){var c=[];return window.addEventListener("message",function(a){var b=a.source;if((b===window||null===b)&&"process-tick"===a.data&&(a.stopPropagation(),c.length>0)){var d=c.shift();d()}},!0),function(a){c.push(a),window.postMessage("process-tick","*")}}return function(a){setTimeout(a,0)}}(),d.title="browser",d.browser=!0,d.env={},d.argv=[],d.on=c,d.addListener=c,d.once=c,d.off=c,d.removeListener=c,d.removeAllListeners=c,d.emit=c,d.binding=function(){throw new Error("process.binding is not supported")},d.cwd=function(){return"/"},d.chdir=function(){throw new Error("process.chdir is not supported")}},{}],4:[function(b,c){!function(a){"use strict";a(function(a){var b=a("./makePromise"),c=a("./scheduler"),d=a("./async");return b({scheduler:new c(d)})})}("function"==typeof a&&a.amd?a:function(a){c.exports=a(b)})},{"./async":7,"./makePromise":17,"./scheduler":18}],5:[function(b,c){!function(a){"use strict";a(function(){function a(a){this.head=this.tail=this.length=0,this.buffer=new Array(1<f;++f)e[f]=d[f];else{for(a=d.length,b=this.tail;a>c;++f,++c)e[f]=d[c];for(c=0;b>c;++f,++c)e[f]=d[c]}this.buffer=e,this.head=0,this.tail=this.length},a})}("function"==typeof a&&a.amd?a:function(a){c.exports=a()})},{}],6:[function(b,c){!function(a){"use strict";a(function(){function a(b){Error.call(this),this.message=b,this.name=a.name,"function"==typeof Error.captureStackTrace&&Error.captureStackTrace(this,a)}return a.prototype=Object.create(Error.prototype),a.prototype.constructor=a,a})}("function"==typeof a&&a.amd?a:function(a){c.exports=a()})},{}],7:[function(b,c){(function(d){!function(a){"use strict";a(function(a){var b,c;return b="undefined"!=typeof d&&null!==d&&"function"==typeof d.nextTick?function(a){d.nextTick(a)}:(c="function"==typeof MutationObserver&&MutationObserver||"function"==typeof WebKitMutationObserver&&WebKitMutationObserver)?function(a,b){function c(){var a=d;d=void 0,a()}var d,e=a.createElement("div"),f=new b(c);return f.observe(e,{attributes:!0}),function(a){d=a,e.setAttribute("class","x")}}(document,c):function(a){try{return a("vertx").runOnLoop||a("vertx").runOnContext}catch(b){}var c=setTimeout;return function(a){c(a,0)}}(a)})}("function"==typeof a&&a.amd?a:function(a){c.exports=a(b)})}).call(this,b("FWaASH"))},{FWaASH:3}],8:[function(b,c){!function(a){"use strict";a(function(){return function(a){function b(b){return new a(function(a,c){function d(a){f.push(a),0===--e&&c(f)}var e=0,f=[];k.call(b,function(b){++e,l(b).then(a,d)}),0===e&&a()})}function c(b,c){return new a(function(a,d,e){function f(b){i>0&&(--i,j.push(b),0===i&&a(j))}function g(a){h>0&&(--h,m.push(a),0===h&&d(m))}var h,i=0,j=[],m=[];return k.call(b,function(a){++i,l(a).then(f,g,e)}),c=Math.max(c,0),h=i-c+1,i=Math.min(c,i),0===i?void a(j):void 0})}function d(a,b,c){return m(h.call(a,function(a){return l(a).then(b,c)}))}function e(a){return m(h.call(a,function(a){function b(){return a.inspect()}return a=l(a),a.then(b,b)}))}function f(a,b){function c(a,c,d){return l(a).then(function(a){return l(c).then(function(c){return b(a,c,d)})})}return arguments.length>2?i.call(a,c,arguments[2]):i.call(a,c)}function g(a,b){function c(a,c,d){return l(a).then(function(a){return l(c).then(function(c){return b(a,c,d)})})}return arguments.length>2?j.call(a,c,arguments[2]):j.call(a,c)}var h=Array.prototype.map,i=Array.prototype.reduce,j=Array.prototype.reduceRight,k=Array.prototype.forEach,l=a.resolve,m=a.all;return a.any=b,a.some=c,a.settle=e,a.map=d,a.reduce=f,a.reduceRight=g,a.prototype.spread=function(a){return this.then(m).then(function(b){return a.apply(void 0,b)})},a}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a()})},{}],9:[function(b,c){!function(a){"use strict";a(function(){function a(){throw new TypeError("catch predicate must be a function")}function b(a,b){return c(b)?a instanceof b:b(a)}function c(a){return a===Error||null!=a&&a.prototype instanceof Error}function d(a,b){return function(){return a.call(this),b}}function e(){}return function(c){function f(a,c){return function(d){return b(d,c)?a.call(this,d):g(d)}}var g=c.reject,h=c.prototype["catch"];return c.prototype.done=function(a,b){var c=this._handler;c.when({resolve:this._maybeFatal,notify:e,context:this,receiver:c.receiver,fulfilled:a,rejected:b,progress:void 0})},c.prototype["catch"]=c.prototype.otherwise=function(b){return 1===arguments.length?h.call(this,b):"function"!=typeof b?this.ensure(a):h.call(this,f(arguments[1],b))},c.prototype["finally"]=c.prototype.ensure=function(a){return"function"!=typeof a?this:(a=d(a,this),this.then(a,a))},c.prototype["else"]=c.prototype.orElse=function(a){return this.then(void 0,function(){return a})},c.prototype["yield"]=function(a){return this.then(function(){return a})},c.prototype.tap=function(a){return this.then(a)["yield"](this)},c}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a()})},{}],10:[function(b,c){!function(a){"use strict";a(function(){return function(a){return a.prototype.fold=function(a,b){var c=this._beget();return this._handler.fold(c._handler,a,b),c},a}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a()})},{}],11:[function(b,c){!function(a){"use strict";a(function(){return function(a){return a.prototype.inspect=function(){return this._handler.inspect()},a}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a()})},{}],12:[function(b,c){!function(a){"use strict";a(function(){return function(a){function b(a,b,d,e){return c(function(b){return[b,a(b)]},b,d,e)}function c(a,b,e,f){function g(f,g){return d(e(f)).then(function(){return c(a,b,e,g)})}return d(f).then(function(c){return d(b(c)).then(function(b){return b?c:d(a(c)).spread(g)})})}var d=a.resolve;return a.iterate=b,a.unfold=c,a}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a()})},{}],13:[function(b,c){!function(a){"use strict";a(function(){return function(a){return a.prototype.progress=function(a){return this.then(void 0,void 0,a)},a}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a()})},{}],14:[function(b,c){!function(a){"use strict";a(function(a){var b=a("../timer"),c=a("../TimeoutError");return function(a){return a.prototype.delay=function(a){var c=this._beget(),d=c._handler;return this._handler.map(function(c){b.set(function(){d.resolve(c)},a)},d),c},a.prototype.timeout=function(a,d){function e(){h.reject(f?d:new c("timed out after "+a+"ms"))}var f=arguments.length>1,g=this._beget(),h=g._handler,i=b.set(e,a);return this._handler.chain(h,function(a){b.clear(i),this.resolve(a)},function(a){b.clear(i),this.reject(a)},h.notify),g},a}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a(b)})},{"../TimeoutError":6,"../timer":19}],15:[function(b,c){!function(a){"use strict";a(function(a){function b(a){var b="object"==typeof a&&a.stack?a.stack:c(a);return a instanceof Error?b:b+" (WARNING: non-Error used)"}function c(a){var b=String(a);return"[object Object]"===b&&"undefined"!=typeof JSON&&(b=d(a,b)),b}function d(a,b){try{return JSON.stringify(a)}catch(a){return b}}function e(a){throw a}function f(){}var g=a("../timer");return function(a){function d(a){a.handled||(n.push(a),k("Potentially unhandled rejection ["+a.id+"] "+b(a.value)))}function h(a){var b=n.indexOf(a);b>=0&&(n.splice(b,1),l("Handled previous rejection ["+a.id+"] "+c(a.value)))}function i(a,b){m.push(a,b),o||(o=!0,o=g.set(j,0))}function j(){for(o=!1;m.length>0;)m.shift()(m.shift())}var k=f,l=f;"undefined"!=typeof console&&(k="undefined"!=typeof console.error?function(a){console.error(a)}:function(a){console.log(a)},l="undefined"!=typeof console.info?function(a){console.info(a)}:function(a){console.log(a)}),a.onPotentiallyUnhandledRejection=function(a){i(d,a)},a.onPotentiallyUnhandledRejectionHandled=function(a){i(h,a)},a.onFatalRejection=function(a){i(e,a.value)};var m=[],n=[],o=!1;return a}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a(b)})},{"../timer":19}],16:[function(b,c){!function(a){"use strict";a(function(){return function(a){return a.prototype["with"]=a.prototype.withThis=a.prototype._bindContext,a}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a()})},{}],17:[function(b,c){!function(a){"use strict";a(function(){return function(a){function b(a,b){this._handler=a===m?b:c(a)}function c(a){function b(a){e.resolve(a)}function c(a){e.reject(a)}function d(a){e.notify(a)}var e=new n;try{a(b,c,d)}catch(f){c(f)}return e}function d(a){return k(a)?a:new b(m,new p(j(a)))}function e(a){return new b(m,new p(new t(a)))}function f(){return M}function g(){return new b(m,new n)}function h(a){function c(a,b,c,d){c.map(function(a){b[d]=a,0===--i&&this.become(new s(b))},a)}var d,e,f,g,h=new n,i=a.length>>>0,j=new Array(i);for(d=0;d0)){h.become(e);break}j[d]=e.value,--i}else j[d]=f,--i;else--i;return 0===i&&h.become(new s(j)),new b(m,h)}function i(a){if(Object(a)===a&&0===a.length)return f();var c,d,e=new n;for(c=0;c0)return new b(m,d);var e=this._beget(),f=e._handler;return d.when({resolve:f.resolve,notify:f.notify,context:f,receiver:d.receiver,fulfilled:a,rejected:c,progress:arguments.length>2?arguments[2]:void 0}),e},b.prototype["catch"]=function(a){return this.then(void 0,a)},b.prototype._bindContext=function(a){return new b(m,new q(this._handler,a))},b.prototype._beget=function(){var a=this._handler,b=new n(a.receiver,a.join().context);return new this.constructor(m,b)},b.prototype._maybeFatal=function(a){if(C(a)){var b=j(a),c=this._handler.context;b.catchError(function(){this._fatal(c)},b)}},b.all=h,b.race=i,m.prototype.when=m.prototype.resolve=m.prototype.reject=m.prototype.notify=m.prototype._fatal=m.prototype._unreport=m.prototype._report=H,m.prototype.inspect=x,m.prototype._state=0,m.prototype.state=function(){return this._state},m.prototype.join=function(){for(var a=this;void 0!==a.handler;)a=a.handler;return a},m.prototype.chain=function(a,b,c,d){this.when({resolve:H,notify:H,context:void 0,receiver:a,fulfilled:b,rejected:c,progress:d})},m.prototype.map=function(a,b){this.chain(b,a,b.reject,b.notify)},m.prototype.catchError=function(a,b){this.chain(b,b.resolve,a,b.notify)},m.prototype.fold=function(a,b,c){this.join().map(function(a){j(c).map(function(c){this.resolve(E(b,c,a,this.receiver))},this)},a)},G(m,n),n.prototype._state=0,n.prototype.inspect=function(){return this.resolved?this.join().inspect():x()},n.prototype.resolve=function(a){this.resolved||this.become(j(a))},n.prototype.reject=function(a){this.resolved||this.become(new t(a))},n.prototype.join=function(){if(this.resolved){for(var a=this;void 0!==a.handler;)if(a=a.handler,a===this)return this.handler=new w;return a}return this},n.prototype.run=function(){var a=this.consumers,b=this.join();this.consumers=void 0;for(var c=0;c0;)a.shift().run();for(this._running=!1,a=this._afterQueue;a.length>0;)a.shift()(a.shift(),a.shift())},b})}("function"==typeof a&&a.amd?a:function(a){c.exports=a(b)})},{"./Queue":5}],19:[function(b,c){!function(a){"use strict";a(function(a){var b,c,d,e;b=a;try{c=b("vertx"),d=function(a,b){return c.setTimer(b,a)},e=c.cancelTimer}catch(f){d=function(a,b){return setTimeout(a,b)},e=function(a){return clearTimeout(a)}}return{set:d,clear:e}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a(b)})},{}],20:[function(b,c){!function(a){"use strict";a(function(a){function b(a,b,c){var d=z.resolve(a);return arguments.length<2?d:arguments.length>3?d.then(b,c,arguments[3]):d.then(b,c)}function c(a){return new z(a)}function d(a){return function(){return f(a,this,A.call(arguments))}}function e(a){return f(a,this,A.call(arguments,1))}function f(a,b,c){return z.all(c).then(function(c){return a.apply(b,c)})}function g(){return new h}function h(){function a(a){d._handler.resolve(a)}function b(a){d._handler.reject(a)}function c(a){d._handler.notify(a)}var d=z._defer();this.promise=d,this.resolve=a,this.reject=b,this.notify=c,this.resolver={resolve:a,reject:b,notify:c}}function i(a){return a&&"function"==typeof a.then}function j(){return z.all(arguments)}function k(a){return b(a,z.all)}function l(a){return b(a,z.settle)}function m(a,c){return b(a,function(a){return z.map(a,c)})}function n(a){var c=A.call(arguments,1);return b(a,function(a){return c.unshift(a),z.reduce.apply(z,c)})}function o(a){var c=A.call(arguments,1);return b(a,function(a){return c.unshift(a),z.reduceRight.apply(z,c)})}var p=a("./lib/decorators/timed"),q=a("./lib/decorators/array"),r=a("./lib/decorators/flow"),s=a("./lib/decorators/fold"),t=a("./lib/decorators/inspect"),u=a("./lib/decorators/iterate"),v=a("./lib/decorators/progress"),w=a("./lib/decorators/with"),x=a("./lib/decorators/unhandledRejection"),y=a("./lib/TimeoutError"),z=[q,r,s,u,v,t,w,p,x].reduce(function(a,b){return b(a)},a("./lib/Promise")),A=Array.prototype.slice;return b.promise=c,b.resolve=z.resolve,b.reject=z.reject,b.lift=d,b["try"]=e,b.attempt=e,b.iterate=z.iterate,b.unfold=z.unfold,b.join=j,b.all=k,b.settle=l,b.any=d(z.any),b.some=d(z.some),b.map=m,b.reduce=n,b.reduceRight=o,b.isPromiseLike=i,b.Promise=z,b.defer=g,b.TimeoutError=y,b})}("function"==typeof a&&a.amd?a:function(a){c.exports=a(b)})},{"./lib/Promise":4,"./lib/TimeoutError":6,"./lib/decorators/array":8,"./lib/decorators/flow":9,"./lib/decorators/fold":10,"./lib/decorators/inspect":11,"./lib/decorators/iterate":12,"./lib/decorators/progress":13,"./lib/decorators/timed":14,"./lib/decorators/unhandledRejection":15,"./lib/decorators/with":16}],21:[function(a,b){function c(a){return this instanceof c?(this._console=this._getConsole(a||{}),this._settings=this._configure(a||{}),this._backoffDelay=this._settings.backoffDelayMin,this._pendingRequests={},this._webSocket=null,d.createEventEmitter(this),this._delegateEvents(),void(this._settings.autoConnect&&this.connect())):new c(a)}var d=a("bane"),e=a("../lib/websocket/"),f=a("when");c.ConnectionError=function(a){this.name="ConnectionError",this.message=a},c.ConnectionError.prototype=new Error,c.ConnectionError.prototype.constructor=c.ConnectionError,c.ServerError=function(a){this.name="ServerError",this.message=a},c.ServerError.prototype=new Error,c.ServerError.prototype.constructor=c.ServerError,c.WebSocket=e.Client,c.prototype._getConsole=function(a){if("undefined"!=typeof a.console)return a.console;var b="undefined"!=typeof console&&console||{};return b.log=b.log||function(){},b.warn=b.warn||function(){},b.error=b.error||function(){},b},c.prototype._configure=function(a){var b="undefined"!=typeof document&&document.location.host||"localhost";return a.webSocketUrl=a.webSocketUrl||"ws://"+b+"/mopidy/ws",a.autoConnect!==!1&&(a.autoConnect=!0),a.backoffDelayMin=a.backoffDelayMin||1e3,a.backoffDelayMax=a.backoffDelayMax||64e3,"undefined"==typeof a.callingConvention&&this._console.warn("Mopidy.js is using the default calling convention. The default will change in the future. You should explicitly specify which calling convention you use."),a.callingConvention=a.callingConvention||"by-position-only",a},c.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)},c.prototype.connect=function(){if(this._webSocket){if(this._webSocket.readyState===c.WebSocket.OPEN)return;this._webSocket.close()}this._webSocket=this._settings.webSocket||new c.WebSocket(this._settings.webSocketUrl),this._webSocket.onclose=function(a){this.emit("websocket:close",a)}.bind(this),this._webSocket.onerror=function(a){this.emit("websocket:error",a)}.bind(this),this._webSocket.onopen=function(){this.emit("websocket:open")}.bind(this),this._webSocket.onmessage=function(a){this.emit("websocket:incomingMessage",a)}.bind(this)},c.prototype._cleanup=function(a){Object.keys(this._pendingRequests).forEach(function(b){var d=this._pendingRequests[b];delete this._pendingRequests[b];var e=new c.ConnectionError("WebSocket closed");e.closeEvent=a,d.reject(e)}.bind(this)),this.emit("state:offline")},c.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)},c.prototype._resetBackoffDelay=function(){this._backoffDelay=this._settings.backoffDelayMin},c.prototype.close=function(){this.off("state:offline",this._reconnect),this._webSocket.close()},c.prototype._handleWebSocketError=function(a){this._console.warn("WebSocket error:",a.stack||a)},c.prototype._send=function(a){switch(this._webSocket.readyState){case c.WebSocket.CONNECTING:return f.reject(new c.ConnectionError("WebSocket is still connecting"));case c.WebSocket.CLOSING:return f.reject(new c.ConnectionError("WebSocket is closing"));case c.WebSocket.CLOSED:return f.reject(new c.ConnectionError("WebSocket is closed"));default:var b=f.defer();return a.jsonrpc="2.0",a.id=this._nextRequestId(),this._pendingRequests[a.id]=b.resolver,this._webSocket.send(JSON.stringify(a)),this.emit("websocket:outgoingMessage",a),b.promise}},c.prototype._nextRequestId=function(){var a=-1;return function(){return a+=1}}(),c.prototype._handleMessage=function(a){try{var b=JSON.parse(a.data);b.hasOwnProperty("id")?this._handleResponse(b):b.hasOwnProperty("event")?this._handleEvent(b):this._console.warn("Unknown message type received. Message was: "+a.data)}catch(c){if(!(c instanceof SyntaxError))throw c;this._console.warn("WebSocket message parsing failed. Message was: "+a.data)}},c.prototype._handleResponse=function(a){if(!this._pendingRequests.hasOwnProperty(a.id))return void this._console.warn("Unexpected response received. Message was:",a);var b,d=this._pendingRequests[a.id];delete this._pendingRequests[a.id],a.hasOwnProperty("result")?d.resolve(a.result):a.hasOwnProperty("error")?(b=new c.ServerError(a.error.message),b.code=a.error.code,b.data=a.error.data,d.reject(b),this._console.warn("Server returned error:",a.error)):(b=new Error("Response without 'result' or 'error' received"),b.data={response:a},d.reject(b),this._console.warn("Response without 'result' or 'error' received. Message was:",a))},c.prototype._handleEvent=function(a){var b=a.event,c=a;delete c.event,this.emit("event:"+this._snakeToCamel(b),c)},c.prototype._getApiSpec=function(){return this._send({method:"core.describe"}).then(this._createApi.bind(this)).catch(this._handleWebSocketError)},c.prototype._createApi=function(a){var b="by-position-or-by-name"===this._settings.callingConvention,c=function(a){return function(){var c={method:a};return 0===arguments.length?this._send(c):b?arguments.length>1?f.reject(new Error("Expected zero arguments, a single array, or a single object.")):Array.isArray(arguments[0])||arguments[0]===Object(arguments[0])?(c.params=arguments[0],this._send(c)):f.reject(new TypeError("Expected an array or an object.")):(c.params=Array.prototype.slice.call(arguments),this._send(c))}.bind(this)}.bind(this),d=function(a){var b=a.split(".");return b.length>=1&&"core"===b[0]&&(b=b.slice(1)),b},e=function(a){var b=this;return a.forEach(function(a){a=this._snakeToCamel(a),b[a]=b[a]||{},b=b[a]}.bind(this)),b}.bind(this),g=function(b){var f=d(b),g=this._snakeToCamel(f.slice(-1)[0]),h=e(f.slice(0,-1));h[g]=c(b),h[g].description=a[b].description,h[g].params=a[b].params}.bind(this);Object.keys(a).forEach(g),this.emit("state:online")},c.prototype._snakeToCamel=function(a){return a.replace(/(_[a-z])/g,function(a){return a.toUpperCase().replace("_","")})},b.exports=c},{"../lib/websocket/":1,bane:2,when:20}]},{},[21])(21)}); \ No newline at end of file +!function(a){if("object"==typeof exports)module.exports=a();else if("function"==typeof define&&define.amd)define(a);else{var b;"undefined"!=typeof window?b=window:"undefined"!=typeof global?b=global:"undefined"!=typeof self&&(b=self),b.Mopidy=a()}}(function(){var a;return function b(a,c,d){function e(g,h){if(!c[g]){if(!a[g]){var i="function"==typeof require&&require;if(!h&&i)return i(g,!0);if(f)return f(g,!0);throw new Error("Cannot find module '"+g+"'")}var j=c[g]={exports:{}};a[g][0].call(j.exports,function(b){var c=a[g][1][b];return e(c?c:b)},j,j.exports,b,a,c,d)}return c[g].exports}for(var f="function"==typeof require&&require,g=0;g0)for(d=0;e>d;++d)c[d](a,b);else setTimeout(function(){throw b.message=a+" listener threw error: "+b.message,b},0)}function b(a){if("function"!=typeof a)throw new TypeError("Listener is not function");return a}function c(a){return a.supervisors||(a.supervisors=[]),a.supervisors}function d(a,b){return a.listeners||(a.listeners={}),b&&!a.listeners[b]&&(a.listeners[b]=[]),b?a.listeners[b]:a.listeners}function e(a){return a.errbacks||(a.errbacks=[]),a.errbacks}function f(f){function h(b,c,d){try{c.listener.apply(c.thisp||f,d)}catch(g){a(b,g,e(f))}}return f=f||{},f.on=function(a,e,f){return"function"==typeof a?c(this).push({listener:a,thisp:e}):void d(this,a).push({listener:b(e),thisp:f})},f.off=function(a,b){var f,g,h,i;if(!a){f=c(this),f.splice(0,f.length),g=d(this);for(h in g)g.hasOwnProperty(h)&&(f=d(this,h),f.splice(0,f.length));return f=e(this),void f.splice(0,f.length)}if("function"==typeof a?(f=c(this),b=a):f=d(this,a),!b)return void f.splice(0,f.length);for(h=0,i=f.length;i>h;++h)if(f[h].listener===b)return void f.splice(h,1)},f.once=function(a,b,c){var d=function(){f.off(a,d),b.apply(this,arguments)};f.on(a,d,c)},f.bind=function(a,b){var c,d,e;if(b)for(d=0,e=b.length;e>d;++d){if("function"!=typeof a[b[d]])throw new Error("No such method "+b[d]);this.on(b[d],a[b[d]],a)}else for(c in a)"function"==typeof a[c]&&this.on(c,a[c],a);return a},f.emit=function(a){var b,e,f=c(this),i=g.call(arguments);for(b=0,e=f.length;e>b;++b)h(a,f[b],i);for(f=d(this,a).slice(),i=g.call(arguments,1),b=0,e=f.length;e>b;++b)h(a,f[b],i)},f.errback=function(a){this.errbacks||(this.errbacks=[]),this.errbacks.push(b(a))},f}var g=Array.prototype.slice;return{createEventEmitter:f,aggregate:function(a){var b=f();return a.forEach(function(a){a.on(function(a,c){b.emit(a,c)})}),b}}})},{}],3:[function(a,b){function c(){}var d=b.exports={};d.nextTick=function(){var a="undefined"!=typeof window&&window.setImmediate,b="undefined"!=typeof window&&window.postMessage&&window.addEventListener;if(a)return function(a){return window.setImmediate(a)};if(b){var c=[];return window.addEventListener("message",function(a){var b=a.source;if((b===window||null===b)&&"process-tick"===a.data&&(a.stopPropagation(),c.length>0)){var d=c.shift();d()}},!0),function(a){c.push(a),window.postMessage("process-tick","*")}}return function(a){setTimeout(a,0)}}(),d.title="browser",d.browser=!0,d.env={},d.argv=[],d.on=c,d.addListener=c,d.once=c,d.off=c,d.removeListener=c,d.removeAllListeners=c,d.emit=c,d.binding=function(){throw new Error("process.binding is not supported")},d.cwd=function(){return"/"},d.chdir=function(){throw new Error("process.chdir is not supported")}},{}],4:[function(b,c){!function(a){"use strict";a(function(a){var b=a("./makePromise"),c=a("./Scheduler"),d=a("./env").asap;return b({scheduler:new c(d)})})}("function"==typeof a&&a.amd?a:function(a){c.exports=a(b)})},{"./Scheduler":5,"./env":17,"./makePromise":19}],5:[function(b,c){!function(a){"use strict";a(function(){function a(a){this._async=a,this._running=!1,this._queue=this,this._queueLen=0,this._afterQueue={},this._afterQueueLen=0;var b=this;this.drain=function(){b._drain()}}return a.prototype.enqueue=function(a){this._queue[this._queueLen++]=a,this.run()},a.prototype.afterQueue=function(a){this._afterQueue[this._afterQueueLen++]=a,this.run()},a.prototype.run=function(){this._running||(this._running=!0,this._async(this.drain))},a.prototype._drain=function(){for(var a=0;a>>0,j=i,k=[],l=0;i>l;++l)if(f=b[l],void 0!==f||l in b){if(e=a._handler(f),e.state()>0){h.become(e),a._visitRemaining(b,l,e);break}e.visit(h,c,d)}else--j;return 0===j&&h.reject(new RangeError("any(): array must not be empty")),g}function e(b,c){function d(a){this.resolved||(k.push(a),0===--n&&(l=null,this.resolve(k)))}function e(a){this.resolved||(l.push(a),0===--f&&(k=null,this.reject(l)))}var f,g,h,i=a._defer(),j=i._handler,k=[],l=[],m=b.length>>>0,n=0;for(h=0;m>h;++h)g=b[h],(void 0!==g||h in b)&&++n;for(c=Math.max(c,0),f=n-c+1,n=Math.min(c,n),c>n?j.reject(new RangeError("some(): array must contain at least "+c+" item(s), but had "+n)):0===n&&j.resolve(k),h=0;m>h;++h)g=b[h],(void 0!==g||h in b)&&a._handler(g).visit(j,d,e,j.notify);return i}function f(b,c){return a._traverse(c,b)}function g(b,c){var d=s.call(b);return a._traverse(c,d).then(function(a){return h(d,a)})}function h(b,c){for(var d=c.length,e=new Array(d),f=0,g=0;d>f;++f)c[f]&&(e[g++]=a._handler(b[f]).value);return e.length=g,e}function i(a){return p(a.map(j))}function j(c){var d=a._handler(c);return 0===d.state()?o(c).then(b.fulfilled,b.rejected):(d._unreport(),b.inspect(d))}function k(a,b){return arguments.length>2?q.call(a,m(b),arguments[2]):q.call(a,m(b))}function l(a,b){return arguments.length>2?r.call(a,m(b),arguments[2]):r.call(a,m(b))}function m(a){return function(b,c,d){return n(a,void 0,[b,c,d])}}var n=c(a),o=a.resolve,p=a.all,q=Array.prototype.reduce,r=Array.prototype.reduceRight,s=Array.prototype.slice;return a.any=d,a.some=e,a.settle=i,a.map=f,a.filter=g,a.reduce=k,a.reduceRight=l,a.prototype.spread=function(a){return this.then(p).then(function(b){return a.apply(this,b)})},a}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a(b)})},{"../apply":7,"../state":20}],9:[function(b,c){!function(a){"use strict";a(function(){function a(){throw new TypeError("catch predicate must be a function")}function b(a,b){return c(b)?a instanceof b:b(a)}function c(a){return a===Error||null!=a&&a.prototype instanceof Error}function d(a){return("object"==typeof a||"function"==typeof a)&&null!==a}function e(a){return a}return function(c){function f(a,c){return function(d){return b(d,c)?a.call(this,d):j(d)}}function g(a,b,c,e){var f=a.call(b);return d(f)?h(f,c,e):c(e)}function h(a,b,c){return i(a).then(function(){return b(c)})}var i=c.resolve,j=c.reject,k=c.prototype["catch"];return c.prototype.done=function(a,b){this._handler.visit(this._handler.receiver,a,b)},c.prototype["catch"]=c.prototype.otherwise=function(b){return arguments.length<2?k.call(this,b):"function"!=typeof b?this.ensure(a):k.call(this,f(arguments[1],b))},c.prototype["finally"]=c.prototype.ensure=function(a){return"function"!=typeof a?this:this.then(function(b){return g(a,this,e,b)},function(b){return g(a,this,j,b)})},c.prototype["else"]=c.prototype.orElse=function(a){return this.then(void 0,function(){return a})},c.prototype["yield"]=function(a){return this.then(function(){return a})},c.prototype.tap=function(a){return this.then(a)["yield"](this)},c}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a()})},{}],10:[function(b,c){!function(a){"use strict";a(function(){return function(a){return a.prototype.fold=function(b,c){var d=this._beget();return this._handler.fold(function(c,d,e){a._handler(c).fold(function(a,c,d){d.resolve(b.call(this,c,a))},d,this,e)},c,d._handler.receiver,d._handler),d},a}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a()})},{}],11:[function(b,c){!function(a){"use strict";a(function(a){var b=a("../state").inspect;return function(a){return a.prototype.inspect=function(){return b(a._handler(this))},a}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a(b)})},{"../state":20}],12:[function(b,c){!function(a){"use strict";a(function(){return function(a){function b(a,b,d,e){return c(function(b){return[b,a(b)]},b,d,e)}function c(a,b,e,f){function g(f,g){return d(e(f)).then(function(){return c(a,b,e,g)})}return d(f).then(function(c){return d(b(c)).then(function(b){return b?c:d(a(c)).spread(g)})})}var d=a.resolve;return a.iterate=b,a.unfold=c,a}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a()})},{}],13:[function(b,c){!function(a){"use strict";a(function(){return function(a){return a.prototype.progress=function(a){return this.then(void 0,void 0,a)},a}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a()})},{}],14:[function(b,c){!function(a){"use strict";a(function(a){function b(a,b,d,e){return c.setTimer(function(){a(d,e,b)},b)}var c=a("../env"),d=a("../TimeoutError");return function(a){function e(a,c,d){b(f,a,c,d)}function f(a,b){b.resolve(a)}function g(a,b,c){var e="undefined"==typeof a?new d("timed out after "+c+"ms"):a;b.reject(e)}return a.prototype.delay=function(a){var b=this._beget();return this._handler.fold(e,a,void 0,b._handler),b},a.prototype.timeout=function(a,d){var e=this._beget(),f=e._handler,h=b(g,a,d,e._handler);return this._handler.visit(f,function(a){c.clearTimer(h),this.resolve(a)},function(a){c.clearTimer(h),this.reject(a)},f.notify),e},a}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a(b)})},{"../TimeoutError":6,"../env":17}],15:[function(b,c){!function(a){"use strict";a(function(a){function b(a){throw a}function c(){}var d=a("../env").setTimer,e=a("../format");return function(a){function f(a){a.handled||(n.push(a),k("Potentially unhandled rejection ["+a.id+"] "+e.formatError(a.value)))}function g(a){var b=n.indexOf(a);b>=0&&(n.splice(b,1),l("Handled previous rejection ["+a.id+"] "+e.formatObject(a.value)))}function h(a,b){m.push(a,b),null===o&&(o=d(i,0))}function i(){for(o=null;m.length>0;)m.shift()(m.shift())}var j,k=c,l=c;"undefined"!=typeof console&&(j=console,k="undefined"!=typeof j.error?function(a){j.error(a)}:function(a){j.log(a)},l="undefined"!=typeof j.info?function(a){j.info(a)}:function(a){j.log(a)}),a.onPotentiallyUnhandledRejection=function(a){h(f,a)},a.onPotentiallyUnhandledRejectionHandled=function(a){h(g,a)},a.onFatalRejection=function(a){h(b,a.value)};var m=[],n=[],o=null;return a}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a(b)})},{"../env":17,"../format":18}],16:[function(b,c){!function(a){"use strict";a(function(){return function(a){return a.prototype["with"]=a.prototype.withThis=function(a){var b=this._beget(),c=b._handler;return c.receiver=a,this._handler.chain(c,a),b},a}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a()})},{}],17:[function(b,c){(function(d){!function(a){"use strict";a(function(a){function b(){return"undefined"!=typeof d&&null!==d&&"function"==typeof d.nextTick}function c(){return"function"==typeof MutationObserver&&MutationObserver||"function"==typeof WebKitMutationObserver&&WebKitMutationObserver}function e(a){function b(){var a=c;c=void 0,a()}var c,d=document.createTextNode(""),e=new a(b);e.observe(d,{characterData:!0});var f=0;return function(a){c=a,d.data=f^=1}}var f,g="undefined"!=typeof setTimeout&&setTimeout,h=function(a,b){return setTimeout(a,b)},i=function(a){return clearTimeout(a)},j=function(a){return g(a,0)};if(b())j=function(a){return d.nextTick(a)};else if(f=c())j=e(f);else if(!g){var k=a,l=k("vertx");h=function(a,b){return l.setTimer(b,a)},i=l.cancelTimer,j=l.runOnLoop||l.runOnContext}return{setTimer:h,clearTimer:i,asap:j}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a(b)})}).call(this,b("FWaASH"))},{FWaASH:3}],18:[function(b,c){!function(a){"use strict";a(function(){function a(a){var c="object"==typeof a&&null!==a&&a.stack?a.stack:b(a);return a instanceof Error?c:c+" (WARNING: non-Error used)"}function b(a){var b=String(a);return"[object Object]"===b&&"undefined"!=typeof JSON&&(b=c(a,b)),b}function c(a,b){try{return JSON.stringify(a)}catch(c){return b}}return{formatError:a,formatObject:b,tryStringify:c}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a()})},{}],19:[function(b,c){(function(b){!function(a){"use strict";a(function(){return function(a){function c(a,b){this._handler=a===u?b:d(a)}function d(a){function b(a){e.resolve(a)}function c(a){e.reject(a)}function d(a){e.notify(a)}var e=new w;try{a(b,c,d)}catch(f){c(f)}return e}function e(a){return J(a)?a:new c(u,new x(r(a)))}function f(a){return new c(u,new x(new A(a)))}function g(){return ab}function h(){return new c(u,new w)}function i(a,b){var c=new w(a.receiver,a.join().context);return new b(u,c)}function j(a){return l(T,null,a)}function k(a,b){return l(O,a,b)}function l(a,b,d){function e(c,e,g){g.resolved||m(d,f,c,a(b,e,c),g)}function f(a,b,c){k[a]=b,0===--j&&c.become(new z(k))}for(var g,h="function"==typeof b?e:f,i=new w,j=d.length>>>0,k=new Array(j),l=0;l0?b(c,f.value,e):(e.become(f),n(a,c+1,f))}else b(c,d,e)}function n(a,b,c){for(var d=b;dc&&a._unreport()}}function p(a){return"object"!=typeof a||null===a?f(new TypeError("non-iterable passed to race()")):0===a.length?g():1===a.length?e(a[0]):q(a)}function q(a){var b,d,e,f=new w;for(b=0;b0||"function"!=typeof b&&0>e)return new this.constructor(u,d);var f=this._beget(),g=f._handler;return d.chain(g,d.receiver,a,b,c),f},c.prototype["catch"]=function(a){return this.then(void 0,a)},c.prototype._beget=function(){return i(this._handler,this.constructor)},c.all=j,c.race=p,c._traverse=k,c._visitRemaining=n,u.prototype.when=u.prototype.become=u.prototype.notify=u.prototype.fail=u.prototype._unreport=u.prototype._report=U,u.prototype._state=0,u.prototype.state=function(){return this._state},u.prototype.join=function(){for(var a=this;void 0!==a.handler;)a=a.handler;return a},u.prototype.chain=function(a,b,c,d,e){this.when({resolver:a,receiver:b,fulfilled:c,rejected:d,progress:e})},u.prototype.visit=function(a,b,c,d){this.chain(Z,a,b,c,d)},u.prototype.fold=function(a,b,c,d){this.when(new I(a,b,c,d))},S(u,v),v.prototype.become=function(a){a.fail()};var Z=new v;S(u,w),w.prototype._state=0,w.prototype.resolve=function(a){this.become(r(a))},w.prototype.reject=function(a){this.resolved||this.become(new A(a))},w.prototype.join=function(){if(!this.resolved)return this;for(var a=this;void 0!==a.handler;)if(a=a.handler,a===this)return this.handler=D();return a},w.prototype.run=function(){var a=this.consumers,b=this.handler;this.handler=this.handler.join(),this.consumers=void 0;for(var c=0;c0?c(d.value):b(d.value)}return{pending:a,fulfilled:c,rejected:b,inspect:d}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a()})},{}],21:[function(b,c){!function(a){"use strict";a(function(a){function b(a,b,c,d){var e=x.resolve(a);return arguments.length<2?e:e.then(b,c,d)}function c(a){return new x(a)}function d(a){return function(){for(var b=0,c=arguments.length,d=new Array(c);c>b;++b)d[b]=arguments[b];return y(a,this,d)}}function e(a){for(var b=0,c=arguments.length-1,d=new Array(c);c>b;++b)d[b]=arguments[b+1];return y(a,this,d)}function f(){return new g}function g(){function a(a){d._handler.resolve(a)}function b(a){d._handler.reject(a)}function c(a){d._handler.notify(a)}var d=x._defer();this.promise=d,this.resolve=a,this.reject=b,this.notify=c,this.resolver={resolve:a,reject:b,notify:c}}function h(a){return a&&"function"==typeof a.then}function i(){return x.all(arguments)}function j(a){return b(a,x.all)}function k(a){return b(a,x.settle)}function l(a,c){return b(a,function(a){return x.map(a,c)})}function m(a,c){return b(a,function(a){return x.filter(a,c)})}var n=a("./lib/decorators/timed"),o=a("./lib/decorators/array"),p=a("./lib/decorators/flow"),q=a("./lib/decorators/fold"),r=a("./lib/decorators/inspect"),s=a("./lib/decorators/iterate"),t=a("./lib/decorators/progress"),u=a("./lib/decorators/with"),v=a("./lib/decorators/unhandledRejection"),w=a("./lib/TimeoutError"),x=[o,p,q,s,t,r,u,n,v].reduce(function(a,b){return b(a)},a("./lib/Promise")),y=a("./lib/apply")(x);return b.promise=c,b.resolve=x.resolve,b.reject=x.reject,b.lift=d,b["try"]=e,b.attempt=e,b.iterate=x.iterate,b.unfold=x.unfold,b.join=i,b.all=j,b.settle=k,b.any=d(x.any),b.some=d(x.some),b.race=d(x.race),b.map=l,b.filter=m,b.reduce=d(x.reduce),b.reduceRight=d(x.reduceRight),b.isPromiseLike=h,b.Promise=x,b.defer=f,b.TimeoutError=w,b})}("function"==typeof a&&a.amd?a:function(a){c.exports=a(b)})},{"./lib/Promise":4,"./lib/TimeoutError":6,"./lib/apply":7,"./lib/decorators/array":8,"./lib/decorators/flow":9,"./lib/decorators/fold":10,"./lib/decorators/inspect":11,"./lib/decorators/iterate":12,"./lib/decorators/progress":13,"./lib/decorators/timed":14,"./lib/decorators/unhandledRejection":15,"./lib/decorators/with":16}],22:[function(a,b){function c(a){return this instanceof c?(this._console=this._getConsole(a||{}),this._settings=this._configure(a||{}),this._backoffDelay=this._settings.backoffDelayMin,this._pendingRequests={},this._webSocket=null,d.createEventEmitter(this),this._delegateEvents(),void(this._settings.autoConnect&&this.connect())):new c(a)}var d=a("bane"),e=a("../lib/websocket/"),f=a("when");c.ConnectionError=function(a){this.name="ConnectionError",this.message=a},c.ConnectionError.prototype=Object.create(Error.prototype),c.ConnectionError.prototype.constructor=c.ConnectionError,c.ServerError=function(a){this.name="ServerError",this.message=a},c.ServerError.prototype=Object.create(Error.prototype),c.ServerError.prototype.constructor=c.ServerError,c.WebSocket=e.Client,c.when=f,c.prototype._getConsole=function(a){if("undefined"!=typeof a.console)return a.console;var b="undefined"!=typeof console&&console||{};return b.log=b.log||function(){},b.warn=b.warn||function(){},b.error=b.error||function(){},b},c.prototype._configure=function(a){var b="undefined"!=typeof document&&"https:"===document.location.protocol?"wss://":"ws://",c="undefined"!=typeof document&&document.location.host||"localhost";return a.webSocketUrl=a.webSocketUrl||b+c+"/mopidy/ws",a.autoConnect!==!1&&(a.autoConnect=!0),a.backoffDelayMin=a.backoffDelayMin||1e3,a.backoffDelayMax=a.backoffDelayMax||64e3,"undefined"==typeof a.callingConvention&&this._console.warn("Mopidy.js is using the default calling convention. The default will change in the future. You should explicitly specify which calling convention you use."),a.callingConvention=a.callingConvention||"by-position-only",a},c.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)},c.prototype.connect=function(){if(this._webSocket){if(this._webSocket.readyState===c.WebSocket.OPEN)return;this._webSocket.close()}this._webSocket=this._settings.webSocket||new c.WebSocket(this._settings.webSocketUrl),this._webSocket.onclose=function(a){this.emit("websocket:close",a)}.bind(this),this._webSocket.onerror=function(a){this.emit("websocket:error",a)}.bind(this),this._webSocket.onopen=function(){this.emit("websocket:open")}.bind(this),this._webSocket.onmessage=function(a){this.emit("websocket:incomingMessage",a)}.bind(this)},c.prototype._cleanup=function(a){Object.keys(this._pendingRequests).forEach(function(b){var d=this._pendingRequests[b];delete this._pendingRequests[b];var e=new c.ConnectionError("WebSocket closed");e.closeEvent=a,d.reject(e)}.bind(this)),this.emit("state:offline")},c.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)},c.prototype._resetBackoffDelay=function(){this._backoffDelay=this._settings.backoffDelayMin},c.prototype.close=function(){this.off("state:offline",this._reconnect),this._webSocket.close()},c.prototype._handleWebSocketError=function(a){this._console.warn("WebSocket error:",a.stack||a)},c.prototype._send=function(a){switch(this._webSocket.readyState){case c.WebSocket.CONNECTING:return f.reject(new c.ConnectionError("WebSocket is still connecting"));case c.WebSocket.CLOSING:return f.reject(new c.ConnectionError("WebSocket is closing"));case c.WebSocket.CLOSED:return f.reject(new c.ConnectionError("WebSocket is closed"));default:var b=f.defer();return a.jsonrpc="2.0",a.id=this._nextRequestId(),this._pendingRequests[a.id]=b.resolver,this._webSocket.send(JSON.stringify(a)),this.emit("websocket:outgoingMessage",a),b.promise}},c.prototype._nextRequestId=function(){var a=-1;return function(){return a+=1}}(),c.prototype._handleMessage=function(a){try{var b=JSON.parse(a.data);b.hasOwnProperty("id")?this._handleResponse(b):b.hasOwnProperty("event")?this._handleEvent(b):this._console.warn("Unknown message type received. Message was: "+a.data)}catch(c){if(!(c instanceof SyntaxError))throw c;this._console.warn("WebSocket message parsing failed. Message was: "+a.data)}},c.prototype._handleResponse=function(a){if(!this._pendingRequests.hasOwnProperty(a.id))return void this._console.warn("Unexpected response received. Message was:",a);var b,d=this._pendingRequests[a.id];delete this._pendingRequests[a.id],a.hasOwnProperty("result")?d.resolve(a.result):a.hasOwnProperty("error")?(b=new c.ServerError(a.error.message),b.code=a.error.code,b.data=a.error.data,d.reject(b),this._console.warn("Server returned error:",a.error)):(b=new Error("Response without 'result' or 'error' received"),b.data={response:a},d.reject(b),this._console.warn("Response without 'result' or 'error' received. Message was:",a))},c.prototype._handleEvent=function(a){var b=a.event,c=a;delete c.event,this.emit("event:"+this._snakeToCamel(b),c)},c.prototype._getApiSpec=function(){return this._send({method:"core.describe"}).then(this._createApi.bind(this))["catch"](this._handleWebSocketError)},c.prototype._createApi=function(a){var b="by-position-or-by-name"===this._settings.callingConvention,c=function(a){return function(){var c={method:a};return 0===arguments.length?this._send(c):b?arguments.length>1?f.reject(new Error("Expected zero arguments, a single array, or a single object.")):Array.isArray(arguments[0])||arguments[0]===Object(arguments[0])?(c.params=arguments[0],this._send(c)):f.reject(new TypeError("Expected an array or an object.")):(c.params=Array.prototype.slice.call(arguments),this._send(c))}.bind(this)}.bind(this),d=function(a){var b=a.split(".");return b.length>=1&&"core"===b[0]&&(b=b.slice(1)),b},e=function(a){var b=this;return a.forEach(function(a){a=this._snakeToCamel(a),b[a]=b[a]||{},b=b[a]}.bind(this)),b}.bind(this),g=function(b){var f=d(b),g=this._snakeToCamel(f.slice(-1)[0]),h=e(f.slice(0,-1));h[g]=c(b),h[g].description=a[b].description,h[g].params=a[b].params}.bind(this);Object.keys(a).forEach(g),this.emit("state:online")},c.prototype._snakeToCamel=function(a){return a.replace(/(_[a-z])/g,function(a){return a.toUpperCase().replace("_","")})},b.exports=c},{"../lib/websocket/":1,bane:2,when:21}]},{},[22])(22)}); \ No newline at end of file diff --git a/mopidy/http/handlers.py b/mopidy/http/handlers.py index 3ef10c3b..a5baf992 100644 --- a/mopidy/http/handlers.py +++ b/mopidy/http/handlers.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import logging import os @@ -10,7 +10,7 @@ import tornado.websocket import mopidy from mopidy import core, models -from mopidy.utils import jsonrpc +from mopidy.utils import encoding, jsonrpc logger = logging.getLogger(__name__) @@ -41,7 +41,9 @@ def make_jsonrpc_wrapper(core_actor): objects={ 'core.get_uri_schemes': core.Core.get_uri_schemes, 'core.get_version': core.Core.get_version, + 'core.history': core.HistoryController, 'core.library': core.LibraryController, + 'core.mixer': core.MixerController, 'core.playback': core.PlaybackController, 'core.playlists': core.PlaylistsController, 'core.tracklist': core.TracklistController, @@ -51,7 +53,9 @@ def make_jsonrpc_wrapper(core_actor): 'core.describe': inspector.describe, 'core.get_uri_schemes': core_actor.get_uri_schemes, 'core.get_version': core_actor.get_version, + 'core.history': core_actor.history, 'core.library': core_actor.library, + 'core.mixer': core_actor.mixer, 'core.playback': core_actor.playback, 'core.playlists': core_actor.playlists, 'core.tracklist': core_actor.tracklist, @@ -71,7 +75,16 @@ class WebSocketHandler(tornado.websocket.WebSocketHandler): @classmethod def broadcast(cls, msg): for client in cls.clients: - client.write_message(msg) + # We could check for client.ws_connection, but we don't really + # care why the broadcast failed, we just want the rest of them + # to succeed, so catch everything. + try: + client.write_message(msg) + except Exception as e: + error_msg = encoding.locale_decode(e) + logger.debug('Broadcast of WebSocket message to %s failed: %s', + client.request.remote_ip, error_msg) + # TODO: should this do the same cleanup as the on_message code? def initialize(self, core): self.jsonrpc = make_jsonrpc_wrapper(core) @@ -109,7 +122,8 @@ class WebSocketHandler(tornado.websocket.WebSocketHandler): 'Sent WebSocket message to %s: %r', self.request.remote_ip, response) except Exception as e: - logger.error('WebSocket request error: %s', e) + error_msg = encoding.locale_decode(e) + logger.error('WebSocket request error: %s', error_msg) if self.ws_connection: # Tornado 3.2+ checks if self.ws_connection is None before # using it, but not older versions. diff --git a/mopidy/listener.py b/mopidy/listener.py index c8ecfa53..286466a5 100644 --- a/mopidy/listener.py +++ b/mopidy/listener.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import logging @@ -17,7 +17,21 @@ def send(cls, event, **kwargs): listeners = pykka.ActorRegistry.get_by_class(cls) logger.debug('Sending %s to %s: %s', event, cls.__name__, kwargs) for listener in listeners: - listener.proxy().on_event(event, **kwargs) + # Save time by calling methods on Pykka actor without creating a + # throwaway actor proxy. + # + # Because we use `.tell()` there is no return channel for any errors, + # so Pykka logs them immediately. The alternative would be to use + # `.ask()` and `.get()` the returned futures to block for the listeners + # to react and return their exceptions to us. Since emitting events in + # practise is making calls upwards in the stack, blocking here would + # quickly deadlock. + listener.tell({ + 'command': 'pykka_call', + 'attr_path': ('on_event',), + 'args': (event,), + 'kwargs': kwargs, + }) class Listener(object): diff --git a/mopidy/local/__init__.py b/mopidy/local/__init__.py index 9b485f19..dedb8632 100644 --- a/mopidy/local/__init__.py +++ b/mopidy/local/__init__.py @@ -1,10 +1,10 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import logging import os import mopidy -from mopidy import config, ext +from mopidy import config, ext, models logger = logging.getLogger(__name__) @@ -24,11 +24,12 @@ class Extension(ext.Extension): schema['library'] = config.String() schema['media_dir'] = config.Path() schema['data_dir'] = config.Path() - schema['playlists_dir'] = config.Path() + schema['playlists_dir'] = config.Deprecated() schema['tag_cache_file'] = config.Deprecated() schema['scan_timeout'] = config.Integer( minimum=1000, maximum=1000 * 60 * 60) schema['scan_flush_threshold'] = config.Integer(minimum=0) + schema['scan_follow_symlinks'] = config.Boolean() schema['excluded_file_extensions'] = config.List(optional=True) return schema @@ -69,6 +70,10 @@ class Library(object): #: Name of the local library implementation, must be overriden. name = None + #: Feature marker to indicate that you want :meth:`add()` calls to be + #: called with optional arguments tags and duration. + add_supports_tags_and_duration = False + def __init__(self, config): self._config = config @@ -84,6 +89,43 @@ class Library(object): """ raise NotImplementedError + def get_distinct(self, field, query=None): + """ + List distinct values for a given field from the library. + + :param string field: One of ``artist``, ``albumartist``, ``album``, + ``composer``, ``performer``, ``date``or ``genre``. + :param dict query: Query to use for limiting results, see + :meth:`search` for details about the query format. + :rtype: set of values corresponding to the requested field type. + """ + return set() + + def get_images(self, uris): + """ + Lookup the images for the given URIs. + + The default implementation will simply call :meth:`lookup` and + try and use the album art for any tracks returned. Most local + libraries should replace this with something smarter or simply + return an empty dictionary. + + :param list uris: list of URIs to find images for + :rtype: {uri: tuple of :class:`mopidy.models.Image`} + """ + result = {} + for uri in uris: + image_uris = set() + tracks = self.lookup(uri) + # local libraries may return single track + if isinstance(tracks, models.Track): + tracks = [tracks] + for track in tracks: + if track.album and track.album.images: + image_uris.update(track.album.images) + result[uri] = [models.Image(uri=u) for u in image_uris] + return result + def load(self): """ (Re)load any tracks stored in memory, if any, otherwise just return @@ -99,11 +141,9 @@ class Library(object): """ Lookup the given URI. - Unlike the core APIs, local tracks uris can only be resolved to a - single track. - :param string uri: track URI - :rtype: :class:`~mopidy.models.Track` + :rtype: list of :class:`~mopidy.models.Track` (or single + :class:`~mopidy.models.Track` for backward compatibility) """ raise NotImplementedError @@ -136,12 +176,19 @@ class Library(object): """ raise NotImplementedError - def add(self, track): + def add(self, track, tags=None, duration=None): """ - Add the given track to library. + Add the given track to library. Optional args will only be added if + :attr:`add_supports_tags_and_duration` has been set. :param track: Track to add to the library :type track: :class:`~mopidy.models.Track` + :param tags: All the tags the scanner found for the media. See + :mod:`mopidy.audio.utils` for details about the tags. + :type tags: dictionary of tag keys with a list of values. + :param duration: Duration of media in milliseconds or :class:`None` if + unknown + :type duration: :class:`int` or :class:`None` """ raise NotImplementedError diff --git a/mopidy/local/actor.py b/mopidy/local/actor.py index 590d7867..435d19a5 100644 --- a/mopidy/local/actor.py +++ b/mopidy/local/actor.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import logging @@ -8,7 +8,6 @@ from mopidy import backend from mopidy.local import storage from mopidy.local.library import LocalLibraryProvider from mopidy.local.playback import LocalPlaybackProvider -from mopidy.local.playlists import LocalPlaylistsProvider logger = logging.getLogger(__name__) @@ -36,5 +35,4 @@ class LocalBackend(pykka.ThreadingActor, backend.Backend): logger.warning('Local library %s not found', library_name) self.playback = LocalPlaybackProvider(audio=audio, backend=self) - self.playlists = LocalPlaylistsProvider(backend=self) self.library = LocalLibraryProvider(backend=self, library=library) diff --git a/mopidy/local/commands.py b/mopidy/local/commands.py index a0dc23d1..af8b0025 100644 --- a/mopidy/local/commands.py +++ b/mopidy/local/commands.py @@ -1,17 +1,20 @@ -from __future__ import print_function, unicode_literals +from __future__ import ( + absolute_import, division, print_function, unicode_literals) import logging import os import time -from mopidy import commands, exceptions -from mopidy.audio import scan +from mopidy import commands, compat, exceptions +from mopidy.audio import scan, utils from mopidy.local import translator from mopidy.utils import path logger = logging.getLogger(__name__) +MIN_DURATION_MS = 100 # Shortest length of track to include. + def _get_library(args, config): libraries = dict((l.name, l) for l in args.registry['local:library']) @@ -39,7 +42,7 @@ class ClearCommand(commands.Command): library = _get_library(args, config) prompt = '\nAre you sure you want to clear the library? [y/N] ' - if raw_input(prompt).lower() != 'y': + if compat.input(prompt).lower() != 'y': print('Clearing library aborted.') return 0 @@ -58,7 +61,10 @@ class ScanCommand(commands.Command): super(ScanCommand, self).__init__() self.add_argument('--limit', action='store', type=int, dest='limit', default=None, - help='Maxmimum number of tracks to scan') + help='Maximum number of tracks to scan') + self.add_argument('--force', + action='store_true', dest='force', default=False, + help='Force rescan of all media files') def run(self, args, config): media_dir = config['local']['media_dir'] @@ -70,23 +76,33 @@ class ScanCommand(commands.Command): library = _get_library(args, config) - uris_to_update = set() - uris_to_remove = set() + file_mtimes, file_errors = path.find_mtimes( + media_dir, follow=config['local']['scan_follow_symlinks']) - file_mtimes = path.find_mtimes(media_dir) logger.info('Found %d files in media_dir.', len(file_mtimes)) + if file_errors: + logger.warning('Encountered %d errors while scanning media_dir.', + len(file_errors)) + for name in file_errors: + logger.debug('Scan error %r for %r', file_errors[name], name) + num_tracks = library.load() logger.info('Checking %d tracks from library.', num_tracks) + uris_to_update = set() + uris_to_remove = set() + uris_in_library = set() + for track in library.begin(): abspath = translator.local_track_uri_to_path(track.uri, media_dir) - mtime = file_mtimes.pop(abspath, None) + mtime = file_mtimes.get(abspath) if mtime is None: logger.debug('Missing file %s', track.uri) uris_to_remove.add(track.uri) - elif mtime > track.last_modified: + elif mtime > track.last_modified or args.force: uris_to_update.add(track.uri) + uris_in_library.add(track.uri) logger.info('Removing %d missing tracks.', len(uris_to_remove)) for uri in uris_to_remove: @@ -96,11 +112,12 @@ class ScanCommand(commands.Command): relpath = os.path.relpath(abspath, media_dir) uri = translator.path_to_local_track_uri(relpath) - if relpath.lower().endswith(excluded_file_extensions): + if b'/.' in relpath: + logger.debug('Skipped %s: Hidden directory/file.', uri) + elif relpath.lower().endswith(excluded_file_extensions): logger.debug('Skipped %s: File extension excluded.', uri) - continue - - uris_to_update.add(uri) + elif uri not in uris_in_library: + uris_to_update.add(uri) logger.info( 'Found %d tracks which need to be updated.', len(uris_to_update)) @@ -116,10 +133,20 @@ class ScanCommand(commands.Command): try: relpath = translator.local_track_uri_to_path(uri, media_dir) file_uri = path.path_to_uri(os.path.join(media_dir, relpath)) - data = scanner.scan(file_uri) - track = scan.audio_data_to_track(data).copy(uri=uri) - library.add(track) - logger.debug('Added %s', track.uri) + result = scanner.scan(file_uri) + tags, duration = result.tags, result.duration + if duration < MIN_DURATION_MS: + logger.warning('Failed %s: Track shorter than %dms', + uri, MIN_DURATION_MS) + else: + mtime = file_mtimes.get(os.path.join(media_dir, relpath)) + track = utils.convert_tags_to_track(tags).copy( + uri=uri, length=duration, last_modified=mtime) + if library.add_supports_tags_and_duration: + library.add(track, tags=tags, duration=duration) + else: + library.add(track) + logger.debug('Added %s', track.uri) except exceptions.ScannerError as error: logger.warning('Failed %s: %s', uri, error) diff --git a/mopidy/local/ext.conf b/mopidy/local/ext.conf index 9a0f19f1..ebd7962f 100644 --- a/mopidy/local/ext.conf +++ b/mopidy/local/ext.conf @@ -3,9 +3,9 @@ enabled = true library = json media_dir = $XDG_MUSIC_DIR data_dir = $XDG_DATA_DIR/mopidy/local -playlists_dir = $XDG_DATA_DIR/mopidy/local/playlists scan_timeout = 1000 scan_flush_threshold = 1000 +scan_follow_symlinks = false excluded_file_extensions = .directory .html diff --git a/mopidy/local/json.py b/mopidy/local/json.py index b3a2ff39..22fcfa5b 100644 --- a/mopidy/local/json.py +++ b/mopidy/local/json.py @@ -1,4 +1,4 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import absolute_import, absolute_import, unicode_literals import collections import gzip @@ -8,12 +8,11 @@ import os import re import sys import tempfile -import time import mopidy -from mopidy import local, models +from mopidy import compat, local, models from mopidy.local import search, storage, translator -from mopidy.utils import encoding +from mopidy.utils import encoding, timer logger = logging.getLogger(__name__) @@ -109,20 +108,6 @@ class _BrowseCache(object): return self._cache.get(uri, {}).values() -# TODO: make this available to other code? -class DebugTimer(object): - def __init__(self, msg): - self.msg = msg - self.start = None - - def __enter__(self): - self.start = time.time() - - def __exit__(self, exc_type, exc_value, traceback): - duration = (time.time() - self.start) * 1000 - logger.debug('%s: %dms', self.msg, duration) - - class JsonLibrary(local.Library): name = 'json' @@ -142,29 +127,63 @@ class JsonLibrary(local.Library): def load(self): logger.debug('Loading library: %s', self._json_file) - with DebugTimer('Loading tracks'): + with timer.time_logger('Loading tracks'): library = load_library(self._json_file) self._tracks = dict((t.uri, t) for t in library.get('tracks', [])) - with DebugTimer('Building browse cache'): + with timer.time_logger('Building browse cache'): self._browse_cache = _BrowseCache(sorted(self._tracks.keys())) return len(self._tracks) def lookup(self, uri): try: - return self._tracks[uri] + return [self._tracks[uri]] except KeyError: - return None + return [] + + def get_distinct(self, field, query=None): + if field == 'artist': + def distinct(track): + return {a.name for a in track.artists} + elif field == 'albumartist': + def distinct(track): + album = track.album or models.Album() + return {a.name for a in album.artists} + elif field == 'album': + def distinct(track): + album = track.album or models.Album() + return {album.name} + elif field == 'composer': + def distinct(track): + return {a.name for a in track.composers} + elif field == 'performer': + def distinct(track): + return {a.name for a in track.performers} + elif field == 'date': + def distinct(track): + return {track.date} + elif field == 'genre': + def distinct(track): + return {track.genre} + else: + return set() + + distinct_result = set() + search_result = search.search(self._tracks.values(), query, limit=None) + for track in search_result.tracks: + distinct_result.update(distinct(track)) + return distinct_result def search(self, query=None, limit=100, offset=0, uris=None, exact=False): tracks = self._tracks.values() - # TODO: pass limit and offset into search helpers if exact: - return search.find_exact(tracks, query=query, uris=uris) + return search.find_exact( + tracks, query=query, limit=limit, offset=offset, uris=uris) else: - return search.search(tracks, query=query, uris=uris) + return search.search( + tracks, query=query, limit=limit, offset=offset, uris=uris) def begin(self): - return self._tracks.itervalues() + return compat.itervalues(self._tracks) def add(self, track): self._tracks[track.uri] = track diff --git a/mopidy/local/library.py b/mopidy/local/library.py index a4645084..5e98964c 100644 --- a/mopidy/local/library.py +++ b/mopidy/local/library.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import logging @@ -23,6 +23,16 @@ class LocalLibraryProvider(backend.LibraryProvider): return [] return self._library.browse(uri) + def get_distinct(self, field, query=None): + if not self._library: + return set() + return self._library.get_distinct(field, query) + + def get_images(self, uris): + if not self._library: + return {} + return self._library.get_images(uris) + def refresh(self, uri=None): if not self._library: return 0 @@ -33,18 +43,15 @@ class LocalLibraryProvider(backend.LibraryProvider): def lookup(self, uri): if not self._library: return [] - track = self._library.lookup(uri) - if track is None: + tracks = self._library.lookup(uri) + if tracks is None: logger.debug('Failed to lookup %r', uri) return [] - return [track] + if isinstance(tracks, models.Track): + tracks = [tracks] + return tracks - def find_exact(self, query=None, uris=None): + def search(self, query=None, uris=None, exact=False): if not self._library: return None - return self._library.search(query=query, uris=uris, exact=True) - - def search(self, query=None, uris=None): - if not self._library: - return None - return self._library.search(query=query, uris=uris, exact=False) + return self._library.search(query=query, uris=uris, exact=exact) diff --git a/mopidy/local/playback.py b/mopidy/local/playback.py index aa0e5b3a..82f27fdd 100644 --- a/mopidy/local/playback.py +++ b/mopidy/local/playback.py @@ -1,16 +1,10 @@ -from __future__ import unicode_literals - -import logging +from __future__ import absolute_import, unicode_literals from mopidy import backend from mopidy.local import translator -logger = logging.getLogger(__name__) - - class LocalPlaybackProvider(backend.PlaybackProvider): - def change_track(self, track): - track = track.copy(uri=translator.local_track_uri_to_file_uri( - track.uri, self.backend.config['local']['media_dir'])) - return super(LocalPlaybackProvider, self).change_track(track) + def translate_uri(self, uri): + return translator.local_track_uri_to_file_uri( + uri, self.backend.config['local']['media_dir']) diff --git a/mopidy/local/playlists.py b/mopidy/local/playlists.py deleted file mode 100644 index f22c6fde..00000000 --- a/mopidy/local/playlists.py +++ /dev/null @@ -1,122 +0,0 @@ -from __future__ import unicode_literals - -import glob -import logging -import os -import shutil - -from mopidy import backend -from mopidy.models import Playlist -from mopidy.utils import formatting, path - -from .translator import parse_m3u - - -logger = logging.getLogger(__name__) - - -class LocalPlaylistsProvider(backend.PlaylistsProvider): - def __init__(self, *args, **kwargs): - super(LocalPlaylistsProvider, self).__init__(*args, **kwargs) - self._media_dir = self.backend.config['local']['media_dir'] - self._playlists_dir = self.backend.config['local']['playlists_dir'] - self.refresh() - - def create(self, name): - name = formatting.slugify(name) - uri = 'local:playlist:%s.m3u' % name - playlist = Playlist(uri=uri, name=name) - return self.save(playlist) - - def delete(self, uri): - playlist = self.lookup(uri) - if not playlist: - return - - self._playlists.remove(playlist) - self._delete_m3u(playlist.uri) - - def lookup(self, uri): - # TODO: store as {uri: playlist}? - for playlist in self._playlists: - if playlist.uri == uri: - return playlist - - def refresh(self): - playlists = [] - - for m3u in glob.glob(os.path.join(self._playlists_dir, '*.m3u')): - name = os.path.splitext(os.path.basename(m3u))[0] - uri = 'local:playlist:%s' % name - - tracks = [] - for track in parse_m3u(m3u, self._media_dir): - tracks.append(track) - - playlist = Playlist(uri=uri, name=name, tracks=tracks) - playlists.append(playlist) - - self.playlists = playlists - # TODO: send what scheme we loaded them for? - backend.BackendListener.send('playlists_loaded') - - logger.info( - 'Loaded %d local playlists from %s', - len(playlists), self._playlists_dir) - - def save(self, playlist): - assert playlist.uri, 'Cannot save playlist without URI' - - old_playlist = self.lookup(playlist.uri) - - if old_playlist and playlist.name != old_playlist.name: - playlist = playlist.copy(name=formatting.slugify(playlist.name)) - playlist = self._rename_m3u(playlist) - - self._save_m3u(playlist) - - if old_playlist is not None: - index = self._playlists.index(old_playlist) - self._playlists[index] = playlist - else: - self._playlists.append(playlist) - - return playlist - - def _m3u_uri_to_path(self, uri): - # TODO: create uri handling helpers for local uri types. - file_path = path.uri_to_path(uri).split(':', 1)[1] - file_path = os.path.join(self._playlists_dir, file_path) - path.check_file_path_is_inside_base_dir(file_path, self._playlists_dir) - return file_path - - def _write_m3u_extinf(self, file_handle, track): - title = track.name.encode('latin-1', 'replace') - runtime = track.length / 1000 if track.length else -1 - file_handle.write('#EXTINF:' + str(runtime) + ',' + title + '\n') - - def _save_m3u(self, playlist): - file_path = self._m3u_uri_to_path(playlist.uri) - extended = any(track.name for track in playlist.tracks) - with open(file_path, 'w') as file_handle: - if extended: - file_handle.write('#EXTM3U\n') - for track in playlist.tracks: - if extended and track.name: - self._write_m3u_extinf(file_handle, track) - file_handle.write(track.uri + '\n') - - def _delete_m3u(self, uri): - file_path = self._m3u_uri_to_path(uri) - if os.path.exists(file_path): - os.remove(file_path) - - def _rename_m3u(self, playlist): - dst_name = formatting.slugify(playlist.name) - dst_uri = 'local:playlist:%s.m3u' % dst_name - - src_file_path = self._m3u_uri_to_path(playlist.uri) - dst_file_path = self._m3u_uri_to_path(dst_uri) - - shutil.move(src_file_path, dst_file_path) - return playlist.copy(uri=dst_uri) diff --git a/mopidy/local/search.py b/mopidy/local/search.py index 68d0a1f5..fdbe871c 100644 --- a/mopidy/local/search.py +++ b/mopidy/local/search.py @@ -1,9 +1,20 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals -from mopidy.models import Album, SearchResult +from mopidy.models import SearchResult -def find_exact(tracks, query=None, uris=None): +def find_exact(tracks, query=None, limit=100, offset=0, uris=None): + """ + Filter a list of tracks where ``field`` is ``values``. + + :param list tracks: a list of :class:`~mopidy.models.Track` + :param dict query: one or more field/value pairs to search for + :param int limit: maximum number of results to return + :param int offset: offset into result set to use. + :param uris: zero or more URI roots to limit the search to + :type uris: list of strings or :class:`None` + :rtype: :class:`~mopidy.models.SearchResult` + """ # TODO Only return results within URI roots given by ``uris`` if query is None: @@ -11,9 +22,7 @@ def find_exact(tracks, query=None, uris=None): _validate_query(query) - for (field, values) in query.iteritems(): - if not hasattr(values, '__iter__'): - values = [values] + for (field, values) in query.items(): # FIXME this is bound to be slow for large libraries for value in values: if field == 'track_no': @@ -21,36 +30,52 @@ def find_exact(tracks, query=None, uris=None): else: q = value.strip() - uri_filter = lambda t: q == t.uri - track_name_filter = lambda t: q == t.name - album_filter = lambda t: q == getattr(t, 'album', Album()).name - artist_filter = lambda t: filter( - lambda a: q == a.name, t.artists) - albumartist_filter = lambda t: any([ - q == a.name - for a in getattr(t.album, 'artists', [])]) - composer_filter = lambda t: any([ - q == a.name - for a in getattr(t, 'composers', [])]) - performer_filter = lambda t: any([ - q == a.name - for a in getattr(t, 'performers', [])]) - track_no_filter = lambda t: q == t.track_no - genre_filter = lambda t: t.genre and q == t.genre - date_filter = lambda t: q == t.date - comment_filter = lambda t: q == t.comment - any_filter = lambda t: ( - uri_filter(t) or - track_name_filter(t) or - album_filter(t) or - artist_filter(t) or - albumartist_filter(t) or - composer_filter(t) or - performer_filter(t) or - track_no_filter(t) or - genre_filter(t) or - date_filter(t) or - comment_filter(t)) + def uri_filter(t): + return q == t.uri + + def track_name_filter(t): + return q == t.name + + def album_filter(t): + return q == getattr(getattr(t, 'album', None), 'name', None) + + def artist_filter(t): + return filter(lambda a: q == a.name, t.artists) + + def albumartist_filter(t): + return any([q == a.name for a in getattr(t.album, + 'artists', [])]) + + def composer_filter(t): + return any([q == a.name for a in getattr(t, 'composers', [])]) + + def performer_filter(t): + return any([q == a.name for a in getattr(t, 'performers', [])]) + + def track_no_filter(t): + return q == t.track_no + + def genre_filter(t): + return (t.genre and q == t.genre) + + def date_filter(t): + return q == t.date + + def comment_filter(t): + return q == t.comment + + def any_filter(t): + return (uri_filter(t) or + track_name_filter(t) or + album_filter(t) or + artist_filter(t) or + albumartist_filter(t) or + composer_filter(t) or + performer_filter(t) or + track_no_filter(t) or + genre_filter(t) or + date_filter(t) or + comment_filter(t)) if field == 'uri': tracks = filter(uri_filter, tracks) @@ -79,11 +104,26 @@ def find_exact(tracks, query=None, uris=None): else: raise LookupError('Invalid lookup field: %s' % field) + if limit is None: + tracks = tracks[offset:] + else: + tracks = tracks[offset:offset + limit] # TODO: add local:search: return SearchResult(uri='local:search', tracks=tracks) -def search(tracks, query=None, uris=None): +def search(tracks, query=None, limit=100, offset=0, uris=None): + """ + Filter a list of tracks where ``field`` is like ``values``. + + :param list tracks: a list of :class:`~mopidy.models.Track` + :param dict query: one or more field/value pairs to search for + :param int limit: maximum number of results to return + :param int offset: offset into result set to use. + :param uris: zero or more URI roots to limit the search to + :type uris: list of strings or :class:`None` + :rtype: :class:`~mopidy.models.SearchResult` + """ # TODO Only return results within URI roots given by ``uris`` if query is None: @@ -91,9 +131,7 @@ def search(tracks, query=None, uris=None): _validate_query(query) - for (field, values) in query.iteritems(): - if not hasattr(values, '__iter__'): - values = [values] + for (field, values) in query.items(): # FIXME this is bound to be slow for large libraries for value in values: if field == 'track_no': @@ -101,38 +139,56 @@ def search(tracks, query=None, uris=None): else: q = value.strip().lower() - uri_filter = lambda t: bool(t.uri and q in t.uri.lower()) - track_name_filter = lambda t: bool(t.name and q in t.name.lower()) - album_filter = lambda t: bool( - t.album and t.album.name and q in t.album.name.lower()) - artist_filter = lambda t: bool(filter( - lambda a: bool(a.name and q in a.name.lower()), t.artists)) - albumartist_filter = lambda t: any([ - a.name and q in a.name.lower() - for a in getattr(t.album, 'artists', [])]) - composer_filter = lambda t: any([ - a.name and q in a.name.lower() - for a in getattr(t, 'composers', [])]) - performer_filter = lambda t: any([ - a.name and q in a.name.lower() - for a in getattr(t, 'performers', [])]) - track_no_filter = lambda t: q == t.track_no - genre_filter = lambda t: bool(t.genre and q in t.genre.lower()) - date_filter = lambda t: bool(t.date and t.date.startswith(q)) - comment_filter = lambda t: bool( - t.comment and q in t.comment.lower()) - any_filter = lambda t: ( - uri_filter(t) or - track_name_filter(t) or - album_filter(t) or - artist_filter(t) or - albumartist_filter(t) or - composer_filter(t) or - performer_filter(t) or - track_no_filter(t) or - genre_filter(t) or - date_filter(t) or - comment_filter(t)) + def uri_filter(t): + return bool(t.uri and q in t.uri.lower()) + + def track_name_filter(t): + return bool(t.name and q in t.name.lower()) + + def album_filter(t): + return bool(t.album and t.album.name and + q in t.album.name.lower()) + + def artist_filter(t): + return bool(filter(lambda a: + bool(a.name and q in a.name.lower()), t.artists)) + + def albumartist_filter(t): + return any([a.name and q in a.name.lower() + for a in getattr(t.album, 'artists', [])]) + + def composer_filter(t): + return any([a.name and q in a.name.lower() + for a in getattr(t, 'composers', [])]) + + def performer_filter(t): + return any([a.name and q in a.name.lower() + for a in getattr(t, 'performers', [])]) + + def track_no_filter(t): + return q == t.track_no + + def genre_filter(t): + return bool(t.genre and q in t.genre.lower()) + + def date_filter(t): + return bool(t.date and t.date.startswith(q)) + + def comment_filter(t): + return bool(t.comment and q in t.comment.lower()) + + def any_filter(t): + return (uri_filter(t) or + track_name_filter(t) or + album_filter(t) or + artist_filter(t) or + albumartist_filter(t) or + composer_filter(t) or + performer_filter(t) or + track_no_filter(t) or + genre_filter(t) or + date_filter(t) or + comment_filter(t)) if field == 'uri': tracks = filter(uri_filter, tracks) @@ -160,12 +216,17 @@ def search(tracks, query=None, uris=None): tracks = filter(any_filter, tracks) else: raise LookupError('Invalid lookup field: %s' % field) + + if limit is None: + tracks = tracks[offset:] + else: + tracks = tracks[offset:offset + limit] # TODO: add local:search: return SearchResult(uri='local:search', tracks=tracks) def _validate_query(query): - for (_, values) in query.iteritems(): + for (_, values) in query.items(): if not values: raise LookupError('Missing query') for value in values: diff --git a/mopidy/local/storage.py b/mopidy/local/storage.py index d83bdf77..21d278e5 100644 --- a/mopidy/local/storage.py +++ b/mopidy/local/storage.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import logging import os @@ -20,11 +20,3 @@ def check_dirs_and_files(config): logger.warning( 'Could not create local data dir: %s', encoding.locale_decode(error)) - - # TODO: replace with data dir? - try: - path.get_or_create_dir(config['local']['playlists_dir']) - except EnvironmentError as error: - logger.warning( - 'Could not create local playlists dir: %s', - encoding.locale_decode(error)) diff --git a/mopidy/local/translator.py b/mopidy/local/translator.py index 3c1d38ae..92b20a7b 100644 --- a/mopidy/local/translator.py +++ b/mopidy/local/translator.py @@ -1,18 +1,13 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import logging import os -import re import urllib -import urlparse -from mopidy.models import Track -from mopidy.utils.encoding import locale_decode +from mopidy import compat from mopidy.utils.path import path_to_uri, uri_to_path -M3U_EXTINF_RE = re.compile(r'#EXTINF:(-1|\d+),(.*)') - logger = logging.getLogger(__name__) @@ -28,93 +23,14 @@ def local_track_uri_to_path(uri, media_dir): def path_to_local_track_uri(relpath): - """Convert path releative to media_dir to local track URI.""" - if isinstance(relpath, unicode): + """Convert path relative to media_dir to local track URI.""" + if isinstance(relpath, compat.text_type): relpath = relpath.encode('utf-8') return b'local:track:%s' % urllib.quote(relpath) def path_to_local_directory_uri(relpath): """Convert path relative to :confval:`local/media_dir` directory URI.""" - if isinstance(relpath, unicode): + if isinstance(relpath, compat.text_type): relpath = relpath.encode('utf-8') return b'local:directory:%s' % urllib.quote(relpath) - - -def m3u_extinf_to_track(line): - """Convert extended M3U directive to track template.""" - m = M3U_EXTINF_RE.match(line) - if not m: - logger.warning('Invalid extended M3U directive: %s', line) - return Track() - (runtime, title) = m.groups() - if int(runtime) > 0: - return Track(name=title, length=1000 * int(runtime)) - else: - return Track(name=title) - - -def parse_m3u(file_path, media_dir): - r""" - Convert M3U file list to list of tracks - - Example M3U data:: - - # This is a comment - Alternative\Band - Song.mp3 - Classical\Other Band - New Song.mp3 - Stuff.mp3 - D:\More Music\Foo.mp3 - http://www.example.com:8000/Listen.pls - http://www.example.com/~user/Mine.mp3 - - Example extended M3U data:: - - #EXTM3U - #EXTINF:123, Sample artist - Sample title - Sample.mp3 - #EXTINF:321,Example Artist - Example title - Greatest Hits\Example.ogg - #EXTINF:-1,Radio XMP - http://mp3stream.example.com:8000/ - - - Relative paths of songs should be with respect to location of M3U. - - Paths are normally platform specific. - - Lines starting with # are ignored, except for extended M3U directives. - - Track.name and Track.length are set from extended M3U directives. - - m3u files are latin-1. - """ - # TODO: uris as bytes - tracks = [] - try: - with open(file_path) as m3u: - contents = m3u.readlines() - except IOError as error: - logger.warning('Couldn\'t open m3u: %s', locale_decode(error)) - return tracks - - if not contents: - return tracks - - extended = contents[0].decode('latin1').startswith('#EXTM3U') - - track = Track() - for line in contents: - line = line.strip().decode('latin1') - - if line.startswith('#'): - if extended and line.startswith('#EXTINF'): - track = m3u_extinf_to_track(line) - continue - - if urlparse.urlsplit(line).scheme: - tracks.append(track.copy(uri=line)) - elif os.path.normpath(line) == os.path.abspath(line): - path = path_to_uri(line) - tracks.append(track.copy(uri=path)) - else: - path = path_to_uri(os.path.join(media_dir, line)) - tracks.append(track.copy(uri=path)) - - track = Track() - return tracks diff --git a/mopidy/m3u/__init__.py b/mopidy/m3u/__init__.py new file mode 100644 index 00000000..e0fcf305 --- /dev/null +++ b/mopidy/m3u/__init__.py @@ -0,0 +1,30 @@ +from __future__ import absolute_import, unicode_literals + +import logging +import os + +import mopidy +from mopidy import config, ext + +logger = logging.getLogger(__name__) + + +class Extension(ext.Extension): + + dist_name = 'Mopidy-M3U' + ext_name = 'm3u' + version = mopidy.__version__ + + def get_default_config(self): + conf_file = os.path.join(os.path.dirname(__file__), 'ext.conf') + return config.read(conf_file) + + def get_config_schema(self): + schema = super(Extension, self).get_config_schema() + schema['playlists_dir'] = config.Path() + return schema + + def setup(self, registry): + from .actor import M3UBackend + + registry.add('backend', M3UBackend) diff --git a/mopidy/m3u/actor.py b/mopidy/m3u/actor.py new file mode 100644 index 00000000..3908d938 --- /dev/null +++ b/mopidy/m3u/actor.py @@ -0,0 +1,32 @@ +from __future__ import absolute_import, unicode_literals + +import logging + +import pykka + +from mopidy import backend +from mopidy.m3u.library import M3ULibraryProvider +from mopidy.m3u.playlists import M3UPlaylistsProvider +from mopidy.utils import encoding, path + + +logger = logging.getLogger(__name__) + + +class M3UBackend(pykka.ThreadingActor, backend.Backend): + uri_schemes = ['m3u'] + + def __init__(self, config, audio): + super(M3UBackend, self).__init__() + + self._config = config + + try: + path.get_or_create_dir(config['m3u']['playlists_dir']) + except EnvironmentError as error: + logger.warning( + 'Could not create M3U playlists dir: %s', + encoding.locale_decode(error)) + + self.playlists = M3UPlaylistsProvider(backend=self) + self.library = M3ULibraryProvider(backend=self) diff --git a/mopidy/m3u/ext.conf b/mopidy/m3u/ext.conf new file mode 100644 index 00000000..0e828b1b --- /dev/null +++ b/mopidy/m3u/ext.conf @@ -0,0 +1,3 @@ +[m3u] +enabled = true +playlists_dir = $XDG_DATA_DIR/mopidy/m3u diff --git a/mopidy/m3u/library.py b/mopidy/m3u/library.py new file mode 100644 index 00000000..3b5bded1 --- /dev/null +++ b/mopidy/m3u/library.py @@ -0,0 +1,18 @@ +from __future__ import absolute_import, unicode_literals + +import logging + +from mopidy import backend + +logger = logging.getLogger(__name__) + + +class M3ULibraryProvider(backend.LibraryProvider): + """Library for looking up M3U playlists.""" + + def __init__(self, backend): + super(M3ULibraryProvider, self).__init__(backend) + + def lookup(self, uri): + # TODO Lookup tracks in M3U playlist + return [] diff --git a/mopidy/m3u/playlists.py b/mopidy/m3u/playlists.py new file mode 100644 index 00000000..c09eccdf --- /dev/null +++ b/mopidy/m3u/playlists.py @@ -0,0 +1,127 @@ +from __future__ import absolute_import, division, unicode_literals + +import glob +import logging +import operator +import os +import re +import sys + +from mopidy import backend +from mopidy.m3u import translator +from mopidy.models import Playlist, Ref + + +logger = logging.getLogger(__name__) + + +class M3UPlaylistsProvider(backend.PlaylistsProvider): + + # TODO: currently this only handles UNIX file systems + _invalid_filename_chars = re.compile(r'[/]') + + def __init__(self, *args, **kwargs): + super(M3UPlaylistsProvider, self).__init__(*args, **kwargs) + + self._playlists_dir = self.backend._config['m3u']['playlists_dir'] + self._playlists = {} + self.refresh() + + def as_list(self): + refs = [ + Ref.playlist(uri=pl.uri, name=pl.name) + for pl in self._playlists.values()] + return sorted(refs, key=operator.attrgetter('name')) + + def get_items(self, uri): + playlist = self._playlists.get(uri) + if playlist is None: + return None + return [Ref.track(uri=t.uri, name=t.name) for t in playlist.tracks] + + def create(self, name): + playlist = self._save_m3u(Playlist(name=name)) + self._playlists[playlist.uri] = playlist + logger.info('Created playlist %s', playlist.uri) + return playlist + + def delete(self, uri): + if uri in self._playlists: + path = translator.playlist_uri_to_path(uri, self._playlists_dir) + if os.path.exists(path): + os.remove(path) + else: + logger.warn('Trying to delete missing playlist file %s', path) + del self._playlists[uri] + else: + logger.warn('Trying to delete unknown playlist %s', uri) + + def lookup(self, uri): + return self._playlists.get(uri) + + def refresh(self): + playlists = {} + + encoding = sys.getfilesystemencoding() + for path in glob.glob(os.path.join(self._playlists_dir, b'*.m3u')): + relpath = os.path.basename(path) + uri = translator.path_to_playlist_uri(relpath) + name = os.path.splitext(relpath)[0].decode(encoding) + tracks = translator.parse_m3u(path) + playlists[uri] = Playlist(uri=uri, name=name, tracks=tracks) + + self._playlists = playlists + + logger.info( + 'Loaded %d M3U playlists from %s', + len(playlists), self._playlists_dir) + + def save(self, playlist): + assert playlist.uri, 'Cannot save playlist without URI' + assert playlist.uri in self._playlists, \ + 'Cannot save playlist with unknown URI: %s' % playlist.uri + + original_uri = playlist.uri + playlist = self._save_m3u(playlist) + if playlist.uri != original_uri and original_uri in self._playlists: + self.delete(original_uri) + self._playlists[playlist.uri] = playlist + return playlist + + def _write_m3u_extinf(self, file_handle, track): + title = track.name.encode('latin-1', 'replace') + runtime = track.length // 1000 if track.length else -1 + file_handle.write('#EXTINF:' + str(runtime) + ',' + title + '\n') + + def _sanitize_m3u_name(self, name, encoding=sys.getfilesystemencoding()): + name = self._invalid_filename_chars.sub('|', name.strip()) + # make sure we end up with a valid path segment + name = name.encode(encoding, errors='replace') + name = os.path.basename(name) # paranoia? + name = name.decode(encoding) + return name + + def _save_m3u(self, playlist, encoding=sys.getfilesystemencoding()): + if playlist.name: + name = self._sanitize_m3u_name(playlist.name, encoding) + uri = translator.path_to_playlist_uri( + name.encode(encoding) + b'.m3u') + path = translator.playlist_uri_to_path(uri, self._playlists_dir) + elif playlist.uri: + uri = playlist.uri + path = translator.playlist_uri_to_path(uri, self._playlists_dir) + name, _ = os.path.splitext(os.path.basename(path).decode(encoding)) + else: + raise ValueError('M3U playlist needs name or URI') + extended = any(track.name for track in playlist.tracks) + + with open(path, 'w') as file_handle: + if extended: + file_handle.write('#EXTM3U\n') + for track in playlist.tracks: + if extended and track.name: + self._write_m3u_extinf(file_handle, track) + file_handle.write(track.uri + '\n') + + # assert playlist name matches file name/uri + return playlist.copy(uri=uri, name=name) diff --git a/mopidy/m3u/translator.py b/mopidy/m3u/translator.py new file mode 100644 index 00000000..4eefce9d --- /dev/null +++ b/mopidy/m3u/translator.py @@ -0,0 +1,110 @@ +from __future__ import absolute_import, unicode_literals + +import logging +import os +import re +import urllib +import urlparse + +from mopidy import compat +from mopidy.models import Track +from mopidy.utils.encoding import locale_decode +from mopidy.utils.path import path_to_uri, uri_to_path + + +M3U_EXTINF_RE = re.compile(r'#EXTINF:(-1|\d+),(.*)') + +logger = logging.getLogger(__name__) + + +def playlist_uri_to_path(uri, playlists_dir): + if not uri.startswith('m3u:'): + raise ValueError('Invalid URI %s' % uri) + file_path = uri_to_path(uri) + return os.path.join(playlists_dir, file_path) + + +def path_to_playlist_uri(relpath): + """Convert path relative to playlists_dir to M3U URI.""" + if isinstance(relpath, compat.text_type): + relpath = relpath.encode('utf-8') + return b'm3u:%s' % urllib.quote(relpath) + + +def m3u_extinf_to_track(line): + """Convert extended M3U directive to track template.""" + m = M3U_EXTINF_RE.match(line) + if not m: + logger.warning('Invalid extended M3U directive: %s', line) + return Track() + (runtime, title) = m.groups() + if int(runtime) > 0: + return Track(name=title, length=1000 * int(runtime)) + else: + return Track(name=title) + + +def parse_m3u(file_path, media_dir=None): + r""" + Convert M3U file list to list of tracks + + Example M3U data:: + + # This is a comment + Alternative\Band - Song.mp3 + Classical\Other Band - New Song.mp3 + Stuff.mp3 + D:\More Music\Foo.mp3 + http://www.example.com:8000/Listen.pls + http://www.example.com/~user/Mine.mp3 + + Example extended M3U data:: + + #EXTM3U + #EXTINF:123, Sample artist - Sample title + Sample.mp3 + #EXTINF:321,Example Artist - Example title + Greatest Hits\Example.ogg + #EXTINF:-1,Radio XMP + http://mp3stream.example.com:8000/ + + - Relative paths of songs should be with respect to location of M3U. + - Paths are normally platform specific. + - Lines starting with # are ignored, except for extended M3U directives. + - Track.name and Track.length are set from extended M3U directives. + - m3u files are latin-1. + """ + # TODO: uris as bytes + tracks = [] + try: + with open(file_path) as m3u: + contents = m3u.readlines() + except IOError as error: + logger.warning('Couldn\'t open m3u: %s', locale_decode(error)) + return tracks + + if not contents: + return tracks + + extended = contents[0].decode('latin1').startswith('#EXTM3U') + + track = Track() + for line in contents: + line = line.strip().decode('latin1') + + if line.startswith('#'): + if extended and line.startswith('#EXTINF'): + track = m3u_extinf_to_track(line) + continue + + if urlparse.urlsplit(line).scheme: + tracks.append(track.copy(uri=line)) + elif os.path.normpath(line) == os.path.abspath(line): + path = path_to_uri(line) + tracks.append(track.copy(uri=path)) + elif media_dir is not None: + path = path_to_uri(os.path.join(media_dir, line)) + tracks.append(track.copy(uri=path)) + + track = Track() + return tracks diff --git a/mopidy/mixer.py b/mopidy/mixer.py index be793a7c..e277fe55 100644 --- a/mopidy/mixer.py +++ b/mopidy/mixer.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import logging diff --git a/mopidy/models.py b/mopidy/models.py index bedf8ca5..f79b70e4 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import json @@ -153,7 +153,7 @@ class Ref(ImmutableObject): :param name: object name :type name: string :param type: object type - :type name: string + :type type: string """ #: The object URI. Read-only. @@ -212,6 +212,23 @@ class Ref(ImmutableObject): return cls(**kwargs) +class Image(ImmutableObject): + """ + :param string uri: URI of the image + :param int width: Optional width of image or :class:`None` + :param int height: Optional height of image or :class:`None` + """ + + #: The image URI. Read-only. + uri = None + + #: Optional width of the image or :class:`None`. Read-only. + width = None + + #: Optional height of the image or :class:`None`. Read-only. + height = None + + class Artist(ImmutableObject): """ :param uri: artist URI @@ -308,7 +325,7 @@ class Track(ImmutableObject): :param date: track release date (YYYY or YYYY-MM-DD) :type date: string :param length: track length in milliseconds - :type length: integer + :type length: integer or :class:`None` if there is no duration :param bitrate: bitrate in kbit/s :type bitrate: integer :param comment: track comment @@ -361,13 +378,16 @@ class Track(ImmutableObject): #: The MusicBrainz ID of the track. Read-only. musicbrainz_id = None - #: Integer representing when the track was last modified, exact meaning - #: depends on source of track. For local files this is the mtime, for other - #: backends it could be a timestamp or simply a version counter. + #: Integer representing when the track was last modified. Exact meaning + #: depends on source of track. For local files this is the modification + #: time in milliseconds since Unix epoch. For other backends it could be an + #: equivalent timestamp or simply a version counter. last_modified = None def __init__(self, *args, **kwargs): - get = lambda key: frozenset(kwargs.pop(key, None) or []) + def get(key): + return frozenset(kwargs.pop(key, None) or []) + self.__dict__['artists'] = get('artists') self.__dict__['composers'] = get('composers') self.__dict__['performers'] = get('performers') diff --git a/mopidy/mpd/__init__.py b/mopidy/mpd/__init__.py index 77aaf83f..b2438b07 100644 --- a/mopidy/mpd/__init__.py +++ b/mopidy/mpd/__init__.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import os @@ -24,6 +24,7 @@ class Extension(ext.Extension): schema['max_connections'] = config.Integer(minimum=1) schema['connection_timeout'] = config.Integer(minimum=1) schema['zeroconf'] = config.String(optional=True) + schema['command_blacklist'] = config.List(optional=True) return schema def validate_environment(self): diff --git a/mopidy/mpd/actor.py b/mopidy/mpd/actor.py index 49d9556e..2aecb6d1 100644 --- a/mopidy/mpd/actor.py +++ b/mopidy/mpd/actor.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import logging @@ -6,7 +6,7 @@ import pykka from mopidy import exceptions, zeroconf from mopidy.core import CoreListener -from mopidy.mpd import session +from mopidy.mpd import session, uri_mapper from mopidy.utils import encoding, network, process logger = logging.getLogger(__name__) @@ -18,6 +18,7 @@ class MpdFrontend(pykka.ThreadingActor, CoreListener): self.hostname = network.format_hostname(config['mpd']['hostname']) self.port = config['mpd']['port'] + self.uri_map = uri_mapper.MpdUriMapper(core) self.zeroconf_name = config['mpd']['zeroconf'] self.zeroconf_service = None @@ -29,6 +30,7 @@ class MpdFrontend(pykka.ThreadingActor, CoreListener): protocol_kwargs={ 'config': config, 'core': core, + 'uri_map': self.uri_map, }, max_connections=config['mpd']['max_connections'], timeout=config['mpd']['connection_timeout']) @@ -71,3 +73,6 @@ class MpdFrontend(pykka.ThreadingActor, CoreListener): def mute_changed(self, mute): self.send_idle('output') + + def stream_title_changed(self, title): + self.send_idle('playlist') diff --git a/mopidy/mpd/dispatcher.py b/mopidy/mpd/dispatcher.py index 84550698..d156b891 100644 --- a/mopidy/mpd/dispatcher.py +++ b/mopidy/mpd/dispatcher.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import logging import re @@ -21,7 +21,7 @@ class MpdDispatcher(object): _noidle = re.compile(r'^noidle$') - def __init__(self, session=None, config=None, core=None): + def __init__(self, session=None, config=None, core=None, uri_map=None): self.config = config self.authenticated = False self.command_list_receiving = False @@ -29,7 +29,7 @@ class MpdDispatcher(object): self.command_list = [] self.command_list_index = None self.context = MpdContext( - self, session=session, config=config, core=core) + self, session=session, config=config, core=core, uri_map=uri_map) def handle_request(self, request, current_command_list_index=None): """Dispatch incoming requests to the correct handler.""" @@ -163,6 +163,11 @@ class MpdDispatcher(object): def _call_handler(self, request): tokens = tokenize.split(request) + # TODO: check that blacklist items are valid commands? + blacklist = self.config['mpd'].get('command_blacklist', []) + if tokens and tokens[0] in blacklist: + logger.warning('Client sent us blacklisted command: %s', tokens[0]) + raise exceptions.MpdDisabled(command=tokens[0]) try: return protocol.commands.call(tokens, context=self.context) except exceptions.MpdAckError as exc: @@ -227,10 +232,10 @@ class MpdContext(object): #: The subsytems that we want to be notified about in idle mode. subscriptions = None - _invalid_browse_chars = re.compile(r'[\n\r]') - _invalid_playlist_chars = re.compile(r'[/]') + _uri_map = None - def __init__(self, dispatcher, session=None, config=None, core=None): + def __init__(self, dispatcher, session=None, config=None, core=None, + uri_map=None): self.dispatcher = dispatcher self.session = session if config is not None: @@ -238,77 +243,52 @@ class MpdContext(object): self.core = core self.events = set() self.subscriptions = set() - self._uri_from_name = {} - self._name_from_uri = {} - self.refresh_playlists_mapping() + self._uri_map = uri_map - def create_unique_name(self, name, uri): - stripped_name = self._invalid_browse_chars.sub(' ', name) - name = stripped_name - i = 2 - while name in self._uri_from_name: - if self._uri_from_name[name] == uri: - return name - name = '%s [%d]' % (stripped_name, i) - i += 1 - return name - - def insert_name_uri_mapping(self, name, uri): - name = self.create_unique_name(name, uri) - self._uri_from_name[name] = uri - self._name_from_uri[uri] = name - return name - - def refresh_playlists_mapping(self): - """ - Maintain map between playlists and unique playlist names to be used by - MPD - """ - if self.core is not None: - for playlist in self.core.playlists.playlists.get(): - if not playlist.name: - continue - # TODO: add scheme to name perhaps 'foo (spotify)' etc. - name = self._invalid_playlist_chars.sub(' ', playlist.name) - self.insert_name_uri_mapping(name, playlist.uri) - - def lookup_playlist_from_name(self, name): + def lookup_playlist_uri_from_name(self, name): """ Helper function to retrieve a playlist from its unique MPD name. """ - if not self._uri_from_name: - self.refresh_playlists_mapping() - if name not in self._uri_from_name: - return None - uri = self._uri_from_name[name] - return self.core.playlists.lookup(uri).get() + return self._uri_map.playlist_uri_from_name(name) def lookup_playlist_name_from_uri(self, uri): """ Helper function to retrieve the unique MPD playlist name from its uri. """ - if uri not in self._name_from_uri: - self.refresh_playlists_mapping() - return self._name_from_uri[uri] + return self._uri_map.playlist_name_from_uri(uri) def browse(self, path, recursive=True, lookup=True): + """ + Browse the contents of a given directory path. + + Returns a sequence of two-tuples ``(path, data)``. + + If ``recursive`` is true, it returns results for all entries in the + given path. + + If ``lookup`` is true and the ``path`` is to a track, the returned + ``data`` is a future which will contain the + :class:`mopidy.models.Track` model. If ``lookup`` is false and the + ``path`` is to a track, the returned ``data`` will be a + :class:`mopidy.models.Ref` for the track. + + For all entries that are not tracks, the returned ``data`` will be + :class:`None`. + """ + path_parts = re.findall(r'[^/]+', path or '') root_path = '/'.join([''] + path_parts) - if root_path not in self._uri_from_name: - uri = None + uri = self._uri_map.uri_from_name(root_path) + if uri is None: for part in path_parts: for ref in self.core.library.browse(uri).get(): - if (ref.type in (ref.DIRECTORY, ref.ALBUM, ref.PLAYLIST) - and ref.name == part): + if ref.type != ref.TRACK and ref.name == part: uri = ref.uri break else: raise exceptions.MpdNoExistError('Not found') - root_path = self.insert_name_uri_mapping(root_path, uri) - - else: - uri = self._uri_from_name[root_path] + root_path = self._uri_map.insert(root_path, uri) if recursive: yield (root_path, None) @@ -318,15 +298,15 @@ class MpdContext(object): base_path, future = path_and_futures.pop() for ref in future.get(): path = '/'.join([base_path, ref.name.replace('/', '')]) - path = self.insert_name_uri_mapping(path, ref.uri) + path = self._uri_map.insert(path, ref.uri) - if ref.type in (ref.DIRECTORY, ref.ALBUM, ref.PLAYLIST): - yield (path, None) - if recursive: - path_and_futures.append( - (path, self.core.library.browse(ref.uri))) - elif ref.type == ref.TRACK: + if ref.type == ref.TRACK: if lookup: yield (path, self.core.library.lookup(ref.uri)) else: yield (path, ref) + else: + yield (path, None) + if recursive: + path_and_futures.append( + (path, self.core.library.browse(ref.uri))) diff --git a/mopidy/mpd/exceptions.py b/mopidy/mpd/exceptions.py index 6738b4c9..6fc925a3 100644 --- a/mopidy/mpd/exceptions.py +++ b/mopidy/mpd/exceptions.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals from mopidy.exceptions import MopidyException @@ -87,3 +87,13 @@ class MpdNotImplemented(MpdAckError): def __init__(self, *args, **kwargs): super(MpdNotImplemented, self).__init__(*args, **kwargs) self.message = 'Not implemented' + + +class MpdDisabled(MpdAckError): + # NOTE: This is a custom error for Mopidy that does not exist in MPD. + error_code = 0 + + def __init__(self, *args, **kwargs): + super(MpdDisabled, self).__init__(*args, **kwargs) + assert self.command is not None, 'command must be given explicitly' + self.message = '"%s" has been disabled in the server' % self.command diff --git a/mopidy/mpd/ext.conf b/mopidy/mpd/ext.conf index c62c37ef..fe9a0494 100644 --- a/mopidy/mpd/ext.conf +++ b/mopidy/mpd/ext.conf @@ -6,3 +6,4 @@ password = max_connections = 20 connection_timeout = 60 zeroconf = Mopidy MPD server on $hostname +command_blacklist = listall,listallinfo diff --git a/mopidy/mpd/protocol/__init__.py b/mopidy/mpd/protocol/__init__.py index f0ae814b..ff04d435 100644 --- a/mopidy/mpd/protocol/__init__.py +++ b/mopidy/mpd/protocol/__init__.py @@ -10,7 +10,7 @@ implement our own MPD server which is compatible with the numerous existing `MPD clients `_. """ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import inspect @@ -36,7 +36,7 @@ def load_protocol_modules(): music_db, playback, reflection, status, stickers, stored_playlists) -def INT(value): +def INT(value): # noqa: N802 """Converts a value that matches [+-]?\d+ into and integer.""" if value is None: raise ValueError('None is not a valid integer') @@ -44,7 +44,7 @@ def INT(value): return int(value) -def UINT(value): +def UINT(value): # noqa: N802 """Converts a value that matches \d+ into an integer.""" if value is None: raise ValueError('None is not a valid integer') @@ -53,14 +53,14 @@ def UINT(value): return int(value) -def BOOL(value): +def BOOL(value): # noqa: N802 """Convert the values 0 and 1 into booleans.""" if value in ('1', '0'): return bool(int(value)) raise ValueError('%r is not 0 or 1' % value) -def RANGE(value): +def RANGE(value): # noqa: N802 """Convert a single integer or range spec into a slice ``n`` should become ``slice(n, n+1)`` diff --git a/mopidy/mpd/protocol/audio_output.py b/mopidy/mpd/protocol/audio_output.py index 2c7aea16..565ea3d0 100644 --- a/mopidy/mpd/protocol/audio_output.py +++ b/mopidy/mpd/protocol/audio_output.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals from mopidy.mpd import exceptions, protocol @@ -13,7 +13,9 @@ def disableoutput(context, outputid): Turns an output off. """ if outputid == 0: - context.core.playback.set_mute(False) + success = context.core.mixer.set_mute(False).get() + if not success: + raise exceptions.MpdSystemError('problems disabling output') else: raise exceptions.MpdNoExistError('No such audio output') @@ -28,13 +30,14 @@ def enableoutput(context, outputid): Turns an output on. """ if outputid == 0: - context.core.playback.set_mute(True) + success = context.core.mixer.set_mute(True).get() + if not success: + raise exceptions.MpdSystemError('problems enabling output') else: raise exceptions.MpdNoExistError('No such audio output') -# TODO: implement and test -# @protocol.commands.add('toggleoutput', outputid=protocol.UINT) +@protocol.commands.add('toggleoutput', outputid=protocol.UINT) def toggleoutput(context, outputid): """ *musicpd.org, audio output section:* @@ -43,7 +46,13 @@ def toggleoutput(context, outputid): Turns an output on or off, depending on the current state. """ - pass + if outputid == 0: + mute_status = context.core.mixer.get_mute().get() + success = context.core.mixer.set_mute(not mute_status) + if not success: + raise exceptions.MpdSystemError('problems toggling output') + else: + raise exceptions.MpdNoExistError('No such audio output') @protocol.commands.add('outputs') @@ -55,7 +64,7 @@ def outputs(context): Shows information about all outputs. """ - muted = 1 if context.core.playback.get_mute().get() else 0 + muted = 1 if context.core.mixer.get_mute().get() else 0 return [ ('outputid', 0), ('outputname', 'Mute'), diff --git a/mopidy/mpd/protocol/channels.py b/mopidy/mpd/protocol/channels.py index 4ae00622..7699abe3 100644 --- a/mopidy/mpd/protocol/channels.py +++ b/mopidy/mpd/protocol/channels.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals from mopidy.mpd import exceptions, protocol diff --git a/mopidy/mpd/protocol/command_list.py b/mopidy/mpd/protocol/command_list.py index d8551105..028134a9 100644 --- a/mopidy/mpd/protocol/command_list.py +++ b/mopidy/mpd/protocol/command_list.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals from mopidy.mpd import exceptions, protocol diff --git a/mopidy/mpd/protocol/connection.py b/mopidy/mpd/protocol/connection.py index 41896acf..f087847a 100644 --- a/mopidy/mpd/protocol/connection.py +++ b/mopidy/mpd/protocol/connection.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals from mopidy.mpd import exceptions, protocol diff --git a/mopidy/mpd/protocol/current_playlist.py b/mopidy/mpd/protocol/current_playlist.py index a2d60e96..d8e1a9d8 100644 --- a/mopidy/mpd/protocol/current_playlist.py +++ b/mopidy/mpd/protocol/current_playlist.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import warnings @@ -275,9 +275,21 @@ def plchanges(context, version): - Calls ``plchanges "-1"`` two times per second to get the entire playlist. """ # XXX Naive implementation that returns all tracks as changed - if int(version) < context.core.tracklist.version.get(): + tracklist_version = context.core.tracklist.version.get() + if version < tracklist_version: return translator.tracks_to_mpd_format( context.core.tracklist.tl_tracks.get()) + elif version == tracklist_version: + # A version match could indicate this is just a metadata update, so + # check for a stream ref and let the client know about the change. + stream_title = context.core.playback.get_stream_title().get() + if stream_title is None: + return None + + tl_track = context.core.playback.current_tl_track.get() + position = context.core.tracklist.index(tl_track).get() + return translator.track_to_mpd_format( + tl_track, position=position, stream_title=stream_title) @protocol.commands.add('plchangesposid', version=protocol.INT) diff --git a/mopidy/mpd/protocol/music_db.py b/mopidy/mpd/protocol/music_db.py index 5a7f51fb..a942abf5 100644 --- a/mopidy/mpd/protocol/music_db.py +++ b/mopidy/mpd/protocol/music_db.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import functools import itertools @@ -30,6 +30,15 @@ _LIST_MAPPING = { 'genre': 'genre', 'performer': 'performer'} +_LIST_NAME_MAPPING = { + 'album': 'Album', + 'albumartist': 'AlbumArtist', + 'artist': 'Artist', + 'composer': 'Composer', + 'date': 'Date', + 'genre': 'Genre', + 'performer': 'Performer'} + def _query_from_mpd_search_parameters(parameters, mapping): query = {} @@ -246,109 +255,30 @@ def list_(context, *args): - does not add quotes around the field argument. - capitalizes the field argument. """ - parameters = list(args) - if not parameters: + params = list(args) + if not params: raise exceptions.MpdArgError('incorrect arguments') - field = parameters.pop(0).lower() + field = params.pop(0).lower() if field not in _LIST_MAPPING: raise exceptions.MpdArgError('incorrect arguments') - if len(parameters) == 1: + if len(params) == 1: if field != 'album': raise exceptions.MpdArgError('should be "Album" for 3 arguments') - return _list_album(context, {'artist': parameters}) + query = {'artist': params} + else: + try: + query = _query_from_mpd_search_parameters(params, _LIST_MAPPING) + except exceptions.MpdArgError as e: + e.message = 'not able to parse args' + raise + except ValueError: + return - try: - query = _query_from_mpd_search_parameters(parameters, _LIST_MAPPING) - except exceptions.MpdArgError as e: - e.message = 'not able to parse args' - raise - except ValueError: - return - - if field == 'artist': - return _list_artist(context, query) - if field == 'albumartist': - return _list_albumartist(context, query) - elif field == 'album': - return _list_album(context, query) - elif field == 'composer': - return _list_composer(context, query) - elif field == 'performer': - return _list_performer(context, query) - elif field == 'date': - return _list_date(context, query) - elif field == 'genre': - return _list_genre(context, query) - - -def _list_artist(context, query): - artists = set() - results = context.core.library.find_exact(**query).get() - for track in _get_tracks(results): - for artist in track.artists: - if artist.name: - artists.add(('Artist', artist.name)) - return artists - - -def _list_albumartist(context, query): - albumartists = set() - results = context.core.library.find_exact(**query).get() - for track in _get_tracks(results): - if track.album: - for artist in track.album.artists: - if artist.name: - albumartists.add(('AlbumArtist', artist.name)) - return albumartists - - -def _list_album(context, query): - albums = set() - results = context.core.library.find_exact(**query).get() - for track in _get_tracks(results): - if track.album and track.album.name: - albums.add(('Album', track.album.name)) - return albums - - -def _list_composer(context, query): - composers = set() - results = context.core.library.find_exact(**query).get() - for track in _get_tracks(results): - for composer in track.composers: - if composer.name: - composers.add(('Composer', composer.name)) - return composers - - -def _list_performer(context, query): - performers = set() - results = context.core.library.find_exact(**query).get() - for track in _get_tracks(results): - for performer in track.performers: - if performer.name: - performers.add(('Performer', performer.name)) - return performers - - -def _list_date(context, query): - dates = set() - results = context.core.library.find_exact(**query).get() - for track in _get_tracks(results): - if track.date: - dates.add(('Date', track.date)) - return dates - - -def _list_genre(context, query): - genres = set() - results = context.core.library.find_exact(**query).get() - for track in _get_tracks(results): - if track.genre: - genres.add(('Genre', track.genre)) - return genres + name = _LIST_NAME_MAPPING[field] + result = context.core.library.get_distinct(field, query) + return [(name, value) for value in result.get()] @protocol.commands.add('listall') @@ -359,6 +289,13 @@ def listall(context, uri=None): ``listall [URI]`` Lists all songs and directories in ``URI``. + + Do not use this command. Do not manage a client-side copy of MPD's + database. That is fragile and adds huge overhead. It will break with + large databases. Instead, query MPD whenever you need something. + + + .. warning:: This command is disabled by default in Mopidy installs. """ result = [] for path, track_ref in context.browse(uri, lookup=False): @@ -381,6 +318,13 @@ def listallinfo(context, uri=None): Same as ``listall``, except it also returns metadata info in the same format as ``lsinfo``. + + Do not use this command. Do not manage a client-side copy of MPD's + database. That is fragile and adds huge overhead. It will break with + large databases. Instead, query MPD whenever you need something. + + + .. warning:: This command is disabled by default in Mopidy installs. """ result = [] for path, lookup_future in context.browse(uri): @@ -521,7 +465,8 @@ def searchaddpl(context, *args): return results = context.core.library.search(**query).get() - playlist = context.lookup_playlist_from_name(playlist_name) + uri = context.lookup_playlist_uri_from_name(playlist_name) + playlist = uri is not None and context.core.playlists.lookup(uri).get() if not playlist: playlist = context.core.playlists.create(playlist_name).get() tracks = list(playlist.tracks) + _get_tracks(results) diff --git a/mopidy/mpd/protocol/playback.py b/mopidy/mpd/protocol/playback.py index 5b63c561..86f2e36b 100644 --- a/mopidy/mpd/protocol/playback.py +++ b/mopidy/mpd/protocol/playback.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import warnings @@ -32,8 +32,7 @@ def crossfade(context, seconds): raise exceptions.MpdNotImplemented # TODO -# TODO: add at least reflection tests before adding NotImplemented version -# @protocol.commands.add('mixrampdb') +@protocol.commands.add('mixrampdb') def mixrampdb(context, decibels): """ *musicpd.org, playback section:* @@ -46,11 +45,10 @@ def mixrampdb(context, decibels): volume so use negative values, I prefer -17dB. In the absence of mixramp tags crossfading will be used. See http://sourceforge.net/projects/mixramp """ - pass + raise exceptions.MpdNotImplemented # TODO -# TODO: add at least reflection tests before adding NotImplemented version -# @protocol.commands.add('mixrampdelay', seconds=protocol.UINT) +@protocol.commands.add('mixrampdelay', seconds=protocol.UINT) def mixrampdelay(context, seconds): """ *musicpd.org, playback section:* @@ -61,7 +59,7 @@ def mixrampdelay(context, seconds): value of "nan" disables MixRamp overlapping and falls back to crossfading. """ - pass + raise exceptions.MpdNotImplemented # TODO @protocol.commands.add('next') @@ -397,7 +395,10 @@ def setvol(context, volume): - issues ``setvol 50`` without quotes around the argument. """ # NOTE: we use INT as clients can pass in +N etc. - context.core.playback.volume = min(max(0, volume), 100) + value = min(max(0, volume), 100) + success = context.core.mixer.set_volume(value).get() + if not success: + raise exceptions.MpdSystemError('problems setting volume') @protocol.commands.add('single', state=protocol.BOOL) diff --git a/mopidy/mpd/protocol/reflection.py b/mopidy/mpd/protocol/reflection.py index 4308c560..7feccca1 100644 --- a/mopidy/mpd/protocol/reflection.py +++ b/mopidy/mpd/protocol/reflection.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals from mopidy.mpd import exceptions, protocol diff --git a/mopidy/mpd/protocol/status.py b/mopidy/mpd/protocol/status.py index 8f97c2e4..aa78b387 100644 --- a/mopidy/mpd/protocol/status.py +++ b/mopidy/mpd/protocol/status.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import pykka @@ -35,9 +35,11 @@ def currentsong(context): identified in status). """ tl_track = context.core.playback.current_tl_track.get() + stream_title = context.core.playback.get_stream_title().get() if tl_track is not None: position = context.core.tracklist.index(tl_track).get() - return translator.track_to_mpd_format(tl_track, position=position) + return translator.track_to_mpd_format( + tl_track, position=position, stream_title=stream_title) @protocol.commands.add('idle', list_command=False) @@ -173,7 +175,7 @@ def status(context): futures = { 'tracklist.length': context.core.tracklist.length, 'tracklist.version': context.core.tracklist.version, - 'playback.volume': context.core.playback.volume, + 'mixer.volume': context.core.mixer.get_volume(), 'tracklist.consume': context.core.tracklist.consume, 'tracklist.random': context.core.tracklist.random, 'tracklist.repeat': context.core.tracklist.repeat, @@ -287,7 +289,7 @@ def _status_time_total(futures): def _status_volume(futures): - volume = futures['playback.volume'].get() + volume = futures['mixer.volume'].get() if volume is not None: return volume else: diff --git a/mopidy/mpd/protocol/stickers.py b/mopidy/mpd/protocol/stickers.py index 4d535423..30b917c6 100644 --- a/mopidy/mpd/protocol/stickers.py +++ b/mopidy/mpd/protocol/stickers.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals from mopidy.mpd import exceptions, protocol diff --git a/mopidy/mpd/protocol/stored_playlists.py b/mopidy/mpd/protocol/stored_playlists.py index f4d48ff0..9d9f66e0 100644 --- a/mopidy/mpd/protocol/stored_playlists.py +++ b/mopidy/mpd/protocol/stored_playlists.py @@ -1,4 +1,4 @@ -from __future__ import division, unicode_literals +from __future__ import absolute_import, division, unicode_literals import datetime @@ -20,7 +20,8 @@ def listplaylist(context, name): file: relative/path/to/file2.ogg file: relative/path/to/file3.mp3 """ - playlist = context.lookup_playlist_from_name(name) + uri = context.lookup_playlist_uri_from_name(name) + playlist = uri is not None and context.core.playlists.lookup(uri).get() if not playlist: raise exceptions.MpdNoExistError('No such playlist') return ['file: %s' % t.uri for t in playlist.tracks] @@ -40,7 +41,8 @@ def listplaylistinfo(context, name): Standard track listing, with fields: file, Time, Title, Date, Album, Artist, Track """ - playlist = context.lookup_playlist_from_name(name) + uri = context.lookup_playlist_uri_from_name(name) + playlist = uri is not None and context.core.playlists.lookup(uri).get() if not playlist: raise exceptions.MpdNoExistError('No such playlist') return translator.playlist_to_mpd_format(playlist) @@ -73,7 +75,7 @@ def listplaylists(context): ignore playlists without names, which isn't very useful anyway. """ result = [] - for playlist in context.core.playlists.playlists.get(): + for playlist in context.core.playlists.get_playlists().get(): if not playlist.name: continue name = context.lookup_playlist_name_from_uri(playlist.uri) @@ -121,7 +123,8 @@ def load(context, name, playlist_slice=slice(0, None)): - MPD 0.17.1 does not fail if the specified range is outside the playlist, in either or both ends. """ - playlist = context.lookup_playlist_from_name(name) + uri = context.lookup_playlist_uri_from_name(name) + playlist = uri is not None and context.core.playlists.lookup(uri).get() if not playlist: raise exceptions.MpdNoExistError('No such playlist') context.core.tracklist.add(playlist.tracks[playlist_slice]) diff --git a/mopidy/mpd/session.py b/mopidy/mpd/session.py index f0317ede..9f7fabeb 100644 --- a/mopidy/mpd/session.py +++ b/mopidy/mpd/session.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import logging @@ -18,10 +18,10 @@ class MpdSession(network.LineProtocol): encoding = protocol.ENCODING delimiter = r'\r?\n' - def __init__(self, connection, config=None, core=None): + def __init__(self, connection, config=None, core=None, uri_map=None): super(MpdSession, self).__init__(connection) self.dispatcher = dispatcher.MpdDispatcher( - session=self, config=config, core=core) + session=self, config=config, core=core, uri_map=uri_map) def on_start(self): logger.info('New MPD connection from [%s]:%s', self.host, self.port) diff --git a/mopidy/mpd/tokenize.py b/mopidy/mpd/tokenize.py index bc0d6b3f..70208ae9 100644 --- a/mopidy/mpd/tokenize.py +++ b/mopidy/mpd/tokenize.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import re diff --git a/mopidy/mpd/translator.py b/mopidy/mpd/translator.py index 8a43f929..8359f86b 100644 --- a/mopidy/mpd/translator.py +++ b/mopidy/mpd/translator.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import re @@ -15,7 +15,7 @@ def normalize_path(path, relative=False): return '/'.join(parts) -def track_to_mpd_format(track, position=None): +def track_to_mpd_format(track, position=None, stream_title=None): """ Format track for output to MPD client. @@ -23,24 +23,28 @@ def track_to_mpd_format(track, position=None): :type track: :class:`mopidy.models.Track` or :class:`mopidy.models.TlTrack` :param position: track's position in playlist :type position: integer - :param key: if we should set key - :type key: boolean - :param mtime: if we should set mtime - :type mtime: boolean + :param stream_title: the current streams title + :type position: string :rtype: list of two-tuples """ if isinstance(track, TlTrack): (tlid, track) = track else: (tlid, track) = (None, track) + result = [ ('file', track.uri or ''), + # TODO: only show length if not none, see: + # https://github.com/mopidy/mopidy/issues/923#issuecomment-79584110 ('Time', track.length and (track.length // 1000) or 0), ('Artist', artists_to_mpd_format(track.artists)), ('Title', track.name or ''), ('Album', track.album and track.album.name or ''), ] + if stream_title: + result.append(('Name', stream_title)) + if track.date: result.append(('Date', track.date)) @@ -59,13 +63,13 @@ def track_to_mpd_format(track, position=None): if track.album is not None and track.album.artists: artists = artists_to_mpd_format(track.album.artists) result.append(('AlbumArtist', artists)) - artists = filter( - lambda a: a.musicbrainz_id is not None, track.album.artists) + artists = [ + a for a in track.album.artists if a.musicbrainz_id is not None] if artists: result.append( ('MUSICBRAINZ_ALBUMARTISTID', artists[0].musicbrainz_id)) if track.artists: - artists = filter(lambda a: a.musicbrainz_id is not None, track.artists) + artists = [a for a in track.artists if a.musicbrainz_id is not None] if artists: result.append(('MUSICBRAINZ_ARTISTID', artists[0].musicbrainz_id)) diff --git a/mopidy/mpd/uri_mapper.py b/mopidy/mpd/uri_mapper.py new file mode 100644 index 00000000..08c7f689 --- /dev/null +++ b/mopidy/mpd/uri_mapper.py @@ -0,0 +1,75 @@ +from __future__ import absolute_import, unicode_literals + +import re + + +class MpdUriMapper(object): + """ + Maintains the mappings between uniquified MPD names and URIs. + """ + + #: The Mopidy core API. An instance of :class:`mopidy.core.Core`. + core = None + + _invalid_browse_chars = re.compile(r'[\n\r]') + _invalid_playlist_chars = re.compile(r'[/]') + + def __init__(self, core=None): + self.core = core + self._uri_from_name = {} + self._name_from_uri = {} + + def _create_unique_name(self, name, uri): + stripped_name = self._invalid_browse_chars.sub(' ', name) + name = stripped_name + i = 2 + while name in self._uri_from_name: + if self._uri_from_name[name] == uri: + return name + name = '%s [%d]' % (stripped_name, i) + i += 1 + return name + + def insert(self, name, uri): + """ + Create a unique and MPD compatible name that maps to the given URI. + """ + name = self._create_unique_name(name, uri) + self._uri_from_name[name] = uri + self._name_from_uri[uri] = name + return name + + def uri_from_name(self, name): + """ + Return the uri for the given MPD name. + """ + if name in self._uri_from_name: + return self._uri_from_name[name] + + def refresh_playlists_mapping(self): + """ + Maintain map between playlists and unique playlist names to be used by + MPD. + """ + if self.core is not None: + for playlist_ref in self.core.playlists.as_list().get(): + if not playlist_ref.name: + continue + name = self._invalid_playlist_chars.sub('|', playlist_ref.name) + self.insert(name, playlist_ref.uri) + + def playlist_uri_from_name(self, name): + """ + Helper function to retrieve a playlist URI from its unique MPD name. + """ + if not self._uri_from_name: + self.refresh_playlists_mapping() + return self._uri_from_name.get(name) + + def playlist_name_from_uri(self, uri): + """ + Helper function to retrieve the unique MPD playlist name from its URI. + """ + if uri not in self._name_from_uri: + self.refresh_playlists_mapping() + return self._name_from_uri[uri] diff --git a/mopidy/softwaremixer/__init__.py b/mopidy/softwaremixer/__init__.py index 242069eb..9e08a719 100644 --- a/mopidy/softwaremixer/__init__.py +++ b/mopidy/softwaremixer/__init__.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import os diff --git a/mopidy/softwaremixer/mixer.py b/mopidy/softwaremixer/mixer.py index 0ebbfeb7..d94a0be2 100644 --- a/mopidy/softwaremixer/mixer.py +++ b/mopidy/softwaremixer/mixer.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import logging @@ -17,40 +17,45 @@ class SoftwareMixer(pykka.ThreadingActor, mixer.Mixer): def __init__(self, config): super(SoftwareMixer, self).__init__(config) - self.audio = None - self._last_volume = None - self._last_mute = None + self._audio_mixer = None + self._initial_volume = None + self._initial_mute = None - logger.info('Mixing using GStreamer software mixing') + def setup(self, mixer_ref): + self._audio_mixer = mixer_ref + + # The Mopidy startup procedure will set the initial volume of a + # mixer, but this happens before the audio actor's mixer is injected + # into the software mixer actor and has no effect. Thus, we need to set + # the initial volume again. + if self._initial_volume is not None: + self.set_volume(self._initial_volume) + if self._initial_mute is not None: + self.set_mute(self._initial_mute) + + def teardown(self): + self._audio_mixer = None def get_volume(self): - if self.audio is None: + if self._audio_mixer is None: return None - return self.audio.get_volume().get() + return self._audio_mixer.get_volume().get() def set_volume(self, volume): - if self.audio is None: + if self._audio_mixer is None: + self._initial_volume = volume return False - self.audio.set_volume(volume) + self._audio_mixer.set_volume(volume) return True def get_mute(self): - if self.audio is None: + if self._audio_mixer is None: return None - return self.audio.get_mute().get() + return self._audio_mixer.get_mute().get() def set_mute(self, mute): - if self.audio is None: + if self._audio_mixer is None: + self._initial_mute = mute return False - self.audio.set_mute(mute) + self._audio_mixer.set_mute(mute) return True - - def trigger_events_for_changed_values(self): - old_volume, self._last_volume = self._last_volume, self.get_volume() - old_mute, self._last_mute = self._last_mute, self.get_mute() - - if old_volume != self._last_volume: - self.trigger_volume_changed(self._last_volume) - - if old_mute != self._last_mute: - self.trigger_mute_changed(self._last_mute) diff --git a/mopidy/stream/__init__.py b/mopidy/stream/__init__.py index 2cb77365..de01cb84 100644 --- a/mopidy/stream/__init__.py +++ b/mopidy/stream/__init__.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import os diff --git a/mopidy/stream/actor.py b/mopidy/stream/actor.py index b17dfcea..47bfd58f 100644 --- a/mopidy/stream/actor.py +++ b/mopidy/stream/actor.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import fnmatch import logging @@ -8,7 +8,7 @@ import urlparse import pykka from mopidy import audio as audio_lib, backend, exceptions -from mopidy.audio import scan +from mopidy.audio import scan, utils from mopidy.models import Track logger = logging.getLogger(__name__) @@ -20,7 +20,8 @@ class StreamBackend(pykka.ThreadingActor, backend.Backend): self.library = StreamLibraryProvider( backend=self, timeout=config['stream']['timeout'], - blacklist=config['stream']['metadata_blacklist']) + blacklist=config['stream']['metadata_blacklist'], + proxy=config['proxy']) self.playback = backend.PlaybackProvider(audio=audio, backend=self) self.playlists = None @@ -29,9 +30,9 @@ class StreamBackend(pykka.ThreadingActor, backend.Backend): class StreamLibraryProvider(backend.LibraryProvider): - def __init__(self, backend, timeout, blacklist): + def __init__(self, backend, timeout, blacklist, proxy): super(StreamLibraryProvider, self).__init__(backend) - self._scanner = scan.Scanner(min_duration=None, timeout=timeout) + self._scanner = scan.Scanner(timeout=timeout, proxy_config=proxy) self._blacklist_re = re.compile( r'^(%s)$' % '|'.join(fnmatch.translate(u) for u in blacklist)) @@ -44,8 +45,9 @@ class StreamLibraryProvider(backend.LibraryProvider): return [Track(uri=uri)] try: - data = self._scanner.scan(uri) - track = scan.audio_data_to_track(data) + result = self._scanner.scan(uri) + track = utils.convert_tags_to_track(result.tags).copy( + uri=uri, length=result.duration) except exceptions.ScannerError as e: logger.warning('Problem looking up %s: %s', uri, e) track = Track(uri=uri) diff --git a/mopidy/utils/__init__.py b/mopidy/utils/__init__.py index baffc488..01e6d4f4 100644 --- a/mopidy/utils/__init__.py +++ b/mopidy/utils/__init__.py @@ -1 +1 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals diff --git a/mopidy/utils/deprecation.py b/mopidy/utils/deprecation.py new file mode 100644 index 00000000..bf4756d7 --- /dev/null +++ b/mopidy/utils/deprecation.py @@ -0,0 +1,15 @@ +from __future__ import unicode_literals + + +def deprecated_property( + getter=None, setter=None, message='Property is deprecated'): + + # During development, this is a convenient place to add logging, emit + # warnings, or ``assert False`` to ensure you are not using any of the + # deprecated properties. + # + # Using inspect to find the call sites to emit proper warnings makes + # parallel execution of our test suite slower than serial execution. Thus, + # we don't want to add any extra overhead here by default. + + return property(getter, setter) diff --git a/mopidy/utils/deps.py b/mopidy/utils/deps.py index ea64a0a0..bc9f7c2f 100644 --- a/mopidy/utils/deps.py +++ b/mopidy/utils/deps.py @@ -1,8 +1,9 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import functools import os import platform +import sys import pygst pygst.require('0.10') @@ -24,6 +25,7 @@ def format_dependency_list(adapters=None): for dist_name in dist_names] adapters = [ + executable_info, platform_info, python_info, functools.partial(pkg_info, 'Mopidy', True) @@ -63,6 +65,13 @@ def _format_dependency(dep_info): return '\n'.join(lines) +def executable_info(): + return { + 'name': 'Executable', + 'version': sys.argv[0], + } + + def platform_info(): return { 'name': 'Platform', diff --git a/mopidy/utils/encoding.py b/mopidy/utils/encoding.py index a21b3384..27506816 100644 --- a/mopidy/utils/encoding.py +++ b/mopidy/utils/encoding.py @@ -1,10 +1,12 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import locale +from mopidy import compat + def locale_decode(bytestr): try: - return unicode(bytestr) + return compat.text_type(bytestr) except UnicodeError: - return str(bytestr).decode(locale.getpreferredencoding()) + return bytes(bytestr).decode(locale.getpreferredencoding()) diff --git a/mopidy/utils/formatting.py b/mopidy/utils/formatting.py index 3c313eae..9cef7afe 100644 --- a/mopidy/utils/formatting.py +++ b/mopidy/utils/formatting.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import re import unicodedata diff --git a/mopidy/utils/jsonrpc.py b/mopidy/utils/jsonrpc.py index 85565262..13199b26 100644 --- a/mopidy/utils/jsonrpc.py +++ b/mopidy/utils/jsonrpc.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import inspect import json @@ -6,6 +6,8 @@ import traceback import pykka +from mopidy import compat + class JsonRpcWrapper(object): """ @@ -137,13 +139,13 @@ class JsonRpcWrapper(object): except TypeError as error: raise JsonRpcInvalidParamsError(data={ 'type': error.__class__.__name__, - 'message': unicode(error), + 'message': compat.text_type(error), 'traceback': traceback.format_exc(), }) except Exception as error: raise JsonRpcApplicationError(data={ 'type': error.__class__.__name__, - 'message': unicode(error), + 'message': compat.text_type(error), 'traceback': traceback.format_exc(), }) except JsonRpcError as error: @@ -164,7 +166,7 @@ class JsonRpcWrapper(object): if 'method' not in request: raise JsonRpcInvalidRequestError( data='"method" member must be included') - if not isinstance(request['method'], unicode): + if not isinstance(request['method'], compat.text_type): raise JsonRpcInvalidRequestError( data='"method" must be a string') @@ -320,12 +322,12 @@ class JsonRpcInspector(object): available properties and methods. """ methods = {} - for mount, obj in self.objects.iteritems(): + for mount, obj in self.objects.items(): if inspect.isroutine(obj): methods[mount] = self._describe_method(obj) else: obj_methods = self._get_methods(obj) - for name, description in obj_methods.iteritems(): + for name, description in obj_methods.items(): if mount: name = '%s.%s' % (mount, name) methods[name] = description diff --git a/mopidy/utils/log.py b/mopidy/utils/log.py index c461b434..d2dcca70 100644 --- a/mopidy/utils/log.py +++ b/mopidy/utils/log.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import logging import logging.config @@ -12,8 +12,13 @@ LOG_LEVELS = { 1: dict(root=logging.WARNING, mopidy=logging.DEBUG), 2: dict(root=logging.INFO, mopidy=logging.DEBUG), 3: dict(root=logging.DEBUG, mopidy=logging.DEBUG), + 4: dict(root=logging.NOTSET, mopidy=logging.NOTSET), } +# Custom log level which has even lower priority than DEBUG +TRACE_LOG_LEVEL = 5 +logging.addLevelName(TRACE_LOG_LEVEL, 'TRACE') + class DelayedHandler(logging.Handler): def __init__(self): @@ -42,6 +47,7 @@ def bootstrap_delayed_logging(): def setup_logging(config, verbosity_level, save_debug_log): + logging.captureWarnings(True) if config['logging']['config_file']: @@ -76,7 +82,7 @@ def setup_console_logging(config, verbosity_level): formatter = logging.Formatter(log_format) if config['logging']['color']: - handler = ColorizingStreamHandler() + handler = ColorizingStreamHandler(config.get('logcolors', {})) else: handler = logging.StreamHandler() handler.addFilter(verbosity_filter) @@ -111,6 +117,11 @@ class VerbosityFilter(logging.Filter): return record.levelno >= required_log_level +#: Available log colors. +COLORS = [b'black', b'red', b'green', b'yellow', b'blue', b'magenta', b'cyan', + b'white'] + + class ColorizingStreamHandler(logging.StreamHandler): """ Stream handler which colorizes the log using ANSI escape sequences. @@ -124,30 +135,27 @@ class ColorizingStreamHandler(logging.StreamHandler): Licensed under the new BSD license. """ - color_map = { - 'black': 0, - 'red': 1, - 'green': 2, - 'yellow': 3, - 'blue': 4, - 'magenta': 5, - 'cyan': 6, - 'white': 7, - } - # Map logging levels to (background, foreground, bold/intense) level_map = { + TRACE_LOG_LEVEL: (None, 'blue', False), logging.DEBUG: (None, 'blue', False), logging.INFO: (None, 'white', False), logging.WARNING: (None, 'yellow', False), logging.ERROR: (None, 'red', False), logging.CRITICAL: ('red', 'white', True), } + # Map logger name to foreground colors + logger_map = {} + csi = '\x1b[' reset = '\x1b[0m' is_windows = platform.system() == 'Windows' + def __init__(self, logger_colors): + super(ColorizingStreamHandler, self).__init__() + self.logger_map = logger_colors + @property def is_tty(self): isatty = getattr(self.stream, 'isatty', None) @@ -166,19 +174,23 @@ class ColorizingStreamHandler(logging.StreamHandler): message = logging.StreamHandler.format(self, record) if not self.is_tty or self.is_windows: return message - return self.colorize(message, record) - - def colorize(self, message, record): + for name, color in self.logger_map.iteritems(): + if record.name.startswith(name): + return self.colorize(message, fg=color) if record.levelno in self.level_map: bg, fg, bold = self.level_map[record.levelno] - params = [] - if bg in self.color_map: - params.append(str(self.color_map[bg] + 40)) - if fg in self.color_map: - params.append(str(self.color_map[fg] + 30)) - if bold: - params.append('1') - if params: - message = ''.join(( - self.csi, ';'.join(params), 'm', message, self.reset)) + return self.colorize(message, bg=bg, fg=fg, bold=bold) + return message + + def colorize(self, message, bg=None, fg=None, bold=False): + params = [] + if bg in COLORS: + params.append(str(COLORS.index(bg) + 40)) + if fg in COLORS: + params.append(str(COLORS.index(fg) + 30)) + if bold: + params.append('1') + if params: + message = ''.join(( + self.csi, ';'.join(params), 'm', message, self.reset)) return message diff --git a/mopidy/utils/network.py b/mopidy/utils/network.py index 4ea25026..f55649e3 100644 --- a/mopidy/utils/network.py +++ b/mopidy/utils/network.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import errno import logging @@ -199,7 +199,8 @@ class Connection(object): except socket.error as e: if e.errno in (errno.EWOULDBLOCK, errno.EINTR): return data - self.stop('Unexpected client error: %s' % e) + self.stop( + 'Unexpected client error: %s' % encoding.locale_decode(e)) return b'' def enable_timeout(self): diff --git a/mopidy/utils/path.py b/mopidy/utils/path.py index 7c1f671b..8bca275d 100644 --- a/mopidy/utils/path.py +++ b/mopidy/utils/path.py @@ -1,6 +1,5 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals -import Queue as queue import logging import os import stat @@ -11,6 +10,10 @@ import urlparse import glib +from mopidy import compat, exceptions +from mopidy.compat import queue +from mopidy.utils import encoding + logger = logging.getLogger(__name__) @@ -36,7 +39,7 @@ def get_or_create_dir(dir_path): '"%s", already exists.' % dir_path) elif not os.path.isdir(dir_path): logger.info('Creating dir %s', dir_path) - os.makedirs(dir_path, 0755) + os.makedirs(dir_path, 0o755) return dir_path @@ -44,7 +47,7 @@ def get_or_create_file(file_path, mkdir=True, content=None): if not isinstance(file_path, bytes): raise ValueError('Path is not a bytestring.') file_path = expand_path(file_path) - if isinstance(content, unicode): + if isinstance(content, compat.text_type): content = content.encode('utf-8') if mkdir: get_or_create_dir(os.path.dirname(file_path)) @@ -66,7 +69,7 @@ def path_to_uri(path): Returns a file:// URI as an unicode string. """ - if isinstance(path, unicode): + if isinstance(path, compat.text_type): path = path.encode('utf-8') path = urllib.quote(path) return urlparse.urlunsplit((b'file', b'', path, b'', b'')) @@ -83,7 +86,7 @@ def uri_to_path(uri): look up the matching dir or file on your file system because the exact path would be lost by ignoring its encoding. """ - if isinstance(uri, unicode): + if isinstance(uri, compat.text_type): uri = uri.encode('utf-8') return urllib.unquote(urlparse.urlsplit(uri).path) @@ -112,11 +115,11 @@ def expand_path(path): return path -def _find_worker(relative, hidden, done, work, results, errors): +def _find_worker(relative, follow, done, work, results, errors): """Worker thread for collecting stat() results. :param str relative: directory to make results relative to - :param bool hidden: whether to include files and dirs starting with '.' + :param bool follow: if symlinks should be followed :param threading.Event done: event indicating that all work has been done :param queue.Queue work: queue of paths to process :param dict results: shared dictionary for storing all the stat() results @@ -124,7 +127,7 @@ def _find_worker(relative, hidden, done, work, results, errors): """ while not done.is_set(): try: - entry = work.get(block=False) + entry, parents = work.get(block=False) except queue.Empty: continue @@ -134,45 +137,58 @@ def _find_worker(relative, hidden, done, work, results, errors): path = entry try: - st = os.lstat(entry) + if follow: + st = os.stat(entry) + else: + st = os.lstat(entry) + + if (st.st_dev, st.st_ino) in parents: + errors[path] = exceptions.FindError('Sym/hardlink loop found.') + continue + + parents = parents + [(st.st_dev, st.st_ino)] if stat.S_ISDIR(st.st_mode): for e in os.listdir(entry): - if hidden or not e.startswith(b'.'): - work.put(os.path.join(entry, e)) + work.put((os.path.join(entry, e), parents)) elif stat.S_ISREG(st.st_mode): results[path] = st + elif stat.S_ISLNK(st.st_mode): + errors[path] = exceptions.FindError('Not following symlinks.') else: - errors[path] = 'Not a file or directory' - except os.error as e: - errors[path] = str(e) + errors[path] = exceptions.FindError('Not a file or directory.') + + except OSError as e: + errors[path] = exceptions.FindError( + encoding.locale_decode(e.strerror), e.errno) finally: work.task_done() -def _find(root, thread_count=10, hidden=True, relative=False): +def _find(root, thread_count=10, relative=False, follow=False): """Threaded find implementation that provides stat results for files. - Note that we do _not_ handle loops from bad sym/hardlinks in any way. + Tries to protect against sym/hardlink loops by keeping an eye on parent + (st_dev, st_ino) pairs. :param str root: root directory to search from, may not be a file :param int thread_count: number of workers to use, mainly useful to mitigate network lag when scanning on NFS etc. - :param bool hidden: whether to include files and dirs starting with '.' :param bool relative: if results should be relative to root or absolute + :param bool follow: if symlinks should be followed """ threads = [] results = {} errors = {} done = threading.Event() work = queue.Queue() - work.put(os.path.abspath(root)) + work.put((os.path.abspath(root), [])) if not relative: root = None + args = (root, follow, done, work, results, errors) for i in range(thread_count): - t = threading.Thread(target=_find_worker, - args=(root, hidden, done, work, results, errors)) + t = threading.Thread(target=_find_worker, args=args) t.daemon = True t.start() threads.append(t) @@ -184,9 +200,10 @@ def _find(root, thread_count=10, hidden=True, relative=False): return results, errors -def find_mtimes(root): - results, errors = _find(root, hidden=False, relative=False) - return dict((f, int(st.st_mtime)) for f, st in results.iteritems()) +def find_mtimes(root, follow=False): + results, errors = _find(root, relative=False, follow=follow) + mtimes = dict((f, int(st.st_mtime * 1000)) for f, st in results.items()) + return mtimes, errors def check_file_path_is_inside_base_dir(file_path, base_path): diff --git a/mopidy/utils/process.py b/mopidy/utils/process.py index 0660efe0..5b2bb9c0 100644 --- a/mopidy/utils/process.py +++ b/mopidy/utils/process.py @@ -1,17 +1,20 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import logging import signal -import thread import threading from pykka import ActorDeadError from pykka.registry import ActorRegistry +from mopidy.compat import thread + + logger = logging.getLogger(__name__) + SIGNALS = dict( - (k, v) for v, k in signal.__dict__.iteritems() + (k, v) for v, k in signal.__dict__.items() if v.startswith('SIG') and not v.startswith('SIG_')) diff --git a/mopidy/utils/timer.py b/mopidy/utils/timer.py new file mode 100644 index 00000000..b8dcb30d --- /dev/null +++ b/mopidy/utils/timer.py @@ -0,0 +1,16 @@ +from __future__ import unicode_literals + +import contextlib +import logging +import time + + +logger = logging.getLogger(__name__) +TRACE = logging.getLevelName('TRACE') + + +@contextlib.contextmanager +def time_logger(name, level=TRACE): + start = time.time() + yield + logger.log(level, '%s took %dms', name, (time.time() - start) * 1000) diff --git a/mopidy/utils/versioning.py b/mopidy/utils/versioning.py index 94578121..db1aa949 100644 --- a/mopidy/utils/versioning.py +++ b/mopidy/utils/versioning.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import os import subprocess diff --git a/mopidy/zeroconf.py b/mopidy/zeroconf.py index 9f726957..0c42dd74 100644 --- a/mopidy/zeroconf.py +++ b/mopidy/zeroconf.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import logging import socket diff --git a/setup.cfg b/setup.cfg index 0d6c1486..95211279 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,10 +1,9 @@ [flake8] application-import-names = mopidy,tests -exclude = .git,.tox,build,js +exclude = .git,.tox,build,js,tmp # Ignored flake8 warnings: # - E402 module level import not at top of file -# - E731 do not assign a lambda expression, use a def -ignore = E402,E731 +ignore = E402 [wheel] universal = 1 diff --git a/setup.py b/setup.py index 900fcf38..9f33236f 100644 --- a/setup.py +++ b/setup.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import re @@ -6,9 +6,9 @@ from setuptools import find_packages, setup def get_version(filename): - init_py = open(filename).read() - metadata = dict(re.findall("__([a-z]+)__ = '([^']+)'", init_py)) - return metadata['version'] + with open(filename) as fh: + metadata = dict(re.findall("__([a-z]+)__ = '([^']+)'", fh.read())) + return metadata['version'] setup( @@ -29,11 +29,6 @@ setup( 'tornado >= 2.3', ], extras_require={'http': []}, - test_suite='nose.collector', - tests_require=[ - 'nose', - 'mock >= 1.0', - ], entry_points={ 'console_scripts': [ 'mopidy = mopidy.__main__:main', @@ -41,13 +36,14 @@ setup( 'mopidy.ext': [ 'http = mopidy.http:Extension', 'local = mopidy.local:Extension', + 'm3u = mopidy.m3u:Extension', 'mpd = mopidy.mpd:Extension', 'softwaremixer = mopidy.softwaremixer:Extension', 'stream = mopidy.stream:Extension', ], }, classifiers=[ - 'Development Status :: 4 - Beta', + 'Development Status :: 5 - Production/Stable', 'Environment :: No Input/Output (Daemon)', 'Intended Audience :: End Users/Desktop', 'License :: OSI Approved :: Apache Software License', diff --git a/tasks.py b/tasks.py new file mode 100644 index 00000000..9353eb8a --- /dev/null +++ b/tasks.py @@ -0,0 +1,48 @@ +import sys + +from invoke import run, task + + +@task +def docs(watch=False, warn=False): + if watch: + return watcher(docs) + run('make -C docs/ html', warn=warn) + + +@task +def test(path=None, coverage=False, watch=False, warn=False): + if watch: + return watcher(test, path=path, coverage=coverage) + path = path or 'tests/' + cmd = 'py.test' + if coverage: + cmd += ' --cov=mopidy --cov-report=term-missing' + cmd += ' %s' % path + run(cmd, pty=True, warn=warn) + + +@task +def lint(watch=False, warn=False): + if watch: + return watcher(lint) + run('flake8', warn=warn) + + +@task +def update_authors(): + # Keep authors in the order of appearance and use awk to filter out dupes + run("git log --format='- %aN <%aE>' --reverse | awk '!x[$0]++' > AUTHORS") + + +def watcher(task, *args, **kwargs): + while True: + run('clear') + kwargs['warn'] = True + task(*args, **kwargs) + try: + run( + 'inotifywait -q -e create -e modify -e delete ' + '--exclude ".*\.(pyc|sw.)" -r docs/ mopidy/ tests/') + except KeyboardInterrupt: + sys.exit() diff --git a/tests/__init__.py b/tests/__init__.py index 327ca5a8..4283e604 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,7 +1,9 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import os +from mopidy import compat + def path_to_data_dir(name): if not isinstance(name, bytes): @@ -31,4 +33,4 @@ class IsA(object): any_int = IsA(int) any_str = IsA(str) -any_unicode = IsA(unicode) +any_unicode = IsA(compat.text_type) diff --git a/tests/__main__.py b/tests/__main__.py deleted file mode 100644 index 164f1e66..00000000 --- a/tests/__main__.py +++ /dev/null @@ -1,5 +0,0 @@ -from __future__ import unicode_literals - -import nose - -nose.main() diff --git a/tests/audio/test_actor.py b/tests/audio/test_actor.py index 3b9fcad5..fbc440de 100644 --- a/tests/audio/test_actor.py +++ b/tests/audio/test_actor.py @@ -1,5 +1,6 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals +import threading import unittest import gobject @@ -14,18 +15,38 @@ import mock import pykka from mopidy import audio +from mopidy.audio.constants import PlaybackState from mopidy.utils.path import path_to_uri -from tests import path_to_data_dir +from tests import dummy_audio, path_to_data_dir + +# We want to make sure both our real audio class and the fake one behave +# correctly. So each test is first run against the real class, then repeated +# against our dummy. -class AudioTest(unittest.TestCase): - def setUp(self): +class BaseTest(unittest.TestCase): + config = { + 'audio': { + 'mixer': 'fakemixer track_max_volume=65536', + 'mixer_track': None, + 'mixer_volume': None, + 'output': 'testoutput', + 'visualizer': None, + } + } + + uris = [path_to_uri(path_to_data_dir('song1.wav')), + path_to_uri(path_to_data_dir('song2.wav'))] + + audio_class = audio.Audio + + def setUp(self): # noqa: N802 config = { 'audio': { 'mixer': 'foomixer', 'mixer_volume': None, - 'output': 'fakesink', + 'output': 'testoutput', 'visualizer': None, }, 'proxy': { @@ -33,30 +54,52 @@ class AudioTest(unittest.TestCase): }, } self.song_uri = path_to_uri(path_to_data_dir('song1.wav')) - self.audio = audio.Audio.start(config=config, mixer=None).proxy() + self.audio = self.audio_class.start(config=config, mixer=None).proxy() - def tearDown(self): + def tearDown(self): # noqa pykka.ActorRegistry.stop_all() - def prepare_uri(self, uri): - self.audio.prepare_change() - self.audio.set_uri(uri) + def possibly_trigger_fake_playback_error(self): + pass + def possibly_trigger_fake_about_to_finish(self): + pass + + +class DummyMixin(object): + audio_class = dummy_audio.DummyAudio + + def possibly_trigger_fake_playback_error(self): + self.audio.trigger_fake_playback_failure() + + def possibly_trigger_fake_about_to_finish(self): + callback = self.audio.get_about_to_finish_callback().get() + if callback: + callback() + + +class AudioTest(BaseTest): def test_start_playback_existing_file(self): - self.prepare_uri(self.song_uri) + self.audio.prepare_change() + self.audio.set_uri(self.uris[0]) self.assertTrue(self.audio.start_playback().get()) def test_start_playback_non_existing_file(self): - self.prepare_uri(self.song_uri + 'bogus') + self.possibly_trigger_fake_playback_error() + + self.audio.prepare_change() + self.audio.set_uri(self.uris[0] + 'bogus') self.assertFalse(self.audio.start_playback().get()) def test_pause_playback_while_playing(self): - self.prepare_uri(self.song_uri) + self.audio.prepare_change() + self.audio.set_uri(self.uris[0]) self.audio.start_playback() self.assertTrue(self.audio.pause_playback().get()) def test_stop_playback_while_playing(self): - self.prepare_uri(self.song_uri) + self.audio.prepare_change() + self.audio.set_uri(self.uris[0]) self.audio.start_playback() self.assertTrue(self.audio.stop_playback().get()) @@ -68,11 +111,335 @@ class AudioTest(unittest.TestCase): def test_end_of_data_stream(self): pass # TODO - def test_set_volume(self): - for value in range(0, 101): - self.assertTrue(self.audio.set_volume(value).get()) - self.assertEqual(value, self.audio.get_volume().get()) + @unittest.SkipTest + def test_set_mute(self): + pass # TODO Probably needs a fakemixer with a mixer track + @unittest.SkipTest + def test_set_state_encapsulation(self): + pass # TODO + + @unittest.SkipTest + def test_set_position(self): + pass # TODO + + @unittest.SkipTest + def test_invalid_output_raises_error(self): + pass # TODO + + +class AudioDummyTest(DummyMixin, AudioTest): + pass + + +@mock.patch.object(audio.AudioListener, 'send') +class AudioEventTest(BaseTest): + def setUp(self): # noqa: N802 + super(AudioEventTest, self).setUp() + self.audio.enable_sync_handler().get() + + # TODO: test without uri set, with bad uri and gapless... + # TODO: playing->playing triggered by seek should be removed + # TODO: codify expected state after EOS + # TODO: consider returning a future or a threading event? + + def test_state_change_stopped_to_playing_event(self, send_mock): + self.audio.prepare_change() + self.audio.set_uri(self.uris[0]) + self.audio.start_playback() + + self.audio.wait_for_state_change().get() + call = mock.call('state_changed', old_state=PlaybackState.STOPPED, + new_state=PlaybackState.PLAYING, target_state=None) + self.assertIn(call, send_mock.call_args_list) + + def test_state_change_stopped_to_paused_event(self, send_mock): + self.audio.prepare_change() + self.audio.set_uri(self.uris[0]) + self.audio.pause_playback() + + self.audio.wait_for_state_change().get() + call = mock.call('state_changed', old_state=PlaybackState.STOPPED, + new_state=PlaybackState.PAUSED, target_state=None) + self.assertIn(call, send_mock.call_args_list) + + def test_state_change_paused_to_playing_event(self, send_mock): + self.audio.prepare_change() + self.audio.set_uri(self.uris[0]) + self.audio.pause_playback() + self.audio.wait_for_state_change() + self.audio.start_playback() + + self.audio.wait_for_state_change().get() + call = mock.call('state_changed', old_state=PlaybackState.PAUSED, + new_state=PlaybackState.PLAYING, target_state=None) + self.assertIn(call, send_mock.call_args_list) + + def test_state_change_paused_to_stopped_event(self, send_mock): + self.audio.prepare_change() + self.audio.set_uri(self.uris[0]) + self.audio.pause_playback() + self.audio.wait_for_state_change() + self.audio.stop_playback() + + self.audio.wait_for_state_change().get() + call = mock.call('state_changed', old_state=PlaybackState.PAUSED, + new_state=PlaybackState.STOPPED, target_state=None) + self.assertIn(call, send_mock.call_args_list) + + def test_state_change_playing_to_paused_event(self, send_mock): + self.audio.prepare_change() + self.audio.set_uri(self.uris[0]) + self.audio.start_playback() + self.audio.wait_for_state_change() + self.audio.pause_playback() + + self.audio.wait_for_state_change().get() + call = mock.call('state_changed', old_state=PlaybackState.PLAYING, + new_state=PlaybackState.PAUSED, target_state=None) + self.assertIn(call, send_mock.call_args_list) + + def test_state_change_playing_to_stopped_event(self, send_mock): + self.audio.prepare_change() + self.audio.set_uri(self.uris[0]) + self.audio.start_playback() + self.audio.wait_for_state_change() + self.audio.stop_playback() + + self.audio.wait_for_state_change().get() + call = mock.call('state_changed', old_state=PlaybackState.PLAYING, + new_state=PlaybackState.STOPPED, target_state=None) + self.assertIn(call, send_mock.call_args_list) + + def test_stream_changed_event_on_playing(self, send_mock): + self.audio.prepare_change() + self.audio.set_uri(self.uris[0]) + self.audio.start_playback() + + # Since we are going from stopped to playing, the state change is + # enough to ensure the stream changed. + self.audio.wait_for_state_change().get() + + call = mock.call('stream_changed', uri=self.uris[0]) + self.assertIn(call, send_mock.call_args_list) + + def test_stream_changed_event_on_paused_to_stopped(self, send_mock): + self.audio.prepare_change() + self.audio.set_uri(self.uris[0]) + self.audio.pause_playback() + self.audio.wait_for_state_change() + self.audio.stop_playback() + + self.audio.wait_for_state_change().get() + + call = mock.call('stream_changed', uri=None) + self.assertIn(call, send_mock.call_args_list) + + def test_position_changed_on_pause(self, send_mock): + self.audio.prepare_change() + self.audio.set_uri(self.uris[0]) + self.audio.pause_playback() + self.audio.wait_for_state_change() + + self.audio.wait_for_state_change().get() + + call = mock.call('position_changed', position=0) + self.assertIn(call, send_mock.call_args_list) + + def test_position_changed_on_play(self, send_mock): + self.audio.prepare_change() + self.audio.set_uri(self.uris[0]) + self.audio.start_playback() + self.audio.wait_for_state_change() + + self.audio.wait_for_state_change().get() + + call = mock.call('position_changed', position=0) + self.assertIn(call, send_mock.call_args_list) + + def test_position_changed_on_seek(self, send_mock): + self.audio.prepare_change() + self.audio.set_uri(self.uris[0]) + self.audio.set_position(2000) + + self.audio.wait_for_state_change().get() + + call = mock.call('position_changed', position=0) + self.assertNotIn(call, send_mock.call_args_list) + + def test_position_changed_on_seek_after_play(self, send_mock): + self.audio.prepare_change() + self.audio.set_uri(self.uris[0]) + self.audio.start_playback() + self.audio.wait_for_state_change() + self.audio.set_position(2000) + + self.audio.wait_for_state_change().get() + + call = mock.call('position_changed', position=2000) + self.assertIn(call, send_mock.call_args_list) + + def test_position_changed_on_seek_after_pause(self, send_mock): + self.audio.prepare_change() + self.audio.set_uri(self.uris[0]) + self.audio.pause_playback() + self.audio.wait_for_state_change() + self.audio.set_position(2000) + + self.audio.wait_for_state_change().get() + + call = mock.call('position_changed', position=2000) + self.assertIn(call, send_mock.call_args_list) + + def test_tags_changed_on_playback(self, send_mock): + self.audio.prepare_change() + self.audio.set_uri(self.uris[0]) + self.audio.start_playback() + self.audio.wait_for_state_change().get() + + send_mock.assert_any_call('tags_changed', tags=mock.ANY) + + # Unlike the other events, having the state changed done is not + # enough to ensure our event is called. So we setup a threading + # event that we can wait for with a timeout while the track playback + # completes. + + def test_stream_changed_event_on_paused(self, send_mock): + event = threading.Event() + + def send(name, **kwargs): + if name == 'stream_changed': + event.set() + send_mock.side_effect = send + + self.audio.prepare_change() + self.audio.set_uri(self.uris[0]) + self.audio.pause_playback().get() + self.audio.wait_for_state_change().get() + + if not event.wait(timeout=1.0): + self.fail('Stream changed not reached within deadline') + + def test_reached_end_of_stream_event(self, send_mock): + event = threading.Event() + + def send(name, **kwargs): + if name == 'reached_end_of_stream': + event.set() + send_mock.side_effect = send + + self.audio.prepare_change() + self.audio.set_uri(self.uris[0]) + self.audio.start_playback() + self.audio.wait_for_state_change().get() + + self.possibly_trigger_fake_about_to_finish() + if not event.wait(timeout=1.0): + self.fail('End of stream not reached within deadline') + + self.assertFalse(self.audio.get_current_tags().get()) + + def test_gapless(self, send_mock): + uris = self.uris[1:] + events = [] + done = threading.Event() + + def callback(): + if uris: + self.audio.set_uri(uris.pop()).get() + + def send(name, **kwargs): + events.append((name, kwargs)) + if name == 'reached_end_of_stream': + done.set() + + send_mock.side_effect = send + self.audio.set_about_to_finish_callback(callback).get() + + self.audio.prepare_change() + self.audio.set_uri(self.uris[0]) + self.audio.start_playback() + + self.possibly_trigger_fake_about_to_finish() + self.audio.wait_for_state_change().get() + + self.possibly_trigger_fake_about_to_finish() + self.audio.wait_for_state_change().get() + if not done.wait(timeout=1.0): + self.fail('EOS not received') + + # Check that both uris got played + self.assertIn(('stream_changed', {'uri': self.uris[0]}), events) + self.assertIn(('stream_changed', {'uri': self.uris[1]}), events) + + # Check that events counts check out. + keys = [k for k, v in events] + self.assertEqual(2, keys.count('stream_changed')) + self.assertEqual(2, keys.count('position_changed')) + self.assertEqual(1, keys.count('state_changed')) + self.assertEqual(1, keys.count('reached_end_of_stream')) + + # TODO: test tag states within gaples + + def test_current_tags_are_blank_to_begin_with(self, send_mock): + self.assertFalse(self.audio.get_current_tags().get()) + + def test_current_tags_blank_after_end_of_stream(self, send_mock): + done = threading.Event() + + def send(name, **kwargs): + if name == 'reached_end_of_stream': + done.set() + + send_mock.side_effect = send + + self.audio.prepare_change() + self.audio.set_uri(self.uris[0]) + self.audio.start_playback() + + self.possibly_trigger_fake_about_to_finish() + self.audio.wait_for_state_change().get() + + if not done.wait(timeout=1.0): + self.fail('EOS not received') + + self.assertFalse(self.audio.get_current_tags().get()) + + def test_current_tags_stored(self, send_mock): + done = threading.Event() + tags = [] + + def callback(): + tags.append(self.audio.get_current_tags().get()) + + def send(name, **kwargs): + if name == 'reached_end_of_stream': + done.set() + + send_mock.side_effect = send + self.audio.set_about_to_finish_callback(callback).get() + + self.audio.prepare_change() + self.audio.set_uri(self.uris[0]) + self.audio.start_playback() + + self.possibly_trigger_fake_about_to_finish() + self.audio.wait_for_state_change().get() + + if not done.wait(timeout=1.0): + self.fail('EOS not received') + + self.assertTrue(tags[0]) + + # TODO: test that we reset when we expect between songs + + +class AudioDummyEventTest(DummyMixin, AudioEventTest): + """Exercise the AudioEventTest against our mock audio classes.""" + + +# TODO: move to mixer tests... +class MixerTest(BaseTest): @unittest.SkipTest def test_set_mute(self): for value in (True, False): @@ -93,24 +460,24 @@ class AudioTest(unittest.TestCase): class AudioStateTest(unittest.TestCase): - def setUp(self): + def setUp(self): # noqa: N802 self.audio = audio.Audio(config=None, mixer=None) def test_state_starts_as_stopped(self): self.assertEqual(audio.PlaybackState.STOPPED, self.audio.state) def test_state_does_not_change_when_in_gst_ready_state(self): - self.audio._on_playbin_state_changed( + self.audio._handler.on_playbin_state_changed( gst.STATE_NULL, gst.STATE_READY, gst.STATE_VOID_PENDING) self.assertEqual(audio.PlaybackState.STOPPED, self.audio.state) def test_state_changes_from_stopped_to_playing_on_play(self): - self.audio._on_playbin_state_changed( + self.audio._handler.on_playbin_state_changed( gst.STATE_NULL, gst.STATE_READY, gst.STATE_PLAYING) - self.audio._on_playbin_state_changed( + self.audio._handler.on_playbin_state_changed( gst.STATE_READY, gst.STATE_PAUSED, gst.STATE_PLAYING) - self.audio._on_playbin_state_changed( + self.audio._handler.on_playbin_state_changed( gst.STATE_PAUSED, gst.STATE_PLAYING, gst.STATE_VOID_PENDING) self.assertEqual(audio.PlaybackState.PLAYING, self.audio.state) @@ -118,7 +485,7 @@ class AudioStateTest(unittest.TestCase): def test_state_changes_from_playing_to_paused_on_pause(self): self.audio.state = audio.PlaybackState.PLAYING - self.audio._on_playbin_state_changed( + self.audio._handler.on_playbin_state_changed( gst.STATE_PLAYING, gst.STATE_PAUSED, gst.STATE_VOID_PENDING) self.assertEqual(audio.PlaybackState.PAUSED, self.audio.state) @@ -126,19 +493,19 @@ class AudioStateTest(unittest.TestCase): def test_state_changes_from_playing_to_stopped_on_stop(self): self.audio.state = audio.PlaybackState.PLAYING - self.audio._on_playbin_state_changed( + self.audio._handler.on_playbin_state_changed( gst.STATE_PLAYING, gst.STATE_PAUSED, gst.STATE_NULL) - self.audio._on_playbin_state_changed( + self.audio._handler.on_playbin_state_changed( gst.STATE_PAUSED, gst.STATE_READY, gst.STATE_NULL) # We never get the following call, so the logic must work without it - # self.audio._on_playbin_state_changed( + # self.audio._handler.on_playbin_state_changed( # gst.STATE_READY, gst.STATE_NULL, gst.STATE_VOID_PENDING) self.assertEqual(audio.PlaybackState.STOPPED, self.audio.state) class AudioBufferingTest(unittest.TestCase): - def setUp(self): + def setUp(self): # noqa: N802 self.audio = audio.Audio(config=None, mixer=None) self.audio._playbin = mock.Mock(spec=['set_state']) @@ -148,7 +515,7 @@ class AudioBufferingTest(unittest.TestCase): playbin.set_state.assert_called_with(gst.STATE_PLAYING) playbin.set_state.reset_mock() - self.audio._on_buffering(0) + self.audio._handler.on_buffering(0) playbin.set_state.assert_called_with(gst.STATE_PAUSED) self.assertTrue(self.audio._buffering) @@ -158,7 +525,7 @@ class AudioBufferingTest(unittest.TestCase): playbin.set_state.assert_called_with(gst.STATE_PAUSED) playbin.set_state.reset_mock() - self.audio._on_buffering(100) + self.audio._handler.on_buffering(100) self.assertEqual(playbin.set_state.call_count, 0) self.assertFalse(self.audio._buffering) @@ -168,12 +535,12 @@ class AudioBufferingTest(unittest.TestCase): playbin.set_state.assert_called_with(gst.STATE_PLAYING) playbin.set_state.reset_mock() - self.audio._on_buffering(0) + self.audio._handler.on_buffering(0) playbin.set_state.assert_called_with(gst.STATE_PAUSED) self.audio.pause_playback() playbin.set_state.reset_mock() - self.audio._on_buffering(100) + self.audio._handler.on_buffering(100) self.assertEqual(playbin.set_state.call_count, 0) self.assertFalse(self.audio._buffering) @@ -183,7 +550,7 @@ class AudioBufferingTest(unittest.TestCase): playbin.set_state.assert_called_with(gst.STATE_PLAYING) playbin.set_state.reset_mock() - self.audio._on_buffering(0) + self.audio._handler.on_buffering(0) playbin.set_state.assert_called_with(gst.STATE_PAUSED) playbin.set_state.reset_mock() diff --git a/tests/audio/test_listener.py b/tests/audio/test_listener.py index 6e0366cf..5cac75bb 100644 --- a/tests/audio/test_listener.py +++ b/tests/audio/test_listener.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import unittest @@ -8,7 +8,7 @@ from mopidy import audio class AudioListenerTest(unittest.TestCase): - def setUp(self): + def setUp(self): # noqa: N802 self.listener = audio.AudioListener() def test_on_event_forwards_to_specific_handler(self): @@ -26,3 +26,12 @@ class AudioListenerTest(unittest.TestCase): def test_listener_has_default_impl_for_state_changed(self): self.listener.state_changed(None, None, None) + + def test_listener_has_default_impl_for_stream_changed(self): + self.listener.stream_changed(None) + + def test_listener_has_default_impl_for_position_changed(self): + self.listener.position_changed(None) + + def test_listener_has_default_impl_for_tags_changed(self): + self.listener.tags_changed([]) diff --git a/tests/audio/test_playlists.py b/tests/audio/test_playlists.py index 51c36eac..f01568f8 100644 --- a/tests/audio/test_playlists.py +++ b/tests/audio/test_playlists.py @@ -1,6 +1,6 @@ # encoding: utf-8 -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import io import unittest @@ -13,7 +13,7 @@ BAD = b'foobarbaz' M3U = b"""#EXTM3U #EXTINF:123, Sample artist - Sample title file:///tmp/foo -#EXTINF:321,Example Artist - Example title +#EXTINF:321,Example Artist - Example \xc5\xa7\xc5\x95 file:///tmp/bar #EXTINF:213,Some Artist - Other title file:///tmp/baz @@ -25,7 +25,7 @@ File1=file:///tmp/foo Title1=Sample Title Length1=123 File2=file:///tmp/bar -Title2=Example title +Title2=Example \xc5\xa7\xc5\x95 Length2=321 File3=file:///tmp/baz Title3=Other title @@ -40,7 +40,7 @@ ASX = b""" - Example title + Example \xc5\xa7\xc5\x95 @@ -65,7 +65,7 @@ XSPF = b""" file:///tmp/foo - Example title + Example \xc5\xa7\xc5\x95 file:///tmp/bar diff --git a/tests/audio/test_scan.py b/tests/audio/test_scan.py index 26caa422..b2937a3f 100644 --- a/tests/audio/test_scan.py +++ b/tests/audio/test_scan.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import os import unittest @@ -8,282 +8,21 @@ gobject.threads_init() from mopidy import exceptions from mopidy.audio import scan -from mopidy.models import Album, Artist, Track from mopidy.utils import path as path_lib from tests import path_to_data_dir -class FakeGstDate(object): - def __init__(self, year, month, day): - self.year = year - self.month = month - self.day = day - - -# TODO: keep ids without name? -class TranslatorTest(unittest.TestCase): - def setUp(self): - self.data = { - 'uri': 'uri', - 'duration': 4531000000, - 'mtime': 1234, - 'tags': { - 'album': ['album'], - 'track-number': [1], - 'artist': ['artist'], - 'composer': ['composer'], - 'performer': ['performer'], - 'album-artist': ['albumartist'], - 'title': ['track'], - 'track-count': [2], - 'album-disc-number': [2], - 'album-disc-count': [3], - 'date': [FakeGstDate(2006, 1, 1,)], - 'container-format': ['ID3 tag'], - 'genre': ['genre'], - 'comment': ['comment'], - 'musicbrainz-trackid': ['trackid'], - 'musicbrainz-albumid': ['albumid'], - 'musicbrainz-artistid': ['artistid'], - 'musicbrainz-albumartistid': ['albumartistid'], - 'bitrate': [1000], - }, - } - - artist = Artist(name='artist', musicbrainz_id='artistid') - composer = Artist(name='composer') - performer = Artist(name='performer') - albumartist = Artist(name='albumartist', - musicbrainz_id='albumartistid') - - album = Album(name='album', num_tracks=2, num_discs=3, - musicbrainz_id='albumid', artists=[albumartist]) - - self.track = Track(uri='uri', name='track', date='2006-01-01', - genre='genre', track_no=1, disc_no=2, length=4531, - comment='comment', musicbrainz_id='trackid', - last_modified=1234, album=album, bitrate=1000, - artists=[artist], composers=[composer], - performers=[performer]) - - def check(self, expected): - actual = scan.audio_data_to_track(self.data) - self.assertEqual(expected, actual) - - def test_track(self): - self.check(self.track) - - def test_none_track_length(self): - self.data['duration'] = None - self.check(self.track.copy(length=None)) - - def test_none_track_last_modified(self): - self.data['mtime'] = None - self.check(self.track.copy(last_modified=None)) - - def test_missing_track_no(self): - del self.data['tags']['track-number'] - self.check(self.track.copy(track_no=None)) - - def test_multiple_track_no(self): - self.data['tags']['track-number'].append(9) - self.check(self.track) - - def test_missing_track_disc_no(self): - del self.data['tags']['album-disc-number'] - self.check(self.track.copy(disc_no=None)) - - def test_multiple_track_disc_no(self): - self.data['tags']['album-disc-number'].append(9) - self.check(self.track) - - def test_missing_track_name(self): - del self.data['tags']['title'] - self.check(self.track.copy(name=None)) - - def test_multiple_track_name(self): - self.data['tags']['title'] = ['name1', 'name2'] - self.check(self.track.copy(name='name1; name2')) - - def test_missing_track_musicbrainz_id(self): - del self.data['tags']['musicbrainz-trackid'] - self.check(self.track.copy(musicbrainz_id=None)) - - def test_multiple_track_musicbrainz_id(self): - self.data['tags']['musicbrainz-trackid'].append('id') - self.check(self.track) - - def test_missing_track_bitrate(self): - del self.data['tags']['bitrate'] - self.check(self.track.copy(bitrate=None)) - - def test_multiple_track_bitrate(self): - self.data['tags']['bitrate'].append(1234) - self.check(self.track) - - def test_missing_track_genre(self): - del self.data['tags']['genre'] - self.check(self.track.copy(genre=None)) - - def test_multiple_track_genre(self): - self.data['tags']['genre'] = ['genre1', 'genre2'] - self.check(self.track.copy(genre='genre1; genre2')) - - def test_missing_track_date(self): - del self.data['tags']['date'] - self.check(self.track.copy(date=None)) - - def test_multiple_track_date(self): - self.data['tags']['date'].append(FakeGstDate(2030, 1, 1)) - self.check(self.track) - - def test_invalid_track_date(self): - self.data['tags']['date'] = [FakeGstDate(65535, 1, 1)] - self.check(self.track.copy(date=None)) - - def test_missing_track_comment(self): - del self.data['tags']['comment'] - self.check(self.track.copy(comment=None)) - - def test_multiple_track_comment(self): - self.data['tags']['comment'] = ['comment1', 'comment2'] - self.check(self.track.copy(comment='comment1; comment2')) - - def test_missing_track_artist_name(self): - del self.data['tags']['artist'] - self.check(self.track.copy(artists=[])) - - def test_multiple_track_artist_name(self): - self.data['tags']['artist'] = ['name1', 'name2'] - artists = [Artist(name='name1'), Artist(name='name2')] - self.check(self.track.copy(artists=artists)) - - def test_missing_track_artist_musicbrainz_id(self): - del self.data['tags']['musicbrainz-artistid'] - artist = list(self.track.artists)[0].copy(musicbrainz_id=None) - self.check(self.track.copy(artists=[artist])) - - def test_multiple_track_artist_musicbrainz_id(self): - self.data['tags']['musicbrainz-artistid'].append('id') - self.check(self.track) - - def test_missing_track_composer_name(self): - del self.data['tags']['composer'] - self.check(self.track.copy(composers=[])) - - def test_multiple_track_composer_name(self): - self.data['tags']['composer'] = ['composer1', 'composer2'] - composers = [Artist(name='composer1'), Artist(name='composer2')] - self.check(self.track.copy(composers=composers)) - - def test_missing_track_performer_name(self): - del self.data['tags']['performer'] - self.check(self.track.copy(performers=[])) - - def test_multiple_track_performe_name(self): - self.data['tags']['performer'] = ['performer1', 'performer2'] - performers = [Artist(name='performer1'), Artist(name='performer2')] - self.check(self.track.copy(performers=performers)) - - def test_missing_album_name(self): - del self.data['tags']['album'] - album = self.track.album.copy(name=None) - self.check(self.track.copy(album=album)) - - def test_multiple_album_name(self): - self.data['tags']['album'].append('album2') - self.check(self.track) - - def test_missing_album_musicbrainz_id(self): - del self.data['tags']['musicbrainz-albumid'] - album = self.track.album.copy(musicbrainz_id=None) - self.check(self.track.copy(album=album)) - - def test_multiple_album_musicbrainz_id(self): - self.data['tags']['musicbrainz-albumid'].append('id') - self.check(self.track) - - def test_missing_album_num_tracks(self): - del self.data['tags']['track-count'] - album = self.track.album.copy(num_tracks=None) - self.check(self.track.copy(album=album)) - - def test_multiple_album_num_tracks(self): - self.data['tags']['track-count'].append(9) - self.check(self.track) - - def test_missing_album_num_discs(self): - del self.data['tags']['album-disc-count'] - album = self.track.album.copy(num_discs=None) - self.check(self.track.copy(album=album)) - - def test_multiple_album_num_discs(self): - self.data['tags']['album-disc-count'].append(9) - self.check(self.track) - - def test_missing_album_artist_name(self): - del self.data['tags']['album-artist'] - album = self.track.album.copy(artists=[]) - self.check(self.track.copy(album=album)) - - def test_multiple_album_artist_name(self): - self.data['tags']['album-artist'] = ['name1', 'name2'] - artists = [Artist(name='name1'), Artist(name='name2')] - album = self.track.album.copy(artists=artists) - self.check(self.track.copy(album=album)) - - def test_missing_album_artist_musicbrainz_id(self): - del self.data['tags']['musicbrainz-albumartistid'] - albumartist = list(self.track.album.artists)[0] - albumartist = albumartist.copy(musicbrainz_id=None) - album = self.track.album.copy(artists=[albumartist]) - self.check(self.track.copy(album=album)) - - def test_multiple_album_artist_musicbrainz_id(self): - self.data['tags']['musicbrainz-albumartistid'].append('id') - self.check(self.track) - - def test_stream_organization_track_name(self): - del self.data['tags']['title'] - self.data['tags']['organization'] = ['organization'] - self.check(self.track.copy(name='organization')) - - def test_multiple_organization_track_name(self): - del self.data['tags']['title'] - self.data['tags']['organization'] = ['organization1', 'organization2'] - self.check(self.track.copy(name='organization1; organization2')) - - # TODO: combine all comment types? - def test_stream_location_track_comment(self): - del self.data['tags']['comment'] - self.data['tags']['location'] = ['location'] - self.check(self.track.copy(comment='location')) - - def test_multiple_location_track_comment(self): - del self.data['tags']['comment'] - self.data['tags']['location'] = ['location1', 'location2'] - self.check(self.track.copy(comment='location1; location2')) - - def test_stream_copyright_track_comment(self): - del self.data['tags']['comment'] - self.data['tags']['copyright'] = ['copyright'] - self.check(self.track.copy(comment='copyright')) - - def test_multiple_copyright_track_comment(self): - del self.data['tags']['comment'] - self.data['tags']['copyright'] = ['copyright1', 'copyright2'] - self.check(self.track.copy(comment='copyright1; copyright2')) - - class ScannerTest(unittest.TestCase): - def setUp(self): + def setUp(self): # noqa: N802 self.errors = {} - self.data = {} + self.tags = {} + self.durations = {} def find(self, path): media_dir = path_to_data_dir(path) - for path in path_lib.find_mtimes(media_dir): + result, errors = path_lib.find_mtimes(media_dir) + for path in result: yield os.path.join(media_dir, path) def scan(self, paths): @@ -292,54 +31,46 @@ class ScannerTest(unittest.TestCase): uri = path_lib.path_to_uri(path) key = uri[len('file://'):] try: - self.data[key] = scanner.scan(uri) + result = scanner.scan(uri) + self.tags[key] = result.tags + self.durations[key] = result.duration except exceptions.ScannerError as error: self.errors[key] = error def check(self, name, key, value): name = path_to_data_dir(name) - self.assertEqual(self.data[name][key], value) + self.assertEqual(self.tags[name][key], value) - def check_tag(self, name, key, value): - name = path_to_data_dir(name) - self.assertEqual(self.data[name]['tags'][key], value) - - def test_data_is_set(self): + def test_tags_is_set(self): self.scan(self.find('scanner/simple')) - self.assert_(self.data) + self.assert_(self.tags) def test_errors_is_not_set(self): self.scan(self.find('scanner/simple')) self.assert_(not self.errors) - def test_uri_is_set(self): - self.scan(self.find('scanner/simple')) - 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(self.find('scanner/simple')) - self.check('scanner/simple/song1.mp3', 'duration', 4680000000) - self.check('scanner/simple/song1.ogg', 'duration', 4680000000) + + self.assertEqual( + self.durations[path_to_data_dir('scanner/simple/song1.mp3')], 4680) + self.assertEqual( + self.durations[path_to_data_dir('scanner/simple/song1.ogg')], 4680) def test_artist_is_set(self): self.scan(self.find('scanner/simple')) - self.check_tag('scanner/simple/song1.mp3', 'artist', ['name']) - self.check_tag('scanner/simple/song1.ogg', 'artist', ['name']) + self.check('scanner/simple/song1.mp3', 'artist', ['name']) + self.check('scanner/simple/song1.ogg', 'artist', ['name']) def test_album_is_set(self): self.scan(self.find('scanner/simple')) - self.check_tag('scanner/simple/song1.mp3', 'album', ['albumname']) - self.check_tag('scanner/simple/song1.ogg', 'album', ['albumname']) + self.check('scanner/simple/song1.mp3', 'album', ['albumname']) + self.check('scanner/simple/song1.ogg', 'album', ['albumname']) def test_track_is_set(self): self.scan(self.find('scanner/simple')) - self.check_tag('scanner/simple/song1.mp3', 'title', ['trackname']) - self.check_tag('scanner/simple/song1.ogg', 'title', ['trackname']) + self.check('scanner/simple/song1.mp3', 'title', ['trackname']) + self.check('scanner/simple/song1.ogg', 'title', ['trackname']) def test_nonexistant_dir_does_not_fail(self): self.scan(self.find('scanner/does-not-exist')) @@ -351,11 +82,13 @@ class ScannerTest(unittest.TestCase): def test_log_file_that_gst_thinks_is_mpeg_1_is_ignored(self): self.scan([path_to_data_dir('scanner/example.log')]) - self.assert_(self.errors) + self.assertLess( + self.durations[path_to_data_dir('scanner/example.log')], 100) - def test_empty_wav_file_is_ignored(self): + def test_empty_wav_file(self): self.scan([path_to_data_dir('scanner/empty.wav')]) - self.assert_(self.errors) + self.assertEqual( + self.durations[path_to_data_dir('scanner/empty.wav')], 0) @unittest.SkipTest def test_song_without_time_is_handeled(self): diff --git a/tests/audio/test_utils.py b/tests/audio/test_utils.py new file mode 100644 index 00000000..f1f15761 --- /dev/null +++ b/tests/audio/test_utils.py @@ -0,0 +1,246 @@ +from __future__ import absolute_import, unicode_literals + +import datetime +import unittest + +from mopidy.audio import utils +from mopidy.models import Album, Artist, Track + + +# TODO: keep ids without name? +# TODO: current test is trying to test everything at once with a complete tags +# set, instead we might want to try with a minimal one making testing easier. +class TagsToTrackTest(unittest.TestCase): + def setUp(self): # noqa: N802 + self.tags = { + 'album': ['album'], + 'track-number': [1], + 'artist': ['artist'], + 'composer': ['composer'], + 'performer': ['performer'], + 'album-artist': ['albumartist'], + 'title': ['track'], + 'track-count': [2], + 'album-disc-number': [2], + 'album-disc-count': [3], + 'date': [datetime.date(2006, 1, 1,)], + 'container-format': ['ID3 tag'], + 'genre': ['genre'], + 'comment': ['comment'], + 'musicbrainz-trackid': ['trackid'], + 'musicbrainz-albumid': ['albumid'], + 'musicbrainz-artistid': ['artistid'], + 'musicbrainz-albumartistid': ['albumartistid'], + 'bitrate': [1000], + } + + artist = Artist(name='artist', musicbrainz_id='artistid') + composer = Artist(name='composer') + performer = Artist(name='performer') + albumartist = Artist(name='albumartist', + musicbrainz_id='albumartistid') + + album = Album(name='album', num_tracks=2, num_discs=3, + musicbrainz_id='albumid', artists=[albumartist]) + + self.track = Track(name='track', date='2006-01-01', + genre='genre', track_no=1, disc_no=2, + comment='comment', musicbrainz_id='trackid', + album=album, bitrate=1000, artists=[artist], + composers=[composer], performers=[performer]) + + def check(self, expected): + actual = utils.convert_tags_to_track(self.tags) + self.assertEqual(expected, actual) + + def test_track(self): + self.check(self.track) + + def test_missing_track_no(self): + del self.tags['track-number'] + self.check(self.track.copy(track_no=None)) + + def test_multiple_track_no(self): + self.tags['track-number'].append(9) + self.check(self.track) + + def test_missing_track_disc_no(self): + del self.tags['album-disc-number'] + self.check(self.track.copy(disc_no=None)) + + def test_multiple_track_disc_no(self): + self.tags['album-disc-number'].append(9) + self.check(self.track) + + def test_missing_track_name(self): + del self.tags['title'] + self.check(self.track.copy(name=None)) + + def test_multiple_track_name(self): + self.tags['title'] = ['name1', 'name2'] + self.check(self.track.copy(name='name1; name2')) + + def test_missing_track_musicbrainz_id(self): + del self.tags['musicbrainz-trackid'] + self.check(self.track.copy(musicbrainz_id=None)) + + def test_multiple_track_musicbrainz_id(self): + self.tags['musicbrainz-trackid'].append('id') + self.check(self.track) + + def test_missing_track_bitrate(self): + del self.tags['bitrate'] + self.check(self.track.copy(bitrate=None)) + + def test_multiple_track_bitrate(self): + self.tags['bitrate'].append(1234) + self.check(self.track) + + def test_missing_track_genre(self): + del self.tags['genre'] + self.check(self.track.copy(genre=None)) + + def test_multiple_track_genre(self): + self.tags['genre'] = ['genre1', 'genre2'] + self.check(self.track.copy(genre='genre1; genre2')) + + def test_missing_track_date(self): + del self.tags['date'] + self.check(self.track.copy(date=None)) + + def test_multiple_track_date(self): + self.tags['date'].append(datetime.date(2030, 1, 1)) + self.check(self.track) + + def test_missing_track_comment(self): + del self.tags['comment'] + self.check(self.track.copy(comment=None)) + + def test_multiple_track_comment(self): + self.tags['comment'] = ['comment1', 'comment2'] + self.check(self.track.copy(comment='comment1; comment2')) + + def test_missing_track_artist_name(self): + del self.tags['artist'] + self.check(self.track.copy(artists=[])) + + def test_multiple_track_artist_name(self): + self.tags['artist'] = ['name1', 'name2'] + artists = [Artist(name='name1'), Artist(name='name2')] + self.check(self.track.copy(artists=artists)) + + def test_missing_track_artist_musicbrainz_id(self): + del self.tags['musicbrainz-artistid'] + artist = list(self.track.artists)[0].copy(musicbrainz_id=None) + self.check(self.track.copy(artists=[artist])) + + def test_multiple_track_artist_musicbrainz_id(self): + self.tags['musicbrainz-artistid'].append('id') + self.check(self.track) + + def test_missing_track_composer_name(self): + del self.tags['composer'] + self.check(self.track.copy(composers=[])) + + def test_multiple_track_composer_name(self): + self.tags['composer'] = ['composer1', 'composer2'] + composers = [Artist(name='composer1'), Artist(name='composer2')] + self.check(self.track.copy(composers=composers)) + + def test_missing_track_performer_name(self): + del self.tags['performer'] + self.check(self.track.copy(performers=[])) + + def test_multiple_track_performe_name(self): + self.tags['performer'] = ['performer1', 'performer2'] + performers = [Artist(name='performer1'), Artist(name='performer2')] + self.check(self.track.copy(performers=performers)) + + def test_missing_album_name(self): + del self.tags['album'] + self.check(self.track.copy(album=None)) + + def test_multiple_album_name(self): + self.tags['album'].append('album2') + self.check(self.track) + + def test_missing_album_musicbrainz_id(self): + del self.tags['musicbrainz-albumid'] + album = self.track.album.copy(musicbrainz_id=None, + images=[]) + self.check(self.track.copy(album=album)) + + def test_multiple_album_musicbrainz_id(self): + self.tags['musicbrainz-albumid'].append('id') + self.check(self.track) + + def test_missing_album_num_tracks(self): + del self.tags['track-count'] + album = self.track.album.copy(num_tracks=None) + self.check(self.track.copy(album=album)) + + def test_multiple_album_num_tracks(self): + self.tags['track-count'].append(9) + self.check(self.track) + + def test_missing_album_num_discs(self): + del self.tags['album-disc-count'] + album = self.track.album.copy(num_discs=None) + self.check(self.track.copy(album=album)) + + def test_multiple_album_num_discs(self): + self.tags['album-disc-count'].append(9) + self.check(self.track) + + def test_missing_album_artist_name(self): + del self.tags['album-artist'] + album = self.track.album.copy(artists=[]) + self.check(self.track.copy(album=album)) + + def test_multiple_album_artist_name(self): + self.tags['album-artist'] = ['name1', 'name2'] + artists = [Artist(name='name1'), Artist(name='name2')] + album = self.track.album.copy(artists=artists) + self.check(self.track.copy(album=album)) + + def test_missing_album_artist_musicbrainz_id(self): + del self.tags['musicbrainz-albumartistid'] + albumartist = list(self.track.album.artists)[0] + albumartist = albumartist.copy(musicbrainz_id=None) + album = self.track.album.copy(artists=[albumartist]) + self.check(self.track.copy(album=album)) + + def test_multiple_album_artist_musicbrainz_id(self): + self.tags['musicbrainz-albumartistid'].append('id') + self.check(self.track) + + def test_stream_organization_track_name(self): + del self.tags['title'] + self.tags['organization'] = ['organization'] + self.check(self.track.copy(name='organization')) + + def test_multiple_organization_track_name(self): + del self.tags['title'] + self.tags['organization'] = ['organization1', 'organization2'] + self.check(self.track.copy(name='organization1; organization2')) + + # TODO: combine all comment types? + def test_stream_location_track_comment(self): + del self.tags['comment'] + self.tags['location'] = ['location'] + self.check(self.track.copy(comment='location')) + + def test_multiple_location_track_comment(self): + del self.tags['comment'] + self.tags['location'] = ['location1', 'location2'] + self.check(self.track.copy(comment='location1; location2')) + + def test_stream_copyright_track_comment(self): + del self.tags['comment'] + self.tags['copyright'] = ['copyright'] + self.check(self.track.copy(comment='copyright')) + + def test_multiple_copyright_track_comment(self): + del self.tags['comment'] + self.tags['copyright'] = ['copyright1', 'copyright2'] + self.check(self.track.copy(comment='copyright1; copyright2')) diff --git a/tests/backend/__init__.py b/tests/backend/__init__.py index baffc488..01e6d4f4 100644 --- a/tests/backend/__init__.py +++ b/tests/backend/__init__.py @@ -1 +1 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals diff --git a/tests/backend/test_backend.py b/tests/backend/test_backend.py new file mode 100644 index 00000000..e6aac76f --- /dev/null +++ b/tests/backend/test_backend.py @@ -0,0 +1,45 @@ +from __future__ import absolute_import, unicode_literals + +import unittest + +from mopidy import backend, models + +from tests import dummy_backend + + +class LibraryTest(unittest.TestCase): + + def test_default_get_images_impl_falls_back_to_album_image(self): + album = models.Album(images=['imageuri']) + track = models.Track(uri='trackuri', album=album) + + library = dummy_backend.DummyLibraryProvider(backend=None) + library.dummy_library.append(track) + + expected = {'trackuri': [models.Image(uri='imageuri')]} + self.assertEqual(library.get_images(['trackuri']), expected) + + def test_default_get_images_impl_no_album_image(self): + # default implementation now returns an empty list if no + # images are found, though it's not required to + track = models.Track(uri='trackuri') + + library = dummy_backend.DummyLibraryProvider(backend=None) + library.dummy_library.append(track) + + expected = {'trackuri': []} + self.assertEqual(library.get_images(['trackuri']), expected) + + +class PlaylistsTest(unittest.TestCase): + + def setUp(self): # noqa: N802 + self.provider = backend.PlaylistsProvider(backend=None) + + def test_as_list_default_impl(self): + with self.assertRaises(NotImplementedError): + self.provider.as_list() + + def test_get_items_default_impl(self): + with self.assertRaises(NotImplementedError): + self.provider.get_items('some uri') diff --git a/tests/backend/test_listener.py b/tests/backend/test_listener.py index 9e080d31..ae8bbffe 100644 --- a/tests/backend/test_listener.py +++ b/tests/backend/test_listener.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import unittest @@ -8,7 +8,7 @@ from mopidy import backend class BackendListenerTest(unittest.TestCase): - def setUp(self): + def setUp(self): # noqa: N802 self.listener = backend.BackendListener() def test_on_event_forwards_to_specific_handler(self): diff --git a/tests/config/test_config.py b/tests/config/test_config.py index da0e5192..8ee91d0d 100644 --- a/tests/config/test_config.py +++ b/tests/config/test_config.py @@ -1,6 +1,6 @@ # encoding: utf-8 -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import unittest @@ -15,6 +15,18 @@ class LoadConfigTest(unittest.TestCase): def test_load_nothing(self): self.assertEqual({}, config._load([], [], [])) + def test_load_missing_file(self): + file0 = path_to_data_dir('file0.conf') + result = config._load([file0], [], []) + self.assertEqual({}, result) + + @mock.patch('os.access') + def test_load_nonreadable_file(self, access_mock): + access_mock.return_value = False + file1 = path_to_data_dir('file1.conf') + result = config._load([file1], [], []) + self.assertEqual({}, result) + def test_load_single_default(self): default = b'[foo]\nbar = baz' expected = {'foo': {'bar': 'baz'}} @@ -84,7 +96,7 @@ class LoadConfigTest(unittest.TestCase): class ValidateTest(unittest.TestCase): - def setUp(self): + def setUp(self): # noqa: N802 self.schema = config.ConfigSchema('foo') self.schema['bar'] = config.ConfigValue() diff --git a/tests/config/test_schemas.py b/tests/config/test_schemas.py index 86cd69f1..502bf61c 100644 --- a/tests/config/test_schemas.py +++ b/tests/config/test_schemas.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import logging import unittest @@ -11,7 +11,7 @@ from tests import any_unicode class ConfigSchemaTest(unittest.TestCase): - def setUp(self): + def setUp(self): # noqa: N802 self.schema = schemas.ConfigSchema('test') self.schema['foo'] = mock.Mock() self.schema['bar'] = mock.Mock() @@ -86,9 +86,9 @@ class ConfigSchemaTest(unittest.TestCase): self.assertNotIn('foo', errors) -class LogLevelConfigSchemaTest(unittest.TestCase): +class MapConfigSchemaTest(unittest.TestCase): def test_conversion(self): - schema = schemas.LogLevelConfigSchema('test') + schema = schemas.MapConfigSchema('test', types.LogLevel()) result, errors = schema.deserialize( {'foo.bar': 'DEBUG', 'baz': 'INFO'}) @@ -97,7 +97,7 @@ class LogLevelConfigSchemaTest(unittest.TestCase): class DidYouMeanTest(unittest.TestCase): - def testSuggestoins(self): + def test_suggestions(self): choices = ('enabled', 'username', 'password', 'bitrate', 'timeout') suggestion = schemas._did_you_mean('bitrate', choices) diff --git a/tests/config/test_types.py b/tests/config/test_types.py index dfb439be..365fa9e0 100644 --- a/tests/config/test_types.py +++ b/tests/config/test_types.py @@ -1,6 +1,6 @@ # encoding: utf-8 -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import logging import socket @@ -8,6 +8,7 @@ import unittest import mock +from mopidy import compat from mopidy.config import types # TODO: DecodeTest and EncodeTest @@ -48,7 +49,7 @@ class StringTest(unittest.TestCase): def test_deserialize_conversion_success(self): value = types.String() self.assertEqual('foo', value.deserialize(b' foo ')) - self.assertIsInstance(value.deserialize(b'foo'), unicode) + self.assertIsInstance(value.deserialize(b'foo'), compat.text_type) def test_deserialize_decodes_utf8(self): value = types.String() @@ -119,7 +120,7 @@ class SecretTest(unittest.TestCase): def test_deserialize_decodes_utf8(self): value = types.Secret() result = value.deserialize('æøå'.encode('utf-8')) - self.assertIsInstance(result, unicode) + self.assertIsInstance(result, compat.text_type) self.assertEqual('æøå', result) def test_deserialize_enforces_required(self): @@ -280,11 +281,14 @@ class ListTest(unittest.TestCase): class LogLevelTest(unittest.TestCase): - levels = {'critical': logging.CRITICAL, - 'error': logging.ERROR, - 'warning': logging.WARNING, - 'info': logging.INFO, - 'debug': logging.DEBUG} + levels = { + 'critical': logging.CRITICAL, + 'error': logging.ERROR, + 'warning': logging.WARNING, + 'info': logging.INFO, + 'debug': logging.DEBUG, + 'all': logging.NOTSET, + } def test_deserialize_conversion_success(self): value = types.LogLevel() diff --git a/tests/config/test_validator.py b/tests/config/test_validator.py index ce773340..8172df0c 100644 --- a/tests/config/test_validator.py +++ b/tests/config/test_validator.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import unittest diff --git a/tests/core/__init__.py b/tests/core/__init__.py index baffc488..01e6d4f4 100644 --- a/tests/core/__init__.py +++ b/tests/core/__init__.py @@ -1 +1 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals diff --git a/tests/core/test_actor.py b/tests/core/test_actor.py index 79d778af..e82962dc 100644 --- a/tests/core/test_actor.py +++ b/tests/core/test_actor.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import unittest @@ -11,7 +11,7 @@ from mopidy.utils import versioning class CoreActorTest(unittest.TestCase): - def setUp(self): + def setUp(self): # noqa: N802 self.backend1 = mock.Mock() self.backend1.uri_schemes.get.return_value = ['dummy1'] self.backend1.actor_ref.actor_class.__name__ = b'B1' @@ -22,7 +22,7 @@ class CoreActorTest(unittest.TestCase): self.core = Core(mixer=None, backends=[self.backend1, self.backend2]) - def tearDown(self): + def tearDown(self): # noqa: N802 pykka.ActorRegistry.stop_all() def test_uri_schemes_has_uris_from_all_backends(self): diff --git a/tests/core/test_events.py b/tests/core/test_events.py index ab7906a8..942f9b5f 100644 --- a/tests/core/test_events.py +++ b/tests/core/test_events.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import unittest @@ -7,17 +7,18 @@ import mock import pykka from mopidy import core -from mopidy.backend import dummy from mopidy.models import Track +from tests import dummy_backend + @mock.patch.object(core.CoreListener, 'send') class BackendEventsTest(unittest.TestCase): - def setUp(self): - self.backend = dummy.create_dummy_backend_proxy() + def setUp(self): # noqa: N802 + self.backend = dummy_backend.create_proxy() self.core = core.Core.start(backends=[self.backend]).proxy() - def tearDown(self): + def tearDown(self): # noqa: N802 pykka.ActorRegistry.stop_all() def test_forwards_backend_playlists_loaded_event_to_frontends(self, send): diff --git a/tests/core/test_history.py b/tests/core/test_history.py new file mode 100644 index 00000000..48062aaf --- /dev/null +++ b/tests/core/test_history.py @@ -0,0 +1,47 @@ +from __future__ import absolute_import, unicode_literals + +import unittest + +from mopidy.core import HistoryController +from mopidy.models import Artist, Track + + +class PlaybackHistoryTest(unittest.TestCase): + + def setUp(self): # noqa: N802 + self.tracks = [ + Track(uri='dummy1:a', name='foo', + artists=[Artist(name='foober'), Artist(name='barber')]), + Track(uri='dummy2:a', name='foo'), + Track(uri='dummy3:a', name='bar') + ] + self.history = HistoryController() + + def test_add_track(self): + self.history._add_track(self.tracks[0]) + self.assertEqual(self.history.get_length(), 1) + + self.history._add_track(self.tracks[1]) + self.assertEqual(self.history.get_length(), 2) + + self.history._add_track(self.tracks[2]) + self.assertEqual(self.history.get_length(), 3) + + def test_non_tracks_are_rejected(self): + with self.assertRaises(TypeError): + self.history._add_track(object()) + + self.assertEqual(self.history.get_length(), 0) + + def test_history_entry_contents(self): + track = self.tracks[0] + self.history._add_track(track) + + result = self.history.get_history() + (timestamp, ref) = result[0] + + self.assertIsInstance(timestamp, int) + self.assertEqual(track.uri, ref.uri) + self.assertIn(track.name, ref.name) + for artist in track.artists: + self.assertIn(artist.name, ref.name) diff --git a/tests/core/test_library.py b/tests/core/test_library.py index 9eac3ebd..51313daa 100644 --- a/tests/core/test_library.py +++ b/tests/core/test_library.py @@ -1,19 +1,21 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import unittest import mock from mopidy import backend, core -from mopidy.models import Ref, SearchResult, Track +from mopidy.models import Image, Ref, SearchResult, Track class CoreLibraryTest(unittest.TestCase): - def setUp(self): + def setUp(self): # noqa: N802 dummy1_root = Ref.directory(uri='dummy1:directory', name='dummy1') self.backend1 = mock.Mock() self.backend1.uri_schemes.get.return_value = ['dummy1'] self.library1 = mock.Mock(spec=backend.LibraryProvider) + self.library1.get_images().get.return_value = {} + self.library1.get_images.reset_mock() self.library1.root_directory.get.return_value = dummy1_root self.backend1.library = self.library1 @@ -21,6 +23,8 @@ class CoreLibraryTest(unittest.TestCase): self.backend2 = mock.Mock() self.backend2.uri_schemes.get.return_value = ['dummy2', 'du2'] self.library2 = mock.Mock(spec=backend.LibraryProvider) + self.library2.get_images().get.return_value = {} + self.library2.get_images.reset_mock() self.library2.root_directory.get.return_value = dummy2_root self.backend2.library = self.library2 @@ -33,6 +37,50 @@ class CoreLibraryTest(unittest.TestCase): self.core = core.Core(mixer=None, backends=[ self.backend1, self.backend2, self.backend3]) + def test_get_images_returns_empty_dict_for_no_uris(self): + self.assertEqual({}, self.core.library.get_images([])) + + def test_get_images_returns_empty_result_for_unknown_uri(self): + result = self.core.library.get_images(['dummy4:track']) + self.assertEqual({'dummy4:track': tuple()}, result) + + def test_get_images_returns_empty_result_for_library_less_uri(self): + result = self.core.library.get_images(['dummy3:track']) + self.assertEqual({'dummy3:track': tuple()}, result) + + def test_get_images_maps_uri_to_backend(self): + self.core.library.get_images(['dummy1:track']) + self.library1.get_images.assert_called_once_with(['dummy1:track']) + self.library2.get_images.assert_not_called() + + def test_get_images_maps_uri_to_backends(self): + self.core.library.get_images(['dummy1:track', 'dummy2:track']) + self.library1.get_images.assert_called_once_with(['dummy1:track']) + self.library2.get_images.assert_called_once_with(['dummy2:track']) + + def test_get_images_returns_images(self): + self.library1.get_images().get.return_value = { + 'dummy1:track': [Image(uri='uri')]} + self.library1.get_images.reset_mock() + + result = self.core.library.get_images(['dummy1:track']) + self.assertEqual({'dummy1:track': (Image(uri='uri'),)}, result) + + def test_get_images_merges_results(self): + self.library1.get_images().get.return_value = { + 'dummy1:track': [Image(uri='uri1')]} + self.library1.get_images.reset_mock() + self.library2.get_images().get.return_value = { + 'dummy2:track': [Image(uri='uri2')]} + self.library2.get_images.reset_mock() + + result = self.core.library.get_images( + ['dummy1:track', 'dummy2:track', 'dummy3:track', 'dummy4:track']) + expected = {'dummy1:track': (Image(uri='uri1'),), + 'dummy2:track': (Image(uri='uri2'),), + 'dummy3:track': tuple(), 'dummy4:track': tuple()} + self.assertEqual(expected, result) + def test_browse_root_returns_dir_ref_for_each_lib_with_root_dir_name(self): result = self.core.library.browse(None) @@ -109,13 +157,31 @@ class CoreLibraryTest(unittest.TestCase): self.assertFalse(self.library1.lookup.called) self.library2.lookup.assert_called_once_with('dummy2:a') - def test_lookup_returns_nothing_for_dummy3_track(self): + def test_lookup_fails_with_uri_and_uris_set(self): + with self.assertRaises(ValueError): + self.core.library.lookup('dummy1:a', ['dummy2:a']) + + def test_lookup_can_handle_uris(self): + self.library1.lookup().get.return_value = [1234] + self.library2.lookup().get.return_value = [5678] + + result = self.core.library.lookup(uris=['dummy1:a', 'dummy2:a']) + self.assertEqual(result, {'dummy2:a': [5678], 'dummy1:a': [1234]}) + + def test_lookup_uri_returns_empty_list_for_dummy3_track(self): result = self.core.library.lookup('dummy3:a') self.assertEqual(result, []) self.assertFalse(self.library1.lookup.called) self.assertFalse(self.library2.lookup.called) + def test_lookup_uris_returns_empty_list_for_dummy3_track(self): + result = self.core.library.lookup(uris=['dummy3:a']) + + self.assertEqual(result, {'dummy3:a': []}) + self.assertFalse(self.library1.lookup.called) + self.assertFalse(self.library2.lookup.called) + def test_refresh_with_uri_selects_dummy1_backend(self): self.core.library.refresh('dummy1:a') @@ -146,54 +212,50 @@ class CoreLibraryTest(unittest.TestCase): result1 = SearchResult(tracks=[track1]) result2 = SearchResult(tracks=[track2]) - self.library1.find_exact().get.return_value = result1 - self.library1.find_exact.reset_mock() - self.library2.find_exact().get.return_value = result2 - self.library2.find_exact.reset_mock() + self.library1.search.return_value.get.return_value = result1 + self.library2.search.return_value.get.return_value = result2 result = self.core.library.find_exact(any=['a']) self.assertIn(result1, result) self.assertIn(result2, result) - 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) + self.library1.search.assert_called_once_with( + query=dict(any=['a']), uris=None, exact=True) + self.library2.search.assert_called_once_with( + query=dict(any=['a']), uris=None, exact=True) 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) + self.library1.search.assert_called_once_with( + query=dict(any=['a']), uris=['dummy1:', 'dummy1:foo'], exact=True) + self.assertFalse(self.library2.search.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:']) + self.library1.search.assert_called_once_with( + query=dict(any=['a']), uris=['dummy1:', 'dummy1:foo'], exact=True) + self.library2.search.assert_called_once_with( + query=dict(any=['a']), uris=['dummy2:'], exact=True) def test_find_exact_filters_out_none(self): track1 = Track(uri='dummy1:a') result1 = SearchResult(tracks=[track1]) - self.library1.find_exact().get.return_value = result1 - self.library1.find_exact.reset_mock() - self.library2.find_exact().get.return_value = None - self.library2.find_exact.reset_mock() + self.library1.search.return_value.get.return_value = result1 + self.library2.search.return_value.get.return_value = None result = self.core.library.find_exact(any=['a']) self.assertIn(result1, result) self.assertNotIn(None, result) - 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) + self.library1.search.assert_called_once_with( + query=dict(any=['a']), uris=None, exact=True) + self.library2.search.assert_called_once_with( + query=dict(any=['a']), uris=None, exact=True) def test_find_accepts_query_dict_instead_of_kwargs(self): track1 = Track(uri='dummy1:a') @@ -201,19 +263,17 @@ class CoreLibraryTest(unittest.TestCase): result1 = SearchResult(tracks=[track1]) result2 = SearchResult(tracks=[track2]) - self.library1.find_exact().get.return_value = result1 - self.library1.find_exact.reset_mock() - self.library2.find_exact().get.return_value = result2 - self.library2.find_exact.reset_mock() + self.library1.search.return_value.get.return_value = result1 + self.library2.search.return_value.get.return_value = result2 result = self.core.library.find_exact(dict(any=['a'])) self.assertIn(result1, result) self.assertIn(result2, result) - 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) + self.library1.search.assert_called_once_with( + query=dict(any=['a']), uris=None, exact=True) + self.library2.search.assert_called_once_with( + query=dict(any=['a']), uris=None, exact=True) def test_search_combines_results_from_all_backends(self): track1 = Track(uri='dummy1:a') @@ -231,16 +291,16 @@ class CoreLibraryTest(unittest.TestCase): self.assertIn(result1, result) self.assertIn(result2, result) self.library1.search.assert_called_once_with( - query=dict(any=['a']), uris=None) + query=dict(any=['a']), uris=None, exact=False) self.library2.search.assert_called_once_with( - query=dict(any=['a']), uris=None) + query=dict(any=['a']), uris=None, exact=False) 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']) + query=dict(any=['a']), uris=['dummy1:', 'dummy1:foo'], exact=False) self.assertFalse(self.library2.search.called) def test_search_with_uris_selects_both_backends(self): @@ -248,9 +308,9 @@ class CoreLibraryTest(unittest.TestCase): query=dict(any=['a']), uris=['dummy1:', 'dummy1:foo', 'dummy2:']) self.library1.search.assert_called_once_with( - query=dict(any=['a']), uris=['dummy1:', 'dummy1:foo']) + query=dict(any=['a']), uris=['dummy1:', 'dummy1:foo'], exact=False) self.library2.search.assert_called_once_with( - query=dict(any=['a']), uris=['dummy2:']) + query=dict(any=['a']), uris=['dummy2:'], exact=False) def test_search_filters_out_none(self): track1 = Track(uri='dummy1:a') @@ -266,9 +326,9 @@ class CoreLibraryTest(unittest.TestCase): self.assertIn(result1, result) self.assertNotIn(None, result) self.library1.search.assert_called_once_with( - query=dict(any=['a']), uris=None) + query=dict(any=['a']), uris=None, exact=False) self.library2.search.assert_called_once_with( - query=dict(any=['a']), uris=None) + query=dict(any=['a']), uris=None, exact=False) def test_search_accepts_query_dict_instead_of_kwargs(self): track1 = Track(uri='dummy1:a') @@ -286,6 +346,50 @@ class CoreLibraryTest(unittest.TestCase): self.assertIn(result1, result) self.assertIn(result2, result) self.library1.search.assert_called_once_with( - query=dict(any=['a']), uris=None) + query=dict(any=['a']), uris=None, exact=False) self.library2.search.assert_called_once_with( - query=dict(any=['a']), uris=None) + query=dict(any=['a']), uris=None, exact=False) + + def test_search_normalises_bad_queries(self): + self.core.library.search({'any': 'foobar'}) + self.library1.search.assert_called_once_with( + query={'any': ['foobar']}, uris=None, exact=False) + + def test_find_exact_normalises_bad_queries(self): + self.core.library.find_exact({'any': 'foobar'}) + self.library1.search.assert_called_once_with( + query={'any': ['foobar']}, uris=None, exact=True) + + +class LegacyFindExactToSearchLibraryTest(unittest.TestCase): + def setUp(self): # noqa: N802 + self.backend = mock.Mock() + self.backend.actor_ref.actor_class.__name__ = 'DummyBackend' + self.backend.uri_schemes.get.return_value = ['dummy'] + self.backend.library = mock.Mock(spec=backend.LibraryProvider) + self.core = core.Core(mixer=None, backends=[self.backend]) + + def test_core_find_exact_calls_backend_search_with_exact(self): + self.core.library.find_exact(query={'any': ['a']}) + self.backend.library.search.assert_called_once_with( + query=dict(any=['a']), uris=None, exact=True) + + def test_core_find_exact_handles_legacy_backend(self): + self.backend.library.search.return_value.get.side_effect = TypeError + self.core.library.find_exact(query={'any': ['a']}) + # We are just testing that this doesn't fail. + + def test_core_search_call_backend_search_with_exact(self): + self.core.library.search(query={'any': ['a']}) + self.backend.library.search.assert_called_once_with( + query=dict(any=['a']), uris=None, exact=False) + + def test_core_search_with_exact_call_backend_search_with_exact(self): + self.core.library.search(query={'any': ['a']}, exact=True) + self.backend.library.search.assert_called_once_with( + query=dict(any=['a']), uris=None, exact=True) + + def test_core_search_with_handles_legacy_backend(self): + self.backend.library.search.return_value.get.side_effect = TypeError + self.core.library.search(query={'any': ['a']}, exact=True) + # We are just testing that this doesn't fail. diff --git a/tests/core/test_listener.py b/tests/core/test_listener.py index c0075450..8ec3a843 100644 --- a/tests/core/test_listener.py +++ b/tests/core/test_listener.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import unittest @@ -9,7 +9,7 @@ from mopidy.models import Playlist, TlTrack class CoreListenerTest(unittest.TestCase): - def setUp(self): + def setUp(self): # noqa: N802 self.listener = CoreListener() def test_on_event_forwards_to_specific_handler(self): @@ -57,3 +57,6 @@ class CoreListenerTest(unittest.TestCase): def test_listener_has_default_impl_for_seeked(self): self.listener.seeked(0) + + def test_listener_has_default_impl_for_stream_title_changed(self): + self.listener.stream_title_changed('foobar') diff --git a/tests/core/test_mixer.py b/tests/core/test_mixer.py new file mode 100644 index 00000000..c4126eaa --- /dev/null +++ b/tests/core/test_mixer.py @@ -0,0 +1,90 @@ +from __future__ import absolute_import, unicode_literals + +import unittest + +import mock + +import pykka + +from mopidy import core, mixer +from tests import dummy_mixer + + +class CoreMixerTest(unittest.TestCase): + def setUp(self): # noqa: N802 + self.mixer = mock.Mock(spec=mixer.Mixer) + self.core = core.Core(mixer=self.mixer, backends=[]) + + def test_get_volume(self): + self.mixer.get_volume.return_value.get.return_value = 30 + + self.assertEqual(self.core.mixer.get_volume(), 30) + self.mixer.get_volume.assert_called_once_with() + + def test_set_volume(self): + self.core.mixer.set_volume(30) + + self.mixer.set_volume.assert_called_once_with(30) + + def test_get_mute(self): + self.mixer.get_mute.return_value.get.return_value = True + + self.assertEqual(self.core.mixer.get_mute(), True) + self.mixer.get_mute.assert_called_once_with() + + def test_set_mute(self): + self.core.mixer.set_mute(True) + + self.mixer.set_mute.assert_called_once_with(True) + + +class CoreNoneMixerTest(unittest.TestCase): + def setUp(self): # noqa: N802 + self.core = core.Core(mixer=None, backends=[]) + + def test_get_volume_return_none_because_it_is_unknown(self): + self.assertEqual(self.core.mixer.get_volume(), None) + + def test_set_volume_return_false_because_it_failed(self): + self.assertEqual(self.core.mixer.set_volume(30), False) + + def test_get_mute_return_none_because_it_is_unknown(self): + self.assertEqual(self.core.mixer.get_mute(), None) + + def test_set_mute_return_false_because_it_failed(self): + self.assertEqual(self.core.mixer.set_mute(True), False) + + +@mock.patch.object(mixer.MixerListener, 'send') +class CoreMixerListenerTest(unittest.TestCase): + def setUp(self): # noqa: N802 + self.mixer = dummy_mixer.create_proxy() + self.core = core.Core(mixer=self.mixer, backends=[]) + + def tearDown(self): # noqa: N802 + pykka.ActorRegistry.stop_all() + + def test_forwards_mixer_volume_changed_event_to_frontends(self, send): + self.assertEqual(self.core.mixer.set_volume(volume=60), True) + self.assertEqual(send.call_args[0][0], 'volume_changed') + self.assertEqual(send.call_args[1]['volume'], 60) + + def test_forwards_mixer_mute_changed_event_to_frontends(self, send): + self.core.mixer.set_mute(mute=True) + + self.assertEqual(send.call_args[0][0], 'mute_changed') + self.assertEqual(send.call_args[1]['mute'], True) + + +@mock.patch.object(mixer.MixerListener, 'send') +class CoreNoneMixerListenerTest(unittest.TestCase): + def setUp(self): # noqa: N802 + self.core = core.Core(mixer=None, backends=[]) + + def test_forwards_mixer_volume_changed_event_to_frontends(self, send): + self.assertEqual(self.core.mixer.set_volume(volume=60), False) + self.assertEqual(send.call_count, 0) + + def test_forwards_mixer_mute_changed_event_to_frontends(self, send): + self.core.mixer.set_mute(mute=True) + self.assertEqual(send.call_count, 0) diff --git a/tests/core/test_playback.py b/tests/core/test_playback.py index 909ca4fa..7c4db0d6 100644 --- a/tests/core/test_playback.py +++ b/tests/core/test_playback.py @@ -1,15 +1,20 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import unittest import mock +import pykka + from mopidy import backend, core from mopidy.models import Track +from tests import dummy_audio as audio + +# TODO: split into smaller easier to follow tests. setup is way to complex. class CorePlaybackTest(unittest.TestCase): - def setUp(self): + def setUp(self): # noqa: N802 self.backend1 = mock.Mock() self.backend1.uri_schemes.get.return_value = ['dummy1'] self.playback1 = mock.Mock(spec=backend.PlaybackProvider) @@ -34,6 +39,7 @@ class CorePlaybackTest(unittest.TestCase): Track(uri='dummy2:a', length=40000), Track(uri='dummy3:a', length=40000), # Unplayable Track(uri='dummy1:b', length=40000), + Track(uri='dummy1:c', length=None), # No duration ] self.core = core.Core(mixer=None, backends=[ @@ -42,34 +48,105 @@ class CorePlaybackTest(unittest.TestCase): self.tl_tracks = self.core.tracklist.tl_tracks self.unplayable_tl_track = self.tl_tracks[2] + self.duration_less_tl_track = self.tl_tracks[4] - # TODO Test get_current_tl_track + def trigger_end_of_track(self): + self.core.playback._on_end_of_track() - # TODO Test get_current_track + def set_current_tl_track(self, tl_track): + self.core.playback._set_current_tl_track(tl_track) + + def test_get_current_tl_track_none(self): + self.set_current_tl_track(None) + + self.assertEqual( + self.core.playback.get_current_tl_track(), None) + + def test_get_current_tl_track_play(self): + self.core.playback.play(self.tl_tracks[0]) + + self.assertEqual( + self.core.playback.get_current_tl_track(), self.tl_tracks[0]) + + def test_get_current_tl_track_next(self): + self.core.playback.play(self.tl_tracks[0]) + self.core.playback.next() + + self.assertEqual( + self.core.playback.get_current_tl_track(), self.tl_tracks[1]) + + def test_get_current_tl_track_prev(self): + self.core.playback.play(self.tl_tracks[1]) + self.core.playback.previous() + + self.assertEqual( + self.core.playback.get_current_tl_track(), self.tl_tracks[0]) + + def test_get_current_track_play(self): + self.core.playback.play(self.tl_tracks[0]) + + self.assertEqual( + self.core.playback.get_current_track(), self.tracks[0]) + + def test_get_current_track_next(self): + self.core.playback.play(self.tl_tracks[0]) + self.core.playback.next() + + self.assertEqual( + self.core.playback.get_current_track(), self.tracks[1]) + + def test_get_current_track_prev(self): + self.core.playback.play(self.tl_tracks[1]) + self.core.playback.previous() + + self.assertEqual( + self.core.playback.get_current_track(), self.tracks[0]) # TODO Test state def test_play_selects_dummy1_backend(self): self.core.playback.play(self.tl_tracks[0]) - self.playback1.play.assert_called_once_with(self.tracks[0]) + self.playback1.prepare_change.assert_called_once_with() + self.playback1.change_track.assert_called_once_with(self.tracks[0]) + self.playback1.play.assert_called_once_with() self.assertFalse(self.playback2.play.called) def test_play_selects_dummy2_backend(self): self.core.playback.play(self.tl_tracks[1]) self.assertFalse(self.playback1.play.called) - self.playback2.play.assert_called_once_with(self.tracks[1]) + self.playback2.prepare_change.assert_called_once_with() + self.playback2.change_track.assert_called_once_with(self.tracks[1]) + self.playback2.play.assert_called_once_with() - def test_play_skips_to_next_on_unplayable_track(self): + def test_play_skips_to_next_on_track_without_playback_backend(self): self.core.playback.play(self.unplayable_tl_track) - self.playback1.play.assert_called_once_with(self.tracks[3]) + self.playback1.prepare_change.assert_called_once_with() + self.playback1.change_track.assert_called_once_with(self.tracks[3]) + self.playback1.play.assert_called_once_with() self.assertFalse(self.playback2.play.called) self.assertEqual( self.core.playback.current_tl_track, self.tl_tracks[3]) + def test_play_skips_to_next_on_unplayable_track(self): + """Checks that we handle backend.change_track failing.""" + self.playback2.change_track.return_value.get.return_value = False + + self.core.tracklist.clear() + self.core.tracklist.add(self.tracks[:2]) + tl_tracks = self.core.tracklist.tl_tracks + + self.core.playback.play(tl_tracks[0]) + self.core.playback.play(tl_tracks[1]) + + # TODO: we really want to check that the track was marked unplayable + # and that next was called. This is just an indirect way of checking + # this :( + self.assertEqual(self.core.playback.state, core.PlaybackState.STOPPED) + @mock.patch( 'mopidy.core.playback.listener.CoreListener', spec=core.CoreListener) def test_play_when_stopped_emits_events(self, listener_mock): @@ -85,6 +162,25 @@ class CorePlaybackTest(unittest.TestCase): 'track_playback_started', tl_track=self.tl_tracks[0]), ]) + @mock.patch( + 'mopidy.core.playback.listener.CoreListener', spec=core.CoreListener) + def test_play_when_paused_emits_events(self, listener_mock): + self.core.playback.play(self.tl_tracks[0]) + self.core.playback.pause() + listener_mock.reset_mock() + + self.core.playback.play(self.tl_tracks[1]) + + self.assertListEqual( + listener_mock.send.mock_calls, + [ + mock.call( + 'playback_state_changed', + old_state='paused', new_state='playing'), + mock.call( + 'track_playback_started', tl_track=self.tl_tracks[1]), + ]) + @mock.patch( 'mopidy.core.playback.listener.CoreListener', spec=core.CoreListener) def test_play_when_playing_emits_events(self, listener_mock): @@ -124,7 +220,7 @@ class CorePlaybackTest(unittest.TestCase): self.playback2.pause.assert_called_once_with() def test_pause_changes_state_even_if_track_is_unplayable(self): - self.core.playback.current_tl_track = self.unplayable_tl_track + self.set_current_tl_track(self.unplayable_tl_track) self.core.playback.pause() self.assertEqual(self.core.playback.state, core.PlaybackState.PAUSED) @@ -167,7 +263,7 @@ class CorePlaybackTest(unittest.TestCase): self.playback2.resume.assert_called_once_with() def test_resume_does_nothing_if_track_is_unplayable(self): - self.core.playback.current_tl_track = self.unplayable_tl_track + self.set_current_tl_track(self.unplayable_tl_track) self.core.playback.state = core.PlaybackState.PAUSED self.core.playback.resume() @@ -210,7 +306,7 @@ class CorePlaybackTest(unittest.TestCase): self.playback2.stop.assert_called_once_with() def test_stop_changes_state_even_if_track_is_unplayable(self): - self.core.playback.current_tl_track = self.unplayable_tl_track + self.set_current_tl_track(self.unplayable_tl_track) self.core.playback.state = core.PlaybackState.PAUSED self.core.playback.stop() @@ -329,7 +425,7 @@ class CorePlaybackTest(unittest.TestCase): tl_track = self.tl_tracks[0] self.core.playback.play(tl_track) - self.core.playback.on_end_of_track() + self.trigger_end_of_track() self.assertIn(tl_track, self.core.tracklist.tl_tracks) @@ -338,7 +434,7 @@ class CorePlaybackTest(unittest.TestCase): self.core.playback.play(tl_track) self.core.tracklist.consume = True - self.core.playback.on_end_of_track() + self.trigger_end_of_track() self.assertNotIn(tl_track, self.core.tracklist.tl_tracks) @@ -348,7 +444,31 @@ class CorePlaybackTest(unittest.TestCase): self.core.playback.play(self.tl_tracks[0]) listener_mock.reset_mock() - self.core.playback.on_end_of_track() + self.trigger_end_of_track() + + self.assertListEqual( + listener_mock.send.mock_calls, + [ + mock.call( + 'playback_state_changed', + old_state='playing', new_state='stopped'), + mock.call( + 'track_playback_ended', + tl_track=self.tl_tracks[0], time_position=mock.ANY), + mock.call( + 'playback_state_changed', + old_state='stopped', new_state='playing'), + mock.call( + 'track_playback_started', tl_track=self.tl_tracks[1]), + ]) + + @mock.patch( + 'mopidy.core.playback.listener.CoreListener', spec=core.CoreListener) + def test_seek_past_end_of_track_emits_events(self, listener_mock): + self.core.playback.play(self.tl_tracks[0]) + listener_mock.reset_mock() + + self.core.playback.seek(self.tracks[0].length * 5) self.assertListEqual( listener_mock.send.mock_calls, @@ -381,7 +501,7 @@ class CorePlaybackTest(unittest.TestCase): self.playback2.seek.assert_called_once_with(10000) def test_seek_fails_for_unplayable_track(self): - self.core.playback.current_tl_track = self.unplayable_tl_track + self.set_current_tl_track(self.unplayable_tl_track) self.core.playback.state = core.PlaybackState.PLAYING success = self.core.playback.seek(1000) @@ -389,6 +509,29 @@ class CorePlaybackTest(unittest.TestCase): self.assertFalse(self.playback1.seek.called) self.assertFalse(self.playback2.seek.called) + def test_seek_fails_for_track_without_duration(self): + self.set_current_tl_track(self.duration_less_tl_track) + self.core.playback.state = core.PlaybackState.PLAYING + success = self.core.playback.seek(1000) + + self.assertFalse(success) + self.assertFalse(self.playback1.seek.called) + self.assertFalse(self.playback2.seek.called) + + def test_seek_play_stay_playing(self): + self.core.playback.play(self.tl_tracks[0]) + self.core.playback.state = core.PlaybackState.PLAYING + self.core.playback.seek(1000) + + self.assertEqual(self.core.playback.state, core.PlaybackState.PLAYING) + + def test_seek_paused_stay_paused(self): + self.core.playback.play(self.tl_tracks[0]) + self.core.playback.state = core.PlaybackState.PAUSED + self.core.playback.seek(1000) + + self.assertEqual(self.core.playback.state, core.PlaybackState.PAUSED) + @mock.patch( 'mopidy.core.playback.listener.CoreListener', spec=core.CoreListener) def test_seek_emits_seeked_event(self, listener_mock): @@ -417,7 +560,7 @@ class CorePlaybackTest(unittest.TestCase): self.playback2.get_time_position.assert_called_once_with() def test_time_position_returns_0_if_track_is_unplayable(self): - self.core.playback.current_tl_track = self.unplayable_tl_track + self.set_current_tl_track(self.unplayable_tl_track) result = self.core.playback.time_position @@ -427,20 +570,101 @@ class CorePlaybackTest(unittest.TestCase): # TODO Test on_tracklist_change - def test_volume(self): - self.assertEqual(self.core.playback.volume, None) - self.core.playback.volume = 30 +# Since we rely on our DummyAudio to actually emit events we need a "real" +# backend and not a mock so the right calls make it through to audio. +class TestBackend(pykka.ThreadingActor, backend.Backend): + uri_schemes = ['dummy'] - self.assertEqual(self.core.playback.volume, 30) + def __init__(self, config, audio): + super(TestBackend, self).__init__() + self.playback = backend.PlaybackProvider(audio=audio, backend=self) - self.core.playback.volume = 70 - self.assertEqual(self.core.playback.volume, 70) +class TestStream(unittest.TestCase): + def setUp(self): # noqa: N802 + self.audio = audio.DummyAudio.start().proxy() + self.backend = TestBackend.start(config={}, audio=self.audio).proxy() + self.core = core.Core(audio=self.audio, backends=[self.backend]) + self.playback = self.core.playback - def test_mute(self): - self.assertEqual(self.core.playback.mute, False) + self.tracks = [Track(uri='dummy:a', length=1234), + Track(uri='dummy:b', length=1234)] - self.core.playback.mute = True + self.core.tracklist.add(self.tracks) - self.assertEqual(self.core.playback.mute, True) + self.events = [] + self.patcher = mock.patch('mopidy.audio.listener.AudioListener.send') + self.send_mock = self.patcher.start() + + def send(event, **kwargs): + self.events.append((event, kwargs)) + + self.send_mock.side_effect = send + + def tearDown(self): # noqa: N802 + pykka.ActorRegistry.stop_all() + self.patcher.stop() + + def replay_audio_events(self): + while self.events: + event, kwargs = self.events.pop(0) + self.core.on_event(event, **kwargs) + + def test_get_stream_title_before_playback(self): + self.assertEqual(self.playback.get_stream_title(), None) + + def test_get_stream_title_during_playback(self): + self.core.playback.play() + + self.replay_audio_events() + self.assertEqual(self.playback.get_stream_title(), None) + + def test_get_stream_title_during_playback_with_tags_change(self): + self.core.playback.play() + self.audio.trigger_fake_tags_changed({'organization': ['baz']}) + self.audio.trigger_fake_tags_changed({'title': ['foobar']}).get() + + self.replay_audio_events() + self.assertEqual(self.playback.get_stream_title(), 'foobar') + + def test_get_stream_title_after_next(self): + self.core.playback.play() + self.audio.trigger_fake_tags_changed({'organization': ['baz']}) + self.audio.trigger_fake_tags_changed({'title': ['foobar']}).get() + self.core.playback.next() + + self.replay_audio_events() + self.assertEqual(self.playback.get_stream_title(), None) + + def test_get_stream_title_after_next_with_tags_change(self): + self.core.playback.play() + self.audio.trigger_fake_tags_changed({'organization': ['baz']}) + self.audio.trigger_fake_tags_changed({'title': ['foo']}).get() + self.core.playback.next() + self.audio.trigger_fake_tags_changed({'organization': ['baz']}) + self.audio.trigger_fake_tags_changed({'title': ['bar']}).get() + + self.replay_audio_events() + self.assertEqual(self.playback.get_stream_title(), 'bar') + + def test_get_stream_title_after_stop(self): + self.core.playback.play() + self.audio.trigger_fake_tags_changed({'organization': ['baz']}) + self.audio.trigger_fake_tags_changed({'title': ['foobar']}).get() + self.core.playback.stop() + + self.replay_audio_events() + self.assertEqual(self.playback.get_stream_title(), None) + + +class CorePlaybackWithOldBackendTest(unittest.TestCase): + def test_type_error_from_old_backend_does_not_crash_core(self): + b = mock.Mock() + b.uri_schemes.get.return_value = ['dummy1'] + b.playback = mock.Mock(spec=backend.PlaybackProvider) + b.playback.play.side_effect = TypeError + + c = core.Core(mixer=None, backends=[b]) + c.tracklist.add([Track(uri='dummy1:a', length=40000)]) + c.playback.play() # No TypeError == test passed. diff --git a/tests/core/test_playlists.py b/tests/core/test_playlists.py index 49f617b5..081f73e6 100644 --- a/tests/core/test_playlists.py +++ b/tests/core/test_playlists.py @@ -1,23 +1,43 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import unittest import mock from mopidy import backend, core -from mopidy.models import Playlist, Track +from mopidy.models import Playlist, Ref, Track class PlaylistsTest(unittest.TestCase): - def setUp(self): - self.backend1 = mock.Mock() - self.backend1.uri_schemes.get.return_value = ['dummy1'] + def setUp(self): # noqa: N802 + self.plr1a = Ref.playlist(name='A', uri='dummy1:pl:a') + self.plr1b = Ref.playlist(name='B', uri='dummy1:pl:b') + self.plr2a = Ref.playlist(name='A', uri='dummy2:pl:a') + self.plr2b = Ref.playlist(name='B', uri='dummy2:pl:b') + + self.pl1a = Playlist(name='A', tracks=[Track(uri='dummy1:t:a')]) + self.pl1b = Playlist(name='B', tracks=[Track(uri='dummy1:t:b')]) + self.pl2a = Playlist(name='A', tracks=[Track(uri='dummy2:t:a')]) + self.pl2b = Playlist(name='B', tracks=[Track(uri='dummy2:t:b')]) + self.sp1 = mock.Mock(spec=backend.PlaylistsProvider) + self.sp1.as_list.return_value.get.return_value = [ + self.plr1a, self.plr1b] + self.sp1.lookup.return_value.get.side_effect = [self.pl1a, self.pl1b] + + self.sp2 = mock.Mock(spec=backend.PlaylistsProvider) + self.sp2.as_list.return_value.get.return_value = [ + self.plr2a, self.plr2b] + self.sp2.lookup.return_value.get.side_effect = [self.pl2a, self.pl2b] + + self.backend1 = mock.Mock() + self.backend1.actor_ref.actor_class.__name__ = 'Backend1' + self.backend1.uri_schemes.get.return_value = ['dummy1'] self.backend1.playlists = self.sp1 self.backend2 = mock.Mock() + self.backend2.actor_ref.actor_class.__name__ = 'Backend2' self.backend2.uri_schemes.get.return_value = ['dummy2'] - self.sp2 = mock.Mock(spec=backend.PlaylistsProvider) self.backend2.playlists = self.sp2 # A backend without the optional playlists provider @@ -26,19 +46,45 @@ class PlaylistsTest(unittest.TestCase): self.backend3.has_playlists().get.return_value = False self.backend3.playlists = None - self.pl1a = Playlist(name='A', tracks=[Track(uri='dummy1:a')]) - self.pl1b = Playlist(name='B', tracks=[Track(uri='dummy1:b')]) - self.sp1.playlists.get.return_value = [self.pl1a, self.pl1b] - - self.pl2a = Playlist(name='A', tracks=[Track(uri='dummy2:a')]) - self.pl2b = Playlist(name='B', tracks=[Track(uri='dummy2:b')]) - self.sp2.playlists.get.return_value = [self.pl2a, self.pl2b] - self.core = core.Core(mixer=None, backends=[ self.backend3, self.backend1, self.backend2]) + def test_as_list_combines_result_from_backends(self): + result = self.core.playlists.as_list() + + self.assertIn(self.plr1a, result) + self.assertIn(self.plr1b, result) + self.assertIn(self.plr2a, result) + self.assertIn(self.plr2b, result) + + def test_as_list_ignores_backends_that_dont_support_it(self): + self.sp2.as_list.return_value.get.side_effect = NotImplementedError + + result = self.core.playlists.as_list() + + self.assertEqual(len(result), 2) + self.assertIn(self.plr1a, result) + self.assertIn(self.plr1b, result) + + def test_get_items_selects_the_matching_backend(self): + ref = Ref.track() + self.sp2.get_items.return_value.get.return_value = [ref] + + result = self.core.playlists.get_items('dummy2:pl:a') + + self.assertEqual([ref], result) + self.assertFalse(self.sp1.get_items.called) + self.sp2.get_items.assert_called_once_with('dummy2:pl:a') + + def test_get_items_with_unknown_uri_scheme_does_nothing(self): + result = self.core.playlists.get_items('unknown:a') + + self.assertIsNone(result) + self.assertFalse(self.sp1.delete.called) + self.assertFalse(self.sp2.delete.called) + def test_get_playlists_combines_result_from_backends(self): - result = self.core.playlists.playlists + result = self.core.playlists.get_playlists() self.assertIn(self.pl1a, result) self.assertIn(self.pl1b, result) diff --git a/tests/core/test_tracklist.py b/tests/core/test_tracklist.py index 963a4bb7..415d1fa0 100644 --- a/tests/core/test_tracklist.py +++ b/tests/core/test_tracklist.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import unittest @@ -9,7 +9,7 @@ from mopidy.models import Track class TracklistTest(unittest.TestCase): - def setUp(self): + def setUp(self): # noqa: N802 self.tracks = [ Track(uri='dummy1:a', name='foo'), Track(uri='dummy1:b', name='foo'), @@ -26,8 +26,7 @@ class TracklistTest(unittest.TestCase): 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() + self.library.lookup.return_value.get.return_value = [track] tl_tracks = self.core.tracklist.add(uri='dummy1:x') @@ -36,6 +35,26 @@ class TracklistTest(unittest.TestCase): self.assertEqual(track, tl_tracks[0].track) self.assertEqual(tl_tracks, self.core.tracklist.tl_tracks[-1:]) + def test_add_by_uris_looks_up_uris_in_library(self): + track1 = Track(uri='dummy1:x', name='x') + track2 = Track(uri='dummy1:y1', name='y1') + track3 = Track(uri='dummy1:y2', name='y2') + self.library.lookup.return_value.get.side_effect = [ + [track1], [track2, track3]] + + tl_tracks = self.core.tracklist.add(uris=['dummy1:x', 'dummy1:y']) + + self.library.lookup.assert_has_calls([ + mock.call('dummy1:x'), + mock.call('dummy1:y'), + ]) + self.assertEqual(3, len(tl_tracks)) + self.assertEqual(track1, tl_tracks[0].track) + self.assertEqual(track2, tl_tracks[1].track) + self.assertEqual(track3, tl_tracks[2].track) + self.assertEqual( + tl_tracks, self.core.tracklist.tl_tracks[-len(tl_tracks):]) + def test_remove_removes_tl_tracks_matching_query(self): tl_tracks = self.core.tracklist.remove(name=['foo']) @@ -67,9 +86,11 @@ class TracklistTest(unittest.TestCase): self.assertListEqual(self.tl_tracks[:2], tl_tracks) def test_filter_fails_if_values_isnt_iterable(self): - self.assertRaises(ValueError, self.core.tracklist.filter, tlid=3) + with self.assertRaises(ValueError): + self.core.tracklist.filter(tlid=3) def test_filter_fails_if_values_is_a_string(self): - self.assertRaises(ValueError, self.core.tracklist.filter, uri='a') + with self.assertRaises(ValueError): + self.core.tracklist.filter(uri='a') # TODO Extract tracklist tests from the local backend tests diff --git a/tests/data/find/.blank.mp3 b/tests/data/find/.blank.mp3 deleted file mode 100644 index ef159a70..00000000 Binary files a/tests/data/find/.blank.mp3 and /dev/null differ diff --git a/tests/data/find/baz/file b/tests/data/find/baz/file deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/data/find/foo/bar/file b/tests/data/find/foo/bar/file deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/data/find/foo/file b/tests/data/find/foo/file deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/dummy_audio.py b/tests/dummy_audio.py new file mode 100644 index 00000000..dcf90ffa --- /dev/null +++ b/tests/dummy_audio.py @@ -0,0 +1,130 @@ +"""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 absolute_import, unicode_literals + +import pykka + +from mopidy import audio + + +def create_proxy(config=None, mixer=None): + return DummyAudio.start(config, mixer).proxy() + + +class DummyAudio(pykka.ThreadingActor): + def __init__(self, config=None, mixer=None): + super(DummyAudio, self).__init__() + self.state = audio.PlaybackState.STOPPED + self._volume = 0 + self._position = 0 + self._callback = None + self._uri = None + self._state_change_result = True + self._tags = {} + + def set_uri(self, uri): + assert self._uri is None, 'prepare change not called before set' + self._tags = {} + self._uri = uri + + 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 + audio.AudioListener.send('position_changed', position=position) + return True + + def start_playback(self): + return self._change_state(audio.PlaybackState.PLAYING) + + def pause_playback(self): + return self._change_state(audio.PlaybackState.PAUSED) + + def prepare_change(self): + self._uri = None + return True + + def stop_playback(self): + return self._change_state(audio.PlaybackState.STOPPED) + + def get_volume(self): + return self._volume + + def set_volume(self, volume): + self._volume = volume + return True + + def set_metadata(self, track): + pass + + def get_current_tags(self): + return self._tags + + def set_about_to_finish_callback(self, callback): + self._callback = callback + + def enable_sync_handler(self): + pass + + def wait_for_state_change(self): + pass + + def _change_state(self, new_state): + if not self._uri: + return False + + if self.state == audio.PlaybackState.STOPPED and self._uri: + audio.AudioListener.send('position_changed', position=0) + audio.AudioListener.send('stream_changed', uri=self._uri) + + if new_state == audio.PlaybackState.STOPPED: + self._uri = None + audio.AudioListener.send('stream_changed', uri=self._uri) + + old_state, self.state = self.state, new_state + audio.AudioListener.send( + 'state_changed', + old_state=old_state, new_state=new_state, target_state=None) + + if new_state == audio.PlaybackState.PLAYING: + self._tags['audio-codec'] = [u'fake info...'] + audio.AudioListener.send('tags_changed', tags=['audio-codec']) + + return self._state_change_result + + def trigger_fake_playback_failure(self): + self._state_change_result = False + + def trigger_fake_tags_changed(self, tags): + self._tags.update(tags) + audio.AudioListener.send('tags_changed', tags=self._tags.keys()) + + def get_about_to_finish_callback(self): + # This needs to be called from outside the actor or we lock up. + def wrapper(): + if self._callback: + self.prepare_change() + self._callback() + + if not self._uri or not self._callback: + self._tags = {} + audio.AudioListener.send('reached_end_of_stream') + else: + audio.AudioListener.send('position_changed', position=0) + audio.AudioListener.send('stream_changed', uri=self._uri) + + return wrapper diff --git a/mopidy/backend/dummy.py b/tests/dummy_backend.py similarity index 66% rename from mopidy/backend/dummy.py rename to tests/dummy_backend.py index 94b01433..61c26c5f 100644 --- a/mopidy/backend/dummy.py +++ b/tests/dummy_backend.py @@ -4,7 +4,7 @@ This backend implements the backend API in the simplest way possible. It is used in tests of the frontends. """ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import pykka @@ -12,7 +12,7 @@ from mopidy import backend from mopidy.models import Playlist, Ref, SearchResult -def create_dummy_backend_proxy(config=None, audio=None): +def create_proxy(config=None, audio=None): return DummyBackend.start(config=config, audio=audio).proxy() @@ -33,6 +33,7 @@ class DummyLibraryProvider(backend.LibraryProvider): def __init__(self, *args, **kwargs): super(DummyLibraryProvider, self).__init__(*args, **kwargs) self.dummy_library = [] + self.dummy_get_distinct_result = {} self.dummy_browse_result = {} self.dummy_find_exact_result = SearchResult() self.dummy_search_result = SearchResult() @@ -40,31 +41,41 @@ class DummyLibraryProvider(backend.LibraryProvider): def browse(self, path): return self.dummy_browse_result.get(path, []) - def find_exact(self, **query): - return self.dummy_find_exact_result + def get_distinct(self, field, query=None): + return self.dummy_get_distinct_result.get(field, set()) def lookup(self, uri): - return filter(lambda t: uri == t.uri, self.dummy_library) + return [t for t in self.dummy_library if uri == t.uri] def refresh(self, uri=None): pass - def search(self, **query): + def search(self, query=None, uris=None, exact=False): + if exact: # TODO: remove uses of dummy_find_exact_result + return self.dummy_find_exact_result return self.dummy_search_result class DummyPlaybackProvider(backend.PlaybackProvider): def __init__(self, *args, **kwargs): super(DummyPlaybackProvider, self).__init__(*args, **kwargs) + self._uri = None self._time_position = 0 def pause(self): return True - def play(self, track): + def play(self): + return self._uri and self._uri != 'dummy:error' + + def change_track(self, track): """Pass a track with URI 'dummy:error' to force failure""" + self._uri = track.uri self._time_position = 0 - return track.uri != 'dummy:error' + return True + + def prepare_change(self): + pass def resume(self): return True @@ -74,6 +85,7 @@ class DummyPlaybackProvider(backend.PlaybackProvider): return True def stop(self): + self._uri = None return True def get_time_position(self): @@ -81,6 +93,33 @@ class DummyPlaybackProvider(backend.PlaybackProvider): class DummyPlaylistsProvider(backend.PlaylistsProvider): + def __init__(self, backend): + super(DummyPlaylistsProvider, self).__init__(backend) + self._playlists = [] + + def set_dummy_playlists(self, playlists): + """For tests using the dummy provider through an actor proxy.""" + self._playlists = playlists + + def as_list(self): + return [ + Ref.playlist(uri=pl.uri, name=pl.name) for pl in self._playlists] + + def get_items(self, uri): + playlist = self.lookup(uri) + if playlist is None: + return + return [ + Ref.track(uri=t.uri, name=t.name) for t in playlist.tracks] + + def lookup(self, uri): + for playlist in self._playlists: + if playlist.uri == uri: + return playlist + + def refresh(self): + pass + def create(self, name): playlist = Playlist(name=name, uri='dummy:%s' % name) self._playlists.append(playlist) @@ -91,14 +130,6 @@ class DummyPlaylistsProvider(backend.PlaylistsProvider): if playlist: self._playlists.remove(playlist) - def lookup(self, uri): - for playlist in self._playlists: - if playlist.uri == uri: - return playlist - - def refresh(self): - pass - def save(self, playlist): old_playlist = self.lookup(playlist.uri) diff --git a/tests/dummy_mixer.py b/tests/dummy_mixer.py new file mode 100644 index 00000000..6defddba --- /dev/null +++ b/tests/dummy_mixer.py @@ -0,0 +1,33 @@ +from __future__ import unicode_literals + +import pykka + +from mopidy import mixer + + +def create_proxy(config=None): + return DummyMixer.start(config=None).proxy() + + +class DummyMixer(pykka.ThreadingActor, mixer.Mixer): + + def __init__(self, config): + super(DummyMixer, self).__init__() + self._volume = None + self._mute = None + + def get_volume(self): + return self._volume + + def set_volume(self, volume): + self._volume = volume + self.trigger_volume_changed(volume=volume) + return True + + def get_mute(self): + return self._mute + + def set_mute(self, mute): + self._mute = mute + self.trigger_mute_changed(mute=mute) + return True diff --git a/tests/http/test_events.py b/tests/http/test_events.py index d03778a6..43d9db58 100644 --- a/tests/http/test_events.py +++ b/tests/http/test_events.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import json import unittest diff --git a/tests/http/test_handlers.py b/tests/http/test_handlers.py index 28e53855..8bd82e11 100644 --- a/tests/http/test_handlers.py +++ b/tests/http/test_handlers.py @@ -1,9 +1,12 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import os +import mock + import tornado.testing import tornado.web +import tornado.websocket import mopidy from mopidy.http import handlers @@ -35,3 +38,49 @@ class StaticFileHandlerTest(tornado.testing.AsyncHTTPTestCase): response.headers['X-Mopidy-Version'], mopidy.__version__) self.assertEqual( response.headers['Cache-Control'], 'no-cache') + + +# We aren't bothering with skipIf as then we would need to "backport" gen_test +if hasattr(tornado.websocket, 'websocket_connect'): + class WebSocketHandlerTest(tornado.testing.AsyncHTTPTestCase): + def get_app(self): + self.core = mock.Mock() + return tornado.web.Application([ + (r'/ws/?', handlers.WebSocketHandler, {'core': self.core}) + ]) + + def connection(self): + url = self.get_url('/ws').replace('http', 'ws') + return tornado.websocket.websocket_connect(url, self.io_loop) + + @tornado.testing.gen_test + def test_invalid_json_rpc_request_doesnt_crash_handler(self): + # An uncaught error would result in no message, so this is just a + # simplistic test to verify this. + conn = yield self.connection() + conn.write_message('invalid request') + message = yield conn.read_message() + self.assertTrue(message) + + @tornado.testing.gen_test + def test_broadcast_makes_it_to_client(self): + conn = yield self.connection() + handlers.WebSocketHandler.broadcast('message') + message = yield conn.read_message() + self.assertEqual(message, 'message') + + @tornado.testing.gen_test + def test_broadcast_to_client_that_just_closed_connection(self): + conn = yield self.connection() + conn.stream.close() + handlers.WebSocketHandler.broadcast('message') + + @tornado.testing.gen_test + def test_broadcast_to_client_without_ws_connection_present(self): + yield self.connection() + # Tornado checks for ws_connection and raises WebSocketClosedError + # if it is missing, this test case simulates winning a race were + # this has happened but we have not yet been removed from clients. + for client in handlers.WebSocketHandler.clients: + client.ws_connection = None + handlers.WebSocketHandler.broadcast('message') diff --git a/tests/http/test_server.py b/tests/http/test_server.py index b3cfa92c..3c7d7c88 100644 --- a/tests/http/test_server.py +++ b/tests/http/test_server.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import os diff --git a/tests/local/__init__.py b/tests/local/__init__.py index f408139f..b1520768 100644 --- a/tests/local/__init__.py +++ b/tests/local/__init__.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals def generate_song(i): diff --git a/tests/local/test_events.py b/tests/local/test_events.py deleted file mode 100644 index f6ae5360..00000000 --- a/tests/local/test_events.py +++ /dev/null @@ -1,38 +0,0 @@ -from __future__ import unicode_literals - -import unittest - -import mock - -import pykka - -from mopidy import audio, backend, core -from mopidy.local import actor - -from tests import path_to_data_dir - - -@mock.patch.object(backend.BackendListener, 'send') -class LocalBackendEventsTest(unittest.TestCase): - config = { - 'local': { - 'media_dir': path_to_data_dir(''), - 'data_dir': path_to_data_dir(''), - 'playlists_dir': b'', - 'library': 'json', - } - } - - def setUp(self): - self.audio = audio.DummyAudio.start().proxy() - self.backend = actor.LocalBackend.start( - config=self.config, audio=self.audio).proxy() - self.core = core.Core.start(backends=[self.backend]).proxy() - - def tearDown(self): - pykka.ActorRegistry.stop_all() - - def test_playlists_refresh_sends_playlists_loaded_event(self, send): - send.reset_mock() - self.core.playlists.refresh().get() - self.assertEqual(send.call_args[0][0], 'playlists_loaded') diff --git a/tests/local/test_json.py b/tests/local/test_json.py index 54afefe7..520287ad 100644 --- a/tests/local/test_json.py +++ b/tests/local/test_json.py @@ -1,15 +1,17 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import unittest from mopidy.local import json -from mopidy.models import Ref +from mopidy.models import Ref, Track + +from tests import path_to_data_dir class BrowseCacheTest(unittest.TestCase): maxDiff = None - def setUp(self): + def setUp(self): # noqa: N802 self.uris = ['local:track:foo/bar/song1', 'local:track:foo/bar/song2', 'local:track:foo/baz/song3', @@ -38,3 +40,52 @@ class BrowseCacheTest(unittest.TestCase): def test_lookup_foo_baz(self): result = self.cache.lookup('local:directory:foo/unknown') self.assertEqual([], result) + + +class JsonLibraryTest(unittest.TestCase): + + config = { + 'local': { + 'media_dir': path_to_data_dir(''), + 'data_dir': path_to_data_dir(''), + 'playlists_dir': b'', + 'library': 'json', + }, + } + + def setUp(self): # noqa: N802 + self.library = json.JsonLibrary(self.config) + + def _create_tracks(self, count): + for i in range(count): + self.library.add(Track(uri='local:track:%d' % i)) + + def test_search_should_default_limit_results(self): + self._create_tracks(101) + + result = self.library.search() + result_exact = self.library.search(exact=True) + + self.assertEqual(len(result.tracks), 100) + self.assertEqual(len(result_exact.tracks), 100) + + def test_search_should_limit_results(self): + self._create_tracks(100) + + result = self.library.search(limit=35) + result_exact = self.library.search(exact=True, limit=35) + + self.assertEqual(len(result.tracks), 35) + self.assertEqual(len(result_exact.tracks), 35) + + def test_search_should_offset_results(self): + self._create_tracks(200) + + expected = self.library.search(limit=110).tracks[10:] + expected_exact = self.library.search(exact=True, limit=110).tracks[10:] + + result = self.library.search(offset=10).tracks + result_exact = self.library.search(offset=10, exact=True).tracks + + self.assertEqual(expected, result) + self.assertEqual(expected_exact, result_exact) diff --git a/tests/local/test_library.py b/tests/local/test_library.py index fcc6d4df..39f0e53e 100644 --- a/tests/local/test_library.py +++ b/tests/local/test_library.py @@ -1,15 +1,17 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import os import shutil import tempfile import unittest +import mock + import pykka from mopidy import core from mopidy.local import actor, json -from mopidy.models import Album, Artist, Track +from mopidy.models import Album, Artist, Image, Track from tests import path_to_data_dir @@ -71,14 +73,14 @@ class LocalLibraryProviderTest(unittest.TestCase): }, } - def setUp(self): + def setUp(self): # noqa: N802 actor.LocalBackend.libraries = [json.JsonLibrary] self.backend = actor.LocalBackend.start( config=self.config, audio=None).proxy() self.core = core.Core(backends=[self.backend]) self.library = self.core.library - def tearDown(self): + def tearDown(self): # noqa: N802 pykka.ActorRegistry.stop_all() actor.LocalBackend.libraries = [] @@ -129,6 +131,22 @@ class LocalLibraryProviderTest(unittest.TestCase): tracks = self.library.lookup('fake uri') self.assertEqual(tracks, []) + # test backward compatibility with local libraries returning a + # single Track + @mock.patch.object(json.JsonLibrary, 'lookup') + def test_lookup_return_single_track(self, mock_lookup): + backend = actor.LocalBackend(config=self.config, audio=None) + + mock_lookup.return_value = self.tracks[0] + tracks = backend.library.lookup(self.tracks[0].uri) + mock_lookup.assert_called_with(self.tracks[0].uri) + self.assertEqual(tracks, self.tracks[0:1]) + + mock_lookup.return_value = None + tracks = backend.library.lookup('fake uri') + mock_lookup.assert_called_with('fake uri') + self.assertEqual(tracks, []) + # TODO: move to search_test module def test_find_exact_no_hits(self): result = self.library.find_exact(track_name=['unknown track']) @@ -317,42 +335,42 @@ class LocalLibraryProviderTest(unittest.TestCase): self.assertEqual(list(result[0].tracks), self.tracks[:1]) def test_find_exact_wrong_type(self): - test = lambda: self.library.find_exact(wrong=['test']) - self.assertRaises(LookupError, test) + with self.assertRaises(LookupError): + self.library.find_exact(wrong=['test']) def test_find_exact_with_empty_query(self): - test = lambda: self.library.find_exact(artist=['']) - self.assertRaises(LookupError, test) + with self.assertRaises(LookupError): + self.library.find_exact(artist=['']) - test = lambda: self.library.find_exact(albumartist=['']) - self.assertRaises(LookupError, test) + with self.assertRaises(LookupError): + self.library.find_exact(albumartist=['']) - test = lambda: self.library.find_exact(track_name=['']) - self.assertRaises(LookupError, test) + with self.assertRaises(LookupError): + self.library.find_exact(track_name=['']) - test = lambda: self.library.find_exact(composer=['']) - self.assertRaises(LookupError, test) + with self.assertRaises(LookupError): + self.library.find_exact(composer=['']) - test = lambda: self.library.find_exact(performer=['']) - self.assertRaises(LookupError, test) + with self.assertRaises(LookupError): + self.library.find_exact(performer=['']) - test = lambda: self.library.find_exact(album=['']) - self.assertRaises(LookupError, test) + with self.assertRaises(LookupError): + self.library.find_exact(album=['']) - test = lambda: self.library.find_exact(track_no=['']) - self.assertRaises(LookupError, test) + with self.assertRaises(LookupError): + self.library.find_exact(track_no=['']) - test = lambda: self.library.find_exact(genre=['']) - self.assertRaises(LookupError, test) + with self.assertRaises(LookupError): + self.library.find_exact(genre=['']) - test = lambda: self.library.find_exact(date=['']) - self.assertRaises(LookupError, test) + with self.assertRaises(LookupError): + self.library.find_exact(date=['']) - test = lambda: self.library.find_exact(comment=['']) - self.assertRaises(LookupError, test) + with self.assertRaises(LookupError): + self.library.find_exact(comment=['']) - test = lambda: self.library.find_exact(any=['']) - self.assertRaises(LookupError, test) + with self.assertRaises(LookupError): + self.library.find_exact(any=['']) def test_search_no_hits(self): result = self.library.search(track_name=['unknown track']) @@ -526,39 +544,78 @@ class LocalLibraryProviderTest(unittest.TestCase): self.assertEqual(list(result[0].tracks), self.tracks[:1]) def test_search_wrong_type(self): - test = lambda: self.library.search(wrong=['test']) - self.assertRaises(LookupError, test) + with self.assertRaises(LookupError): + self.library.search(wrong=['test']) def test_search_with_empty_query(self): - test = lambda: self.library.search(artist=['']) - self.assertRaises(LookupError, test) + with self.assertRaises(LookupError): + self.library.search(artist=['']) - test = lambda: self.library.search(albumartist=['']) - self.assertRaises(LookupError, test) + with self.assertRaises(LookupError): + self.library.search(albumartist=['']) - test = lambda: self.library.search(composer=['']) - self.assertRaises(LookupError, test) + with self.assertRaises(LookupError): + self.library.search(composer=['']) - test = lambda: self.library.search(performer=['']) - self.assertRaises(LookupError, test) + with self.assertRaises(LookupError): + self.library.search(performer=['']) - test = lambda: self.library.search(track_name=['']) - self.assertRaises(LookupError, test) + with self.assertRaises(LookupError): + self.library.search(track_name=['']) - test = lambda: self.library.search(album=['']) - self.assertRaises(LookupError, test) + with self.assertRaises(LookupError): + self.library.search(album=['']) - test = lambda: self.library.search(genre=['']) - self.assertRaises(LookupError, test) + with self.assertRaises(LookupError): + self.library.search(genre=['']) - test = lambda: self.library.search(date=['']) - self.assertRaises(LookupError, test) + with self.assertRaises(LookupError): + self.library.search(date=['']) - test = lambda: self.library.search(comment=['']) - self.assertRaises(LookupError, test) + with self.assertRaises(LookupError): + self.library.search(comment=['']) - test = lambda: self.library.search(uri=['']) - self.assertRaises(LookupError, test) + with self.assertRaises(LookupError): + self.library.search(uri=['']) - test = lambda: self.library.search(any=['']) - self.assertRaises(LookupError, test) + with self.assertRaises(LookupError): + self.library.search(any=['']) + + def test_default_get_images_impl_no_images(self): + result = self.library.get_images([track.uri for track in self.tracks]) + self.assertEqual(result, {track.uri: tuple() for track in self.tracks}) + + @mock.patch.object(json.JsonLibrary, 'lookup') + def test_default_get_images_impl_album_images(self, mock_lookup): + library = actor.LocalBackend(config=self.config, audio=None).library + + image = Image(uri='imageuri') + album = Album(images=[image.uri]) + track = Track(uri='trackuri', album=album) + mock_lookup.return_value = [track] + + result = library.get_images([track.uri]) + self.assertEqual(result, {track.uri: [image]}) + + @mock.patch.object(json.JsonLibrary, 'lookup') + def test_default_get_images_impl_single_track(self, mock_lookup): + library = actor.LocalBackend(config=self.config, audio=None).library + + image = Image(uri='imageuri') + album = Album(images=[image.uri]) + track = Track(uri='trackuri', album=album) + mock_lookup.return_value = track + + result = library.get_images([track.uri]) + self.assertEqual(result, {track.uri: [image]}) + + @mock.patch.object(json.JsonLibrary, 'get_images') + def test_local_library_get_images(self, mock_get_images): + library = actor.LocalBackend(config=self.config, audio=None).library + + image = Image(uri='imageuri') + track = Track(uri='trackuri') + mock_get_images.return_value = {track.uri: [image]} + + result = library.get_images([track.uri]) + self.assertEqual(result, {track.uri: [image]}) diff --git a/tests/local/test_playback.py b/tests/local/test_playback.py index 89015739..6ea82f2d 100644 --- a/tests/local/test_playback.py +++ b/tests/local/test_playback.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import time import unittest @@ -7,12 +7,12 @@ import mock import pykka -from mopidy import audio, core +from mopidy import core from mopidy.core import PlaybackState from mopidy.local import actor from mopidy.models import Track -from tests import path_to_data_dir +from tests import dummy_audio, path_to_data_dir from tests.local import generate_song, populate_tracklist @@ -39,8 +39,11 @@ class LocalPlaybackProviderTest(unittest.TestCase): track = Track(uri=uri, length=4464) self.tracklist.add([track]) - def setUp(self): - self.audio = audio.DummyAudio.start().proxy() + def trigger_end_of_track(self): + self.playback._on_end_of_track() + + def setUp(self): # noqa: N802 + self.audio = dummy_audio.create_proxy() self.backend = actor.LocalBackend.start( config=self.config, audio=self.audio).proxy() self.core = core.Core(backends=[self.backend]) @@ -52,7 +55,7 @@ class LocalPlaybackProviderTest(unittest.TestCase): assert self.tracks[0].length >= 2000, \ 'First song needs to be at least 2000 miliseconds' - def tearDown(self): + def tearDown(self): # noqa: N802 pykka.ActorRegistry.stop_all() def test_uri_scheme(self): @@ -154,7 +157,8 @@ class LocalPlaybackProviderTest(unittest.TestCase): @populate_tracklist def test_play_skips_to_next_track_on_failure(self): # If backend's play() returns False, it is a failure. - self.backend.playback.play = lambda track: track != self.tracks[0] + return_values = [True, False] + self.backend.playback.play = lambda: return_values.pop() self.playback.play() self.assertNotEqual(self.playback.current_track, self.tracks[0]) self.assertEqual(self.playback.current_track, self.tracks[1]) @@ -162,7 +166,7 @@ class LocalPlaybackProviderTest(unittest.TestCase): @populate_tracklist def test_current_track_after_completed_playlist(self): self.playback.play(self.tracklist.tl_tracks[-1]) - self.playback.on_end_of_track() + self.trigger_end_of_track() self.assertEqual(self.playback.state, PlaybackState.STOPPED) self.assertEqual(self.playback.current_track, None) @@ -214,7 +218,8 @@ class LocalPlaybackProviderTest(unittest.TestCase): @populate_tracklist def test_previous_skips_to_previous_track_on_failure(self): # If backend's play() returns False, it is a failure. - self.backend.playback.play = lambda track: track != self.tracks[1] + return_values = [True, False, True] + self.backend.playback.play = lambda: return_values.pop() self.playback.play(self.tracklist.tl_tracks[2]) self.assertEqual(self.playback.current_track, self.tracks[2]) self.playback.previous() @@ -281,7 +286,8 @@ class LocalPlaybackProviderTest(unittest.TestCase): @populate_tracklist def test_next_skips_to_next_track_on_failure(self): # If backend's play() returns False, it is a failure. - self.backend.playback.play = lambda track: track != self.tracks[1] + return_values = [True, False, True] + self.backend.playback.play = lambda: return_values.pop() self.playback.play() self.assertEqual(self.playback.current_track, self.tracks[0]) self.playback.next() @@ -403,7 +409,7 @@ class LocalPlaybackProviderTest(unittest.TestCase): old_position = self.tracklist.index(tl_track) old_uri = tl_track.track.uri - self.playback.on_end_of_track() + self.trigger_end_of_track() tl_track = self.playback.current_tl_track self.assertEqual( @@ -413,11 +419,11 @@ class LocalPlaybackProviderTest(unittest.TestCase): @populate_tracklist def test_end_of_track_return_value(self): self.playback.play() - self.assertEqual(self.playback.on_end_of_track(), None) + self.assertEqual(self.trigger_end_of_track(), None) @populate_tracklist def test_end_of_track_does_not_trigger_playback(self): - self.playback.on_end_of_track() + self.trigger_end_of_track() self.assertEqual(self.playback.state, PlaybackState.STOPPED) @populate_tracklist @@ -430,7 +436,7 @@ class LocalPlaybackProviderTest(unittest.TestCase): tl_track = self.playback.current_tl_track self.assertEqual(self.tracklist.index(tl_track), i) - self.playback.on_end_of_track() + self.trigger_end_of_track() self.assertEqual(self.playback.state, PlaybackState.STOPPED) @@ -439,7 +445,7 @@ class LocalPlaybackProviderTest(unittest.TestCase): self.playback.play() for _ in self.tracks: - self.playback.on_end_of_track() + self.trigger_end_of_track() self.assertEqual(self.playback.current_track, None) self.assertEqual(self.playback.state, PlaybackState.STOPPED) @@ -449,16 +455,17 @@ class LocalPlaybackProviderTest(unittest.TestCase): self.assertEqual(self.playback.current_track, self.tracks[0]) def test_end_of_track_for_empty_playlist(self): - self.playback.on_end_of_track() + self.trigger_end_of_track() self.assertEqual(self.playback.state, PlaybackState.STOPPED) @populate_tracklist def test_end_of_track_skips_to_next_track_on_failure(self): # If backend's play() returns False, it is a failure. - self.backend.playback.play = lambda track: track != self.tracks[1] + return_values = [True, False, True] + self.backend.playback.play = lambda: return_values.pop() self.playback.play() self.assertEqual(self.playback.current_track, self.tracks[0]) - self.playback.on_end_of_track() + self.trigger_end_of_track() self.assertNotEqual(self.playback.current_track, self.tracks[1]) self.assertEqual(self.playback.current_track, self.tracks[2]) @@ -478,7 +485,7 @@ class LocalPlaybackProviderTest(unittest.TestCase): @populate_tracklist def test_end_of_track_track_after_previous(self): self.playback.play() - self.playback.on_end_of_track() + self.trigger_end_of_track() self.playback.previous() tl_track = self.playback.current_tl_track self.assertEqual( @@ -492,7 +499,7 @@ class LocalPlaybackProviderTest(unittest.TestCase): def test_end_of_track_track_at_end_of_playlist(self): self.playback.play() for _ in self.tracklist.tl_tracks[1:]: - self.playback.on_end_of_track() + self.trigger_end_of_track() tl_track = self.playback.current_tl_track self.assertEqual(self.tracklist.next_track(tl_track), None) @@ -501,7 +508,7 @@ class LocalPlaybackProviderTest(unittest.TestCase): self.tracklist.repeat = True self.playback.play() for _ in self.tracks[1:]: - self.playback.on_end_of_track() + self.trigger_end_of_track() tl_track = self.playback.current_tl_track self.assertEqual( self.tracklist.next_track(tl_track), self.tl_tracks[0]) @@ -520,7 +527,7 @@ class LocalPlaybackProviderTest(unittest.TestCase): def test_end_of_track_with_consume(self): self.tracklist.consume = True self.playback.play() - self.playback.on_end_of_track() + self.trigger_end_of_track() self.assertNotIn(self.tracks[0], self.tracklist.tracks) @populate_tracklist @@ -531,7 +538,7 @@ class LocalPlaybackProviderTest(unittest.TestCase): self.tracklist.random = True self.playback.play() self.assertEqual(self.playback.current_track, self.tracks[-1]) - self.playback.on_end_of_track() + self.trigger_end_of_track() self.assertEqual(self.playback.current_track, self.tracks[-2]) @populate_tracklist @@ -650,19 +657,19 @@ class LocalPlaybackProviderTest(unittest.TestCase): @populate_tracklist def test_tracklist_position_at_end_of_playlist(self): self.playback.play(self.tracklist.tl_tracks[-1]) - self.playback.on_end_of_track() + self.trigger_end_of_track() tl_track = self.playback.current_tl_track self.assertEqual(self.tracklist.index(tl_track), None) def test_on_tracklist_change_gets_called(self): - callback = self.playback.on_tracklist_change + callback = self.playback._on_tracklist_change def wrapper(): wrapper.called = True return callback() wrapper.called = False - self.playback.on_tracklist_change = wrapper + self.playback._on_tracklist_change = wrapper self.tracklist.add([Track()]) self.assert_(wrapper.called) @@ -798,6 +805,7 @@ class LocalPlaybackProviderTest(unittest.TestCase): self.playback.pause() result = self.playback.seek(self.tracks[0].length - 1000) self.assert_(result, 'Seek return value was %s' % result) + self.assertEqual(self.playback.state, PlaybackState.PAUSED) @populate_tracklist def test_seek_when_paused_updates_position(self): @@ -808,13 +816,6 @@ class LocalPlaybackProviderTest(unittest.TestCase): position = self.playback.time_position self.assertGreaterEqual(position, length - 1010) - @populate_tracklist - def test_seek_when_paused_triggers_play(self): - self.playback.play() - self.playback.pause() - self.playback.seek(0) - self.assertEqual(self.playback.state, PlaybackState.PLAYING) - @unittest.SkipTest @populate_tracklist def test_seek_beyond_end_of_song(self): @@ -919,7 +920,7 @@ class LocalPlaybackProviderTest(unittest.TestCase): self.tracklist.consume = True self.playback.play() for _ in range(len(self.tracklist.tracks)): - self.playback.on_end_of_track() + self.trigger_end_of_track() self.assertEqual(len(self.tracklist.tracks), 0) @populate_tracklist @@ -946,7 +947,7 @@ class LocalPlaybackProviderTest(unittest.TestCase): @populate_tracklist def test_end_of_song_starts_next_track(self): self.playback.play() - self.playback.on_end_of_track() + self.trigger_end_of_track() self.assertEqual(self.playback.current_track, self.tracks[1]) @populate_tracklist @@ -955,7 +956,7 @@ class LocalPlaybackProviderTest(unittest.TestCase): self.tracklist.repeat = True self.playback.play() self.assertEqual(self.playback.current_track, self.tracks[0]) - self.playback.on_end_of_track() + self.trigger_end_of_track() self.assertEqual(self.playback.current_track, self.tracks[0]) @populate_tracklist @@ -965,7 +966,7 @@ class LocalPlaybackProviderTest(unittest.TestCase): self.tracklist.random = True self.playback.play() current_track = self.playback.current_track - self.playback.on_end_of_track() + self.trigger_end_of_track() self.assertEqual(self.playback.current_track, current_track) @populate_tracklist @@ -973,7 +974,7 @@ class LocalPlaybackProviderTest(unittest.TestCase): self.tracklist.single = True self.playback.play() self.assertEqual(self.playback.current_track, self.tracks[0]) - self.playback.on_end_of_track() + self.trigger_end_of_track() self.assertEqual(self.playback.current_track, None) self.assertEqual(self.playback.state, PlaybackState.STOPPED) @@ -982,14 +983,14 @@ class LocalPlaybackProviderTest(unittest.TestCase): self.tracklist.single = True self.tracklist.random = True self.playback.play() - self.playback.on_end_of_track() + self.trigger_end_of_track() self.assertEqual(self.playback.current_track, None) self.assertEqual(self.playback.state, PlaybackState.STOPPED) @populate_tracklist def test_end_of_playlist_stops(self): self.playback.play(self.tracklist.tl_tracks[-1]) - self.playback.on_end_of_track() + self.trigger_end_of_track() self.assertEqual(self.playback.state, PlaybackState.STOPPED) def test_repeat_off_by_default(self): @@ -1015,7 +1016,7 @@ class LocalPlaybackProviderTest(unittest.TestCase): self.tracklist.random = True self.playback.play() for _ in self.tracks[1:]: - self.playback.on_end_of_track() + self.trigger_end_of_track() tl_track = self.playback.current_tl_track self.assertEqual(self.tracklist.eot_track(tl_track), None) @@ -1036,7 +1037,7 @@ class LocalPlaybackProviderTest(unittest.TestCase): self.tracklist.random = True self.playback.play() for _ in self.tracks: - self.playback.on_end_of_track() + self.trigger_end_of_track() tl_track = self.playback.current_tl_track self.assertNotEqual(self.tracklist.eot_track(tl_track), None) self.assertEqual(self.playback.state, PlaybackState.STOPPED) @@ -1081,5 +1082,5 @@ class LocalPlaybackProviderTest(unittest.TestCase): @populate_tracklist def test_playing_track_that_isnt_in_playlist(self): - test = lambda: self.playback.play((17, Track())) - self.assertRaises(AssertionError, test) + with self.assertRaises(AssertionError): + self.playback.play((17, Track())) diff --git a/tests/local/test_playlists.py b/tests/local/test_playlists.py deleted file mode 100644 index f054ffc9..00000000 --- a/tests/local/test_playlists.py +++ /dev/null @@ -1,231 +0,0 @@ -from __future__ import unicode_literals - -import os -import shutil -import tempfile -import unittest - -import pykka - -from mopidy import audio, core -from mopidy.local import actor -from mopidy.models import Playlist, Track - -from tests import path_to_data_dir -from tests.local import generate_song - - -class LocalPlaylistsProviderTest(unittest.TestCase): - backend_class = actor.LocalBackend - config = { - 'local': { - 'media_dir': path_to_data_dir(''), - 'data_dir': path_to_data_dir(''), - 'library': 'json', - } - } - - def setUp(self): - self.config['local']['playlists_dir'] = tempfile.mkdtemp() - self.playlists_dir = self.config['local']['playlists_dir'] - - self.audio = audio.DummyAudio.start().proxy() - self.backend = actor.LocalBackend.start( - config=self.config, audio=self.audio).proxy() - self.core = core.Core(backends=[self.backend]) - - def tearDown(self): - pykka.ActorRegistry.stop_all() - - if os.path.exists(self.playlists_dir): - shutil.rmtree(self.playlists_dir) - - def test_created_playlist_is_persisted(self): - path = os.path.join(self.playlists_dir, 'test.m3u') - self.assertFalse(os.path.exists(path)) - - self.core.playlists.create('test') - self.assertTrue(os.path.exists(path)) - - def test_create_slugifies_playlist_name(self): - path = os.path.join(self.playlists_dir, 'test-foo-bar.m3u') - self.assertFalse(os.path.exists(path)) - - playlist = self.core.playlists.create('test FOO baR') - self.assertEqual('test-foo-bar', playlist.name) - self.assertTrue(os.path.exists(path)) - - def test_create_slugifies_names_which_tries_to_change_directory(self): - path = os.path.join(self.playlists_dir, 'test-foo-bar.m3u') - self.assertFalse(os.path.exists(path)) - - playlist = self.core.playlists.create('../../test FOO baR') - self.assertEqual('test-foo-bar', playlist.name) - self.assertTrue(os.path.exists(path)) - - def test_saved_playlist_is_persisted(self): - path1 = os.path.join(self.playlists_dir, 'test1.m3u') - path2 = os.path.join(self.playlists_dir, 'test2-foo-bar.m3u') - - playlist = self.core.playlists.create('test1') - - self.assertTrue(os.path.exists(path1)) - self.assertFalse(os.path.exists(path2)) - - playlist = playlist.copy(name='test2 FOO baR') - playlist = self.core.playlists.save(playlist) - - self.assertEqual('test2-foo-bar', playlist.name) - self.assertFalse(os.path.exists(path1)) - self.assertTrue(os.path.exists(path2)) - - def test_deleted_playlist_is_removed(self): - path = os.path.join(self.playlists_dir, 'test.m3u') - self.assertFalse(os.path.exists(path)) - - playlist = self.core.playlists.create('test') - self.assertTrue(os.path.exists(path)) - - self.core.playlists.delete(playlist.uri) - self.assertFalse(os.path.exists(path)) - - def test_playlist_contents_is_written_to_disk(self): - track = Track(uri=generate_song(1)) - playlist = self.core.playlists.create('test') - playlist_path = os.path.join(self.playlists_dir, 'test.m3u') - playlist = playlist.copy(tracks=[track]) - playlist = self.core.playlists.save(playlist) - - with open(playlist_path) as playlist_file: - contents = playlist_file.read() - - self.assertEqual(track.uri, contents.strip()) - - def test_extended_playlist_contents_is_written_to_disk(self): - track = Track(uri=generate_song(1), name='Test', length=60000) - playlist = self.core.playlists.create('test') - playlist_path = os.path.join(self.playlists_dir, 'test.m3u') - playlist = playlist.copy(tracks=[track]) - playlist = self.core.playlists.save(playlist) - - with open(playlist_path) as playlist_file: - contents = playlist_file.read().splitlines() - - self.assertEqual(contents, ['#EXTM3U', '#EXTINF:60,Test', track.uri]) - - def test_playlists_are_loaded_at_startup(self): - track = Track(uri='local:track:path2') - playlist = self.core.playlists.create('test') - playlist = playlist.copy(tracks=[track]) - playlist = self.core.playlists.save(playlist) - - backend = self.backend_class(config=self.config, audio=self.audio) - - self.assert_(backend.playlists.playlists) - self.assertEqual( - 'local:playlist:test', backend.playlists.playlists[0].uri) - self.assertEqual( - playlist.name, backend.playlists.playlists[0].name) - self.assertEqual( - track.uri, backend.playlists.playlists[0].tracks[0].uri) - - @unittest.SkipTest - def test_santitising_of_playlist_filenames(self): - pass - - @unittest.SkipTest - def test_playlist_dir_is_created(self): - pass - - def test_create_returns_playlist_with_name_set(self): - playlist = self.core.playlists.create('test') - self.assertEqual(playlist.name, 'test') - - def test_create_returns_playlist_with_uri_set(self): - playlist = self.core.playlists.create('test') - self.assert_(playlist.uri) - - def test_create_adds_playlist_to_playlists_collection(self): - playlist = self.core.playlists.create('test') - self.assert_(self.core.playlists.playlists) - self.assertIn(playlist, self.core.playlists.playlists) - - def test_playlists_empty_to_start_with(self): - self.assert_(not self.core.playlists.playlists) - - def test_delete_non_existant_playlist(self): - self.core.playlists.delete('file:///unknown/playlist') - - def test_delete_playlist_removes_it_from_the_collection(self): - playlist = self.core.playlists.create('test') - self.assertIn(playlist, self.core.playlists.playlists) - - self.core.playlists.delete(playlist.uri) - - self.assertNotIn(playlist, self.core.playlists.playlists) - - def test_filter_without_criteria(self): - self.assertEqual( - self.core.playlists.playlists, self.core.playlists.filter()) - - def test_filter_with_wrong_criteria(self): - self.assertEqual([], self.core.playlists.filter(name='foo')) - - def test_filter_with_right_criteria(self): - playlist = self.core.playlists.create('test') - playlists = self.core.playlists.filter(name='test') - self.assertEqual([playlist], playlists) - - def test_filter_by_name_returns_single_match(self): - playlist = Playlist(name='b') - self.backend.playlists.playlists = [Playlist(name='a'), playlist] - self.assertEqual([playlist], self.core.playlists.filter(name='b')) - - def test_filter_by_name_returns_multiple_matches(self): - playlist = Playlist(name='b') - self.backend.playlists.playlists = [ - playlist, Playlist(name='a'), Playlist(name='b')] - playlists = self.core.playlists.filter(name='b') - self.assertIn(playlist, playlists) - self.assertEqual(2, len(playlists)) - - def test_filter_by_name_returns_no_matches(self): - self.backend.playlists.playlists = [ - Playlist(name='a'), Playlist(name='b')] - self.assertEqual([], self.core.playlists.filter(name='c')) - - def test_lookup_finds_playlist_by_uri(self): - original_playlist = self.core.playlists.create('test') - - looked_up_playlist = self.core.playlists.lookup(original_playlist.uri) - - self.assertEqual(original_playlist, looked_up_playlist) - - @unittest.SkipTest - def test_refresh(self): - pass - - def test_save_replaces_existing_playlist_with_updated_playlist(self): - playlist1 = self.core.playlists.create('test1') - self.assertIn(playlist1, self.core.playlists.playlists) - - playlist2 = playlist1.copy(name='test2') - playlist2 = self.core.playlists.save(playlist2) - self.assertNotIn(playlist1, self.core.playlists.playlists) - self.assertIn(playlist2, self.core.playlists.playlists) - - def test_playlist_with_unknown_track(self): - track = Track(uri='file:///dev/null') - playlist = self.core.playlists.create('test') - playlist = playlist.copy(tracks=[track]) - playlist = self.core.playlists.save(playlist) - - backend = self.backend_class(config=self.config, audio=self.audio) - - self.assert_(backend.playlists.playlists) - self.assertEqual( - 'local:playlist:test', backend.playlists.playlists[0].uri) - self.assertEqual( - playlist.name, backend.playlists.playlists[0].name) - self.assertEqual( - track.uri, backend.playlists.playlists[0].tracks[0].uri) diff --git a/tests/local/test_search.py b/tests/local/test_search.py new file mode 100644 index 00000000..2a704e48 --- /dev/null +++ b/tests/local/test_search.py @@ -0,0 +1,16 @@ +from __future__ import unicode_literals + +import unittest + +from mopidy.local import search +from mopidy.models import Album, Track + + +class LocalLibrarySearchTest(unittest.TestCase): + def test_find_exact_with_album_query(self): + expected_tracks = [Track(album=Album(name='foo'))] + tracks = [Track(), Track(album=Album(name='bar'))] + expected_tracks + + search_result = search.find_exact(tracks, {'album': ['foo']}) + + self.assertEqual(search_result.tracks, tuple(expected_tracks)) diff --git a/tests/local/test_tracklist.py b/tests/local/test_tracklist.py index af07a4e6..db5de58b 100644 --- a/tests/local/test_tracklist.py +++ b/tests/local/test_tracklist.py @@ -1,16 +1,16 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import random import unittest import pykka -from mopidy import audio, core +from mopidy import core from mopidy.core import PlaybackState from mopidy.local import actor from mopidy.models import Playlist, TlTrack, Track -from tests import path_to_data_dir +from tests import dummy_audio, path_to_data_dir from tests.local import generate_song, populate_tracklist @@ -26,8 +26,8 @@ class LocalTracklistProviderTest(unittest.TestCase): tracks = [ Track(uri=generate_song(i), length=4464) for i in range(1, 4)] - def setUp(self): - self.audio = audio.DummyAudio.start().proxy() + def setUp(self): # noqa: N802 + self.audio = dummy_audio.create_proxy() self.backend = actor.LocalBackend.start( config=self.config, audio=self.audio).proxy() self.core = core.Core(mixer=None, backends=[self.backend]) @@ -36,7 +36,7 @@ class LocalTracklistProviderTest(unittest.TestCase): assert len(self.tracks) == 3, 'Need three tracks to run tests.' - def tearDown(self): + def tearDown(self): # noqa: N802 pykka.ActorRegistry.stop_all() def test_length(self): @@ -198,25 +198,25 @@ class LocalTracklistProviderTest(unittest.TestCase): @populate_tracklist def test_moving_track_outside_of_playlist(self): tracks = len(self.controller.tracks) - test = lambda: self.controller.move(0, 0, tracks + 5) - self.assertRaises(AssertionError, test) + with self.assertRaises(AssertionError): + self.controller.move(0, 0, tracks + 5) @populate_tracklist def test_move_group_outside_of_playlist(self): tracks = len(self.controller.tracks) - test = lambda: self.controller.move(0, 2, tracks + 5) - self.assertRaises(AssertionError, test) + with self.assertRaises(AssertionError): + self.controller.move(0, 2, tracks + 5) @populate_tracklist def test_move_group_out_of_range(self): tracks = len(self.controller.tracks) - test = lambda: self.controller.move(tracks + 2, tracks + 3, 0) - self.assertRaises(AssertionError, test) + with self.assertRaises(AssertionError): + self.controller.move(tracks + 2, tracks + 3, 0) @populate_tracklist def test_move_group_invalid_group(self): - test = lambda: self.controller.move(2, 1, 0) - self.assertRaises(AssertionError, test) + with self.assertRaises(AssertionError): + self.controller.move(2, 1, 0) def test_tracks_attribute_is_immutable(self): tracks1 = self.controller.tracks @@ -275,14 +275,14 @@ class LocalTracklistProviderTest(unittest.TestCase): @populate_tracklist def test_shuffle_invalid_subset(self): - test = lambda: self.controller.shuffle(3, 1) - self.assertRaises(AssertionError, test) + with self.assertRaises(AssertionError): + self.controller.shuffle(3, 1) @populate_tracklist def test_shuffle_superset(self): tracks = len(self.controller.tracks) - test = lambda: self.controller.shuffle(1, tracks + 5) - self.assertRaises(AssertionError, test) + with self.assertRaises(AssertionError): + self.controller.shuffle(1, tracks + 5) @populate_tracklist def test_shuffle_open_subset(self): @@ -310,7 +310,7 @@ class LocalTracklistProviderTest(unittest.TestCase): def test_version_does_not_change_when_adding_nothing(self): version = self.controller.version self.controller.add([]) - self.assertEquals(version, self.controller.version) + self.assertEqual(version, self.controller.version) def test_version_increases_when_adding_something(self): version = self.controller.version diff --git a/tests/m3u/__init__.py b/tests/m3u/__init__.py new file mode 100644 index 00000000..702deac5 --- /dev/null +++ b/tests/m3u/__init__.py @@ -0,0 +1,5 @@ +from __future__ import absolute_import, unicode_literals + + +def generate_song(i): + return 'dummy:track:song%s' % i diff --git a/tests/m3u/test_playlists.py b/tests/m3u/test_playlists.py new file mode 100644 index 00000000..355aabf5 --- /dev/null +++ b/tests/m3u/test_playlists.py @@ -0,0 +1,294 @@ +from __future__ import absolute_import, unicode_literals + +import os +import shutil +import tempfile +import unittest + +import pykka + +from mopidy import core +from mopidy.m3u import actor +from mopidy.m3u.translator import playlist_uri_to_path +from mopidy.models import Playlist, Track + +from tests import dummy_audio, path_to_data_dir +from tests.m3u import generate_song + + +class M3UPlaylistsProviderTest(unittest.TestCase): + backend_class = actor.M3UBackend + config = { + 'm3u': { + 'playlists_dir': path_to_data_dir(''), + } + } + + def setUp(self): # noqa: N802 + self.config['m3u']['playlists_dir'] = tempfile.mkdtemp() + self.playlists_dir = self.config['m3u']['playlists_dir'] + + audio = dummy_audio.create_proxy() + backend = actor.M3UBackend.start( + config=self.config, audio=audio).proxy() + self.core = core.Core(backends=[backend]) + + def tearDown(self): # noqa: N802 + pykka.ActorRegistry.stop_all() + + if os.path.exists(self.playlists_dir): + shutil.rmtree(self.playlists_dir) + + def test_created_playlist_is_persisted(self): + uri = 'm3u:test.m3u' + path = playlist_uri_to_path(uri, self.playlists_dir) + self.assertFalse(os.path.exists(path)) + + playlist = self.core.playlists.create('test') + self.assertEqual('test', playlist.name) + self.assertEqual(uri, playlist.uri) + self.assertTrue(os.path.exists(path)) + + def test_create_sanitizes_playlist_name(self): + playlist = self.core.playlists.create(' ../../test FOO baR ') + self.assertEqual('..|..|test FOO baR', playlist.name) + path = playlist_uri_to_path(playlist.uri, self.playlists_dir) + self.assertEqual(self.playlists_dir, os.path.dirname(path)) + self.assertTrue(os.path.exists(path)) + + def test_saved_playlist_is_persisted(self): + uri1 = 'm3u:test1.m3u' + uri2 = 'm3u:test2.m3u' + + path1 = playlist_uri_to_path(uri1, self.playlists_dir) + path2 = playlist_uri_to_path(uri2, self.playlists_dir) + + playlist = self.core.playlists.create('test1') + self.assertEqual('test1', playlist.name) + self.assertEqual(uri1, playlist.uri) + self.assertTrue(os.path.exists(path1)) + self.assertFalse(os.path.exists(path2)) + + playlist = self.core.playlists.save(playlist.copy(name='test2')) + self.assertEqual('test2', playlist.name) + self.assertEqual(uri2, playlist.uri) + self.assertFalse(os.path.exists(path1)) + self.assertTrue(os.path.exists(path2)) + + def test_deleted_playlist_is_removed(self): + uri = 'm3u:test.m3u' + path = playlist_uri_to_path(uri, self.playlists_dir) + + self.assertFalse(os.path.exists(path)) + + playlist = self.core.playlists.create('test') + self.assertEqual('test', playlist.name) + self.assertEqual(uri, playlist.uri) + self.assertTrue(os.path.exists(path)) + + self.core.playlists.delete(playlist.uri) + self.assertFalse(os.path.exists(path)) + + def test_playlist_contents_is_written_to_disk(self): + track = Track(uri=generate_song(1)) + playlist = self.core.playlists.create('test') + playlist = self.core.playlists.save(playlist.copy(tracks=[track])) + path = playlist_uri_to_path(playlist.uri, self.playlists_dir) + + with open(path) as f: + contents = f.read() + + self.assertEqual(track.uri, contents.strip()) + + def test_extended_playlist_contents_is_written_to_disk(self): + track = Track(uri=generate_song(1), name='Test', length=60000) + playlist = self.core.playlists.create('test') + playlist = self.core.playlists.save(playlist.copy(tracks=[track])) + path = playlist_uri_to_path(playlist.uri, self.playlists_dir) + + with open(path) as f: + contents = f.read().splitlines() + + self.assertEqual(contents, ['#EXTM3U', '#EXTINF:60,Test', track.uri]) + + def test_playlists_are_loaded_at_startup(self): + track = Track(uri='dummy:track:path2') + playlist = self.core.playlists.create('test') + playlist = playlist.copy(tracks=[track]) + playlist = self.core.playlists.save(playlist) + + self.assertEqual(len(self.core.playlists.as_list()), 1) + result = self.core.playlists.lookup(playlist.uri) + self.assertEqual(playlist.uri, result.uri) + self.assertEqual(playlist.name, result.name) + self.assertEqual(track.uri, result.tracks[0].uri) + + @unittest.SkipTest + def test_santitising_of_playlist_filenames(self): + pass + + @unittest.SkipTest + def test_playlists_dir_is_created(self): + pass + + def test_create_returns_playlist_with_name_set(self): + playlist = self.core.playlists.create('test') + self.assertEqual(playlist.name, 'test') + + def test_create_returns_playlist_with_uri_set(self): + playlist = self.core.playlists.create('test') + self.assert_(playlist.uri) + + def test_create_adds_playlist_to_playlists_collection(self): + playlist = self.core.playlists.create('test') + self.assert_(self.core.playlists.playlists) + self.assertIn(playlist, self.core.playlists.playlists) + + def test_as_list_empty_to_start_with(self): + self.assertEqual(len(self.core.playlists.as_list()), 0) + + def test_delete_non_existant_playlist(self): + self.core.playlists.delete('m3u:unknown') + + def test_delete_playlist_removes_it_from_the_collection(self): + playlist = self.core.playlists.create('test') + self.assertEqual(playlist, self.core.playlists.lookup(playlist.uri)) + + self.core.playlists.delete(playlist.uri) + + self.assertIsNone(self.core.playlists.lookup(playlist.uri)) + + def test_delete_playlist_without_file(self): + playlist = self.core.playlists.create('test') + self.assertEqual(playlist, self.core.playlists.lookup(playlist.uri)) + + path = playlist_uri_to_path(playlist.uri, self.playlists_dir) + self.assertTrue(os.path.exists(path)) + + os.remove(path) + self.assertFalse(os.path.exists(path)) + + self.core.playlists.delete(playlist.uri) + self.assertIsNone(self.core.playlists.lookup(playlist.uri)) + + def test_filter_without_criteria(self): + self.assertEqual( + self.core.playlists.get_playlists(), self.core.playlists.filter()) + + def test_filter_with_wrong_criteria(self): + self.assertEqual([], self.core.playlists.filter(name='foo')) + + def test_filter_with_right_criteria(self): + playlist = self.core.playlists.create('test') + playlists = self.core.playlists.filter(name='test') + self.assertEqual([playlist], playlists) + + def test_filter_by_name_returns_single_match(self): + self.core.playlists.create('a') + playlist = self.core.playlists.create('b') + + self.assertEqual([playlist], self.core.playlists.filter(name='b')) + + def test_filter_by_name_returns_no_matches(self): + self.core.playlists.create('a') + self.core.playlists.create('b') + + self.assertEqual([], self.core.playlists.filter(name='c')) + + def test_lookup_finds_playlist_by_uri(self): + original_playlist = self.core.playlists.create('test') + + looked_up_playlist = self.core.playlists.lookup(original_playlist.uri) + + self.assertEqual(original_playlist, looked_up_playlist) + + def test_refresh(self): + playlist = self.core.playlists.create('test') + self.assertEqual(playlist, self.core.playlists.lookup(playlist.uri)) + + self.core.playlists.refresh() + + self.assertEqual(playlist, self.core.playlists.lookup(playlist.uri)) + + def test_save_replaces_existing_playlist_with_updated_playlist(self): + playlist1 = self.core.playlists.create('test1') + self.assertEqual(playlist1, self.core.playlists.lookup(playlist1.uri)) + + playlist2 = playlist1.copy(name='test2') + playlist2 = self.core.playlists.save(playlist2) + self.assertIsNone(self.core.playlists.lookup(playlist1.uri)) + self.assertEqual(playlist2, self.core.playlists.lookup(playlist2.uri)) + + def test_create_replaces_existing_playlist_with_updated_playlist(self): + track = Track(uri=generate_song(1)) + playlist1 = self.core.playlists.create('test') + playlist1 = self.core.playlists.save(playlist1.copy(tracks=[track])) + self.assertEqual(playlist1, self.core.playlists.lookup(playlist1.uri)) + + playlist2 = self.core.playlists.create('test') + self.assertEqual(playlist1.uri, playlist2.uri) + self.assertNotEqual( + playlist1, self.core.playlists.lookup(playlist1.uri)) + self.assertEqual(playlist2, self.core.playlists.lookup(playlist1.uri)) + + def test_save_playlist_with_new_uri(self): + uri = 'm3u:test.m3u' + + with self.assertRaises(AssertionError): + self.core.playlists.save(Playlist(uri=uri)) + + path = playlist_uri_to_path(uri, self.playlists_dir) + self.assertFalse(os.path.exists(path)) + + def test_playlist_with_unknown_track(self): + track = Track(uri='file:///dev/null') + playlist = self.core.playlists.create('test') + playlist = playlist.copy(tracks=[track]) + playlist = self.core.playlists.save(playlist) + + self.assertEqual(len(self.core.playlists.as_list()), 1) + result = self.core.playlists.lookup('m3u:test.m3u') + self.assertEqual('m3u:test.m3u', result.uri) + self.assertEqual(playlist.name, result.name) + self.assertEqual(track.uri, result.tracks[0].uri) + + def test_playlist_sort_order(self): + def check_order(playlists, names): + self.assertEqual(names, [playlist.name for playlist in playlists]) + + self.core.playlists.create('c') + self.core.playlists.create('a') + self.core.playlists.create('b') + + check_order(self.core.playlists.as_list(), ['a', 'b', 'c']) + + self.core.playlists.refresh() + + check_order(self.core.playlists.as_list(), ['a', 'b', 'c']) + + playlist = self.core.playlists.lookup('m3u:a.m3u') + playlist = playlist.copy(name='d') + playlist = self.core.playlists.save(playlist) + + check_order(self.core.playlists.as_list(), ['b', 'c', 'd']) + + self.core.playlists.delete('m3u:c.m3u') + + check_order(self.core.playlists.as_list(), ['b', 'd']) + + def test_get_items_returns_item_refs(self): + track = Track(uri='dummy:a', name='A', length=60000) + playlist = self.core.playlists.create('test') + playlist = self.core.playlists.save(playlist.copy(tracks=[track])) + + item_refs = self.core.playlists.get_items(playlist.uri) + + self.assertEqual(len(item_refs), 1) + self.assertEqual(item_refs[0].type, 'track') + self.assertEqual(item_refs[0].uri, 'dummy:a') + self.assertEqual(item_refs[0].name, 'A') + + def test_get_items_of_unknown_playlist_returns_none(self): + item_refs = self.core.playlists.get_items('dummy:unknown') + + self.assertIsNone(item_refs) diff --git a/tests/local/test_translator.py b/tests/m3u/test_translator.py similarity index 71% rename from tests/local/test_translator.py rename to tests/m3u/test_translator.py index b7ffd5cf..fc7fc958 100644 --- a/tests/local/test_translator.py +++ b/tests/m3u/test_translator.py @@ -1,14 +1,14 @@ # encoding: utf-8 -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import os import tempfile import unittest -from mopidy.local.translator import parse_m3u +from mopidy.m3u import translator from mopidy.models import Track -from mopidy.utils.path import path_to_uri +from mopidy.utils import path from tests import path_to_data_dir @@ -16,9 +16,9 @@ data_dir = path_to_data_dir('') song1_path = path_to_data_dir('song1.mp3') song2_path = path_to_data_dir('song2.mp3') encoded_path = path_to_data_dir('æøå.mp3') -song1_uri = path_to_uri(song1_path) -song2_uri = path_to_uri(song2_path) -encoded_uri = path_to_uri(encoded_path) +song1_uri = path.path_to_uri(song1_path) +song2_uri = path.path_to_uri(song2_path) +encoded_uri = path.path_to_uri(encoded_path) song1_track = Track(uri=song1_uri) song2_track = Track(uri=song2_uri) encoded_track = Track(uri=encoded_uri) @@ -30,23 +30,26 @@ encoded_ext_track = encoded_track.copy(name='æøå') # FIXME use mock instead of tempfile.NamedTemporaryFile class M3UToUriTest(unittest.TestCase): + def parse(self, name): + return translator.parse_m3u(name, data_dir) + def test_empty_file(self): - tracks = parse_m3u(path_to_data_dir('empty.m3u'), data_dir) + tracks = self.parse(path_to_data_dir('empty.m3u')) self.assertEqual([], tracks) def test_basic_file(self): - tracks = parse_m3u(path_to_data_dir('one.m3u'), data_dir) + tracks = self.parse(path_to_data_dir('one.m3u')) self.assertEqual([song1_track], tracks) def test_file_with_comment(self): - tracks = parse_m3u(path_to_data_dir('comment.m3u'), data_dir) + tracks = self.parse(path_to_data_dir('comment.m3u')) self.assertEqual([song1_track], tracks) def test_file_is_relative_to_correct_dir(self): with tempfile.NamedTemporaryFile(delete=False) as tmp: tmp.write('song1.mp3') try: - tracks = parse_m3u(tmp.name, data_dir) + tracks = self.parse(tmp.name) self.assertEqual([song1_track], tracks) finally: if os.path.exists(tmp.name): @@ -56,7 +59,7 @@ class M3UToUriTest(unittest.TestCase): with tempfile.NamedTemporaryFile(delete=False) as tmp: tmp.write(song1_path) try: - tracks = parse_m3u(tmp.name, data_dir) + tracks = self.parse(tmp.name) self.assertEqual([song1_track], tracks) finally: if os.path.exists(tmp.name): @@ -68,7 +71,7 @@ class M3UToUriTest(unittest.TestCase): tmp.write('# comment \n') tmp.write(song2_path) try: - tracks = parse_m3u(tmp.name, data_dir) + tracks = self.parse(tmp.name) self.assertEqual([song1_track, song2_track], tracks) finally: if os.path.exists(tmp.name): @@ -78,38 +81,38 @@ class M3UToUriTest(unittest.TestCase): with tempfile.NamedTemporaryFile(delete=False) as tmp: tmp.write(song1_uri) try: - tracks = parse_m3u(tmp.name, data_dir) + tracks = self.parse(tmp.name) self.assertEqual([song1_track], tracks) finally: if os.path.exists(tmp.name): os.remove(tmp.name) def test_encoding_is_latin1(self): - tracks = parse_m3u(path_to_data_dir('encoding.m3u'), data_dir) + tracks = self.parse(path_to_data_dir('encoding.m3u')) self.assertEqual([encoded_track], tracks) def test_open_missing_file(self): - tracks = parse_m3u(path_to_data_dir('non-existant.m3u'), data_dir) + tracks = self.parse(path_to_data_dir('non-existant.m3u')) self.assertEqual([], tracks) def test_empty_ext_file(self): - tracks = parse_m3u(path_to_data_dir('empty-ext.m3u'), data_dir) + tracks = self.parse(path_to_data_dir('empty-ext.m3u')) self.assertEqual([], tracks) def test_basic_ext_file(self): - tracks = parse_m3u(path_to_data_dir('one-ext.m3u'), data_dir) + tracks = self.parse(path_to_data_dir('one-ext.m3u')) self.assertEqual([song1_ext_track], tracks) def test_multi_ext_file(self): - tracks = parse_m3u(path_to_data_dir('two-ext.m3u'), data_dir) + tracks = self.parse(path_to_data_dir('two-ext.m3u')) self.assertEqual([song1_ext_track, song2_ext_track], tracks) def test_ext_file_with_comment(self): - tracks = parse_m3u(path_to_data_dir('comment-ext.m3u'), data_dir) + tracks = self.parse(path_to_data_dir('comment-ext.m3u')) self.assertEqual([song1_ext_track], tracks) def test_ext_encoding_is_latin1(self): - tracks = parse_m3u(path_to_data_dir('encoding-ext.m3u'), data_dir) + tracks = self.parse(path_to_data_dir('encoding-ext.m3u')) self.assertEqual([encoded_ext_track], tracks) diff --git a/tests/mpd/__init__.py b/tests/mpd/__init__.py index baffc488..01e6d4f4 100644 --- a/tests/mpd/__init__.py +++ b/tests/mpd/__init__.py @@ -1 +1 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals diff --git a/tests/mpd/protocol/__init__.py b/tests/mpd/protocol/__init__.py index f4776f4f..88e3567b 100644 --- a/tests/mpd/protocol/__init__.py +++ b/tests/mpd/protocol/__init__.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import unittest @@ -7,8 +7,9 @@ import mock import pykka from mopidy import core -from mopidy.backend import dummy -from mopidy.mpd import session +from mopidy.mpd import session, uri_mapper + +from tests import dummy_backend, dummy_mixer class MockConnection(mock.Mock): @@ -24,6 +25,8 @@ class MockConnection(mock.Mock): class BaseTestCase(unittest.TestCase): + enable_mixer = True + def get_config(self): return { 'mpd': { @@ -31,47 +34,54 @@ class BaseTestCase(unittest.TestCase): } } - def setUp(self): - self.backend = dummy.create_dummy_backend_proxy() - self.core = core.Core.start(backends=[self.backend]).proxy() + def setUp(self): # noqa: N802 + if self.enable_mixer: + self.mixer = dummy_mixer.create_proxy() + else: + self.mixer = None + self.backend = dummy_backend.create_proxy() + self.core = core.Core.start( + mixer=self.mixer, backends=[self.backend]).proxy() + self.uri_map = uri_mapper.MpdUriMapper(self.core) self.connection = MockConnection() self.session = session.MpdSession( - self.connection, config=self.get_config(), core=self.core) + self.connection, config=self.get_config(), core=self.core, + uri_map=self.uri_map) self.dispatcher = self.session.dispatcher self.context = self.dispatcher.context - def tearDown(self): + def tearDown(self): # noqa: N802 pykka.ActorRegistry.stop_all() - def sendRequest(self, request): + def send_request(self, request): self.connection.response = [] request = '%s\n' % request.encode('utf-8') self.session.on_receive({'received': request}) return self.connection.response - def assertNoResponse(self): + def assertNoResponse(self): # noqa: N802 self.assertEqual([], self.connection.response) - def assertInResponse(self, value): + def assertInResponse(self, value): # noqa: N802 self.assertIn( value, self.connection.response, 'Did not find %s in %s' % ( repr(value), repr(self.connection.response))) - def assertOnceInResponse(self, value): + def assertOnceInResponse(self, value): # noqa: N802 matched = len([r for r in self.connection.response if r == value]) self.assertEqual( 1, matched, 'Expected to find %s once in %s' % ( repr(value), repr(self.connection.response))) - def assertNotInResponse(self, value): + def assertNotInResponse(self, value): # noqa: N802 self.assertNotIn( value, self.connection.response, 'Found %s in %s' % ( repr(value), repr(self.connection.response))) - def assertEqualResponse(self, value): + def assertEqualResponse(self, value): # noqa: N802 self.assertEqual(1, len(self.connection.response)) self.assertEqual(value, self.connection.response[0]) diff --git a/tests/mpd/protocol/test_audio_output.py b/tests/mpd/protocol/test_audio_output.py index 643682ef..b42b4c56 100644 --- a/tests/mpd/protocol/test_audio_output.py +++ b/tests/mpd/protocol/test_audio_output.py @@ -1,40 +1,41 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals from tests.mpd import protocol class AudioOutputHandlerTest(protocol.BaseTestCase): - def test_enableoutput(self): - self.core.playback.mute = False - self.sendRequest('enableoutput "0"') + def test_enableoutput(self): + self.core.mixer.set_mute(False) + + self.send_request('enableoutput "0"') self.assertInResponse('OK') - self.assertEqual(self.core.playback.mute.get(), True) + self.assertEqual(self.core.mixer.get_mute().get(), True) def test_enableoutput_unknown_outputid(self): - self.sendRequest('enableoutput "7"') + self.send_request('enableoutput "7"') self.assertInResponse('ACK [50@0] {enableoutput} No such audio output') def test_disableoutput(self): - self.core.playback.mute = True + self.core.mixer.set_mute(True) - self.sendRequest('disableoutput "0"') + self.send_request('disableoutput "0"') self.assertInResponse('OK') - self.assertEqual(self.core.playback.mute.get(), False) + self.assertEqual(self.core.mixer.get_mute().get(), False) def test_disableoutput_unknown_outputid(self): - self.sendRequest('disableoutput "7"') + self.send_request('disableoutput "7"') self.assertInResponse( 'ACK [50@0] {disableoutput} No such audio output') def test_outputs_when_unmuted(self): - self.core.playback.mute = False + self.core.mixer.set_mute(False) - self.sendRequest('outputs') + self.send_request('outputs') self.assertInResponse('outputid: 0') self.assertInResponse('outputname: Mute') @@ -42,11 +43,105 @@ class AudioOutputHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_outputs_when_muted(self): - self.core.playback.mute = True + self.core.mixer.set_mute(True) - self.sendRequest('outputs') + self.send_request('outputs') self.assertInResponse('outputid: 0') self.assertInResponse('outputname: Mute') self.assertInResponse('outputenabled: 1') self.assertInResponse('OK') + + def test_outputs_toggleoutput(self): + self.core.mixer.set_mute(False) + + self.send_request('toggleoutput "0"') + self.send_request('outputs') + + self.assertInResponse('outputid: 0') + self.assertInResponse('outputname: Mute') + self.assertInResponse('outputenabled: 1') + self.assertInResponse('OK') + + self.send_request('toggleoutput "0"') + self.send_request('outputs') + + self.assertInResponse('outputid: 0') + self.assertInResponse('outputname: Mute') + self.assertInResponse('outputenabled: 0') + self.assertInResponse('OK') + + self.send_request('toggleoutput "0"') + self.send_request('outputs') + + self.assertInResponse('outputid: 0') + self.assertInResponse('outputname: Mute') + self.assertInResponse('outputenabled: 1') + self.assertInResponse('OK') + + def test_outputs_toggleoutput_unknown_outputid(self): + self.send_request('toggleoutput "7"') + + self.assertInResponse( + 'ACK [50@0] {toggleoutput} No such audio output') + + +class AudioOutputHandlerNoneMixerTest(protocol.BaseTestCase): + enable_mixer = False + + def test_enableoutput(self): + self.assertEqual(self.core.mixer.get_mute().get(), None) + + self.send_request('enableoutput "0"') + self.assertInResponse( + 'ACK [52@0] {enableoutput} problems enabling output') + + self.assertEqual(self.core.mixer.get_mute().get(), None) + + def test_disableoutput(self): + self.assertEqual(self.core.mixer.get_mute().get(), None) + + self.send_request('disableoutput "0"') + self.assertInResponse( + 'ACK [52@0] {disableoutput} problems disabling output') + + self.assertEqual(self.core.mixer.get_mute().get(), None) + + def test_outputs_when_unmuted(self): + self.core.mixer.set_mute(False) + + self.send_request('outputs') + + self.assertInResponse('outputid: 0') + self.assertInResponse('outputname: Mute') + self.assertInResponse('outputenabled: 0') + self.assertInResponse('OK') + + def test_outputs_when_muted(self): + self.core.mixer.set_mute(True) + + self.send_request('outputs') + + self.assertInResponse('outputid: 0') + self.assertInResponse('outputname: Mute') + self.assertInResponse('outputenabled: 0') + self.assertInResponse('OK') + + def test_outputs_toggleoutput(self): + self.core.mixer.set_mute(False) + + self.send_request('toggleoutput "0"') + self.send_request('outputs') + + self.assertInResponse('outputid: 0') + self.assertInResponse('outputname: Mute') + self.assertInResponse('outputenabled: 0') + self.assertInResponse('OK') + + self.send_request('toggleoutput "0"') + self.send_request('outputs') + + self.assertInResponse('outputid: 0') + self.assertInResponse('outputname: Mute') + self.assertInResponse('outputenabled: 0') + self.assertInResponse('OK') diff --git a/tests/mpd/protocol/test_authentication.py b/tests/mpd/protocol/test_authentication.py index 4937c04f..ac6e71da 100644 --- a/tests/mpd/protocol/test_authentication.py +++ b/tests/mpd/protocol/test_authentication.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals from tests.mpd import protocol @@ -10,53 +10,53 @@ class AuthenticationActiveTest(protocol.BaseTestCase): return config def test_authentication_with_valid_password_is_accepted(self): - self.sendRequest('password "topsecret"') + self.send_request('password "topsecret"') self.assertTrue(self.dispatcher.authenticated) self.assertInResponse('OK') def test_authentication_with_invalid_password_is_not_accepted(self): - self.sendRequest('password "secret"') + self.send_request('password "secret"') self.assertFalse(self.dispatcher.authenticated) self.assertEqualResponse('ACK [3@0] {password} incorrect password') def test_authentication_without_password_fails(self): - self.sendRequest('password') + self.send_request('password') self.assertFalse(self.dispatcher.authenticated) self.assertEqualResponse( 'ACK [2@0] {password} wrong number of arguments for "password"') def test_anything_when_not_authenticated_should_fail(self): - self.sendRequest('any request at all') + self.send_request('any request at all') self.assertFalse(self.dispatcher.authenticated) self.assertEqualResponse( u'ACK [4@0] {any} you don\'t have permission for "any"') def test_close_is_allowed_without_authentication(self): - self.sendRequest('close') + self.send_request('close') self.assertFalse(self.dispatcher.authenticated) def test_commands_is_allowed_without_authentication(self): - self.sendRequest('commands') + self.send_request('commands') self.assertFalse(self.dispatcher.authenticated) self.assertInResponse('OK') def test_notcommands_is_allowed_without_authentication(self): - self.sendRequest('notcommands') + self.send_request('notcommands') self.assertFalse(self.dispatcher.authenticated) self.assertInResponse('OK') def test_ping_is_allowed_without_authentication(self): - self.sendRequest('ping') + self.send_request('ping') self.assertFalse(self.dispatcher.authenticated) self.assertInResponse('OK') class AuthenticationInactiveTest(protocol.BaseTestCase): def test_authentication_with_anything_when_password_check_turned_off(self): - self.sendRequest('any request at all') + self.send_request('any request at all') self.assertTrue(self.dispatcher.authenticated) self.assertEqualResponse('ACK [5@0] {} unknown command "any"') def test_any_password_is_not_accepted_when_password_check_turned_off(self): - self.sendRequest('password "secret"') + self.send_request('password "secret"') self.assertEqualResponse('ACK [3@0] {password} incorrect password') diff --git a/tests/mpd/protocol/test_channels.py b/tests/mpd/protocol/test_channels.py index be3b96a8..c29b2b57 100644 --- a/tests/mpd/protocol/test_channels.py +++ b/tests/mpd/protocol/test_channels.py @@ -1,25 +1,25 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals from tests.mpd import protocol class ChannelsHandlerTest(protocol.BaseTestCase): def test_subscribe(self): - self.sendRequest('subscribe "topic"') + self.send_request('subscribe "topic"') self.assertEqualResponse('ACK [0@0] {subscribe} Not implemented') def test_unsubscribe(self): - self.sendRequest('unsubscribe "topic"') + self.send_request('unsubscribe "topic"') self.assertEqualResponse('ACK [0@0] {unsubscribe} Not implemented') def test_channels(self): - self.sendRequest('channels') + self.send_request('channels') self.assertEqualResponse('ACK [0@0] {channels} Not implemented') def test_readmessages(self): - self.sendRequest('readmessages') + self.send_request('readmessages') self.assertEqualResponse('ACK [0@0] {readmessages} Not implemented') def test_sendmessage(self): - self.sendRequest('sendmessage "topic" "a message"') + self.send_request('sendmessage "topic" "a message"') self.assertEqualResponse('ACK [0@0] {sendmessage} Not implemented') diff --git a/tests/mpd/protocol/test_command_list.py b/tests/mpd/protocol/test_command_list.py index 9d66bd5d..bd9a9e6c 100644 --- a/tests/mpd/protocol/test_command_list.py +++ b/tests/mpd/protocol/test_command_list.py @@ -1,59 +1,59 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals from tests.mpd import protocol class CommandListsTest(protocol.BaseTestCase): def test_command_list_begin(self): - response = self.sendRequest('command_list_begin') - self.assertEquals([], response) + response = self.send_request('command_list_begin') + self.assertEqual([], response) def test_command_list_end(self): - self.sendRequest('command_list_begin') - self.sendRequest('command_list_end') + self.send_request('command_list_begin') + self.send_request('command_list_end') self.assertInResponse('OK') def test_command_list_end_without_start_first_is_an_unknown_command(self): - self.sendRequest('command_list_end') + self.send_request('command_list_end') self.assertEqualResponse( 'ACK [5@0] {} unknown command "command_list_end"') def test_command_list_with_ping(self): - self.sendRequest('command_list_begin') + self.send_request('command_list_begin') self.assertTrue(self.dispatcher.command_list_receiving) self.assertFalse(self.dispatcher.command_list_ok) self.assertEqual([], self.dispatcher.command_list) - self.sendRequest('ping') + self.send_request('ping') self.assertIn('ping', self.dispatcher.command_list) - self.sendRequest('command_list_end') + self.send_request('command_list_end') self.assertInResponse('OK') self.assertFalse(self.dispatcher.command_list_receiving) self.assertFalse(self.dispatcher.command_list_ok) self.assertEqual([], self.dispatcher.command_list) def test_command_list_with_error_returns_ack_with_correct_index(self): - self.sendRequest('command_list_begin') - self.sendRequest('play') # Known command - self.sendRequest('paly') # Unknown command - self.sendRequest('command_list_end') + self.send_request('command_list_begin') + self.send_request('play') # Known command + self.send_request('paly') # Unknown command + self.send_request('command_list_end') self.assertEqualResponse('ACK [5@1] {} unknown command "paly"') def test_command_list_ok_begin(self): - response = self.sendRequest('command_list_ok_begin') - self.assertEquals([], response) + response = self.send_request('command_list_ok_begin') + self.assertEqual([], response) def test_command_list_ok_with_ping(self): - self.sendRequest('command_list_ok_begin') + self.send_request('command_list_ok_begin') self.assertTrue(self.dispatcher.command_list_receiving) self.assertTrue(self.dispatcher.command_list_ok) self.assertEqual([], self.dispatcher.command_list) - self.sendRequest('ping') + self.send_request('ping') self.assertIn('ping', self.dispatcher.command_list) - self.sendRequest('command_list_end') + self.send_request('command_list_end') self.assertInResponse('list_OK') self.assertInResponse('OK') self.assertFalse(self.dispatcher.command_list_receiving) diff --git a/tests/mpd/protocol/test_connection.py b/tests/mpd/protocol/test_connection.py index 34cce6a0..da25153d 100644 --- a/tests/mpd/protocol/test_connection.py +++ b/tests/mpd/protocol/test_connection.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals from mock import patch @@ -8,22 +8,22 @@ from tests.mpd import protocol class ConnectionHandlerTest(protocol.BaseTestCase): def test_close_closes_the_client_connection(self): with patch.object(self.session, 'close') as close_mock: - self.sendRequest('close') + self.send_request('close') close_mock.assertEqualResponsecalled_once_with() self.assertEqualResponse('OK') def test_empty_request(self): - self.sendRequest('') + self.send_request('') self.assertEqualResponse('ACK [5@0] {} No command given') - self.sendRequest(' ') + self.send_request(' ') self.assertEqualResponse('ACK [5@0] {} No command given') def test_kill(self): - self.sendRequest('kill') + self.send_request('kill') self.assertEqualResponse( 'ACK [4@0] {kill} you don\'t have permission for "kill"') def test_ping(self): - self.sendRequest('ping') + self.send_request('ping') self.assertEqualResponse('OK') diff --git a/tests/mpd/protocol/test_current_playlist.py b/tests/mpd/protocol/test_current_playlist.py index e9898dd9..d6fdce8e 100644 --- a/tests/mpd/protocol/test_current_playlist.py +++ b/tests/mpd/protocol/test_current_playlist.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals from mopidy.models import Ref, Track @@ -14,13 +14,13 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): [Track(), Track(), Track(), Track(), Track()]) self.assertEqual(len(self.core.tracklist.tracks.get()), 5) - self.sendRequest('add "dummy://foo"') + self.send_request('add "dummy://foo"') self.assertEqual(len(self.core.tracklist.tracks.get()), 6) self.assertEqual(self.core.tracklist.tracks.get()[5], needle) self.assertEqualResponse('OK') def test_add_with_uri_not_found_in_library_should_ack(self): - self.sendRequest('add "dummy://foo"') + self.send_request('add "dummy://foo"') self.assertEqualResponse( 'ACK [50@0] {add} directory or file not found') @@ -29,7 +29,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.backend.library.dummy_browse_result = { 'dummy:/': [Ref.track(uri='dummy:/a', name='a')]} - self.sendRequest('add ""') + self.send_request('add ""') self.assertEqual(len(self.core.tracklist.tracks.get()), 0) self.assertInResponse('OK') @@ -43,7 +43,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): Ref.directory(uri='dummy:/foo', name='foo')], 'dummy:/foo': [Ref.track(uri='dummy:/foo/b', name='b')]} - self.sendRequest('add "/dummy"') + self.send_request('add "/dummy"') self.assertEqual(self.core.tracklist.tracks.get(), tracks) self.assertInResponse('OK') @@ -52,7 +52,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.backend.library.dummy_browse_result = { 'dummy:/': [Ref.track(uri='dummy:/a', name='a')]} - self.sendRequest('add "/"') + self.send_request('add "/"') self.assertEqual(len(self.core.tracklist.tracks.get()), 0) self.assertInResponse('OK') @@ -64,7 +64,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): [Track(), Track(), Track(), Track(), Track()]) self.assertEqual(len(self.core.tracklist.tracks.get()), 5) - self.sendRequest('addid "dummy://foo"') + self.send_request('addid "dummy://foo"') self.assertEqual(len(self.core.tracklist.tracks.get()), 6) self.assertEqual(self.core.tracklist.tracks.get()[5], needle) self.assertInResponse( @@ -72,7 +72,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_addid_with_empty_uri_acks(self): - self.sendRequest('addid ""') + self.send_request('addid ""') self.assertEqualResponse('ACK [50@0] {addid} No such song') def test_addid_with_songpos(self): @@ -83,7 +83,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): [Track(), Track(), Track(), Track(), Track()]) self.assertEqual(len(self.core.tracklist.tracks.get()), 5) - self.sendRequest('addid "dummy://foo" "3"') + self.send_request('addid "dummy://foo" "3"') self.assertEqual(len(self.core.tracklist.tracks.get()), 6) self.assertEqual(self.core.tracklist.tracks.get()[3], needle) self.assertInResponse( @@ -98,11 +98,11 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): [Track(), Track(), Track(), Track(), Track()]) self.assertEqual(len(self.core.tracklist.tracks.get()), 5) - self.sendRequest('addid "dummy://foo" "6"') + self.send_request('addid "dummy://foo" "6"') self.assertEqualResponse('ACK [2@0] {addid} Bad song index') def test_addid_with_uri_not_found_in_library_should_ack(self): - self.sendRequest('addid "dummy://foo"') + self.send_request('addid "dummy://foo"') self.assertEqualResponse('ACK [50@0] {addid} No such song') def test_clear(self): @@ -110,7 +110,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): [Track(), Track(), Track(), Track(), Track()]) self.assertEqual(len(self.core.tracklist.tracks.get()), 5) - self.sendRequest('clear') + self.send_request('clear') self.assertEqual(len(self.core.tracklist.tracks.get()), 0) self.assertEqual(self.core.playback.current_track.get(), None) self.assertInResponse('OK') @@ -120,7 +120,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): [Track(), Track(), Track(), Track(), Track()]) self.assertEqual(len(self.core.tracklist.tracks.get()), 5) - self.sendRequest( + self.send_request( 'delete "%d"' % self.core.tracklist.tl_tracks.get()[2].tlid) self.assertEqual(len(self.core.tracklist.tracks.get()), 4) self.assertInResponse('OK') @@ -130,7 +130,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): [Track(), Track(), Track(), Track(), Track()]) self.assertEqual(len(self.core.tracklist.tracks.get()), 5) - self.sendRequest('delete "5"') + self.send_request('delete "5"') self.assertEqual(len(self.core.tracklist.tracks.get()), 5) self.assertEqualResponse('ACK [2@0] {delete} Bad song index') @@ -139,7 +139,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): [Track(), Track(), Track(), Track(), Track()]) self.assertEqual(len(self.core.tracklist.tracks.get()), 5) - self.sendRequest('delete "1:"') + self.send_request('delete "1:"') self.assertEqual(len(self.core.tracklist.tracks.get()), 1) self.assertInResponse('OK') @@ -148,7 +148,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): [Track(), Track(), Track(), Track(), Track()]) self.assertEqual(len(self.core.tracklist.tracks.get()), 5) - self.sendRequest('delete "1:3"') + self.send_request('delete "1:3"') self.assertEqual(len(self.core.tracklist.tracks.get()), 3) self.assertInResponse('OK') @@ -157,7 +157,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): [Track(), Track(), Track(), Track(), Track()]) self.assertEqual(len(self.core.tracklist.tracks.get()), 5) - self.sendRequest('delete "5:7"') + self.send_request('delete "5:7"') self.assertEqual(len(self.core.tracklist.tracks.get()), 5) self.assertEqualResponse('ACK [2@0] {delete} Bad song index') @@ -165,7 +165,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.core.tracklist.add([Track(), Track()]) self.assertEqual(len(self.core.tracklist.tracks.get()), 2) - self.sendRequest('deleteid "1"') + self.send_request('deleteid "1"') self.assertEqual(len(self.core.tracklist.tracks.get()), 1) self.assertInResponse('OK') @@ -173,7 +173,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.core.tracklist.add([Track(), Track()]) self.assertEqual(len(self.core.tracklist.tracks.get()), 2) - self.sendRequest('deleteid "12345"') + self.send_request('deleteid "12345"') self.assertEqual(len(self.core.tracklist.tracks.get()), 2) self.assertEqualResponse('ACK [50@0] {deleteid} No such song') @@ -183,7 +183,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): Track(name='d'), Track(name='e'), Track(name='f'), ]) - self.sendRequest('move "1" "0"') + self.send_request('move "1" "0"') tracks = self.core.tracklist.tracks.get() self.assertEqual(tracks[0].name, 'b') self.assertEqual(tracks[1].name, 'a') @@ -199,7 +199,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): Track(name='d'), Track(name='e'), Track(name='f'), ]) - self.sendRequest('move "2:" "0"') + self.send_request('move "2:" "0"') tracks = self.core.tracklist.tracks.get() self.assertEqual(tracks[0].name, 'c') self.assertEqual(tracks[1].name, 'd') @@ -215,7 +215,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): Track(name='d'), Track(name='e'), Track(name='f'), ]) - self.sendRequest('move "1:3" "0"') + self.send_request('move "1:3" "0"') tracks = self.core.tracklist.tracks.get() self.assertEqual(tracks[0].name, 'b') self.assertEqual(tracks[1].name, 'c') @@ -231,7 +231,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): Track(name='d'), Track(name='e'), Track(name='f'), ]) - self.sendRequest('moveid "4" "2"') + self.send_request('moveid "4" "2"') tracks = self.core.tracklist.tracks.get() self.assertEqual(tracks[0].name, 'a') self.assertEqual(tracks[1].name, 'b') @@ -242,31 +242,31 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_moveid_with_tlid_not_found_in_tracklist_should_ack(self): - self.sendRequest('moveid "9" "0"') + self.send_request('moveid "9" "0"') self.assertEqualResponse( 'ACK [50@0] {moveid} No such song') def test_playlist_returns_same_as_playlistinfo(self): - playlist_response = self.sendRequest('playlist') - playlistinfo_response = self.sendRequest('playlistinfo') + playlist_response = self.send_request('playlist') + playlistinfo_response = self.send_request('playlistinfo') self.assertEqual(playlist_response, playlistinfo_response) def test_playlistfind(self): - self.sendRequest('playlistfind "tag" "needle"') + self.send_request('playlistfind "tag" "needle"') self.assertEqualResponse('ACK [0@0] {playlistfind} Not implemented') def test_playlistfind_by_filename_not_in_tracklist(self): - self.sendRequest('playlistfind "filename" "file:///dev/null"') + self.send_request('playlistfind "filename" "file:///dev/null"') self.assertEqualResponse('OK') def test_playlistfind_by_filename_without_quotes(self): - self.sendRequest('playlistfind filename "file:///dev/null"') + self.send_request('playlistfind filename "file:///dev/null"') self.assertEqualResponse('OK') def test_playlistfind_by_filename_in_tracklist(self): self.core.tracklist.add([Track(uri='file:///exists')]) - self.sendRequest('playlistfind filename "file:///exists"') + self.send_request('playlistfind filename "file:///exists"') self.assertInResponse('file: file:///exists') self.assertInResponse('Id: 0') self.assertInResponse('Pos: 0') @@ -275,7 +275,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): def test_playlistid_without_songid(self): self.core.tracklist.add([Track(name='a'), Track(name='b')]) - self.sendRequest('playlistid') + self.send_request('playlistid') self.assertInResponse('Title: a') self.assertInResponse('Title: b') self.assertInResponse('OK') @@ -283,7 +283,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): def test_playlistid_with_songid(self): self.core.tracklist.add([Track(name='a'), Track(name='b')]) - self.sendRequest('playlistid "1"') + self.send_request('playlistid "1"') self.assertNotInResponse('Title: a') self.assertNotInResponse('Id: 0') self.assertInResponse('Title: b') @@ -293,7 +293,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): def test_playlistid_with_not_existing_songid_fails(self): self.core.tracklist.add([Track(name='a'), Track(name='b')]) - self.sendRequest('playlistid "25"') + self.send_request('playlistid "25"') self.assertEqualResponse('ACK [50@0] {playlistid} No such song') def test_playlistinfo_without_songpos_or_range(self): @@ -302,7 +302,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): Track(name='d'), Track(name='e'), Track(name='f'), ]) - self.sendRequest('playlistinfo') + self.send_request('playlistinfo') self.assertInResponse('Title: a') self.assertInResponse('Pos: 0') self.assertInResponse('Title: b') @@ -325,7 +325,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): Track(name='d'), Track(name='e'), Track(name='f'), ]) - self.sendRequest('playlistinfo "4"') + self.send_request('playlistinfo "4"') self.assertNotInResponse('Title: a') self.assertNotInResponse('Pos: 0') self.assertNotInResponse('Title: b') @@ -341,8 +341,8 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_playlistinfo_with_negative_songpos_same_as_playlistinfo(self): - response1 = self.sendRequest('playlistinfo "-1"') - response2 = self.sendRequest('playlistinfo') + response1 = self.send_request('playlistinfo "-1"') + response2 = self.send_request('playlistinfo') self.assertEqual(response1, response2) def test_playlistinfo_with_open_range(self): @@ -351,7 +351,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): Track(name='d'), Track(name='e'), Track(name='f'), ]) - self.sendRequest('playlistinfo "2:"') + self.send_request('playlistinfo "2:"') self.assertNotInResponse('Title: a') self.assertNotInResponse('Pos: 0') self.assertNotInResponse('Title: b') @@ -372,7 +372,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): Track(name='d'), Track(name='e'), Track(name='f'), ]) - self.sendRequest('playlistinfo "2:4"') + self.send_request('playlistinfo "2:4"') self.assertNotInResponse('Title: a') self.assertNotInResponse('Title: b') self.assertInResponse('Title: c') @@ -382,30 +382,30 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_playlistinfo_with_too_high_start_of_range_returns_arg_error(self): - self.sendRequest('playlistinfo "10:20"') + self.send_request('playlistinfo "10:20"') self.assertEqualResponse('ACK [2@0] {playlistinfo} Bad song index') def test_playlistinfo_with_too_high_end_of_range_returns_ok(self): - self.sendRequest('playlistinfo "0:20"') + self.send_request('playlistinfo "0:20"') self.assertInResponse('OK') def test_playlistinfo_with_zero_returns_ok(self): - self.sendRequest('playlistinfo "0"') + self.send_request('playlistinfo "0"') self.assertInResponse('OK') def test_playlistsearch(self): - self.sendRequest('playlistsearch "any" "needle"') + self.send_request('playlistsearch "any" "needle"') self.assertEqualResponse('ACK [0@0] {playlistsearch} Not implemented') def test_playlistsearch_without_quotes(self): - self.sendRequest('playlistsearch any "needle"') + self.send_request('playlistsearch any "needle"') self.assertEqualResponse('ACK [0@0] {playlistsearch} Not implemented') def test_plchanges_with_lower_version_returns_changes(self): self.core.tracklist.add( [Track(name='a'), Track(name='b'), Track(name='c')]) - self.sendRequest('plchanges "0"') + self.send_request('plchanges "0"') self.assertInResponse('Title: a') self.assertInResponse('Title: b') self.assertInResponse('Title: c') @@ -416,7 +416,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): [Track(name='a'), Track(name='b'), Track(name='c')]) self.assertEqual(self.core.tracklist.version.get(), 1) - self.sendRequest('plchanges "1"') + self.send_request('plchanges "1"') self.assertNotInResponse('Title: a') self.assertNotInResponse('Title: b') self.assertNotInResponse('Title: c') @@ -427,7 +427,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): [Track(name='a'), Track(name='b'), Track(name='c')]) self.assertEqual(self.core.tracklist.version.get(), 1) - self.sendRequest('plchanges "2"') + self.send_request('plchanges "2"') self.assertNotInResponse('Title: a') self.assertNotInResponse('Title: b') self.assertNotInResponse('Title: c') @@ -437,7 +437,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.core.tracklist.add( [Track(name='a'), Track(name='b'), Track(name='c')]) - self.sendRequest('plchanges "-1"') + self.send_request('plchanges "-1"') self.assertInResponse('Title: a') self.assertInResponse('Title: b') self.assertInResponse('Title: c') @@ -447,7 +447,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.core.tracklist.add( [Track(name='a'), Track(name='b'), Track(name='c')]) - self.sendRequest('plchanges 0') + self.send_request('plchanges 0') self.assertInResponse('Title: a') self.assertInResponse('Title: b') self.assertInResponse('Title: c') @@ -456,7 +456,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): def test_plchangesposid(self): self.core.tracklist.add([Track(), Track(), Track()]) - self.sendRequest('plchangesposid "0"') + self.send_request('plchangesposid "0"') tl_tracks = self.core.tracklist.tl_tracks.get() self.assertInResponse('cpos: 0') self.assertInResponse('Id: %d' % tl_tracks[0].tlid) @@ -473,7 +473,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): ]) version = self.core.tracklist.version.get() - self.sendRequest('shuffle') + self.send_request('shuffle') self.assertLess(version, self.core.tracklist.version.get()) self.assertInResponse('OK') @@ -484,7 +484,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): ]) version = self.core.tracklist.version.get() - self.sendRequest('shuffle "4:"') + self.send_request('shuffle "4:"') self.assertLess(version, self.core.tracklist.version.get()) tracks = self.core.tracklist.tracks.get() self.assertEqual(tracks[0].name, 'a') @@ -500,7 +500,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): ]) version = self.core.tracklist.version.get() - self.sendRequest('shuffle "1:3"') + self.send_request('shuffle "1:3"') self.assertLess(version, self.core.tracklist.version.get()) tracks = self.core.tracklist.tracks.get() self.assertEqual(tracks[0].name, 'a') @@ -515,7 +515,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): Track(name='d'), Track(name='e'), Track(name='f'), ]) - self.sendRequest('swap "1" "4"') + self.send_request('swap "1" "4"') tracks = self.core.tracklist.tracks.get() self.assertEqual(tracks[0].name, 'a') self.assertEqual(tracks[1].name, 'e') @@ -531,7 +531,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): Track(name='d'), Track(name='e'), Track(name='f'), ]) - self.sendRequest('swapid "1" "4"') + self.send_request('swapid "1" "4"') tracks = self.core.tracklist.tracks.get() self.assertEqual(tracks[0].name, 'a') self.assertEqual(tracks[1].name, 'e') @@ -543,12 +543,12 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): def test_swapid_with_first_id_unknown_should_ack(self): self.core.tracklist.add([Track()]) - self.sendRequest('swapid "0" "4"') + self.send_request('swapid "0" "4"') self.assertEqualResponse( 'ACK [50@0] {swapid} No such song') def test_swapid_with_second_id_unknown_should_ack(self): self.core.tracklist.add([Track()]) - self.sendRequest('swapid "4" "0"') + self.send_request('swapid "4" "0"') self.assertEqualResponse( 'ACK [50@0] {swapid} No such song') diff --git a/tests/mpd/protocol/test_idle.py b/tests/mpd/protocol/test_idle.py index cc937119..e3c6ad38 100644 --- a/tests/mpd/protocol/test_idle.py +++ b/tests/mpd/protocol/test_idle.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals from mock import patch @@ -8,19 +8,19 @@ from tests.mpd import protocol class IdleHandlerTest(protocol.BaseTestCase): - def idleEvent(self, subsystem): + def idle_event(self, subsystem): self.session.on_idle(subsystem) - def assertEqualEvents(self, events): + def assertEqualEvents(self, events): # noqa: N802 self.assertEqual(set(events), self.context.events) - def assertEqualSubscriptions(self, events): + def assertEqualSubscriptions(self, events): # noqa: N802 self.assertEqual(set(events), self.context.subscriptions) - def assertNoEvents(self): + def assertNoEvents(self): # noqa: N802 self.assertEqualEvents([]) - def assertNoSubscriptions(self): + def assertNoSubscriptions(self): # noqa: N802 self.assertEqualSubscriptions([]) def test_base_state(self): @@ -29,96 +29,118 @@ class IdleHandlerTest(protocol.BaseTestCase): self.assertNoResponse() def test_idle(self): - self.sendRequest('idle') + self.send_request('idle') self.assertEqualSubscriptions(SUBSYSTEMS) self.assertNoEvents() self.assertNoResponse() def test_idle_disables_timeout(self): - self.sendRequest('idle') + self.send_request('idle') self.connection.disable_timeout.assert_called_once_with() def test_noidle(self): - self.sendRequest('noidle') + self.send_request('noidle') self.assertNoSubscriptions() self.assertNoEvents() self.assertNoResponse() def test_idle_player(self): - self.sendRequest('idle player') + self.send_request('idle player') self.assertEqualSubscriptions(['player']) self.assertNoEvents() self.assertNoResponse() + def test_idle_output(self): + self.send_request('idle output') + self.assertEqualSubscriptions(['output']) + self.assertNoEvents() + self.assertNoResponse() + def test_idle_player_playlist(self): - self.sendRequest('idle player playlist') + self.send_request('idle player playlist') self.assertEqualSubscriptions(['player', 'playlist']) self.assertNoEvents() self.assertNoResponse() def test_idle_then_noidle(self): - self.sendRequest('idle') - self.sendRequest('noidle') + self.send_request('idle') + self.send_request('noidle') self.assertNoSubscriptions() self.assertNoEvents() self.assertOnceInResponse('OK') def test_idle_then_noidle_enables_timeout(self): - self.sendRequest('idle') - self.sendRequest('noidle') + self.send_request('idle') + self.send_request('noidle') self.connection.enable_timeout.assert_called_once_with() def test_idle_then_play(self): with patch.object(self.session, 'stop') as stop_mock: - self.sendRequest('idle') - self.sendRequest('play') + self.send_request('idle') + self.send_request('play') stop_mock.assert_called_once_with() def test_idle_then_idle(self): with patch.object(self.session, 'stop') as stop_mock: - self.sendRequest('idle') - self.sendRequest('idle') + self.send_request('idle') + self.send_request('idle') stop_mock.assert_called_once_with() def test_idle_player_then_play(self): with patch.object(self.session, 'stop') as stop_mock: - self.sendRequest('idle player') - self.sendRequest('play') + self.send_request('idle player') + self.send_request('play') stop_mock.assert_called_once_with() def test_idle_then_player(self): - self.sendRequest('idle') - self.idleEvent('player') + self.send_request('idle') + self.idle_event('player') self.assertNoSubscriptions() self.assertNoEvents() self.assertOnceInResponse('changed: player') self.assertOnceInResponse('OK') def test_idle_player_then_event_player(self): - self.sendRequest('idle player') - self.idleEvent('player') + self.send_request('idle player') + self.idle_event('player') self.assertNoSubscriptions() self.assertNoEvents() self.assertOnceInResponse('changed: player') self.assertOnceInResponse('OK') + def test_idle_then_output(self): + self.send_request('idle') + self.idle_event('output') + self.assertNoSubscriptions() + self.assertNoEvents() + self.assertOnceInResponse('changed: output') + self.assertOnceInResponse('OK') + + def test_idle_output_then_event_output(self): + self.send_request('idle output') + self.idle_event('output') + self.assertNoSubscriptions() + self.assertNoEvents() + self.assertOnceInResponse('changed: output') + self.assertOnceInResponse('OK') + def test_idle_player_then_noidle(self): - self.sendRequest('idle player') - self.sendRequest('noidle') + self.send_request('idle player') + self.send_request('noidle') self.assertNoSubscriptions() self.assertNoEvents() self.assertOnceInResponse('OK') def test_idle_player_playlist_then_noidle(self): - self.sendRequest('idle player playlist') - self.sendRequest('noidle') + self.send_request('idle player playlist') + self.send_request('noidle') self.assertNoEvents() self.assertNoSubscriptions() self.assertOnceInResponse('OK') def test_idle_player_playlist_then_player(self): - self.sendRequest('idle player playlist') - self.idleEvent('player') + self.send_request('idle player playlist') + self.idle_event('player') self.assertNoEvents() self.assertNoSubscriptions() self.assertOnceInResponse('changed: player') @@ -126,16 +148,16 @@ class IdleHandlerTest(protocol.BaseTestCase): self.assertOnceInResponse('OK') def test_idle_playlist_then_player(self): - self.sendRequest('idle playlist') - self.idleEvent('player') + self.send_request('idle playlist') + self.idle_event('player') self.assertEqualEvents(['player']) self.assertEqualSubscriptions(['playlist']) self.assertNoResponse() def test_idle_playlist_then_player_then_playlist(self): - self.sendRequest('idle playlist') - self.idleEvent('player') - self.idleEvent('playlist') + self.send_request('idle playlist') + self.idle_event('player') + self.idle_event('playlist') self.assertNoEvents() self.assertNoSubscriptions() self.assertNotInResponse('changed: player') @@ -143,14 +165,14 @@ class IdleHandlerTest(protocol.BaseTestCase): self.assertOnceInResponse('OK') def test_player(self): - self.idleEvent('player') + self.idle_event('player') self.assertEqualEvents(['player']) self.assertNoSubscriptions() self.assertNoResponse() def test_player_then_idle_player(self): - self.idleEvent('player') - self.sendRequest('idle player') + self.idle_event('player') + self.send_request('idle player') self.assertNoEvents() self.assertNoSubscriptions() self.assertOnceInResponse('changed: player') @@ -158,24 +180,24 @@ class IdleHandlerTest(protocol.BaseTestCase): self.assertOnceInResponse('OK') def test_player_then_playlist(self): - self.idleEvent('player') - self.idleEvent('playlist') + self.idle_event('player') + self.idle_event('playlist') self.assertEqualEvents(['player', 'playlist']) self.assertNoSubscriptions() self.assertNoResponse() def test_player_then_idle(self): - self.idleEvent('player') - self.sendRequest('idle') + self.idle_event('player') + self.send_request('idle') self.assertNoEvents() self.assertNoSubscriptions() self.assertOnceInResponse('changed: player') self.assertOnceInResponse('OK') def test_player_then_playlist_then_idle(self): - self.idleEvent('player') - self.idleEvent('playlist') - self.sendRequest('idle') + self.idle_event('player') + self.idle_event('playlist') + self.send_request('idle') self.assertNoEvents() self.assertNoSubscriptions() self.assertOnceInResponse('changed: player') @@ -183,26 +205,34 @@ class IdleHandlerTest(protocol.BaseTestCase): self.assertOnceInResponse('OK') def test_player_then_idle_playlist(self): - self.idleEvent('player') - self.sendRequest('idle playlist') + self.idle_event('player') + self.send_request('idle playlist') self.assertEqualEvents(['player']) self.assertEqualSubscriptions(['playlist']) self.assertNoResponse() def test_player_then_idle_playlist_then_noidle(self): - self.idleEvent('player') - self.sendRequest('idle playlist') - self.sendRequest('noidle') + self.idle_event('player') + self.send_request('idle playlist') + self.send_request('noidle') self.assertNoEvents() self.assertNoSubscriptions() self.assertOnceInResponse('OK') def test_player_then_playlist_then_idle_playlist(self): - self.idleEvent('player') - self.idleEvent('playlist') - self.sendRequest('idle playlist') + self.idle_event('player') + self.idle_event('playlist') + self.send_request('idle playlist') self.assertNoEvents() self.assertNoSubscriptions() self.assertNotInResponse('changed: player') self.assertOnceInResponse('changed: playlist') self.assertOnceInResponse('OK') + + def test_output_then_idle_toggleoutput(self): + self.idle_event('output') + self.send_request('idle output') + self.assertNoEvents() + self.assertNoSubscriptions() + self.assertOnceInResponse('changed: output') + self.assertOnceInResponse('OK') diff --git a/tests/mpd/protocol/test_music_db.py b/tests/mpd/protocol/test_music_db.py index 8efd1dc4..b9fbcdf6 100644 --- a/tests/mpd/protocol/test_music_db.py +++ b/tests/mpd/protocol/test_music_db.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import unittest @@ -34,19 +34,19 @@ class QueryFromMpdListFormatTest(unittest.TestCase): class MusicDatabaseHandlerTest(protocol.BaseTestCase): def test_count(self): - self.sendRequest('count "artist" "needle"') + self.send_request('count "artist" "needle"') self.assertInResponse('songs: 0') self.assertInResponse('playtime: 0') self.assertInResponse('OK') def test_count_without_quotes(self): - self.sendRequest('count artist "needle"') + self.send_request('count artist "needle"') self.assertInResponse('songs: 0') self.assertInResponse('playtime: 0') self.assertInResponse('OK') def test_count_with_multiple_pairs(self): - self.sendRequest('count "artist" "foo" "album" "bar"') + self.send_request('count "artist" "foo" "album" "bar"') self.assertInResponse('songs: 0') self.assertInResponse('playtime: 0') self.assertInResponse('OK') @@ -55,9 +55,9 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): # Count the lone track self.backend.library.dummy_find_exact_result = SearchResult( tracks=[ - Track(uri='dummy:a', name="foo", date="2001", length=4000), + Track(uri='dummy:a', name='foo', date='2001', length=4000), ]) - self.sendRequest('count "title" "foo"') + self.send_request('count "title" "foo"') self.assertInResponse('songs: 1') self.assertInResponse('playtime: 4') self.assertInResponse('OK') @@ -68,7 +68,7 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): Track(uri='dummy:b', date="2001", length=50000), Track(uri='dummy:c', date="2001", length=600000), ]) - self.sendRequest('count "date" "2001"') + self.send_request('count "date" "2001"') self.assertInResponse('songs: 2') self.assertInResponse('playtime: 650') self.assertInResponse('OK') @@ -78,7 +78,7 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): tracks=[Track(uri='dummy:a', name='A')]) self.assertEqual(self.core.tracklist.length.get(), 0) - self.sendRequest('findadd "title" "A"') + self.send_request('findadd "title" "A"') self.assertEqual(self.core.tracklist.length.get(), 1) self.assertEqual(self.core.tracklist.tracks.get()[0].uri, 'dummy:a') @@ -89,7 +89,7 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): tracks=[Track(uri='dummy:a', name='A')]) self.assertEqual(self.core.tracklist.length.get(), 0) - self.sendRequest('searchadd "title" "a"') + self.send_request('searchadd "title" "a"') self.assertEqual(self.core.tracklist.length.get(), 1) self.assertEqual(self.core.tracklist.tracks.get()[0].uri, 'dummy:a') @@ -108,7 +108,7 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): self.assertEqual(len(playlists), 1) self.assertEqual(len(playlists[0].tracks), 2) - self.sendRequest('searchaddpl "my favs" "title" "a"') + self.send_request('searchaddpl "my favs" "title" "a"') playlists = self.core.playlists.filter(name='my favs').get() self.assertEqual(len(playlists), 1) @@ -124,7 +124,7 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): self.assertEqual( len(self.core.playlists.filter(name='my favs').get()), 0) - self.sendRequest('searchaddpl "my favs" "title" "a"') + self.send_request('searchaddpl "my favs" "title" "a"') playlists = self.core.playlists.filter(name='my favs').get() self.assertEqual(len(playlists), 1) @@ -139,14 +139,16 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): 'dummy:/': [Ref.track(uri='dummy:/a', name='a'), Ref.directory(uri='dummy:/foo', name='foo'), Ref.album(uri='dummy:/album', name='album'), + Ref.artist(uri='dummy:/artist', name='artist'), Ref.playlist(uri='dummy:/pl', name='pl')], 'dummy:/foo': [Ref.track(uri='dummy:/foo/b', name='b')]} - self.sendRequest('listall') + self.send_request('listall') self.assertInResponse('file: dummy:/a') self.assertInResponse('directory: /dummy/foo') self.assertInResponse('directory: /dummy/album') + self.assertInResponse('directory: /dummy/artist') self.assertInResponse('directory: /dummy/pl') self.assertInResponse('file: dummy:/foo/b') self.assertInResponse('OK') @@ -160,7 +162,7 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): Ref.directory(uri='dummy:/foo', name='foo')], 'dummy:/foo': [Ref.track(uri='dummy:/foo/b', name='b')]} - self.sendRequest('listall "/dummy/foo"') + self.send_request('listall "/dummy/foo"') self.assertNotInResponse('file: dummy:/a') self.assertInResponse('directory: /dummy/foo') @@ -168,7 +170,7 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_listall_with_unknown_uri(self): - self.sendRequest('listall "/unknown"') + self.send_request('listall "/unknown"') self.assertEqualResponse('ACK [50@0] {listall} Not found') @@ -177,8 +179,8 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): 'dummy:/': [Ref.track(uri='dummy:/a', name='a'), Ref.directory(uri='dummy:/foo', name='foo')]} - response1 = self.sendRequest('listall "dummy"') - response2 = self.sendRequest('listall "/dummy"') + response1 = self.send_request('listall "dummy"') + response2 = self.send_request('listall "/dummy"') self.assertEqual(response1, response2) def test_listall_for_dir_with_and_without_trailing_slash_is_the_same(self): @@ -186,8 +188,8 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): 'dummy:/': [Ref.track(uri='dummy:/a', name='a'), Ref.directory(uri='dummy:/foo', name='foo')]} - response1 = self.sendRequest('listall "dummy"') - response2 = self.sendRequest('listall "dummy/"') + response1 = self.send_request('listall "dummy"') + response2 = self.send_request('listall "dummy/"') self.assertEqual(response1, response2) def test_listall_duplicate(self): @@ -195,7 +197,7 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): 'dummy:/': [Ref.directory(uri='dummy:/a1', name='a'), Ref.directory(uri='dummy:/a2', name='a')]} - self.sendRequest('listall') + self.send_request('listall') self.assertInResponse('directory: /dummy/a') self.assertInResponse('directory: /dummy/a [2]') @@ -207,15 +209,17 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): 'dummy:/': [Ref.track(uri='dummy:/a', name='a'), Ref.directory(uri='dummy:/foo', name='foo'), Ref.album(uri='dummy:/album', name='album'), + Ref.artist(uri='dummy:/artist', name='artist'), Ref.playlist(uri='dummy:/pl', name='pl')], 'dummy:/foo': [Ref.track(uri='dummy:/foo/b', name='b')]} - self.sendRequest('listallinfo') + self.send_request('listallinfo') self.assertInResponse('file: dummy:/a') self.assertInResponse('Title: a') self.assertInResponse('directory: /dummy/foo') self.assertInResponse('directory: /dummy/album') + self.assertInResponse('directory: /dummy/artist') self.assertInResponse('directory: /dummy/pl') self.assertInResponse('file: dummy:/foo/b') self.assertInResponse('Title: b') @@ -230,7 +234,7 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): Ref.directory(uri='dummy:/foo', name='foo')], 'dummy:/foo': [Ref.track(uri='dummy:/foo/b', name='b')]} - self.sendRequest('listallinfo "/dummy/foo"') + self.send_request('listallinfo "/dummy/foo"') self.assertNotInResponse('file: dummy:/a') self.assertNotInResponse('Title: a') @@ -240,7 +244,7 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_listallinfo_with_unknown_uri(self): - self.sendRequest('listallinfo "/unknown"') + self.send_request('listallinfo "/unknown"') self.assertEqualResponse('ACK [50@0] {listallinfo} Not found') @@ -249,8 +253,8 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): 'dummy:/': [Ref.track(uri='dummy:/a', name='a'), Ref.directory(uri='dummy:/foo', name='foo')]} - response1 = self.sendRequest('listallinfo "dummy"') - response2 = self.sendRequest('listallinfo "/dummy"') + response1 = self.send_request('listallinfo "dummy"') + response2 = self.send_request('listallinfo "/dummy"') self.assertEqual(response1, response2) def test_listallinfo_for_dir_with_and_without_trailing_slash_is_same(self): @@ -258,8 +262,8 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): 'dummy:/': [Ref.track(uri='dummy:/a', name='a'), Ref.directory(uri='dummy:/foo', name='foo')]} - response1 = self.sendRequest('listallinfo "dummy"') - response2 = self.sendRequest('listallinfo "dummy/"') + response1 = self.send_request('listallinfo "dummy"') + response2 = self.send_request('listallinfo "dummy/"') self.assertEqual(response1, response2) def test_listallinfo_duplicate(self): @@ -267,34 +271,34 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): 'dummy:/': [Ref.directory(uri='dummy:/a1', name='a'), Ref.directory(uri='dummy:/a2', name='a')]} - self.sendRequest('listallinfo') + self.send_request('listallinfo') self.assertInResponse('directory: /dummy/a') self.assertInResponse('directory: /dummy/a [2]') def test_lsinfo_without_path_returns_same_as_for_root(self): last_modified = 1390942873222 - self.backend.playlists.playlists = [ - Playlist(name='a', uri='dummy:/a', last_modified=last_modified)] + self.backend.playlists.set_dummy_playlists([ + Playlist(name='a', uri='dummy:/a', last_modified=last_modified)]) - response1 = self.sendRequest('lsinfo') - response2 = self.sendRequest('lsinfo "/"') + response1 = self.send_request('lsinfo') + response2 = self.send_request('lsinfo "/"') self.assertEqual(response1, response2) def test_lsinfo_with_empty_path_returns_same_as_for_root(self): last_modified = 1390942873222 - self.backend.playlists.playlists = [ - Playlist(name='a', uri='dummy:/a', last_modified=last_modified)] + self.backend.playlists.set_dummy_playlists([ + Playlist(name='a', uri='dummy:/a', last_modified=last_modified)]) - response1 = self.sendRequest('lsinfo ""') - response2 = self.sendRequest('lsinfo "/"') + response1 = self.send_request('lsinfo ""') + response2 = self.send_request('lsinfo "/"') self.assertEqual(response1, response2) def test_lsinfo_for_root_includes_playlists(self): last_modified = 1390942873222 - self.backend.playlists.playlists = [ - Playlist(name='a', uri='dummy:/a', last_modified=last_modified)] + self.backend.playlists.set_dummy_playlists([ + Playlist(name='a', uri='dummy:/a', last_modified=last_modified)]) - self.sendRequest('lsinfo "/"') + self.send_request('lsinfo "/"') self.assertInResponse('playlist: a') # Date without milliseconds and with time zone information self.assertInResponse('Last-Modified: 2014-01-28T21:01:13Z') @@ -305,7 +309,7 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): 'dummy:/': [Ref.track(uri='dummy:/a', name='a'), Ref.directory(uri='dummy:/foo', name='foo')]} - self.sendRequest('lsinfo "/"') + self.send_request('lsinfo "/"') self.assertInResponse('directory: dummy') self.assertInResponse('OK') @@ -314,8 +318,8 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): 'dummy:/': [Ref.track(uri='dummy:/a', name='a'), Ref.directory(uri='dummy:/foo', name='foo')]} - response1 = self.sendRequest('lsinfo "dummy"') - response2 = self.sendRequest('lsinfo "/dummy"') + response1 = self.send_request('lsinfo "dummy"') + response2 = self.send_request('lsinfo "/dummy"') self.assertEqual(response1, response2) def test_lsinfo_for_dir_with_and_without_trailing_slash_is_the_same(self): @@ -323,8 +327,8 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): 'dummy:/': [Ref.track(uri='dummy:/a', name='a'), Ref.directory(uri='dummy:/foo', name='foo')]} - response1 = self.sendRequest('lsinfo "dummy"') - response2 = self.sendRequest('lsinfo "dummy/"') + response1 = self.send_request('lsinfo "dummy"') + response2 = self.send_request('lsinfo "dummy/"') self.assertEqual(response1, response2) def test_lsinfo_for_dir_includes_tracks(self): @@ -334,7 +338,7 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): self.backend.library.dummy_browse_result = { 'dummy:/': [Ref.track(uri='dummy:/a', name='a')]} - self.sendRequest('lsinfo "/dummy"') + self.send_request('lsinfo "/dummy"') self.assertInResponse('file: dummy:/a') self.assertInResponse('Title: a') self.assertInResponse('OK') @@ -343,7 +347,7 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): self.backend.library.dummy_browse_result = { 'dummy:/': [Ref.directory(uri='dummy:/foo', name='foo')]} - self.sendRequest('lsinfo "/dummy"') + self.send_request('lsinfo "/dummy"') self.assertInResponse('directory: dummy/foo') self.assertInResponse('OK') @@ -351,7 +355,7 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): self.backend.library.dummy_browse_result = { 'dummy:/': []} - self.sendRequest('lsinfo "/dummy"') + self.send_request('lsinfo "/dummy"') self.assertInResponse('OK') def test_lsinfo_for_dir_does_not_recurse(self): @@ -362,7 +366,7 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): 'dummy:/': [Ref.directory(uri='dummy:/foo', name='foo')], 'dummy:/foo': [Ref.track(uri='dummy:/a', name='a')]} - self.sendRequest('lsinfo "/dummy"') + self.send_request('lsinfo "/dummy"') self.assertNotInResponse('file: dummy:/a') self.assertInResponse('OK') @@ -371,7 +375,7 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): 'dummy:/': [Ref.directory(uri='dummy:/foo', name='foo')], 'dummy:/foo': [Ref.track(uri='dummy:/a', name='a')]} - self.sendRequest('lsinfo "/dummy"') + self.send_request('lsinfo "/dummy"') self.assertNotInResponse('directory: dummy') self.assertInResponse('OK') @@ -380,10 +384,10 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): self.backend.library.dummy_browse_result = { 'dummy:/': [Ref.track(uri='dummy:/a', name='a'), Ref.directory(uri='dummy:/foo', name='foo')]} - self.backend.playlists.playlists = [ - Playlist(name='a', uri='dummy:/a', last_modified=last_modified)] + self.backend.playlists.set_dummy_playlists([ + Playlist(name='a', uri='dummy:/a', last_modified=last_modified)]) - response = self.sendRequest('lsinfo "/"') + response = self.send_request('lsinfo "/"') self.assertLess(response.index('directory: dummy'), response.index('playlist: a')) @@ -392,27 +396,27 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): 'dummy:/': [Ref.directory(uri='dummy:/a1', name='a'), Ref.directory(uri='dummy:/a2', name='a')]} - self.sendRequest('lsinfo "/dummy"') + self.send_request('lsinfo "/dummy"') self.assertInResponse('directory: dummy/a') self.assertInResponse('directory: dummy/a [2]') def test_update_without_uri(self): - self.sendRequest('update') + self.send_request('update') self.assertInResponse('updating_db: 0') self.assertInResponse('OK') def test_update_with_uri(self): - self.sendRequest('update "file:///dev/urandom"') + self.send_request('update "file:///dev/urandom"') self.assertInResponse('updating_db: 0') self.assertInResponse('OK') def test_rescan_without_uri(self): - self.sendRequest('rescan') + self.send_request('rescan') self.assertInResponse('updating_db: 0') self.assertInResponse('OK') def test_rescan_with_uri(self): - self.sendRequest('rescan "file:///dev/urandom"') + self.send_request('rescan "file:///dev/urandom"') self.assertInResponse('updating_db: 0') self.assertInResponse('OK') @@ -424,7 +428,7 @@ class MusicDatabaseFindTest(protocol.BaseTestCase): artists=[Artist(uri='dummy:artist:b', name='B')], tracks=[Track(uri='dummy:track:c', name='C')]) - self.sendRequest('find "any" "foo"') + self.send_request('find "any" "foo"') self.assertInResponse('file: dummy:artist:b') self.assertInResponse('Title: Artist: B') @@ -444,7 +448,7 @@ class MusicDatabaseFindTest(protocol.BaseTestCase): artists=[Artist(uri='dummy:artist:b', name='B')], tracks=[Track(uri='dummy:track:c', name='C')]) - self.sendRequest('find "artist" "foo"') + self.send_request('find "artist" "foo"') self.assertNotInResponse('file: dummy:artist:b') self.assertNotInResponse('Title: Artist: B') @@ -464,7 +468,7 @@ class MusicDatabaseFindTest(protocol.BaseTestCase): artists=[Artist(uri='dummy:artist:b', name='B')], tracks=[Track(uri='dummy:track:c', name='C')]) - self.sendRequest('find "albumartist" "foo"') + self.send_request('find "albumartist" "foo"') self.assertNotInResponse('file: dummy:artist:b') self.assertNotInResponse('Title: Artist: B') @@ -484,7 +488,7 @@ class MusicDatabaseFindTest(protocol.BaseTestCase): artists=[Artist(uri='dummy:artist:b', name='B')], tracks=[Track(uri='dummy:track:c', name='C')]) - self.sendRequest('find "artist" "foo" "album" "bar"') + self.send_request('find "artist" "foo" "album" "bar"') self.assertNotInResponse('file: dummy:artist:b') self.assertNotInResponse('Title: Artist: B') @@ -499,247 +503,244 @@ class MusicDatabaseFindTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_find_album(self): - self.sendRequest('find "album" "what"') + self.send_request('find "album" "what"') self.assertInResponse('OK') def test_find_album_without_quotes(self): - self.sendRequest('find album "what"') + self.send_request('find album "what"') self.assertInResponse('OK') def test_find_artist(self): - self.sendRequest('find "artist" "what"') + self.send_request('find "artist" "what"') self.assertInResponse('OK') def test_find_artist_without_quotes(self): - self.sendRequest('find artist "what"') + self.send_request('find artist "what"') self.assertInResponse('OK') def test_find_albumartist(self): - self.sendRequest('find "albumartist" "what"') + self.send_request('find "albumartist" "what"') self.assertInResponse('OK') def test_find_albumartist_without_quotes(self): - self.sendRequest('find albumartist "what"') + self.send_request('find albumartist "what"') self.assertInResponse('OK') def test_find_composer(self): - self.sendRequest('find "composer" "what"') + self.send_request('find "composer" "what"') self.assertInResponse('OK') def test_find_composer_without_quotes(self): - self.sendRequest('find composer "what"') + self.send_request('find composer "what"') self.assertInResponse('OK') def test_find_performer(self): - self.sendRequest('find "performer" "what"') + self.send_request('find "performer" "what"') self.assertInResponse('OK') def test_find_performer_without_quotes(self): - self.sendRequest('find performer "what"') + self.send_request('find performer "what"') self.assertInResponse('OK') def test_find_filename(self): - self.sendRequest('find "filename" "afilename"') + self.send_request('find "filename" "afilename"') self.assertInResponse('OK') def test_find_filename_without_quotes(self): - self.sendRequest('find filename "afilename"') + self.send_request('find filename "afilename"') self.assertInResponse('OK') def test_find_file(self): - self.sendRequest('find "file" "afilename"') + self.send_request('find "file" "afilename"') self.assertInResponse('OK') def test_find_file_without_quotes(self): - self.sendRequest('find file "afilename"') + self.send_request('find file "afilename"') self.assertInResponse('OK') def test_find_title(self): - self.sendRequest('find "title" "what"') + self.send_request('find "title" "what"') self.assertInResponse('OK') def test_find_title_without_quotes(self): - self.sendRequest('find title "what"') + self.send_request('find title "what"') self.assertInResponse('OK') def test_find_track_no(self): - self.sendRequest('find "track" "10"') + self.send_request('find "track" "10"') self.assertInResponse('OK') def test_find_track_no_without_quotes(self): - self.sendRequest('find track "10"') + self.send_request('find track "10"') self.assertInResponse('OK') def test_find_track_no_without_filter_value(self): - self.sendRequest('find "track" ""') + self.send_request('find "track" ""') self.assertInResponse('OK') def test_find_genre(self): - self.sendRequest('find "genre" "what"') + self.send_request('find "genre" "what"') self.assertInResponse('OK') def test_find_genre_without_quotes(self): - self.sendRequest('find genre "what"') + self.send_request('find genre "what"') self.assertInResponse('OK') def test_find_date(self): - self.sendRequest('find "date" "2002-01-01"') + self.send_request('find "date" "2002-01-01"') self.assertInResponse('OK') def test_find_date_without_quotes(self): - self.sendRequest('find date "2002-01-01"') + self.send_request('find date "2002-01-01"') self.assertInResponse('OK') def test_find_date_with_capital_d_and_incomplete_date(self): - self.sendRequest('find Date "2005"') + self.send_request('find Date "2005"') self.assertInResponse('OK') def test_find_else_should_fail(self): - self.sendRequest('find "somethingelse" "what"') + self.send_request('find "somethingelse" "what"') self.assertEqualResponse('ACK [2@0] {find} incorrect arguments') def test_find_album_and_artist(self): - self.sendRequest('find album "album_what" artist "artist_what"') + self.send_request('find album "album_what" artist "artist_what"') self.assertInResponse('OK') def test_find_without_filter_value(self): - self.sendRequest('find "album" ""') + self.send_request('find "album" ""') self.assertInResponse('OK') class MusicDatabaseListTest(protocol.BaseTestCase): def test_list(self): - self.backend.library.dummy_find_exact_result = SearchResult( - tracks=[ - Track(uri='dummy:a', name='A', artists=[ - Artist(name='A Artist')])]) - - self.sendRequest('list "artist" "artist" "foo"') + self.backend.library.dummy_get_distinct_result = { + 'artist': set(['A Artist'])} + self.send_request('list "artist" "artist" "foo"') self.assertInResponse('Artist: A Artist') self.assertInResponse('OK') def test_list_foo_returns_ack(self): - self.sendRequest('list "foo"') + self.send_request('list "foo"') self.assertEqualResponse('ACK [2@0] {list} incorrect arguments') # Artist def test_list_artist_with_quotes(self): - self.sendRequest('list "artist"') + self.send_request('list "artist"') self.assertInResponse('OK') def test_list_artist_without_quotes(self): - self.sendRequest('list artist') + self.send_request('list artist') self.assertInResponse('OK') def test_list_artist_without_quotes_and_capitalized(self): - self.sendRequest('list Artist') + self.send_request('list Artist') self.assertInResponse('OK') def test_list_artist_with_query_of_one_token(self): - self.sendRequest('list "artist" "anartist"') + self.send_request('list "artist" "anartist"') self.assertEqualResponse( 'ACK [2@0] {list} should be "Album" for 3 arguments') def test_list_artist_with_unknown_field_in_query_returns_ack(self): - self.sendRequest('list "artist" "foo" "bar"') + self.send_request('list "artist" "foo" "bar"') self.assertEqualResponse('ACK [2@0] {list} not able to parse args') def test_list_artist_by_artist(self): - self.sendRequest('list "artist" "artist" "anartist"') + self.send_request('list "artist" "artist" "anartist"') self.assertInResponse('OK') def test_list_artist_by_album(self): - self.sendRequest('list "artist" "album" "analbum"') + self.send_request('list "artist" "album" "analbum"') self.assertInResponse('OK') def test_list_artist_by_full_date(self): - self.sendRequest('list "artist" "date" "2001-01-01"') + self.send_request('list "artist" "date" "2001-01-01"') self.assertInResponse('OK') def test_list_artist_by_year(self): - self.sendRequest('list "artist" "date" "2001"') + self.send_request('list "artist" "date" "2001"') self.assertInResponse('OK') def test_list_artist_by_genre(self): - self.sendRequest('list "artist" "genre" "agenre"') + self.send_request('list "artist" "genre" "agenre"') self.assertInResponse('OK') def test_list_artist_by_artist_and_album(self): - self.sendRequest( + self.send_request( 'list "artist" "artist" "anartist" "album" "analbum"') self.assertInResponse('OK') def test_list_artist_without_filter_value(self): - self.sendRequest('list "artist" "artist" ""') + self.send_request('list "artist" "artist" ""') self.assertInResponse('OK') def test_list_artist_should_not_return_artists_without_names(self): self.backend.library.dummy_find_exact_result = SearchResult( tracks=[Track(artists=[Artist(name='')])]) - self.sendRequest('list "artist"') + self.send_request('list "artist"') self.assertNotInResponse('Artist: ') self.assertInResponse('OK') # Albumartist def test_list_albumartist_with_quotes(self): - self.sendRequest('list "albumartist"') + self.send_request('list "albumartist"') self.assertInResponse('OK') def test_list_albumartist_without_quotes(self): - self.sendRequest('list albumartist') + self.send_request('list albumartist') self.assertInResponse('OK') def test_list_albumartist_without_quotes_and_capitalized(self): - self.sendRequest('list Albumartist') + self.send_request('list Albumartist') self.assertInResponse('OK') def test_list_albumartist_with_query_of_one_token(self): - self.sendRequest('list "albumartist" "anartist"') + self.send_request('list "albumartist" "anartist"') self.assertEqualResponse( 'ACK [2@0] {list} should be "Album" for 3 arguments') def test_list_albumartist_with_unknown_field_in_query_returns_ack(self): - self.sendRequest('list "albumartist" "foo" "bar"') + self.send_request('list "albumartist" "foo" "bar"') self.assertEqualResponse('ACK [2@0] {list} not able to parse args') def test_list_albumartist_by_artist(self): - self.sendRequest('list "albumartist" "artist" "anartist"') + self.send_request('list "albumartist" "artist" "anartist"') self.assertInResponse('OK') def test_list_albumartist_by_album(self): - self.sendRequest('list "albumartist" "album" "analbum"') + self.send_request('list "albumartist" "album" "analbum"') self.assertInResponse('OK') def test_list_albumartist_by_full_date(self): - self.sendRequest('list "albumartist" "date" "2001-01-01"') + self.send_request('list "albumartist" "date" "2001-01-01"') self.assertInResponse('OK') def test_list_albumartist_by_year(self): - self.sendRequest('list "albumartist" "date" "2001"') + self.send_request('list "albumartist" "date" "2001"') self.assertInResponse('OK') def test_list_albumartist_by_genre(self): - self.sendRequest('list "albumartist" "genre" "agenre"') + self.send_request('list "albumartist" "genre" "agenre"') self.assertInResponse('OK') def test_list_albumartist_by_artist_and_album(self): - self.sendRequest( + self.send_request( 'list "albumartist" "artist" "anartist" "album" "analbum"') self.assertInResponse('OK') def test_list_albumartist_without_filter_value(self): - self.sendRequest('list "albumartist" "artist" ""') + self.send_request('list "albumartist" "artist" ""') self.assertInResponse('OK') def test_list_albumartist_should_not_return_artists_without_names(self): self.backend.library.dummy_find_exact_result = SearchResult( tracks=[Track(album=Album(artists=[Artist(name='')]))]) - self.sendRequest('list "albumartist"') + self.send_request('list "albumartist"') self.assertNotInResponse('Artist: ') self.assertNotInResponse('Albumartist: ') self.assertNotInResponse('Composer: ') @@ -749,60 +750,60 @@ class MusicDatabaseListTest(protocol.BaseTestCase): # Composer def test_list_composer_with_quotes(self): - self.sendRequest('list "composer"') + self.send_request('list "composer"') self.assertInResponse('OK') def test_list_composer_without_quotes(self): - self.sendRequest('list composer') + self.send_request('list composer') self.assertInResponse('OK') def test_list_composer_without_quotes_and_capitalized(self): - self.sendRequest('list Composer') + self.send_request('list Composer') self.assertInResponse('OK') def test_list_composer_with_query_of_one_token(self): - self.sendRequest('list "composer" "anartist"') + self.send_request('list "composer" "anartist"') self.assertEqualResponse( 'ACK [2@0] {list} should be "Album" for 3 arguments') def test_list_composer_with_unknown_field_in_query_returns_ack(self): - self.sendRequest('list "composer" "foo" "bar"') + self.send_request('list "composer" "foo" "bar"') self.assertEqualResponse('ACK [2@0] {list} not able to parse args') def test_list_composer_by_artist(self): - self.sendRequest('list "composer" "artist" "anartist"') + self.send_request('list "composer" "artist" "anartist"') self.assertInResponse('OK') def test_list_composer_by_album(self): - self.sendRequest('list "composer" "album" "analbum"') + self.send_request('list "composer" "album" "analbum"') self.assertInResponse('OK') def test_list_composer_by_full_date(self): - self.sendRequest('list "composer" "date" "2001-01-01"') + self.send_request('list "composer" "date" "2001-01-01"') self.assertInResponse('OK') def test_list_composer_by_year(self): - self.sendRequest('list "composer" "date" "2001"') + self.send_request('list "composer" "date" "2001"') self.assertInResponse('OK') def test_list_composer_by_genre(self): - self.sendRequest('list "composer" "genre" "agenre"') + self.send_request('list "composer" "genre" "agenre"') self.assertInResponse('OK') def test_list_composer_by_artist_and_album(self): - self.sendRequest( + self.send_request( 'list "composer" "artist" "anartist" "album" "analbum"') self.assertInResponse('OK') def test_list_composer_without_filter_value(self): - self.sendRequest('list "composer" "artist" ""') + self.send_request('list "composer" "artist" ""') self.assertInResponse('OK') def test_list_composer_should_not_return_artists_without_names(self): self.backend.library.dummy_find_exact_result = SearchResult( tracks=[Track(composers=[Artist(name='')])]) - self.sendRequest('list "composer"') + self.send_request('list "composer"') self.assertNotInResponse('Artist: ') self.assertNotInResponse('Albumartist: ') self.assertNotInResponse('Composer: ') @@ -812,60 +813,60 @@ class MusicDatabaseListTest(protocol.BaseTestCase): # Performer def test_list_performer_with_quotes(self): - self.sendRequest('list "performer"') + self.send_request('list "performer"') self.assertInResponse('OK') def test_list_performer_without_quotes(self): - self.sendRequest('list performer') + self.send_request('list performer') self.assertInResponse('OK') def test_list_performer_without_quotes_and_capitalized(self): - self.sendRequest('list Albumartist') + self.send_request('list Albumartist') self.assertInResponse('OK') def test_list_performer_with_query_of_one_token(self): - self.sendRequest('list "performer" "anartist"') + self.send_request('list "performer" "anartist"') self.assertEqualResponse( 'ACK [2@0] {list} should be "Album" for 3 arguments') def test_list_performer_with_unknown_field_in_query_returns_ack(self): - self.sendRequest('list "performer" "foo" "bar"') + self.send_request('list "performer" "foo" "bar"') self.assertEqualResponse('ACK [2@0] {list} not able to parse args') def test_list_performer_by_artist(self): - self.sendRequest('list "performer" "artist" "anartist"') + self.send_request('list "performer" "artist" "anartist"') self.assertInResponse('OK') def test_list_performer_by_album(self): - self.sendRequest('list "performer" "album" "analbum"') + self.send_request('list "performer" "album" "analbum"') self.assertInResponse('OK') def test_list_performer_by_full_date(self): - self.sendRequest('list "performer" "date" "2001-01-01"') + self.send_request('list "performer" "date" "2001-01-01"') self.assertInResponse('OK') def test_list_performer_by_year(self): - self.sendRequest('list "performer" "date" "2001"') + self.send_request('list "performer" "date" "2001"') self.assertInResponse('OK') def test_list_performer_by_genre(self): - self.sendRequest('list "performer" "genre" "agenre"') + self.send_request('list "performer" "genre" "agenre"') self.assertInResponse('OK') def test_list_performer_by_artist_and_album(self): - self.sendRequest( + self.send_request( 'list "performer" "artist" "anartist" "album" "analbum"') self.assertInResponse('OK') def test_list_performer_without_filter_value(self): - self.sendRequest('list "performer" "artist" ""') + self.send_request('list "performer" "artist" ""') self.assertInResponse('OK') def test_list_performer_should_not_return_artists_without_names(self): self.backend.library.dummy_find_exact_result = SearchResult( tracks=[Track(performers=[Artist(name='')])]) - self.sendRequest('list "performer"') + self.send_request('list "performer"') self.assertNotInResponse('Artist: ') self.assertNotInResponse('Albumartist: ') self.assertNotInResponse('Composer: ') @@ -875,179 +876,179 @@ class MusicDatabaseListTest(protocol.BaseTestCase): # Album def test_list_album_with_quotes(self): - self.sendRequest('list "album"') + self.send_request('list "album"') self.assertInResponse('OK') def test_list_album_without_quotes(self): - self.sendRequest('list album') + self.send_request('list album') self.assertInResponse('OK') def test_list_album_without_quotes_and_capitalized(self): - self.sendRequest('list Album') + self.send_request('list Album') self.assertInResponse('OK') def test_list_album_with_artist_name(self): - self.backend.library.dummy_find_exact_result = SearchResult( - tracks=[Track(album=Album(name='foo'))]) + self.backend.library.dummy_get_distinct_result = { + 'album': set(['foo'])} - self.sendRequest('list "album" "anartist"') + self.send_request('list "album" "anartist"') self.assertInResponse('Album: foo') self.assertInResponse('OK') def test_list_album_with_artist_name_without_filter_value(self): - self.sendRequest('list "album" ""') + self.send_request('list "album" ""') self.assertInResponse('OK') def test_list_album_by_artist(self): - self.sendRequest('list "album" "artist" "anartist"') + self.send_request('list "album" "artist" "anartist"') self.assertInResponse('OK') def test_list_album_by_album(self): - self.sendRequest('list "album" "album" "analbum"') + self.send_request('list "album" "album" "analbum"') self.assertInResponse('OK') def test_list_album_by_albumartist(self): - self.sendRequest('list "album" "albumartist" "anartist"') + self.send_request('list "album" "albumartist" "anartist"') self.assertInResponse('OK') def test_list_album_by_composer(self): - self.sendRequest('list "album" "composer" "anartist"') + self.send_request('list "album" "composer" "anartist"') self.assertInResponse('OK') def test_list_album_by_performer(self): - self.sendRequest('list "album" "performer" "anartist"') + self.send_request('list "album" "performer" "anartist"') self.assertInResponse('OK') def test_list_album_by_full_date(self): - self.sendRequest('list "album" "date" "2001-01-01"') + self.send_request('list "album" "date" "2001-01-01"') self.assertInResponse('OK') def test_list_album_by_year(self): - self.sendRequest('list "album" "date" "2001"') + self.send_request('list "album" "date" "2001"') self.assertInResponse('OK') def test_list_album_by_genre(self): - self.sendRequest('list "album" "genre" "agenre"') + self.send_request('list "album" "genre" "agenre"') self.assertInResponse('OK') def test_list_album_by_artist_and_album(self): - self.sendRequest( + self.send_request( 'list "album" "artist" "anartist" "album" "analbum"') self.assertInResponse('OK') def test_list_album_without_filter_value(self): - self.sendRequest('list "album" "artist" ""') + self.send_request('list "album" "artist" ""') self.assertInResponse('OK') def test_list_album_should_not_return_albums_without_names(self): self.backend.library.dummy_find_exact_result = SearchResult( tracks=[Track(album=Album(name=''))]) - self.sendRequest('list "album"') + self.send_request('list "album"') self.assertNotInResponse('Album: ') self.assertInResponse('OK') # Date def test_list_date_with_quotes(self): - self.sendRequest('list "date"') + self.send_request('list "date"') self.assertInResponse('OK') def test_list_date_without_quotes(self): - self.sendRequest('list date') + self.send_request('list date') self.assertInResponse('OK') def test_list_date_without_quotes_and_capitalized(self): - self.sendRequest('list Date') + self.send_request('list Date') self.assertInResponse('OK') def test_list_date_with_query_of_one_token(self): - self.sendRequest('list "date" "anartist"') + self.send_request('list "date" "anartist"') self.assertEqualResponse( 'ACK [2@0] {list} should be "Album" for 3 arguments') def test_list_date_by_artist(self): - self.sendRequest('list "date" "artist" "anartist"') + self.send_request('list "date" "artist" "anartist"') self.assertInResponse('OK') def test_list_date_by_album(self): - self.sendRequest('list "date" "album" "analbum"') + self.send_request('list "date" "album" "analbum"') self.assertInResponse('OK') def test_list_date_by_full_date(self): - self.sendRequest('list "date" "date" "2001-01-01"') + self.send_request('list "date" "date" "2001-01-01"') self.assertInResponse('OK') def test_list_date_by_year(self): - self.sendRequest('list "date" "date" "2001"') + self.send_request('list "date" "date" "2001"') self.assertInResponse('OK') def test_list_date_by_genre(self): - self.sendRequest('list "date" "genre" "agenre"') + self.send_request('list "date" "genre" "agenre"') self.assertInResponse('OK') def test_list_date_by_artist_and_album(self): - self.sendRequest('list "date" "artist" "anartist" "album" "analbum"') + self.send_request('list "date" "artist" "anartist" "album" "analbum"') self.assertInResponse('OK') def test_list_date_without_filter_value(self): - self.sendRequest('list "date" "artist" ""') + self.send_request('list "date" "artist" ""') self.assertInResponse('OK') def test_list_date_should_not_return_blank_dates(self): self.backend.library.dummy_find_exact_result = SearchResult( tracks=[Track(date='')]) - self.sendRequest('list "date"') + self.send_request('list "date"') self.assertNotInResponse('Date: ') self.assertInResponse('OK') # Genre def test_list_genre_with_quotes(self): - self.sendRequest('list "genre"') + self.send_request('list "genre"') self.assertInResponse('OK') def test_list_genre_without_quotes(self): - self.sendRequest('list genre') + self.send_request('list genre') self.assertInResponse('OK') def test_list_genre_without_quotes_and_capitalized(self): - self.sendRequest('list Genre') + self.send_request('list Genre') self.assertInResponse('OK') def test_list_genre_with_query_of_one_token(self): - self.sendRequest('list "genre" "anartist"') + self.send_request('list "genre" "anartist"') self.assertEqualResponse( 'ACK [2@0] {list} should be "Album" for 3 arguments') def test_list_genre_by_artist(self): - self.sendRequest('list "genre" "artist" "anartist"') + self.send_request('list "genre" "artist" "anartist"') self.assertInResponse('OK') def test_list_genre_by_album(self): - self.sendRequest('list "genre" "album" "analbum"') + self.send_request('list "genre" "album" "analbum"') self.assertInResponse('OK') def test_list_genre_by_full_date(self): - self.sendRequest('list "genre" "date" "2001-01-01"') + self.send_request('list "genre" "date" "2001-01-01"') self.assertInResponse('OK') def test_list_genre_by_year(self): - self.sendRequest('list "genre" "date" "2001"') + self.send_request('list "genre" "date" "2001"') self.assertInResponse('OK') def test_list_genre_by_genre(self): - self.sendRequest('list "genre" "genre" "agenre"') + self.send_request('list "genre" "genre" "agenre"') self.assertInResponse('OK') def test_list_genre_by_artist_and_album(self): - self.sendRequest( + self.send_request( 'list "genre" "artist" "anartist" "album" "analbum"') self.assertInResponse('OK') def test_list_genre_without_filter_value(self): - self.sendRequest('list "genre" "artist" ""') + self.send_request('list "genre" "artist" ""') self.assertInResponse('OK') @@ -1058,7 +1059,7 @@ class MusicDatabaseSearchTest(protocol.BaseTestCase): artists=[Artist(uri='dummy:artist:b', name='B')], tracks=[Track(uri='dummy:track:c', name='C')]) - self.sendRequest('search "any" "foo"') + self.send_request('search "any" "foo"') self.assertInResponse('file: dummy:album:a') self.assertInResponse('Title: Album: A') @@ -1070,165 +1071,165 @@ class MusicDatabaseSearchTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_search_album(self): - self.sendRequest('search "album" "analbum"') + self.send_request('search "album" "analbum"') self.assertInResponse('OK') def test_search_album_without_quotes(self): - self.sendRequest('search album "analbum"') + self.send_request('search album "analbum"') self.assertInResponse('OK') def test_search_album_without_filter_value(self): - self.sendRequest('search "album" ""') + self.send_request('search "album" ""') self.assertInResponse('OK') def test_search_artist(self): - self.sendRequest('search "artist" "anartist"') + self.send_request('search "artist" "anartist"') self.assertInResponse('OK') def test_search_artist_without_quotes(self): - self.sendRequest('search artist "anartist"') + self.send_request('search artist "anartist"') self.assertInResponse('OK') def test_search_artist_without_filter_value(self): - self.sendRequest('search "artist" ""') + self.send_request('search "artist" ""') self.assertInResponse('OK') def test_search_albumartist(self): - self.sendRequest('search "albumartist" "analbumartist"') + self.send_request('search "albumartist" "analbumartist"') self.assertInResponse('OK') def test_search_albumartist_without_quotes(self): - self.sendRequest('search albumartist "analbumartist"') + self.send_request('search albumartist "analbumartist"') self.assertInResponse('OK') def test_search_albumartist_without_filter_value(self): - self.sendRequest('search "albumartist" ""') + self.send_request('search "albumartist" ""') self.assertInResponse('OK') def test_search_composer(self): - self.sendRequest('search "composer" "acomposer"') + self.send_request('search "composer" "acomposer"') self.assertInResponse('OK') def test_search_composer_without_quotes(self): - self.sendRequest('search composer "acomposer"') + self.send_request('search composer "acomposer"') self.assertInResponse('OK') def test_search_composer_without_filter_value(self): - self.sendRequest('search "composer" ""') + self.send_request('search "composer" ""') self.assertInResponse('OK') def test_search_performer(self): - self.sendRequest('search "performer" "aperformer"') + self.send_request('search "performer" "aperformer"') self.assertInResponse('OK') def test_search_performer_without_quotes(self): - self.sendRequest('search performer "aperformer"') + self.send_request('search performer "aperformer"') self.assertInResponse('OK') def test_search_performer_without_filter_value(self): - self.sendRequest('search "performer" ""') + self.send_request('search "performer" ""') self.assertInResponse('OK') def test_search_filename(self): - self.sendRequest('search "filename" "afilename"') + self.send_request('search "filename" "afilename"') self.assertInResponse('OK') def test_search_filename_without_quotes(self): - self.sendRequest('search filename "afilename"') + self.send_request('search filename "afilename"') self.assertInResponse('OK') def test_search_filename_without_filter_value(self): - self.sendRequest('search "filename" ""') + self.send_request('search "filename" ""') self.assertInResponse('OK') def test_search_file(self): - self.sendRequest('search "file" "afilename"') + self.send_request('search "file" "afilename"') self.assertInResponse('OK') def test_search_file_without_quotes(self): - self.sendRequest('search file "afilename"') + self.send_request('search file "afilename"') self.assertInResponse('OK') def test_search_file_without_filter_value(self): - self.sendRequest('search "file" ""') + self.send_request('search "file" ""') self.assertInResponse('OK') def test_search_title(self): - self.sendRequest('search "title" "atitle"') + self.send_request('search "title" "atitle"') self.assertInResponse('OK') def test_search_title_without_quotes(self): - self.sendRequest('search title "atitle"') + self.send_request('search title "atitle"') self.assertInResponse('OK') def test_search_title_without_filter_value(self): - self.sendRequest('search "title" ""') + self.send_request('search "title" ""') self.assertInResponse('OK') def test_search_any(self): - self.sendRequest('search "any" "anything"') + self.send_request('search "any" "anything"') self.assertInResponse('OK') def test_search_any_without_quotes(self): - self.sendRequest('search any "anything"') + self.send_request('search any "anything"') self.assertInResponse('OK') def test_search_any_without_filter_value(self): - self.sendRequest('search "any" ""') + self.send_request('search "any" ""') self.assertInResponse('OK') def test_search_track_no(self): - self.sendRequest('search "track" "10"') + self.send_request('search "track" "10"') self.assertInResponse('OK') def test_search_track_no_without_quotes(self): - self.sendRequest('search track "10"') + self.send_request('search track "10"') self.assertInResponse('OK') def test_search_track_no_without_filter_value(self): - self.sendRequest('search "track" ""') + self.send_request('search "track" ""') self.assertInResponse('OK') def test_search_genre(self): - self.sendRequest('search "genre" "agenre"') + self.send_request('search "genre" "agenre"') self.assertInResponse('OK') def test_search_genre_without_quotes(self): - self.sendRequest('search genre "agenre"') + self.send_request('search genre "agenre"') self.assertInResponse('OK') def test_search_genre_without_filter_value(self): - self.sendRequest('search "genre" ""') + self.send_request('search "genre" ""') self.assertInResponse('OK') def test_search_date(self): - self.sendRequest('search "date" "2002-01-01"') + self.send_request('search "date" "2002-01-01"') self.assertInResponse('OK') def test_search_date_without_quotes(self): - self.sendRequest('search date "2002-01-01"') + self.send_request('search date "2002-01-01"') self.assertInResponse('OK') def test_search_date_with_capital_d_and_incomplete_date(self): - self.sendRequest('search Date "2005"') + self.send_request('search Date "2005"') self.assertInResponse('OK') def test_search_date_without_filter_value(self): - self.sendRequest('search "date" ""') + self.send_request('search "date" ""') self.assertInResponse('OK') def test_search_comment(self): - self.sendRequest('search "comment" "acomment"') + self.send_request('search "comment" "acomment"') self.assertInResponse('OK') def test_search_comment_without_quotes(self): - self.sendRequest('search comment "acomment"') + self.send_request('search comment "acomment"') self.assertInResponse('OK') def test_search_comment_without_filter_value(self): - self.sendRequest('search "comment" ""') + self.send_request('search "comment" ""') self.assertInResponse('OK') def test_search_else_should_fail(self): - self.sendRequest('search "sometype" "something"') + self.send_request('search "sometype" "something"') self.assertEqualResponse('ACK [2@0] {search} incorrect arguments') diff --git a/tests/mpd/protocol/test_playback.py b/tests/mpd/protocol/test_playback.py index 67b4e787..22527e1e 100644 --- a/tests/mpd/protocol/test_playback.py +++ b/tests/mpd/protocol/test_playback.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import unittest @@ -15,141 +15,149 @@ STOPPED = PlaybackState.STOPPED class PlaybackOptionsHandlerTest(protocol.BaseTestCase): def test_consume_off(self): - self.sendRequest('consume "0"') + self.send_request('consume "0"') self.assertFalse(self.core.tracklist.consume.get()) self.assertInResponse('OK') def test_consume_off_without_quotes(self): - self.sendRequest('consume 0') + self.send_request('consume 0') self.assertFalse(self.core.tracklist.consume.get()) self.assertInResponse('OK') def test_consume_on(self): - self.sendRequest('consume "1"') + self.send_request('consume "1"') self.assertTrue(self.core.tracklist.consume.get()) self.assertInResponse('OK') def test_consume_on_without_quotes(self): - self.sendRequest('consume 1') + self.send_request('consume 1') self.assertTrue(self.core.tracklist.consume.get()) self.assertInResponse('OK') def test_crossfade(self): - self.sendRequest('crossfade "10"') + self.send_request('crossfade "10"') self.assertInResponse('ACK [0@0] {crossfade} Not implemented') def test_random_off(self): - self.sendRequest('random "0"') + self.send_request('random "0"') self.assertFalse(self.core.tracklist.random.get()) self.assertInResponse('OK') def test_random_off_without_quotes(self): - self.sendRequest('random 0') + self.send_request('random 0') self.assertFalse(self.core.tracklist.random.get()) self.assertInResponse('OK') def test_random_on(self): - self.sendRequest('random "1"') + self.send_request('random "1"') self.assertTrue(self.core.tracklist.random.get()) self.assertInResponse('OK') def test_random_on_without_quotes(self): - self.sendRequest('random 1') + self.send_request('random 1') self.assertTrue(self.core.tracklist.random.get()) self.assertInResponse('OK') def test_repeat_off(self): - self.sendRequest('repeat "0"') + self.send_request('repeat "0"') self.assertFalse(self.core.tracklist.repeat.get()) self.assertInResponse('OK') def test_repeat_off_without_quotes(self): - self.sendRequest('repeat 0') + self.send_request('repeat 0') self.assertFalse(self.core.tracklist.repeat.get()) self.assertInResponse('OK') def test_repeat_on(self): - self.sendRequest('repeat "1"') + self.send_request('repeat "1"') self.assertTrue(self.core.tracklist.repeat.get()) self.assertInResponse('OK') def test_repeat_on_without_quotes(self): - self.sendRequest('repeat 1') + self.send_request('repeat 1') self.assertTrue(self.core.tracklist.repeat.get()) self.assertInResponse('OK') def test_setvol_below_min(self): - self.sendRequest('setvol "-10"') - self.assertEqual(0, self.core.playback.volume.get()) + self.send_request('setvol "-10"') + self.assertEqual(0, self.core.mixer.get_volume().get()) self.assertInResponse('OK') def test_setvol_min(self): - self.sendRequest('setvol "0"') - self.assertEqual(0, self.core.playback.volume.get()) + self.send_request('setvol "0"') + self.assertEqual(0, self.core.mixer.get_volume().get()) self.assertInResponse('OK') def test_setvol_middle(self): - self.sendRequest('setvol "50"') - self.assertEqual(50, self.core.playback.volume.get()) + self.send_request('setvol "50"') + self.assertEqual(50, self.core.mixer.get_volume().get()) self.assertInResponse('OK') def test_setvol_max(self): - self.sendRequest('setvol "100"') - self.assertEqual(100, self.core.playback.volume.get()) + self.send_request('setvol "100"') + self.assertEqual(100, self.core.mixer.get_volume().get()) self.assertInResponse('OK') def test_setvol_above_max(self): - self.sendRequest('setvol "110"') - self.assertEqual(100, self.core.playback.volume.get()) + self.send_request('setvol "110"') + self.assertEqual(100, self.core.mixer.get_volume().get()) self.assertInResponse('OK') def test_setvol_plus_is_ignored(self): - self.sendRequest('setvol "+10"') - self.assertEqual(10, self.core.playback.volume.get()) + self.send_request('setvol "+10"') + self.assertEqual(10, self.core.mixer.get_volume().get()) self.assertInResponse('OK') def test_setvol_without_quotes(self): - self.sendRequest('setvol 50') - self.assertEqual(50, self.core.playback.volume.get()) + self.send_request('setvol 50') + self.assertEqual(50, self.core.mixer.get_volume().get()) self.assertInResponse('OK') def test_single_off(self): - self.sendRequest('single "0"') + self.send_request('single "0"') self.assertFalse(self.core.tracklist.single.get()) self.assertInResponse('OK') def test_single_off_without_quotes(self): - self.sendRequest('single 0') + self.send_request('single 0') self.assertFalse(self.core.tracklist.single.get()) self.assertInResponse('OK') def test_single_on(self): - self.sendRequest('single "1"') + self.send_request('single "1"') self.assertTrue(self.core.tracklist.single.get()) self.assertInResponse('OK') def test_single_on_without_quotes(self): - self.sendRequest('single 1') + self.send_request('single 1') self.assertTrue(self.core.tracklist.single.get()) self.assertInResponse('OK') def test_replay_gain_mode_off(self): - self.sendRequest('replay_gain_mode "off"') + self.send_request('replay_gain_mode "off"') self.assertInResponse('ACK [0@0] {replay_gain_mode} Not implemented') def test_replay_gain_mode_track(self): - self.sendRequest('replay_gain_mode "track"') + self.send_request('replay_gain_mode "track"') self.assertInResponse('ACK [0@0] {replay_gain_mode} Not implemented') def test_replay_gain_mode_album(self): - self.sendRequest('replay_gain_mode "album"') + self.send_request('replay_gain_mode "album"') self.assertInResponse('ACK [0@0] {replay_gain_mode} Not implemented') def test_replay_gain_status_default(self): - self.sendRequest('replay_gain_status') + self.send_request('replay_gain_status') self.assertInResponse('OK') self.assertInResponse('off') + def test_mixrampdb(self): + self.send_request('mixrampdb "10"') + self.assertInResponse('ACK [0@0] {mixrampdb} Not implemented') + + def test_mixrampdelay(self): + self.send_request('mixrampdelay "10"') + self.assertInResponse('ACK [0@0] {mixrampdelay} Not implemented') + @unittest.SkipTest def test_replay_gain_status_off(self): pass @@ -165,66 +173,66 @@ class PlaybackOptionsHandlerTest(protocol.BaseTestCase): class PlaybackControlHandlerTest(protocol.BaseTestCase): def test_next(self): - self.sendRequest('next') + self.send_request('next') self.assertInResponse('OK') def test_pause_off(self): self.core.tracklist.add([Track(uri='dummy:a')]) - self.sendRequest('play "0"') - self.sendRequest('pause "1"') - self.sendRequest('pause "0"') + self.send_request('play "0"') + self.send_request('pause "1"') + self.send_request('pause "0"') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse('OK') def test_pause_on(self): self.core.tracklist.add([Track(uri='dummy:a')]) - self.sendRequest('play "0"') - self.sendRequest('pause "1"') + self.send_request('play "0"') + self.send_request('pause "1"') self.assertEqual(PAUSED, self.core.playback.state.get()) self.assertInResponse('OK') def test_pause_toggle(self): self.core.tracklist.add([Track(uri='dummy:a')]) - self.sendRequest('play "0"') + self.send_request('play "0"') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse('OK') - self.sendRequest('pause') + self.send_request('pause') self.assertEqual(PAUSED, self.core.playback.state.get()) self.assertInResponse('OK') - self.sendRequest('pause') + self.send_request('pause') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse('OK') def test_play_without_pos(self): self.core.tracklist.add([Track(uri='dummy:a')]) - self.sendRequest('play') + self.send_request('play') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse('OK') def test_play_with_pos(self): self.core.tracklist.add([Track(uri='dummy:a')]) - self.sendRequest('play "0"') + self.send_request('play "0"') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse('OK') def test_play_with_pos_without_quotes(self): self.core.tracklist.add([Track(uri='dummy:a')]) - self.sendRequest('play 0') + self.send_request('play 0') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse('OK') def test_play_with_pos_out_of_bounds(self): self.core.tracklist.add([]) - self.sendRequest('play "0"') + self.send_request('play "0"') self.assertEqual(STOPPED, self.core.playback.state.get()) self.assertInResponse('ACK [2@0] {play} Bad song index') @@ -232,7 +240,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertEqual(self.core.playback.current_track.get(), None) self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) - self.sendRequest('play "-1"') + self.send_request('play "-1"') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertEqual( 'dummy:a', self.core.playback.current_track.get().uri) @@ -246,7 +254,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.core.playback.stop() self.assertNotEqual(self.core.playback.current_track.get(), None) - self.sendRequest('play "-1"') + self.send_request('play "-1"') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertEqual( 'dummy:b', self.core.playback.current_track.get().uri) @@ -255,7 +263,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): def test_play_minus_one_on_empty_playlist_does_not_ack(self): self.core.tracklist.clear() - self.sendRequest('play "-1"') + self.send_request('play "-1"') self.assertEqual(STOPPED, self.core.playback.state.get()) self.assertEqual(None, self.core.playback.current_track.get()) self.assertInResponse('OK') @@ -265,9 +273,9 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.core.playback.seek(30000) self.assertGreaterEqual( self.core.playback.time_position.get(), 30000) - self.assertEquals(PLAYING, self.core.playback.state.get()) + self.assertEqual(PLAYING, self.core.playback.state.get()) - self.sendRequest('play "-1"') + self.send_request('play "-1"') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertGreaterEqual( self.core.playback.time_position.get(), 30000) @@ -278,11 +286,11 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.core.playback.seek(30000) self.assertGreaterEqual( self.core.playback.time_position.get(), 30000) - self.assertEquals(PLAYING, self.core.playback.state.get()) + self.assertEqual(PLAYING, self.core.playback.state.get()) self.core.playback.pause() - self.assertEquals(PAUSED, self.core.playback.state.get()) + self.assertEqual(PAUSED, self.core.playback.state.get()) - self.sendRequest('play "-1"') + self.send_request('play "-1"') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertGreaterEqual( self.core.playback.time_position.get(), 30000) @@ -291,14 +299,14 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): def test_playid(self): self.core.tracklist.add([Track(uri='dummy:a')]) - self.sendRequest('playid "0"') + self.send_request('playid "0"') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse('OK') def test_playid_without_quotes(self): self.core.tracklist.add([Track(uri='dummy:a')]) - self.sendRequest('playid 0') + self.send_request('playid 0') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse('OK') @@ -306,7 +314,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertEqual(self.core.playback.current_track.get(), None) self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) - self.sendRequest('playid "-1"') + self.send_request('playid "-1"') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertEqual( 'dummy:a', self.core.playback.current_track.get().uri) @@ -320,7 +328,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.core.playback.stop() self.assertNotEqual(None, self.core.playback.current_track.get()) - self.sendRequest('playid "-1"') + self.send_request('playid "-1"') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertEqual( 'dummy:b', self.core.playback.current_track.get().uri) @@ -329,7 +337,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): def test_playid_minus_one_on_empty_playlist_does_not_ack(self): self.core.tracklist.clear() - self.sendRequest('playid "-1"') + self.send_request('playid "-1"') self.assertEqual(STOPPED, self.core.playback.state.get()) self.assertEqual(None, self.core.playback.current_track.get()) self.assertInResponse('OK') @@ -339,9 +347,9 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.core.playback.seek(30000) self.assertGreaterEqual( self.core.playback.time_position.get(), 30000) - self.assertEquals(PLAYING, self.core.playback.state.get()) + self.assertEqual(PLAYING, self.core.playback.state.get()) - self.sendRequest('playid "-1"') + self.send_request('playid "-1"') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertGreaterEqual( self.core.playback.time_position.get(), 30000) @@ -352,11 +360,11 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.core.playback.seek(30000) self.assertGreaterEqual( self.core.playback.time_position.get(), 30000) - self.assertEquals(PLAYING, self.core.playback.state.get()) + self.assertEqual(PLAYING, self.core.playback.state.get()) self.core.playback.pause() - self.assertEquals(PAUSED, self.core.playback.state.get()) + self.assertEqual(PAUSED, self.core.playback.state.get()) - self.sendRequest('playid "-1"') + self.send_request('playid "-1"') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertGreaterEqual( self.core.playback.time_position.get(), 30000) @@ -365,11 +373,11 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): def test_playid_which_does_not_exist(self): self.core.tracklist.add([Track(uri='dummy:a')]) - self.sendRequest('playid "12345"') + self.send_request('playid "12345"') self.assertInResponse('ACK [50@0] {playid} No such song') def test_previous(self): - self.sendRequest('previous') + self.send_request('previous') self.assertInResponse('OK') def test_seek_in_current_track(self): @@ -377,7 +385,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.core.tracklist.add([seek_track]) self.core.playback.play() - self.sendRequest('seek "0" "30"') + self.send_request('seek "0" "30"') self.assertEqual(self.core.playback.current_track.get(), seek_track) self.assertGreaterEqual(self.core.playback.time_position, 30000) @@ -390,7 +398,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.core.playback.play() self.assertNotEqual(self.core.playback.current_track.get(), seek_track) - self.sendRequest('seek "1" "30"') + self.send_request('seek "1" "30"') self.assertEqual(self.core.playback.current_track.get(), seek_track) self.assertInResponse('OK') @@ -399,7 +407,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) self.core.playback.play() - self.sendRequest('seek 0 30') + self.send_request('seek 0 30') self.assertGreaterEqual( self.core.playback.time_position.get(), 30000) self.assertInResponse('OK') @@ -409,7 +417,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.core.tracklist.add([seek_track]) self.core.playback.play() - self.sendRequest('seekid "0" "30"') + self.send_request('seekid "0" "30"') self.assertEqual(self.core.playback.current_track.get(), seek_track) self.assertGreaterEqual( @@ -422,7 +430,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): [Track(uri='dummy:a', length=40000), seek_track]) self.core.playback.play() - self.sendRequest('seekid "1" "30"') + self.send_request('seekid "1" "30"') self.assertEqual(1, self.core.playback.current_tl_track.get().tlid) self.assertEqual(seek_track, self.core.playback.current_track.get()) @@ -432,7 +440,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) self.core.playback.play() - self.sendRequest('seekcur "30"') + self.send_request('seekcur "30"') self.assertGreaterEqual(self.core.playback.time_position.get(), 30000) self.assertInResponse('OK') @@ -443,7 +451,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.core.playback.seek(10000) self.assertGreaterEqual(self.core.playback.time_position.get(), 10000) - self.sendRequest('seekcur "+20"') + self.send_request('seekcur "+20"') self.assertGreaterEqual(self.core.playback.time_position.get(), 30000) self.assertInResponse('OK') @@ -454,12 +462,20 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.core.playback.seek(30000) self.assertGreaterEqual(self.core.playback.time_position.get(), 30000) - self.sendRequest('seekcur "-20"') + self.send_request('seekcur "-20"') self.assertLessEqual(self.core.playback.time_position.get(), 15000) self.assertInResponse('OK') def test_stop(self): - self.sendRequest('stop') + self.send_request('stop') self.assertEqual(STOPPED, self.core.playback.state.get()) self.assertInResponse('OK') + + +class PlaybackOptionsHandlerNoneMixerTest(protocol.BaseTestCase): + enable_mixer = False + + def test_setvol_max_error(self): + self.send_request('setvol "100"') + self.assertInResponse('ACK [52@0] {setvol} problems setting volume') diff --git a/tests/mpd/protocol/test_reflection.py b/tests/mpd/protocol/test_reflection.py index 160c9876..5c44c464 100644 --- a/tests/mpd/protocol/test_reflection.py +++ b/tests/mpd/protocol/test_reflection.py @@ -1,16 +1,16 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals from tests.mpd import protocol class ReflectionHandlerTest(protocol.BaseTestCase): def test_config_is_not_allowed_across_the_network(self): - self.sendRequest('config') + self.send_request('config') self.assertEqualResponse( 'ACK [4@0] {config} you don\'t have permission for "config"') def test_commands_returns_list_of_all_commands(self): - self.sendRequest('commands') + self.send_request('commands') # Check if some random commands are included self.assertInResponse('command: commands') self.assertInResponse('command: play') @@ -28,22 +28,22 @@ class ReflectionHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_decoders(self): - self.sendRequest('decoders') + self.send_request('decoders') self.assertInResponse('OK') def test_notcommands_returns_only_config_and_kill_and_ok(self): - response = self.sendRequest('notcommands') + response = self.send_request('notcommands') self.assertEqual(3, len(response)) self.assertInResponse('command: config') self.assertInResponse('command: kill') self.assertInResponse('OK') def test_tagtypes(self): - self.sendRequest('tagtypes') + self.send_request('tagtypes') self.assertInResponse('OK') def test_urlhandlers(self): - self.sendRequest('urlhandlers') + self.send_request('urlhandlers') self.assertInResponse('OK') self.assertInResponse('handler: dummy') @@ -55,7 +55,7 @@ class ReflectionWhenNotAuthedTest(protocol.BaseTestCase): return config def test_commands_show_less_if_auth_required_and_not_authed(self): - self.sendRequest('commands') + self.send_request('commands') # Not requiring auth self.assertInResponse('command: close') self.assertInResponse('command: commands') @@ -67,7 +67,7 @@ class ReflectionWhenNotAuthedTest(protocol.BaseTestCase): self.assertNotInResponse('command: status') def test_notcommands_returns_more_if_auth_required_and_not_authed(self): - self.sendRequest('notcommands') + self.send_request('notcommands') # Not requiring auth self.assertNotInResponse('command: close') self.assertNotInResponse('command: commands') diff --git a/tests/mpd/protocol/test_regression.py b/tests/mpd/protocol/test_regression.py index 3389573f..6fb59afd 100644 --- a/tests/mpd/protocol/test_regression.py +++ b/tests/mpd/protocol/test_regression.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import random @@ -28,22 +28,22 @@ class IssueGH17RegressionTest(protocol.BaseTestCase): ]) random.seed(1) # Playlist order: abcfde - self.sendRequest('play') - self.assertEquals( + self.send_request('play') + self.assertEqual( 'dummy:a', self.core.playback.current_track.get().uri) - self.sendRequest('random "1"') - self.sendRequest('next') - self.assertEquals( + self.send_request('random "1"') + self.send_request('next') + self.assertEqual( 'dummy:b', self.core.playback.current_track.get().uri) - self.sendRequest('next') + self.send_request('next') # Should now be at track 'c', but playback fails and it skips ahead - self.assertEquals( + self.assertEqual( 'dummy:f', self.core.playback.current_track.get().uri) - self.sendRequest('next') - self.assertEquals( + self.send_request('next') + self.assertEqual( 'dummy:d', self.core.playback.current_track.get().uri) - self.sendRequest('next') - self.assertEquals( + self.send_request('next') + self.assertEqual( 'dummy:e', self.core.playback.current_track.get().uri) @@ -64,17 +64,17 @@ class IssueGH18RegressionTest(protocol.BaseTestCase): Track(uri='dummy:d'), Track(uri='dummy:e'), Track(uri='dummy:f')]) random.seed(1) - self.sendRequest('play') - self.sendRequest('random "1"') - self.sendRequest('next') - self.sendRequest('random "0"') - self.sendRequest('next') + self.send_request('play') + self.send_request('random "1"') + self.send_request('next') + self.send_request('random "0"') + self.send_request('next') - self.sendRequest('next') + self.send_request('next') tl_track_1 = self.core.playback.current_tl_track.get() - self.sendRequest('next') + self.send_request('next') tl_track_2 = self.core.playback.current_tl_track.get() - self.sendRequest('next') + self.send_request('next') tl_track_3 = self.core.playback.current_tl_track.get() self.assertNotEqual(tl_track_1, tl_track_2) @@ -100,15 +100,15 @@ class IssueGH22RegressionTest(protocol.BaseTestCase): Track(uri='dummy:d'), Track(uri='dummy:e'), Track(uri='dummy:f')]) random.seed(1) - self.sendRequest('play') - self.sendRequest('random "1"') - self.sendRequest('deleteid "1"') - self.sendRequest('deleteid "2"') - self.sendRequest('deleteid "3"') - self.sendRequest('deleteid "4"') - self.sendRequest('deleteid "5"') - self.sendRequest('deleteid "6"') - self.sendRequest('status') + self.send_request('play') + self.send_request('random "1"') + self.send_request('deleteid "1"') + self.send_request('deleteid "2"') + self.send_request('deleteid "3"') + self.send_request('deleteid "4"') + self.send_request('deleteid "5"') + self.send_request('deleteid "6"') + self.send_request('status') class IssueGH69RegressionTest(protocol.BaseTestCase): @@ -128,10 +128,10 @@ class IssueGH69RegressionTest(protocol.BaseTestCase): Track(uri='dummy:a'), Track(uri='dummy:b'), Track(uri='dummy:c'), Track(uri='dummy:d'), Track(uri='dummy:e'), Track(uri='dummy:f')]) - self.sendRequest('play') - self.sendRequest('stop') - self.sendRequest('clear') - self.sendRequest('load "foo"') + self.send_request('play') + self.send_request('stop') + self.send_request('clear') + self.send_request('load "foo"') self.assertNotInResponse('song: None') @@ -151,11 +151,11 @@ class IssueGH113RegressionTest(protocol.BaseTestCase): self.core.playlists.create( u'all lart spotify:track:\w\{22\} pastes') - self.sendRequest('lsinfo "/"') + self.send_request('lsinfo "/"') self.assertInResponse( u'playlist: all lart spotify:track:\w\{22\} pastes') - self.sendRequest( + self.send_request( r'listplaylistinfo "all lart spotify:track:\\w\\{22\\} pastes"') self.assertInResponse('OK') @@ -170,7 +170,7 @@ class IssueGH137RegressionTest(protocol.BaseTestCase): """ def test(self): - self.sendRequest( + self.send_request( u'list Date Artist "Anita Ward" ' u'Album "This Is Remixed Hits - Mashups & Rare 12" Mixes"') diff --git a/tests/mpd/protocol/test_status.py b/tests/mpd/protocol/test_status.py index 7d30ea89..09df3526 100644 --- a/tests/mpd/protocol/test_status.py +++ b/tests/mpd/protocol/test_status.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals from mopidy.models import Track @@ -7,14 +7,14 @@ from tests.mpd import protocol class StatusHandlerTest(protocol.BaseTestCase): def test_clearerror(self): - self.sendRequest('clearerror') + self.send_request('clearerror') self.assertEqualResponse('ACK [0@0] {clearerror} Not implemented') def test_currentsong(self): track = Track() self.core.tracklist.add([track]) self.core.playback.play() - self.sendRequest('currentsong') + self.send_request('currentsong') self.assertInResponse('file: ') self.assertInResponse('Time: 0') self.assertInResponse('Artist: ') @@ -27,13 +27,13 @@ class StatusHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_currentsong_without_song(self): - self.sendRequest('currentsong') + self.send_request('currentsong') self.assertInResponse('OK') def test_stats_command(self): - self.sendRequest('stats') + self.send_request('stats') self.assertInResponse('OK') def test_status_command(self): - self.sendRequest('status') + self.send_request('status') self.assertInResponse('OK') diff --git a/tests/mpd/protocol/test_stickers.py b/tests/mpd/protocol/test_stickers.py index c3ce264a..0844c461 100644 --- a/tests/mpd/protocol/test_stickers.py +++ b/tests/mpd/protocol/test_stickers.py @@ -1,35 +1,35 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals from tests.mpd import protocol class StickersHandlerTest(protocol.BaseTestCase): def test_sticker_get(self): - self.sendRequest( + self.send_request( 'sticker get "song" "file:///dev/urandom" "a_name"') self.assertEqualResponse('ACK [0@0] {sticker} Not implemented') def test_sticker_set(self): - self.sendRequest( + self.send_request( 'sticker set "song" "file:///dev/urandom" "a_name" "a_value"') self.assertEqualResponse('ACK [0@0] {sticker} Not implemented') def test_sticker_delete_with_name(self): - self.sendRequest( + self.send_request( 'sticker delete "song" "file:///dev/urandom" "a_name"') self.assertEqualResponse('ACK [0@0] {sticker} Not implemented') def test_sticker_delete_without_name(self): - self.sendRequest( + self.send_request( 'sticker delete "song" "file:///dev/urandom"') self.assertEqualResponse('ACK [0@0] {sticker} Not implemented') def test_sticker_list(self): - self.sendRequest( + self.send_request( 'sticker list "song" "file:///dev/urandom"') self.assertEqualResponse('ACK [0@0] {sticker} Not implemented') def test_sticker_find(self): - self.sendRequest( + self.send_request( 'sticker find "song" "file:///dev/urandom" "a_name"') self.assertEqualResponse('ACK [0@0] {sticker} Not implemented') diff --git a/tests/mpd/protocol/test_stored_playlists.py b/tests/mpd/protocol/test_stored_playlists.py index 56011435..cca32b0d 100644 --- a/tests/mpd/protocol/test_stored_playlists.py +++ b/tests/mpd/protocol/test_stored_playlists.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals from mopidy.models import Playlist, Track @@ -7,69 +7,69 @@ from tests.mpd import protocol class PlaylistsHandlerTest(protocol.BaseTestCase): def test_listplaylist(self): - self.backend.playlists.playlists = [ + self.backend.playlists.set_dummy_playlists([ Playlist( - name='name', uri='dummy:name', tracks=[Track(uri='dummy:a')])] + name='name', uri='dummy:name', tracks=[Track(uri='dummy:a')])]) - self.sendRequest('listplaylist "name"') + self.send_request('listplaylist "name"') self.assertInResponse('file: dummy:a') self.assertInResponse('OK') def test_listplaylist_without_quotes(self): - self.backend.playlists.playlists = [ + self.backend.playlists.set_dummy_playlists([ Playlist( - name='name', uri='dummy:name', tracks=[Track(uri='dummy:a')])] + name='name', uri='dummy:name', tracks=[Track(uri='dummy:a')])]) - self.sendRequest('listplaylist name') + self.send_request('listplaylist name') self.assertInResponse('file: dummy:a') self.assertInResponse('OK') def test_listplaylist_fails_if_no_playlist_is_found(self): - self.sendRequest('listplaylist "name"') + self.send_request('listplaylist "name"') self.assertEqualResponse('ACK [50@0] {listplaylist} No such playlist') def test_listplaylist_duplicate(self): playlist1 = Playlist(name='a', uri='dummy:a1', tracks=[Track(uri='b')]) playlist2 = Playlist(name='a', uri='dummy:a2', tracks=[Track(uri='c')]) - self.backend.playlists.playlists = [playlist1, playlist2] + self.backend.playlists.set_dummy_playlists([playlist1, playlist2]) - self.sendRequest('listplaylist "a [2]"') + self.send_request('listplaylist "a [2]"') self.assertInResponse('file: c') self.assertInResponse('OK') def test_listplaylistinfo(self): - self.backend.playlists.playlists = [ + self.backend.playlists.set_dummy_playlists([ Playlist( - name='name', uri='dummy:name', tracks=[Track(uri='dummy:a')])] + name='name', uri='dummy:name', tracks=[Track(uri='dummy:a')])]) - self.sendRequest('listplaylistinfo "name"') + self.send_request('listplaylistinfo "name"') self.assertInResponse('file: dummy:a') self.assertInResponse('Track: 0') self.assertNotInResponse('Pos: 0') self.assertInResponse('OK') def test_listplaylistinfo_without_quotes(self): - self.backend.playlists.playlists = [ + self.backend.playlists.set_dummy_playlists([ Playlist( - name='name', uri='dummy:name', tracks=[Track(uri='dummy:a')])] + name='name', uri='dummy:name', tracks=[Track(uri='dummy:a')])]) - self.sendRequest('listplaylistinfo name') + self.send_request('listplaylistinfo name') self.assertInResponse('file: dummy:a') self.assertInResponse('Track: 0') self.assertNotInResponse('Pos: 0') self.assertInResponse('OK') def test_listplaylistinfo_fails_if_no_playlist_is_found(self): - self.sendRequest('listplaylistinfo "name"') + self.send_request('listplaylistinfo "name"') self.assertEqualResponse( 'ACK [50@0] {listplaylistinfo} No such playlist') def test_listplaylistinfo_duplicate(self): playlist1 = Playlist(name='a', uri='dummy:a1', tracks=[Track(uri='b')]) playlist2 = Playlist(name='a', uri='dummy:a2', tracks=[Track(uri='c')]) - self.backend.playlists.playlists = [playlist1, playlist2] + self.backend.playlists.set_dummy_playlists([playlist1, playlist2]) - self.sendRequest('listplaylistinfo "a [2]"') + self.send_request('listplaylistinfo "a [2]"') self.assertInResponse('file: c') self.assertInResponse('Track: 0') self.assertNotInResponse('Pos: 0') @@ -77,10 +77,10 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): def test_listplaylists(self): last_modified = 1390942873222 - self.backend.playlists.playlists = [ - Playlist(name='a', uri='dummy:a', last_modified=last_modified)] + self.backend.playlists.set_dummy_playlists([ + Playlist(name='a', uri='dummy:a', last_modified=last_modified)]) - self.sendRequest('listplaylists') + self.send_request('listplaylists') self.assertInResponse('playlist: a') # Date without milliseconds and with time zone information self.assertInResponse('Last-Modified: 2014-01-28T21:01:13Z') @@ -89,54 +89,54 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): def test_listplaylists_duplicate(self): playlist1 = Playlist(name='a', uri='dummy:a1') playlist2 = Playlist(name='a', uri='dummy:a2') - self.backend.playlists.playlists = [playlist1, playlist2] + self.backend.playlists.set_dummy_playlists([playlist1, playlist2]) - self.sendRequest('listplaylists') + self.send_request('listplaylists') self.assertInResponse('playlist: a') self.assertInResponse('playlist: a [2]') self.assertInResponse('OK') def test_listplaylists_ignores_playlists_without_name(self): last_modified = 1390942873222 - self.backend.playlists.playlists = [ - Playlist(name='', uri='dummy:', last_modified=last_modified)] + self.backend.playlists.set_dummy_playlists([ + Playlist(name='', uri='dummy:', last_modified=last_modified)]) - self.sendRequest('listplaylists') + self.send_request('listplaylists') self.assertNotInResponse('playlist: ') self.assertInResponse('OK') def test_listplaylists_replaces_newline_with_space(self): - self.backend.playlists.playlists = [ - Playlist(name='a\n', uri='dummy:')] - self.sendRequest('listplaylists') + self.backend.playlists.set_dummy_playlists([ + Playlist(name='a\n', uri='dummy:')]) + self.send_request('listplaylists') self.assertInResponse('playlist: a ') self.assertNotInResponse('playlist: a\n') self.assertInResponse('OK') def test_listplaylists_replaces_carriage_return_with_space(self): - self.backend.playlists.playlists = [ - Playlist(name='a\r', uri='dummy:')] - self.sendRequest('listplaylists') + self.backend.playlists.set_dummy_playlists([ + Playlist(name='a\r', uri='dummy:')]) + self.send_request('listplaylists') self.assertInResponse('playlist: a ') self.assertNotInResponse('playlist: a\r') self.assertInResponse('OK') - def test_listplaylists_replaces_forward_slash_with_space(self): - self.backend.playlists.playlists = [ - Playlist(name='a/', uri='dummy:')] - self.sendRequest('listplaylists') - self.assertInResponse('playlist: a ') - self.assertNotInResponse('playlist: a/') + def test_listplaylists_replaces_forward_slash_with_pipe(self): + self.backend.playlists.set_dummy_playlists([ + Playlist(name='a/b', uri='dummy:')]) + self.send_request('listplaylists') + self.assertInResponse('playlist: a|b') + self.assertNotInResponse('playlist: a/b') self.assertInResponse('OK') def test_load_appends_to_tracklist(self): self.core.tracklist.add([Track(uri='a'), Track(uri='b')]) self.assertEqual(len(self.core.tracklist.tracks.get()), 2) - self.backend.playlists.playlists = [ + self.backend.playlists.set_dummy_playlists([ Playlist(name='A-list', uri='dummy:A-list', tracks=[ - Track(uri='c'), Track(uri='d'), Track(uri='e')])] + Track(uri='c'), Track(uri='d'), Track(uri='e')])]) - self.sendRequest('load "A-list"') + self.send_request('load "A-list"') tracks = self.core.tracklist.tracks.get() self.assertEqual(5, len(tracks)) @@ -150,11 +150,11 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): def test_load_with_range_loads_part_of_playlist(self): self.core.tracklist.add([Track(uri='a'), Track(uri='b')]) self.assertEqual(len(self.core.tracklist.tracks.get()), 2) - self.backend.playlists.playlists = [ + self.backend.playlists.set_dummy_playlists([ Playlist(name='A-list', uri='dummy:A-list', tracks=[ - Track(uri='c'), Track(uri='d'), Track(uri='e')])] + Track(uri='c'), Track(uri='d'), Track(uri='e')])]) - self.sendRequest('load "A-list" "1:2"') + self.send_request('load "A-list" "1:2"') tracks = self.core.tracklist.tracks.get() self.assertEqual(3, len(tracks)) @@ -166,11 +166,11 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): def test_load_with_range_without_end_loads_rest_of_playlist(self): self.core.tracklist.add([Track(uri='a'), Track(uri='b')]) self.assertEqual(len(self.core.tracklist.tracks.get()), 2) - self.backend.playlists.playlists = [ + self.backend.playlists.set_dummy_playlists([ Playlist(name='A-list', uri='dummy:A-list', tracks=[ - Track(uri='c'), Track(uri='d'), Track(uri='e')])] + Track(uri='c'), Track(uri='d'), Track(uri='e')])]) - self.sendRequest('load "A-list" "1:"') + self.send_request('load "A-list" "1:"') tracks = self.core.tracklist.tracks.get() self.assertEqual(4, len(tracks)) @@ -181,34 +181,34 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_load_unknown_playlist_acks(self): - self.sendRequest('load "unknown playlist"') + self.send_request('load "unknown playlist"') self.assertEqual(0, len(self.core.tracklist.tracks.get())) self.assertEqualResponse('ACK [50@0] {load} No such playlist') def test_playlistadd(self): - self.sendRequest('playlistadd "name" "dummy:a"') + self.send_request('playlistadd "name" "dummy:a"') self.assertEqualResponse('ACK [0@0] {playlistadd} Not implemented') def test_playlistclear(self): - self.sendRequest('playlistclear "name"') + self.send_request('playlistclear "name"') self.assertEqualResponse('ACK [0@0] {playlistclear} Not implemented') def test_playlistdelete(self): - self.sendRequest('playlistdelete "name" "5"') + self.send_request('playlistdelete "name" "5"') self.assertEqualResponse('ACK [0@0] {playlistdelete} Not implemented') def test_playlistmove(self): - self.sendRequest('playlistmove "name" "5" "10"') + self.send_request('playlistmove "name" "5" "10"') self.assertEqualResponse('ACK [0@0] {playlistmove} Not implemented') def test_rename(self): - self.sendRequest('rename "old_name" "new_name"') + self.send_request('rename "old_name" "new_name"') self.assertEqualResponse('ACK [0@0] {rename} Not implemented') def test_rm(self): - self.sendRequest('rm "name"') + self.send_request('rm "name"') self.assertEqualResponse('ACK [0@0] {rm} Not implemented') def test_save(self): - self.sendRequest('save "name"') + self.send_request('save "name"') self.assertEqualResponse('ACK [0@0] {save} Not implemented') diff --git a/tests/mpd/test_commands.py b/tests/mpd/test_commands.py index 2b4205fe..a281d10e 100644 --- a/tests/mpd/test_commands.py +++ b/tests/mpd/test_commands.py @@ -1,6 +1,6 @@ # encoding: utf-8 -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import unittest @@ -55,7 +55,7 @@ class TestConverts(unittest.TestCase): class TestCommands(unittest.TestCase): - def setUp(self): + def setUp(self): # noqa: N802 self.commands = protocol.Commands() def test_add_as_a_decorator(self): @@ -64,7 +64,8 @@ class TestCommands(unittest.TestCase): pass def test_register_second_command_to_same_name_fails(self): - func = lambda context: True + def func(context): + pass self.commands.add('foo')(func) with self.assertRaises(Exception): @@ -88,7 +89,10 @@ class TestCommands(unittest.TestCase): def test_function_has_required_and_optional_args_succeeds(self): sentinel = object() - func = lambda context, required, optional=None: sentinel + + def func(context, required, optional=None): + return sentinel + self.commands.add('bar')(func) self.assertEqual(sentinel, self.commands.call(['bar', 'arg'])) self.assertEqual(sentinel, self.commands.call(['bar', 'arg', 'arg'])) @@ -111,12 +115,16 @@ class TestCommands(unittest.TestCase): def test_function_has_required_and_varargs_fails(self): with self.assertRaises(TypeError): - func = lambda context, required, *args: True + def func(context, required, *args): + pass + self.commands.add('test')(func) def test_function_has_optional_and_varargs_fails(self): with self.assertRaises(TypeError): - func = lambda context, optional=None, *args: True + def func(context, optional=None, *args): + pass + self.commands.add('test')(func) def test_function_hash_keywordargs_fails(self): @@ -158,7 +166,9 @@ class TestCommands(unittest.TestCase): self.assertEqual('test', self.commands.call(['foo', 'test'])) def test_call_passes_required_and_optional_argument(self): - func = lambda context, required, optional=None: (required, optional) + def func(context, required, optional=None): + return (required, optional) + self.commands.add('foo')(func) self.assertEqual(('arg', None), self.commands.call(['foo', 'arg'])) self.assertEqual( @@ -182,20 +192,29 @@ class TestCommands(unittest.TestCase): def test_validator_gets_applied_to_required_arg(self): sentinel = object() - func = lambda context, required: required + + def func(context, required): + return required + self.commands.add('test', required=lambda v: sentinel)(func) self.assertEqual(sentinel, self.commands.call(['test', 'foo'])) def test_validator_gets_applied_to_optional_arg(self): sentinel = object() - func = lambda context, optional=None: optional + + def func(context, optional=None): + return optional + self.commands.add('foo', optional=lambda v: sentinel)(func) self.assertEqual(sentinel, self.commands.call(['foo', '123'])) def test_validator_skips_optional_default(self): sentinel = object() - func = lambda context, optional=sentinel: optional + + def func(context, optional=sentinel): + return optional + self.commands.add('foo', optional=lambda v: None)(func) self.assertEqual(sentinel, self.commands.call(['foo'])) @@ -203,28 +222,38 @@ class TestCommands(unittest.TestCase): def test_validator_applied_to_non_existent_arg_fails(self): self.commands.add('foo')(lambda context, arg: arg) with self.assertRaises(TypeError): - func = lambda context, wrong_arg: wrong_arg + def func(context, wrong_arg): + return wrong_arg + self.commands.add('bar', arg=lambda v: v)(func) def test_validator_called_context_fails(self): return # TODO: how to handle this with self.assertRaises(TypeError): - func = lambda context: True + def func(context): + pass + self.commands.add('bar', context=lambda v: v)(func) def test_validator_value_error_is_converted(self): def validdate(value): raise ValueError - func = lambda context, arg: True + def func(context, arg): + pass + self.commands.add('bar', arg=validdate)(func) with self.assertRaises(exceptions.MpdArgError): self.commands.call(['bar', 'test']) def test_auth_required_gets_stored(self): - func1 = lambda context: context - func2 = lambda context: context + def func1(context): + pass + + def func2(context): + pass + self.commands.add('foo')(func1) self.commands.add('bar', auth_required=False)(func2) @@ -232,8 +261,12 @@ class TestCommands(unittest.TestCase): self.assertFalse(self.commands.handlers['bar'].auth_required) def test_list_command_gets_stored(self): - func1 = lambda context: context - func2 = lambda context: context + def func1(context): + pass + + def func2(context): + pass + self.commands.add('foo')(func1) self.commands.add('bar', list_command=False)(func2) diff --git a/tests/mpd/test_dispatcher.py b/tests/mpd/test_dispatcher.py index cee4531a..d6b11e43 100644 --- a/tests/mpd/test_dispatcher.py +++ b/tests/mpd/test_dispatcher.py @@ -1,38 +1,44 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import unittest import pykka from mopidy import core -from mopidy.backend import dummy from mopidy.mpd.dispatcher import MpdDispatcher from mopidy.mpd.exceptions import MpdAckError +from tests import dummy_backend + class MpdDispatcherTest(unittest.TestCase): - def setUp(self): + def setUp(self): # noqa: N802 config = { 'mpd': { 'password': None, + 'command_blacklist': ['disabled'], } } - self.backend = dummy.create_dummy_backend_proxy() + self.backend = dummy_backend.create_proxy() self.core = core.Core.start(backends=[self.backend]).proxy() self.dispatcher = MpdDispatcher(config=config) - def tearDown(self): + def tearDown(self): # noqa: N802 pykka.ActorRegistry.stop_all() def test_call_handler_for_unknown_command_raises_exception(self): - try: + with self.assertRaises(MpdAckError) as cm: self.dispatcher._call_handler('an_unknown_command with args') - self.fail('Should raise exception') - except MpdAckError as e: - self.assertEqual( - e.get_mpd_ack(), - 'ACK [5@0] {} unknown command "an_unknown_command"') + + self.assertEqual( + cm.exception.get_mpd_ack(), + 'ACK [5@0] {} unknown command "an_unknown_command"') def test_handling_unknown_request_yields_error(self): result = self.dispatcher.handle_request('an unhandled request') self.assertEqual(result[0], 'ACK [5@0] {} unknown command "an"') + + def test_handling_blacklisted_command(self): + result = self.dispatcher.handle_request('disabled') + self.assertEqual(result[0], 'ACK [0@0] {disabled} "disabled" has been ' + 'disabled in the server') diff --git a/tests/mpd/test_exceptions.py b/tests/mpd/test_exceptions.py index 7f50c41b..7bb64096 100644 --- a/tests/mpd/test_exceptions.py +++ b/tests/mpd/test_exceptions.py @@ -1,10 +1,10 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import unittest from mopidy.mpd.exceptions import ( - MpdAckError, MpdNoCommand, MpdNotImplemented, MpdPermissionError, - MpdSystemError, MpdUnknownCommand) + MpdAckError, MpdNoCommand, MpdNoExistError, MpdNotImplemented, + MpdPermissionError, MpdSystemError, MpdUnknownCommand) class MpdExceptionsTest(unittest.TestCase): @@ -13,7 +13,7 @@ class MpdExceptionsTest(unittest.TestCase): try: raise KeyError('Track X not found') except KeyError as e: - raise MpdAckError(e[0]) + raise MpdAckError(e.message) except MpdAckError as e: self.assertEqual(e.message, 'Track X not found') @@ -61,3 +61,11 @@ class MpdExceptionsTest(unittest.TestCase): self.assertEqual( e.get_mpd_ack(), 'ACK [4@0] {foo} you don\'t have permission for "foo"') + + def test_mpd_noexist_error(self): + try: + raise MpdNoExistError(command='foo') + except MpdNoExistError as e: + self.assertEqual( + e.get_mpd_ack(), + 'ACK [50@0] {foo} ') diff --git a/tests/mpd/test_status.py b/tests/mpd/test_status.py index cd910340..e130353b 100644 --- a/tests/mpd/test_status.py +++ b/tests/mpd/test_status.py @@ -1,16 +1,18 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import unittest import pykka from mopidy import core -from mopidy.backend import dummy from mopidy.core import PlaybackState from mopidy.models import Track from mopidy.mpd import dispatcher from mopidy.mpd.protocol import status +from tests import dummy_backend, dummy_mixer + + PAUSED = PlaybackState.PAUSED PLAYING = PlaybackState.PLAYING STOPPED = PlaybackState.STOPPED @@ -20,13 +22,15 @@ STOPPED = PlaybackState.STOPPED class StatusHandlerTest(unittest.TestCase): - def setUp(self): - self.backend = dummy.create_dummy_backend_proxy() - self.core = core.Core.start(backends=[self.backend]).proxy() + def setUp(self): # noqa: N802 + self.mixer = dummy_mixer.create_proxy() + self.backend = dummy_backend.create_proxy() + self.core = core.Core.start( + mixer=self.mixer, backends=[self.backend]).proxy() self.dispatcher = dispatcher.MpdDispatcher(core=self.core) self.context = self.dispatcher.context - def tearDown(self): + def tearDown(self): # noqa: N802 pykka.ActorRegistry.stop_all() def test_stats_method(self): @@ -52,7 +56,7 @@ class StatusHandlerTest(unittest.TestCase): self.assertEqual(int(result['volume']), -1) def test_status_method_contains_volume(self): - self.core.playback.volume = 17 + self.core.mixer.set_volume(17) result = dict(status.status(self.context)) self.assertIn('volume', result) self.assertEqual(int(result['volume']), 17) @@ -98,7 +102,8 @@ class StatusHandlerTest(unittest.TestCase): def test_status_method_contains_playlist(self): result = dict(status.status(self.context)) self.assertIn('playlist', result) - self.assertIn(int(result['playlist']), xrange(0, 2 ** 31 - 1)) + self.assertGreaterEqual(int(result['playlist']), 0) + self.assertLessEqual(int(result['playlist']), 2 ** 31 - 1) def test_status_method_contains_playlistlength(self): result = dict(status.status(self.context)) diff --git a/tests/mpd/test_tokenizer.py b/tests/mpd/test_tokenizer.py index 01ecd17d..b4d46719 100644 --- a/tests/mpd/test_tokenizer.py +++ b/tests/mpd/test_tokenizer.py @@ -1,6 +1,6 @@ # encoding: utf-8 -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import unittest @@ -8,10 +8,10 @@ from mopidy.mpd import exceptions, tokenize class TestTokenizer(unittest.TestCase): - def assertTokenizeEquals(self, expected, line): + def assertTokenizeEquals(self, expected, line): # noqa: N802 self.assertEqual(expected, tokenize.split(line)) - def assertTokenizeRaises(self, exception, message, line): + def assertTokenizeRaises(self, exception, message, line): # noqa: N802 with self.assertRaises(exception) as cm: tokenize.split(line) self.assertEqual(cm.exception.message, message) diff --git a/tests/mpd/test_translator.py b/tests/mpd/test_translator.py index 05a0d187..527cfef8 100644 --- a/tests/mpd/test_translator.py +++ b/tests/mpd/test_translator.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import datetime import unittest @@ -26,14 +26,16 @@ class TrackMpdFormatTest(unittest.TestCase): length=137000, ) - def setUp(self): + def setUp(self): # noqa: N802 self.media_dir = '/dir/subdir' mtime.set_fake_time(1234567) - def tearDown(self): + def tearDown(self): # noqa: N802 mtime.undo_fake() def test_track_to_mpd_format_for_empty_track(self): + # TODO: this is likely wrong, see: + # https://github.com/mopidy/mopidy/issues/923#issuecomment-79584110 result = translator.track_to_mpd_format(Track()) self.assertIn(('file', ''), result) self.assertIn(('Time', 0), result) diff --git a/tests/data/find/.hidden/.gitignore b/tests/stream/__init__.py similarity index 100% rename from tests/data/find/.hidden/.gitignore rename to tests/stream/__init__.py diff --git a/tests/stream/test_library.py b/tests/stream/test_library.py new file mode 100644 index 00000000..93292376 --- /dev/null +++ b/tests/stream/test_library.py @@ -0,0 +1,43 @@ +from __future__ import absolute_import, unicode_literals + +import unittest + +import gobject +gobject.threads_init() + +import pygst +pygst.require('0.10') +import gst # noqa: pygst magic is needed to import correct gst + +import mock + +from mopidy.models import Track +from mopidy.stream import actor +from mopidy.utils.path import path_to_uri + +from tests import path_to_data_dir + + +class LibraryProviderTest(unittest.TestCase): + def setUp(self): # noqa: N802 + self.backend = mock.Mock() + self.backend.uri_schemes = ['file'] + self.uri = path_to_uri(path_to_data_dir('song1.wav')) + + def test_lookup_ignores_unknown_scheme(self): + library = actor.StreamLibraryProvider(self.backend, 1000, [], {}) + self.assertFalse(library.lookup('http://example.com')) + + def test_lookup_respects_blacklist(self): + library = actor.StreamLibraryProvider(self.backend, 10, [self.uri], {}) + self.assertEqual([Track(uri=self.uri)], library.lookup(self.uri)) + + def test_lookup_respects_blacklist_globbing(self): + blacklist = [path_to_uri(path_to_data_dir('')) + '*'] + library = actor.StreamLibraryProvider(self.backend, 100, blacklist, {}) + self.assertEqual([Track(uri=self.uri)], library.lookup(self.uri)) + + def test_lookup_converts_uri_metadata_to_track(self): + library = actor.StreamLibraryProvider(self.backend, 100, [], {}) + self.assertEqual([Track(length=4406, uri=self.uri)], + library.lookup(self.uri)) diff --git a/tests/test_commands.py b/tests/test_commands.py index 9d3d0bda..0942b3a0 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import argparse import unittest @@ -35,24 +35,21 @@ class ConfigOverrideTypeTest(unittest.TestCase): expected, commands.config_override_type(b'section/key= ')) def test_invalid_override(self): - self.assertRaises( - argparse.ArgumentTypeError, - commands.config_override_type, b'section/key') - self.assertRaises( - argparse.ArgumentTypeError, - commands.config_override_type, b'section=') - self.assertRaises( - argparse.ArgumentTypeError, - commands.config_override_type, b'section') + with self.assertRaises(argparse.ArgumentTypeError): + commands.config_override_type(b'section/key') + with self.assertRaises(argparse.ArgumentTypeError): + commands.config_override_type(b'section=') + with self.assertRaises(argparse.ArgumentTypeError): + commands.config_override_type(b'section') class CommandParsingTest(unittest.TestCase): - def setUp(self): + def setUp(self): # noqa: N802 self.exit_patcher = mock.patch.object(commands.Command, 'exit') self.exit_mock = self.exit_patcher.start() self.exit_mock.side_effect = SystemExit - def tearDown(self): + def tearDown(self): # noqa: N802 self.exit_patcher.stop() def test_command_parsing_returns_namespace(self): diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index 47b3080d..3420891e 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import unittest @@ -20,6 +20,16 @@ class ExceptionsTest(unittest.TestCase): self.assert_(issubclass( exceptions.ExtensionError, exceptions.MopidyException)) + def test_find_error_is_a_mopidy_exception(self): + self.assert_(issubclass( + exceptions.FindError, exceptions.MopidyException)) + + def test_find_error_can_store_an_errno(self): + exc = exceptions.FindError('msg', errno=1234) + + self.assertEqual(exc.message, 'msg') + self.assertEqual(exc.errno, 1234) + def test_frontend_error_is_a_mopidy_exception(self): self.assert_(issubclass( exceptions.FrontendError, exceptions.MopidyException)) @@ -31,3 +41,7 @@ class ExceptionsTest(unittest.TestCase): def test_scanner_error_is_a_mopidy_exception(self): self.assert_(issubclass( exceptions.ScannerError, exceptions.MopidyException)) + + def test_audio_error_is_a_mopidy_exception(self): + self.assert_(issubclass( + exceptions.AudioException, exceptions.MopidyException)) diff --git a/tests/test_ext.py b/tests/test_ext.py index 428f3712..f4e247b6 100644 --- a/tests/test_ext.py +++ b/tests/test_ext.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import unittest @@ -6,7 +6,7 @@ from mopidy import config, ext class ExtensionTest(unittest.TestCase): - def setUp(self): + def setUp(self): # noqa: N802 self.ext = ext.Extension() def test_dist_name_is_none(self): diff --git a/tests/test_help.py b/tests/test_help.py index 6499cac1..d8058cb7 100644 --- a/tests/test_help.py +++ b/tests/test_help.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import os import subprocess diff --git a/tests/test_mixer.py b/tests/test_mixer.py index 53c10292..c57d861a 100644 --- a/tests/test_mixer.py +++ b/tests/test_mixer.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import unittest @@ -8,7 +8,7 @@ from mopidy import mixer class MixerListenerTest(unittest.TestCase): - def setUp(self): + def setUp(self): # noqa: N802 self.listener = mixer.MixerListener() def test_on_event_forwards_to_specific_handler(self): diff --git a/tests/test_models.py b/tests/test_models.py index 56d6c76b..7711f00d 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,11 +1,11 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import json import unittest from mopidy.models import ( - Album, Artist, ModelJSONEncoder, Playlist, Ref, SearchResult, TlTrack, - Track, model_json_decoder) + Album, Artist, Image, ModelJSONEncoder, Playlist, Ref, SearchResult, + TlTrack, Track, model_json_decoder) class GenericCopyTest(unittest.TestCase): @@ -49,12 +49,12 @@ class GenericCopyTest(unittest.TestCase): self.assertIn(artist2, copy.artists) def test_copying_track_with_invalid_key(self): - test = lambda: Track().copy(invalid_key=True) - self.assertRaises(TypeError, test) + with self.assertRaises(TypeError): + Track().copy(invalid_key=True) def test_copying_track_to_remove(self): track = Track(name='foo').copy(name=None) - self.assertEquals(track.__dict__, Track().__dict__) + self.assertEqual(track.__dict__, Track().__dict__) class RefTest(unittest.TestCase): @@ -62,20 +62,22 @@ class RefTest(unittest.TestCase): uri = 'an_uri' ref = Ref(uri=uri) self.assertEqual(ref.uri, uri) - self.assertRaises(AttributeError, setattr, ref, 'uri', None) + with self.assertRaises(AttributeError): + ref.uri = None def test_name(self): name = 'a name' ref = Ref(name=name) self.assertEqual(ref.name, name) - self.assertRaises(AttributeError, setattr, ref, 'name', None) + with self.assertRaises(AttributeError): + ref.name = None def test_invalid_kwarg(self): - test = lambda: SearchResult(foo='baz') - self.assertRaises(TypeError, test) + with self.assertRaises(TypeError): + Ref(foo='baz') def test_repr_without_results(self): - self.assertEquals( + self.assertEqual( "Ref(name=u'foo', type=u'artist', uri=u'uri')", repr(Ref(uri='uri', name='foo', type='artist'))) @@ -128,39 +130,66 @@ class RefTest(unittest.TestCase): self.assertEqual(ref.type, Ref.TRACK) +class ImageTest(unittest.TestCase): + def test_uri(self): + uri = 'an_uri' + image = Image(uri=uri) + self.assertEqual(image.uri, uri) + with self.assertRaises(AttributeError): + image.uri = None + + def test_width(self): + image = Image(width=100) + self.assertEqual(image.width, 100) + with self.assertRaises(AttributeError): + image.width = None + + def test_height(self): + image = Image(height=100) + self.assertEqual(image.height, 100) + with self.assertRaises(AttributeError): + image.height = None + + def test_invalid_kwarg(self): + with self.assertRaises(TypeError): + Image(foo='baz') + + class ArtistTest(unittest.TestCase): def test_uri(self): uri = 'an_uri' artist = Artist(uri=uri) self.assertEqual(artist.uri, uri) - self.assertRaises(AttributeError, setattr, artist, 'uri', None) + with self.assertRaises(AttributeError): + artist.uri = None def test_name(self): name = 'a name' artist = Artist(name=name) self.assertEqual(artist.name, name) - self.assertRaises(AttributeError, setattr, artist, 'name', None) + with self.assertRaises(AttributeError): + artist.name = None def test_musicbrainz_id(self): mb_id = 'mb-id' artist = Artist(musicbrainz_id=mb_id) self.assertEqual(artist.musicbrainz_id, mb_id) - self.assertRaises( - AttributeError, setattr, artist, 'musicbrainz_id', None) + with self.assertRaises(AttributeError): + artist.musicbrainz_id = None def test_invalid_kwarg(self): - test = lambda: Artist(foo='baz') - self.assertRaises(TypeError, test) + with self.assertRaises(TypeError): + Artist(foo='baz') def test_invalid_kwarg_with_name_matching_method(self): - test = lambda: Artist(copy='baz') - self.assertRaises(TypeError, test) + with self.assertRaises(TypeError): + Artist(copy='baz') - test = lambda: Artist(serialize='baz') - self.assertRaises(TypeError, test) + with self.assertRaises(TypeError): + Artist(serialize='baz') def test_repr(self): - self.assertEquals( + self.assertEqual( "Artist(name=u'name', uri=u'uri')", repr(Artist(uri='uri', name='name'))) @@ -184,22 +213,22 @@ class ArtistTest(unittest.TestCase): artist = Artist(uri='uri', name='name').serialize() artist['foo'] = 'foo' serialized = json.dumps(artist) - test = lambda: json.loads(serialized, object_hook=model_json_decoder) - self.assertRaises(TypeError, test) + with self.assertRaises(TypeError): + json.loads(serialized, object_hook=model_json_decoder) def test_to_json_and_back_with_field_matching_method(self): artist = Artist(uri='uri', name='name').serialize() artist['copy'] = 'foo' serialized = json.dumps(artist) - test = lambda: json.loads(serialized, object_hook=model_json_decoder) - self.assertRaises(TypeError, test) + with self.assertRaises(TypeError): + json.loads(serialized, object_hook=model_json_decoder) def test_to_json_and_back_with_field_matching_internal_field(self): artist = Artist(uri='uri', name='name').serialize() artist['__mro__'] = 'foo' serialized = json.dumps(artist) - test = lambda: json.loads(serialized, object_hook=model_json_decoder) - self.assertRaises(TypeError, test) + with self.assertRaises(TypeError): + json.loads(serialized, object_hook=model_json_decoder) def test_eq_name(self): artist1 = Artist(name='name') @@ -261,19 +290,22 @@ class AlbumTest(unittest.TestCase): uri = 'an_uri' album = Album(uri=uri) self.assertEqual(album.uri, uri) - self.assertRaises(AttributeError, setattr, album, 'uri', None) + with self.assertRaises(AttributeError): + album.uri = None def test_name(self): name = 'a name' album = Album(name=name) self.assertEqual(album.name, name) - self.assertRaises(AttributeError, setattr, album, 'name', None) + with self.assertRaises(AttributeError): + album.name = None def test_artists(self): artist = Artist() album = Album(artists=[artist]) self.assertIn(artist, album.artists) - self.assertRaises(AttributeError, setattr, album, 'artists', None) + with self.assertRaises(AttributeError): + album.artists = None def test_artists_none(self): self.assertEqual(set(), Album(artists=None).artists) @@ -282,47 +314,51 @@ class AlbumTest(unittest.TestCase): num_tracks = 11 album = Album(num_tracks=num_tracks) self.assertEqual(album.num_tracks, num_tracks) - self.assertRaises(AttributeError, setattr, album, 'num_tracks', None) + with self.assertRaises(AttributeError): + album.num_tracks = None def test_num_discs(self): num_discs = 2 album = Album(num_discs=num_discs) self.assertEqual(album.num_discs, num_discs) - self.assertRaises(AttributeError, setattr, album, 'num_discs', None) + with self.assertRaises(AttributeError): + album.num_discs = None def test_date(self): date = '1977-01-01' album = Album(date=date) self.assertEqual(album.date, date) - self.assertRaises(AttributeError, setattr, album, 'date', None) + with self.assertRaises(AttributeError): + album.date = None def test_musicbrainz_id(self): mb_id = 'mb-id' album = Album(musicbrainz_id=mb_id) self.assertEqual(album.musicbrainz_id, mb_id) - self.assertRaises( - AttributeError, setattr, album, 'musicbrainz_id', None) + with self.assertRaises(AttributeError): + album.musicbrainz_id = None def test_images(self): image = 'data:foobar' album = Album(images=[image]) self.assertIn(image, album.images) - self.assertRaises(AttributeError, setattr, album, 'images', None) + with self.assertRaises(AttributeError): + album.images = None def test_images_none(self): self.assertEqual(set(), Album(images=None).images) def test_invalid_kwarg(self): - test = lambda: Album(foo='baz') - self.assertRaises(TypeError, test) + with self.assertRaises(TypeError): + Album(foo='baz') def test_repr_without_artists(self): - self.assertEquals( + self.assertEqual( "Album(name=u'name', uri=u'uri')", repr(Album(uri='uri', name='name'))) def test_repr_with_artists(self): - self.assertEquals( + self.assertEqual( "Album(artists=[Artist(name=u'foo')], name=u'name', uri=u'uri')", repr(Album(uri='uri', name='name', artists=[Artist(name='foo')]))) @@ -466,19 +502,22 @@ class TrackTest(unittest.TestCase): uri = 'an_uri' track = Track(uri=uri) self.assertEqual(track.uri, uri) - self.assertRaises(AttributeError, setattr, track, 'uri', None) + with self.assertRaises(AttributeError): + track.uri = None def test_name(self): name = 'a name' track = Track(name=name) self.assertEqual(track.name, name) - self.assertRaises(AttributeError, setattr, track, 'name', None) + with self.assertRaises(AttributeError): + track.name = None def test_artists(self): artists = [Artist(name='name1'), Artist(name='name2')] track = Track(artists=artists) self.assertEqual(set(track.artists), set(artists)) - self.assertRaises(AttributeError, setattr, track, 'artists', None) + with self.assertRaises(AttributeError): + track.artists = None def test_artists_none(self): self.assertEqual(set(), Track(artists=None).artists) @@ -487,7 +526,8 @@ class TrackTest(unittest.TestCase): artists = [Artist(name='name1'), Artist(name='name2')] track = Track(composers=artists) self.assertEqual(set(track.composers), set(artists)) - self.assertRaises(AttributeError, setattr, track, 'composers', None) + with self.assertRaises(AttributeError): + track.composers = None def test_composers_none(self): self.assertEqual(set(), Track(composers=None).composers) @@ -496,7 +536,8 @@ class TrackTest(unittest.TestCase): artists = [Artist(name='name1'), Artist(name='name2')] track = Track(performers=artists) self.assertEqual(set(track.performers), set(artists)) - self.assertRaises(AttributeError, setattr, track, 'performers', None) + with self.assertRaises(AttributeError): + track.performers = None def test_performers_none(self): self.assertEqual(set(), Track(performers=None).performers) @@ -505,56 +546,62 @@ class TrackTest(unittest.TestCase): album = Album() track = Track(album=album) self.assertEqual(track.album, album) - self.assertRaises(AttributeError, setattr, track, 'album', None) + with self.assertRaises(AttributeError): + track.album = None def test_track_no(self): track_no = 7 track = Track(track_no=track_no) self.assertEqual(track.track_no, track_no) - self.assertRaises(AttributeError, setattr, track, 'track_no', None) + with self.assertRaises(AttributeError): + track.track_no = None def test_disc_no(self): disc_no = 2 track = Track(disc_no=disc_no) self.assertEqual(track.disc_no, disc_no) - self.assertRaises(AttributeError, setattr, track, 'disc_no', None) + with self.assertRaises(AttributeError): + track.disc_no = None def test_date(self): date = '1977-01-01' track = Track(date=date) self.assertEqual(track.date, date) - self.assertRaises(AttributeError, setattr, track, 'date', None) + with self.assertRaises(AttributeError): + track.date = None def test_length(self): length = 137000 track = Track(length=length) self.assertEqual(track.length, length) - self.assertRaises(AttributeError, setattr, track, 'length', None) + with self.assertRaises(AttributeError): + track.length = None def test_bitrate(self): bitrate = 160 track = Track(bitrate=bitrate) self.assertEqual(track.bitrate, bitrate) - self.assertRaises(AttributeError, setattr, track, 'bitrate', None) + with self.assertRaises(AttributeError): + track.bitrate = None def test_musicbrainz_id(self): mb_id = 'mb-id' track = Track(musicbrainz_id=mb_id) self.assertEqual(track.musicbrainz_id, mb_id) - self.assertRaises( - AttributeError, setattr, track, 'musicbrainz_id', None) + with self.assertRaises(AttributeError): + track.musicbrainz_id = None def test_invalid_kwarg(self): - test = lambda: Track(foo='baz') - self.assertRaises(TypeError, test) + with self.assertRaises(TypeError): + Track(foo='baz') def test_repr_without_artists(self): - self.assertEquals( + self.assertEqual( "Track(name=u'name', uri=u'uri')", repr(Track(uri='uri', name='name'))) def test_repr_with_artists(self): - self.assertEquals( + self.assertEqual( "Track(artists=[Artist(name=u'foo')], name=u'name', uri=u'uri')", repr(Track(uri='uri', name='name', artists=[Artist(name='foo')]))) @@ -753,17 +800,19 @@ class TlTrackTest(unittest.TestCase): tlid = 123 tl_track = TlTrack(tlid=tlid) self.assertEqual(tl_track.tlid, tlid) - self.assertRaises(AttributeError, setattr, tl_track, 'tlid', None) + with self.assertRaises(AttributeError): + tl_track.tlid = None def test_track(self): track = Track() tl_track = TlTrack(track=track) self.assertEqual(tl_track.track, track) - self.assertRaises(AttributeError, setattr, tl_track, 'track', None) + with self.assertRaises(AttributeError): + tl_track.track = None def test_invalid_kwarg(self): - test = lambda: TlTrack(foo='baz') - self.assertRaises(TypeError, test) + with self.assertRaises(TypeError): + TlTrack(foo='baz') def test_positional_args(self): tlid = 123 @@ -781,7 +830,7 @@ class TlTrackTest(unittest.TestCase): self.assertEqual(track2, track) def test_repr(self): - self.assertEquals( + self.assertEqual( "TlTrack(tlid=123, track=Track(uri=u'uri'))", repr(TlTrack(tlid=123, track=Track(uri='uri')))) @@ -829,19 +878,22 @@ class PlaylistTest(unittest.TestCase): uri = 'an_uri' playlist = Playlist(uri=uri) self.assertEqual(playlist.uri, uri) - self.assertRaises(AttributeError, setattr, playlist, 'uri', None) + with self.assertRaises(AttributeError): + playlist.uri = None def test_name(self): name = 'a name' playlist = Playlist(name=name) self.assertEqual(playlist.name, name) - self.assertRaises(AttributeError, setattr, playlist, 'name', None) + with self.assertRaises(AttributeError): + playlist.name = None def test_tracks(self): tracks = [Track(), Track(), Track()] playlist = Playlist(tracks=tracks) self.assertEqual(list(playlist.tracks), tracks) - self.assertRaises(AttributeError, setattr, playlist, 'tracks', None) + with self.assertRaises(AttributeError): + playlist.tracks = None def test_length(self): tracks = [Track(), Track(), Track()] @@ -852,8 +904,8 @@ class PlaylistTest(unittest.TestCase): last_modified = 1390942873000 playlist = Playlist(last_modified=last_modified) self.assertEqual(playlist.last_modified, last_modified) - self.assertRaises( - AttributeError, setattr, playlist, 'last_modified', None) + with self.assertRaises(AttributeError): + playlist.last_modified = None def test_with_new_uri(self): tracks = [Track()] @@ -906,16 +958,16 @@ class PlaylistTest(unittest.TestCase): self.assertEqual(new_playlist.last_modified, new_last_modified) def test_invalid_kwarg(self): - test = lambda: Playlist(foo='baz') - self.assertRaises(TypeError, test) + with self.assertRaises(TypeError): + Playlist(foo='baz') def test_repr_without_tracks(self): - self.assertEquals( + self.assertEqual( "Playlist(name=u'name', uri=u'uri')", repr(Playlist(uri='uri', name='name'))) def test_repr_with_tracks(self): - self.assertEquals( + self.assertEqual( "Playlist(name=u'name', tracks=[Track(name=u'foo')], uri=u'uri')", repr(Playlist(uri='uri', name='name', tracks=[Track(name='foo')]))) @@ -1017,32 +1069,36 @@ class SearchResultTest(unittest.TestCase): uri = 'an_uri' result = SearchResult(uri=uri) self.assertEqual(result.uri, uri) - self.assertRaises(AttributeError, setattr, result, 'uri', None) + with self.assertRaises(AttributeError): + result.uri = None def test_tracks(self): tracks = [Track(), Track(), Track()] result = SearchResult(tracks=tracks) self.assertEqual(list(result.tracks), tracks) - self.assertRaises(AttributeError, setattr, result, 'tracks', None) + with self.assertRaises(AttributeError): + result.tracks = None def test_artists(self): artists = [Artist(), Artist(), Artist()] result = SearchResult(artists=artists) self.assertEqual(list(result.artists), artists) - self.assertRaises(AttributeError, setattr, result, 'artists', None) + with self.assertRaises(AttributeError): + result.artists = None def test_albums(self): albums = [Album(), Album(), Album()] result = SearchResult(albums=albums) self.assertEqual(list(result.albums), albums) - self.assertRaises(AttributeError, setattr, result, 'albums', None) + with self.assertRaises(AttributeError): + result.albums = None def test_invalid_kwarg(self): - test = lambda: SearchResult(foo='baz') - self.assertRaises(TypeError, test) + with self.assertRaises(TypeError): + SearchResult(foo='baz') def test_repr_without_results(self): - self.assertEquals( + self.assertEqual( "SearchResult(uri=u'uri')", repr(SearchResult(uri='uri'))) diff --git a/tests/test_version.py b/tests/test_version.py index 7e3922ca..932cc639 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -1,55 +1,59 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import unittest -from distutils.version import StrictVersion as SV +from distutils.version import StrictVersion from mopidy import __version__ class VersionTest(unittest.TestCase): + def assertVersionLess(self, first, second): # noqa: N802 + self.assertLess(StrictVersion(first), StrictVersion(second)) + def test_current_version_is_parsable_as_a_strict_version_number(self): - SV(__version__) + StrictVersion(__version__) def test_versions_can_be_strictly_ordered(self): - self.assertLess(SV('0.1.0a0'), SV('0.1.0a1')) - self.assertLess(SV('0.1.0a1'), SV('0.1.0a2')) - self.assertLess(SV('0.1.0a2'), SV('0.1.0a3')) - self.assertLess(SV('0.1.0a3'), SV('0.1.0')) - self.assertLess(SV('0.1.0'), SV('0.2.0')) - self.assertLess(SV('0.1.0'), SV('1.0.0')) - self.assertLess(SV('0.2.0'), SV('0.3.0')) - self.assertLess(SV('0.3.0'), SV('0.3.1')) - self.assertLess(SV('0.3.1'), SV('0.4.0')) - self.assertLess(SV('0.4.0'), SV('0.4.1')) - self.assertLess(SV('0.4.1'), SV('0.5.0')) - self.assertLess(SV('0.5.0'), SV('0.6.0')) - self.assertLess(SV('0.6.0'), SV('0.6.1')) - self.assertLess(SV('0.6.1'), SV('0.7.0')) - self.assertLess(SV('0.7.0'), SV('0.7.1')) - self.assertLess(SV('0.7.1'), SV('0.7.2')) - self.assertLess(SV('0.7.2'), SV('0.7.3')) - self.assertLess(SV('0.7.3'), SV('0.8.0')) - self.assertLess(SV('0.8.0'), SV('0.8.1')) - self.assertLess(SV('0.8.1'), SV('0.9.0')) - 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('0.12.0')) - self.assertLess(SV('0.12.0'), SV('0.13.0')) - self.assertLess(SV('0.13.0'), SV('0.14.0')) - self.assertLess(SV('0.14.0'), SV('0.14.1')) - self.assertLess(SV('0.14.1'), SV('0.14.2')) - self.assertLess(SV('0.14.2'), SV('0.15.0')) - self.assertLess(SV('0.15.0'), SV('0.16.0')) - self.assertLess(SV('0.16.0'), SV('0.17.0')) - self.assertLess(SV('0.17.0'), SV('0.18.0')) - self.assertLess(SV('0.18.0'), SV('0.18.1')) - self.assertLess(SV('0.18.1'), SV('0.18.2')) - self.assertLess(SV('0.18.2'), SV('0.18.3')) - self.assertLess(SV('0.18.3'), SV('0.19.0')) - self.assertLess(SV('0.19.0'), SV('0.19.1')) - self.assertLess(SV('0.19.1'), SV('0.19.2')) - self.assertLess(SV('0.19.2'), SV('0.19.3')) - self.assertLess(SV('0.19.3'), SV('0.19.4')) - self.assertLess(SV('0.19.4'), SV(__version__)) - self.assertLess(SV(__version__), SV('0.19.6')) + self.assertVersionLess('0.1.0a0', '0.1.0a1') + self.assertVersionLess('0.1.0a1', '0.1.0a2') + self.assertVersionLess('0.1.0a2', '0.1.0a3') + self.assertVersionLess('0.1.0a3', '0.1.0') + self.assertVersionLess('0.1.0', '0.2.0') + self.assertVersionLess('0.1.0', '1.0.0') + self.assertVersionLess('0.2.0', '0.3.0') + self.assertVersionLess('0.3.0', '0.3.1') + self.assertVersionLess('0.3.1', '0.4.0') + self.assertVersionLess('0.4.0', '0.4.1') + self.assertVersionLess('0.4.1', '0.5.0') + self.assertVersionLess('0.5.0', '0.6.0') + self.assertVersionLess('0.6.0', '0.6.1') + self.assertVersionLess('0.6.1', '0.7.0') + self.assertVersionLess('0.7.0', '0.7.1') + self.assertVersionLess('0.7.1', '0.7.2') + self.assertVersionLess('0.7.2', '0.7.3') + self.assertVersionLess('0.7.3', '0.8.0') + self.assertVersionLess('0.8.0', '0.8.1') + self.assertVersionLess('0.8.1', '0.9.0') + self.assertVersionLess('0.9.0', '0.10.0') + self.assertVersionLess('0.10.0', '0.11.0') + self.assertVersionLess('0.11.0', '0.11.1') + self.assertVersionLess('0.11.1', '0.12.0') + self.assertVersionLess('0.12.0', '0.13.0') + self.assertVersionLess('0.13.0', '0.14.0') + self.assertVersionLess('0.14.0', '0.14.1') + self.assertVersionLess('0.14.1', '0.14.2') + self.assertVersionLess('0.14.2', '0.15.0') + self.assertVersionLess('0.15.0', '0.16.0') + self.assertVersionLess('0.16.0', '0.17.0') + self.assertVersionLess('0.17.0', '0.18.0') + self.assertVersionLess('0.18.0', '0.18.1') + self.assertVersionLess('0.18.1', '0.18.2') + self.assertVersionLess('0.18.2', '0.18.3') + self.assertVersionLess('0.18.3', '0.19.0') + self.assertVersionLess('0.19.0', '0.19.1') + self.assertVersionLess('0.19.1', '0.19.2') + self.assertVersionLess('0.19.2', '0.19.3') + self.assertVersionLess('0.19.3', '0.19.4') + self.assertVersionLess('0.19.4', '0.19.5') + self.assertVersionLess('0.19.5', __version__) + self.assertVersionLess(__version__, '1.0.1') diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py index baffc488..01e6d4f4 100644 --- a/tests/utils/__init__.py +++ b/tests/utils/__init__.py @@ -1 +1 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals diff --git a/tests/utils/network/__init__.py b/tests/utils/network/__init__.py index baffc488..01e6d4f4 100644 --- a/tests/utils/network/__init__.py +++ b/tests/utils/network/__init__.py @@ -1 +1 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals diff --git a/tests/utils/network/test_connection.py b/tests/utils/network/test_connection.py index c3200689..0ccaea0a 100644 --- a/tests/utils/network/test_connection.py +++ b/tests/utils/network/test_connection.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import errno import logging @@ -17,7 +17,7 @@ from tests import any_int, any_unicode class ConnectionTest(unittest.TestCase): - def setUp(self): + def setUp(self): # noqa: N802 self.mock = Mock(spec=network.Connection) def test_init_ensure_nonblocking_io(self): diff --git a/tests/utils/network/test_lineprotocol.py b/tests/utils/network/test_lineprotocol.py index d7db67f6..1b584e47 100644 --- a/tests/utils/network/test_lineprotocol.py +++ b/tests/utils/network/test_lineprotocol.py @@ -1,19 +1,20 @@ # encoding: utf-8 -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import re import unittest from mock import Mock, sentinel +from mopidy import compat from mopidy.utils import network from tests import any_unicode class LineProtocolTest(unittest.TestCase): - def setUp(self): + def setUp(self): # noqa: N802 self.mock = Mock(spec=network.LineProtocol) self.mock.terminator = network.LineProtocol.terminator @@ -124,14 +125,16 @@ class LineProtocolTest(unittest.TestCase): self.mock.recv_buffer = '' lines = network.LineProtocol.parse_lines(self.mock) - self.assertRaises(StopIteration, lines.next) + with self.assertRaises(StopIteration): + lines.next() def test_parse_lines_no_terminator(self): self.mock.delimiter = re.compile(r'\n') self.mock.recv_buffer = 'data' lines = network.LineProtocol.parse_lines(self.mock) - self.assertRaises(StopIteration, lines.next) + with self.assertRaises(StopIteration): + lines.next() def test_parse_lines_termintor(self): self.mock.delimiter = re.compile(r'\n') @@ -139,7 +142,8 @@ class LineProtocolTest(unittest.TestCase): lines = network.LineProtocol.parse_lines(self.mock) self.assertEqual('data', lines.next()) - self.assertRaises(StopIteration, lines.next) + with self.assertRaises(StopIteration): + lines.next() self.assertEqual('', self.mock.recv_buffer) def test_parse_lines_termintor_with_carriage_return(self): @@ -148,7 +152,8 @@ class LineProtocolTest(unittest.TestCase): lines = network.LineProtocol.parse_lines(self.mock) self.assertEqual('data', lines.next()) - self.assertRaises(StopIteration, lines.next) + with self.assertRaises(StopIteration): + lines.next() self.assertEqual('', self.mock.recv_buffer) def test_parse_lines_no_data_before_terminator(self): @@ -157,7 +162,8 @@ class LineProtocolTest(unittest.TestCase): lines = network.LineProtocol.parse_lines(self.mock) self.assertEqual('', lines.next()) - self.assertRaises(StopIteration, lines.next) + with self.assertRaises(StopIteration): + lines.next() self.assertEqual('', self.mock.recv_buffer) def test_parse_lines_extra_data_after_terminator(self): @@ -166,7 +172,8 @@ class LineProtocolTest(unittest.TestCase): lines = network.LineProtocol.parse_lines(self.mock) self.assertEqual('data1', lines.next()) - self.assertRaises(StopIteration, lines.next) + with self.assertRaises(StopIteration): + lines.next() self.assertEqual('data2', self.mock.recv_buffer) def test_parse_lines_unicode(self): @@ -175,7 +182,8 @@ class LineProtocolTest(unittest.TestCase): lines = network.LineProtocol.parse_lines(self.mock) self.assertEqual('æøå'.encode('utf-8'), lines.next()) - self.assertRaises(StopIteration, lines.next) + with self.assertRaises(StopIteration): + lines.next() self.assertEqual('', self.mock.recv_buffer) def test_parse_lines_multiple_lines(self): @@ -186,7 +194,8 @@ class LineProtocolTest(unittest.TestCase): self.assertEqual('abc', lines.next()) self.assertEqual('def', lines.next()) self.assertEqual('ghi', lines.next()) - self.assertRaises(StopIteration, lines.next) + with self.assertRaises(StopIteration): + lines.next() self.assertEqual('jkl', self.mock.recv_buffer) def test_parse_lines_multiple_calls(self): @@ -194,14 +203,16 @@ class LineProtocolTest(unittest.TestCase): self.mock.recv_buffer = 'data1' lines = network.LineProtocol.parse_lines(self.mock) - self.assertRaises(StopIteration, lines.next) + with self.assertRaises(StopIteration): + lines.next() self.assertEqual('data1', self.mock.recv_buffer) self.mock.recv_buffer += '\ndata2' lines = network.LineProtocol.parse_lines(self.mock) self.assertEqual('data1', lines.next()) - self.assertRaises(StopIteration, lines.next) + with self.assertRaises(StopIteration): + lines.next() self.assertEqual('data2', self.mock.recv_buffer) def test_send_lines_called_with_no_lines(self): @@ -249,13 +260,13 @@ class LineProtocolTest(unittest.TestCase): def test_decode_plain_ascii(self): result = network.LineProtocol.decode(self.mock, 'abc') self.assertEqual('abc', result) - self.assertEqual(unicode, type(result)) + self.assertEqual(compat.text_type, type(result)) def test_decode_utf8(self): result = network.LineProtocol.decode( self.mock, 'æøå'.encode('utf-8')) self.assertEqual('æøå', result) - self.assertEqual(unicode, type(result)) + self.assertEqual(compat.text_type, type(result)) def test_decode_invalid_data(self): string = Mock() diff --git a/tests/utils/network/test_server.py b/tests/utils/network/test_server.py index c5b8c41a..d85d6c27 100644 --- a/tests/utils/network/test_server.py +++ b/tests/utils/network/test_server.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import errno import socket @@ -14,7 +14,7 @@ from tests import any_int class ServerTest(unittest.TestCase): - def setUp(self): + def setUp(self): # noqa: N802 self.mock = Mock(spec=network.Server) def test_init_calls_create_server_socket(self): @@ -38,9 +38,9 @@ class ServerTest(unittest.TestCase): sock.fileno.side_effect = socket.error self.mock.create_server_socket.return_value = sock - self.assertRaises( - socket.error, network.Server.__init__, self.mock, sentinel.host, - sentinel.port, sentinel.protocol) + with self.assertRaises(socket.error): + network.Server.__init__( + self.mock, sentinel.host, sentinel.port, sentinel.protocol) def test_init_stores_values_in_attributes(self): # This need to be a mock and no a sentinel as fileno() is called on it @@ -68,27 +68,27 @@ class ServerTest(unittest.TestCase): @patch.object(network, 'create_socket', new=Mock()) def test_create_server_socket_fails(self): network.create_socket.side_effect = socket.error - self.assertRaises( - socket.error, network.Server.create_server_socket, self.mock, - sentinel.host, sentinel.port) + with self.assertRaises(socket.error): + network.Server.create_server_socket( + self.mock, sentinel.host, sentinel.port) @patch.object(network, 'create_socket', new=Mock()) def test_create_server_bind_fails(self): sock = network.create_socket.return_value sock.bind.side_effect = socket.error - self.assertRaises( - socket.error, network.Server.create_server_socket, self.mock, - sentinel.host, sentinel.port) + with self.assertRaises(socket.error): + network.Server.create_server_socket( + self.mock, sentinel.host, sentinel.port) @patch.object(network, 'create_socket', new=Mock()) def test_create_server_listen_fails(self): sock = network.create_socket.return_value sock.listen.side_effect = socket.error - self.assertRaises( - socket.error, network.Server.create_server_socket, self.mock, - sentinel.host, sentinel.port) + with self.assertRaises(socket.error): + network.Server.create_server_socket( + self.mock, sentinel.host, sentinel.port) @patch.object(gobject, 'io_add_watch', new=Mock()) def test_register_server_socket_sets_up_io_watch(self): @@ -137,17 +137,16 @@ class ServerTest(unittest.TestCase): for error in (errno.EAGAIN, errno.EINTR): sock.accept.side_effect = socket.error(error, '') - self.assertRaises( - network.ShouldRetrySocketCall, - network.Server.accept_connection, self.mock) + with self.assertRaises(network.ShouldRetrySocketCall): + network.Server.accept_connection(self.mock) # FIXME decide if this should be allowed to propegate def test_accept_connection_unrecoverable_error(self): sock = Mock(spec=socket.SocketType) self.mock.server_socket = sock sock.accept.side_effect = socket.error - self.assertRaises( - socket.error, network.Server.accept_connection, self.mock) + with self.assertRaises(socket.error): + network.Server.accept_connection(self.mock) def test_maximum_connections_exceeded(self): self.mock.max_connections = 10 diff --git a/tests/utils/network/test_utils.py b/tests/utils/network/test_utils.py index d0886cfc..d5f558b4 100644 --- a/tests/utils/network/test_utils.py +++ b/tests/utils/network/test_utils.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import socket import unittest diff --git a/tests/utils/test_deps.py b/tests/utils/test_deps.py index 103f478c..95f5b982 100644 --- a/tests/utils/test_deps.py +++ b/tests/utils/test_deps.py @@ -1,6 +1,7 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import platform +import sys import unittest import mock @@ -46,16 +47,22 @@ class DepsTest(unittest.TestCase): self.assertIn(' pylast: 0.5', result) self.assertIn(' setuptools: 0.6', result) + def test_executable_info(self): + result = deps.executable_info() + + self.assertEqual('Executable', result['name']) + self.assertIn(sys.argv[0], result['version']) + def test_platform_info(self): result = deps.platform_info() - self.assertEquals('Platform', result['name']) + self.assertEqual('Platform', result['name']) self.assertIn(platform.platform(), result['version']) def test_python_info(self): result = deps.python_info() - self.assertEquals('Python', result['name']) + self.assertEqual('Python', result['name']) self.assertIn(platform.python_implementation(), result['version']) self.assertIn(platform.python_version(), result['version']) self.assertIn('python', result['path']) @@ -64,8 +71,8 @@ class DepsTest(unittest.TestCase): def test_gstreamer_info(self): result = deps.gstreamer_info() - self.assertEquals('GStreamer', result['name']) - self.assertEquals( + self.assertEqual('GStreamer', result['name']) + self.assertEqual( '.'.join(map(str, gst.get_gst_version())), result['version']) self.assertIn('gst', result['path']) self.assertNotIn('__init__.py', result['path']) @@ -99,17 +106,17 @@ class DepsTest(unittest.TestCase): result = deps.pkg_info() - self.assertEquals('Mopidy', result['name']) - self.assertEquals('0.13', result['version']) + self.assertEqual('Mopidy', result['name']) + self.assertEqual('0.13', result['version']) self.assertIn('mopidy', result['path']) dep_info_pykka = result['dependencies'][0] - self.assertEquals('Pykka', dep_info_pykka['name']) - self.assertEquals('1.1', dep_info_pykka['version']) + self.assertEqual('Pykka', dep_info_pykka['name']) + self.assertEqual('1.1', dep_info_pykka['version']) dep_info_setuptools = dep_info_pykka['dependencies'][0] - self.assertEquals('setuptools', dep_info_setuptools['name']) - self.assertEquals('0.6', dep_info_setuptools['version']) + self.assertEqual('setuptools', dep_info_setuptools['name']) + self.assertEqual('0.6', dep_info_setuptools['version']) @mock.patch('pkg_resources.get_distribution') def test_pkg_info_for_missing_dist(self, get_distribution_mock): @@ -117,7 +124,7 @@ class DepsTest(unittest.TestCase): result = deps.pkg_info() - self.assertEquals('Mopidy', result['name']) + self.assertEqual('Mopidy', result['name']) self.assertNotIn('version', result) self.assertNotIn('path', result) @@ -127,6 +134,6 @@ class DepsTest(unittest.TestCase): result = deps.pkg_info() - self.assertEquals('Mopidy', result['name']) + self.assertEqual('Mopidy', result['name']) self.assertNotIn('version', result) self.assertNotIn('path', result) diff --git a/tests/utils/test_encoding.py b/tests/utils/test_encoding.py index 912f38c0..68634855 100644 --- a/tests/utils/test_encoding.py +++ b/tests/utils/test_encoding.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import unittest diff --git a/tests/utils/test_jsonrpc.py b/tests/utils/test_jsonrpc.py index c8d37d04..fb59d06b 100644 --- a/tests/utils/test_jsonrpc.py +++ b/tests/utils/test_jsonrpc.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import json import unittest @@ -8,11 +8,15 @@ import mock import pykka from mopidy import core, models -from mopidy.backend import dummy from mopidy.utils import jsonrpc +from tests import dummy_backend + class Calculator(object): + def __init__(self): + self._mem = None + def model(self): return 'TI83' @@ -23,6 +27,12 @@ class Calculator(object): def sub(self, a, b): return a - b + def set_mem(self, value): + self._mem = value + + def get_mem(self): + return self._mem + def describe(self): return { 'add': 'Returns the sum of the terms', @@ -40,14 +50,15 @@ class Calculator(object): class JsonRpcTestBase(unittest.TestCase): - def setUp(self): - self.backend = dummy.create_dummy_backend_proxy() + def setUp(self): # noqa: N802 + self.backend = dummy_backend.create_proxy() self.core = core.Core.start(backends=[self.backend]).proxy() + self.calc = Calculator() self.jrw = jsonrpc.JsonRpcWrapper( objects={ 'hello': lambda: 'Hello, world!', - 'calc': Calculator(), + 'calc': self.calc, 'core': self.core, 'core.playback': self.core.playback, 'core.tracklist': self.core.tracklist, @@ -56,14 +67,14 @@ class JsonRpcTestBase(unittest.TestCase): encoders=[models.ModelJSONEncoder], decoders=[models.model_json_decoder]) - def tearDown(self): + def tearDown(self): # noqa: N802 pykka.ActorRegistry.stop_all() class JsonRpcSetupTest(JsonRpcTestBase): def test_empty_object_mounts_is_not_allowed(self): - test = lambda: jsonrpc.JsonRpcWrapper(objects={'': Calculator()}) - self.assertRaises(AttributeError, test) + with self.assertRaises(AttributeError): + jsonrpc.JsonRpcWrapper(objects={'': Calculator()}) class JsonRpcSerializationTest(JsonRpcTestBase): @@ -188,12 +199,12 @@ class JsonRpcSingleCommandTest(JsonRpcTestBase): def test_call_method_on_actor_member(self): request = { 'jsonrpc': '2.0', - 'method': 'core.playback.get_volume', + 'method': 'core.playback.get_time_position', 'id': 1, } response = self.jrw.handle_data(request) - self.assertEqual(response['result'], None) + self.assertEqual(response['result'], 0) def test_call_method_which_is_a_directly_mounted_actor_member(self): # 'get_uri_schemes' isn't a regular callable, but a Pykka @@ -215,26 +226,24 @@ class JsonRpcSingleCommandTest(JsonRpcTestBase): def test_call_method_with_positional_params(self): request = { 'jsonrpc': '2.0', - 'method': 'core.playback.set_volume', - 'params': [37], + 'method': 'calc.add', + 'params': [3, 4], 'id': 1, } response = self.jrw.handle_data(request) - self.assertEqual(response['result'], None) - self.assertEqual(self.core.playback.get_volume().get(), 37) + self.assertEqual(response['result'], 7) def test_call_methods_with_named_params(self): request = { 'jsonrpc': '2.0', - 'method': 'core.playback.set_volume', - 'params': {'volume': 37}, + 'method': 'calc.add', + 'params': {'a': 3, 'b': 4}, 'id': 1, } response = self.jrw.handle_data(request) - self.assertEqual(response['result'], None) - self.assertEqual(self.core.playback.get_volume().get(), 37) + self.assertEqual(response['result'], 7) class JsonRpcSingleNotificationTest(JsonRpcTestBase): @@ -248,17 +257,17 @@ class JsonRpcSingleNotificationTest(JsonRpcTestBase): self.assertIsNone(response) def test_notification_makes_an_observable_change(self): - self.assertEqual(self.core.playback.get_volume().get(), None) + self.assertEqual(self.calc.get_mem(), None) request = { 'jsonrpc': '2.0', - 'method': 'core.playback.set_volume', + 'method': 'calc.set_mem', 'params': [37], } response = self.jrw.handle_data(request) self.assertIsNone(response) - self.assertEqual(self.core.playback.get_volume().get(), 37) + self.assertEqual(self.calc.get_mem(), 37) def test_notification_unknown_method_returns_nothing(self): request = { @@ -526,7 +535,7 @@ class JsonRpcBatchErrorTest(JsonRpcTestBase): def test_batch_of_both_successfull_and_failing_requests(self): request = [ # Call with positional params - {'jsonrpc': '2.0', 'method': 'core.playback.set_volume', + {'jsonrpc': '2.0', 'method': 'core.playback.seek', 'params': [47], 'id': '1'}, # Notification {'jsonrpc': '2.0', 'method': 'core.tracklist.set_consume', @@ -547,7 +556,7 @@ class JsonRpcBatchErrorTest(JsonRpcTestBase): self.assertEqual(len(response), 5) response = dict((row['id'], row) for row in response) - self.assertEqual(response['1']['result'], None) + self.assertEqual(response['1']['result'], False) self.assertEqual(response['2']['result'], None) self.assertEqual(response[None]['error']['code'], -32600) self.assertEqual(response['5']['error']['code'], -32601) @@ -556,8 +565,8 @@ class JsonRpcBatchErrorTest(JsonRpcTestBase): class JsonRpcInspectorTest(JsonRpcTestBase): def test_empty_object_mounts_is_not_allowed(self): - test = lambda: jsonrpc.JsonRpcInspector(objects={'': Calculator}) - self.assertRaises(AttributeError, test) + with self.assertRaises(AttributeError): + jsonrpc.JsonRpcInspector(objects={'': Calculator}) def test_can_describe_method_on_root(self): inspector = jsonrpc.JsonRpcInspector({ @@ -620,23 +629,23 @@ class JsonRpcInspectorTest(JsonRpcTestBase): methods = inspector.describe() self.assertIn('core.get_uri_schemes', methods) - self.assertEquals(len(methods['core.get_uri_schemes']['params']), 0) + self.assertEqual(len(methods['core.get_uri_schemes']['params']), 0) self.assertIn('core.library.lookup', methods.keys()) - self.assertEquals( + self.assertEqual( methods['core.library.lookup']['params'][0]['name'], 'uri') self.assertIn('core.playback.next', methods) - self.assertEquals(len(methods['core.playback.next']['params']), 0) + self.assertEqual(len(methods['core.playback.next']['params']), 0) self.assertIn('core.playlists.get_playlists', methods) - self.assertEquals( + self.assertEqual( len(methods['core.playlists.get_playlists']['params']), 1) self.assertIn('core.tracklist.filter', methods.keys()) - self.assertEquals( + self.assertEqual( methods['core.tracklist.filter']['params'][0]['name'], 'criteria') - self.assertEquals( + self.assertEqual( methods['core.tracklist.filter']['params'][1]['name'], 'kwargs') - self.assertEquals( + self.assertEqual( methods['core.tracklist.filter']['params'][1]['kwargs'], True) diff --git a/tests/utils/test_path.py b/tests/utils/test_path.py index db152654..6fd4f8d1 100644 --- a/tests/utils/test_path.py +++ b/tests/utils/test_path.py @@ -1,6 +1,6 @@ # encoding: utf-8 -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import os import shutil @@ -9,16 +9,17 @@ import unittest import glib +from mopidy import compat, exceptions from mopidy.utils import path -from tests import any_int, path_to_data_dir +import tests class GetOrCreateDirTest(unittest.TestCase): - def setUp(self): + def setUp(self): # noqa: N802 self.parent = tempfile.mkdtemp() - def tearDown(self): + def tearDown(self): # noqa: N802 if os.path.isdir(self.parent): shutil.rmtree(self.parent) @@ -52,11 +53,12 @@ class GetOrCreateDirTest(unittest.TestCase): conflicting_file = os.path.join(self.parent, b'test') open(conflicting_file, 'w').close() dir_path = os.path.join(self.parent, b'test') - self.assertRaises(OSError, path.get_or_create_dir, dir_path) + with self.assertRaises(OSError): + path.get_or_create_dir(dir_path) def test_create_dir_with_unicode(self): with self.assertRaises(ValueError): - dir_path = unicode(os.path.join(self.parent, b'test')) + dir_path = compat.text_type(os.path.join(self.parent, b'test')) path.get_or_create_dir(dir_path) def test_create_dir_with_none(self): @@ -65,10 +67,10 @@ class GetOrCreateDirTest(unittest.TestCase): class GetOrCreateFileTest(unittest.TestCase): - def setUp(self): + def setUp(self): # noqa: N802 self.parent = tempfile.mkdtemp() - def tearDown(self): + def tearDown(self): # noqa: N802 if os.path.isdir(self.parent): shutil.rmtree(self.parent) @@ -107,7 +109,7 @@ class GetOrCreateFileTest(unittest.TestCase): def test_create_dir_with_unicode_filename_throws_value_error(self): with self.assertRaises(ValueError): - file_path = unicode(os.path.join(self.parent, b'test')) + file_path = compat.text_type(os.path.join(self.parent, b'test')) path.get_or_create_file(file_path) def test_create_file_with_none_filename_throws_value_error(self): @@ -219,36 +221,164 @@ class ExpandPathTest(unittest.TestCase): class FindMTimesTest(unittest.TestCase): maxDiff = None - def find(self, value): - return path.find_mtimes(path_to_data_dir(value)) + def setUp(self): # noqa: N802 + self.tmpdir = tempfile.mkdtemp(b'.mopidy-tests') - def test_basic_dir(self): - self.assert_(self.find('')) + def tearDown(self): # noqa: N802 + shutil.rmtree(self.tmpdir, ignore_errors=True) - def test_nonexistant_dir(self): - self.assertEqual(self.find('does-not-exist'), {}) + def mkdir(self, *args): + name = os.path.join(self.tmpdir, *[bytes(a) for a in args]) + os.mkdir(name) + return name - def test_file(self): - self.assertEqual({path_to_data_dir('blank.mp3'): any_int}, - self.find('blank.mp3')) - - def test_files(self): - mtimes = self.find('find') - expected_files = [ - b'find/foo/bar/file', b'find/foo/file', b'find/baz/file'] - expected = {path_to_data_dir(p): any_int for p in expected_files} - self.assertEqual(expected, mtimes) + def touch(self, *args): + name = os.path.join(self.tmpdir, *[bytes(a) for a in args]) + open(name, 'w').close() + return name def test_names_are_bytestrings(self): - is_bytes = lambda f: isinstance(f, bytes) - for name in self.find(''): - self.assert_( - is_bytes(name), '%s is not bytes object' % repr(name)) + """We shouldn't be mixing in unicode for paths.""" + result, errors = path.find_mtimes(tests.path_to_data_dir('')) + for name in result.keys() + errors.keys(): + self.assertEqual(name, tests.IsA(bytes)) + + def test_nonexistent_dir(self): + """Non existent search roots are an error""" + missing = os.path.join(self.tmpdir, 'does-not-exist') + result, errors = path.find_mtimes(missing) + self.assertEqual(result, {}) + self.assertEqual(errors, {missing: tests.IsA(exceptions.FindError)}) + + def test_empty_dir(self): + """Empty directories should not show up in results""" + self.mkdir('empty') + + result, errors = path.find_mtimes(self.tmpdir) + self.assertEqual(result, {}) + self.assertEqual(errors, {}) + + def test_file_as_the_root(self): + """Specifying a file as the root should just return the file""" + single = self.touch('single') + + result, errors = path.find_mtimes(single) + self.assertEqual(result, {single: tests.any_int}) + self.assertEqual(errors, {}) + + def test_nested_directories(self): + """Searching nested directories should find all files""" + + # Setup foo/bar and baz directories + self.mkdir('foo') + self.mkdir('foo', 'bar') + self.mkdir('baz') + + # Touch foo/file foo/bar/file and baz/file + foo_file = self.touch('foo', 'file') + foo_bar_file = self.touch('foo', 'bar', 'file') + baz_file = self.touch('baz', 'file') + + result, errors = path.find_mtimes(self.tmpdir) + self.assertEqual(result, {foo_file: tests.any_int, + foo_bar_file: tests.any_int, + baz_file: tests.any_int}) + self.assertEqual(errors, {}) + + def test_missing_permission_to_file(self): + """Missing permissions to a file is not a search error""" + target = self.touch('no-permission') + os.chmod(target, 0) + + result, errors = path.find_mtimes(self.tmpdir) + self.assertEqual({target: tests.any_int}, result) + self.assertEqual({}, errors) + + def test_missing_permission_to_directory(self): + """Missing permissions to a directory is an error""" + directory = self.mkdir('no-permission') + os.chmod(directory, 0) + + result, errors = path.find_mtimes(self.tmpdir) + self.assertEqual({}, result) + self.assertEqual({directory: tests.IsA(exceptions.FindError)}, errors) + + def test_symlinks_are_ignored(self): + """By default symlinks should be treated as an error""" + target = self.touch('target') + link = os.path.join(self.tmpdir, 'link') + os.symlink(target, link) + + result, errors = path.find_mtimes(self.tmpdir) + self.assertEqual(result, {target: tests.any_int}) + self.assertEqual(errors, {link: tests.IsA(exceptions.FindError)}) + + def test_symlink_to_file_as_root_is_followed(self): + """Passing a symlink as the root should be followed when follow=True""" + target = self.touch('target') + link = os.path.join(self.tmpdir, 'link') + os.symlink(target, link) + + result, errors = path.find_mtimes(link, follow=True) + self.assertEqual({link: tests.any_int}, result) + self.assertEqual({}, errors) + + def test_symlink_to_directory_is_followed(self): + pass + + def test_symlink_pointing_at_itself_fails(self): + """Symlink pointing at itself should give as an OS error""" + link = os.path.join(self.tmpdir, 'link') + os.symlink(link, link) + + result, errors = path.find_mtimes(link, follow=True) + self.assertEqual({}, result) + self.assertEqual({link: tests.IsA(exceptions.FindError)}, errors) + + def test_symlink_pointing_at_parent_fails(self): + """We should detect a loop via the parent and give up on the branch""" + os.symlink(self.tmpdir, os.path.join(self.tmpdir, 'link')) + + result, errors = path.find_mtimes(self.tmpdir, follow=True) + self.assertEqual({}, result) + self.assertEqual(1, len(errors)) + self.assertEqual(tests.IsA(Exception), errors.values()[0]) + + def test_indirect_symlink_loop(self): + """More indirect loops should also be detected""" + # Setup tmpdir/directory/loop where loop points to tmpdir + directory = os.path.join(self.tmpdir, b'directory') + loop = os.path.join(directory, b'loop') + + os.mkdir(directory) + os.symlink(self.tmpdir, loop) + + result, errors = path.find_mtimes(self.tmpdir, follow=True) + self.assertEqual({}, result) + self.assertEqual({loop: tests.IsA(Exception)}, errors) + + def test_symlink_branches_are_not_excluded(self): + """Using symlinks to make a file show up multiple times should work""" + self.mkdir('directory') + target = self.touch('directory', 'target') + link1 = os.path.join(self.tmpdir, b'link1') + link2 = os.path.join(self.tmpdir, b'link2') + + os.symlink(target, link1) + os.symlink(target, link2) + + expected = {target: tests.any_int, + link1: tests.any_int, + link2: tests.any_int} + + result, errors = path.find_mtimes(self.tmpdir, follow=True) + self.assertEqual(expected, result) + self.assertEqual({}, errors) # TODO: kill this in favour of just os.path.getmtime + mocks class MtimeTest(unittest.TestCase): - def tearDown(self): + def tearDown(self): # noqa: N802 path.mtime.undo_fake() def test_mtime_of_current_dir(self): diff --git a/tox.ini b/tox.ini index ed6f0271..6dfab5ae 100644 --- a/tox.ini +++ b/tox.ini @@ -3,23 +3,30 @@ envlist = py27, py27-tornado23, py27-tornado31, docs, flake8 [testenv] sitepackages = true -commands = nosetests -v --with-xunit --xunit-file=xunit-{envname}.xml --with-coverage --cover-package=mopidy +commands = + py.test \ + --basetemp={envtmpdir} \ + --junit-xml=xunit-{envname}.xml \ + --cov=mopidy --cov-report=term-missing \ + -n 4 \ + {posargs} deps = - coverage mock - nose + pytest + pytest-cov + pytest-xdist [testenv:py27-tornado23] -commands = nosetests -v tests/http +commands = py.test tests/http deps = {[testenv]deps} tornado==2.3 [testenv:py27-tornado31] -commands = nosetests -v tests/http +commands = py.test tests/http deps = {[testenv]deps} - tornado==3.1 + tornado==3.1.1 [testenv:docs] deps = -r{toxinidir}/docs/requirements.txt @@ -30,4 +37,5 @@ commands = sphinx-build -b html -d {envtmpdir}/doctrees . {envtmpdir}/html deps = flake8 flake8-import-order -commands = flake8 + pep8-naming +commands = flake8 --show-source --statistics mopidy tests