diff --git a/.gitignore b/.gitignore index 0edb30e0..990d75ca 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ docs/_build/ mopidy.log* 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..5f01f223 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,8 +1,18 @@ +sudo: false + language: python python: - "2.7_with_system_site_packages" +addons: + apt: + sources: + - mopidy-stable + packages: + - graphviz-dev + - mopidy + env: - TOX_ENV=py27 - TOX_ENV=py27-tornado23 @@ -11,10 +21,6 @@ env: - TOX_ENV=flake8 install: - - "wget -O - http://apt.mopidy.com/mopidy.gpg | sudo apt-key add -" - - "sudo wget -O /etc/apt/sources.list.d/mopidy.list http://apt.mopidy.com/mopidy.list" - - "sudo apt-get update || true" - - "sudo apt-get install mopidy graphviz-dev" - "pip install tox" script: @@ -23,6 +29,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 cd347da8..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 @@ -47,3 +47,9 @@ - Lukas Vogel - Thomas Amland - Deni Bertovic +- Ali Ukani +- Dirk Groenen +- John Cass +- Laura Barber +- Jakab Kristóf +- Ronald Zielaznicki diff --git a/dev-requirements.txt b/dev-requirements.txt index 7b0e96c8..eba66348 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -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/concepts.rst b/docs/api/concepts.rst index d127561b..9c542777 100644 --- a/docs/api/concepts.rst +++ b/docs/api/concepts.rst @@ -22,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 @@ -55,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" @@ -95,7 +97,8 @@ Audio The audio actor is a thin wrapper around the parts of the GStreamer library we use. If you implement an advanced backend, you may need to implement your own -playback provider using the :ref:`audio-api`. +playback provider using the :ref:`audio-api`, but most backends can use the +default playback provider without any changes. Mixer diff --git a/docs/api/core.rst b/docs/api/core.rst index 21ff79f5..27ab2f57 100644 --- a/docs/api/core.rst +++ b/docs/api/core.rst @@ -64,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.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 361c24fd..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 =================================== @@ -289,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 8fd3ad40..605a30fe 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -5,53 +5,424 @@ Changelog This changelog is used to track all major changes to Mopidy. -v0.20.0 (UNRELEASED) -==================== +v1.1.0 (UNRELEASED) +=================== -**Core API** +Core API +-------- -- Added :class:`mopidy.core.HistoryController` which keeps track of what - tracks have been played. (Fixes: :issue:`423`, PR: :issue:`803`) +- Calling :meth:`mopidy.core.library.LibraryController.search`` with ``kwargs`` + as the query is no longer supported (PR: :issue:`1090`) -- Removed ``clear_current_track`` keyword argument to - :meth:`mopidy.core.Playback.stop`. It was a leaky internal abstraction, - which was never intended to be used externally. +Internal changes +---------------- -**Commands** +- Tests have been cleaned up to stop using deprecated APIs where feasible. + (Partial fix: :issue:`1083`, PR: :issue:`1090`) + + +v1.0.1 (UNRELEASED) +=================== + +- Audio: Software volume control has been reworked to greatly reduce the delay + between changing the volume and the change taking effect. (Fixes: + :issue:`1097`) + +- Audio: As a side effect of the previous bug fix, software volume is no longer + tied to the PulseAudio application volume when using ``pulsesink``. This + behavior was confusing for many users and doesn't work well with the plans + for multiple outputs. + + +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`) -**Local backend** +- 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 cover URL to all scanned files with MusicBrainz album IDs. (Fixes: - :issue:`697`, PR: :issue:`802`) +- 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. -- 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`) +Configuration +------------- -**File scanner** +- 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``. -- Improve error logging for scan code (Fixes: :issue:`856`, PR: :issue:`874`) +- Add debug logging of unknown sections. (Fixes: :issue:`694`, PR: :issue:`1002`) -- Add symlink support with loop protection to file finder (Fixes: :issue:`858`, - PR: :isusue:`874`) +Logging +------- -**MPD frontend** +- 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``. -- In stored playlist names, replace "/", which are illegal, with "|" instead of - a whitespace. Pipes are more similar to forward slash. +- 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`) -**Audio** +- Switch the ``list`` command over to using the new method + :meth:`mopidy.core.LibraryController.get_distinct` for increased performance. + (Fixes: :issue:`913`) -- 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. +- 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: @@ -65,40 +436,59 @@ v0.20.0 (UNRELEASED) - Add internal helper for converting GStreamer data types to Python. - - Move MusicBrainz coverart code out of audio and into local. - - - Reduce scope of audio scanner to just tags + duration. Mtime, uri and min - length handling are now outside of this class. + - 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. -- Add :meth:`mopidy.audio.AudioListener.tags_changed`. Notifies core when new tags - are found. + - Update scanner to use a custom source, typefind and decodebin. This allows + us to detect playlists before we try to decode them. -- Add :meth:`mopidy.audio.Audio.get_current_tags` for looking up the current - tags of the playing media. + - 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. - - Helper now ignores albums without a name. +- Ignore albums without a name when converting tags to tracks. -- Kill support for visualizers. 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 didn't really ever - make sense for a server such as Mopidy. Currently the only way to find out if - it is in use and will be missed is to go ahead and remove it. +- Support UTF-8 in M3U playlists. (Fixes: :issue:`853`) -**Stream backend** +- Add workaround for volume not persisting across tracks on OS X. + (Issue: :issue:`886`, PR: :issue:`958`) -- Add basic tests for the stream library provider. +- 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`) -v0.19.6 (UNRELEASED) -==================== +- Added support for checking if the media is seekable, and getting the initial + MIME type guess. (PR: :issue:`1033`) -Bug fix release. +Mopidy.js client library +------------------------ -- Audio: Support UTF-8 in M3U playlists. (Fixes: :issue:`853`) +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) @@ -571,6 +961,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/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 938ec87b..96209182 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -15,6 +15,7 @@ sys.path.insert(0, os.path.abspath(os.path.dirname(__file__) + '/../')) class Mock(object): + def __init__(self, *args, **kwargs): pass @@ -34,11 +35,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() @@ -112,6 +115,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'] @@ -155,6 +161,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 03bb83ac..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 `_. @@ -131,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 c94ef6ad..b5230b18 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -4,149 +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. Ignore the rare - cases you need to ignore a check use `# noqa: ` so we can lookup what - you are ignoring. - -#. 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_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/mopify.jpg b/docs/ext/mopify.jpg new file mode 100644 index 00000000..1ac060b7 Binary files /dev/null and b/docs/ext/mopify.jpg differ diff --git a/docs/ext/mopify.png b/docs/ext/mopify.png deleted file mode 100644 index 94ca9d17..00000000 Binary files a/docs/ext/mopify.png and /dev/null 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 9f45e3d2..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 @@ -64,12 +86,13 @@ Mopidy-Mopify https://github.com/dirkgroenen/mopidy-mopify -An web client that mainly targets using Spotify through Mopidy. Made by Dirk -Groenen. +A web client that uses external web services to provide additional features and +a more "complete" Spotify music experience. It's currently targeted at people +using Spotify through Mopidy. Made by Dirk Groenen. -.. image:: /ext/mopify.png - :width: 720 - :height: 424 +.. image:: /ext/mopify.jpg + :width: 800 + :height: 416 To install, run:: diff --git a/docs/extensiondev.rst b/docs/extensiondev.rst index c6a88619..a2a5f463 100644 --- a/docs/extensiondev.rst +++ b/docs/extensiondev.rst @@ -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', @@ -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 ==================== 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 51cd8bc4..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 `_. 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/mopidy/__init__.py b/mopidy/__init__.py index 60d7a428..388bb9f0 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -30,4 +30,4 @@ except ImportError: warnings.filterwarnings('ignore', 'could not open display') -__version__ = '0.19.5' +__version__ = '1.0.0' diff --git a/mopidy/audio/__init__.py b/mopidy/audio/__init__.py index 1d47e682..a74d4456 100644 --- a/mopidy/audio/__init__.py +++ b/mopidy/audio/__init__.py @@ -2,7 +2,6 @@ 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 ccb802a4..a1e1e119 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -16,7 +16,7 @@ from mopidy import exceptions from mopidy.audio import playlists, utils from mopidy.audio.constants import PlaybackState from mopidy.audio.listener import AudioListener -from mopidy.utils import process +from mopidy.utils import deprecation, process logger = logging.getLogger(__name__) @@ -34,26 +34,11 @@ _GST_STATE_MAPPING = { gst.STATE_PAUSED: PlaybackState.PAUSED, gst.STATE_NULL: PlaybackState.STOPPED} -MB = 1 << 20 - -# GST_PLAY_FLAG_VIDEO (1<<0) -# GST_PLAY_FLAG_AUDIO (1<<1) -# GST_PLAY_FLAG_TEXT (1<<2) -# GST_PLAY_FLAG_VIS (1<<3) -# GST_PLAY_FLAG_SOFT_VOLUME (1<<4) -# GST_PLAY_FLAG_NATIVE_AUDIO (1<<5) -# GST_PLAY_FLAG_NATIVE_VIDEO (1<<6) -# GST_PLAY_FLAG_DOWNLOAD (1<<7) -# GST_PLAY_FLAG_BUFFERING (1<<8) -# GST_PLAY_FLAG_DEINTERLACE (1<<9) -# GST_PLAY_FLAG_SOFT_COLORBALANCE (1<<10) - -# Default flags to use for playbin: AUDIO, SOFT_VOLUME, DOWNLOAD -PLAYBIN_FLAGS = (1 << 1) | (1 << 4) | (1 << 7) - class _Signals(object): + """Helper for tracking gobject signal registrations""" + def __init__(self): self._ids = {} @@ -82,7 +67,9 @@ class _Signals(object): # 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() @@ -112,7 +99,7 @@ class _Appsrc(object): 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('max-bytes', 1 << 20) # 1MB source.set_property('min-percent', 50) if self._need_data_callback: @@ -128,6 +115,9 @@ class _Appsrc(object): self._source = source def push(self, buffer_): + if self._source is None: + return False + if buffer_ is None: gst_logger.debug('Sending appsrc end-of-stream event.') return self._source.emit('end-of-stream') == gst.FLOW_OK @@ -146,27 +136,14 @@ class _Appsrc(object): # TODO: expose this as a property on audio when #790 gets further along. class _Outputs(gst.Bin): + def __init__(self): - gst.Bin.__init__(self) + gst.Bin.__init__(self, 'outputs') self._tee = gst.element_factory_make('tee') self.add(self._tee) - # 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')) + ghost_pad = gst.GhostPad('sink', self._tee.get_pad('sink')) self.add_pad(ghost_pad) # Add an always connected fakesink which respects the clock so the tee @@ -190,7 +167,9 @@ class _Outputs(gst.Bin): def _add(self, element): # All tee branches need a queue in front of them. + # But keep the queue short so the volume change isn't to slow: queue = gst.element_factory_make('queue') + queue.set_property('max-size-buffers', 5) self.add(element) self.add(queue) queue.link(element) @@ -209,10 +188,6 @@ class SoftwareMixer(object): 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): @@ -224,41 +199,20 @@ class SoftwareMixer(object): def set_volume(self, volume): self._element.set_property('volume', volume / 100.0) + self._mixer.trigger_volume_changed(volume) 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) - - -def setup_proxy(element, config): - # TODO: reuse in scanner code - if 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')) + result = self._element.set_property('mute', bool(mute)) + if result: + self._mixer.trigger_mute_changed(bool(mute)) + return result class _Handler(object): + def __init__(self, audio): self._audio = audio self._element = None @@ -290,7 +244,7 @@ class _Handler(object): 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() elif msg.type == gst.MESSAGE_ERROR: @@ -353,16 +307,23 @@ class _Handler(object): gst.DEBUG_BIN_TO_DOT_FILE( self._audio._playbin, gst.DEBUG_GRAPH_SHOW_ALL, 'mopidy') - def on_buffering(self, percent): - gst_logger.debug('Got buffering message: percent=%d%%', percent) + 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._audio._buffering = False if self._audio._target_state == gst.STATE_PLAYING: self._audio._playbin.set_state(gst.STATE_PLAYING) + level = logging.DEBUG + + gst_logger.log(level, 'Got buffering message: percent=%d%%', percent) def on_end_of_stream(self): gst_logger.debug('Got end-of-stream message.') @@ -420,6 +381,7 @@ class _Handler(object): # TODO: create a player class which replaces the actors internals class Audio(pykka.ThreadingActor): + """ Audio output through `GStreamer `_. """ @@ -453,8 +415,8 @@ class Audio(pykka.ThreadingActor): try: self._setup_preferences() self._setup_playbin() - self._setup_output() - self._setup_mixer() + self._setup_outputs() + self._setup_audio_sink() except gobject.GError as ex: logger.exception(ex) process.exit_process() @@ -474,11 +436,11 @@ class Audio(pykka.ThreadingActor): def _setup_playbin(self): playbin = gst.element_factory_make('playbin2') - playbin.set_property('flags', PLAYBIN_FLAGS) + playbin.set_property('flags', 2) # GST_PLAY_FLAG_AUDIO # TODO: turn into config values... - playbin.set_property('buffer-size', 2*1024*1024) - playbin.set_property('buffer-duration', 2*gst.SECOND) + playbin.set_property('buffer-size', 5 << 20) # 5MB + playbin.set_property('buffer-duration', 5 * gst.SECOND) self._signals.connect(playbin, 'source-setup', self._on_source_setup) self._signals.connect(playbin, 'about-to-finish', @@ -494,7 +456,7 @@ class Audio(pykka.ThreadingActor): self._signals.disconnect(self._playbin, 'source-setup') self._playbin.set_state(gst.STATE_NULL) - def _setup_output(self): + def _setup_outputs(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': @@ -507,11 +469,36 @@ class Audio(pykka.ThreadingActor): 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): + def _setup_audio_sink(self): + audio_sink = gst.Bin('audio-sink') + + # 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? + 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', 3 * gst.SECOND) + queue.set_property('min-threshold-time', 1 * gst.SECOND) + + audio_sink.add(queue) + audio_sink.add(self._outputs) + if self.mixer: - self.mixer.setup(self._playbin, self.actor_ref.proxy().mixer) + volume = gst.element_factory_make('volume') + audio_sink.add(volume) + queue.link(volume) + volume.link(self._outputs) + self.mixer.setup(volume, self.actor_ref.proxy().mixer) + else: + queue.link(self._outputs) + + ghost_pad = gst.GhostPad('sink', queue.get_pad('sink')) + audio_sink.add_pad(ghost_pad) + + self._playbin.set_property('audio-sink', audio_sink) def _teardown_mixer(self): if self.mixer: @@ -531,8 +518,7 @@ class Audio(pykka.ThreadingActor): else: self._appsrc.reset() - if hasattr(source.props, 'proxy'): - setup_proxy(source, self._config['proxy']) + utils.setup_proxy(source, self._config['proxy']) def set_uri(self, uri): """ @@ -543,9 +529,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): """ @@ -594,9 +591,10 @@ 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:: 0.20 + .. deprecated:: 1.0 Use :meth:`emit_data` with a :class:`None` buffer instead. """ + deprecation.warn('audio.emit_end_of_stream') self._appsrc.push(None) def set_about_to_finish_callback(self, callback): diff --git a/mopidy/audio/constants.py b/mopidy/audio/constants.py index 718fde1b..bdcdf29f 100644 --- a/mopidy/audio/constants.py +++ b/mopidy/audio/constants.py @@ -2,6 +2,7 @@ from __future__ import absolute_import, unicode_literals class PlaybackState(object): + """ Enum of playback states. """ diff --git a/mopidy/audio/listener.py b/mopidy/audio/listener.py index 280d4f86..e4e3f427 100644 --- a/mopidy/audio/listener.py +++ b/mopidy/audio/listener.py @@ -4,6 +4,7 @@ from mopidy import listener class AudioListener(listener.Listener): + """ Marker interface for recipients of events sent by the audio actor. diff --git a/mopidy/audio/playlists.py b/mopidy/audio/playlists.py index 5a362191..58c7fe24 100644 --- a/mopidy/audio/playlists.py +++ b/mopidy/audio/playlists.py @@ -78,7 +78,7 @@ def parse_pls(data): if section.lower() != 'playlist': continue for i in range(cp.getint(section, 'numberofentries')): - yield cp.get(section, 'file%d' % (i+1)) + yield cp.get(section, 'file%d' % (i + 1)) def parse_xspf(data): @@ -136,6 +136,7 @@ def register_typefinders(): class BasePlaylistElement(gst.Bin): + """Base class for creating GStreamer elements for playlist support. This element performs the following steps: diff --git a/mopidy/audio/scan.py b/mopidy/audio/scan.py index 2cf8f493..384b4197 100644 --- a/mopidy/audio/scan.py +++ b/mopidy/audio/scan.py @@ -1,42 +1,38 @@ from __future__ import absolute_import, division, unicode_literals -import time +import collections import pygst pygst.require('0.10') import gst # noqa +import gst.pbutils from mopidy import exceptions 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 + :param proxy_config: dictionary containing proxy config strings. :type event: int """ - def __init__(self, timeout=1000): - self._timeout_ms = timeout - - 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): """ @@ -44,68 +40,125 @@ class Scanner(object): :param uri: URI of the resource to scan. :type event: string - :return: (tags, duration) pair. tags is a dictionary of lists for all - the tags we found and duration is the length of the URI in - milliseconds, or :class:`None` if the URI has no duration. + :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 = None, None + tags, duration, seekable, mime = None, None, None, None + pipeline = _setup_pipeline(uri, self._proxy_config) + try: - self._setup(uri) - tags = self._collect() - 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 - return tags, duration + return _Result(uri, tags, duration, seekable, mime) - 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 / 1000.0 - tags = {} +# 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) - while time.time() - start < timeout_s: - if not self._bus.have_pending(): - continue - message = self._bus.pop() + typefind = gst.element_factory_make('typefind') + decodebin = gst.element_factory_make('decodebin2') + sink = gst.element_factory_make('fakesink') - 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: - taglist = message.parse_tag() - # Note that this will only keep the last tag. - tags.update(utils.convert_taglist(taglist)) + pipeline = gst.element_factory_make('pipeline') + for e in (src, typefind, decodebin, sink): + pipeline.add(e) + gst.element_link_many(src, typefind, decodebin) - raise exceptions.ScannerError('Timeout after %dms' % self._timeout_ms) + if proxy_config: + utils.setup_proxy(src, proxy_config) - def _reset(self): - """Ensures we cleanup child elements and flush the bus.""" - self._bus.set_flushing(True) - self._pipe.set_state(gst.STATE_NULL) + decodebin.set_property('caps', _RAW_AUDIO) + decodebin.connect('pad-added', _pad_added, sink) + typefind.connect('have-type', _have_type, decodebin) - def _query_duration(self): - try: - duration = self._pipe.query_duration(gst.FORMAT_TIME, None)[0] - except gst.QueryError: - return None + return pipeline - if duration < 0: - return None - else: - return duration // gst.MSECOND + +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: + 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 _query_seekable(pipeline): + query = gst.query_new_seeking(gst.FORMAT_TIME) + pipeline.query(query) + return query.parse_seeking()[1] + + +def _process(pipeline, timeout_ms): + clock = pipeline.get_clock() + bus = pipeline.get_bus() + timeout = timeout_ms * gst.MSECOND + tags, mime, missing_description = {}, None, None + + types = (gst.MESSAGE_ELEMENT | gst.MESSAGE_APPLICATION | gst.MESSAGE_ERROR + | gst.MESSAGE_EOS | gst.MESSAGE_ASYNC_DONE | gst.MESSAGE_TAG) + + start = clock.get_time() + while timeout > 0: + message = bus.timed_pop_filtered(timeout, types) + + 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)) + + timeout -= clock.get_time() - start + + raise exceptions.ScannerError('Timeout after %dms' % timeout_ms) diff --git a/mopidy/audio/utils.py b/mopidy/audio/utils.py index 8581fd61..1a8bf6a7 100644 --- a/mopidy/audio/utils.py +++ b/mopidy/audio/utils.py @@ -82,7 +82,8 @@ def _artists(tags, artist_name, artist_id=None): def convert_tags_to_track(tags): """Convert our normalized tags to a track. - :param :class:`dict` tags: dictionary of tag keys with a list of values + :param tags: dictionary of tag keys with a list of values + :type tags: :class:`dict` :rtype: :class:`mopidy.models.Track` """ album_kwargs = {} @@ -130,6 +131,26 @@ def convert_tags_to_track(tags): 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. @@ -147,7 +168,8 @@ def convert_taglist(taglist): .. _GstTagList: http://gstreamer.freedesktop.org/data/doc/gstreamer/\ 0.10.36/gstreamer/html/gstreamer-GstTagList.html - :param gst.Taglist taglist: A GStreamer taglist to be converted. + :param taglist: A GStreamer taglist to be converted. + :type taglist: :class:`gst.Taglist` :rtype: dictionary of tag keys with a list of values. """ result = {} diff --git a/mopidy/backend/__init__.py b/mopidy/backend.py similarity index 76% rename from mopidy/backend/__init__.py rename to mopidy/backend.py index 53954f4f..fe8676ca 100644 --- a/mopidy/backend/__init__.py +++ b/mopidy/backend.py @@ -1,11 +1,10 @@ from __future__ import absolute_import, unicode_literals -import copy - -from mopidy import listener +from mopidy import listener, models class Backend(object): + """Backend API If the backend has problems during initialization it should raise @@ -61,6 +60,7 @@ class Backend(object): class LibraryProvider(object): + """ :param backend: backend the controller is a part of :type backend: :class:`mopidy.backend.Backend` @@ -92,14 +92,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,16 +137,20 @@ 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 class PlaybackProvider(object): + """ :param audio: the audio actor :type audio: actor proxy to an instance of :class:`mopidy.audio.Audio` @@ -172,23 +196,44 @@ class PlaybackProvider(object): """ 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.* - This is very likely the *only* thing you need to override as a backend - author. Typically this is where you convert any mopidy specific URIs - to real URIs and then return:: + 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. - return super(MyBackend, self).change_track(track.copy(uri=new_uri)) + 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): @@ -219,7 +264,7 @@ class PlaybackProvider(object): *MAY be reimplemented by subclass.* - Should not be used for tracking if tracks have been played / when we + 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` @@ -238,6 +283,7 @@ 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 @@ -251,25 +297,36 @@ class PlaylistsProvider(object): def __init__(self, backend): self.backend = backend - self._playlists = [] - # TODO Replace playlists property with a get_playlists() method which - # returns playlist Ref's instead of the gigantic data structures we - # currently make available. lookup() should be used for getting full - # playlists with all details. - - @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): """ @@ -338,6 +395,7 @@ class PlaylistsProvider(object): class BackendListener(listener.Listener): + """ Marker interface for recipients of events sent by the backend actors. diff --git a/mopidy/commands.py b/mopidy/commands.py index b414b29e..ca7c519c 100644 --- a/mopidy/commands.py +++ b/mopidy/commands.py @@ -2,11 +2,9 @@ from __future__ import absolute_import, print_function, unicode_literals import argparse import collections -import contextlib import logging import os import sys -import time import glib @@ -15,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__) @@ -40,7 +38,9 @@ def config_override_type(value): class _ParserError(Exception): - pass + + def __init__(self, message): + self.message = message class _HelpError(Exception): @@ -48,11 +48,13 @@ class _HelpError(Exception): class _ArgumentParser(argparse.ArgumentParser): + def error(self, message): raise _ParserError(message) class _HelpAction(argparse.Action): + def __init__(self, option_strings, dest=None, help=None): super(_HelpAction, self).__init__( option_strings=option_strings, @@ -65,14 +67,8 @@ class _HelpAction(argparse.Action): raise _HelpError() -@contextlib.contextmanager -def _startup_timer(name): - start = time.time() - yield - logger.debug('%s startup took %dms', name, (time.time() - start) * 1000) - - class Command(object): + """Command parser and runner for building trees of commands. This class provides a wraper around :class:`argparse.ArgumentParser` @@ -236,6 +232,7 @@ class Command(object): # TODO: move out of this file class RootCommand(Command): + def __init__(self): super(RootCommand, self).__init__() self.set(base_verbosity_level=0) @@ -277,10 +274,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(audio, mixer, backends) + core = self.start_core(mixer, backends, audio) self.start_frontends(config, frontend_classes, core) loop.run() except (exceptions.BackendError, @@ -298,7 +297,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 @@ -307,13 +307,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] @@ -349,7 +354,7 @@ class RootCommand(Command): backends = [] for backend_class in backend_classes: try: - with _startup_timer(backend_class.__name__): + with timer.time_logger(backend_class.__name__): backend = backend_class.start( config=config, audio=audio).proxy() backends.append(backend) @@ -361,9 +366,9 @@ class RootCommand(Command): return backends - def start_core(self, audio, mixer, backends): + def start_core(self, mixer, backends, audio): logger.info('Starting Mopidy core') - return Core.start(audio=audio, 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( @@ -372,7 +377,7 @@ class RootCommand(Command): for frontend_class in frontend_classes: try: - with _startup_timer(frontend_class.__name__): + with timer.time_logger(frontend_class.__name__): frontend_class.start(config=config, core=core) except exceptions.FrontendError as exc: logger.error( diff --git a/mopidy/config/__init__.py b/mopidy/config/__init__.py index db451cef..fd914994 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -22,7 +22,8 @@ _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() @@ -42,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: @@ -148,6 +150,11 @@ def _load_file(parser, 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) @@ -170,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 @@ -251,6 +264,7 @@ def _postprocess(config_string): class Proxy(collections.Mapping): + def __init__(self, data): self._data = data diff --git a/mopidy/config/schemas.py b/mopidy/config/schemas.py index 56826a53..6be10ff1 100644 --- a/mopidy/config/schemas.py +++ b/mopidy/config/schemas.py @@ -38,6 +38,7 @@ def _levenshtein(a, b): class ConfigSchema(collections.OrderedDict): + """Logical group of config values that correspond to a config section. Schemas are set up by assigning config keys with config values to @@ -47,6 +48,7 @@ class ConfigSchema(collections.OrderedDict): :meth:`serialize` for converting the values to a form suitable for persistence. """ + def __init__(self, name): super(ConfigSchema, self).__init__() self.name = name @@ -94,17 +96,17 @@ class ConfigSchema(collections.OrderedDict): return result -class LogLevelConfigSchema(object): - """Special cased schema for handling a config section with loglevels. +class MapConfigSchema(object): - 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. + """Schema for handling multiple unknown keys with the same type. + + 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 +114,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 +123,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 bed03fa2..8359766f 100644 --- a/mopidy/config/types.py +++ b/mopidy/config/types.py @@ -6,7 +6,7 @@ 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): @@ -25,6 +25,7 @@ def encode(value): class ExpandedPath(bytes): + def __new__(cls, original, expanded): return super(ExpandedPath, cls).__new__(cls, expanded) @@ -37,6 +38,7 @@ class DeprecatedValue(object): class ConfigValue(object): + """Represents a config key's value and how to handle it. Normally you will only be interacting with sub-classes for config values @@ -65,6 +67,7 @@ class ConfigValue(object): class Deprecated(ConfigValue): + """Deprecated value Used for ignoring old config values that are no longer in use, but should @@ -79,10 +82,12 @@ class Deprecated(ConfigValue): class String(ConfigValue): + """String value. Is decoded as utf-8 and \\n \\t escapes should work and be preserved. """ + def __init__(self, optional=False, choices=None): self._required = not optional self._choices = choices @@ -102,6 +107,7 @@ class String(ConfigValue): class Secret(String): + """Secret string value. Is decoded as utf-8 and \\n \\t escapes should work and be preserved. @@ -109,6 +115,7 @@ class Secret(String): Should be used for passwords, auth tokens etc. Will mask value when being displayed. """ + def __init__(self, optional=False, choices=None): self._required = not optional self._choices = None # Choices doesn't make sense for secrets @@ -120,6 +127,7 @@ class Secret(String): class Integer(ConfigValue): + """Integer value.""" def __init__( @@ -141,6 +149,7 @@ class Integer(ConfigValue): class Boolean(ConfigValue): + """Boolean value. Accepts ``1``, ``yes``, ``true``, and ``on`` with any casing as @@ -173,11 +182,13 @@ class Boolean(ConfigValue): class List(ConfigValue): + """List value. Supports elements split by commas or newlines. Newlines take presedence and empty list items will be filtered out. """ + def __init__(self, optional=False): self._required = not optional @@ -197,11 +208,24 @@ 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, @@ -209,6 +233,7 @@ class LogLevel(ConfigValue): b'warning': logging.WARNING, b'info': logging.INFO, b'debug': logging.DEBUG, + b'all': logging.NOTSET, } def deserialize(self, value): @@ -223,6 +248,7 @@ class LogLevel(ConfigValue): class Hostname(ConfigValue): + """Network hostname value.""" def __init__(self, optional=False): @@ -240,18 +266,21 @@ class Hostname(ConfigValue): class Port(Integer): + """Network port value. Expects integer in the range 0-65535, zero tells the kernel to simply allocate a port for us. """ # TODO: consider probing if port is free or not? + def __init__(self, choices=None, optional=False): super(Port, self).__init__( minimum=0, maximum=2 ** 16 - 1, choices=choices, optional=optional) class Path(ConfigValue): + """File system path The following expansions of the path will be done: @@ -266,6 +295,7 @@ class Path(ConfigValue): - ``$XDG_MUSIC_DIR`` according to the XDG spec """ + def __init__(self, optional=False): self._required = not optional diff --git a/mopidy/core/__init__.py b/mopidy/core/__init__.py index 7fa7e299..720f9c38 100644 --- a/mopidy/core/__init__.py +++ b/mopidy/core/__init__.py @@ -5,6 +5,7 @@ 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 0058d9b9..e83b6a34 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -10,10 +10,12 @@ 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( @@ -28,6 +30,10 @@ class Core( """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`.""" @@ -40,43 +46,49 @@ class Core( """The tracklist controller. An instance of :class:`mopidy.core.TracklistController`.""" - def __init__(self, audio=None, 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.history = HistoryController() - + self.mixer = MixerController(mixer=mixer) self.playback = PlaybackController( - audio=audio, mixer=mixer, backends=self.backends, core=self) - - self.playlists = PlaylistsController( - backends=self.backends, core=self) - + audio=audio, 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_stream() + self.playback._on_end_of_stream() def stream_changed(self, uri): - self.playback.on_stream_changed(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 @@ -88,8 +100,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() @@ -105,8 +117,25 @@ 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): super(Backends, self).__init__(backends) @@ -116,7 +145,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 index 9d7cf59f..f0d5e9d4 100644 --- a/mopidy/core/history.py +++ b/mopidy/core/history.py @@ -15,9 +15,11 @@ class HistoryController(object): def __init__(self): self._history = [] - def add(self, track): + 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` """ diff --git a/mopidy/core/library.py b/mopidy/core/library.py index 2ada23d4..c787e013 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -1,11 +1,17 @@ from __future__ import absolute_import, unicode_literals import collections +import logging import operator import urlparse import pykka +from mopidy.utils import deprecation + + +logger = logging.getLogger(__name__) + class LibraryController(object): pykka_traversable = True @@ -60,6 +66,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 +80,65 @@ 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. + """ + deprecation.warn('core.library.find_exact') + return self.search(query=query, uris=uris, exact=True, **kwargs) + + def lookup(self, uri=None, uris=None): """ Lookup the given URI. @@ -125,14 +146,48 @@ 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: + deprecation.warn('core.library.lookup:uri_arg') + + 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,13 +205,10 @@ 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. - 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. @@ -186,10 +238,58 @@ 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`. + + .. 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. + + .. deprecated:: 1.1 + Providing the search query via ``kwargs`` is no longer supported. """ - 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) + + if kwargs: + deprecation.warn('core.library.search:kwargs_query') + + if not query: + deprecation.warn('core.library.search:empty_query') + + 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 2c027e1b..45109bba 100644 --- a/mopidy/core/listener.py +++ b/mopidy/core/listener.py @@ -4,6 +4,7 @@ from mopidy import listener class CoreListener(listener.Listener): + """ Marker interface for recipients of events sent by the core actor. @@ -163,3 +164,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 b33e098f..108a7c04 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -5,29 +5,28 @@ import urlparse from mopidy.audio import PlaybackState from mopidy.core import listener - +from mopidy.utils import deprecation logger = logging.getLogger(__name__) -# TODO: split mixing out from playback? class PlaybackController(object): pykka_traversable = True - def __init__(self, audio, mixer, backends, core): + def __init__(self, audio, backends, core): # TODO: these should be internal - self.mixer = mixer self.backends = backends self.core = core - self._audio = audio + + self._stream_title = None self._state = PlaybackState.STOPPED - self._volume = None - self._mute = False + + self._current_tl_track = None self._pending_tl_track = None if self._audio: - self._audio.set_about_to_finish_callback(self.on_about_to_finish) + self._audio.set_about_to_finish_callback(self._on_about_to_finish) def _get_backend(self, tl_track): if tl_track is None: @@ -38,137 +37,188 @@ class PlaybackController(object): # 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 = deprecation.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 = deprecation.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 = deprecation.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): - backend = self._get_backend(self.current_tl_track) + """Get time position in milliseconds.""" + backend = self._get_backend(self.get_current_tl_track()) if backend: return backend.playback.get_time_position().get() else: return 0 - time_position = property(get_time_position) - """Time position in milliseconds.""" + time_position = deprecation.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. + """ + deprecation.warn('core.playback.get_volume') + 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. + """ + deprecation.warn('core.playback.set_volume') + 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 = deprecation.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. + """ + deprecation.warn('core.playback.get_mute') + 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. + """ + deprecation.warn('core.playback.set_mute') + 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 = deprecation.deprecated_property(get_mute, set_mute) + """ + .. deprecated:: 1.0 + Use :meth:`core.mixer.get_mute() + ` and + :meth:`core.mixer.set_mute() + ` instead. + """ # Methods - def on_end_of_stream(self): - self.state = PlaybackState.STOPPED - self.current_tl_track = None + def _on_end_of_stream(self): + self.set_state(PlaybackState.STOPPED) + self._set_current_tl_track(None) # TODO: self._trigger_track_playback_ended? - def on_stream_changed(self, uri): + def _on_stream_changed(self, uri): + self._stream_title = None if self._pending_tl_track: - self.current_tl_track = self._pending_tl_track + self._set_current_tl_track(self._pending_tl_track) self._pending_tl_track = None self._trigger_track_playback_started() - def on_about_to_finish(self): + def _on_about_to_finish(self): # TODO: check that we always have a current track - - 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) + # TODO: only set pending if we have a backend that can play it? + # TODO: skip tracks that don't have a backend? self._pending_tl_track = next_tl_track backend = self._get_backend(next_tl_track) if backend: backend.playback.change_track(next_tl_track.track).get() - 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 not self.core.tracklist.tl_tracks: self.stop() - self.current_tl_track = None - elif self.current_tl_track not in self.core.tracklist.tl_tracks: - self.current_tl_track = None + self._set_current_tl_track(None) + elif self.get_current_tl_track() not in self.core.tracklist.tl_tracks: + self._set_current_tl_track(None) def next(self): """ @@ -177,64 +227,61 @@ 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) backend = self._get_backend(next_tl_track) - self.current_tl_track = next_tl_track + self._set_current_tl_track(next_tl_track) if backend: backend.playback.prepare_change() backend.playback.change_track(next_tl_track.track) - if self.state == PlaybackState.PLAYING: + if self.get_state() == PlaybackState.PLAYING: result = backend.playback.play().get() - elif self.state == PlaybackState.PAUSED: + elif self.get_state() == PlaybackState.PAUSED: result = backend.playback.pause().get() else: result = True - if result and self.state != PlaybackState.PAUSED: + if result and self.get_state() != PlaybackState.PAUSED: self._trigger_track_playback_started() elif not result: - self.core.tracklist.mark_unplayable(next_tl_track) + self.core.tracklist._mark_unplayable(next_tl_track) # TODO: can cause an endless loop for single track repeat. self.next() else: self.stop() - 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(self.current_tl_track) + backend = self._get_backend(self.get_current_tl_track()) if not backend or backend.playback.pause().get(): # TODO: switch to: # backend.track(pause) # wait for state change? - self.state = PlaybackState.PAUSED + 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. **INTERNAL** - :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) @@ -244,29 +291,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() # TODO: switch to: # backend.play(track) # wait for state change? - if self.state == PlaybackState.PLAYING: + if self.get_state() == PlaybackState.PLAYING: self.stop() - self.current_tl_track = tl_track - self.state = PlaybackState.PLAYING - backend = self._get_backend(self.current_tl_track) + self._set_current_tl_track(tl_track) + self.set_state(PlaybackState.PLAYING) + backend = self._get_backend(tl_track) success = False if backend: backend.playback.prepare_change() - backend.playback.change_track(tl_track.track) - success = backend.playback.play().get() + 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.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() @@ -280,35 +335,38 @@ 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() prev_tl_track = self.core.tracklist.previous_track(original_tl_track) backend = self._get_backend(prev_tl_track) - self.current_tl_track = prev_tl_track + self._set_current_tl_track(prev_tl_track) if backend: backend.playback.prepare_change() + # TODO: check return values of change track backend.playback.change_track(prev_tl_track.track) - if self.state == PlaybackState.PLAYING: + if self.get_state() == PlaybackState.PLAYING: result = backend.playback.play().get() - elif self.state == PlaybackState.PAUSED: + elif self.get_state() == PlaybackState.PAUSED: result = backend.playback.pause().get() else: result = True - if result and self.state != PlaybackState.PAUSED: + if result and self.get_state() != PlaybackState.PAUSED: self._trigger_track_playback_started() elif not result: - self.core.tracklist.mark_unplayable(prev_tl_track) + self.core.tracklist._mark_unplayable(prev_tl_track) self.previous() + # TODO: no return value? + 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(self.current_tl_track) + backend = self._get_backend(self.get_current_tl_track()) 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: @@ -327,18 +385,20 @@ 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 elif time_position > self.current_track.length: + # TODO: gstreamer will trigger a about to finish for us, use that? self.next() return True - backend = self._get_backend(self.current_tl_track) + backend = self._get_backend(self.get_current_tl_track()) if not backend: return False @@ -349,11 +409,11 @@ class PlaybackController(object): def stop(self): """Stop playing.""" - if self.state != PlaybackState.STOPPED: - backend = self._get_backend(self.current_tl_track) - time_position_before_stop = self.time_position + if self.get_state() != PlaybackState.STOPPED: + backend = self._get_backend(self.get_current_tl_track()) + 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) def _trigger_track_playback_paused(self): @@ -362,7 +422,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') @@ -370,27 +431,27 @@ 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): # TODO: replace with stream-changed logger.debug('Triggering track playback started event') - if self.current_tl_track is None: + if self.get_current_tl_track() is None: return - self.core.tracklist.mark_playing(self.current_tl_track) - self.core.history.add(self.current_tl_track.track) - listener.CoreListener.send( - 'track_playback_started', - tl_track=self.current_tl_track) + tl_track = self.get_current_tl_track() + self.core.tracklist._mark_playing(tl_track) + self.core.history._add_track(tl_track.track) + listener.CoreListener.send('track_playback_started', tl_track=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 c896bfa7..2c997d84 100644 --- a/mopidy/core/playlists.py +++ b/mopidy/core/playlists.py @@ -1,11 +1,15 @@ 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 import deprecation + +logger = logging.getLogger(__name__) class PlaylistsController(object): @@ -15,20 +19,85 @@ 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. + """ + deprecation.warn('core.playlists.get_playlists') + + 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 = deprecation.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 +109,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 @@ -94,7 +163,12 @@ 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. """ + deprecation.warn('core.playlists.filter') + criteria = criteria or kwargs matches = self.playlists for (key, value) in criteria.iteritems(): @@ -145,14 +219,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 f9560a13..9a251b75 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -7,7 +7,7 @@ import random from mopidy import compat from mopidy.core import listener from mopidy.models import TlTrack - +from mopidy.utils import deprecation logger = logging.getLogger(__name__) @@ -26,114 +26,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 = deprecation.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 = deprecation.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 = deprecation.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 = deprecation.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 = deprecation.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 = deprecation.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 = deprecation.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 = deprecation.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 @@ -161,9 +223,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 @@ -186,30 +248,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 @@ -226,7 +288,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) @@ -234,15 +296,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. @@ -256,12 +321,32 @@ 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' + + # TODO: assert that tracks are track instances + + if tracks: + deprecation.warn('core.tracklist.add:tracks_arg') + + if uri: + deprecation.warn('core.tracklist.add:uri_arg') + + if tracks is None: + if uri is not None: + uris = [uri] + + tracks = [] + track_map = self.core.library.lookup(uris=uris) + for uri in uris: + tracks.extend(track_map[uri]) tl_tracks = [] @@ -329,8 +414,8 @@ class TracklistController(object): criteria = criteria or kwargs matches = self._tl_tracks for (key, values) in criteria.items(): - if (not isinstance(values, collections.Iterable) - or isinstance(values, compat.string_types)): + 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': @@ -436,27 +521,27 @@ class TracklistController(object): """ return self._tl_tracks[start:end] - def mark_playing(self, tl_track): - """Method for :class:`mopidy.core.PlaybackController`. **INTERNAL**""" - 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): - """Method for :class:`mopidy.core.PlaybackController`. **INTERNAL**""" + 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): - """Method for :class:`mopidy.core.PlaybackController`. **INTERNAL**""" + 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 4c4a0f6d..32a2bd9a 100644 --- a/mopidy/exceptions.py +++ b/mopidy/exceptions.py @@ -2,6 +2,7 @@ from __future__ import absolute_import, unicode_literals class MopidyException(Exception): + def __init__(self, message, *args, **kwargs): super(MopidyException, self).__init__(message, *args, **kwargs) self._message = message @@ -25,6 +26,7 @@ class ExtensionError(MopidyException): class FindError(MopidyException): + def __init__(self, message, errno=None): super(FindError, self).__init__(message, errno) self.errno = errno diff --git a/mopidy/ext.py b/mopidy/ext.py index 2f02c43b..f5f15058 100644 --- a/mopidy/ext.py +++ b/mopidy/ext.py @@ -12,6 +12,7 @@ logger = logging.getLogger(__name__) class Extension(object): + """Base class for Mopidy extensions""" dist_name = None @@ -104,6 +105,7 @@ class Extension(object): class Registry(collections.Mapping): + """Registry of components provided by Mopidy extensions. Passed to the :meth:`~Extension.setup` method of all extensions. The diff --git a/mopidy/http/data/mopidy.js b/mopidy/http/data/mopidy.js index ce2f9763..7c95a56a 100644 --- a/mopidy/http/data/mopidy.js +++ b/mopidy/http/data/mopidy.js @@ -1,6 +1,6 @@ -/*! Mopidy.js v0.4.1 - built 2014-09-11 +/*! 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 721e419c..342108f8 100644 --- a/mopidy/http/handlers.py +++ b/mopidy/http/handlers.py @@ -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__) @@ -43,6 +43,7 @@ def make_jsonrpc_wrapper(core_actor): '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, @@ -54,6 +55,7 @@ def make_jsonrpc_wrapper(core_actor): '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, @@ -73,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) @@ -111,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. @@ -130,6 +142,7 @@ def set_mopidy_headers(request_handler): class JsonRpcHandler(tornado.web.RequestHandler): + def initialize(self, core): self.jsonrpc = make_jsonrpc_wrapper(core) @@ -164,6 +177,7 @@ class JsonRpcHandler(tornado.web.RequestHandler): class ClientListHandler(tornado.web.RequestHandler): + def initialize(self, apps, statics): self.apps = apps self.statics = statics @@ -185,6 +199,7 @@ class ClientListHandler(tornado.web.RequestHandler): class StaticFileHandler(tornado.web.StaticFileHandler): + def set_extra_headers(self, path): set_mopidy_headers(self) diff --git a/mopidy/listener.py b/mopidy/listener.py index 41f8e8e0..410558ac 100644 --- a/mopidy/listener.py +++ b/mopidy/listener.py @@ -17,10 +17,25 @@ 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): + def on_event(self, event, **kwargs): """ Called on all events. diff --git a/mopidy/local/__init__.py b/mopidy/local/__init__.py index 73d07f75..ff61c17c 100644 --- a/mopidy/local/__init__.py +++ b/mopidy/local/__init__.py @@ -4,7 +4,7 @@ import logging import os import mopidy -from mopidy import config, ext +from mopidy import config, ext, models logger = logging.getLogger(__name__) @@ -24,10 +24,10 @@ 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) + 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) @@ -48,6 +48,7 @@ class Extension(ext.Extension): class Library(object): + """ Local library interface. @@ -70,6 +71,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 @@ -85,6 +90,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 @@ -135,12 +177,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 f315607a..435d19a5 100644 --- a/mopidy/local/actor.py +++ b/mopidy/local/actor.py @@ -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 d49ab8f8..d9320d4a 100644 --- a/mopidy/local/commands.py +++ b/mopidy/local/commands.py @@ -29,6 +29,7 @@ def _get_library(args, config): class LocalCommand(commands.Command): + def __init__(self): super(LocalCommand, self).__init__() self.add_child('scan', ScanCommand()) @@ -61,7 +62,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'] @@ -97,7 +101,7 @@ class ScanCommand(commands.Command): 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) @@ -130,7 +134,8 @@ 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)) - tags, duration = scanner.scan(file_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) @@ -138,9 +143,10 @@ class ScanCommand(commands.Command): 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) - track = translator.add_musicbrainz_coverart_to_track(track) - # TODO: add tags to call if library supports it. - library.add(track) + 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) @@ -157,6 +163,7 @@ class ScanCommand(commands.Command): class _Progress(object): + def __init__(self, batch_size, total): self.count = 0 self.batch_size = batch_size @@ -173,6 +180,6 @@ class _Progress(object): logger.info('Scanned %d of %d files in %ds.', self.count, self.total, duration) else: - remainder = duration // self.count * (self.total - self.count) + remainder = duration / self.count * (self.total - self.count) logger.info('Scanned %d of %d files in %ds, ~%ds left.', self.count, self.total, duration, remainder) diff --git a/mopidy/local/ext.conf b/mopidy/local/ext.conf index 535f4806..ebd7962f 100644 --- a/mopidy/local/ext.conf +++ b/mopidy/local/ext.conf @@ -3,7 +3,6 @@ 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 diff --git a/mopidy/local/json.py b/mopidy/local/json.py index 70dc68c4..22fcfa5b 100644 --- a/mopidy/local/json.py +++ b/mopidy/local/json.py @@ -8,12 +8,11 @@ import os import re import sys import tempfile -import time import mopidy 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__) @@ -75,7 +74,7 @@ class _BrowseCache(object): parent_uri = None child = None for i in reversed(range(len(parts))): - directory = '/'.join(parts[:i+1]) + directory = '/'.join(parts[:i + 1]) uri = translator.path_to_local_directory_uri(directory) # First dir we process is our parent @@ -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,10 +127,10 @@ 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) @@ -155,13 +140,47 @@ class JsonLibrary(local.Library): except KeyError: 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 compat.itervalues(self._tracks) diff --git a/mopidy/local/library.py b/mopidy/local/library.py index f3828f1b..26e20774 100644 --- a/mopidy/local/library.py +++ b/mopidy/local/library.py @@ -8,6 +8,7 @@ logger = logging.getLogger(__name__) class LocalLibraryProvider(backend.LibraryProvider): + """Proxy library that delegates work to our active local library.""" root_directory = models.Ref.directory( @@ -23,6 +24,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 @@ -41,12 +52,7 @@ class LocalLibraryProvider(backend.LibraryProvider): 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 92dc6e15..24038426 100644 --- a/mopidy/local/playback.py +++ b/mopidy/local/playback.py @@ -1,16 +1,11 @@ from __future__ import absolute_import, unicode_literals -import logging - 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 deeae2b5..00000000 --- a/mopidy/local/playlists.py +++ /dev/null @@ -1,122 +0,0 @@ -from __future__ import absolute_import, division, 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 bc46c33e..322cdd1e 100644 --- a/mopidy/local/search.py +++ b/mopidy/local/search.py @@ -3,7 +3,18 @@ from __future__ import absolute_import, unicode_literals 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: @@ -12,8 +23,6 @@ def find_exact(tracks, query=None, uris=None): _validate_query(query) for (field, values) in query.items(): - if not hasattr(values, '__iter__'): - values = [values] # FIXME this is bound to be slow for large libraries for value in values: if field == 'track_no': @@ -21,37 +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( - getattr(t, 'album', None), 'name', None) - 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) @@ -80,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: @@ -93,8 +132,6 @@ def search(tracks, query=None, uris=None): _validate_query(query) for (field, values) in query.items(): - if not hasattr(values, '__iter__'): - values = [values] # FIXME this is bound to be slow for large libraries for value in values: if field == 'track_no': @@ -102,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) @@ -161,6 +216,11 @@ 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) diff --git a/mopidy/local/storage.py b/mopidy/local/storage.py index 9cdcd12e..21d278e5 100644 --- a/mopidy/local/storage.py +++ b/mopidy/local/storage.py @@ -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 3cbe2066..92b20a7b 100644 --- a/mopidy/local/translator.py +++ b/mopidy/local/translator.py @@ -2,30 +2,15 @@ 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+),(.*)') -COVERART_BASE = 'http://coverartarchive.org/release/%s/front' - logger = logging.getLogger(__name__) -def add_musicbrainz_coverart_to_track(track): - if track.album and track.album.musicbrainz_id: - images = [COVERART_BASE % track.album.musicbrainz_id] - album = track.album.copy(images=images) - track = track.copy(album=album) - return track - - def local_track_uri_to_file_uri(uri, media_dir): return path_to_uri(local_track_uri_to_path(uri, media_dir)) @@ -38,7 +23,7 @@ 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.""" + """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) @@ -49,82 +34,3 @@ def path_to_local_directory_uri(relpath): 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..291a6194 --- /dev/null +++ b/mopidy/m3u/library.py @@ -0,0 +1,19 @@ +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 e277fe55..b25688fb 100644 --- a/mopidy/mixer.py +++ b/mopidy/mixer.py @@ -9,6 +9,7 @@ logger = logging.getLogger(__name__) class Mixer(object): + """ Audio mixer API @@ -111,6 +112,7 @@ class Mixer(object): class MixerListener(listener.Listener): + """ Marker interface for recipients of events sent by the mixer actor. diff --git a/mopidy/models.py b/mopidy/models.py index 5508d4de..1ae26811 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -4,6 +4,7 @@ import json class ImmutableObject(object): + """ Superclass for immutable objects whose fields can only be modified via the constructor. @@ -102,6 +103,7 @@ class ImmutableObject(object): class ModelJSONEncoder(json.JSONEncoder): + """ Automatically serialize Mopidy models to JSON. @@ -112,6 +114,7 @@ class ModelJSONEncoder(json.JSONEncoder): '{"a_track": {"__model__": "Track", "name": "name"}}' """ + def default(self, obj): if isinstance(obj, ImmutableObject): return obj.serialize() @@ -143,6 +146,7 @@ def model_json_decoder(dct): class Ref(ImmutableObject): + """ Model to represent URI references with a human friendly name and type attached. This is intended for use a lightweight object "free" of metadata @@ -153,7 +157,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,7 +216,26 @@ 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 :type uri: string @@ -233,6 +256,7 @@ class Artist(ImmutableObject): class Album(ImmutableObject): + """ :param uri: album URI :type uri: string @@ -286,6 +310,7 @@ class Album(ImmutableObject): class Track(ImmutableObject): + """ :param uri: track URI :type uri: string @@ -308,7 +333,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 +386,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') @@ -375,6 +403,7 @@ class Track(ImmutableObject): class TlTrack(ImmutableObject): + """ A tracklist track. Wraps a regular track and it's tracklist ID. @@ -413,6 +442,7 @@ class TlTrack(ImmutableObject): class Playlist(ImmutableObject): + """ :param uri: playlist URI :type uri: string @@ -453,6 +483,7 @@ class Playlist(ImmutableObject): class SearchResult(ImmutableObject): + """ :param uri: search result URI :type uri: string diff --git a/mopidy/mpd/__init__.py b/mopidy/mpd/__init__.py index 05c83baa..b2438b07 100644 --- a/mopidy/mpd/__init__.py +++ b/mopidy/mpd/__init__.py @@ -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 c8123c32..36775578 100644 --- a/mopidy/mpd/actor.py +++ b/mopidy/mpd/actor.py @@ -6,18 +6,20 @@ 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__) class MpdFrontend(pykka.ThreadingActor, CoreListener): + def __init__(self, config, core): super(MpdFrontend, self).__init__() 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 +31,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 +74,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 5d9cecd9..5abc1b4b 100644 --- a/mopidy/mpd/dispatcher.py +++ b/mopidy/mpd/dispatcher.py @@ -13,6 +13,7 @@ protocol.load_protocol_modules() class MpdDispatcher(object): + """ The MPD session feeds the MPD dispatcher with requests. The dispatcher finds the correct handler, processes the request and sends the response @@ -21,7 +22,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 +30,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 +164,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: @@ -204,6 +210,7 @@ class MpdDispatcher(object): class MpdContext(object): + """ This object is passed as the first argument to all MPD command handlers to give the command handlers access to important parts of Mopidy. @@ -227,10 +234,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,58 +245,19 @@ 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): """ @@ -301,10 +269,10 @@ class MpdContext(object): 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. + ``data`` is a future which will contain the results from looking up + the URI with :meth:`mopidy.core.LibraryController.lookup` 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`. @@ -313,8 +281,8 @@ class MpdContext(object): 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 != ref.TRACK and ref.name == part: @@ -322,10 +290,7 @@ class MpdContext(object): 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) @@ -335,11 +300,12 @@ 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 == ref.TRACK: if lookup: - yield (path, self.core.library.lookup(ref.uri)) + # TODO: can we lookup all the refs at once now? + yield (path, self.core.library.lookup(uris=[ref.uri])) else: yield (path, ref) else: diff --git a/mopidy/mpd/exceptions.py b/mopidy/mpd/exceptions.py index e7ab0068..3bd51567 100644 --- a/mopidy/mpd/exceptions.py +++ b/mopidy/mpd/exceptions.py @@ -4,6 +4,7 @@ from mopidy.exceptions import MopidyException class MpdAckError(MopidyException): + """See fields on this class for available MPD error codes""" ACK_ERROR_NOT_LIST = 1 @@ -59,6 +60,7 @@ class MpdUnknownError(MpdAckError): class MpdUnknownCommand(MpdUnknownError): + def __init__(self, *args, **kwargs): super(MpdUnknownCommand, self).__init__(*args, **kwargs) assert self.command is not None, 'command must be given explicitly' @@ -67,6 +69,7 @@ class MpdUnknownCommand(MpdUnknownError): class MpdNoCommand(MpdUnknownCommand): + def __init__(self, *args, **kwargs): kwargs['command'] = '' super(MpdNoCommand, self).__init__(*args, **kwargs) @@ -87,3 +90,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 ff04d435..e6b88dbd 100644 --- a/mopidy/mpd/protocol/__init__.py +++ b/mopidy/mpd/protocol/__init__.py @@ -83,6 +83,7 @@ def RANGE(value): # noqa: N802 class Commands(object): + """Collection of MPD commands to expose to users. Normally used through the global instance which command handlers have been diff --git a/mopidy/mpd/protocol/audio_output.py b/mopidy/mpd/protocol/audio_output.py index 4a5310f5..565ea3d0 100644 --- a/mopidy/mpd/protocol/audio_output.py +++ b/mopidy/mpd/protocol/audio_output.py @@ -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/current_playlist.py b/mopidy/mpd/protocol/current_playlist.py index 33c090e3..38ad4017 100644 --- a/mopidy/mpd/protocol/current_playlist.py +++ b/mopidy/mpd/protocol/current_playlist.py @@ -1,8 +1,7 @@ from __future__ import absolute_import, unicode_literals -import warnings - from mopidy.mpd import exceptions, protocol, translator +from mopidy.utils import deprecation @protocol.commands.add('add') @@ -22,21 +21,21 @@ def add(context, uri): if not uri.strip('/'): return - if context.core.tracklist.add(uri=uri).get(): + if context.core.tracklist.add(uris=[uri]).get(): return try: - tracks = [] - for path, lookup_future in context.browse(uri): - if lookup_future: - tracks.extend(lookup_future.get()) + uris = [] + for path, ref in context.browse(uri, lookup=False): + if ref: + uris.append(ref.uri) except exceptions.MpdNoExistError as e: e.message = 'directory or file not found' raise - if not tracks: + if not uris: raise exceptions.MpdNoExistError('directory or file not found') - context.core.tracklist.add(tracks=tracks) + context.core.tracklist.add(uris=uris).get() @protocol.commands.add('addid', songpos=protocol.UINT) @@ -62,7 +61,8 @@ def addid(context, uri, songpos=None): raise exceptions.MpdNoExistError('No such song') if songpos is not None and songpos > context.core.tracklist.length.get(): raise exceptions.MpdArgError('Bad song index') - tl_tracks = context.core.tracklist.add(uri=uri, at_position=songpos).get() + tl_tracks = context.core.tracklist.add( + uris=[uri], at_position=songpos).get() if not tl_tracks: raise exceptions.MpdNoExistError('No such song') return ('Id', tl_tracks[0].tlid) @@ -162,8 +162,7 @@ def playlist(context): Do not use this, instead use ``playlistinfo``. """ - warnings.warn( - 'Do not use this, instead use playlistinfo', DeprecationWarning) + deprecation.warn('mpd.protocol.current_playlist.playlist') return playlistinfo(context) @@ -275,9 +274,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) @@ -337,8 +348,12 @@ def swap(context, songpos1, songpos2): tracks.insert(songpos1, song2) del tracks[songpos2] tracks.insert(songpos2, song1) + + # TODO: do we need a tracklist.replace() context.core.tracklist.clear() - context.core.tracklist.add(tracks) + + with deprecation.ignore('core.tracklist.add:tracks_arg'): + context.core.tracklist.add(tracks=tracks).get() @protocol.commands.add('swapid', tlid1=protocol.UINT, tlid2=protocol.UINT) diff --git a/mopidy/mpd/protocol/music_db.py b/mopidy/mpd/protocol/music_db.py index c143df31..fc726255 100644 --- a/mopidy/mpd/protocol/music_db.py +++ b/mopidy/mpd/protocol/music_db.py @@ -2,6 +2,7 @@ from __future__ import absolute_import, unicode_literals import functools import itertools +import warnings from mopidy.models import Track from mopidy.mpd import exceptions, protocol, translator @@ -30,6 +31,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 = {} @@ -91,7 +101,7 @@ def count(context, *args): query = _query_from_mpd_search_parameters(args, _SEARCH_MAPPING) except ValueError: raise exceptions.MpdArgError('incorrect arguments') - results = context.core.library.find_exact(**query).get() + results = context.core.library.search(query=query, exact=True).get() result_tracks = _get_tracks(results) return [ ('songs', len(result_tracks)), @@ -132,7 +142,7 @@ def find(context, *args): except ValueError: return - results = context.core.library.find_exact(**query).get() + results = context.core.library.search(query=query, exact=True).get() result_tracks = [] if ('artist' not in query and 'albumartist' not in query and @@ -159,8 +169,14 @@ def findadd(context, *args): query = _query_from_mpd_search_parameters(args, _SEARCH_MAPPING) except ValueError: return - results = context.core.library.find_exact(**query).get() - context.core.tracklist.add(_get_tracks(results)) + + results = context.core.library.search(query=query, exact=True).get() + + with warnings.catch_warnings(): + # TODO: for now just use tracks as other wise we have to lookup the + # tracks we just got from the search. + warnings.filterwarnings('ignore', 'tracklist.add.*"tracks" argument.*') + context.core.tracklist.add(tracks=_get_tracks(results)).get() @protocol.commands.add('list') @@ -246,109 +262,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 +296,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,14 +325,22 @@ 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): if not lookup_future: result.append(('directory', path)) else: - for track in lookup_future.get(): - result.extend(translator.track_to_mpd_format(track)) + for tracks in lookup_future.get().values(): + for track in tracks: + result.extend(translator.track_to_mpd_format(track)) return result @@ -414,9 +366,9 @@ def lsinfo(context, uri=None): if not lookup_future: result.append(('directory', path.lstrip('/'))) else: - tracks = lookup_future.get() - if tracks: - result.extend(translator.track_to_mpd_format(tracks[0])) + for tracks in lookup_future.get().values(): + if tracks: + result.extend(translator.track_to_mpd_format(tracks[0])) if uri in (None, '', '/'): result.extend(protocol.stored_playlists.listplaylists(context)) @@ -468,7 +420,7 @@ def search(context, *args): query = _query_from_mpd_search_parameters(args, _SEARCH_MAPPING) except ValueError: return - results = context.core.library.search(**query).get() + results = context.core.library.search(query).get() artists = [_artist_as_track(a) for a in _get_artists(results)] albums = [_album_as_track(a) for a in _get_albums(results)] tracks = _get_tracks(results) @@ -492,8 +444,14 @@ def searchadd(context, *args): query = _query_from_mpd_search_parameters(args, _SEARCH_MAPPING) except ValueError: return - results = context.core.library.search(**query).get() - context.core.tracklist.add(_get_tracks(results)) + + results = context.core.library.search(query).get() + + with warnings.catch_warnings(): + # TODO: for now just use tracks as other wise we have to lookup the + # tracks we just got from the search. + warnings.filterwarnings('ignore', 'tracklist.add.*"tracks".*') + context.core.tracklist.add(_get_tracks(results)).get() @protocol.commands.add('searchaddpl') @@ -519,9 +477,10 @@ def searchaddpl(context, *args): query = _query_from_mpd_search_parameters(parameters, _SEARCH_MAPPING) except ValueError: return - results = context.core.library.search(**query).get() + 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 07102492..6beb4277 100644 --- a/mopidy/mpd/protocol/playback.py +++ b/mopidy/mpd/protocol/playback.py @@ -1,9 +1,8 @@ from __future__ import absolute_import, unicode_literals -import warnings - from mopidy.core import PlaybackState from mopidy.mpd import exceptions, protocol +from mopidy.utils import deprecation @protocol.commands.add('consume', state=protocol.BOOL) @@ -32,8 +31,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 +44,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 +58,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') @@ -136,9 +133,7 @@ def pause(context, state=None): - Calls ``pause`` without any arguments to toogle pause. """ if state is None: - warnings.warn( - 'The use of pause command w/o the PAUSE argument is deprecated.', - DeprecationWarning) + deprecation.warn('mpd.protocol.playback.pause:state_arg') if (context.core.playback.state.get() == PlaybackState.PLAYING): context.core.playback.pause() @@ -397,7 +392,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/status.py b/mopidy/mpd/protocol/status.py index 9dae635e..aa78b387 100644 --- a/mopidy/mpd/protocol/status.py +++ b/mopidy/mpd/protocol/status.py @@ -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/stored_playlists.py b/mopidy/mpd/protocol/stored_playlists.py index f273e9b9..a5d4b180 100644 --- a/mopidy/mpd/protocol/stored_playlists.py +++ b/mopidy/mpd/protocol/stored_playlists.py @@ -1,6 +1,7 @@ from __future__ import absolute_import, division, unicode_literals import datetime +import warnings from mopidy.mpd import exceptions, protocol, translator @@ -20,7 +21,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 +42,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 +76,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,10 +124,14 @@ 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]) + + with warnings.catch_warnings(): + warnings.filterwarnings('ignore', 'tracklist.add.*"tracks".*') + context.core.tracklist.add(playlist.tracks[playlist_slice]).get() @protocol.commands.add('playlistadd') diff --git a/mopidy/mpd/session.py b/mopidy/mpd/session.py index 0e606c8f..adbf6cc3 100644 --- a/mopidy/mpd/session.py +++ b/mopidy/mpd/session.py @@ -9,6 +9,7 @@ logger = logging.getLogger(__name__) class MpdSession(network.LineProtocol): + """ The MPD client session. Keeps track of a single client session. Any requests from the client is passed on to the MPD request dispatcher. @@ -18,10 +19,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/translator.py b/mopidy/mpd/translator.py index 23fb2874..8359f86b 100644 --- a/mopidy/mpd/translator.py +++ b/mopidy/mpd/translator.py @@ -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)) diff --git a/mopidy/mpd/uri_mapper.py b/mopidy/mpd/uri_mapper.py new file mode 100644 index 00000000..37e4b783 --- /dev/null +++ b/mopidy/mpd/uri_mapper.py @@ -0,0 +1,76 @@ +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/mixer.py b/mopidy/softwaremixer/mixer.py index dadbbec8..d94a0be2 100644 --- a/mopidy/softwaremixer/mixer.py +++ b/mopidy/softwaremixer/mixer.py @@ -21,16 +21,13 @@ class SoftwareMixer(pykka.ThreadingActor, mixer.Mixer): self._initial_volume = None self._initial_mute = None - # TODO: shouldn't this be logged by thing that choose us? - 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 is injected into the - # software mixer and has no effect. Thus, we need to set the initial - # volume again. + # 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: diff --git a/mopidy/stream/actor.py b/mopidy/stream/actor.py index 9599d9d3..81e07b6d 100644 --- a/mopidy/stream/actor.py +++ b/mopidy/stream/actor.py @@ -15,12 +15,14 @@ logger = logging.getLogger(__name__) class StreamBackend(pykka.ThreadingActor, backend.Backend): + def __init__(self, config, audio): super(StreamBackend, self).__init__() 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 +31,10 @@ 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(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,9 +47,9 @@ class StreamLibraryProvider(backend.LibraryProvider): return [Track(uri=uri)] try: - tags, duration = self._scanner.scan(uri) - track = utils.convert_tags_to_track(tags).copy( - uri=uri, length=duration) + 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/deprecation.py b/mopidy/utils/deprecation.py new file mode 100644 index 00000000..57042347 --- /dev/null +++ b/mopidy/utils/deprecation.py @@ -0,0 +1,76 @@ +from __future__ import unicode_literals + +import contextlib +import re +import warnings + +# Messages used in deprecation warnings are collected here so we can target +# them easily when ignoring warnings. +_MESSAGES = { + # Deprecated features mpd: + 'mpd.protocol.playback.pause:state_arg': + 'The use of pause command w/o the PAUSE argument is deprecated.', + 'mpd.protocol.current_playlist.playlist': + 'Do not use this, instead use playlistinfo', + + # Deprecated features in audio: + 'audio.emit_end_of_stream': 'audio.emit_end_of_stream() is deprecated', + + # Deprecated features in core libary: + 'core.library.find_exact': 'library.find_exact() is deprecated', + 'core.library.lookup:uri_arg': + 'library.lookup() "uri" argument is deprecated', + 'core.library.search:kwargs_query': + 'library.search() with "kwargs" as query is deprecated', + 'core.library.search:empty_query': + 'library.search() with empty "query" argument deprecated', + + # Deprecated features in core playback: + 'core.playback.get_mute': 'playback.get_mute() is deprecated', + 'core.playback.set_mute': 'playback.set_mute() is deprecated', + 'core.playback.get_volume': 'playback.get_volume() is deprecated', + 'core.playback.set_volume': 'playback.set_volume() is deprecated', + + # Deprecated features in core playlists: + 'core.playlists.filter': 'playlists.filter() is deprecated', + 'core.playlists.get_playlists': 'playlists.get_playlists() is deprecated', + + # Deprecated features in core tracklist: + 'core.tracklist.add:tracks_arg': + 'tracklist.add() "tracks" argument is deprecated', + 'core.tracklist.add:uri_arg': + 'tracklist.add() "uri" argument is deprecated', +} + + +def warn(msg_id): + warnings.warn(_MESSAGES.get(msg_id, msg_id), DeprecationWarning) + + +@contextlib.contextmanager +def ignore(ids=None): + with warnings.catch_warnings(): + if isinstance(ids, basestring): + ids = [ids] + + if ids: + for msg_id in ids: + msg = re.escape(_MESSAGES.get(msg_id, msg_id)) + warnings.filterwarnings('ignore', msg, DeprecationWarning) + else: + warnings.filterwarnings('ignore', category=DeprecationWarning) + yield + + +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 886b8818..bc9f7c2f 100644 --- a/mopidy/utils/deps.py +++ b/mopidy/utils/deps.py @@ -3,6 +3,7 @@ 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/jsonrpc.py b/mopidy/utils/jsonrpc.py index 13199b26..e567ef87 100644 --- a/mopidy/utils/jsonrpc.py +++ b/mopidy/utils/jsonrpc.py @@ -10,6 +10,7 @@ from mopidy import compat class JsonRpcWrapper(object): + """ Wrap objects and make them accessible through JSON-RPC 2.0 messaging. @@ -278,6 +279,7 @@ def get_combined_json_decoder(decoders): def get_combined_json_encoder(encoders): class JsonRpcEncoder(json.JSONEncoder): + def default(self, obj): for encoder in encoders: try: @@ -289,6 +291,7 @@ def get_combined_json_encoder(encoders): class JsonRpcInspector(object): + """ Inspects a group of classes and functions to create a description of what methods they can expose over JSON-RPC 2.0. diff --git a/mopidy/utils/log.py b/mopidy/utils/log.py index 396c05b9..9c40da4f 100644 --- a/mopidy/utils/log.py +++ b/mopidy/utils/log.py @@ -12,10 +12,16 @@ 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): logging.Handler.__init__(self) self._released = False @@ -42,6 +48,7 @@ def bootstrap_delayed_logging(): def setup_logging(config, verbosity_level, save_debug_log): + logging.captureWarnings(True) if config['logging']['config_file']: @@ -76,7 +83,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) @@ -95,6 +102,7 @@ def setup_debug_logging_to_file(config): class VerbosityFilter(logging.Filter): + def __init__(self, verbosity_level, loglevels): self.verbosity_level = verbosity_level self.loglevels = loglevels @@ -111,7 +119,13 @@ 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 +138,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 +177,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 ce02ef0e..000382e3 100644 --- a/mopidy/utils/network.py +++ b/mopidy/utils/network.py @@ -18,6 +18,7 @@ logger = logging.getLogger(__name__) class ShouldRetrySocketCall(Exception): + """Indicate that attempted socket call should be retried""" @@ -65,6 +66,7 @@ def format_hostname(hostname): class Server(object): + """Setup listener and register it with gobject's event loop.""" def __init__(self, host, port, protocol, protocol_kwargs=None, @@ -199,7 +201,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): @@ -304,6 +307,7 @@ class Connection(object): class LineProtocol(pykka.ThreadingActor): + """ Base class for handling line based protocols. diff --git a/mopidy/utils/path.py b/mopidy/utils/path.py index c72d3b18..e845cd95 100644 --- a/mopidy/utils/path.py +++ b/mopidy/utils/path.py @@ -12,6 +12,7 @@ import glib from mopidy import compat, exceptions from mopidy.compat import queue +from mopidy.utils import encoding logger = logging.getLogger(__name__) @@ -157,7 +158,8 @@ def _find_worker(relative, follow, done, work, results, errors): errors[path] = exceptions.FindError('Not a file or directory.') except OSError as e: - errors[path] = exceptions.FindError(e.strerror, e.errno) + errors[path] = exceptions.FindError( + encoding.locale_decode(e.strerror), e.errno) finally: work.task_done() @@ -200,7 +202,7 @@ def _find(root, thread_count=10, relative=False, follow=False): def find_mtimes(root, follow=False): results, errors = _find(root, relative=False, follow=follow) - mtimes = dict((f, int(st.st_mtime)) for f, st in results.items()) + mtimes = dict((f, int(st.st_mtime * 1000)) for f, st in results.items()) return mtimes, errors @@ -225,6 +227,7 @@ def check_file_path_is_inside_base_dir(file_path, base_path): # FIXME replace with mock usage in tests. class Mtime(object): + def __init__(self): self.fake = None diff --git a/mopidy/utils/process.py b/mopidy/utils/process.py index 5b2bb9c0..e826e43c 100644 --- a/mopidy/utils/process.py +++ b/mopidy/utils/process.py @@ -53,6 +53,7 @@ def stop_remaining_actors(): class BaseThread(threading.Thread): + def __init__(self): super(BaseThread, self).__init__() # No thread should block process from exiting 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/zeroconf.py b/mopidy/zeroconf.py index 0c42dd74..ddd155b6 100644 --- a/mopidy/zeroconf.py +++ b/mopidy/zeroconf.py @@ -31,6 +31,7 @@ def _convert_text_list_to_dbus_format(text_list): class Zeroconf(object): + """Publish a network service with Zeroconf. Currently, this only works on Linux using Avahi via D-Bus. diff --git a/setup.cfg b/setup.cfg index 80ab9645..95211279 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +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 +ignore = E402 [wheel] universal = 1 diff --git a/setup.py b/setup.py index 384aaec5..9f33236f 100644 --- a/setup.py +++ b/setup.py @@ -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 index 7b5692e3..9353eb8a 100644 --- a/tasks.py +++ b/tasks.py @@ -15,11 +15,9 @@ def test(path=None, coverage=False, watch=False, warn=False): if watch: return watcher(test, path=path, coverage=coverage) path = path or 'tests/' - cmd = 'nosetests' + cmd = 'py.test' if coverage: - cmd += ( - ' --with-coverage --cover-package=mopidy' - ' --cover-branches --cover-html') + cmd += ' --cov=mopidy --cov-report=term-missing' cmd += ' %s' % path run(cmd, pty=True, warn=warn) diff --git a/tests/__init__.py b/tests/__init__.py index 82759578..fc8d5dcf 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -15,6 +15,7 @@ def path_to_data_dir(name): class IsA(object): + def __init__(self, klass): self.klass = klass @@ -22,7 +23,7 @@ class IsA(object): try: return isinstance(rhs, self.klass) except TypeError: - return type(rhs) == type(self.klass) + return type(rhs) == type(self.klass) # flake8: noqa def __ne__(self, rhs): return not self.__eq__(rhs) diff --git a/tests/audio/test_actor.py b/tests/audio/test_actor.py index df83d130..4a442481 100644 --- a/tests/audio/test_actor.py +++ b/tests/audio/test_actor.py @@ -15,11 +15,10 @@ import mock import pykka from mopidy import audio, listener -from mopidy.audio import dummy as dummy_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 @@ -80,6 +79,7 @@ class DummyMixin(object): class AudioTest(BaseTest): + def test_start_playback_existing_file(self): self.audio.prepare_change() self.audio.set_uri(self.uris[0]) @@ -156,6 +156,7 @@ class DummyAudioListener(pykka.ThreadingActor, audio.AudioListener): class AudioEventTest(BaseTest): + def setUp(self): # noqa: N802 super(AudioEventTest, self).setUp() self.audio.enable_sync_handler().get() @@ -439,11 +440,13 @@ class AudioEventTest(BaseTest): 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): @@ -464,6 +467,7 @@ class MixerTest(BaseTest): class AudioStateTest(unittest.TestCase): + def setUp(self): # noqa: N802 self.audio = audio.Audio(config=None, mixer=None) @@ -509,6 +513,7 @@ class AudioStateTest(unittest.TestCase): class AudioBufferingTest(unittest.TestCase): + def setUp(self): # noqa: N802 self.audio = audio.Audio(config=None, mixer=None) self.audio._playbin = mock.Mock(spec=['set_state']) diff --git a/tests/audio/test_listener.py b/tests/audio/test_listener.py index 5cac75bb..8d32e4c6 100644 --- a/tests/audio/test_listener.py +++ b/tests/audio/test_listener.py @@ -8,6 +8,7 @@ from mopidy import audio class AudioListenerTest(unittest.TestCase): + def setUp(self): # noqa: N802 self.listener = audio.AudioListener() diff --git a/tests/audio/test_playlists.py b/tests/audio/test_playlists.py index f01568f8..769e1592 100644 --- a/tests/audio/test_playlists.py +++ b/tests/audio/test_playlists.py @@ -78,6 +78,7 @@ XSPF = b""" class TypeFind(object): + def __init__(self, data): self.data = data diff --git a/tests/audio/test_scan.py b/tests/audio/test_scan.py index 50ec8352..1a4fec7e 100644 --- a/tests/audio/test_scan.py +++ b/tests/audio/test_scan.py @@ -14,6 +14,7 @@ from tests import path_to_data_dir class ScannerTest(unittest.TestCase): + def setUp(self): # noqa: N802 self.errors = {} self.tags = {} @@ -31,9 +32,9 @@ class ScannerTest(unittest.TestCase): uri = path_lib.path_to_uri(path) key = uri[len('file://'):] try: - tags, duration = scanner.scan(uri) - self.tags[key] = tags - self.durations[key] = duration + result = scanner.scan(uri) + self.tags[key] = result.tags + self.durations[key] = result.duration except exceptions.ScannerError as error: self.errors[key] = error @@ -41,17 +42,26 @@ class ScannerTest(unittest.TestCase): name = path_to_data_dir(name) self.assertEqual(self.tags[name][key], value) + def check_if_missing_plugin(self): + if any(['missing a plug-in' in str(e) for e in self.errors.values()]): + raise unittest.SkipTest('Missing MP3 support?') + def test_tags_is_set(self): self.scan(self.find('scanner/simple')) self.assert_(self.tags) def test_errors_is_not_set(self): self.scan(self.find('scanner/simple')) + + self.check_if_missing_plugin() + self.assert_(not self.errors) def test_duration_is_set(self): self.scan(self.find('scanner/simple')) + self.check_if_missing_plugin() + self.assertEqual( self.durations[path_to_data_dir('scanner/simple/song1.mp3')], 4680) self.assertEqual( @@ -59,16 +69,25 @@ class ScannerTest(unittest.TestCase): def test_artist_is_set(self): self.scan(self.find('scanner/simple')) + + self.check_if_missing_plugin() + 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_if_missing_plugin() + 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_if_missing_plugin() + self.check('scanner/simple/song1.mp3', 'title', ['trackname']) self.check('scanner/simple/song1.ogg', 'title', ['trackname']) @@ -82,6 +101,9 @@ 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.check_if_missing_plugin() + self.assertLess( self.durations[path_to_data_dir('scanner/example.log')], 100) diff --git a/tests/audio/test_utils.py b/tests/audio/test_utils.py index f1f15761..a49ead90 100644 --- a/tests/audio/test_utils.py +++ b/tests/audio/test_utils.py @@ -11,6 +11,7 @@ from mopidy.models import Album, Artist, Track # 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'], 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 ae8bbffe..48d7fd22 100644 --- a/tests/backend/test_listener.py +++ b/tests/backend/test_listener.py @@ -8,6 +8,7 @@ from mopidy import backend class BackendListenerTest(unittest.TestCase): + def setUp(self): # noqa: N802 self.listener = backend.BackendListener() diff --git a/tests/config/test_config.py b/tests/config/test_config.py index b893c5df..139f3a69 100644 --- a/tests/config/test_config.py +++ b/tests/config/test_config.py @@ -12,9 +12,22 @@ from tests import path_to_data_dir 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,6 +97,7 @@ class LoadConfigTest(unittest.TestCase): class ValidateTest(unittest.TestCase): + 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 8412b899..e84a3aff 100644 --- a/tests/config/test_schemas.py +++ b/tests/config/test_schemas.py @@ -11,6 +11,7 @@ from tests import any_unicode class ConfigSchemaTest(unittest.TestCase): + def setUp(self): # noqa: N802 self.schema = schemas.ConfigSchema('test') self.schema['foo'] = mock.Mock() @@ -86,9 +87,10 @@ 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,6 +99,7 @@ class LogLevelConfigSchemaTest(unittest.TestCase): class DidYouMeanTest(unittest.TestCase): + def test_suggestions(self): choices = ('enabled', 'username', 'password', 'bitrate', 'timeout') diff --git a/tests/config/test_types.py b/tests/config/test_types.py index 939d028b..be1ab829 100644 --- a/tests/config/test_types.py +++ b/tests/config/test_types.py @@ -15,6 +15,7 @@ from mopidy.config import types class ConfigValueTest(unittest.TestCase): + def test_deserialize_passes_through(self): value = types.ConfigValue() sentinel = object() @@ -36,6 +37,7 @@ class ConfigValueTest(unittest.TestCase): class DeprecatedTest(unittest.TestCase): + def test_deserialize_returns_deprecated_value(self): self.assertIsInstance(types.Deprecated().deserialize(b'foobar'), types.DeprecatedValue) @@ -46,6 +48,7 @@ class DeprecatedTest(unittest.TestCase): class StringTest(unittest.TestCase): + def test_deserialize_conversion_success(self): value = types.String() self.assertEqual('foo', value.deserialize(b' foo ')) @@ -117,6 +120,7 @@ class StringTest(unittest.TestCase): class SecretTest(unittest.TestCase): + def test_deserialize_decodes_utf8(self): value = types.Secret() result = value.deserialize('æøå'.encode('utf-8')) @@ -152,6 +156,7 @@ class SecretTest(unittest.TestCase): class IntegerTest(unittest.TestCase): + def test_deserialize_conversion_success(self): value = types.Integer() self.assertEqual(123, value.deserialize('123')) @@ -186,6 +191,7 @@ class IntegerTest(unittest.TestCase): class BooleanTest(unittest.TestCase): + def test_deserialize_conversion_success(self): value = types.Boolean() for true in ('1', 'yes', 'true', 'on'): @@ -281,11 +287,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() @@ -309,6 +318,7 @@ class LogLevelTest(unittest.TestCase): class HostnameTest(unittest.TestCase): + @mock.patch('socket.getaddrinfo') def test_deserialize_conversion_success(self, getaddrinfo_mock): value = types.Hostname() @@ -336,6 +346,7 @@ class HostnameTest(unittest.TestCase): class PortTest(unittest.TestCase): + def test_valid_ports(self): value = types.Port() self.assertEqual(0, value.deserialize('0')) @@ -353,6 +364,7 @@ class PortTest(unittest.TestCase): class ExpandedPathTest(unittest.TestCase): + def test_is_bytes(self): self.assertIsInstance(types.ExpandedPath(b'/tmp', b'foo'), bytes) @@ -370,6 +382,7 @@ class ExpandedPathTest(unittest.TestCase): class PathTest(unittest.TestCase): + def test_deserialize_conversion_success(self): result = types.Path().deserialize(b'/foo') self.assertEqual('/foo', result) diff --git a/tests/config/test_validator.py b/tests/config/test_validator.py index 8172df0c..cafb1788 100644 --- a/tests/config/test_validator.py +++ b/tests/config/test_validator.py @@ -6,6 +6,7 @@ from mopidy.config import validators class ValidateChoiceTest(unittest.TestCase): + def test_no_choices_passes(self): validators.validate_choice('foo', None) @@ -25,6 +26,7 @@ class ValidateChoiceTest(unittest.TestCase): class ValidateMinimumTest(unittest.TestCase): + def test_no_minimum_passes(self): validators.validate_minimum(10, None) @@ -39,6 +41,7 @@ class ValidateMinimumTest(unittest.TestCase): class ValidateMaximumTest(unittest.TestCase): + def test_no_maximum_passes(self): validators.validate_maximum(5, None) @@ -53,6 +56,7 @@ class ValidateMaximumTest(unittest.TestCase): class ValidateRequiredTest(unittest.TestCase): + def test_passes_when_false(self): validators.validate_required('foo', False) validators.validate_required('', False) diff --git a/tests/core/test_actor.py b/tests/core/test_actor.py index e82962dc..520c5026 100644 --- a/tests/core/test_actor.py +++ b/tests/core/test_actor.py @@ -11,6 +11,7 @@ from mopidy.utils import versioning class CoreActorTest(unittest.TestCase): + def setUp(self): # noqa: N802 self.backend1 = mock.Mock() self.backend1.uri_schemes.get.return_value = ['dummy1'] diff --git a/tests/core/test_events.py b/tests/core/test_events.py index 7226673d..e916b670 100644 --- a/tests/core/test_events.py +++ b/tests/core/test_events.py @@ -7,15 +7,22 @@ import mock import pykka from mopidy import core -from mopidy.backend import dummy from mopidy.models import Track +from mopidy.utils import deprecation + +from tests import dummy_backend @mock.patch.object(core.CoreListener, 'send') class BackendEventsTest(unittest.TestCase): + def setUp(self): # noqa: N802 - self.backend = dummy.create_dummy_backend_proxy() - self.core = core.Core.start(backends=[self.backend]).proxy() + self.backend = dummy_backend.create_proxy() + self.backend.library.dummy_library = [ + Track(uri='dummy:a'), Track(uri='dummy:b')] + + with deprecation.ignore(): + self.core = core.Core.start(backends=[self.backend]).proxy() def tearDown(self): # noqa: N802 pykka.ActorRegistry.stop_all() @@ -40,12 +47,12 @@ class BackendEventsTest(unittest.TestCase): def test_tracklist_add_sends_tracklist_changed_event(self, send): send.reset_mock() - self.core.tracklist.add([Track(uri='dummy:a')]).get() + self.core.tracklist.add(uris=['dummy:a']).get() self.assertEqual(send.call_args[0][0], 'tracklist_changed') def test_tracklist_clear_sends_tracklist_changed_event(self, send): - self.core.tracklist.add([Track(uri='dummy:a')]).get() + self.core.tracklist.add(uris=['dummy:a']).get() send.reset_mock() self.core.tracklist.clear().get() @@ -53,8 +60,7 @@ class BackendEventsTest(unittest.TestCase): self.assertEqual(send.call_args[0][0], 'tracklist_changed') def test_tracklist_move_sends_tracklist_changed_event(self, send): - self.core.tracklist.add( - [Track(uri='dummy:a'), Track(uri='dummy:b')]).get() + self.core.tracklist.add(uris=['dummy:a', 'dummy:b']).get() send.reset_mock() self.core.tracklist.move(0, 1, 1).get() @@ -62,7 +68,7 @@ class BackendEventsTest(unittest.TestCase): self.assertEqual(send.call_args[0][0], 'tracklist_changed') def test_tracklist_remove_sends_tracklist_changed_event(self, send): - self.core.tracklist.add([Track(uri='dummy:a')]).get() + self.core.tracklist.add(uris=['dummy:a']).get() send.reset_mock() self.core.tracklist.remove(uri=['dummy:a']).get() @@ -70,8 +76,7 @@ class BackendEventsTest(unittest.TestCase): self.assertEqual(send.call_args[0][0], 'tracklist_changed') def test_tracklist_shuffle_sends_tracklist_changed_event(self, send): - self.core.tracklist.add( - [Track(uri='dummy:a'), Track(uri='dummy:b')]).get() + self.core.tracklist.add(uris=['dummy:a', 'dummy:b']).get() send.reset_mock() self.core.tracklist.shuffle().get() diff --git a/tests/core/test_history.py b/tests/core/test_history.py index 42922e52..48062aaf 100644 --- a/tests/core/test_history.py +++ b/tests/core/test_history.py @@ -18,24 +18,24 @@ class PlaybackHistoryTest(unittest.TestCase): self.history = HistoryController() def test_add_track(self): - self.history.add(self.tracks[0]) + self.history._add_track(self.tracks[0]) self.assertEqual(self.history.get_length(), 1) - self.history.add(self.tracks[1]) + self.history._add_track(self.tracks[1]) self.assertEqual(self.history.get_length(), 2) - self.history.add(self.tracks[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(object()) + 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) + self.history._add_track(track) result = self.history.get_history() (timestamp, ref) = result[0] diff --git a/tests/core/test_library.py b/tests/core/test_library.py index 9bd3b244..8d2195a2 100644 --- a/tests/core/test_library.py +++ b/tests/core/test_library.py @@ -5,15 +5,19 @@ import unittest import mock from mopidy import backend, core -from mopidy.models import Ref, SearchResult, Track +from mopidy.models import Image, Ref, SearchResult, Track +from mopidy.utils import deprecation -class CoreLibraryTest(unittest.TestCase): +class BaseCoreLibraryTest(unittest.TestCase): + 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 +25,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 +39,54 @@ class CoreLibraryTest(unittest.TestCase): self.core = core.Core(mixer=None, backends=[ self.backend1, self.backend2, self.backend3]) + +# TODO: split by method +class CoreLibraryTest(BaseCoreLibraryTest): + + 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) @@ -97,22 +151,21 @@ class CoreLibraryTest(unittest.TestCase): Ref.track(uri='dummy1:track:/foo/baz.mp3', name='Baz'), ]) - def test_lookup_selects_dummy1_backend(self): - self.core.library.lookup('dummy1:a') + def test_lookup_fails_with_uri_and_uris_set(self): + with self.assertRaises(ValueError): + self.core.library.lookup('dummy1:a', ['dummy2:a']) - self.library1.lookup.assert_called_once_with('dummy1:a') - self.assertFalse(self.library2.lookup.called) + def test_lookup_can_handle_uris(self): + self.library1.lookup().get.return_value = [1234] + self.library2.lookup().get.return_value = [5678] - def test_lookup_selects_dummy2_backend(self): - self.core.library.lookup('dummy2:a') + result = self.core.library.lookup(uris=['dummy1:a', 'dummy2:a']) + self.assertEqual(result, {'dummy2:a': [5678], 'dummy1:a': [1234]}) - self.assertFalse(self.library1.lookup.called) - self.library2.lookup.assert_called_once_with('dummy2:a') + def test_lookup_uris_returns_empty_list_for_dummy3_track(self): + result = self.core.library.lookup(uris=['dummy3:a']) - def test_lookup_returns_nothing_for_dummy3_track(self): - result = self.core.library.lookup('dummy3:a') - - self.assertEqual(result, []) + self.assertEqual(result, {'dummy3:a': []}) self.assertFalse(self.library1.lookup.called) self.assertFalse(self.library2.lookup.called) @@ -140,81 +193,6 @@ class CoreLibraryTest(unittest.TestCase): self.library1.refresh.assert_called_once_with(None) self.library2.refresh.assert_called_twice_with(None) - def test_find_exact_combines_results_from_all_backends(self): - track1 = Track(uri='dummy1:a') - track2 = Track(uri='dummy2:a') - 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() - - 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) - - def test_find_exact_with_uris_selects_dummy1_backend(self): - self.core.library.find_exact( - any=['a'], uris=['dummy1:', 'dummy1:foo', 'dummy3:']) - - self.library1.find_exact.assert_called_once_with( - query=dict(any=['a']), uris=['dummy1:', 'dummy1:foo']) - self.assertFalse(self.library2.find_exact.called) - - def test_find_exact_with_uris_selects_both_backends(self): - self.core.library.find_exact( - any=['a'], uris=['dummy1:', 'dummy1:foo', 'dummy2:']) - - self.library1.find_exact.assert_called_once_with( - query=dict(any=['a']), uris=['dummy1:', 'dummy1:foo']) - self.library2.find_exact.assert_called_once_with( - query=dict(any=['a']), uris=['dummy2:']) - - def test_find_exact_filters_out_none(self): - track1 = Track(uri='dummy1:a') - 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() - - 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) - - def test_find_accepts_query_dict_instead_of_kwargs(self): - track1 = Track(uri='dummy1:a') - track2 = Track(uri='dummy2:a') - 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() - - 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) - def test_search_combines_results_from_all_backends(self): track1 = Track(uri='dummy1:a') track2 = Track(uri='dummy2:a') @@ -226,31 +204,31 @@ class CoreLibraryTest(unittest.TestCase): self.library2.search().get.return_value = result2 self.library2.search.reset_mock() - result = self.core.library.search(any=['a']) + result = self.core.library.search({'any': ['a']}) self.assertIn(result1, result) self.assertIn(result2, result) self.library1.search.assert_called_once_with( - query=dict(any=['a']), uris=None) + query={'any': ['a']}, uris=None, exact=False) self.library2.search.assert_called_once_with( - query=dict(any=['a']), uris=None) + query={'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:']) + query={'any': ['a']}, uris=['dummy1:', 'dummy1:foo', 'dummy3:']) self.library1.search.assert_called_once_with( - query=dict(any=['a']), uris=['dummy1:', 'dummy1:foo']) + query={'any': ['a']}, uris=['dummy1:', 'dummy1:foo'], exact=False) self.assertFalse(self.library2.search.called) def test_search_with_uris_selects_both_backends(self): self.core.library.search( - query=dict(any=['a']), uris=['dummy1:', 'dummy1:foo', 'dummy2:']) + query={'any': ['a']}, uris=['dummy1:', 'dummy1:foo', 'dummy2:']) self.library1.search.assert_called_once_with( - query=dict(any=['a']), uris=['dummy1:', 'dummy1:foo']) + query={'any': ['a']}, uris=['dummy1:', 'dummy1:foo'], exact=False) self.library2.search.assert_called_once_with( - query=dict(any=['a']), uris=['dummy2:']) + query={'any': ['a']}, uris=['dummy2:'], exact=False) def test_search_filters_out_none(self): track1 = Track(uri='dummy1:a') @@ -261,14 +239,14 @@ class CoreLibraryTest(unittest.TestCase): self.library2.search().get.return_value = None self.library2.search.reset_mock() - result = self.core.library.search(any=['a']) + result = self.core.library.search({'any': ['a']}) self.assertIn(result1, result) self.assertNotIn(None, result) self.library1.search.assert_called_once_with( - query=dict(any=['a']), uris=None) + query={'any': ['a']}, uris=None, exact=False) self.library2.search.assert_called_once_with( - query=dict(any=['a']), uris=None) + query={'any': ['a']}, uris=None, exact=False) def test_search_accepts_query_dict_instead_of_kwargs(self): track1 = Track(uri='dummy1:a') @@ -281,11 +259,163 @@ class CoreLibraryTest(unittest.TestCase): self.library2.search().get.return_value = result2 self.library2.search.reset_mock() - result = self.core.library.search(dict(any=['a'])) + result = self.core.library.search({'any': ['a']}) self.assertIn(result1, result) self.assertIn(result2, result) self.library1.search.assert_called_once_with( - query=dict(any=['a']), uris=None) + query={'any': ['a']}, uris=None, exact=False) self.library2.search.assert_called_once_with( - query=dict(any=['a']), uris=None) + query={'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) + + +class DeprecatedFindExactCoreLibraryTest(BaseCoreLibraryTest): + + def run(self, result=None): + with deprecation.ignore('core.library.find_exact'): + return super(DeprecatedFindExactCoreLibraryTest, self).run(result) + + def test_find_exact_combines_results_from_all_backends(self): + track1 = Track(uri='dummy1:a') + track2 = Track(uri='dummy2:a') + result1 = SearchResult(tracks=[track1]) + result2 = SearchResult(tracks=[track2]) + + 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.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( + query={'any': ['a']}, uris=['dummy1:', 'dummy1:foo', 'dummy3:']) + + self.library1.search.assert_called_once_with( + query={'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( + query={'any': ['a']}, uris=['dummy1:', 'dummy1:foo', 'dummy2:']) + + self.library1.search.assert_called_once_with( + query={'any': ['a']}, uris=['dummy1:', 'dummy1:foo'], exact=True) + self.library2.search.assert_called_once_with( + query={'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.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.search.assert_called_once_with( + query={'any': ['a']}, uris=None, exact=True) + self.library2.search.assert_called_once_with( + query={'any': ['a']}, uris=None, exact=True) + + def test_find_accepts_query_dict_instead_of_kwargs(self): + track1 = Track(uri='dummy1:a') + track2 = Track(uri='dummy2:a') + result1 = SearchResult(tracks=[track1]) + result2 = SearchResult(tracks=[track2]) + + 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.search.assert_called_once_with( + query={'any': ['a']}, uris=None, exact=True) + self.library2.search.assert_called_once_with( + query={'any': ['a']}, uris=None, exact=True) + + 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 DeprecatedLookupCoreLibraryTest(BaseCoreLibraryTest): + + def run(self, result=None): + with deprecation.ignore('core.library.lookup:uri_arg'): + return super(DeprecatedLookupCoreLibraryTest, self).run(result) + + def test_lookup_selects_dummy1_backend(self): + self.core.library.lookup('dummy1:a') + + self.library1.lookup.assert_called_once_with('dummy1:a') + self.assertFalse(self.library2.lookup.called) + + def test_lookup_selects_dummy2_backend(self): + self.core.library.lookup('dummy2:a') + + self.assertFalse(self.library1.lookup.called) + self.library2.lookup.assert_called_once_with('dummy2:a') + + 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) + + +class LegacyFindExactToSearchLibraryTest(unittest.TestCase): + + def run(self, result=None): + with deprecation.ignore('core.library.find_exact'): + return super(LegacyFindExactToSearchLibraryTest, self).run(result) + + 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 64003769..95c4da51 100644 --- a/tests/core/test_listener.py +++ b/tests/core/test_listener.py @@ -9,6 +9,7 @@ from mopidy.models import Playlist, TlTrack class CoreListenerTest(unittest.TestCase): + def setUp(self): # noqa: N802 self.listener = CoreListener() @@ -57,3 +58,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..c4ef7fe9 --- /dev/null +++ b/tests/core/test_mixer.py @@ -0,0 +1,94 @@ +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 ae64eaef..1141c783 100644 --- a/tests/core/test_playback.py +++ b/tests/core/test_playback.py @@ -6,10 +6,14 @@ import mock import pykka -from mopidy import audio, backend, core +from mopidy import backend, core from mopidy.models import Track +from tests import dummy_audio + +# 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'] @@ -20,7 +24,7 @@ class TestBackend(pykka.ThreadingActor, backend.Backend): class TestCurrentAndPendingTlTrack(unittest.TestCase): def setUp(self): # noqa: N802 - self.audio = audio.DummyAudio.start().proxy() + self.audio = dummy_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 @@ -84,7 +88,10 @@ class TestCurrentAndPendingTlTrack(unittest.TestCase): self.assertEqual(self.playback.current_tl_track, None) +# TODO: split into smaller easier to follow tests. setup is way to complex. +# TODO: just mock tracklist? class CorePlaybackTest(unittest.TestCase): + def setUp(self): # noqa: N802 self.backend1 = mock.Mock() self.backend1.uri_schemes.get.return_value = ['dummy1'] @@ -110,18 +117,86 @@ 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.uris = [ + 'dummy1:a', 'dummy2:a', 'dummy3:a', 'dummy1:b', 'dummy1:c'] + self.core = core.Core(mixer=None, backends=[ self.backend1, self.backend2, self.backend3]) - self.core.tracklist.add(self.tracks) + + def lookup(uris): + result = {uri: [] for uri in uris} + for track in self.tracks: + if track.uri in result: + result[track.uri].append(track) + return result + + self.lookup_patcher = mock.patch.object(self.core.library, 'lookup') + self.lookup_mock = self.lookup_patcher.start() + self.lookup_mock.side_effect = lookup + + self.core.tracklist.add(uris=self.uris) 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 tearDown(self): # noqa: N802 + self.lookup_patcher.stop() - # TODO Test get_current_track + def trigger_end_of_track(self): + self.core.playback._on_end_of_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 @@ -141,7 +216,7 @@ class CorePlaybackTest(unittest.TestCase): 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.prepare_change.assert_called_once_with() @@ -152,6 +227,22 @@ class CorePlaybackTest(unittest.TestCase): 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(uris=self.uris[: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): @@ -167,6 +258,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): @@ -206,7 +316,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) @@ -249,7 +359,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() @@ -292,7 +402,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() @@ -411,7 +521,7 @@ class CorePlaybackTest(unittest.TestCase): tl_track = self.tl_tracks[0] self.core.playback.play(tl_track) - self.core.playback.on_about_to_finish() + self.core.playback._on_about_to_finish() # TODO trigger_about_to.. self.assertIn(tl_track, self.core.tracklist.tl_tracks) @@ -420,7 +530,7 @@ class CorePlaybackTest(unittest.TestCase): self.core.playback.play(tl_track) self.core.tracklist.consume = True - self.core.playback.on_about_to_finish() + self.core.playback._on_about_to_finish() # TODO trigger_about_to.. self.assertNotIn(tl_track, self.core.tracklist.tl_tracks) @@ -431,7 +541,32 @@ 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]), + ]) + + @unittest.skip('Currently tests wrong events, and nothing generates them.') + @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, @@ -464,7 +599,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) @@ -472,6 +607,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): @@ -500,7 +658,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 @@ -510,20 +668,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 +class TestStream(unittest.TestCase): - self.assertEqual(self.core.playback.volume, 30) + def setUp(self): # noqa: N802 + self.audio = dummy_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 - self.core.playback.volume = 70 + self.tracks = [Track(uri='dummy:a', length=1234), + Track(uri='dummy:b', length=1234)] - self.assertEqual(self.core.playback.volume, 70) + self.lookup_patcher = mock.patch.object(self.core.library, 'lookup') + self.lookup_mock = self.lookup_patcher.start() + self.lookup_mock.return_value = {t.uri: [t] for t in self.tracks} - def test_mute(self): - self.assertEqual(self.core.playback.mute, False) + self.core.tracklist.add(uris=[t.uri for t in self.tracks]) - self.core.playback.mute = True + self.events = [] + self.send_patcher = mock.patch( + 'mopidy.audio.listener.AudioListener.send') + self.send_mock = self.send_patcher.start() - self.assertEqual(self.core.playback.mute, True) + 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.lookup_patcher.stop() + self.send_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 + b.library.lookup.return_value.get.return_value = [ + Track(uri='dummy1:a', length=40000)] + + c = core.Core(mixer=None, backends=[b]) + c.tracklist.add(uris=['dummy1:a']) + c.playback.play() # No TypeError == test passed. diff --git a/tests/core/test_playlists.py b/tests/core/test_playlists.py index 55a75767..4ca3d6df 100644 --- a/tests/core/test_playlists.py +++ b/tests/core/test_playlists.py @@ -5,19 +5,41 @@ import unittest import mock from mopidy import backend, core -from mopidy.models import Playlist, Track +from mopidy.models import Playlist, Ref, Track +from mopidy.utils import deprecation -class PlaylistsTest(unittest.TestCase): +class BasePlaylistsTest(unittest.TestCase): + def setUp(self): # noqa: N802 - self.backend1 = mock.Mock() - self.backend1.uri_schemes.get.return_value = ['dummy1'] + 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,40 +48,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_get_playlists_combines_result_from_backends(self): - result = self.core.playlists.playlists - self.assertIn(self.pl1a, result) - self.assertIn(self.pl1b, result) - self.assertIn(self.pl2a, result) - self.assertIn(self.pl2b, result) +class PlaylistTest(BasePlaylistsTest): - def test_get_playlists_includes_tracks_by_default(self): - result = self.core.playlists.get_playlists() + def test_as_list_combines_result_from_backends(self): + result = self.core.playlists.as_list() - self.assertEqual(result[0].name, 'A') - self.assertEqual(len(result[0].tracks), 1) - self.assertEqual(result[1].name, 'B') - self.assertEqual(len(result[1].tracks), 1) + self.assertIn(self.plr1a, result) + self.assertIn(self.plr1b, result) + self.assertIn(self.plr2a, result) + self.assertIn(self.plr2b, result) - def test_get_playlist_can_strip_tracks_from_returned_playlists(self): - result = self.core.playlists.get_playlists(include_tracks=False) + def test_as_list_ignores_backends_that_dont_support_it(self): + self.sp2.as_list.return_value.get.side_effect = NotImplementedError - self.assertEqual(result[0].name, 'A') - self.assertEqual(len(result[0].tracks), 0) - self.assertEqual(result[1].name, 'B') - self.assertEqual(len(result[1].tracks), 0) + 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_create_without_uri_scheme_uses_first_backend(self): playlist = Playlist() @@ -118,16 +145,6 @@ class PlaylistsTest(unittest.TestCase): self.assertFalse(self.sp1.delete.called) self.assertFalse(self.sp2.delete.called) - def test_filter_returns_matching_playlists(self): - result = self.core.playlists.filter(name='A') - - self.assertEqual(2, len(result)) - - def test_filter_accepts_dict_instead_of_kwargs(self): - result = self.core.playlists.filter({'name': 'A'}) - - self.assertEqual(2, len(result)) - def test_lookup_selects_the_dummy1_backend(self): self.core.playlists.lookup('dummy1:a') @@ -213,3 +230,52 @@ class PlaylistsTest(unittest.TestCase): self.assertIsNone(result) self.assertFalse(self.sp1.save.called) self.assertFalse(self.sp2.save.called) + + +class DeprecatedFilterPlaylistsTest(BasePlaylistsTest): + + def run(self, result=None): + with deprecation.ignore(ids=['core.playlists.filter', + 'core.playlists.get_playlists']): + return super(DeprecatedFilterPlaylistsTest, self).run(result) + + def test_filter_returns_matching_playlists(self): + result = self.core.playlists.filter(name='A') + + self.assertEqual(2, len(result)) + + def test_filter_accepts_dict_instead_of_kwargs(self): + result = self.core.playlists.filter({'name': 'A'}) + + self.assertEqual(2, len(result)) + + +class DeprecatedGetPlaylistsTest(BasePlaylistsTest): + + def run(self, result=None): + with deprecation.ignore('core.playlists.get_playlists'): + return super(DeprecatedGetPlaylistsTest, self).run(result) + + def test_get_playlists_combines_result_from_backends(self): + result = self.core.playlists.get_playlists() + + self.assertIn(self.pl1a, result) + self.assertIn(self.pl1b, result) + self.assertIn(self.pl2a, result) + self.assertIn(self.pl2b, result) + + def test_get_playlists_includes_tracks_by_default(self): + result = self.core.playlists.get_playlists() + + self.assertEqual(result[0].name, 'A') + self.assertEqual(len(result[0].tracks), 1) + self.assertEqual(result[1].name, 'B') + self.assertEqual(len(result[1].tracks), 1) + + def test_get_playlist_can_strip_tracks_from_returned_playlists(self): + result = self.core.playlists.get_playlists(include_tracks=False) + + self.assertEqual(result[0].name, 'A') + self.assertEqual(len(result[0].tracks), 0) + self.assertEqual(result[1].name, 'B') + self.assertEqual(len(result[1].tracks), 0) diff --git a/tests/core/test_tracklist.py b/tests/core/test_tracklist.py index 7b5577f9..24a9ef0f 100644 --- a/tests/core/test_tracklist.py +++ b/tests/core/test_tracklist.py @@ -6,9 +6,11 @@ import mock from mopidy import backend, core from mopidy.models import Track +from mopidy.utils import deprecation class TracklistTest(unittest.TestCase): + def setUp(self): # noqa: N802 self.tracks = [ Track(uri='dummy1:a', name='foo'), @@ -16,26 +18,51 @@ class TracklistTest(unittest.TestCase): Track(uri='dummy1:c', name='bar'), ] + def lookup(uri): + future = mock.Mock() + future.get.return_value = [t for t in self.tracks if t.uri == uri] + return future + self.backend = mock.Mock() self.backend.uri_schemes.get.return_value = ['dummy1'] self.library = mock.Mock(spec=backend.LibraryProvider) + self.library.lookup.side_effect = lookup self.backend.library = self.library self.core = core.Core(mixer=None, backends=[self.backend]) - self.tl_tracks = self.core.tracklist.add(self.tracks) + self.tl_tracks = self.core.tracklist.add(uris=[ + t.uri for t in self.tracks]) def test_add_by_uri_looks_up_uri_in_library(self): - track = Track(uri='dummy1:x', name='x') - self.library.lookup().get.return_value = [track] self.library.lookup.reset_mock() + self.core.tracklist.clear() - tl_tracks = self.core.tracklist.add(uri='dummy1:x') + with deprecation.ignore('core.tracklist.add:uri_arg'): + tl_tracks = self.core.tracklist.add(uris=['dummy1:a']) - self.library.lookup.assert_called_once_with('dummy1:x') + self.library.lookup.assert_called_once_with('dummy1:a') self.assertEqual(1, len(tl_tracks)) - self.assertEqual(track, tl_tracks[0].track) + self.assertEqual(self.tracks[0], 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): + self.library.lookup.reset_mock() + self.core.tracklist.clear() + + tl_tracks = self.core.tracklist.add(uris=[t.uri for t in self.tracks]) + + self.library.lookup.assert_has_calls([ + mock.call('dummy1:a'), + mock.call('dummy1:b'), + mock.call('dummy1:c'), + ]) + self.assertEqual(3, len(tl_tracks)) + self.assertEqual(self.tracks[0], tl_tracks[0].track) + self.assertEqual(self.tracks[1], tl_tracks[1].track) + self.assertEqual(self.tracks[2], 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']) diff --git a/mopidy/audio/dummy.py b/tests/dummy_audio.py similarity index 62% rename from mopidy/audio/dummy.py rename to tests/dummy_audio.py index 95b9d0fb..7c48d9f0 100644 --- a/mopidy/audio/dummy.py +++ b/tests/dummy_audio.py @@ -8,14 +8,18 @@ from __future__ import absolute_import, unicode_literals import pykka -from .constants import PlaybackState -from .listener import AudioListener +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 = PlaybackState.STOPPED + self.state = audio.PlaybackState.STOPPED self._volume = 0 self._position = 0 self._callback = None @@ -42,21 +46,21 @@ class DummyAudio(pykka.ThreadingActor): def set_position(self, position): self._position = position - AudioListener.send('position_changed', position=position) + audio.AudioListener.send('position_changed', position=position) return True def start_playback(self): - return self._change_state(PlaybackState.PLAYING) + return self._change_state(audio.PlaybackState.PLAYING) def pause_playback(self): - return self._change_state(PlaybackState.PAUSED) + return self._change_state(audio.PlaybackState.PAUSED) def prepare_change(self): self._uri = None return True def stop_playback(self): - return self._change_state(PlaybackState.STOPPED) + return self._change_state(audio.PlaybackState.STOPPED) def get_volume(self): return self._volume @@ -84,27 +88,32 @@ class DummyAudio(pykka.ThreadingActor): if not self._uri: return False - if self.state == PlaybackState.STOPPED and self._uri: - AudioListener.send('position_changed', position=0) - AudioListener.send('stream_changed', uri=self._uri) + 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 == PlaybackState.STOPPED: + if new_state == audio.PlaybackState.STOPPED: self._uri = None - AudioListener.send('stream_changed', uri=self._uri) + audio.AudioListener.send('stream_changed', uri=self._uri) old_state, self.state = self.state, new_state - AudioListener.send('state_changed', old_state=old_state, - new_state=new_state, target_state=None) + audio.AudioListener.send( + 'state_changed', + old_state=old_state, new_state=new_state, target_state=None) - if new_state == PlaybackState.PLAYING: + if new_state == audio.PlaybackState.PLAYING: self._tags['audio-codec'] = [u'fake info...'] - AudioListener.send('tags_changed', tags=['audio-codec']) + 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(): @@ -114,9 +123,9 @@ class DummyAudio(pykka.ThreadingActor): if not self._uri or not self._callback: self._tags = {} - AudioListener.send('reached_end_of_stream') + audio.AudioListener.send('reached_end_of_stream') else: - AudioListener.send('position_changed', position=0) - AudioListener.send('stream_changed', uri=self._uri) + 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 75% rename from mopidy/backend/dummy.py rename to tests/dummy_backend.py index e8d50e61..9ce8e38f 100644 --- a/mopidy/backend/dummy.py +++ b/tests/dummy_backend.py @@ -12,11 +12,12 @@ 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() class DummyBackend(pykka.ThreadingActor, backend.Backend): + def __init__(self, config, audio): super(DummyBackend, self).__init__() @@ -33,6 +34,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,8 +42,8 @@ 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 [t for t in self.dummy_library if uri == t.uri] @@ -49,11 +51,14 @@ class DummyLibraryProvider(backend.LibraryProvider): 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 @@ -90,6 +95,34 @@ 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) @@ -100,14 +133,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_handlers.py b/tests/http/test_handlers.py index 5c958d9a..78071fb2 100644 --- a/tests/http/test_handlers.py +++ b/tests/http/test_handlers.py @@ -2,14 +2,18 @@ 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 class StaticFileHandlerTest(tornado.testing.AsyncHTTPTestCase): + def get_app(self): return tornado.web.Application([ (r'/(.*)', handlers.StaticFileHandler, { @@ -35,3 +39,50 @@ 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 3c7d7c88..bb1d8cf0 100644 --- a/tests/http/test_server.py +++ b/tests/http/test_server.py @@ -12,6 +12,7 @@ from mopidy.http import actor, handlers class HttpServerTest(tornado.testing.AsyncHTTPTestCase): + def get_config(self): return { 'http': { @@ -43,6 +44,7 @@ class HttpServerTest(tornado.testing.AsyncHTTPTestCase): class RootRedirectTest(HttpServerTest): + def test_should_redirect_to_mopidy_app(self): response = self.fetch('/', method='GET', follow_redirects=False) @@ -51,6 +53,7 @@ class RootRedirectTest(HttpServerTest): class LegacyStaticDirAppTest(HttpServerTest): + def get_config(self): config = super(LegacyStaticDirAppTest, self).get_config() config['http']['static_dir'] = os.path.dirname(__file__) @@ -73,6 +76,7 @@ class LegacyStaticDirAppTest(HttpServerTest): class MopidyAppTest(HttpServerTest): + def test_should_return_index(self): response = self.fetch('/mopidy/', method='GET') body = tornado.escape.to_unicode(response.body) @@ -103,6 +107,7 @@ class MopidyAppTest(HttpServerTest): class MopidyWebSocketHandlerTest(HttpServerTest): + def test_should_return_ws(self): response = self.fetch('/mopidy/ws', method='GET') @@ -119,6 +124,7 @@ class MopidyWebSocketHandlerTest(HttpServerTest): class MopidyRPCHandlerTest(HttpServerTest): + def test_should_return_rpc_error(self): cmd = tornado.escape.json_encode({'action': 'get_version'}) @@ -164,6 +170,7 @@ class MopidyRPCHandlerTest(HttpServerTest): class HttpServerWithStaticFilesTest(tornado.testing.AsyncHTTPTestCase): + def get_app(self): config = { 'http': { @@ -214,6 +221,7 @@ def wsgi_app_factory(config, core): class HttpServerWithWsgiAppTest(tornado.testing.AsyncHTTPTestCase): + def get_app(self): config = { 'http': { diff --git a/tests/local/__init__.py b/tests/local/__init__.py index b1520768..3841a1e4 100644 --- a/tests/local/__init__.py +++ b/tests/local/__init__.py @@ -1,5 +1,7 @@ from __future__ import absolute_import, unicode_literals +from mopidy.utils import deprecation + def generate_song(i): return 'local:track:song%s.wav' % i @@ -7,7 +9,8 @@ def generate_song(i): def populate_tracklist(func): def wrapper(self): - self.tl_tracks = self.core.tracklist.add(self.tracks) + with deprecation.ignore('core.tracklist.add:tracks_arg'): + self.tl_tracks = self.core.tracklist.add(self.tracks) return func(self) wrapper.__name__ = func.__name__ diff --git a/tests/local/test_events.py b/tests/local/test_events.py deleted file mode 100644 index ae2ec66a..00000000 --- a/tests/local/test_events.py +++ /dev/null @@ -1,38 +0,0 @@ -from __future__ import absolute_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): # noqa: N802 - 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): # noqa: N802 - 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 0d62c2e3..520287ad 100644 --- a/tests/local/test_json.py +++ b/tests/local/test_json.py @@ -3,7 +3,9 @@ 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): @@ -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 6cc1992e..0198ec9e 100644 --- a/tests/local/test_library.py +++ b/tests/local/test_library.py @@ -11,7 +11,7 @@ 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 @@ -84,6 +84,14 @@ class LocalLibraryProviderTest(unittest.TestCase): pykka.ActorRegistry.stop_all() actor.LocalBackend.libraries = [] + def find_exact(self, **query): + # TODO: remove this helper? + return self.library.search(query=query, exact=True) + + def search(self, **query): + # TODO: remove this helper? + return self.library.search(query=query) + def test_refresh(self): self.library.refresh() @@ -124,12 +132,13 @@ class LocalLibraryProviderTest(unittest.TestCase): pass # TODO def test_lookup(self): - tracks = self.library.lookup(self.tracks[0].uri) - self.assertEqual(tracks, self.tracks[0:1]) + uri = self.tracks[0].uri + result = self.library.lookup(uris=[uri]) + self.assertEqual(result[uri], self.tracks[0:1]) def test_lookup_unknown_track(self): - tracks = self.library.lookup('fake uri') - self.assertEqual(tracks, []) + tracks = self.library.lookup(uris=['fake uri']) + self.assertEqual(tracks, {'fake uri': []}) # test backward compatibility with local libraries returning a # single Track @@ -149,434 +158,473 @@ class LocalLibraryProviderTest(unittest.TestCase): # TODO: move to search_test module def test_find_exact_no_hits(self): - result = self.library.find_exact(track_name=['unknown track']) + result = self.find_exact(track_name=['unknown track']) self.assertEqual(list(result[0].tracks), []) - result = self.library.find_exact(artist=['unknown artist']) + result = self.find_exact(artist=['unknown artist']) self.assertEqual(list(result[0].tracks), []) - result = self.library.find_exact(albumartist=['unknown albumartist']) + result = self.find_exact(albumartist=['unknown albumartist']) self.assertEqual(list(result[0].tracks), []) - result = self.library.find_exact(composer=['unknown composer']) + result = self.find_exact(composer=['unknown composer']) self.assertEqual(list(result[0].tracks), []) - result = self.library.find_exact(performer=['unknown performer']) + result = self.find_exact(performer=['unknown performer']) self.assertEqual(list(result[0].tracks), []) - result = self.library.find_exact(album=['unknown album']) + result = self.find_exact(album=['unknown album']) self.assertEqual(list(result[0].tracks), []) - result = self.library.find_exact(date=['1990']) + result = self.find_exact(date=['1990']) self.assertEqual(list(result[0].tracks), []) - result = self.library.find_exact(genre=['unknown genre']) + result = self.find_exact(genre=['unknown genre']) self.assertEqual(list(result[0].tracks), []) - result = self.library.find_exact(track_no=['9']) + result = self.find_exact(track_no=['9']) self.assertEqual(list(result[0].tracks), []) - result = self.library.find_exact(track_no=['no_match']) + result = self.find_exact(track_no=['no_match']) self.assertEqual(list(result[0].tracks), []) - result = self.library.find_exact(comment=['fake comment']) + result = self.find_exact(comment=['fake comment']) self.assertEqual(list(result[0].tracks), []) - result = self.library.find_exact(uri=['fake uri']) + result = self.find_exact(uri=['fake uri']) self.assertEqual(list(result[0].tracks), []) - result = self.library.find_exact(any=['unknown any']) + result = self.find_exact(any=['unknown any']) self.assertEqual(list(result[0].tracks), []) def test_find_exact_uri(self): track_1_uri = 'local:track:path1' - result = self.library.find_exact(uri=track_1_uri) + result = self.find_exact(uri=track_1_uri) self.assertEqual(list(result[0].tracks), self.tracks[:1]) track_2_uri = 'local:track:path2' - result = self.library.find_exact(uri=track_2_uri) + result = self.find_exact(uri=track_2_uri) self.assertEqual(list(result[0].tracks), self.tracks[1:2]) def test_find_exact_track_name(self): - result = self.library.find_exact(track_name=['track1']) + result = self.find_exact(track_name=['track1']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) - result = self.library.find_exact(track_name=['track2']) + result = self.find_exact(track_name=['track2']) self.assertEqual(list(result[0].tracks), self.tracks[1:2]) def test_find_exact_artist(self): - result = self.library.find_exact(artist=['artist1']) + result = self.find_exact(artist=['artist1']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) - result = self.library.find_exact(artist=['artist2']) + result = self.find_exact(artist=['artist2']) self.assertEqual(list(result[0].tracks), self.tracks[1:2]) - result = self.library.find_exact(artist=['artist3']) + result = self.find_exact(artist=['artist3']) self.assertEqual(list(result[0].tracks), self.tracks[3:4]) def test_find_exact_composer(self): - result = self.library.find_exact(composer=['artist5']) + result = self.find_exact(composer=['artist5']) self.assertEqual(list(result[0].tracks), self.tracks[4:5]) - result = self.library.find_exact(composer=['artist6']) + result = self.find_exact(composer=['artist6']) self.assertEqual(list(result[0].tracks), []) def test_find_exact_performer(self): - result = self.library.find_exact(performer=['artist6']) + result = self.find_exact(performer=['artist6']) self.assertEqual(list(result[0].tracks), self.tracks[5:6]) - result = self.library.find_exact(performer=['artist5']) + result = self.find_exact(performer=['artist5']) self.assertEqual(list(result[0].tracks), []) def test_find_exact_album(self): - result = self.library.find_exact(album=['album1']) + result = self.find_exact(album=['album1']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) - result = self.library.find_exact(album=['album2']) + result = self.find_exact(album=['album2']) self.assertEqual(list(result[0].tracks), self.tracks[1:2]) def test_find_exact_albumartist(self): # Artist is both track artist and album artist - result = self.library.find_exact(albumartist=['artist1']) + result = self.find_exact(albumartist=['artist1']) self.assertEqual(list(result[0].tracks), [self.tracks[0]]) # Artist is both track and album artist - result = self.library.find_exact(albumartist=['artist2']) + result = self.find_exact(albumartist=['artist2']) self.assertEqual(list(result[0].tracks), [self.tracks[1]]) # Artist is just album artist - result = self.library.find_exact(albumartist=['artist3']) + result = self.find_exact(albumartist=['artist3']) self.assertEqual(list(result[0].tracks), [self.tracks[2]]) def test_find_exact_track_no(self): - result = self.library.find_exact(track_no=['1']) + result = self.find_exact(track_no=['1']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) - result = self.library.find_exact(track_no=['2']) + result = self.find_exact(track_no=['2']) self.assertEqual(list(result[0].tracks), self.tracks[1:2]) def test_find_exact_genre(self): - result = self.library.find_exact(genre=['genre1']) + result = self.find_exact(genre=['genre1']) self.assertEqual(list(result[0].tracks), self.tracks[4:5]) - result = self.library.find_exact(genre=['genre2']) + result = self.find_exact(genre=['genre2']) self.assertEqual(list(result[0].tracks), self.tracks[5:6]) def test_find_exact_date(self): - result = self.library.find_exact(date=['2001']) + result = self.find_exact(date=['2001']) self.assertEqual(list(result[0].tracks), []) - result = self.library.find_exact(date=['2001-02-03']) + result = self.find_exact(date=['2001-02-03']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) - result = self.library.find_exact(date=['2002']) + result = self.find_exact(date=['2002']) self.assertEqual(list(result[0].tracks), self.tracks[1:2]) def test_find_exact_comment(self): - result = self.library.find_exact( + result = self.find_exact( comment=['This is a fantastic track']) self.assertEqual(list(result[0].tracks), self.tracks[3:4]) - result = self.library.find_exact( + result = self.find_exact( comment=['This is a fantastic']) self.assertEqual(list(result[0].tracks), []) def test_find_exact_any(self): # Matches on track artist - result = self.library.find_exact(any=['artist1']) + result = self.find_exact(any=['artist1']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) - result = self.library.find_exact(any=['artist2']) + result = self.find_exact(any=['artist2']) self.assertEqual(list(result[0].tracks), self.tracks[1:2]) # Matches on track name - result = self.library.find_exact(any=['track1']) + result = self.find_exact(any=['track1']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) - result = self.library.find_exact(any=['track2']) + result = self.find_exact(any=['track2']) self.assertEqual(list(result[0].tracks), self.tracks[1:2]) # Matches on track album - result = self.library.find_exact(any=['album1']) + result = self.find_exact(any=['album1']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) # Matches on track album artists - result = self.library.find_exact(any=['artist3']) + result = self.find_exact(any=['artist3']) self.assertEqual(len(result[0].tracks), 2) self.assertIn(self.tracks[2], result[0].tracks) self.assertIn(self.tracks[3], result[0].tracks) # Matches on track composer - result = self.library.find_exact(any=['artist5']) + result = self.find_exact(any=['artist5']) self.assertEqual(list(result[0].tracks), self.tracks[4:5]) # Matches on track performer - result = self.library.find_exact(any=['artist6']) + result = self.find_exact(any=['artist6']) self.assertEqual(list(result[0].tracks), self.tracks[5:6]) # Matches on track genre - result = self.library.find_exact(any=['genre1']) + result = self.find_exact(any=['genre1']) self.assertEqual(list(result[0].tracks), self.tracks[4:5]) - result = self.library.find_exact(any=['genre2']) + result = self.find_exact(any=['genre2']) self.assertEqual(list(result[0].tracks), self.tracks[5:6]) # Matches on track date - result = self.library.find_exact(any=['2002']) + result = self.find_exact(any=['2002']) self.assertEqual(list(result[0].tracks), self.tracks[1:2]) # Matches on track comment - result = self.library.find_exact( + result = self.find_exact( any=['This is a fantastic track']) self.assertEqual(list(result[0].tracks), self.tracks[3:4]) # Matches on URI - result = self.library.find_exact(any=['local:track:path1']) + result = self.find_exact(any=['local:track:path1']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) def test_find_exact_wrong_type(self): with self.assertRaises(LookupError): - self.library.find_exact(wrong=['test']) + self.find_exact(wrong=['test']) def test_find_exact_with_empty_query(self): with self.assertRaises(LookupError): - self.library.find_exact(artist=['']) + self.find_exact(artist=['']) with self.assertRaises(LookupError): - self.library.find_exact(albumartist=['']) + self.find_exact(albumartist=['']) with self.assertRaises(LookupError): - self.library.find_exact(track_name=['']) + self.find_exact(track_name=['']) with self.assertRaises(LookupError): - self.library.find_exact(composer=['']) + self.find_exact(composer=['']) with self.assertRaises(LookupError): - self.library.find_exact(performer=['']) + self.find_exact(performer=['']) with self.assertRaises(LookupError): - self.library.find_exact(album=['']) + self.find_exact(album=['']) with self.assertRaises(LookupError): - self.library.find_exact(track_no=['']) + self.find_exact(track_no=['']) with self.assertRaises(LookupError): - self.library.find_exact(genre=['']) + self.find_exact(genre=['']) with self.assertRaises(LookupError): - self.library.find_exact(date=['']) + self.find_exact(date=['']) with self.assertRaises(LookupError): - self.library.find_exact(comment=['']) + self.find_exact(comment=['']) with self.assertRaises(LookupError): - self.library.find_exact(any=['']) + self.find_exact(any=['']) def test_search_no_hits(self): - result = self.library.search(track_name=['unknown track']) + result = self.search(track_name=['unknown track']) self.assertEqual(list(result[0].tracks), []) - result = self.library.search(artist=['unknown artist']) + result = self.search(artist=['unknown artist']) self.assertEqual(list(result[0].tracks), []) - result = self.library.search(albumartist=['unknown albumartist']) + result = self.search(albumartist=['unknown albumartist']) self.assertEqual(list(result[0].tracks), []) - result = self.library.search(composer=['unknown composer']) + result = self.search(composer=['unknown composer']) self.assertEqual(list(result[0].tracks), []) - result = self.library.search(performer=['unknown performer']) + result = self.search(performer=['unknown performer']) self.assertEqual(list(result[0].tracks), []) - result = self.library.search(album=['unknown album']) + result = self.search(album=['unknown album']) self.assertEqual(list(result[0].tracks), []) - result = self.library.search(track_no=['9']) + result = self.search(track_no=['9']) self.assertEqual(list(result[0].tracks), []) - result = self.library.search(track_no=['no_match']) + result = self.search(track_no=['no_match']) self.assertEqual(list(result[0].tracks), []) - result = self.library.search(genre=['unknown genre']) + result = self.search(genre=['unknown genre']) self.assertEqual(list(result[0].tracks), []) - result = self.library.search(date=['unknown date']) + result = self.search(date=['unknown date']) self.assertEqual(list(result[0].tracks), []) - result = self.library.search(comment=['unknown comment']) + result = self.search(comment=['unknown comment']) self.assertEqual(list(result[0].tracks), []) - result = self.library.search(uri=['unknown uri']) + result = self.search(uri=['unknown uri']) self.assertEqual(list(result[0].tracks), []) - result = self.library.search(any=['unknown anything']) + result = self.search(any=['unknown anything']) self.assertEqual(list(result[0].tracks), []) def test_search_uri(self): - result = self.library.search(uri=['TH1']) + result = self.search(uri=['TH1']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) - result = self.library.search(uri=['TH2']) + result = self.search(uri=['TH2']) self.assertEqual(list(result[0].tracks), self.tracks[1:2]) def test_search_track_name(self): - result = self.library.search(track_name=['Rack1']) + result = self.search(track_name=['Rack1']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) - result = self.library.search(track_name=['Rack2']) + result = self.search(track_name=['Rack2']) self.assertEqual(list(result[0].tracks), self.tracks[1:2]) def test_search_artist(self): - result = self.library.search(artist=['Tist1']) + result = self.search(artist=['Tist1']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) - result = self.library.search(artist=['Tist2']) + result = self.search(artist=['Tist2']) self.assertEqual(list(result[0].tracks), self.tracks[1:2]) def test_search_albumartist(self): # Artist is both track artist and album artist - result = self.library.search(albumartist=['Tist1']) + result = self.search(albumartist=['Tist1']) self.assertEqual(list(result[0].tracks), [self.tracks[0]]) # Artist is both track artist and album artist - result = self.library.search(albumartist=['Tist2']) + result = self.search(albumartist=['Tist2']) self.assertEqual(list(result[0].tracks), [self.tracks[1]]) # Artist is just album artist - result = self.library.search(albumartist=['Tist3']) + result = self.search(albumartist=['Tist3']) self.assertEqual(list(result[0].tracks), [self.tracks[2]]) def test_search_composer(self): - result = self.library.search(composer=['Tist5']) + result = self.search(composer=['Tist5']) self.assertEqual(list(result[0].tracks), self.tracks[4:5]) def test_search_performer(self): - result = self.library.search(performer=['Tist6']) + result = self.search(performer=['Tist6']) self.assertEqual(list(result[0].tracks), self.tracks[5:6]) def test_search_album(self): - result = self.library.search(album=['Bum1']) + result = self.search(album=['Bum1']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) - result = self.library.search(album=['Bum2']) + result = self.search(album=['Bum2']) self.assertEqual(list(result[0].tracks), self.tracks[1:2]) def test_search_genre(self): - result = self.library.search(genre=['Enre1']) + result = self.search(genre=['Enre1']) self.assertEqual(list(result[0].tracks), self.tracks[4:5]) - result = self.library.search(genre=['Enre2']) + result = self.search(genre=['Enre2']) self.assertEqual(list(result[0].tracks), self.tracks[5:6]) def test_search_date(self): - result = self.library.search(date=['2001']) + result = self.search(date=['2001']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) - result = self.library.search(date=['2001-02-03']) + result = self.search(date=['2001-02-03']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) - result = self.library.search(date=['2001-02-04']) + result = self.search(date=['2001-02-04']) self.assertEqual(list(result[0].tracks), []) - result = self.library.search(date=['2002']) + result = self.search(date=['2002']) self.assertEqual(list(result[0].tracks), self.tracks[1:2]) def test_search_track_no(self): - result = self.library.search(track_no=['1']) + result = self.search(track_no=['1']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) - result = self.library.search(track_no=['2']) + result = self.search(track_no=['2']) self.assertEqual(list(result[0].tracks), self.tracks[1:2]) def test_search_comment(self): - result = self.library.search(comment=['fantastic']) + result = self.search(comment=['fantastic']) self.assertEqual(list(result[0].tracks), self.tracks[3:4]) - result = self.library.search(comment=['antasti']) + result = self.search(comment=['antasti']) self.assertEqual(list(result[0].tracks), self.tracks[3:4]) def test_search_any(self): # Matches on track artist - result = self.library.search(any=['Tist1']) + result = self.search(any=['Tist1']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) # Matches on track composer - result = self.library.search(any=['Tist5']) + result = self.search(any=['Tist5']) self.assertEqual(list(result[0].tracks), self.tracks[4:5]) # Matches on track performer - result = self.library.search(any=['Tist6']) + result = self.search(any=['Tist6']) self.assertEqual(list(result[0].tracks), self.tracks[5:6]) # Matches on track - result = self.library.search(any=['Rack1']) + result = self.search(any=['Rack1']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) - result = self.library.search(any=['Rack2']) + result = self.search(any=['Rack2']) self.assertEqual(list(result[0].tracks), self.tracks[1:2]) # Matches on track album - result = self.library.search(any=['Bum1']) + result = self.search(any=['Bum1']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) # Matches on track album artists - result = self.library.search(any=['Tist3']) + result = self.search(any=['Tist3']) self.assertEqual(len(result[0].tracks), 2) self.assertIn(self.tracks[2], result[0].tracks) self.assertIn(self.tracks[3], result[0].tracks) # Matches on track genre - result = self.library.search(any=['Enre1']) + result = self.search(any=['Enre1']) self.assertEqual(list(result[0].tracks), self.tracks[4:5]) - result = self.library.search(any=['Enre2']) + result = self.search(any=['Enre2']) self.assertEqual(list(result[0].tracks), self.tracks[5:6]) # Matches on track comment - result = self.library.search(any=['fanta']) + result = self.search(any=['fanta']) self.assertEqual(list(result[0].tracks), self.tracks[3:4]) - result = self.library.search(any=['is a fan']) + result = self.search(any=['is a fan']) self.assertEqual(list(result[0].tracks), self.tracks[3:4]) # Matches on URI - result = self.library.search(any=['TH1']) + result = self.search(any=['TH1']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) def test_search_wrong_type(self): with self.assertRaises(LookupError): - self.library.search(wrong=['test']) + self.search(wrong=['test']) def test_search_with_empty_query(self): with self.assertRaises(LookupError): - self.library.search(artist=['']) + self.search(artist=['']) with self.assertRaises(LookupError): - self.library.search(albumartist=['']) + self.search(albumartist=['']) with self.assertRaises(LookupError): - self.library.search(composer=['']) + self.search(composer=['']) with self.assertRaises(LookupError): - self.library.search(performer=['']) + self.search(performer=['']) with self.assertRaises(LookupError): - self.library.search(track_name=['']) + self.search(track_name=['']) with self.assertRaises(LookupError): - self.library.search(album=['']) + self.search(album=['']) with self.assertRaises(LookupError): - self.library.search(genre=['']) + self.search(genre=['']) with self.assertRaises(LookupError): - self.library.search(date=['']) + self.search(date=['']) with self.assertRaises(LookupError): - self.library.search(comment=['']) + self.search(comment=['']) with self.assertRaises(LookupError): - self.library.search(uri=['']) + self.search(uri=['']) with self.assertRaises(LookupError): - self.library.search(any=['']) + self.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 8fedb6a2..131af2ca 100644 --- a/tests/local/test_playback.py +++ b/tests/local/test_playback.py @@ -8,12 +8,13 @@ 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 mopidy.utils import deprecation -from tests import path_to_data_dir +from tests import dummy_audio, path_to_data_dir from tests.local import generate_song, populate_tracklist logger = logging.getLogger(__name__) @@ -50,8 +51,12 @@ class LocalPlaybackProviderTest(unittest.TestCase): logger.debug('Replaying: %s %s', event, kwargs) self.core.on_event(event, **kwargs) + def run(self, result=None): + with deprecation.ignore('core.tracklist.add:tracks_arg'): + return super(LocalPlaybackProviderTest, self).run(result) + def setUp(self): # noqa: N802 - self.audio = audio.DummyAudio.start().proxy() + 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], audio=self.audio) @@ -438,7 +443,17 @@ class LocalPlaybackProviderTest(unittest.TestCase): self.assertNotEqual(self.playback.current_track.uri, old_uri) @populate_tracklist - def test_about_to_finish_at_end_of_playlist(self): + def test_end_of_track_return_value(self): + self.playback.play() + self.assertEqual(self.trigger_about_to_finish(), None) + + @populate_tracklist + def test_end_of_track_does_not_trigger_playback(self): + self.trigger_about_to_finish() + self.assertEqual(self.playback.state, PlaybackState.STOPPED) + + @populate_tracklist + def test_end_of_track_at_end_of_playlist(self): self.playback.play() for i, track in enumerate(self.tracks): @@ -466,6 +481,10 @@ class LocalPlaybackProviderTest(unittest.TestCase): self.assertEqual(self.playback.state, PlaybackState.PLAYING) self.assertEqual(self.playback.current_track, self.tracks[0]) + def test_end_of_track_for_empty_playlist(self): + self.trigger_about_to_finish() + self.assertEqual(self.playback.state, PlaybackState.STOPPED) + @unittest.skip('This is broken with gapless support') @populate_tracklist def test_end_of_track_skips_to_next_track_on_failure(self): @@ -524,6 +543,14 @@ class LocalPlaybackProviderTest(unittest.TestCase): self.playback.play() for _ in self.tracks[1:]: self.trigger_about_to_finish() + tl_track = self.playback.current_tl_track + self.assertEqual( + self.tracklist.next_track(tl_track), self.tl_tracks[0]) + + @populate_tracklist + @mock.patch('random.shuffle') + def test_end_of_track_track_with_random(self, shuffle_mock): + shuffle_mock.side_effect = lambda tracks: tracks.reverse() tl_track = self.playback.current_tl_track self.assertEqual( @@ -669,14 +696,14 @@ class LocalPlaybackProviderTest(unittest.TestCase): 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) @@ -812,6 +839,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): @@ -822,13 +850,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): diff --git a/tests/local/test_playlists.py b/tests/local/test_playlists.py deleted file mode 100644 index c9aa299a..00000000 --- a/tests/local/test_playlists.py +++ /dev/null @@ -1,231 +0,0 @@ -from __future__ import absolute_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): # noqa: N802 - 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): # 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): - 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 index 2a704e48..bb741125 100644 --- a/tests/local/test_search.py +++ b/tests/local/test_search.py @@ -7,6 +7,7 @@ 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 diff --git a/tests/local/test_tracklist.py b/tests/local/test_tracklist.py index d74d436c..22d4c954 100644 --- a/tests/local/test_tracklist.py +++ b/tests/local/test_tracklist.py @@ -5,12 +5,13 @@ 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 mopidy.utils import deprecation -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 +27,12 @@ class LocalTracklistProviderTest(unittest.TestCase): tracks = [ Track(uri=generate_song(i), length=4464) for i in range(1, 4)] + def run(self, result=None): + with deprecation.ignore('core.tracklist.add:tracks_arg'): + return super(LocalTracklistProviderTest, self).run(result) + def setUp(self): # noqa: N802 - self.audio = audio.DummyAudio.start().proxy() + 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]) @@ -310,7 +315,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/__main__.py b/tests/m3u/__init__.py similarity index 50% rename from tests/__main__.py rename to tests/m3u/__init__.py index ae7a18e6..702deac5 100644 --- a/tests/__main__.py +++ b/tests/m3u/__init__.py @@ -1,5 +1,5 @@ from __future__ import absolute_import, unicode_literals -import nose -nose.main() +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..a294e6cf --- /dev/null +++ b/tests/m3u/test_playlists.py @@ -0,0 +1,303 @@ +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 mopidy.utils import deprecation + +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') + playlists = self.core.playlists.as_list() + self.assertIn(playlist.uri, [ref.uri for ref in 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_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) + + +class DeprecatedM3UPlaylistsProviderTest(M3UPlaylistsProviderTest): + + def run(self, result=None): + with deprecation.ignore(ids=['core.playlists.filter', + 'core.playlists.get_playlists']): + return super(DeprecatedM3UPlaylistsProviderTest, self).run(result) + + 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')) diff --git a/tests/local/test_translator.py b/tests/m3u/test_translator.py similarity index 88% rename from tests/local/test_translator.py rename to tests/m3u/test_translator.py index b238c909..c84f12bf 100644 --- a/tests/local/test_translator.py +++ b/tests/m3u/test_translator.py @@ -6,8 +6,8 @@ import os import tempfile import unittest -from mopidy.local import translator -from mopidy.models import Album, Track +from mopidy.m3u import translator +from mopidy.models import Track from mopidy.utils import path from tests import path_to_data_dir @@ -30,6 +30,7 @@ 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) @@ -118,16 +119,3 @@ class M3UToUriTest(unittest.TestCase): class URItoM3UTest(unittest.TestCase): pass - - -class AddMusicbrainzCoverartTest(unittest.TestCase): - def test_add_cover_for_album(self): - album = Album(musicbrainz_id='someid') - track = Track(album=album) - - expected = album.copy( - images=['http://coverartarchive.org/release/someid/front']) - - self.assertEqual( - track.copy(album=expected), - translator.add_musicbrainz_coverart_to_track(track)) diff --git a/tests/mpd/protocol/__init__.py b/tests/mpd/protocol/__init__.py index 8c744a78..4b009407 100644 --- a/tests/mpd/protocol/__init__.py +++ b/tests/mpd/protocol/__init__.py @@ -7,11 +7,14 @@ 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 mopidy.utils import deprecation + +from tests import dummy_backend, dummy_mixer class MockConnection(mock.Mock): + def __init__(self, *args, **kwargs): super(MockConnection, self).__init__(*args, **kwargs) self.host = mock.sentinel.host @@ -24,6 +27,8 @@ class MockConnection(mock.Mock): class BaseTestCase(unittest.TestCase): + enable_mixer = True + def get_config(self): return { 'mpd': { @@ -32,12 +37,21 @@ class BaseTestCase(unittest.TestCase): } def setUp(self): # noqa: N802 - self.backend = dummy.create_dummy_backend_proxy() - self.core = core.Core.start(backends=[self.backend]).proxy() + if self.enable_mixer: + self.mixer = dummy_mixer.create_proxy() + else: + self.mixer = None + self.backend = dummy_backend.create_proxy() + with deprecation.ignore(): + 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 diff --git a/tests/mpd/protocol/test_audio_output.py b/tests/mpd/protocol/test_audio_output.py index 137ac029..b42b4c56 100644 --- a/tests/mpd/protocol/test_audio_output.py +++ b/tests/mpd/protocol/test_audio_output.py @@ -4,13 +4,14 @@ from tests.mpd import protocol class AudioOutputHandlerTest(protocol.BaseTestCase): + def test_enableoutput(self): - self.core.playback.mute = False + 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.send_request('enableoutput "7"') @@ -18,12 +19,12 @@ class AudioOutputHandlerTest(protocol.BaseTestCase): 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.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.send_request('disableoutput "7"') @@ -32,7 +33,7 @@ class AudioOutputHandlerTest(protocol.BaseTestCase): '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.send_request('outputs') @@ -42,7 +43,7 @@ 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.send_request('outputs') @@ -50,3 +51,97 @@ class AudioOutputHandlerTest(protocol.BaseTestCase): 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 ac6e71da..325fca18 100644 --- a/tests/mpd/protocol/test_authentication.py +++ b/tests/mpd/protocol/test_authentication.py @@ -4,6 +4,7 @@ from tests.mpd import protocol class AuthenticationActiveTest(protocol.BaseTestCase): + def get_config(self): config = super(AuthenticationActiveTest, self).get_config() config['mpd']['password'] = 'topsecret' @@ -52,6 +53,7 @@ class AuthenticationActiveTest(protocol.BaseTestCase): class AuthenticationInactiveTest(protocol.BaseTestCase): + def test_authentication_with_anything_when_password_check_turned_off(self): self.send_request('any request at all') self.assertTrue(self.dispatcher.authenticated) diff --git a/tests/mpd/protocol/test_channels.py b/tests/mpd/protocol/test_channels.py index c29b2b57..90c425fd 100644 --- a/tests/mpd/protocol/test_channels.py +++ b/tests/mpd/protocol/test_channels.py @@ -4,6 +4,7 @@ from tests.mpd import protocol class ChannelsHandlerTest(protocol.BaseTestCase): + def test_subscribe(self): self.send_request('subscribe "topic"') self.assertEqualResponse('ACK [0@0] {subscribe} Not implemented') diff --git a/tests/mpd/protocol/test_command_list.py b/tests/mpd/protocol/test_command_list.py index 28642b47..2aeab3b0 100644 --- a/tests/mpd/protocol/test_command_list.py +++ b/tests/mpd/protocol/test_command_list.py @@ -4,9 +4,10 @@ from tests.mpd import protocol class CommandListsTest(protocol.BaseTestCase): + def test_command_list_begin(self): response = self.send_request('command_list_begin') - self.assertEquals([], response) + self.assertEqual([], response) def test_command_list_end(self): self.send_request('command_list_begin') @@ -42,7 +43,7 @@ class CommandListsTest(protocol.BaseTestCase): def test_command_list_ok_begin(self): response = self.send_request('command_list_ok_begin') - self.assertEquals([], response) + self.assertEqual([], response) def test_command_list_ok_with_ping(self): self.send_request('command_list_ok_begin') diff --git a/tests/mpd/protocol/test_connection.py b/tests/mpd/protocol/test_connection.py index da25153d..9c7edb4b 100644 --- a/tests/mpd/protocol/test_connection.py +++ b/tests/mpd/protocol/test_connection.py @@ -6,6 +6,7 @@ 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.send_request('close') diff --git a/tests/mpd/protocol/test_current_playlist.py b/tests/mpd/protocol/test_current_playlist.py index d6fdce8e..6ec53adc 100644 --- a/tests/mpd/protocol/test_current_playlist.py +++ b/tests/mpd/protocol/test_current_playlist.py @@ -1,22 +1,31 @@ from __future__ import absolute_import, unicode_literals from mopidy.models import Ref, Track +from mopidy.utils import deprecation from tests.mpd import protocol -class CurrentPlaylistHandlerTest(protocol.BaseTestCase): - def test_add(self): - needle = Track(uri='dummy://foo') - self.backend.library.dummy_library = [ - Track(), Track(), needle, Track()] - self.core.tracklist.add( - [Track(), Track(), Track(), Track(), Track()]) - self.assertEqual(len(self.core.tracklist.tracks.get()), 5) +class AddCommandsTest(protocol.BaseTestCase): - self.send_request('add "dummy://foo"') - self.assertEqual(len(self.core.tracklist.tracks.get()), 6) - self.assertEqual(self.core.tracklist.tracks.get()[5], needle) + def setUp(self): # noqa: N802 + super(AddCommandsTest, self).setUp() + + self.tracks = [Track(uri='dummy:/a', name='a'), + Track(uri='dummy:/foo/b', name='b')] + + self.refs = {'/a': Ref.track(uri='dummy:/a', name='a'), + '/foo': Ref.directory(uri='dummy:/foo', name='foo'), + '/foo/b': Ref.track(uri='dummy:/foo/b', name='b')} + + self.backend.library.dummy_library = self.tracks + + def test_add(self): + for track in [self.tracks[0], self.tracks[0], self.tracks[1]]: + self.send_request('add "%s"' % track.uri) + + self.assertEqual(len(self.core.tracklist.tracks.get()), 3) + self.assertEqual(self.core.tracklist.tracks.get()[2], self.tracks[1]) self.assertEqualResponse('OK') def test_add_with_uri_not_found_in_library_should_ack(self): @@ -25,220 +34,153 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): 'ACK [50@0] {add} directory or file not found') def test_add_with_empty_uri_should_not_add_anything_and_ok(self): - self.backend.library.dummy_library = [Track(uri='dummy:/a', name='a')] self.backend.library.dummy_browse_result = { - 'dummy:/': [Ref.track(uri='dummy:/a', name='a')]} + 'dummy:/': [self.refs['/a']]} self.send_request('add ""') self.assertEqual(len(self.core.tracklist.tracks.get()), 0) self.assertInResponse('OK') def test_add_with_library_should_recurse(self): - tracks = [Track(uri='dummy:/a', name='a'), - Track(uri='dummy:/foo/b', name='b')] - - self.backend.library.dummy_library = tracks self.backend.library.dummy_browse_result = { - 'dummy:/': [Ref.track(uri='dummy:/a', name='a'), - Ref.directory(uri='dummy:/foo', name='foo')], - 'dummy:/foo': [Ref.track(uri='dummy:/foo/b', name='b')]} + 'dummy:/': [self.refs['/a'], self.refs['/foo']], + 'dummy:/foo': [self.refs['/foo/b']]} self.send_request('add "/dummy"') - self.assertEqual(self.core.tracklist.tracks.get(), tracks) + self.assertEqual(self.core.tracklist.tracks.get(), self.tracks) self.assertInResponse('OK') def test_add_root_should_not_add_anything_and_ok(self): - self.backend.library.dummy_library = [Track(uri='dummy:/a', name='a')] self.backend.library.dummy_browse_result = { - 'dummy:/': [Ref.track(uri='dummy:/a', name='a')]} + 'dummy:/': [self.refs['/a']]} self.send_request('add "/"') self.assertEqual(len(self.core.tracklist.tracks.get()), 0) self.assertInResponse('OK') def test_addid_without_songpos(self): - needle = Track(uri='dummy://foo') - self.backend.library.dummy_library = [ - Track(), Track(), needle, Track()] - self.core.tracklist.add( - [Track(), Track(), Track(), Track(), Track()]) - self.assertEqual(len(self.core.tracklist.tracks.get()), 5) + for track in [self.tracks[0], self.tracks[0], self.tracks[1]]: + self.send_request('addid "%s"' % track.uri) + tl_tracks = self.core.tracklist.tl_tracks.get() - 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( - 'Id: %d' % self.core.tracklist.tl_tracks.get()[5].tlid) + self.assertEqual(len(tl_tracks), 3) + self.assertEqual(tl_tracks[2].track, self.tracks[1]) + self.assertInResponse('Id: %d' % tl_tracks[2].tlid) self.assertInResponse('OK') + def test_addid_with_songpos(self): + for track in [self.tracks[0], self.tracks[0]]: + self.send_request('add "%s"' % track.uri) + self.send_request('addid "%s" "1"' % self.tracks[1].uri) + tl_tracks = self.core.tracklist.tl_tracks.get() + + self.assertEqual(len(tl_tracks), 3) + self.assertEqual(tl_tracks[1].track, self.tracks[1]) + self.assertInResponse('Id: %d' % tl_tracks[1].tlid) + self.assertInResponse('OK') + + def test_addid_with_songpos_out_of_bounds_should_ack(self): + self.send_request('addid "%s" "3"' % self.tracks[0].uri) + self.assertEqualResponse('ACK [2@0] {addid} Bad song index') + def test_addid_with_empty_uri_acks(self): self.send_request('addid ""') self.assertEqualResponse('ACK [50@0] {addid} No such song') - def test_addid_with_songpos(self): - needle = Track(uri='dummy://foo') - self.backend.library.dummy_library = [ - Track(), Track(), needle, Track()] - self.core.tracklist.add( - [Track(), Track(), Track(), Track(), Track()]) - self.assertEqual(len(self.core.tracklist.tracks.get()), 5) - - 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( - 'Id: %d' % self.core.tracklist.tl_tracks.get()[3].tlid) - self.assertInResponse('OK') - - def test_addid_with_songpos_out_of_bounds_should_ack(self): - needle = Track(uri='dummy://foo') - self.backend.library.dummy_library = [ - Track(), Track(), needle, Track()] - self.core.tracklist.add( - [Track(), Track(), Track(), Track(), Track()]) - self.assertEqual(len(self.core.tracklist.tracks.get()), 5) - - 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.send_request('addid "dummy://foo"') self.assertEqualResponse('ACK [50@0] {addid} No such song') - def test_clear(self): - self.core.tracklist.add( - [Track(), Track(), Track(), Track(), Track()]) - self.assertEqual(len(self.core.tracklist.tracks.get()), 5) +class BasePopulatedTracklistTestCase(protocol.BaseTestCase): + + def setUp(self): # noqa: N802 + super(BasePopulatedTracklistTestCase, self).setUp() + tracks = [Track(uri='dummy:/%s' % x, name=x) for x in 'abcdef'] + self.backend.library.dummy_library = tracks + self.core.tracklist.add(uris=[t.uri for t in tracks]) + + +class DeleteCommandsTest(BasePopulatedTracklistTestCase): + + def test_clear(self): 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') def test_delete_songpos(self): - self.core.tracklist.add( - [Track(), Track(), Track(), Track(), Track()]) + tl_tracks = self.core.tracklist.tl_tracks.get() + self.send_request('delete "%d"' % tl_tracks[1].tlid) self.assertEqual(len(self.core.tracklist.tracks.get()), 5) - - 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') def test_delete_songpos_out_of_bounds(self): - self.core.tracklist.add( - [Track(), Track(), Track(), Track(), Track()]) - self.assertEqual(len(self.core.tracklist.tracks.get()), 5) - - self.send_request('delete "5"') - self.assertEqual(len(self.core.tracklist.tracks.get()), 5) + self.send_request('delete "8"') + self.assertEqual(len(self.core.tracklist.tracks.get()), 6) self.assertEqualResponse('ACK [2@0] {delete} Bad song index') def test_delete_open_range(self): - self.core.tracklist.add( - [Track(), Track(), Track(), Track(), Track()]) - self.assertEqual(len(self.core.tracklist.tracks.get()), 5) - self.send_request('delete "1:"') self.assertEqual(len(self.core.tracklist.tracks.get()), 1) self.assertInResponse('OK') - def test_delete_closed_range(self): - self.core.tracklist.add( - [Track(), Track(), Track(), Track(), Track()]) - self.assertEqual(len(self.core.tracklist.tracks.get()), 5) + # TODO: check how this should work. + # def test_delete_open_upper_range(self): + # self.send_request('delete ":8"') + # self.assertEqual(len(self.core.tracklist.tracks.get()), 0) + # self.assertInResponse('OK') + def test_delete_closed_range(self): self.send_request('delete "1:3"') - self.assertEqual(len(self.core.tracklist.tracks.get()), 3) + self.assertEqual(len(self.core.tracklist.tracks.get()), 4) self.assertInResponse('OK') - def test_delete_range_out_of_bounds(self): - self.core.tracklist.add( - [Track(), Track(), Track(), Track(), Track()]) - self.assertEqual(len(self.core.tracklist.tracks.get()), 5) - - self.send_request('delete "5:7"') - self.assertEqual(len(self.core.tracklist.tracks.get()), 5) + def test_delete_entire_range_out_of_bounds(self): + self.send_request('delete "8:9"') + self.assertEqual(len(self.core.tracklist.tracks.get()), 6) self.assertEqualResponse('ACK [2@0] {delete} Bad song index') - def test_deleteid(self): - self.core.tracklist.add([Track(), Track()]) - self.assertEqual(len(self.core.tracklist.tracks.get()), 2) + def test_delete_upper_range_out_of_bounds(self): + self.send_request('delete "5:9"') + self.assertEqual(len(self.core.tracklist.tracks.get()), 5) + self.assertEqualResponse('OK') + def test_deleteid(self): self.send_request('deleteid "1"') - self.assertEqual(len(self.core.tracklist.tracks.get()), 1) + self.assertEqual(len(self.core.tracklist.tracks.get()), 5) self.assertInResponse('OK') def test_deleteid_does_not_exist(self): - self.core.tracklist.add([Track(), Track()]) - self.assertEqual(len(self.core.tracklist.tracks.get()), 2) - self.send_request('deleteid "12345"') - self.assertEqual(len(self.core.tracklist.tracks.get()), 2) + self.assertEqual(len(self.core.tracklist.tracks.get()), 6) self.assertEqualResponse('ACK [50@0] {deleteid} No such song') - def test_move_songpos(self): - self.core.tracklist.add([ - Track(name='a'), Track(name='b'), Track(name='c'), - Track(name='d'), Track(name='e'), Track(name='f'), - ]) +class MoveCommandsTest(BasePopulatedTracklistTestCase): + + def test_move_songpos(self): self.send_request('move "1" "0"') - tracks = self.core.tracklist.tracks.get() - self.assertEqual(tracks[0].name, 'b') - self.assertEqual(tracks[1].name, 'a') - self.assertEqual(tracks[2].name, 'c') - self.assertEqual(tracks[3].name, 'd') - self.assertEqual(tracks[4].name, 'e') - self.assertEqual(tracks[5].name, 'f') + result = [t.name for t in self.core.tracklist.tracks.get()] + self.assertEqual(result, ['b', 'a', 'c', 'd', 'e', 'f']) self.assertInResponse('OK') def test_move_open_range(self): - self.core.tracklist.add([ - Track(name='a'), Track(name='b'), Track(name='c'), - Track(name='d'), Track(name='e'), Track(name='f'), - ]) - self.send_request('move "2:" "0"') - tracks = self.core.tracklist.tracks.get() - self.assertEqual(tracks[0].name, 'c') - self.assertEqual(tracks[1].name, 'd') - self.assertEqual(tracks[2].name, 'e') - self.assertEqual(tracks[3].name, 'f') - self.assertEqual(tracks[4].name, 'a') - self.assertEqual(tracks[5].name, 'b') + result = [t.name for t in self.core.tracklist.tracks.get()] + self.assertEqual(result, ['c', 'd', 'e', 'f', 'a', 'b']) self.assertInResponse('OK') def test_move_closed_range(self): - self.core.tracklist.add([ - Track(name='a'), Track(name='b'), Track(name='c'), - Track(name='d'), Track(name='e'), Track(name='f'), - ]) - 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') - self.assertEqual(tracks[2].name, 'a') - self.assertEqual(tracks[3].name, 'd') - self.assertEqual(tracks[4].name, 'e') - self.assertEqual(tracks[5].name, 'f') + result = [t.name for t in self.core.tracklist.tracks.get()] + self.assertEqual(result, ['b', 'c', 'a', 'd', 'e', 'f']) self.assertInResponse('OK') def test_moveid(self): - self.core.tracklist.add([ - Track(name='a'), Track(name='b'), Track(name='c'), - Track(name='d'), Track(name='e'), Track(name='f'), - ]) - self.send_request('moveid "4" "2"') - tracks = self.core.tracklist.tracks.get() - self.assertEqual(tracks[0].name, 'a') - self.assertEqual(tracks[1].name, 'b') - self.assertEqual(tracks[2].name, 'e') - self.assertEqual(tracks[3].name, 'c') - self.assertEqual(tracks[4].name, 'd') - self.assertEqual(tracks[5].name, 'f') + result = [t.name for t in self.core.tracklist.tracks.get()] + self.assertEqual(result, ['a', 'b', 'e', 'c', 'd', 'f']) self.assertInResponse('OK') def test_moveid_with_tlid_not_found_in_tracklist_should_ack(self): @@ -246,10 +188,8 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertEqualResponse( 'ACK [50@0] {moveid} No such song') - def test_playlist_returns_same_as_playlistinfo(self): - playlist_response = self.send_request('playlist') - playlistinfo_response = self.send_request('playlistinfo') - self.assertEqual(playlist_response, playlistinfo_response) + +class PlaylistFindCommandTest(protocol.BaseTestCase): def test_playlistfind(self): self.send_request('playlistfind "tag" "needle"') @@ -264,25 +204,26 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertEqualResponse('OK') def test_playlistfind_by_filename_in_tracklist(self): - self.core.tracklist.add([Track(uri='file:///exists')]) + track = Track(uri='dummy:///exists') + self.backend.library.dummy_library = [track] + self.core.tracklist.add(uris=[track.uri]) - self.send_request('playlistfind filename "file:///exists"') - self.assertInResponse('file: file:///exists') + self.send_request('playlistfind filename "dummy:///exists"') + self.assertInResponse('file: dummy:///exists') self.assertInResponse('Id: 0') self.assertInResponse('Pos: 0') self.assertInResponse('OK') - def test_playlistid_without_songid(self): - self.core.tracklist.add([Track(name='a'), Track(name='b')]) +class PlaylistIdCommandTest(BasePopulatedTracklistTestCase): + + def test_playlistid_without_songid(self): self.send_request('playlistid') self.assertInResponse('Title: a') self.assertInResponse('Title: b') self.assertInResponse('OK') def test_playlistid_with_songid(self): - self.core.tracklist.add([Track(name='a'), Track(name='b')]) - self.send_request('playlistid "1"') self.assertNotInResponse('Title: a') self.assertNotInResponse('Id: 0') @@ -291,17 +232,20 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_playlistid_with_not_existing_songid_fails(self): - self.core.tracklist.add([Track(name='a'), Track(name='b')]) - self.send_request('playlistid "25"') self.assertEqualResponse('ACK [50@0] {playlistid} No such song') - def test_playlistinfo_without_songpos_or_range(self): - self.core.tracklist.add([ - Track(name='a'), Track(name='b'), Track(name='c'), - Track(name='d'), Track(name='e'), Track(name='f'), - ]) +class PlaylistInfoCommandTest(BasePopulatedTracklistTestCase): + + def test_playlist_returns_same_as_playlistinfo(self): + with deprecation.ignore('mpd.protocol.current_playlist.playlist'): + playlist_response = self.send_request('playlist') + + playlistinfo_response = self.send_request('playlistinfo') + self.assertEqual(playlist_response, playlistinfo_response) + + def test_playlistinfo_without_songpos_or_range(self): self.send_request('playlistinfo') self.assertInResponse('Title: a') self.assertInResponse('Pos: 0') @@ -320,10 +264,6 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): def test_playlistinfo_with_songpos(self): # Make the track's CPID not match the playlist position self.core.tracklist.tlid = 17 - self.core.tracklist.add([ - Track(name='a'), Track(name='b'), Track(name='c'), - Track(name='d'), Track(name='e'), Track(name='f'), - ]) self.send_request('playlistinfo "4"') self.assertNotInResponse('Title: a') @@ -346,11 +286,6 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertEqual(response1, response2) def test_playlistinfo_with_open_range(self): - self.core.tracklist.add([ - Track(name='a'), Track(name='b'), Track(name='c'), - Track(name='d'), Track(name='e'), Track(name='f'), - ]) - self.send_request('playlistinfo "2:"') self.assertNotInResponse('Title: a') self.assertNotInResponse('Pos: 0') @@ -367,11 +302,6 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_playlistinfo_with_closed_range(self): - self.core.tracklist.add([ - Track(name='a'), Track(name='b'), Track(name='c'), - Track(name='d'), Track(name='e'), Track(name='f'), - ]) - self.send_request('playlistinfo "2:4"') self.assertNotInResponse('Title: a') self.assertNotInResponse('Title: b') @@ -393,6 +323,9 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.send_request('playlistinfo "0"') self.assertInResponse('OK') + +class PlaylistSearchCommandTest(protocol.BaseTestCase): + def test_playlistsearch(self): self.send_request('playlistsearch "any" "needle"') self.assertEqualResponse('ACK [0@0] {playlistsearch} Not implemented') @@ -401,10 +334,10 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): 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')]) +class PlChangeCommandTest(BasePopulatedTracklistTestCase): + + def test_plchanges_with_lower_version_returns_changes(self): self.send_request('plchanges "0"') self.assertInResponse('Title: a') self.assertInResponse('Title: b') @@ -412,9 +345,6 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_plchanges_with_equal_version_returns_nothing(self): - self.core.tracklist.add( - [Track(name='a'), Track(name='b'), Track(name='c')]) - self.assertEqual(self.core.tracklist.version.get(), 1) self.send_request('plchanges "1"') self.assertNotInResponse('Title: a') @@ -423,9 +353,6 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_plchanges_with_greater_version_returns_nothing(self): - self.core.tracklist.add( - [Track(name='a'), Track(name='b'), Track(name='c')]) - self.assertEqual(self.core.tracklist.version.get(), 1) self.send_request('plchanges "2"') self.assertNotInResponse('Title: a') @@ -434,9 +361,6 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_plchanges_with_minus_one_returns_entire_playlist(self): - self.core.tracklist.add( - [Track(name='a'), Track(name='b'), Track(name='c')]) - self.send_request('plchanges "-1"') self.assertInResponse('Title: a') self.assertInResponse('Title: b') @@ -444,9 +368,6 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_plchanges_without_quotes_works(self): - self.core.tracklist.add( - [Track(name='a'), Track(name='b'), Track(name='c')]) - self.send_request('plchanges 0') self.assertInResponse('Title: a') self.assertInResponse('Title: b') @@ -454,8 +375,6 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_plchangesposid(self): - self.core.tracklist.add([Track(), Track(), Track()]) - self.send_request('plchangesposid "0"') tl_tracks = self.core.tracklist.tl_tracks.get() self.assertInResponse('cpos: 0') @@ -466,11 +385,11 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse('Id: %d' % tl_tracks[2].tlid) self.assertInResponse('OK') + +# TODO: we only seem to be testing that don't touch the non shuffled region :/ +class ShuffleCommandTest(BasePopulatedTracklistTestCase): + def test_shuffle_without_range(self): - self.core.tracklist.add([ - Track(name='a'), Track(name='b'), Track(name='c'), - Track(name='d'), Track(name='e'), Track(name='f'), - ]) version = self.core.tracklist.version.get() self.send_request('shuffle') @@ -478,77 +397,47 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_shuffle_with_open_range(self): - self.core.tracklist.add([ - Track(name='a'), Track(name='b'), Track(name='c'), - Track(name='d'), Track(name='e'), Track(name='f'), - ]) version = self.core.tracklist.version.get() 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') - self.assertEqual(tracks[1].name, 'b') - self.assertEqual(tracks[2].name, 'c') - self.assertEqual(tracks[3].name, 'd') + + result = [t.name for t in self.core.tracklist.tracks.get()] + self.assertEqual(result[:4], ['a', 'b', 'c', 'd']) self.assertInResponse('OK') def test_shuffle_with_closed_range(self): - self.core.tracklist.add([ - Track(name='a'), Track(name='b'), Track(name='c'), - Track(name='d'), Track(name='e'), Track(name='f'), - ]) version = self.core.tracklist.version.get() 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') - self.assertEqual(tracks[3].name, 'd') - self.assertEqual(tracks[4].name, 'e') - self.assertEqual(tracks[5].name, 'f') + + result = [t.name for t in self.core.tracklist.tracks.get()] + self.assertEqual(result[:1], ['a']) + self.assertEqual(result[3:], ['d', 'e', 'f']) self.assertInResponse('OK') - def test_swap(self): - self.core.tracklist.add([ - Track(name='a'), Track(name='b'), Track(name='c'), - Track(name='d'), Track(name='e'), Track(name='f'), - ]) +class SwapCommandTest(BasePopulatedTracklistTestCase): + + def test_swap(self): self.send_request('swap "1" "4"') - tracks = self.core.tracklist.tracks.get() - self.assertEqual(tracks[0].name, 'a') - self.assertEqual(tracks[1].name, 'e') - self.assertEqual(tracks[2].name, 'c') - self.assertEqual(tracks[3].name, 'd') - self.assertEqual(tracks[4].name, 'b') - self.assertEqual(tracks[5].name, 'f') + result = [t.name for t in self.core.tracklist.tracks.get()] + self.assertEqual(result, ['a', 'e', 'c', 'd', 'b', 'f']) self.assertInResponse('OK') def test_swapid(self): - self.core.tracklist.add([ - Track(name='a'), Track(name='b'), Track(name='c'), - Track(name='d'), Track(name='e'), Track(name='f'), - ]) - self.send_request('swapid "1" "4"') - tracks = self.core.tracklist.tracks.get() - self.assertEqual(tracks[0].name, 'a') - self.assertEqual(tracks[1].name, 'e') - self.assertEqual(tracks[2].name, 'c') - self.assertEqual(tracks[3].name, 'd') - self.assertEqual(tracks[4].name, 'b') - self.assertEqual(tracks[5].name, 'f') + result = [t.name for t in self.core.tracklist.tracks.get()] + self.assertEqual(result, ['a', 'e', 'c', 'd', 'b', 'f']) self.assertInResponse('OK') def test_swapid_with_first_id_unknown_should_ack(self): - self.core.tracklist.add([Track()]) - self.send_request('swapid "0" "4"') + self.send_request('swapid "0" "8"') 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.send_request('swapid "4" "0"') + self.send_request('swapid "8" "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 0bd16992..075da845 100644 --- a/tests/mpd/protocol/test_idle.py +++ b/tests/mpd/protocol/test_idle.py @@ -8,6 +8,7 @@ from tests.mpd import protocol class IdleHandlerTest(protocol.BaseTestCase): + def idle_event(self, subsystem): self.session.on_idle(subsystem) @@ -50,6 +51,12 @@ class IdleHandlerTest(protocol.BaseTestCase): 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.send_request('idle player playlist') self.assertEqualSubscriptions(['player', 'playlist']) @@ -102,6 +109,22 @@ class IdleHandlerTest(protocol.BaseTestCase): 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.send_request('idle player') self.send_request('noidle') @@ -206,3 +229,11 @@ class IdleHandlerTest(protocol.BaseTestCase): 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 9f3b7348..ca043d3c 100644 --- a/tests/mpd/protocol/test_music_db.py +++ b/tests/mpd/protocol/test_music_db.py @@ -7,8 +7,11 @@ from mopidy.mpd.protocol import music_db from tests.mpd import protocol +# TODO: split into more modules for faster parallel tests? + class QueryFromMpdSearchFormatTest(unittest.TestCase): + def test_dates_are_extracted(self): result = music_db._query_from_mpd_search_parameters( ['Date', '1974-01-02', 'Date', '1975'], music_db._SEARCH_MAPPING) @@ -32,7 +35,10 @@ class QueryFromMpdListFormatTest(unittest.TestCase): pass # TODO +# TODO: why isn't core.playlists.filter getting deprecation warnings? + class MusicDatabaseHandlerTest(protocol.BaseTestCase): + def test_count(self): self.send_request('count "artist" "needle"') self.assertInResponse('songs: 0') @@ -55,7 +61,7 @@ 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.send_request('count "title" "foo"') self.assertInResponse('songs: 1') @@ -104,31 +110,35 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): self.core.playlists.save(playlist) self.backend.library.dummy_search_result = SearchResult( tracks=[Track(uri='dummy:a', name='A')]) - playlists = self.core.playlists.filter(name='my favs').get() - self.assertEqual(len(playlists), 1) - self.assertEqual(len(playlists[0].tracks), 2) + + items = self.core.playlists.get_items(playlist.uri).get() + self.assertEqual(len(items), 2) self.send_request('searchaddpl "my favs" "title" "a"') - playlists = self.core.playlists.filter(name='my favs').get() - self.assertEqual(len(playlists), 1) - self.assertEqual(len(playlists[0].tracks), 3) - self.assertEqual(playlists[0].tracks[0].uri, 'dummy:x') - self.assertEqual(playlists[0].tracks[1].uri, 'dummy:y') - self.assertEqual(playlists[0].tracks[2].uri, 'dummy:a') + items = self.core.playlists.get_items(playlist.uri).get() + self.assertEqual(len(items), 3) + self.assertEqual(items[0].uri, 'dummy:x') + self.assertEqual(items[1].uri, 'dummy:y') + self.assertEqual(items[2].uri, 'dummy:a') self.assertInResponse('OK') def test_searchaddpl_creates_missing_playlist(self): self.backend.library.dummy_search_result = SearchResult( tracks=[Track(uri='dummy:a', name='A')]) - self.assertEqual( - len(self.core.playlists.filter(name='my favs').get()), 0) + + playlists = self.core.playlists.as_list().get() + self.assertNotIn('my favs', {p.name for p in playlists}) self.send_request('searchaddpl "my favs" "title" "a"') - playlists = self.core.playlists.filter(name='my favs').get() - self.assertEqual(len(playlists), 1) - self.assertEqual(playlists[0].tracks[0].uri, 'dummy:a') + playlists = self.core.playlists.as_list().get() + playlist = {p.name: p for p in playlists}['my favs'] + + items = self.core.playlists.get_items(playlist.uri).get() + + self.assertEqual(len(items), 1) + self.assertEqual(items[0].uri, 'dummy:a') self.assertInResponse('OK') def test_listall_without_uri(self): @@ -277,8 +287,8 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): 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.send_request('lsinfo') response2 = self.send_request('lsinfo "/"') @@ -286,8 +296,8 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): 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.send_request('lsinfo ""') response2 = self.send_request('lsinfo "/"') @@ -295,8 +305,8 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): 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.send_request('lsinfo "/"') self.assertInResponse('playlist: a') @@ -384,8 +394,8 @@ 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.send_request('lsinfo "/"') self.assertLess(response.index('directory: dummy'), @@ -422,6 +432,7 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): class MusicDatabaseFindTest(protocol.BaseTestCase): + def test_find_includes_fake_artist_and_album_tracks(self): self.backend.library.dummy_find_exact_result = SearchResult( albums=[Album(uri='dummy:album:a', name='A', date='2001')], @@ -612,12 +623,10 @@ class MusicDatabaseFindTest(protocol.BaseTestCase): 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')])]) + def test_list(self): + self.backend.library.dummy_get_distinct_result = { + 'artist': set(['A Artist'])} self.send_request('list "artist" "artist" "foo"') self.assertInResponse('Artist: A Artist') @@ -891,8 +900,8 @@ class MusicDatabaseListTest(protocol.BaseTestCase): 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.send_request('list "album" "anartist"') self.assertInResponse('Album: foo') @@ -1056,6 +1065,7 @@ class MusicDatabaseListTest(protocol.BaseTestCase): class MusicDatabaseSearchTest(protocol.BaseTestCase): + def test_search(self): self.backend.library.dummy_search_result = SearchResult( albums=[Album(uri='dummy:album:a', name='A')], diff --git a/tests/mpd/protocol/test_playback.py b/tests/mpd/protocol/test_playback.py index 1cd62bba..6121f540 100644 --- a/tests/mpd/protocol/test_playback.py +++ b/tests/mpd/protocol/test_playback.py @@ -4,6 +4,7 @@ import unittest from mopidy.core import PlaybackState from mopidy.models import Track +from mopidy.utils import deprecation from tests.mpd import protocol @@ -14,6 +15,7 @@ STOPPED = PlaybackState.STOPPED class PlaybackOptionsHandlerTest(protocol.BaseTestCase): + def test_consume_off(self): self.send_request('consume "0"') self.assertFalse(self.core.tracklist.consume.get()) @@ -80,37 +82,37 @@ class PlaybackOptionsHandlerTest(protocol.BaseTestCase): def test_setvol_below_min(self): self.send_request('setvol "-10"') - self.assertEqual(0, self.core.playback.volume.get()) + self.assertEqual(0, self.core.mixer.get_volume().get()) self.assertInResponse('OK') def test_setvol_min(self): self.send_request('setvol "0"') - self.assertEqual(0, self.core.playback.volume.get()) + self.assertEqual(0, self.core.mixer.get_volume().get()) self.assertInResponse('OK') def test_setvol_middle(self): self.send_request('setvol "50"') - self.assertEqual(50, self.core.playback.volume.get()) + self.assertEqual(50, self.core.mixer.get_volume().get()) self.assertInResponse('OK') def test_setvol_max(self): self.send_request('setvol "100"') - self.assertEqual(100, self.core.playback.volume.get()) + self.assertEqual(100, self.core.mixer.get_volume().get()) self.assertInResponse('OK') def test_setvol_above_max(self): self.send_request('setvol "110"') - self.assertEqual(100, self.core.playback.volume.get()) + self.assertEqual(100, self.core.mixer.get_volume().get()) self.assertInResponse('OK') def test_setvol_plus_is_ignored(self): self.send_request('setvol "+10"') - self.assertEqual(10, self.core.playback.volume.get()) + self.assertEqual(10, self.core.mixer.get_volume().get()) self.assertInResponse('OK') def test_setvol_without_quotes(self): self.send_request('setvol 50') - self.assertEqual(50, self.core.playback.volume.get()) + self.assertEqual(50, self.core.mixer.get_volume().get()) self.assertInResponse('OK') def test_single_off(self): @@ -150,6 +152,14 @@ class PlaybackOptionsHandlerTest(protocol.BaseTestCase): 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 @@ -164,13 +174,20 @@ class PlaybackOptionsHandlerTest(protocol.BaseTestCase): class PlaybackControlHandlerTest(protocol.BaseTestCase): + + def setUp(self): # noqa: N802 + super(PlaybackControlHandlerTest, self).setUp() + self.tracks = [Track(uri='dummy:a', length=40000), + Track(uri='dummy:b', length=40000)] + self.backend.library.dummy_library = self.tracks + self.core.tracklist.add(uris=[t.uri for t in self.tracks]).get() + def test_next(self): + self.core.tracklist.clear().get() self.send_request('next') self.assertInResponse('OK') def test_pause_off(self): - self.core.tracklist.add([Track(uri='dummy:a')]) - self.send_request('play "0"') self.send_request('pause "1"') self.send_request('pause "0"') @@ -178,59 +195,48 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_pause_on(self): - self.core.tracklist.add([Track(uri='dummy:a')]) - 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.send_request('play "0"') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse('OK') - self.send_request('pause') - self.assertEqual(PAUSED, self.core.playback.state.get()) - self.assertInResponse('OK') + with deprecation.ignore('mpd.protocol.playback.pause:state_arg'): + self.send_request('pause') + self.assertEqual(PAUSED, self.core.playback.state.get()) + self.assertInResponse('OK') - self.send_request('pause') - self.assertEqual(PLAYING, self.core.playback.state.get()) - self.assertInResponse('OK') + 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.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.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.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.core.tracklist.clear().get() self.send_request('play "0"') self.assertEqual(STOPPED, self.core.playback.state.get()) self.assertInResponse('ACK [2@0] {play} Bad song index') def test_play_minus_one_plays_first_in_playlist_if_no_current_track(self): self.assertEqual(self.core.playback.current_track.get(), None) - self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) self.send_request('play "-1"') self.assertEqual(PLAYING, self.core.playback.state.get()) @@ -239,7 +245,6 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_play_minus_one_plays_current_track_if_current_track_is_set(self): - self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) self.assertEqual(self.core.playback.current_track.get(), None) self.core.playback.play() self.core.playback.next() @@ -261,11 +266,10 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_play_minus_is_ignored_if_playing(self): - self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) 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.send_request('play "-1"') self.assertEqual(PLAYING, self.core.playback.state.get()) @@ -274,13 +278,12 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_play_minus_one_resumes_if_paused(self): - self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) 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.send_request('play "-1"') self.assertEqual(PLAYING, self.core.playback.state.get()) @@ -289,22 +292,17 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_playid(self): - self.core.tracklist.add([Track(uri='dummy:a')]) - 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.send_request('playid 0') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse('OK') def test_playid_minus_1_plays_first_in_playlist_if_no_current_track(self): self.assertEqual(self.core.playback.current_track.get(), None) - self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) self.send_request('playid "-1"') self.assertEqual(PLAYING, self.core.playback.state.get()) @@ -313,7 +311,6 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_playid_minus_1_plays_current_track_if_current_track_is_set(self): - self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) self.assertEqual(self.core.playback.current_track.get(), None) self.core.playback.play() self.core.playback.next() @@ -335,11 +332,10 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_playid_minus_is_ignored_if_playing(self): - self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) 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.send_request('playid "-1"') self.assertEqual(PLAYING, self.core.playback.state.get()) @@ -348,13 +344,12 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_playid_minus_one_resumes_if_paused(self): - self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) 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.send_request('playid "-1"') self.assertEqual(PLAYING, self.core.playback.state.get()) @@ -363,40 +358,36 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_playid_which_does_not_exist(self): - self.core.tracklist.add([Track(uri='dummy:a')]) - self.send_request('playid "12345"') self.assertInResponse('ACK [50@0] {playid} No such song') def test_previous(self): + self.core.tracklist.clear().get() self.send_request('previous') self.assertInResponse('OK') def test_seek_in_current_track(self): - seek_track = Track(uri='dummy:a', length=40000) - self.core.tracklist.add([seek_track]) self.core.playback.play() self.send_request('seek "0" "30"') - self.assertEqual(self.core.playback.current_track.get(), seek_track) + current_track = self.core.playback.current_track.get() + self.assertEqual(current_track, self.tracks[0]) self.assertGreaterEqual(self.core.playback.time_position, 30000) self.assertInResponse('OK') def test_seek_in_another_track(self): - seek_track = Track(uri='dummy:b', length=40000) - self.core.tracklist.add( - [Track(uri='dummy:a', length=40000), seek_track]) self.core.playback.play() - self.assertNotEqual(self.core.playback.current_track.get(), seek_track) + current_track = self.core.playback.current_track.get() + self.assertNotEqual(current_track, self.tracks[1]) self.send_request('seek "1" "30"') - self.assertEqual(self.core.playback.current_track.get(), seek_track) + current_track = self.core.playback.current_track.get() + self.assertEqual(current_track, self.tracks[1]) self.assertInResponse('OK') def test_seek_without_quotes(self): - self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) self.core.playback.play() self.send_request('seek 0 30') @@ -405,31 +396,27 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_seekid_in_current_track(self): - seek_track = Track(uri='dummy:a', length=40000) - self.core.tracklist.add([seek_track]) self.core.playback.play() self.send_request('seekid "0" "30"') - self.assertEqual(self.core.playback.current_track.get(), seek_track) + current_track = self.core.playback.current_track.get() + self.assertEqual(current_track, self.tracks[0]) self.assertGreaterEqual( self.core.playback.time_position.get(), 30000) self.assertInResponse('OK') def test_seekid_in_another_track(self): - seek_track = Track(uri='dummy:b', length=40000) - self.core.tracklist.add( - [Track(uri='dummy:a', length=40000), seek_track]) self.core.playback.play() 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()) + current_tl_track = self.core.playback.current_tl_track.get() + self.assertEqual(current_tl_track.tlid, 1) + self.assertEqual(current_tl_track.track, self.tracks[1]) self.assertInResponse('OK') def test_seekcur_absolute_value(self): - self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) self.core.playback.play() self.send_request('seekcur "30"') @@ -438,7 +425,6 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_seekcur_positive_diff(self): - self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) self.core.playback.play() self.core.playback.seek(10000) self.assertGreaterEqual(self.core.playback.time_position.get(), 10000) @@ -449,7 +435,6 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_seekcur_negative_diff(self): - self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) self.core.playback.play() self.core.playback.seek(30000) self.assertGreaterEqual(self.core.playback.time_position.get(), 30000) @@ -460,6 +445,15 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_stop(self): + self.core.tracklist.clear().get() 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 5c44c464..4641a8f4 100644 --- a/tests/mpd/protocol/test_reflection.py +++ b/tests/mpd/protocol/test_reflection.py @@ -4,6 +4,7 @@ from tests.mpd import protocol class ReflectionHandlerTest(protocol.BaseTestCase): + def test_config_is_not_allowed_across_the_network(self): self.send_request('config') self.assertEqualResponse( @@ -49,6 +50,7 @@ class ReflectionHandlerTest(protocol.BaseTestCase): class ReflectionWhenNotAuthedTest(protocol.BaseTestCase): + def get_config(self): config = super(ReflectionWhenNotAuthedTest, self).get_config() config['mpd']['password'] = 'topsecret' diff --git a/tests/mpd/protocol/test_regression.py b/tests/mpd/protocol/test_regression.py index 09ec8a46..7591d55c 100644 --- a/tests/mpd/protocol/test_regression.py +++ b/tests/mpd/protocol/test_regression.py @@ -8,6 +8,7 @@ from tests.mpd import protocol class IssueGH17RegressionTest(protocol.BaseTestCase): + """ The issue: http://github.com/mopidy/mopidy/issues/17 @@ -17,37 +18,42 @@ class IssueGH17RegressionTest(protocol.BaseTestCase): - Turn on random mode - Press next until you get to the unplayable track """ + def test(self): - self.core.tracklist.add([ + tracks = [ Track(uri='dummy:a'), Track(uri='dummy:b'), Track(uri='dummy:error'), Track(uri='dummy:d'), Track(uri='dummy:e'), Track(uri='dummy:f'), - ]) + ] + self.backend.library.dummy_library = tracks + self.core.tracklist.add(uris=[t.uri for t in tracks]).get() + random.seed(1) # Playlist order: abcfde self.send_request('play') - self.assertEquals( + self.assertEqual( 'dummy:a', self.core.playback.current_track.get().uri) self.send_request('random "1"') self.send_request('next') - self.assertEquals( + self.assertEqual( 'dummy:b', self.core.playback.current_track.get().uri) 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.send_request('next') - self.assertEquals( + self.assertEqual( 'dummy:d', self.core.playback.current_track.get().uri) self.send_request('next') - self.assertEquals( + self.assertEqual( 'dummy:e', self.core.playback.current_track.get().uri) class IssueGH18RegressionTest(protocol.BaseTestCase): + """ The issue: http://github.com/mopidy/mopidy/issues/18 @@ -59,9 +65,13 @@ class IssueGH18RegressionTest(protocol.BaseTestCase): """ def test(self): - self.core.tracklist.add([ + tracks = [ Track(uri='dummy:a'), Track(uri='dummy:b'), Track(uri='dummy:c'), - Track(uri='dummy:d'), Track(uri='dummy:e'), Track(uri='dummy:f')]) + Track(uri='dummy:d'), Track(uri='dummy:e'), Track(uri='dummy:f'), + ] + self.backend.library.dummy_library = tracks + self.core.tracklist.add(uris=[t.uri for t in tracks]).get() + random.seed(1) self.send_request('play') @@ -82,6 +92,7 @@ class IssueGH18RegressionTest(protocol.BaseTestCase): class IssueGH22RegressionTest(protocol.BaseTestCase): + """ The issue: http://github.com/mopidy/mopidy/issues/22 @@ -95,9 +106,13 @@ class IssueGH22RegressionTest(protocol.BaseTestCase): """ def test(self): - self.core.tracklist.add([ + tracks = [ Track(uri='dummy:a'), Track(uri='dummy:b'), Track(uri='dummy:c'), - Track(uri='dummy:d'), Track(uri='dummy:e'), Track(uri='dummy:f')]) + Track(uri='dummy:d'), Track(uri='dummy:e'), Track(uri='dummy:f'), + ] + self.backend.library.dummy_library = tracks + self.core.tracklist.add(uris=[t.uri for t in tracks]).get() + random.seed(1) self.send_request('play') @@ -112,6 +127,7 @@ class IssueGH22RegressionTest(protocol.BaseTestCase): class IssueGH69RegressionTest(protocol.BaseTestCase): + """ The issue: https://github.com/mopidy/mopidy/issues/69 @@ -124,9 +140,13 @@ class IssueGH69RegressionTest(protocol.BaseTestCase): def test(self): self.core.playlists.create('foo') - self.core.tracklist.add([ + + tracks = [ Track(uri='dummy:a'), Track(uri='dummy:b'), Track(uri='dummy:c'), - Track(uri='dummy:d'), Track(uri='dummy:e'), Track(uri='dummy:f')]) + Track(uri='dummy:d'), Track(uri='dummy:e'), Track(uri='dummy:f'), + ] + self.backend.library.dummy_library = tracks + self.core.tracklist.add(uris=[t.uri for t in tracks]).get() self.send_request('play') self.send_request('stop') @@ -136,6 +156,7 @@ class IssueGH69RegressionTest(protocol.BaseTestCase): class IssueGH113RegressionTest(protocol.BaseTestCase): + """ The issue: https://github.com/mopidy/mopidy/issues/113 @@ -161,6 +182,7 @@ class IssueGH113RegressionTest(protocol.BaseTestCase): class IssueGH137RegressionTest(protocol.BaseTestCase): + """ The issue: https://github.com/mopidy/mopidy/issues/137 diff --git a/tests/mpd/protocol/test_status.py b/tests/mpd/protocol/test_status.py index 09df3526..ea4137de 100644 --- a/tests/mpd/protocol/test_status.py +++ b/tests/mpd/protocol/test_status.py @@ -6,16 +6,19 @@ from tests.mpd import protocol class StatusHandlerTest(protocol.BaseTestCase): + def test_clearerror(self): self.send_request('clearerror') self.assertEqualResponse('ACK [0@0] {clearerror} Not implemented') def test_currentsong(self): - track = Track() - self.core.tracklist.add([track]) + track = Track(uri='dummy:/a') + self.backend.library.dummy_library = [track] + self.core.tracklist.add(uris=[track.uri]).get() + self.core.playback.play() self.send_request('currentsong') - self.assertInResponse('file: ') + self.assertInResponse('file: dummy:/a') self.assertInResponse('Time: 0') self.assertInResponse('Artist: ') self.assertInResponse('Title: ') diff --git a/tests/mpd/protocol/test_stickers.py b/tests/mpd/protocol/test_stickers.py index 0844c461..57f941da 100644 --- a/tests/mpd/protocol/test_stickers.py +++ b/tests/mpd/protocol/test_stickers.py @@ -4,6 +4,7 @@ from tests.mpd import protocol class StickersHandlerTest(protocol.BaseTestCase): + def test_sticker_get(self): self.send_request( 'sticker get "song" "file:///dev/urandom" "a_name"') diff --git a/tests/mpd/protocol/test_stored_playlists.py b/tests/mpd/protocol/test_stored_playlists.py index a9190aa1..90b25a70 100644 --- a/tests/mpd/protocol/test_stored_playlists.py +++ b/tests/mpd/protocol/test_stored_playlists.py @@ -6,19 +6,20 @@ 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.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.send_request('listplaylist name') self.assertInResponse('file: dummy:a') @@ -31,16 +32,16 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): 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.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.send_request('listplaylistinfo "name"') self.assertInResponse('file: dummy:a') @@ -49,9 +50,9 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): 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.send_request('listplaylistinfo name') self.assertInResponse('file: dummy:a') @@ -67,7 +68,7 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): 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.send_request('listplaylistinfo "a [2]"') self.assertInResponse('file: c') @@ -77,8 +78,8 @@ 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.send_request('listplaylists') self.assertInResponse('playlist: a') @@ -89,7 +90,7 @@ 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.send_request('listplaylists') self.assertInResponse('playlist: a') @@ -98,86 +99,110 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): 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.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.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.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_pipe(self): - self.backend.playlists.playlists = [ - Playlist(name='a/b', uri='dummy:')] + 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')]) + tracks = [ + Track(uri='dummy:a'), + Track(uri='dummy:b'), + Track(uri='dummy:c'), + Track(uri='dummy:d'), + Track(uri='dummy:e'), + ] + self.backend.library.dummy_library = tracks + self.core.tracklist.add(uris=['dummy:a', 'dummy:b']).get() + self.assertEqual(len(self.core.tracklist.tracks.get()), 2) - self.backend.playlists.playlists = [ - Playlist(name='A-list', uri='dummy:A-list', tracks=[ - Track(uri='c'), Track(uri='d'), Track(uri='e')])] + self.backend.playlists.set_dummy_playlists([ + Playlist(name='A-list', uri='dummy:A-list', tracks=tracks[2:])]) self.send_request('load "A-list"') tracks = self.core.tracklist.tracks.get() self.assertEqual(5, len(tracks)) - self.assertEqual('a', tracks[0].uri) - self.assertEqual('b', tracks[1].uri) - self.assertEqual('c', tracks[2].uri) - self.assertEqual('d', tracks[3].uri) - self.assertEqual('e', tracks[4].uri) + self.assertEqual('dummy:a', tracks[0].uri) + self.assertEqual('dummy:b', tracks[1].uri) + self.assertEqual('dummy:c', tracks[2].uri) + self.assertEqual('dummy:d', tracks[3].uri) + self.assertEqual('dummy:e', tracks[4].uri) self.assertInResponse('OK') def test_load_with_range_loads_part_of_playlist(self): - self.core.tracklist.add([Track(uri='a'), Track(uri='b')]) + tracks = [ + Track(uri='dummy:a'), + Track(uri='dummy:b'), + Track(uri='dummy:c'), + Track(uri='dummy:d'), + Track(uri='dummy:e'), + ] + self.backend.library.dummy_library = tracks + self.core.tracklist.add(uris=['dummy:a', 'dummy:b']).get() + self.assertEqual(len(self.core.tracklist.tracks.get()), 2) - self.backend.playlists.playlists = [ - Playlist(name='A-list', uri='dummy:A-list', tracks=[ - Track(uri='c'), Track(uri='d'), Track(uri='e')])] + self.backend.playlists.set_dummy_playlists([ + Playlist(name='A-list', uri='dummy:A-list', tracks=tracks[2:])]) self.send_request('load "A-list" "1:2"') tracks = self.core.tracklist.tracks.get() self.assertEqual(3, len(tracks)) - self.assertEqual('a', tracks[0].uri) - self.assertEqual('b', tracks[1].uri) - self.assertEqual('d', tracks[2].uri) + self.assertEqual('dummy:a', tracks[0].uri) + self.assertEqual('dummy:b', tracks[1].uri) + self.assertEqual('dummy:d', tracks[2].uri) self.assertInResponse('OK') def test_load_with_range_without_end_loads_rest_of_playlist(self): - self.core.tracklist.add([Track(uri='a'), Track(uri='b')]) + tracks = [ + Track(uri='dummy:a'), + Track(uri='dummy:b'), + Track(uri='dummy:c'), + Track(uri='dummy:d'), + Track(uri='dummy:e'), + ] + self.backend.library.dummy_library = tracks + self.core.tracklist.add(uris=['dummy:a', 'dummy:b']).get() + self.assertEqual(len(self.core.tracklist.tracks.get()), 2) - self.backend.playlists.playlists = [ - Playlist(name='A-list', uri='dummy:A-list', tracks=[ - Track(uri='c'), Track(uri='d'), Track(uri='e')])] + self.backend.playlists.set_dummy_playlists([ + Playlist(name='A-list', uri='dummy:A-list', tracks=tracks[2:])]) self.send_request('load "A-list" "1:"') tracks = self.core.tracklist.tracks.get() self.assertEqual(4, len(tracks)) - self.assertEqual('a', tracks[0].uri) - self.assertEqual('b', tracks[1].uri) - self.assertEqual('d', tracks[2].uri) - self.assertEqual('e', tracks[3].uri) + self.assertEqual('dummy:a', tracks[0].uri) + self.assertEqual('dummy:b', tracks[1].uri) + self.assertEqual('dummy:d', tracks[2].uri) + self.assertEqual('dummy:e', tracks[3].uri) self.assertInResponse('OK') def test_load_unknown_playlist_acks(self): diff --git a/tests/mpd/test_commands.py b/tests/mpd/test_commands.py index e0903e9f..0a8daf30 100644 --- a/tests/mpd/test_commands.py +++ b/tests/mpd/test_commands.py @@ -8,6 +8,7 @@ from mopidy.mpd import exceptions, protocol class TestConverts(unittest.TestCase): + def test_integer(self): self.assertEqual(123, protocol.INT('123')) self.assertEqual(-123, protocol.INT('-123')) @@ -55,6 +56,7 @@ class TestConverts(unittest.TestCase): class TestCommands(unittest.TestCase): + def setUp(self): # noqa: N802 self.commands = protocol.Commands() @@ -64,7 +66,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 +91,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 +117,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 +168,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 +194,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 +224,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 +263,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 1a230451..be2bf608 100644 --- a/tests/mpd/test_dispatcher.py +++ b/tests/mpd/test_dispatcher.py @@ -5,34 +5,44 @@ 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 mopidy.utils import deprecation + +from tests import dummy_backend class MpdDispatcherTest(unittest.TestCase): + def setUp(self): # noqa: N802 config = { 'mpd': { 'password': None, + 'command_blacklist': ['disabled'], } } - self.backend = dummy.create_dummy_backend_proxy() - self.core = core.Core.start(backends=[self.backend]).proxy() + self.backend = dummy_backend.create_proxy() self.dispatcher = MpdDispatcher(config=config) + with deprecation.ignore(): + self.core = core.Core.start(backends=[self.backend]).proxy() + 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 d055ef7e..e3759e4e 100644 --- a/tests/mpd/test_exceptions.py +++ b/tests/mpd/test_exceptions.py @@ -3,19 +3,11 @@ 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): - def test_key_error_wrapped_in_mpd_ack_error(self): - try: - try: - raise KeyError('Track X not found') - except KeyError as e: - raise MpdAckError(e.message) - except MpdAckError as e: - self.assertEqual(e.message, 'Track X not found') def test_mpd_not_implemented_is_a_mpd_ack_error(self): try: @@ -61,3 +53,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 1015615c..6f134df5 100644 --- a/tests/mpd/test_status.py +++ b/tests/mpd/test_status.py @@ -5,11 +5,14 @@ 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 mopidy.utils import deprecation + +from tests import dummy_backend, dummy_mixer + PAUSED = PlaybackState.PAUSED PLAYING = PlaybackState.PLAYING @@ -20,15 +23,25 @@ STOPPED = PlaybackState.STOPPED class StatusHandlerTest(unittest.TestCase): + def setUp(self): # noqa: N802 - self.backend = dummy.create_dummy_backend_proxy() - self.core = core.Core.start(backends=[self.backend]).proxy() + self.mixer = dummy_mixer.create_proxy() + self.backend = dummy_backend.create_proxy() + + with deprecation.ignore(): + 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): # noqa: N802 pykka.ActorRegistry.stop_all() + def set_tracklist(self, track): + self.backend.library.dummy_library = [track] + self.core.tracklist.add(uris=[track.uri]).get() + def test_stats_method(self): result = status.stats(self.context) self.assertIn('artists', result) @@ -52,7 +65,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) @@ -131,21 +144,22 @@ class StatusHandlerTest(unittest.TestCase): self.assertEqual(result['state'], 'pause') def test_status_method_when_playlist_loaded_contains_song(self): - self.core.tracklist.add([Track(uri='dummy:a')]) + self.set_tracklist(Track(uri='dummy:/a')) + self.core.playback.play() result = dict(status.status(self.context)) self.assertIn('song', result) self.assertGreaterEqual(int(result['song']), 0) def test_status_method_when_playlist_loaded_contains_tlid_as_songid(self): - self.core.tracklist.add([Track(uri='dummy:a')]) + self.set_tracklist(Track(uri='dummy:/a')) self.core.playback.play() result = dict(status.status(self.context)) self.assertIn('songid', result) self.assertEqual(int(result['songid']), 0) def test_status_method_when_playing_contains_time_with_no_length(self): - self.core.tracklist.add([Track(uri='dummy:a', length=None)]) + self.set_tracklist(Track(uri='dummy:/a', length=None)) self.core.playback.play() result = dict(status.status(self.context)) self.assertIn('time', result) @@ -155,7 +169,7 @@ class StatusHandlerTest(unittest.TestCase): self.assertLessEqual(position, total) def test_status_method_when_playing_contains_time_with_length(self): - self.core.tracklist.add([Track(uri='dummy:a', length=10000)]) + self.set_tracklist(Track(uri='dummy:/a', length=10000)) self.core.playback.play() result = dict(status.status(self.context)) self.assertIn('time', result) @@ -165,7 +179,7 @@ class StatusHandlerTest(unittest.TestCase): self.assertLessEqual(position, total) def test_status_method_when_playing_contains_elapsed(self): - self.core.tracklist.add([Track(uri='dummy:a', length=60000)]) + self.set_tracklist(Track(uri='dummy:/a', length=60000)) self.core.playback.play() self.core.playback.pause() self.core.playback.seek(59123) @@ -174,7 +188,7 @@ class StatusHandlerTest(unittest.TestCase): self.assertEqual(result['elapsed'], '59.123') def test_status_method_when_starting_playing_contains_elapsed_zero(self): - self.core.tracklist.add([Track(uri='dummy:a', length=10000)]) + self.set_tracklist(Track(uri='dummy:/a', length=10000)) self.core.playback.play() self.core.playback.pause() result = dict(status.status(self.context)) @@ -182,8 +196,8 @@ class StatusHandlerTest(unittest.TestCase): self.assertEqual(result['elapsed'], '0.000') def test_status_method_when_playing_contains_bitrate(self): - self.core.tracklist.add([Track(uri='dummy:a', bitrate=320)]) + self.set_tracklist(Track(uri='dummy:/a', bitrate=3200)) self.core.playback.play() result = dict(status.status(self.context)) self.assertIn('bitrate', result) - self.assertEqual(int(result['bitrate']), 320) + self.assertEqual(int(result['bitrate']), 3200) diff --git a/tests/mpd/test_tokenizer.py b/tests/mpd/test_tokenizer.py index b4d46719..2e3a6558 100644 --- a/tests/mpd/test_tokenizer.py +++ b/tests/mpd/test_tokenizer.py @@ -8,6 +8,7 @@ from mopidy.mpd import exceptions, tokenize class TestTokenizer(unittest.TestCase): + def assertTokenizeEquals(self, expected, line): # noqa: N802 self.assertEqual(expected, tokenize.split(line)) diff --git a/tests/mpd/test_translator.py b/tests/mpd/test_translator.py index 027ce28f..bf50687d 100644 --- a/tests/mpd/test_translator.py +++ b/tests/mpd/test_translator.py @@ -34,6 +34,8 @@ class TrackMpdFormatTest(unittest.TestCase): 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) @@ -114,6 +116,7 @@ class TrackMpdFormatTest(unittest.TestCase): class PlaylistMpdFormatTest(unittest.TestCase): + def test_mpd_format(self): playlist = Playlist(tracks=[ Track(track_no=1), Track(track_no=2), Track(track_no=3)]) diff --git a/tests/stream/test_library.py b/tests/stream/test_library.py index 7ed871cb..462136e4 100644 --- a/tests/stream/test_library.py +++ b/tests/stream/test_library.py @@ -19,25 +19,26 @@ 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, []) + library = actor.StreamLibraryProvider(self.backend, 1000, [], {}) self.assertFalse(library.lookup('http://example.com')) def test_lookup_respects_blacklist(self): - library = actor.StreamLibraryProvider(self.backend, 100, [self.uri]) + 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) + 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, []) + 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 0942b3a0..e16a660c 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -9,6 +9,7 @@ from mopidy import commands class ConfigOverrideTypeTest(unittest.TestCase): + def test_valid_override(self): expected = (b'section', b'key', b'value') self.assertEqual( @@ -44,6 +45,7 @@ class ConfigOverrideTypeTest(unittest.TestCase): class CommandParsingTest(unittest.TestCase): + def setUp(self): # noqa: N802 self.exit_patcher = mock.patch.object(commands.Command, 'exit') self.exit_mock = self.exit_patcher.start() @@ -258,6 +260,7 @@ class CommandParsingTest(unittest.TestCase): class UsageTest(unittest.TestCase): + @mock.patch('sys.argv') def test_prog_name_default_and_override(self, argv_mock): argv_mock.__getitem__.return_value = '/usr/bin/foo' @@ -294,6 +297,7 @@ class UsageTest(unittest.TestCase): class HelpTest(unittest.TestCase): + @mock.patch('sys.argv') def test_prog_name_default_and_override(self, argv_mock): argv_mock.__getitem__.return_value = '/usr/bin/foo' @@ -485,6 +489,7 @@ class HelpTest(unittest.TestCase): class RunTest(unittest.TestCase): + def test_default_implmentation_raises_error(self): with self.assertRaises(NotImplementedError): commands.Command().run() diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index 3420891e..d684d8f5 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -6,6 +6,7 @@ from mopidy import exceptions class ExceptionsTest(unittest.TestCase): + def test_exception_can_include_message_string(self): exc = exceptions.MopidyException('foo') diff --git a/tests/test_ext.py b/tests/test_ext.py index f4e247b6..c58f6b20 100644 --- a/tests/test_ext.py +++ b/tests/test_ext.py @@ -6,6 +6,7 @@ from mopidy import config, ext class ExtensionTest(unittest.TestCase): + def setUp(self): # noqa: N802 self.ext = ext.Extension() diff --git a/tests/test_help.py b/tests/test_help.py index d8058cb7..6dbf1da9 100644 --- a/tests/test_help.py +++ b/tests/test_help.py @@ -9,6 +9,7 @@ import mopidy class HelpTest(unittest.TestCase): + def test_help_has_mopidy_options(self): mopidy_dir = os.path.dirname(mopidy.__file__) args = [sys.executable, mopidy_dir, '--help'] diff --git a/tests/test_mixer.py b/tests/test_mixer.py index c57d861a..b9e05650 100644 --- a/tests/test_mixer.py +++ b/tests/test_mixer.py @@ -8,6 +8,7 @@ from mopidy import mixer class MixerListenerTest(unittest.TestCase): + def setUp(self): # noqa: N802 self.listener = mixer.MixerListener() diff --git a/tests/test_models.py b/tests/test_models.py index ed1586da..e9a8f439 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -4,11 +4,12 @@ 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): + def compare(self, orig, other): self.assertEqual(orig, other) self.assertNotEqual(id(orig), id(other)) @@ -54,10 +55,11 @@ class GenericCopyTest(unittest.TestCase): 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): + def test_uri(self): uri = 'an_uri' ref = Ref(uri=uri) @@ -74,10 +76,10 @@ class RefTest(unittest.TestCase): def test_invalid_kwarg(self): with self.assertRaises(TypeError): - SearchResult(foo='baz') + 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'))) @@ -130,7 +132,34 @@ 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) @@ -164,7 +193,7 @@ class ArtistTest(unittest.TestCase): Artist(serialize='baz') def test_repr(self): - self.assertEquals( + self.assertEqual( "Artist(name=u'name', uri=u'uri')", repr(Artist(uri='uri', name='name'))) @@ -261,6 +290,7 @@ class ArtistTest(unittest.TestCase): class AlbumTest(unittest.TestCase): + def test_uri(self): uri = 'an_uri' album = Album(uri=uri) @@ -328,12 +358,12 @@ class AlbumTest(unittest.TestCase): 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')]))) @@ -473,6 +503,7 @@ class AlbumTest(unittest.TestCase): class TrackTest(unittest.TestCase): + def test_uri(self): uri = 'an_uri' track = Track(uri=uri) @@ -571,12 +602,12 @@ class TrackTest(unittest.TestCase): 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')]))) @@ -771,6 +802,7 @@ class TrackTest(unittest.TestCase): class TlTrackTest(unittest.TestCase): + def test_tlid(self): tlid = 123 tl_track = TlTrack(tlid=tlid) @@ -805,7 +837,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')))) @@ -849,6 +881,7 @@ class TlTrackTest(unittest.TestCase): class PlaylistTest(unittest.TestCase): + def test_uri(self): uri = 'an_uri' playlist = Playlist(uri=uri) @@ -937,12 +970,12 @@ class PlaylistTest(unittest.TestCase): 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')]))) @@ -1040,6 +1073,7 @@ class PlaylistTest(unittest.TestCase): class SearchResultTest(unittest.TestCase): + def test_uri(self): uri = 'an_uri' result = SearchResult(uri=uri) @@ -1073,7 +1107,7 @@ class SearchResultTest(unittest.TestCase): 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 8c3f9404..de4f8d4f 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -7,6 +7,7 @@ from mopidy import __version__ class VersionTest(unittest.TestCase): + def assertVersionLess(self, first, second): # noqa: N802 self.assertLess(StrictVersion(first), StrictVersion(second)) @@ -54,5 +55,6 @@ class VersionTest(unittest.TestCase): 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', __version__) - self.assertVersionLess(__version__, '0.19.6') + 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/network/test_connection.py b/tests/utils/network/test_connection.py index 0ccaea0a..3ad1df6b 100644 --- a/tests/utils/network/test_connection.py +++ b/tests/utils/network/test_connection.py @@ -17,6 +17,7 @@ from tests import any_int, any_unicode class ConnectionTest(unittest.TestCase): + def setUp(self): # noqa: N802 self.mock = Mock(spec=network.Connection) diff --git a/tests/utils/network/test_lineprotocol.py b/tests/utils/network/test_lineprotocol.py index 1b584e47..d3548117 100644 --- a/tests/utils/network/test_lineprotocol.py +++ b/tests/utils/network/test_lineprotocol.py @@ -14,6 +14,7 @@ from tests import any_unicode class LineProtocolTest(unittest.TestCase): + def setUp(self): # noqa: N802 self.mock = Mock(spec=network.LineProtocol) diff --git a/tests/utils/network/test_server.py b/tests/utils/network/test_server.py index d85d6c27..5ea64fca 100644 --- a/tests/utils/network/test_server.py +++ b/tests/utils/network/test_server.py @@ -14,6 +14,7 @@ from tests import any_int class ServerTest(unittest.TestCase): + def setUp(self): # noqa: N802 self.mock = Mock(spec=network.Server) diff --git a/tests/utils/network/test_utils.py b/tests/utils/network/test_utils.py index d5f558b4..55d68a99 100644 --- a/tests/utils/network/test_utils.py +++ b/tests/utils/network/test_utils.py @@ -9,6 +9,7 @@ from mopidy.utils import network class FormatHostnameTest(unittest.TestCase): + @patch('mopidy.utils.network.has_ipv6', True) def test_format_hostname_prefixes_ipv4_addresses_when_ipv6_available(self): network.has_ipv6 = True @@ -22,6 +23,7 @@ class FormatHostnameTest(unittest.TestCase): class TryIPv6SocketTest(unittest.TestCase): + @patch('socket.has_ipv6', False) def test_system_that_claims_no_ipv6_support(self): self.assertFalse(network.try_ipv6_socket()) @@ -40,6 +42,7 @@ class TryIPv6SocketTest(unittest.TestCase): class CreateSocketTest(unittest.TestCase): + @patch('mopidy.utils.network.has_ipv6', False) @patch('socket.socket') def test_ipv4_socket(self, socket_mock): diff --git a/tests/utils/test_deps.py b/tests/utils/test_deps.py index 3144fe30..0639d296 100644 --- a/tests/utils/test_deps.py +++ b/tests/utils/test_deps.py @@ -1,6 +1,7 @@ from __future__ import absolute_import, unicode_literals import platform +import sys import unittest import mock @@ -15,6 +16,7 @@ from mopidy.utils import deps class DepsTest(unittest.TestCase): + def test_format_dependency_list(self): adapters = [ lambda: dict(name='Python', version='FooPython 2.7.3'), @@ -46,16 +48,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 +72,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 +107,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 +125,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 +135,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 68634855..2ec7e529 100644 --- a/tests/utils/test_encoding.py +++ b/tests/utils/test_encoding.py @@ -9,6 +9,7 @@ from mopidy.utils.encoding import locale_decode @mock.patch('mopidy.utils.encoding.locale.getpreferredencoding') class LocaleDecodeTest(unittest.TestCase): + def test_can_decode_utf8_strings_with_french_content(self, mock): mock.return_value = 'UTF-8' diff --git a/tests/utils/test_jsonrpc.py b/tests/utils/test_jsonrpc.py index a74000b2..160afc4d 100644 --- a/tests/utils/test_jsonrpc.py +++ b/tests/utils/test_jsonrpc.py @@ -8,11 +8,16 @@ import mock import pykka from mopidy import core, models -from mopidy.backend import dummy -from mopidy.utils import jsonrpc +from mopidy.utils import deprecation, jsonrpc + +from tests import dummy_backend class Calculator(object): + + def __init__(self): + self._mem = None + def model(self): return 'TI83' @@ -23,6 +28,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 +51,18 @@ class Calculator(object): class JsonRpcTestBase(unittest.TestCase): + def setUp(self): # noqa: N802 - self.backend = dummy.create_dummy_backend_proxy() - self.core = core.Core.start(backends=[self.backend]).proxy() + self.backend = dummy_backend.create_proxy() + self.calc = Calculator() + + with deprecation.ignore(): + self.core = core.Core.start(backends=[self.backend]).proxy() 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, @@ -61,12 +76,14 @@ class JsonRpcTestBase(unittest.TestCase): class JsonRpcSetupTest(JsonRpcTestBase): + def test_empty_object_mounts_is_not_allowed(self): with self.assertRaises(AttributeError): jsonrpc.JsonRpcWrapper(objects={'': Calculator()}) class JsonRpcSerializationTest(JsonRpcTestBase): + def test_handle_json_converts_from_and_to_json(self): self.jrw.handle_data = mock.Mock() self.jrw.handle_data.return_value = {'foo': 'response'} @@ -132,6 +149,7 @@ class JsonRpcSerializationTest(JsonRpcTestBase): class JsonRpcSingleCommandTest(JsonRpcTestBase): + def test_call_method_on_root(self): request = { 'jsonrpc': '2.0', @@ -188,12 +206,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,29 +233,28 @@ 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): + def test_notification_does_not_return_a_result(self): request = { 'jsonrpc': '2.0', @@ -248,17 +265,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 = { @@ -272,6 +289,7 @@ class JsonRpcSingleNotificationTest(JsonRpcTestBase): class JsonRpcBatchTest(JsonRpcTestBase): + def test_batch_of_only_commands_returns_all(self): self.core.tracklist.set_random(True).get() @@ -320,6 +338,7 @@ class JsonRpcBatchTest(JsonRpcTestBase): class JsonRpcSingleCommandErrorTest(JsonRpcTestBase): + def test_application_error_response(self): request = { 'jsonrpc': '2.0', @@ -489,6 +508,7 @@ class JsonRpcSingleCommandErrorTest(JsonRpcTestBase): class JsonRpcBatchErrorTest(JsonRpcTestBase): + def test_empty_batch_list_causes_invalid_request_error(self): request = [] response = self.jrw.handle_data(request) @@ -526,7 +546,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 +567,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) @@ -555,6 +575,7 @@ class JsonRpcBatchErrorTest(JsonRpcTestBase): class JsonRpcInspectorTest(JsonRpcTestBase): + def test_empty_object_mounts_is_not_allowed(self): with self.assertRaises(AttributeError): jsonrpc.JsonRpcInspector(objects={'': Calculator}) @@ -614,29 +635,29 @@ class JsonRpcInspectorTest(JsonRpcTestBase): 'core.library': core.LibraryController, 'core.playback': core.PlaybackController, 'core.playlists': core.PlaylistsController, - 'core.tracklist': core.TracklistController, + 'core.tracklist': core.TracklistController, }) 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 6fd4f8d1..1acd7271 100644 --- a/tests/utils/test_path.py +++ b/tests/utils/test_path.py @@ -16,6 +16,7 @@ import tests class GetOrCreateDirTest(unittest.TestCase): + def setUp(self): # noqa: N802 self.parent = tempfile.mkdtemp() @@ -67,6 +68,7 @@ class GetOrCreateDirTest(unittest.TestCase): class GetOrCreateFileTest(unittest.TestCase): + def setUp(self): # noqa: N802 self.parent = tempfile.mkdtemp() @@ -135,6 +137,7 @@ class GetOrCreateFileTest(unittest.TestCase): class PathToFileURITest(unittest.TestCase): + def test_simple_path(self): result = path.path_to_uri('/etc/fstab') self.assertEqual(result, 'file:///etc/fstab') @@ -157,6 +160,7 @@ class PathToFileURITest(unittest.TestCase): class UriToPathTest(unittest.TestCase): + def test_simple_uri(self): result = path.uri_to_path('file:///etc/fstab') self.assertEqual(result, '/etc/fstab'.encode('utf-8')) @@ -175,6 +179,7 @@ class UriToPathTest(unittest.TestCase): class SplitPathTest(unittest.TestCase): + def test_empty_path(self): self.assertEqual([], path.split_path('')) @@ -378,6 +383,7 @@ class FindMTimesTest(unittest.TestCase): # TODO: kill this in favour of just os.path.getmtime + mocks class MtimeTest(unittest.TestCase): + def tearDown(self): # noqa: N802 path.mtime.undo_fake() diff --git a/tox.ini b/tox.ini index 277ae9d3..6dfab5ae 100644 --- a/tox.ini +++ b/tox.ini @@ -3,20 +3,27 @@ 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.1